/* App shell (rail + topbar + canvas). Token-based, docs/16 §2. No raw hex. */

/* The app's only global reset.
   `margin: 0` — without it the UA's 8px body margin sat outside a 100vh shell, so every page had a
   few pixels of whole-window scroll no matter what, which is exactly what the fixed-viewport layout
   below has to eliminate. `height: 100%` gives the shell a definite height to be 100% OF, rather
   than one that depends on its own content. */
html, body { height: 100%; margin: 0; }

/* `width: 100%` under the default content-box means 100% PLUS the padding, so any padded element
   told to fill its parent overflows it by exactly its padding. That is invisible wherever there is
   slack — the content column had 80px of it at 1920px — and appears the moment there is none, which
   is why the page was correct at 100% zoom and overflowed horizontally when zoomed in. Border-box is
   the baseline every other project starts from; this one had no reset at all. */
*, *::before, *::after { box-sizing: border-box; }

/* ---- Scrollbars -----------------------------------------------------------
   Thin and translucent, on the app's own scroll regions only. The platform default is a ~15px slab
   in an opaque track, which against the rail reads as a second border and against the canvas as a
   panel edge — it draws the eye to the container rather than to the data. 6px with a 14% thumb is
   about the floor for something that still reads as a scrollbar; the hover state carries the
   grabbing, so the resting state can afford to be this quiet.

   The colour is mixed from the text token rather than picked, so it lands correctly on a dark theme
   without a second rule, and stays translucent so whatever is beneath still shows through.

   Chrome and Safari are styled through the pseudo-elements; Firefox has no equivalents, so it gets
   the standard properties instead. They are scoped behind @supports on purpose: setting
   scrollbar-width/scrollbar-color to anything but `auto` makes Chrome ignore ::-webkit-scrollbar
   entirely, which would silently undo everything below. */
.nav-list::-webkit-scrollbar,
.content-canvas::-webkit-scrollbar,
.settings-card-rows::-webkit-scrollbar,
.table-viewport::-webkit-scrollbar,
.combo-panel::-webkit-scrollbar,
.select-list::-webkit-scrollbar,
.table-scroll::-webkit-scrollbar { width: 8px; height: 8px; }

.nav-list::-webkit-scrollbar-track,
.content-canvas::-webkit-scrollbar-track,
.settings-card-rows::-webkit-scrollbar-track,
.table-viewport::-webkit-scrollbar-track,
.combo-panel::-webkit-scrollbar-track,
.select-list::-webkit-scrollbar-track,
.table-scroll::-webkit-scrollbar-track { background: transparent; }

/* 8px of track carrying a 6px thumb. The transparent border plus padding-box clipping is what
   floats it: the thumb keeps a pixel of clearance on each side instead of running along the edge
   of its container.
   That clearance is why the rail's bar looked heavier than the canvas's even though both have
   always shared these rules — the rail's `border-right` hairline sits immediately beside the
   scrollbar, so a thumb touching that edge read as one thick double line, while the same thumb in
   the canvas had open space beside it. */
.nav-list::-webkit-scrollbar-thumb,
.content-canvas::-webkit-scrollbar-thumb,
.settings-card-rows::-webkit-scrollbar-thumb,
.table-viewport::-webkit-scrollbar-thumb,
.combo-panel::-webkit-scrollbar-thumb,
.select-list::-webkit-scrollbar-thumb,
.table-scroll::-webkit-scrollbar-thumb {
    background: color-mix(in srgb, var(--c-text) 14%, transparent);
    border-radius: 999px;
    border: 1px solid transparent;
    background-clip: padding-box;
}

/* Darkens under the pointer: faint enough to ignore while reading, solid enough to aim at. */
.nav-list::-webkit-scrollbar-thumb:hover,
.content-canvas::-webkit-scrollbar-thumb:hover,
.settings-card-rows::-webkit-scrollbar-thumb:hover,
.table-viewport::-webkit-scrollbar-thumb:hover,
.combo-panel::-webkit-scrollbar-thumb:hover,
.select-list::-webkit-scrollbar-thumb:hover,
.table-scroll::-webkit-scrollbar-thumb:hover {
    background: color-mix(in srgb, var(--c-text) 34%, transparent);
    background-clip: padding-box;   /* re-stated: shorthand-free rules do not inherit the clip */
}

/* Where the two bars meet on a surface that scrolls both ways — the canvas, and a wide table capped
   in a card. */
.content-canvas::-webkit-scrollbar-corner,
.table-scroll::-webkit-scrollbar-corner { background: transparent; }

@supports not selector(::-webkit-scrollbar) {
    .nav-list, .content-canvas, .settings-card-rows, .table-viewport, .table-scroll, .combo-panel, .select-list {
        scrollbar-width: thin;
        scrollbar-color: color-mix(in srgb, var(--c-text) 14%, transparent) transparent;
    }
}

/* One scrolling region: the content canvas.
   The shell is exactly the viewport and clips, the rail and the canvas each scroll inside it. So the
   topbar and the rail's logo stay put while a long page or a long menu moves under them, instead of
   the whole window scrolling and taking the header off screen.
   Deliberately NOT applied to the auth screens (.auth-shell), which are short, centred, and must
   still scroll normally on a laptop with a small viewport. */
.app-shell {
    display: grid;
    grid-template-columns: 240px 1fr;
    height: 100vh;
    overflow: hidden;
    background: var(--c-bg);
    color: var(--c-text);
    font-family: var(--font-body);
}

/* The rail and the topbar own two separate tokens rather than borrowing --c-surface and
   --c-surface-raised, so a theme can set the navigation and the header to different colours without
   dragging every card, table head and menu panel along with them (docs/16 §4).
   The var() fallback is what keeps a custom theme published before those tokens existed looking
   right: it has no --c-rail-bg, and tokens.css deliberately does not supply one at :root, so the
   rail lands on that theme's own --c-surface — exactly where it used to be. */
.rail {
    background: var(--c-rail-bg, var(--c-surface));
    border-right: 1px solid var(--c-border);
    display: flex;
    flex-direction: column;
    /*gap: var(--sp-4);
    padding: var(--sp-4) 0;*/
    /* A flex item's default min-height is its content, which would push a 28-item menu past the
       viewport and re-introduce the page scrollbar. Zero lets .nav-list take a bounded height and
       scroll within it. */
    min-height: 0;
}

/* The wordmark that stands in for a missing theme logo. It holds the same band of the rail the logo
   does, so the navigation below starts at one height either way.

   Retuned when the word became the vendor's name instead of "PRODAQ". 0.12em tracking is an all-caps
   device: it spaces six capitals into a mark, and applied to a mixed-case multi-word name it just
   reads as loose. The name is also three times as long — 240px of rail less padding leaves ~208px,
   which one line of it does not reliably fit at every font pair — so this wraps on its word breaks
   with tight leading rather than being squeezed or clipped. */
.rail-brand {
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 1.0625rem;
    line-height: 1.25;
    letter-spacing: 0.01em;
    padding: var(--sp-4);
    color: var(--c-primary);
    /* Long single tokens (a future name with no spaces) break rather than widening the rail. */
    overflow-wrap: anywhere;
}

/* Theme logo in the rail (docs/16 §9.1.2). Height-capped so an oversized upload cannot push the
   navigation down the page; width is free so wide wordmarks are not squashed. */
.rail-logo { display: block; /*max-height: 40px;*/ max-width: calc(100% - var(--sp-8)); margin: 0 var(--sp-4); object-fit: contain; }

/* The menu is the rail's only flexible part, so it absorbs whatever height the logo leaves and
   scrolls when the items do not fit — an admin holding every permission sees 28 of them, which
   overflows a 768px-tall screen. The brand above it stays fixed. */
.nav-list {
    list-style: none; margin: 0; padding: 0;
    display: flex; flex-direction: column;
    /* Breathing room between entries. `gap`, not extra padding on the rows: padding would grow each
       row's height and its hit area, which is already at the 40px §8 target — this separates the rows
       without touching them. It also gives the hover and active tints a visible edge, so a highlighted
       row reads as one row rather than as a band bleeding into its neighbours.
       Costs vertical space in a list that already scrolls (28 entries for an admin holding every
       permission), which is why it is --sp-1 and not more: ~27 gaps, ~108px, absorbed by the scroll
       this container was built to do. */
    gap: var(--sp-1);
    /* Side inset, so the rows below can be rounded and read as separate pills rather than as full-
       bleed bands butted against the rail's edges. The rounding is what the inset is FOR: a rounded
       corner touching the container edge reads as a rendering fault, not as a shape. */
    padding: var(--sp-2);
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
}

/* ---- The pinned foot ------------------------------------------------------
   Profile, settings, themes, notifications: the four a user reaches from
   anywhere rather than as a step in a task. `flex: none` and no scroll of its
   own, so it holds the bottom of the rail while the body above scrolls past it.

   NO RULE ABOVE IT. A hairline sat here briefly and was removed: the rail carries
   exactly one border, its own right edge, and a second one inside it made the two
   lists read as two panels. Whitespace does the separating instead — the body
   list's bottom padding and this one's top padding meet, so on a menu short enough
   to fit there is a clear band between the last task item and the first of these.

   The case that band does not cover is a full permission set, where the body
   scrolls and the two lists end up adjacent. That is a real cost and it is the
   reason the rule existed; it is accepted deliberately rather than overlooked.
   If it needs solving later, solve it with a scroll shadow on `.nav-list` —
   which appears only when there is something scrolled out of view — rather than
   with a rule that is always drawn whether or not it is separating anything. */
.nav-foot {
    flex: none;
    overflow: visible;
}

.nav-item {
    display: flex;
    align-items: center;
    gap: var(--sp-3);
    /* §8 puts the floor for a plant-floor touch target at 40px; this lands at 40 with the icon. */
    padding: var(--sp-2) var(--sp-3);
    border-radius: var(--radius-control);
    color: var(--c-text);
    text-decoration: none;
    font-size: 0.875rem;
}

/* Muted until the row is active or hovered: 28 icons at full text colour reads as noise, and the
   nav's job is to make the CURRENT location obvious. */
.nav-item .icon { color: var(--c-text-muted); }
.nav-item:hover .icon,
.nav-item.active .icon { color: var(--c-primary); }

/* The count badge keeps its place at the far end once the row became a flex container. */
.nav-item .nav-badge { margin-left: auto; }

.nav-item:hover { background: var(--c-selection); }

/* TWO SIGNALS, NEITHER OF THEM COLOUR ALONE (§8 / WCAG 1.4.1): the filled pill and the heavier
   weight. A 2px accent bar down the left edge used to be a third, and it went with the rounding — an
   accent bar is a square-edge device, and drawn inside a rounded pill it reads as a stray mark rather
   than as an edge. The fill and the weight carry the state on their own; do not add a bar back
   without taking the radius off. */
.nav-item.active {
    background: var(--c-selection);
    font-weight: 500;
}

/* ---- Collapsible groups (docs/01 §Navigation) -----------------------------
   <details>/<summary>, so the rail needs no circuit and no JavaScript — the layout renders
   statically. The group holding the current page is rendered with `open` already set, server-side. */
.nav-group > summary.nav-group-head {
    display: flex;
    align-items: center;
    gap: var(--sp-3);
    /* Same padding and the same radius as `.nav-item` — a group head and a link are different things
       to click, but they are the same kind of row, and a rounded item under a square head looked like
       two lists. */
    padding: var(--sp-2) var(--sp-3);
    border-radius: var(--radius-control);
    font-size: 0.875rem;
    color: var(--c-text);
    cursor: pointer;
    user-select: none;
    /* Same 2px reserve as .nav-item, so a group heading's icon lines up with a top-level item's. */
    border-left: 2px solid transparent;
    list-style: none;
}
.nav-group > summary.nav-group-head::-webkit-details-marker { display: none; }
.nav-group > summary.nav-group-head::marker { content: ""; }
.nav-group > summary.nav-group-head:hover { background: var(--c-selection); }
.nav-group .nav-group-head .icon { color: var(--c-text-muted); }

/* Takes the slack, so the caret sits at the far edge and every group's caret aligns. */
.nav-group-label { flex: 1 1 auto; }

.nav-group-caret { transition: transform 0.12s ease; }
.nav-group[open] > .nav-group-head .nav-group-caret { transform: rotate(180deg); }

/* The group holding the current page: tinted heading, no accent bar. The bar belongs to the ONE
   active item — putting it on the parent too would show two active rows for one location. */
.nav-group.current > .nav-group-head,
.nav-group.current > .nav-group-head .icon { color: var(--c-primary); }

/* Bubbled count, shown only while the group is shut. Once it is open the child below is showing the
   same number, and two copies of it read as two different pieces of pending work. */
.nav-group[open] > .nav-group-head .nav-badge { display: none; }

/* Half the rail's gap inside a group, and the same again above the first child: a group's items are one
   set, so they sit closer to each other and to their heading than two unrelated top-level entries do.
   Equal gaps everywhere would have made an open group indistinguishable from a run of loose items. */
.nav-sub {
    list-style: none; margin: 0; padding: 0;
    display: flex; flex-direction: column;
    gap: 2px;
    margin-top: 2px;
}

/* Indented content, but the accent bar stays at the rail's edge — a child's active marker lines up
   with a top-level item's instead of stepping in with the text. */
/* Indented under its group head. The measurement is the head's own left padding plus its icon
   and gap, so a child's label starts where its parent's label does. */
.nav-item-sub { padding-left: calc(var(--sp-3) + 18px + var(--sp-3)); font-size: 0.8125rem; }

.app-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }

.topbar {
    /* flex-basis rather than height alone: as a flex child of .app-main it would otherwise be
       squeezed by a tall page and the header would creep upward as content grew. */
    flex: 0 0 56px;
    border-bottom: 1px solid var(--c-border);
    display: flex;
    align-items: center;
    padding: 0 var(--sp-6);
    /* See .rail: paired token, same fallback reasoning. */
    background: var(--c-topbar-bg, var(--c-surface-raised));
}

/* Pushed right by margin, not by `justify-content: space-between` on the bar: the bar has only one
   child now that the CONSOLE crumb is gone, and space-between leaves a lone child on the left. */
.topbar-right { display: flex; align-items: center; gap: var(--sp-4); margin-left: auto; }

/* The live clock docs/16 §2 specifies for the topbar's left side. Named zone beneath the time, not
   beside it: in a regulated environment "which clock is this" is the question a timestamp has to
   answer, and the answer should not depend on hovering. Tabular figures so the seconds do not make
   the whole row twitch once per second. */
.site-clock { display: flex; flex-direction: column; line-height: 1.15; }
.site-clock-time { font-family: var(--font-mono); font-size: 0.9375rem; font-variant-numeric: tabular-nums; color: var(--c-text); }
.site-clock-zone { font-size: 0.6875rem; color: var(--c-text-muted); }

/* ---- Account menu (topbar) ------------------------------------------------
   A <details>/<summary> disclosure, so it works with no Blazor circuit and no JavaScript — the
   layout renders statically. js/user-menu.js only adds click-outside-to-close, which is a
   convenience, not the mechanism. */
.user-menu { position: relative; }

.user-menu-trigger {
    display: flex; align-items: center; gap: var(--sp-2);
    padding: var(--sp-1) var(--sp-2);
    border-radius: var(--radius-control);
    font-size: 0.8125rem;
    color: var(--c-text);
    cursor: pointer;
    user-select: none;
    list-style: none;          /* Firefox draws the default triangle */
}
.user-menu-trigger::-webkit-details-marker { display: none; }
.user-menu-trigger::marker { content: ""; }
.user-menu-trigger:hover { background: var(--c-selection); }
.user-menu-name { font-weight: 500; white-space: nowrap; }
/* The username is the identifier, not the label: muted and unweighted so the full name still reads
   first at a glance and the brackets do not compete with it. The margin sits on top of the single
   space the markup already contributes, so the bracket reads as a separate piece of information
   rather than as part of the surname. */
.user-menu-username { font-weight: 400; color: var(--c-text-muted); margin-left: var(--sp-2); }
.user-menu-caret { transition: transform 0.12s ease; }
.user-menu[open] .user-menu-caret { transform: rotate(180deg); }

.user-menu-panel {
    position: absolute;
    top: calc(100% + var(--sp-2));
    right: 0;
    z-index: 40;              /* above the content canvas, below #blazor-error-ui at 1000 */
    min-width: 220px;
    display: flex; flex-direction: column;
    background: var(--c-surface-raised);
    border: 1px solid var(--c-border);
    border-radius: var(--radius-card);
    box-shadow: 0 6px 20px rgb(0 0 0 / 0.14);
    overflow: hidden;         /* clips the first/last item's hover fill to the rounded corners */
}

.user-menu-head {
    display: flex; flex-direction: column; gap: 2px;
    padding: var(--sp-3) var(--sp-4);
    border-bottom: 1px solid var(--c-border);
    background: var(--c-surface);
}
.user-menu-head-name { font-family: var(--font-display); font-size: 0.875rem; }
.user-menu-head-sub { font-size: 0.75rem; color: var(--c-text-muted); }

/* One rule for both the links and the sign-out <button>, so the row that submits a form is not a
   pixel taller or a shade different from the two that navigate. */
.user-menu-item {
    display: flex; align-items: center; gap: var(--sp-3);
    width: 100%;
    padding: var(--sp-3) var(--sp-4);
    font-family: var(--font-body);
    font-size: 0.8125rem;
    text-align: left;
    text-decoration: none;
    color: var(--c-text);
    background: transparent;
    border: 0;
    cursor: pointer;
}
.user-menu-item:hover { background: var(--c-selection); }
.user-menu-item .icon { color: var(--c-text-muted); }
.user-menu-item.danger { color: var(--s-alarm); border-top: 1px solid var(--c-border); }
.user-menu-item.danger .icon { color: var(--s-alarm); }

/* The one scrolling region on an application page — full width on purpose.
   The 1600px reading measure lives on .content-inner instead. When the cap was on the scrolling
   element itself, its scrollbar sat at the 1600px edge with ~80px of dead background to the right
   of it on a 1920px screen, which reads as a scrollbar floating in the middle of the page. */
.content-canvas {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
}

/* Fills the canvas. docs/16 §2 originally capped this at 1600px as a reading measure, which is the
   right instinct for prose and the wrong one here: every screen in this app is a table, a filter
   card or an embedded PDF, all of which use whatever width they are given. The cap only showed as
   dead space down the right-hand side — 80px at 1920, far more zoomed out, and it moved as the zoom
   changed, which reads as a bug rather than as a margin (decision 2026-08-04).

   No `width: 100%` — a block already fills its parent, and stating it again only re-creates the
   padding overflow the border-box reset above exists to prevent. */
.content-inner {
    padding: var(--sp-6);
}

/* ---- A page that fills the canvas ------------------------------------------
   For a screen that is one long list — Active Alarms — where flowing down the canvas takes the column
   headers off the top as you scroll, and the list's end is wherever the data happens to stop.

   A page opts in by wrapping its whole body in `.fill-canvas`; `:has()` then turns the shared
   `.content-inner` into the flex column that gives it a height to fill. Scoped through `:has()`
   rather than applied to `.content-inner` outright because every other page in the app is a normal
   block flow, and switching that element to flex would stop adjacent margins collapsing on all of
   them. A browser without `:has()` simply does not match the rule and the page flows and scrolls as
   any other does — no truncation, no overlap, just today's behaviour. */
.content-inner:has(> .fill-canvas) { display: flex; flex-direction: column; height: 100%; }
.fill-canvas { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }

/* The scrolling half: everything left after the page header. `min-height: 0` for the usual reason —
   a flex item will not shrink below its content without it, so the region would grow to the length of
   the list and push the page into scrolling instead of scrolling itself.
   --c-surface-raised, not --c-surface: the sticky header row IS --c-surface, and a same-on-same
   container would leave nothing separating the headers from the rows beneath them. */
.table-viewport {
    flex: 1 1 auto;
    min-height: 0;
    overflow: auto;
    border: 1px solid var(--c-border);
    border-radius: var(--radius-card);
    background: var(--c-surface-raised);
}

/* The search row above a list. `.filter-actions` carries a top margin only, which is all it needs
   between two blocks in a normal flow — but inside `.fill-canvas` the next thing is a bordered,
   tinted viewport and there is no gap to collapse into, so the box and the table's top edge met.
   Stated on the pair rather than as a blanket `margin-bottom` on `.filter-actions`: the row also
   appears as the LAST thing in a filter card, where a trailing margin would only pad the card out. */
.filter-actions + .table-viewport { margin-top: var(--sp-3); }

/* The same viewport on a page the grid does NOT own: a list sitting above the panel that edits
   whatever you pick out of it. Filling the screen there would squeeze the panel — the actual work
   area — to nothing, so the list takes a ceiling instead. The headings still hold, the list still
   scrolls, and what the clicked row opened below stays reachable without scrolling past a hundred
   rows to reach it.

   ONE page uses this now: the report queue, whose signature ceremony is still an inline panel under
   the list. Chambers, Users, Roles and Groups all carried it for the same reason and no longer need
   it — their edit panels became modals, so nothing renders below the list for the ceiling to protect,
   and each of those pages now takes the full canvas. Read this rule as "there is still something
   under the table", not as the default for a list. */
.table-viewport.capped { max-height: 60vh; }

/* `.data-table th` is already `position: sticky`, but under `border-collapse: collapse` the cell's
   border belongs to the table rather than to the cell, so it scrolls away and leaves the headers
   floating on the rows. An inset shadow is drawn by the cell itself and stays with it.
   All three scrolling hosts a table can sit in: `.table-viewport` is the page-level one,
   `.settings-card-rows` is the middle of a titled card (Countries & States), and `.table-scroll` is a
   card-width table that scrolls on both axes (the Themes lists). */
.table-viewport .data-table th,
.settings-card-rows .data-table th,
.table-scroll .data-table th { box-shadow: inset 0 -1px 0 var(--c-border); }

/* The pager under a scrolling list. Three pages wrote this as the same four-declaration inline style,
   and all three had it INSIDE the scroll flow — so on a fifty-row page you scrolled to the bottom of
   the table to reach the control that fetches the next fifty. Outside the viewport it stays put. */
.table-pager { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; margin-top: var(--sp-3); }

/* Data tables + admin forms */
.data-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
/* Row height and vertical padding come from the theme's density (docs/16 §2, §9.1.2); the fallbacks
   are the compact defaults, so a theme that sets nothing looks exactly as it always did. */
.data-table th, .data-table td { text-align: left; padding: var(--cell-pad-y, var(--sp-2)) var(--sp-3); height: var(--row-height, 40px); border-bottom: 1px solid var(--c-border); }
/* `z-index: 1` is not decoration — without it the row action buttons scroll OVER the sticky heading.
   `.btn.icon-btn` is `position: relative` (it needs that for the `::after` that widens its hit target
   to 40px), so a header cell at `z-index: auto` and a button at `z-index: auto` are two positioned
   boxes in one stacking context, and those paint in DOM order — `<tbody>` comes after `<thead>`, so
   the buttons won. The symptom was a column of restore/edit icons sliding up through the header band
   on every grid that has a row-actions column.
   1, matching `.assign-group-head`, which is the same sticky-heading-over-scrolling-rows problem. */
.data-table th { font-family: var(--font-display); font-weight: 600; background: var(--c-surface); position: sticky; top: 0; z-index: 1; }
.data-table td.num, .data-table th.num { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* `.num` without the right-alignment.
   `.num` bundles two decisions that only travel together for actual numbers: tabular mono figures, and
   flush-right so the units line up under each other. A TIMESTAMP wants the first and not the second —
   every value is the same width, so the digits already align on the left edge, and reading a date is a
   left-to-right act. Pushing a date column right is most obviously wrong when it is the first column,
   where it walks away from the row it belongs to.
   Apply this instead of `.num` on both the cells and the heading, exactly as `.num` is applied. */
.data-table td.mono, .data-table th.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* Sized to the timestamp and no wider, on one line.
   `table-layout: auto` hands the leftover width out across ALL columns, which gave this one less than
   a full timestamp needs — so "18-Aug-2026 17:35:24" wrapped onto two lines in a column that then
   held a ragged two-line block, while the width it wanted had been handed to the free-text column
   beside it. `width: 1%` with `nowrap` inverts that: the column resolves to its min-content width,
   which for unwrappable text is the whole timestamp, and every remaining pixel goes to the message.
   Same idiom as `.select-cell` above, for the same reason. Alignment is untouched — `.mono` cells
   inherit the `text-align: left` all cells get, which is the point of `.mono` over `.num`. */
.data-table th.mono, .data-table td.mono { width: 1%; white-space: nowrap; }
/* The two free-text columns of a grid, splitting the leftover width evenly and holding one line
   each (Audit Trail: Remark and Old → New). The counterpart to `.mono` above: that one asks for
   its content's width and no more, this one asks for a share of the row and refuses to grow past it.

   `max-width: 0` is what makes the ellipsis possible while the table stays `table-layout: auto`. It
   takes the cell out of the column's min/max-content measurement, so the column resolves to the
   declared 50% instead of to the longest value on the page — without it one 400-character remark
   sets the width for the whole column and pushes the columns after it off the canvas. The declared
   50% on both, over a table that is already `width: 100%`, is then reduced proportionally against
   the fixed columns, which is what leaves the pair equal to each other.

   Truncation must never be the only copy of a value on an audit screen, so every such cell also
   carries its full text in `title` and shows it on hover. */
.data-table th.half, .data-table td.half { width: 50%; }
.data-table td.half { max-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A reading outside its probe's alarm band (Data Log). The PDF marks these red with a trailing '*';
   on screen the colour is the whole signal, so the weight carries what the asterisk was carrying —
   colour alone is not a distinction a colour-blind reader can make, and a grid of numbers has nowhere
   to put a legend. The row still reads as data: no fill, no border, just a value that stands out of
   the column it sits in. */
.data-table td.excursion { color: var(--s-alarm); font-weight: 600; }

/* ---- A corrected value, in place (User Log Book) ---------------------------
   `old → new`, with what was replaced struck through and the value that stands now in normal ink.
   The same reading as `.modal-change-old`/`-new` further down, moved into a grid cell: the log book
   shows a correction where the reader is already looking rather than behind a History button, which
   is the whole point of an entry that may be edited after it is signed.

   The markup is <del>/<ins>, not two spans. A browser strikes <del> through on its own, so the
   distinction survives a stylesheet that fails to load, and a screen reader announces the deletion
   instead of reading both values as one sentence. Both default to inline with no decoration of their
   own beyond that strike, so the rules here are colour, weight and spacing only.

   Muted, not red: a correction is a normal act properly recorded, and colouring it like an alarm
   would tell an operator scanning the grid that something is wrong. */
.data-table del.cell-superseded {
    color: var(--c-text-muted);
    text-decoration-color: var(--c-text-muted);
}
.data-table ins.cell-current { text-decoration: none; color: var(--c-text); font-weight: 600; }
/* aria-hidden in the markup — the <del>/<ins> pair already says "replaced by" to a screen reader,
   and an arrow read aloud between them says it twice. */
.data-table .cell-revised-arrow { color: var(--c-text-muted); padding: 0 var(--sp-1); }

/* Purpose is prose, not a value: it needs room to wrap, and a trail of corrections needs more. Every
   other cell stays on one line, so this is opt-in per column rather than a change to the table. */
.data-table td.logbook-purpose { white-space: normal; min-width: 18rem; }

/* The AVG / MIN / MAX band under a readings grid (Data Log).

   Pinned to the bottom edge of the scrolling viewport so the figures stay in view however far down the
   list you are — the mirror of the sticky `<thead>` above, and for the same reason: they describe the
   columns, and a summary you have to scroll to the end to see is a summary nobody reads. The shadow
   is drawn by the cell rather than as a border for the reason stated on the header rule — under
   `border-collapse: collapse` the border belongs to the table and scrolls away from a sticky cell.

   The `bottom` offsets stack the three rows in DOM order: AVG sits two row-heights up, MIN one, MAX on
   the floor, so together they read as one block. Tied to `--row-height`, the theme's density token, so
   the band moves with the rows rather than needing a second figure kept in step by hand.

   `top: auto` is load-bearing, not tidiness. The row label is a `<th>`, and `.data-table th` above
   makes every `th` sticky to `top: 0` for the column headings — a sticky box given both a top and a
   bottom resolves the top first, so without this the AVG/MIN/MAX labels would climb to the top of the
   viewport and sit in the header band while their own figures stayed at the foot. */
.data-table tfoot th, .data-table tfoot td {
    position: sticky;
    top: auto;
    z-index: 1;
    background: var(--c-surface);
    font-weight: 600;
    border-bottom: none;
    box-shadow: inset 0 1px 0 var(--c-border);
}
.data-table tfoot tr:nth-last-child(3) th, .data-table tfoot tr:nth-last-child(3) td { bottom: calc(var(--row-height, 40px) * 2); }
.data-table tfoot tr:nth-last-child(2) th, .data-table tfoot tr:nth-last-child(2) td { bottom: var(--row-height, 40px); }
.data-table tfoot tr:last-child th, .data-table tfoot tr:last-child td { bottom: 0; }
/* Only the top of the band gets the dividing line; the rows inside it are one block. */
.data-table tfoot tr:not(:first-child) th, .data-table tfoot tr:not(:first-child) td { box-shadow: none; }
.data-table tr.selected-row td { background: var(--c-selection); }
/* The row-selection column (Audit Trail deletion). Sized to the checkbox and no wider — it pushes
   eight data columns right, and every pixel it takes is taken from the Old → New column that needs
   them most. `accent-color` rather than a drawn control: this is a plain checkbox in a grid, not the
   Toggle switch, and the native box already answers the space bar and reports its state to a reader. */
.data-table th.select-cell, .data-table td.select-cell { width: 1%; padding-right: 0; white-space: nowrap; }
.data-table .select-cell input[type="checkbox"] { display: block; margin: 0; accent-color: var(--c-primary); cursor: pointer; }
/* A control sitting in a table cell — the alarm acknowledgement comment. `.data-table` styles no
   inputs of its own, so this was a bare native box beside a styled button, and its width was an
   inline style. Narrower than `.input-inline`: it shares a cell with the button that consumes it. */
/* The comment box and its Ack button share one cell; nowrap keeps the button beside the box rather
   than dropped under it when the Detail column takes a long message. */
.ack-cell { display: flex; align-items: center; gap: var(--sp-2); }
.data-table .input-cell {
    width: 12rem;
    padding: var(--sp-1) var(--sp-2);
    border: 1px solid var(--c-border); border-radius: var(--radius-control);
    background: var(--c-surface-raised); color: var(--c-text);
}
/* Carries its own size now that it is used bare as well as with `.hint` (Settings still pairs the
   two) — the same 0.75rem `.hint` sets, so the two spellings render identically. */
.field-error { color: var(--s-alarm); font-size: 0.75rem; line-height: 1.4; }
/* The header's password-expiry countdown. Plain muted text for most of the password's life — it is
   information, and a bordered alert badge sitting in the corner for a year stops being read. It
   takes the alert colour and border only once `.is-urgent` says the deadline is close. */
.expiry-notice { font-size: 0.75rem; color: var(--c-text-muted); border: 1px solid transparent; border-radius: var(--radius-control); padding: 2px var(--sp-2); text-decoration: none; white-space: nowrap; }
.expiry-notice:hover { text-decoration: underline; }
.expiry-notice.is-urgent { color: var(--s-alert); border-color: var(--s-alert); }

/* The licence countdown, above the content and full width. Deliberately not built like
   `.expiry-notice` above it, though both are deadlines: a password expiring inconveniences one
   person and gets a chip in the corner, a licence expiring stops every screen in the plant and gets
   the width of the page. It is also the only warning there is — the stop at midnight is absolute —
   so it does not fade into muted text for most of its life the way the password one does.

   Alert amber from the first day and alarm red inside the last three. Colour is not carrying the
   meaning on its own (§8, WCAG 1.4.1): the glyph and the sentence both state it, and the sentence
   names the actual date rather than only a count. */
.license-banner {
    display: flex;
    align-items: flex-start;
    gap: var(--sp-3);
    padding: var(--sp-3) var(--sp-4);
    border-bottom: 1px solid var(--s-alert);
    background: color-mix(in srgb, var(--s-alert) 12%, var(--c-bg));
    color: var(--c-text);
    font-size: 0.8125rem;
    line-height: 1.5;
}

.license-banner .icon { flex: none; margin-top: 1px; color: var(--s-alert); }

.license-banner-urgent { border-bottom-color: var(--s-alarm); background: color-mix(in srgb, var(--s-alarm) 12%, var(--c-bg)); }
.license-banner-urgent .icon { color: var(--s-alarm); }

/* The machine id is what the customer has to send their vendor to get a key, so it is set to be
   read back accurately and copied — mono, tracked out, wrapping anywhere rather than overflowing. */
.license-banner code {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    letter-spacing: 0.04em;
    overflow-wrap: anywhere;
}

.page-header { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-bottom: var(--sp-4); }
/* A header with more than one button: they sit together at the right edge, and whatever precedes them
   (title, a zone hint) stays together at the left. Without this, space-between spreads every child
   across the full width and a hint and two buttons land in three separate places. The auto margin
   takes the free space before justify-content can hand it out. */
.page-header-actions { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.page-title { font-family: var(--font-display); font-size: 1.5rem; margin: 0; }

/* Icons (docs/16 §1): one stroke set, currentColor so an icon takes the colour of whatever it sits
   in — a .danger button's icon turns alarm-red with no extra rule, in every theme and both modes.
   flex-shrink guards the glyph when a long label wraps. */
.icon { display: inline-block; vertical-align: -0.15em; flex-shrink: 0; }

/* .btn-primary on the auth screens is a separate class, not a modifier of .btn, so it inherits none
   of its layout. (.btn-logout was the other one; the topbar's sign-out is now a .user-menu-item.) */
.btn-primary { display: inline-flex; align-items: center; gap: var(--sp-2); }
/* A sort header is text plus its own ▲▼ mark; centring the row keeps an icon-free header aligned
   with the icon-bearing action buttons in the same table. */
.col-sort { display: inline-flex; align-items: center; gap: var(--sp-1); }

/* inline-flex, so the icon and label share a baseline row rather than the icon sitting on the text
   baseline and dragging the button taller.

   `text-decoration: none` is for the `<a class="btn">` form — a navigation that looks like an action
   ("View PDF", "Change password"). There is no global anchor reset, so those were rendering with the
   UA's underline running through the label and under the icon's box. */
.btn { display: inline-flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-control); border: 1px solid var(--c-border); background: var(--c-surface-raised); color: var(--c-text); cursor: pointer; font-size: 0.8125rem; text-decoration: none; }
.btn.primary { background: var(--c-primary); color: var(--c-primary-ink); border-color: var(--c-primary); }
.btn.danger { border-color: var(--s-alarm); color: var(--s-alarm); }

/* Row actions in a grid (RowAction.razor): the icon alone, label on hover via the native tooltip.
   Square, because a rectangle sized for text that is no longer there reads as a button waiting for its
   label. The horizontal padding drops to `--sp-2` so it matches the vertical one the base .btn already
   has, which lands on the same height as a labelled button — a grid that mixes the two (an Ack button
   beside its comment box) must not look like two sizes of control. */
.btn.icon-btn { padding: var(--sp-2); gap: 0; position: relative; }
/* §8 asks for ≥40px touch targets (tablet on a plant floor). The visual box is 34px — the height every
   .btn in a grid already had, so keeping it leaves row density untouched — and this extends the POINTER
   target to 40x40 without changing layout. Losing the label already made these harder to hit; shrinking
   the target as well is what would make them genuinely worse on a tablet.
   -4px, not -3px: an absolutely positioned child is offset from its parent's PADDING box, which the 1px
   border already insets, so -3px only reached 38px. Measured by clicking outward one pixel at a time,
   not derived — a click lands on the button up to 3px past the painted edge and misses at 4px, so the
   target is 34+3+3 = 40px square.
   Against the 8px gap that leaves a 1px dead zone between neighbours: targets do not overlap, but they
   are effectively adjacent, so a badly aimed tap can reach the next action rather than nothing. Fine for
   these rows — the only destructive one (Drop a restored database) sits alone in its cell. */
.btn.icon-btn::after { content: ''; position: absolute; inset: -4px; border-radius: inherit; }
/* Hover feedback matters more here than on a labelled button: the icon is the only thing on it, so
   without this there is nothing to confirm the pointer is on the right one of six. */
.btn.icon-btn:hover:not(:disabled) { border-color: var(--c-primary); color: var(--c-primary); }
.btn.icon-btn.primary:hover:not(:disabled) { color: var(--c-primary-ink); }
/* Several actions in one cell. Inline whitespace between inline-flex buttons collapses to a single
   space, which is too tight to aim at once the buttons are 32px squares. */
.row-actions { display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }

/* The actions column hugs the right edge of the table.
   `table-layout: auto` on a `width: 100%` table hands the leftover width out across ALL columns, so
   the trailing actions column ended up several hundred pixels wider than its buttons and left them
   stranded in the middle of the row with dead space after them — reading as a column that had failed
   to load rather than as a deliberate edge.

   Detected through `:has()` rather than a class every table has to remember to pass: `.row-actions` is
   already the marker for "this cell is the actions cell", so the stylesheet can see it. The heading is
   matched via the TABLE — an actions header is either empty or the word "Actions", so there is nothing
   in the cell itself to detect, but a table that contains an actions cell always carries it last.
   A table WITHOUT one (the alarm acknowledgement column, the audit trail) matches neither rule and
   keeps its last column left-aligned. */
.data-table td:has(> .row-actions) { text-align: right; }
.data-table:has(td > .row-actions) thead th:last-child { text-align: right; }

.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); max-width: 720px; }
/* Drops the 720px reading measure for a grid that fills its card. A form is easier to read narrow;
   a FILTER row is read across, beside the result set it controls, so it takes the card's full width
   (docs/16 §5.5). Used by every filter card, and by the wider modal forms. */
.form-grid.wide { max-width: none; }

/* ---- Filter cards: three across ------------------------------------------
   A filter is a short labelled control — a chamber picker, a date, a row count — and two to a row
   pushed a six-filter card three rows deep, taller than the result set it exists to narrow. Three
   puts Generate Report's chamber/type/layout on one line and from/to/interval on the next.

   A separate class rather than `.wide`, and rather than a descendant rule like `.panel .form-grid`:
   `.wide` is worn by the modal FORMS too (chambers, themes, schedules), and a panel can hold a form
   as easily as a filter. Only the grids that actually filter something opt in.

   Forms deliberately keep two columns. A form is read down rather than across, and its fields carry
   required marks, hints and validation lines that need the width — the chamber form at three across
   would put a validation sentence under a 200px box.

   `.span-2` still spans the whole row here: it is `1 / -1`, not `span 2`. */
.form-grid.filter-grid { grid-template-columns: repeat(3, 1fr); }
/* 1024, not one of the sheet's existing steps: with the nav rail taken off, a viewport narrower than
   this leaves each of three columns under ~270px, which is where a date input starts clipping. */
@media (max-width: 1024px) { .form-grid.filter-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 640px)  { .form-grid.filter-grid { grid-template-columns: 1fr; } }
.form-grid label { display: flex; flex-direction: column; gap: var(--sp-1); font-size: 0.8125rem; }
/* A field that takes the whole row — one that has no natural partner beside it, or whose hint is a
   sentence rather than a phrase. `1 / -1` rather than `span 2` so it still fills the row under the
   single-column rules the modal and the narrow breakpoint apply. */
.form-grid label.span-2 { grid-column: 1 / -1; }
/* `textarea` joins the list for the audit-deletion reason — the first multi-line field in the app.
   Its own two rules follow, because a text area needs a line height an input does not and inherits a
   monospace default from the UA that would make it the only prose box in the app not set in the body
   face. `resize: vertical` and not `both`: widening it past the grid column breaks the card. */
.form-grid input, .form-grid select, .form-grid textarea { padding: var(--sp-2) var(--sp-3); border: 1px solid var(--c-border); border-radius: var(--radius-control); background-color: var(--c-surface-raised); color: var(--c-text); }
.form-grid textarea { font: inherit; line-height: 1.5; resize: vertical; min-height: 4.5rem; }
/* A field shown for reference but not editable here — a theme's key while an existing theme is being
   republished. Recessed rather than raised, so "you cannot type in this" is visible before it is
   clicked. A step from the field's own background TOWARDS the muted ink, not a named surface: which
   of --c-surface and --c-surface-raised is the darker one differs by theme, so naming one would make
   the field look recessed in some palettes and lifted in others. */
.form-grid input[readonly] {
    background: color-mix(in srgb, var(--c-text-muted) 8%, var(--c-surface-raised));
    color: var(--c-text-muted);
    cursor: default;
}

/* ---- Required-field marker (RequiredMark.razor) ----------------------------
   A mandatory box carries a red asterisk after its caption, so the form states what it needs BEFORE
   Save is pressed. The field-level messages below still say which answer is wrong and why; this only
   says, at rest, which boxes cannot be left empty. The two are a pair — the mark is not a reason to
   drop a message, and a message is not a reason to leave a field unmarked.

   The caption is wrapped in `.field-caption` rather than left as a bare text node, because
   `.form-grid label` is a flex COLUMN: an asterisk beside a bare text node is a flex item of its own
   and lands on the line UNDER the label. The wrapper makes caption and mark one item, with the
   asterisk inline after the words. Auth's `.field > span` (auth.css) already is such a wrapper, so
   those forms mark up the same way without needing this class.

   Here rather than in auth.css because this side owns `.form-grid`; both stylesheets are global, so
   it is one mark in one colour on every form in the app. */
.required-mark { color: var(--s-alarm); font-weight: 600; margin-left: 2px; }

/* ---- Field-level validation (FieldError.razor) -----------------------------
   `.invalid` on the control, matching auth.css's `.field input.invalid` rather than inventing a
   second name for the same state. `:not(:focus)` there existed because the auth form marks a field
   the moment it blurs; these are marked on submit and cleared as the box is corrected, so the ring
   must stay visible while the reader is typing the fix INTO the focused box. */
.form-grid input.invalid, .form-grid select.invalid { border-color: var(--s-alarm); }
.form-grid input.invalid:focus, .form-grid select.invalid:focus {
    outline: var(--focus-width) solid color-mix(in srgb, var(--s-alarm) 45%, transparent);
    outline-offset: var(--focus-offset);
}

/* ---- Native <select> -------------------------------------------------------
   Every drop-down that is not a ComboBox — around sixty of them across twenty-three screens: form
   fields, filter bars and toolbar filters. Three rules already agreed on the BOX (`.form-grid
   select`, `.filter-bar select`, `.inline-field select`) and not one of them said anything about the
   ARROW, so each screen showed whatever the platform draws — a grey square button on Windows, a blue
   disc on macOS — beside a ComboBox wearing our own chevron. This settles the arrow for all of them
   at once. The three keep their padding, which genuinely differs between a form field and a toolbar
   filter, and this carries the rest.

   It has to carry the rest. `appearance: none` takes the platform's border and background away with
   the arrow, so a select that reached none of those three — and several do — would be left as bare
   text with a chevron after it. The box is repeated here so that cannot happen to one nobody
   remembered to check.

   The chevron is two gradients rather than an SVG data URI, because a background image is a separate
   document and cannot read `currentColor`: its colour would have to be a literal in the URL, which is
   what the CI guard forbids (docs/16 §9.2) and would be wrong on most of the themes anyway, twice
   over in the ones carrying a dark mode. Two diagonal bands meeting corner to corner draw the same
   "v" as the ComboBox's lucide chevron, and being `currentColor` they follow the field's own ink
   into every theme without a second rule to maintain.

   `appearance: none` reaches the CLOSED control only — the half that sits in the layout beside our
   own boxes. The open list stays the platform's, and is not stylable from here; a screen that needs
   the list itself styled is asking for ComboBox, not for this. Safe on all of them either way:
   nothing in the app uses `multiple` or `size`, so there is no select whose list is meant to render
   inline rather than as a popup. */
select {
    appearance: none;
    -webkit-appearance: none;   /* Safari honours only the prefixed one for the drop-down button */
    padding: var(--sp-1) var(--sp-2);
    padding-right: var(--sp-6);
    border: 1px solid var(--c-border);
    border-radius: var(--radius-control);
    background-color: var(--c-surface-raised);
    color: var(--c-text);
    cursor: pointer;

    /* Two 5px squares meeting corner to corner — "\" then "/" — ending 7px in from the right edge, so
       the pair clears the border and sits where the ComboBox caret sits on the same form. */
    background-image:
        linear-gradient(to bottom left,  transparent 40%, currentColor 40%, currentColor 60%, transparent 60%),
        linear-gradient(to bottom right, transparent 40%, currentColor 40%, currentColor 60%, transparent 60%);
    background-repeat: no-repeat;
    background-size: 5px 5px;
    background-position: right 12px center, right 7px center;
}

/* Room for the chevron at each density. Stated per context, and not once above, because all three of
   those rules set `padding` as a shorthand — which resets any right-hand padding this one asked for.
   `.form-grid` is the roomy one; the two toolbar densities share the tighter figure and are set
   after their own rules further down. */
.form-grid select { padding-right: var(--sp-8); }

/* Muted ink takes the chevron with it, since it is drawn in `currentColor`. */
select:disabled { color: var(--c-text-muted); cursor: default; }

/* The ring `.combo-control:focus-within` draws, so the two kinds of drop-down answer a Tab the same
   way. `:focus-visible` rather than `:focus`: a select takes focus on a mouse press, and ringing it
   for the length of a click is noise on a control the pointer is already on. */
select:focus-visible {
    outline: var(--focus-width) solid var(--c-focus);
    outline-offset: var(--focus-offset);
}

/* ---- Combo box (ComboBox.razor) -------------------------------------------
   A dropdown you can also type into. It has to read as ONE control at rest — a reader glancing down a
   form must not see two different kinds of box — so the border, radius and background live on the
   wrapper and the input inside is stripped bare. That also puts the focus ring around the pair rather
   than around the text half of it, which is what a <select> does.

   The panel is the account menu's panel (§ Account menu) at a different anchor: same raised surface,
   border, radius and shadow. z-index 40 for the same reason — above the content canvas, far below the
   modals and the reconnect banner. */
.combo { position: relative; }
.combo-control {
    display: flex; align-items: stretch;
    border: 1px solid var(--c-border);
    border-radius: var(--radius-control);
    background: var(--c-surface-raised);
}
/* The ring belongs to the whole control, so it is drawn here off :focus-within and suppressed on the
   input — two rings, one inside the other, is what happens if you forget the second half. */
.combo-control:focus-within {
    outline: var(--focus-width) solid var(--c-focus);
    outline-offset: var(--focus-offset);
}
.form-grid .combo-input {
    flex: 1; min-width: 0;
    border: 0; background: none; border-radius: 0;
    padding: var(--sp-2) var(--sp-3);
    color: var(--c-text);
}
.form-grid .combo-input:focus { outline: none; }
.combo-control:has(.combo-input.invalid) { border-color: var(--s-alarm); }
.combo-control:has(.combo-input.invalid):focus-within {
    outline-color: color-mix(in srgb, var(--s-alarm) 45%, transparent);
}

.combo-caret {
    display: flex; align-items: center;
    padding: 0 var(--sp-2);
    border: 0; background: none; color: var(--c-text-muted); cursor: pointer;
}
.combo-caret:hover { color: var(--c-text); }
.combo.open .combo-caret .icon { transform: rotate(180deg); }
.combo-caret .icon { transition: transform 0.12s ease; }
.combo-input:disabled, .combo-caret:disabled { cursor: default; color: var(--c-text-muted); }

.combo-panel {
    position: absolute;
    top: calc(100% + 2px);
    left: 0; right: 0;
    z-index: 40;
    /* Roughly eight rows before it scrolls. A list of every country is long, and a panel that runs off
       the bottom of a card puts its last options where a mouse cannot reach them. */
    max-height: 15rem; overflow-y: auto;
    margin: 0; padding: var(--sp-1) 0; list-style: none;
    background: var(--c-surface-raised);
    border: 1px solid var(--c-border);
    border-radius: var(--radius-card);
    box-shadow: 0 6px 20px rgb(0 0 0 / 0.14);
}
.combo-option {
    display: flex; align-items: center; gap: var(--sp-2);
    padding: var(--sp-2) var(--sp-3);
    font-size: 0.8125rem;
    cursor: pointer;
}
/* One rule for hover AND keyboard, so the row the arrow keys are on looks like the row the mouse is
   on. Two different highlights for the same state is how a keyboard user loses their place. */
.combo-option:hover, .combo-option.active { background: var(--c-selection); }
.combo-check { margin-left: auto; color: var(--c-primary); }
/* The "Add …" row is an action, not one of the values — separated and in the primary ink so it cannot
   be mistaken for an option that already exists. */
.combo-new { border-top: 1px solid var(--c-border); margin-top: var(--sp-1); color: var(--c-primary); }
.combo-new .icon { color: var(--c-primary); }
.combo-empty { padding: var(--sp-2) var(--sp-3); font-size: 0.75rem; color: var(--c-text-muted); }

/* ---- Select (Select.razor) -------------------------------------------------
   The picker that replaces a native <select> where the LIST has to be ours too. It reuses every rule
   above — `.combo` for the anchor, `.combo-control` for the box, `.combo-panel` and `.combo-option`
   for the list — so the two controls are not two designs that have to be kept level. All that is
   left is the trigger, which is a <button> where ComboBox has an <input>, and so arrives carrying the
   UA's button font, centring and padding. */
.select-trigger {
    width: 100%;
    text-align: left;
    padding: 0;              /* the value span carries it, so the caret can sit flush */
    cursor: pointer;
    color: var(--c-text);

    /* NO `font: inherit` here, deliberately, and this is what made the control four pixels taller
       than the inputs beside it. The shorthand carries line-height, so inheriting it pulled in
       `.modal-body`'s 1.5 — 13px of Archivo on a 19.5px line, against an input's UA font on a ~15px
       one. Both have the same padding, so the whole difference landed in the height.

       Left alone, a button takes the same UA control font an input does, which is what `.combo-input`
       and every native <select> in the app are already set in. That makes this exactly as tall as the
       box beside it in the same grid row, which is the point — matching the app's body face is a
       change worth making to every form control at once, or to none. */
}
/* `.combo-control` is the bordered box; the ring belongs to it, exactly as it does on the ComboBox.
   Stated here rather than borrowed from `:focus-within` because a button IS the focus target — there
   is no inner control for that pseudo-class to catch. */
.select-trigger:focus-visible {
    outline: var(--focus-width) solid var(--c-focus);
    outline-offset: var(--focus-offset);
}
.select-trigger:disabled { cursor: default; color: var(--c-text-muted); }

/* The open panel: a search row that stays put, over a list that scrolls. The panel keeps
   `.combo-panel`'s surface and max-height (including the shorter one inside a modal) but hands the
   scrolling to the list, so the search box is never scrolled out of reach on a long list. */
.select-panel {
    display: flex; flex-direction: column;
    overflow: hidden;
    padding: 0;
}
.select-search {
    flex: 0 0 auto;
    display: flex; align-items: center; gap: var(--sp-2);
    padding: 0 var(--sp-3);
    border-bottom: 1px solid var(--c-border);
    color: var(--c-text-muted);
}
.select-search-icon { display: flex; }
/* Three classes deep so it outranks `.form-grid input`, `.setting-control input` and the filter bars'
   input rules: this box sits inside every one of those and must look like none of them — it is part
   of the panel, not another field. */
.combo .select-search .select-search-input {
    flex: 1 1 auto; min-width: 0;
    padding: var(--sp-2) 0;
    border: 0; border-radius: 0;
    background: none;
    color: var(--c-text);
    font-size: 0.8125rem;
}
.combo .select-search .select-search-input:focus { outline: none; }
.select-list {
    flex: 1 1 auto; min-height: 0;
    overflow-y: auto;
    margin: 0; padding: var(--sp-1) 0; list-style: none;
}

/* Truncates rather than widening the field: a department name is longer than the column it sits in on
   a two-column form, and a picker that grows past its grid cell breaks the row. */
.select-value {
    flex: 1 1 auto; min-width: 0;
    padding: var(--sp-2) var(--sp-3);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* "Nothing chosen" — muted in the box AND on its row in the list, so the two agree. Same treatment a
   placeholder attribute gets on an input, which is what this replaced. */
.is-placeholder { color: var(--c-text-muted); }

/* An option shown but not choosable — `<option disabled>`. Greyed and inert, including under the
   pointer: the row must not light up like a target when pressing it does nothing. Graphical Analysis
   is the case it exists for, greying the humidity graphs on a chamber that has no humidity sensors
   rather than hiding them, which says "this report exists, not for this chamber". */
.combo-option.is-disabled,
.combo-option.is-disabled:hover { background: none; color: var(--c-text-muted); cursor: default; }

/* ---- Date picker (DatePicker.razor) ----------------------------------------
   The third control on the `.combo` anatomy, after ComboBox and Select: same anchor, same box, same
   panel. Only the panel's CONTENTS are new, so a calendar and a drop-down list sit on one surface
   rather than two that have to be kept level.

   The panel sizes to its content here rather than to the box — `.combo-panel` stretches left-to-right
   across the trigger, which is right for a list of options and wrong for a calendar, whose width is
   set by seven columns of two-digit days. */
.datepick-panel {
    left: 0; right: auto;
    width: max-content;
    max-height: none;      /* the calendar is a fixed six rows; only the time columns scroll */
    overflow: visible;
    padding: var(--sp-3);
    /* A COLUMN, and the calendar + time row is its own box below. `max-content` on a wrapping row
       means "the width at which nothing wraps", so with the footer as a third sibling the panel sized
       itself to calendar-plus-footer side by side and carried 125px of dead space on the right.
       Stacked, max-content is the wider of the two lines, which is what it should always have been. */
    display: flex; flex-direction: column; gap: var(--sp-3);
}
/* The one row that does sit side by side: the calendar, and the Hr/Min columns when there are any. */
.datepick-body { display: flex; gap: var(--sp-3); }
/* Keeps the panel on screen when the field sits in the right-hand column of a form grid: it grows
   leftward from the box's right edge instead of off the card. */
.form-grid > label:nth-child(even) .datepick-panel { left: auto; right: 0; }

/* The typed box. Stated here as well as by `.form-grid .combo-input` because the picker also sits in
   toolbars outside a form grid, where `.inline-field input` would otherwise draw a second border
   inside the control's own. Two classes plus the element out-rank both of those rules. */
.datepick .datepick-control input.combo-input {
    flex: 1; min-width: 0;
    border: 0; background: none; border-radius: 0;
    padding: var(--sp-2) var(--sp-3);
    color: var(--c-text);
}
.datepick .datepick-control input.combo-input:focus { outline: none; }
.datepick .datepick-control input.combo-input::placeholder { color: var(--c-text-muted); }
.datepick > .field-error { display: block; margin-top: var(--sp-1); }

.datepick-cal { display: flex; flex-direction: column; gap: var(--sp-2); }

.datepick-head { display: flex; align-items: center; gap: var(--sp-1); }
/* The month name takes the slack so the four arrows sit at the ends and do not shuffle as the name
   changes length between "May 2026" and "September 2026". */
.datepick-month {
    flex: 1 1 auto; text-align: center;
    font-family: var(--font-display); font-size: 0.8125rem; white-space: nowrap;
}
.datepick-nav {
    display: flex; align-items: center; justify-content: center;
    width: 22px; height: 22px; flex: none;
    border: 0; border-radius: var(--radius-control);
    background: none; color: var(--c-text-muted); cursor: pointer;
}
.datepick-nav:hover { background: var(--c-selection); color: var(--c-text); }

.datepick-grid { display: grid; grid-template-columns: repeat(7, 30px); gap: 2px; }
.datepick-dow {
    display: flex; align-items: center; justify-content: center;
    height: 22px;
    font-size: 0.6875rem; color: var(--c-text-muted);
}
.datepick-day {
    display: flex; align-items: center; justify-content: center;
    height: 28px;
    border: 1px solid transparent; border-radius: var(--radius-control);
    background: none; color: var(--c-text);
    font: inherit; font-size: 0.75rem; cursor: pointer;
}
.datepick-day:hover { background: var(--c-selection); }
/* The days either side of the month boundary. Kept on the grid rather than blanked, so the six rows
   never show a ragged edge — but muted, because they are not what this month is about. */
.datepick-day.outside { color: var(--c-text-muted); }
/* Today is an outline; the SELECTED day is filled. Two different questions — "where are we now" and
   "what is set" — so they must not answer with the same mark. */
.datepick-day.today { border-color: var(--c-border); }
.datepick-day.selected {
    background: var(--c-primary); color: var(--c-primary-ink);
    border-color: var(--c-primary);
}
/* Where the keyboard is, which is not yet where the value is — Enter is what moves the value. */
.datepick-day.focused { outline: var(--focus-width) solid var(--c-focus); outline-offset: -2px; }

/* The two columns that replace typing a time. Fixed height so the panel does not grow to 60 rows. */
.datepick-time { display: flex; gap: var(--sp-2); }
.datepick-timecol { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 3rem; }
.datepick-timehead { font-size: 0.6875rem; color: var(--c-text-muted); text-align: center; }
.datepick-timelist {
    margin: 0; padding: 0; list-style: none;
    max-height: 13.5rem; overflow-y: auto;
    border: 1px solid var(--c-border); border-radius: var(--radius-control);
}
.datepick-timeopt {
    padding: 3px var(--sp-2);
    font-family: var(--font-mono); font-size: 0.75rem;
    text-align: center; cursor: pointer;
}
.datepick-timeopt:hover { background: var(--c-selection); }
.datepick-timeopt.selected { background: var(--c-primary); color: var(--c-primary-ink); }

.datepick-foot {
    display: flex; align-items: center; gap: var(--sp-3);
    padding-top: var(--sp-2);
    border-top: 1px solid var(--c-border);
}
.datepick-link {
    border: 0; background: none; padding: 0;
    font: inherit; font-size: 0.75rem; color: var(--c-primary); cursor: pointer;
}
.datepick-link:hover { text-decoration: underline; }
/* Done is the way OUT, so it sits at the far end away from the two that change the value. */
.datepick-link.close { margin-left: auto; color: var(--c-text-muted); }

/* The time lists get the app's thin scrollbar, not the platform's slab. */
.datepick-timelist::-webkit-scrollbar { width: 8px; }
.datepick-timelist::-webkit-scrollbar-track { background: transparent; }
.datepick-timelist::-webkit-scrollbar-thumb {
    background: color-mix(in srgb, var(--c-text) 14%, transparent);
    border-radius: 999px; border: 1px solid transparent; background-clip: padding-box;
}

/* Inside a form modal the panel floats over the form exactly as it does anywhere else — it is a
   dropdown, and a dropdown that shoves the fields below it down the page is not one. It was in the
   flow here briefly, following PathPicker's precedent (§ Path picker), because `.modal-body` scrolls
   and a scrolling box clips on both axes. Wrong trade: PathPicker's list is a folder BROWSER the
   reader works in for a while, so the form making room for it is honest; this is a pick-one list open
   for a second, and paying for it with the whole form jumping is worse than the clip it avoided.

   What the clip costs is capped instead. Shorter than the 15rem a floating panel gets on a full page,
   so there is more room under the field before the panel reaches the body's edge — the panel scrolls
   inside itself past six rows either way, and typing filters the list, so a long master is reachable
   without the height. */
.modal-body .combo-panel { max-height: 11rem; }

/* …but NOT the calendar, which floats over the form inside a modal rather than in it.

   `.modal-body` scrolls, and a scrolling box clips on both axes, so an absolutely positioned calendar
   loses whatever hangs past the body edge. `fixed` escapes the clip WITHOUT joining the flow, which
   is the point: the dialog does not grow taller every time a date is clicked, and the form under the
   panel stays exactly where it was.

   All four offsets stay `auto` on purpose. A fixed box with auto offsets is painted at its STATIC
   position — right under the field, where it belongs — so this is already correct before any script
   runs. js/date-picker.js then refines it: flips the panel above the field when the field sits near
   the foot of the viewport, and clamps it inside the edges. With the script missing the panel is
   still in the right place; it just will not follow if the body is scrolled underneath it. */
.modal-body .datepick-panel {
    position: fixed;
    inset: auto;
    max-height: none;
    margin-top: var(--sp-1);
}

/* ---- Path picker (PathPicker.razor) ----------------------------------------
   A path box plus a Browse button, and under it the panel that lists the SERVER's folders. The panel
   is in the flow rather than floating like `.combo-panel`, deliberately: two of the three fields
   using it sit inside a FormModal, and an absolutely positioned list would be clipped by the modal
   body's own scroll. In the flow it pushes the form down instead, which is visible and reachable in
   both places.

   `.path-field` puts the box and the button on one row. The button keeps the standard `.btn` metrics
   so it lines up with everything else in `.report-actions`; `align-items: stretch` is what makes it
   exactly as tall as the input beside it rather than 2px shorter. */
.path-picker { display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0; }
.path-field { display: flex; align-items: stretch; gap: var(--sp-2); min-width: 0; }
.path-field input { flex: 1 1 auto; min-width: 0; }
.path-field .btn { flex: 0 0 auto; white-space: nowrap; }

.path-browser {
    border: 1px solid var(--c-border);
    border-radius: var(--radius-card);
    background: var(--c-surface-raised);
    overflow: hidden;   /* the bar and foot are tinted to the edge; without this they square the corners */
}
/* Where you are, and the two ways out of it. A long path truncates with an ellipsis and keeps the
   whole of itself in `title`; it is not reversed with `direction: rtl` to save the leaf folder,
   because bidi reordering moves the ":" and "\" of a Windows path and prints something that is not
   the path. */
.path-browser-bar {
    display: flex; align-items: center; gap: var(--sp-2);
    padding: var(--sp-2);
    border-bottom: 1px solid var(--c-border);
    background: var(--c-surface);
}
.path-browser-here {
    flex: 1 1 auto; min-width: 0;
    font-family: var(--font-mono); font-size: 0.75rem;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.path-shortcuts {
    display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-2);
    padding: var(--sp-2);
    border-bottom: 1px solid var(--c-border);
}
.path-shortcuts .hint { margin: 0; }
.path-shortcut {
    display: inline-flex; align-items: center; gap: var(--sp-1);
    max-width: 100%; overflow-wrap: anywhere;
    padding: 2px var(--sp-2);
    border: 1px solid var(--c-border); border-radius: 999px;
    background: var(--c-surface-raised); color: var(--c-text);
    font-family: var(--font-mono); font-size: 0.6875rem;
    cursor: pointer;
}
.path-shortcut:hover:not(:disabled) { border-color: var(--c-primary); color: var(--c-primary); }

/* Capped at roughly nine rows: enough to see a folder's shape without the panel pushing the form's
   own Save button off the screen — the rule docs/16 §6 states for a modal footer applies here too. */
.path-browser-list {
    margin: 0; padding: 0; list-style: none;
    max-height: 17rem; overflow-y: auto;
}
.path-entry {
    display: flex; align-items: center; gap: var(--sp-2);
    width: 100%;
    padding: var(--sp-2) var(--sp-3);
    border: 0; background: none; color: var(--c-text);
    font-size: 0.8125rem; text-align: left;
    cursor: pointer;
}
.path-entry:hover:not(:disabled) { background: var(--c-selection); }
.path-entry:disabled { cursor: default; color: var(--c-text-muted); }
.path-entry .icon { flex: 0 0 auto; color: var(--c-text-muted); }
.path-entry-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
/* Size and date are context, not the thing being chosen — muted, monospaced so a column of them
   lines up, and never wrapping onto a second line under the name. */
.path-entry-meta {
    flex: 0 0 auto;
    font-family: var(--font-mono); font-size: 0.6875rem; color: var(--c-text-muted);
    white-space: nowrap;
}

.path-browser-note {
    display: flex; align-items: center; gap: var(--sp-2);
    margin: 0; padding: var(--sp-3);
    font-size: 0.75rem;
}
/* Colour with the words, never instead of them (docs/16 §4) — the sentence already says what failed. */
.path-browser-note.error { color: var(--s-alarm); }

.path-browser-foot {
    display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
    padding: var(--sp-2) var(--sp-3);
    border-top: 1px solid var(--c-border);
    background: var(--c-surface);
}
.path-browser-foot .hint { margin: 0; margin-right: auto; }

/* ---- Toggle switch (Toggle.razor) -----------------------------------------
   Drawn over a real checkbox. The input is not `display: none` — that would remove it from the tab
   order and from the accessibility tree, which is most of what it was kept for. It is collapsed to a
   zero-size transparent box instead, so it still takes focus, still answers the space bar, and still
   gets clicked through the wrapping <label>. */
.toggle {
    display: inline-flex;
    align-items: center;
    gap: var(--sp-2);
    cursor: pointer;
    font-size: 0.8125rem;
    line-height: 1.4;
}
.toggle input {
    position: absolute;
    width: 0; height: 0;
    opacity: 0;
    margin: 0;
}

.toggle-track {
    position: relative;
    flex: none;
    width: 34px; height: 20px;
    border-radius: 999px;
    border: 1px solid var(--c-border);
    /* Off: a grey mixed from the text token rather than picked, so it lands on a dark theme without a
       second rule and stays translucent over whatever surface it sits on. */
    background: color-mix(in srgb, var(--c-text) 25%, transparent);
    transition: background-color var(--motion-fast) var(--motion-ease),
                border-color var(--motion-fast) var(--motion-ease);
}
.toggle-knob {
    position: absolute;
    top: 2px; left: 2px;
    width: 14px; height: 14px;
    border-radius: 50%;
    background: var(--c-surface-raised);
    transition: transform var(--motion-fast) var(--motion-ease),
                background-color var(--motion-fast) var(--motion-ease);
}

/* On: the track takes the brand colour and the knob takes --c-primary-ink, which is DEFINED as the ink
   that reads on --c-primary. That is why the knob is not hard-coded white: on a dark theme the brand
   is a light teal and its ink is near-black, so a white knob would vanish into the track. Letting the
   pair travel together means the knob contrasts in every palette without a per-theme rule. */
.toggle input:checked + .toggle-track {
    background: var(--c-primary);
    border-color: var(--c-primary);
}
.toggle input:checked + .toggle-track .toggle-knob {
    /* 34 − 2 border − 2 inset − 14 knob − 2 inset = 14px of travel. */
    transform: translateX(14px);
    background: var(--c-primary-ink);
}

/* The ring goes on the track, because the input it belongs to is a zero-size box nobody can see. */
.toggle input:focus-visible + .toggle-track {
    outline: var(--focus-width) solid var(--c-focus);
    outline-offset: var(--focus-offset);
}

.toggle.is-disabled { cursor: default; opacity: 0.5; }

/* A switch that commands a GROUP of switches — the All switch on each permission group head — has a
   third state its rows do not: some on, some off. The knob parks half way and the track takes a
   diluted brand colour, so "three of eight" does not read as an empty group.
   Only the drawing changes: the input underneath is genuinely unchecked here, which is what makes
   the first click grant the whole group and the second clear it. It cannot collide with the :checked
   pair above — a complete group is not partial — and would lose anyway, those carrying a pseudo-class
   this selector does not. */
.toggle.is-partial .toggle-track {
    background: color-mix(in srgb, var(--c-primary) 45%, transparent);
    border-color: color-mix(in srgb, var(--c-primary) 45%, var(--c-border));
}
.toggle.is-partial .toggle-knob { transform: translateX(7px); }

.toggle-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.toggle-label { overflow-wrap: anywhere; }
/* The hint is already muted and small by `.hint`; this only stops it inheriting the label's gap twice. */
.toggle-text .hint { margin: 0; }

/* A switch used as a field inside a `.form-grid`. Without this it inherits `.form-grid label`'s
   column direction — specificity (0,1,1) beats a bare `.toggle` — and the track stacks above its own
   label. This replaced `.check-inline`, which did the same job for the raw checkboxes that used to
   sit in these slots. */
.form-grid label.toggle { flex-direction: row; align-items: center; }
/* Fills its grid cell so a run of switches lines their labels up rather than each shrinking to fit. */
.toggle.toggle-block { display: flex; }

/* Centred in a table cell — the per-row channel switches on Notification Setup. */
.data-table td > .toggle { vertical-align: middle; }

/* ---- Permission group (the role access matrix) -----------------------------
   One named block of the catalogue: its heading, how much of it is granted, its two bulk actions, and
   the checkbox grid beneath. The markup was a `<section>` with an inline bottom margin wrapping a
   hand-written flex row, whose `<h3>` spelled out the display face, size and tracking inline and
   claimed a class — `.engraved` — that is defined in no stylesheet.

   The head is STICKY, for the reason `.assign-group-head` is: the matrix runs to a hundred boxes in a
   scrolling modal, and "which group am I in" is the question a reader has to be able to answer at the
   row under the pointer, not by scrolling back up. */
.perm-group + .perm-group { margin-top: var(--sp-4); }

.perm-group-head {
    position: sticky; top: 0; z-index: 1;
    display: flex; align-items: center; gap: var(--sp-3);
    padding: var(--sp-2) 0;
    margin-bottom: var(--sp-2);
    background: var(--c-surface-raised);   /* the modal body it sits on, so rows pass behind it */
    border-bottom: 1px solid var(--c-border);
}
.perm-group-title {
    margin: 0;
    font-family: var(--font-display); font-size: 0.75rem; font-weight: 600;
    letter-spacing: 0.06em; text-transform: uppercase;
}
/* n/total for the group, mono and tabular so the counts line up down the modal — the same answer
   `.assign-cat-count` gives in the event picker, and the reason that rail is scannable. */
.perm-group-count {
    font-family: var(--font-mono); font-size: 0.6875rem;
    font-variant-numeric: tabular-nums;
    color: var(--c-text-muted);
}
/* A group with something granted is what an auditor scans for, so it takes the accent; an empty group
   stays muted rather than turning a colour of its own. */
.perm-group.has-some .perm-group-count { color: var(--c-primary); }

.perm-group-actions { margin-left: auto; display: flex; align-items: center; gap: var(--sp-2); }

.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--sp-2); }
/* What a picker is telling the reader about rather than asking them to decide — the chambers a user's
   GROUP already grants, above the switches for the ones it does not (Manage Users → Chambers).

   Stated as codes, not as rows: a site with forty chambers on one group would push the switches off
   the card if each got a line, and the code is what a reader scans a chamber list for anyway. The name
   is on the chip's `title`.

   Not sticky and not scrollable — it sits above `.picker-toolbar`, is read once on opening, and a
   second scrolling rail inside a modal body that already scrolls is how a reader loses their place. */
.granted-block { margin-bottom: var(--sp-3); }
.granted-block .hint { display: block; }
.code-chips { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: var(--sp-1) 0; }
/* A `.path-shortcut` that does nothing: same pill, same mono type, no hover and no pointer, because
   this one is a fact and not a control. `default` rather than the inherited text cursor for the same
   reason — an I-beam over a pill invites a selection drag nobody wants. */
.code-chip {
    display: inline-flex; align-items: center;
    padding: 1px var(--sp-2);
    border: 1px solid var(--c-border); border-radius: 999px;
    background: var(--c-surface); color: var(--c-text-muted);
    font-family: var(--font-mono); font-size: 0.6875rem;
    cursor: default;
}

/* The filter and bulk actions over a picker grid — the chamber pickers on Users and Groups.
   Sticky, for the reason the permission group heads are: the grid below scrolls to forty-odd rows in a
   modal, and the filter box and the bulk control beside it are needed most at the bottom of it. As an
   ordinary `.filter-actions` row they scrolled away with the first screenful, so narrowing a long list
   meant scrolling back up to reach the box that narrows it.

   The bulk control differs by page: Users holds one all/none switch, in the vocabulary of the switches
   it acts on; Groups still holds the older Select/Clear button pair. Both are flex children here.

   z-index 2, above the switches: `.toggle-track` is `position: relative`, and two positioned boxes at
   `z-index: auto` paint in DOM order — the rows would slide over the toolbar, which is the same fault
   the sticky table headings had. */
.picker-toolbar {
    position: sticky; top: 0; z-index: 2;
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
    padding: var(--sp-2) 0;
    margin-bottom: var(--sp-3);
    background: var(--c-surface-raised);
    border-bottom: 1px solid var(--c-border);
}
/* Pushed to the right of the row: how much of the list the filter is currently showing. */
.picker-toolbar .hint { margin-left: auto; }

/* The same grid holding switches instead of checkboxes. A `.toggle-track` is 34px against a checkbox's
   ~13px, so roughly 20px of every cell moves from the label to the control; without a wider track the
   longer labels — and every label carrying a hint — wrap a line earlier than they used to. 260px buys
   the difference back and still fits three columns in the `xl` modal this is read in. */
.perm-grid.toggles { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
/* The row is the hit target, so the switch keeps its own alignment at the top of a two-line label
   rather than centring against the block and floating beside the gap. */
.perm-grid.toggles .toggle { align-items: flex-start; }
.perm-grid.toggles .toggle-track { margin-top: 1px; }
.perm-grid label { display: flex; gap: var(--sp-2); align-items: center; font-size: 0.8125rem; }
/* The same checkbox row, stacked instead of gridded — for a short list whose labels are sentences
   rather than names ("Users may pick any enabled theme in their profile"). In `.perm-grid` a sentence
   gets a 220px cell and wraps to three lines beside a one-line neighbour. */
.check-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.check-list label { display: flex; gap: var(--sp-2); align-items: center; font-size: 0.8125rem; }
/* A standing notice that belongs WITH the content it qualifies — "this restore overwrites live
   data", "you cannot edit a preset theme". Outcomes of an action are toasts (below), not these:
   a banner that appears and disappears reflowed the page under the button just clicked.

   The top margin is the rule two pages were writing inline: a notice that qualifies the control above
   it must not butt against it. It collapses against a preceding block's bottom margin, so a banner at
   the top of a flow is unaffected. */
.status-msg { padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-control); font-size: 0.8125rem; margin: var(--sp-3) 0; }
.status-msg.ok { color: var(--s-normal); border: 1px solid var(--s-normal); background: color-mix(in srgb, var(--s-normal) 8%, transparent); }
.status-msg.err { color: var(--s-alarm); border: 1px solid var(--s-alarm); background: color-mix(in srgb, var(--s-alarm) 8%, transparent); }
/* A consequence stated BEFORE the fact — the site-zone change in the Settings save confirmation. Alert,
   not alarm: nothing has gone wrong, and colouring a heads-up like a failure is how a real failure stops
   being noticed. Flex so the glyph sits beside a wrapping sentence rather than only beside its first line. */
.status-msg.warn {
    display: flex; align-items: flex-start; gap: var(--sp-2);
    color: var(--s-alert); border: 1px solid var(--s-alert);
    background: color-mix(in srgb, var(--s-alert) 8%, transparent);
}
.status-msg.warn .icon { margin-top: 0.15em; }
/* A notice that names several things — the token pairs a contrast gate rejected. The list was an
   inline margin on every `<ul>` that needed one. */
.status-msg ul { margin: var(--sp-2) 0 0 var(--sp-4); }

/* ---- Toasts ---------------------------------------------------------------
   Where a screen reports the outcome of an action: created, updated, rejected, failed.
   Top-right, under the 56px topbar so a notice never covers the account menu or the clock. The
   container is always present and never intercepts the pointer; only the cards themselves do, so
   the empty stack cannot swallow clicks on the content beneath it. */
.toast-stack {
    position: fixed;
    top: calc(56px + var(--sp-3));
    right: var(--sp-4);
    z-index: 960;             /* above the content canvas and the account menu at 40, ABOVE the modal
                                 layer at 950, and below #blazor-error-ui and the reconnect modal at
                                 1000.

                                 This used to be 900, deliberately below the modal: a toast from the
                                 previous action must not sit on top of the dialog asking about the
                                 next one. That held while the only modal was ConfirmDialog, which
                                 raises no toasts of its own. Forms now open in a modal too, and a form
                                 reports "Enter a product name" through this stack — at 900 that
                                 message rendered behind the backdrop, dimmed by its 45% black, telling
                                 the one reader who needs it nothing. A rejected save that appears to
                                 do nothing is worse than a stale toast overlapping a dialog, and the
                                 stale case fixes itself: toasts expire, and this one is dismissible. */
    display: flex;
    flex-direction: column;
    gap: var(--sp-2);
    width: min(400px, calc(100vw - var(--sp-8)));
    pointer-events: none;
}

/* Two animations on one declaration: the card fades in immediately, then the fade-out fires after
   --toast-life (set per card from the server's lifetime) and `forwards` holds it hidden until the
   service removes it. That is what lets a statically rendered screen — My Profile, which posts a
   form and has no circuit — dismiss its own toast with no JavaScript at all.

   Motion follows docs/16 §7: the dialog's fade + 2px rise at 200ms on the house easing curve, not
   a slide, so a toast reads as the same kind of surface as everything else that appears. */
.toast {
    pointer-events: auto;
    display: flex;
    align-items: flex-start;
    gap: var(--sp-2);
    padding: var(--sp-3);
    border: 1px solid var(--c-border);
    border-left: 3px solid var(--s-info);
    border-radius: var(--radius-card);
    background: var(--c-surface-raised);
    color: var(--c-text);
    font-size: 0.8125rem;
    line-height: 1.4;
    box-shadow: 0 6px 20px rgb(0 0 0 / 0.14);
    animation: toast-in 200ms cubic-bezier(0.2, 0, 0, 1),
               toast-out 200ms cubic-bezier(0.2, 0, 0, 1) forwards;
    animation-delay: 0ms, var(--toast-life, 5000ms);
}

.toast-icon { flex: 0 0 auto; display: flex; padding-top: 1px; color: var(--s-info); }
.toast-text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }

.toast-close {
    flex: 0 0 auto;
    display: flex;
    padding: 2px;
    border: 0;
    border-radius: var(--radius-control);
    background: none;
    color: var(--c-text-muted);
    cursor: pointer;
}
.toast-close:hover { background: var(--c-selection); color: var(--c-text); }

.toast-success { border-left-color: var(--s-normal); }
.toast-success .toast-icon { color: var(--s-normal); }
.toast-error { border-left-color: var(--s-alarm); }
.toast-error .toast-icon { color: var(--s-alarm); }
.toast-warning { border-left-color: var(--s-alert); }
.toast-warning .toast-icon { color: var(--s-alert); }
.toast-info { border-left-color: var(--s-info); }

/* Modal confirmation (ConfirmDialog.razor).

   z-index 950: above the toast stack at 900 — a toast from the previous action must not sit on top of
   the dialog asking about the next one — and below #blazor-error-ui and the reconnect modal at 1000,
   which report that the circuit itself is gone and outrank any question this dialog could be asking. */
.modal-backdrop {
    position: fixed;
    inset: 0;
    z-index: 950;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--sp-4);
    background: rgb(0 0 0 / 0.45);
    animation: modal-fade 200ms cubic-bezier(0.2, 0, 0, 1);
}

.modal-card {
    display: flex;
    flex-direction: column;
    /* min() so the card never exceeds the viewport on a tablet held portrait. */
    width: min(560px, 100%);
    max-height: min(80vh, 100%);
    border: 1px solid var(--c-border);
    border-radius: var(--radius-card);
    background: var(--c-surface-raised);
    color: var(--c-text);
    box-shadow: 0 16px 48px rgb(0 0 0 / 0.28);
    animation: modal-rise 200ms cubic-bezier(0.2, 0, 0, 1);
    /* The card owns focus while open, but it is not itself a control — no ring on the container. */
    outline: none;
    /* Clips the head's and foot's tinted bands to the rounded corners; without it they square off the
       top and bottom of the card. Same reason `.settings-card` carries it. */
    overflow: hidden;
    /* Load-bearing, and not obvious. `text-align` is INHERITED, and `position: fixed` on the backdrop
       takes the dialog out of flow without taking it out of the cascade — it still inherits from
       whatever DOM node it was rendered inside. ActiveToggle mounts its dialog in the row it belongs
       to, and that cell is `text-align: right` (see `td:has(> .row-actions)` further up), so the
       confirmation text set flush right. A dialog's prose alignment is a property of the dialog, not
       of the place it was opened from, so the card states it rather than trusting its ancestry.
       `left`, not `start`: this app has no RTL build, and the surrounding rules all say `left`. */
    text-align: left;
}
/* A 3px top rule in alarm red rather than a red card: docs/16 §5.1's rule against flood-filling a
   surface with a status colour applies to a dialog as much as to a chamber card. */
.modal-card.danger { border-top: 3px solid var(--s-alarm); }

/* The same tinted band as a card head and a grid's column headings — a dialog is the third titled
   surface and had been the one with no band at all, since the card beneath it is already white. */
.modal-head {
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    padding: var(--sp-4);
    background: var(--c-surface);
    border-bottom: 1px solid var(--c-border);
}
/* `margin: 0` is load-bearing. This is an <h2>, and there is no global heading reset in this app —
   html/body are the only elements zeroed, and every other heading class states its own margins
   (`.settings-card-title` sets 0, `.detail-heading` sets a real pair). Without it the UA's
   `0.83em 0` survives, which at 1rem is ~13px above and below the title: the top margin pushed the
   text down until it no longer sat level with the header's icon tile, and the bottom margin threw the
   subtitle onto a line of its own about 13px too far down. Both were one missing declaration. */
.modal-title { margin: 0; font-family: var(--font-display); font-size: 1rem; font-weight: 600; }
.modal-card.danger .modal-head { color: var(--s-alarm); }

/* The only scrolling part, so the question and its buttons stay put around a long change list — the
   same anatomy as a settings card, for the same reason: a confirm button below the fold is not one. */
.modal-body { padding: var(--sp-4); overflow-y: auto; font-size: 0.8125rem; line-height: 1.5; }
.modal-message { margin: 0 0 var(--sp-3); }
.modal-body > :last-child { margin-bottom: 0; }

.modal-foot {
    display: flex;
    justify-content: flex-end;
    gap: var(--sp-2);
    padding: var(--sp-4);
    background: var(--c-surface);
    border-top: 1px solid var(--c-border);
}

/* The change list a save is confirming: label, old value, new value, one row each. */
.modal-changes { display: flex; flex-direction: column; gap: var(--sp-2); margin: 0; }
/* Flex-wrap, not a two-column grid. With `1fr auto` the value column won every tie, and a long value —
   a time zone's display name is fifty-odd characters — starved the label until "Panel time zone" set as
   three stacked words beside it. Wrapping instead drops the values onto their own line when they do not
   fit, which keeps the label readable and is what the reader needs: the name identifies the row, the
   before/after is what they are checking. */
.modal-change {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--sp-1) var(--sp-3);
    padding-bottom: var(--sp-2);
    border-bottom: 1px solid var(--c-border);
}
.modal-changes .modal-change:last-child { padding-bottom: 0; border-bottom: 0; }
.modal-change-name { font-weight: 600; }
/* Mono and tabular, like every other value in the app: "25.0" and "26.0" have to line up for the
   difference between them to be the thing you notice.
   `flex-basis: auto` with `min-width: 0` lets a long value wrap inside itself once it has its own line,
   rather than forcing the row wider than the card. */
.modal-change-values {
    font-family: var(--font-mono); font-variant-numeric: tabular-nums;
    text-align: right; min-width: 0; overflow-wrap: anywhere;
}
.modal-change-old { color: var(--c-text-muted); text-decoration: line-through; }
.modal-change-new { color: var(--c-text); }
.modal-change-arrow { color: var(--c-text-muted); padding: 0 var(--sp-1); }
/* "(not set)" and "•••• replaced" are prose, not values — they must not read as a stored string. */
.modal-change-empty { font-family: var(--font-body); font-style: italic; color: var(--c-text-muted); }

/* A heading over one list inside a confirmation that carries more than one — the Company save shows
   what is changing AND what is being fixed permanently, and an unlabelled second list of values reads
   as more edits. Sized and spaced like the small caps headings the settings cards use rather than as a
   heading in its own right: it labels a list, it does not open a section. */
.modal-section-title {
    display: flex; align-items: center; gap: var(--sp-2);
    margin: var(--sp-4) 0 var(--sp-2);
    font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
    color: var(--c-text-muted);
}
.modal-body > .modal-section-title:first-child { margin-top: 0; }
/* A value that is NOT changing, shown so the reader can check it before it stops being editable.
   Plain ink, no strike-through and no arrow: `.modal-change-old` means "replaced", which is the
   opposite of what this row says. */
.modal-change-fixed { color: var(--c-text); }

/* ---- Form modal (FormModal.razor) -----------------------------------------
   The same card as the confirmation above, carrying a form instead of a question. Everything here is
   additive: ConfirmDialog uses none of these classes and is unchanged by them.

   Width is a size class rather than a free parameter, so two screens asking for "a bit wider" cannot
   drift into two nearly-equal widths. One field wants `sm`; a two-column form wants `md`; a form with
   its own table or a permission matrix wants `lg`/`xl`. Each still collapses to the viewport on a
   tablet, and `max-height` is taller than the confirmation's 80vh because a form has more to show and
   its own scrolling body to show it in. */
.form-modal { max-height: min(88vh, 100%); }
.form-modal.modal-sm { width: min(480px, 100%); }
.form-modal.modal-md { width: min(640px, 100%); }
.form-modal.modal-lg { width: min(860px, 100%); }
.form-modal.modal-xl { width: min(1040px, 100%); }

/* The header is a row of four parts: tile, text, optional actions, close.
   Centred by default — with a title alone, a top-aligned 32px tile beside a ~21px line of text reads
   as if the tile has slipped. */
.form-modal .modal-head { align-items: center; gap: var(--sp-3); }

/* A subtitle turns the middle into a two-line block, and centring against it floats the tile in the
   gap between the two lines, level with neither. Top alignment pairs it with the title, which is what
   it labels. `:has()` rather than a class the caller has to remember to pass — the stylesheet can see
   the subtitle for itself, and `.content-inner:has(> .fill-canvas)` above sets the precedent. */
.form-modal .modal-head:has(.modal-subtitle) { align-items: flex-start; }

/* A tinted square rather than a bare glyph. At 18px an icon beside 1rem display text reads as a stray
   mark; a 32px tile gives it a shape of its own and makes "what kind of form is this" legible before
   the title is read. Primary at 10% — the same recipe as .status-msg's backgrounds, so it tints
   correctly on every theme instead of needing a per-palette colour. */
.modal-head-icon {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    border-radius: var(--radius-control);
    background: color-mix(in srgb, var(--c-primary) 10%, transparent);
    color: var(--c-primary);
}
/* A destructive form's tile follows the card's red top rule rather than staying brand-coloured. */
.form-modal.danger .modal-head-icon {
    background: color-mix(in srgb, var(--s-alarm) 10%, transparent);
    color: var(--s-alarm);
}

/* min-width:0 so a long title wraps inside the header instead of shoving the close button off it. */
.modal-head-text { flex: 1 1 auto; min-width: 0; }
.form-modal .modal-title { line-height: 1.3; overflow-wrap: anywhere; }
/* The one line saying what the form is for. Muted and smaller: it must not compete with the title,
   and a reader who already knows why they opened the modal should be able to skip it. */
.modal-subtitle {
    margin: 2px 0 0;
    font-family: var(--font-body);
    font-size: 0.75rem;
    font-weight: 400;
    line-height: 1.4;
    color: var(--c-text-muted);
}

.modal-head-actions { flex: 0 0 auto; display: flex; align-items: center; gap: var(--sp-2); }

/* Last in the row, always, so its position never moves between one modal and the next — a hand that
   learns where the ✕ is should not have to re-find it on the next screen.
   Sized to the 32px tile opposite it so the header reads as balanced rather than top-heavy on one end. */
.modal-close {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    padding: 0;
    border: 1px solid transparent;
    border-radius: var(--radius-control);
    background: none;
    color: var(--c-text-muted);
    cursor: pointer;
}
.modal-close:hover:not(:disabled) {
    background: var(--c-selection);
    border-color: var(--c-border);
    color: var(--c-text);
}
.modal-close:disabled { opacity: 0.5; cursor: default; }

/* A form is denser than a confirmation's paragraph, so the body gets the page's own text size back
   and a little more room at the sides than the header's padding implies it would need. */
.form-modal .modal-body { padding: var(--sp-4); font-size: 0.8125rem; }
/* The 720px reading measure is the CARD's job here: the modal is already narrow by construction, and
   leaving the measure on would strand a second column of white space inside an `lg` card. */
.form-modal .modal-body .form-grid { max-width: none; }
/* A one-field form should not be a two-column grid with an empty right half. */
.form-modal.modal-sm .modal-body .form-grid { grid-template-columns: 1fr; }
/* `.form-actions` inside a modal body is NOT hidden, deliberately. The tempting rule — "the footer is
   the commit row, so suppress any other one" — would have silently swallowed the four independent Save
   buttons in the chamber editor, where details, setpoints, sensor counts and the photo are four
   separate writes with four separate audit entries. A commit button that a stylesheet makes invisible
   is the worst failure this file could cause, so a modal that keeps buttons in its body says so in its
   own markup instead. */
/* A `.panel` nested in the body would draw a second card inside the card. Flatten it: the modal is
   already the container, so the panel keeps only its spacing. */
.form-modal .modal-body > .panel { margin: 0; border: 0; padding: 0; background: none; }
/* A hint directly under a field belongs to it — the default paragraph margin reads as a gap between
   two unrelated blocks. */
.form-modal .modal-body .hint { margin-top: var(--sp-1); }
/* A table inside a form — the chambers in a group, a role's assigned users — scrolls in its own box
   rather than stretching the card. */
.form-modal .modal-body .table-viewport { max-height: 40vh; }

/* The notification event picker inside a modal. `.assign-split` is `flex: 1 1 auto` with `min-height:0`
   — it fills whatever height its parent gives it, which was `.assign-card`'s fixed 36rem. In a modal
   body there is no such parent: the body sizes to content, the split collapses to its intrinsic height
   and the two rails stop scrolling, so the whole list lands in the body's own scrollbar and the
   category rail scrolls away with it.
   Giving the split a fixed slice of the viewport restores exactly what the 36rem was doing — the rail
   and the rows each keep their own scrollbar, and the modal's footer stays put. The border and radius
   come with it, because inside the body it is a bordered box rather than a card's flush middle. */
.assign-modal-split {
    height: min(58vh, 34rem);
    border: 1px solid var(--c-border);
    border-radius: var(--radius-card);
    overflow: hidden;         /* clips the rail's own border to the rounded corner */
}
/* Stacked below 640px the rail sits above the rows and both need more room — the same reasoning as
   `.assign-card`'s 44rem at this width. */
@media (max-width: 640px) {
    .assign-modal-split { height: min(70vh, 40rem); }
}

/* Hint left, buttons right. `margin-right: auto` on the note rather than `space-between` on the row,
   so the buttons stay pinned right whether or not a note is present — otherwise a footer with no note
   would spread its two buttons across the full width. */
.form-modal .modal-foot { flex-wrap: wrap; align-items: center; }
.modal-foot-info {
    margin-right: auto;
    max-width: 60%;
    font-size: 0.75rem;
    line-height: 1.4;
    color: var(--c-text-muted);
}

/* Under ~640px the card is effectively full-width, so the two-column form grid has no room for two
   columns and the footer buttons are easier to hit stretched. */
@media (max-width: 640px) {
    .form-modal .modal-body .form-grid { grid-template-columns: 1fr; }
    .form-modal .modal-foot { justify-content: stretch; }
    .form-modal .modal-foot .btn { flex: 1 1 auto; justify-content: center; }
    .modal-foot-info { max-width: none; margin-bottom: var(--sp-2); }
}

/* The modal body is the fifth scrolling surface in the app and gets the same quiet 8px bar as the
   other four, rather than the OS default appearing for the first time inside a dialog. */
.modal-body::-webkit-scrollbar { width: 8px; height: 8px; }
.modal-body::-webkit-scrollbar-track { background: transparent; }
.modal-body::-webkit-scrollbar-thumb {
    background: color-mix(in srgb, var(--c-text) 14%, transparent);
    border-radius: 999px;
    border: 1px solid transparent;
    background-clip: padding-box;
}
.modal-body::-webkit-scrollbar-thumb:hover {
    background: color-mix(in srgb, var(--c-text) 34%, transparent);
    background-clip: padding-box;
}
@supports not selector(::-webkit-scrollbar) {
    .modal-body {
        scrollbar-width: thin;
        scrollbar-color: color-mix(in srgb, var(--c-text) 14%, transparent) transparent;
    }
}

@keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-rise {
    from { opacity: 0; transform: translateY(2px); }
    to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
    .modal-backdrop, .modal-card { animation: none; }
}

@keyframes toast-in {
    from { opacity: 0; transform: translateY(2px); }
    to { opacity: 1; transform: none; }
}
@keyframes toast-out {
    from { opacity: 1; visibility: visible; }
    to { opacity: 0; visibility: hidden; }
}

/* docs/16 §7 honors prefers-reduced-motion globally, but an outcome that never clears is worse than
   one that moves 2px. Only the rise goes; the timed fade stays, delays and all. */
@media (prefers-reduced-motion: reduce) {
    .toast { animation-name: none, toast-out; }
}

/* Workflow state badges (docs/16 §5.4) */
.wf-badge { font-family: var(--font-display); font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.04em; padding: 2px var(--sp-2); border-radius: var(--radius-control); border: 1px solid var(--s-offline); color: var(--s-offline); }
.wf-badge.wf-generated { border-color: var(--s-info); color: var(--s-info); }
.wf-badge.wf-inreview { border-color: var(--s-alert); color: var(--s-alert); }
.wf-badge.wf-reviewed { border-color: var(--s-info); color: var(--s-info); }
.wf-badge.wf-approved { border-color: var(--s-normal); color: var(--s-normal); }
.wf-badge.wf-rejected { border-color: var(--s-alarm); color: var(--s-alarm); }
/* Chamber status reuses the same badge anatomy (docs/16 §5.4); Layer-3 colours, unthemeable. */
.wf-badge.wf-normal { border-color: var(--s-normal); color: var(--s-normal); }
.wf-badge.wf-alert { border-color: var(--s-alert); color: var(--s-alert); }
.wf-badge.wf-alarm { border-color: var(--s-alarm); color: var(--s-alarm); }
.wf-badge.wf-offline { border-color: var(--s-offline); color: var(--s-offline); }

/* The general-purpose surface: forms, filter bars, grouped settings (docs/16 §10.3 `ProDaqCard`).
   Was `.sign-panel`, which read as an e-signature control on twenty screens that sign nothing —
   a misleading name for a regulated term. `.sign-ceremony` below is the real one. */
.panel { margin-top: var(--sp-6); padding: var(--sp-4); border: 1px solid var(--c-border); border-radius: var(--radius-card); background: var(--c-surface); }
/* Sits flush under a page header or another panel — replaces a repeated inline style. */
.panel.flush { margin-top: 0; }
/* A panel with something directly beneath it — a filter card above its list, one of a run of detail
   panels. `.panel` carries a top margin only, so eight places were writing this bottom one as an
   inline style. */
.panel.spaced { margin-bottom: var(--sp-4); }

/* docs/16 §10.3 `FilterBar` / §5.5 "filters live in a single toolbar row above, never a sidebar
   of accordions". Combine with .panel: <div class="panel flush filter-bar">. */
.filter-bar { display: flex; flex-wrap: wrap; gap: var(--sp-4); align-items: center; margin-bottom: var(--sp-4); }
.filter-bar select, .filter-bar input[type="text"], .filter-bar input[type="date"] {
    padding: var(--sp-1) var(--sp-2); border: 1px solid var(--c-border);
    border-radius: var(--radius-control); background-color: var(--c-surface-raised); color: var(--c-text);
}

/* docs/16 §10.3 `EmptyState` — a zero-row grid must explain itself, not render blank. */
.empty-state { color: var(--c-text-muted); padding: var(--sp-6) 0; }
.chamber-photo { width: 100%; max-height: 120px; object-fit: cover; border-radius: var(--radius-card); margin: var(--sp-2) 0; }

/* Sortable column headers — a button styled as the header text itself. */
.col-sort { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-align: left; }
.col-sort:hover { color: var(--c-primary); }

/* `.check-inline` was here — the row-shaped label for a raw checkbox in a `.form-grid`. Every one of
   its call sites is now a `Toggle`, which brings its own row layout, so the rule had no markup left to
   style. `.form-grid label.toggle` above is its replacement. */

.detail-heading { font-family: var(--font-display); font-size: 0.9375rem; margin: var(--sp-4) 0 var(--sp-2); }

/* Review & Approval "viewed by reviewer/approver" flags (docs/06 §3). */
.viewed-cell { white-space: nowrap; }
.viewed-flag {
    display: inline-block; width: 1.25rem; text-align: center;
    font-family: var(--font-display); font-size: 0.6875rem; font-weight: 600;
    border: 1px solid var(--s-offline); border-radius: var(--radius-control);
    color: var(--s-offline); opacity: 0.5;
}
.viewed-flag.on { border-color: var(--s-normal); color: var(--s-normal); opacity: 1; }

/* E-signature ceremony (docs/06 §2): identity, meaning and attestation shown before the password. */
.sign-ceremony { margin-top: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--c-primary); border-radius: var(--radius-card); background: var(--c-surface-raised); }
.ceremony-facts { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-1) var(--sp-4); margin: 0; font-size: 0.8125rem; }
.ceremony-facts dt { color: var(--c-text-muted); }
.ceremony-facts dd { margin: 0; }
.attestation { margin-top: var(--sp-3); font-size: 0.8125rem; border-left: 2px solid var(--c-primary); padding-left: var(--sp-3); }

/* Confirmation step for an operation worth stopping over (docs/16 §6: destructive actions confirm with
   the entity name typed). Same anatomy as the signature ceremony — facts, then what will happen, then
   the commit button — so "read this before you click" looks the same wherever it appears. `.danger`
   is the variant for an irreversible action and reuses `.ceremony-facts`. */
.confirm-ceremony { margin-top: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--c-primary); border-radius: var(--radius-card); background: var(--c-surface-raised); }
.confirm-ceremony.danger { border-color: var(--s-alarm); border-width: 2px; background: color-mix(in srgb, var(--s-alarm) 5%, var(--c-surface-raised)); }
.confirm-title { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-display); font-weight: 600; font-size: 0.9375rem; margin: 0 0 var(--sp-3); }
.confirm-ceremony.danger .confirm-title { color: var(--s-alarm); }
.confirm-consequences { margin: var(--sp-3) 0 0; padding-left: var(--sp-4); font-size: 0.8125rem; }
.confirm-consequences li { margin-bottom: var(--sp-1); }
.confirm-ceremony.danger .confirm-consequences { border-left: 2px solid var(--s-alarm); padding-left: var(--sp-4); margin-left: 0; list-style-position: inside; }

.field-label { font-size: 0.8125rem; color: var(--c-text-muted); font-family: var(--font-display); }

/* Three action rows with the same anatomy — buttons, then a hint that wraps rather than pushing them.
   `.report-actions` is one row per report on the Compliance screen; `.filter-actions` is the row
   beneath a filter grid (Review & Approval, the report generator); `.form-actions` is the commit row
   under an admin form — save/create beside its cancel. Kept as separate names because they are
   separate things: only one of them will move if any ever needs to. */
.report-actions, .filter-actions, .form-actions { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; margin-top: var(--sp-3); }
.report-actions-label { font-family: var(--font-display); font-size: 0.8125rem; min-width: 8rem; }
.inline-field { display: flex; align-items: center; gap: var(--sp-2); font-size: 0.8125rem; }
/* `select` as well as `input`: the theme pickers on this pattern (Themes' logo and version history,
   the dashboard's department filter) were unstyled native drop-downs sitting beside styled boxes. */
.inline-field input, .inline-field select { padding: var(--sp-1) var(--sp-2); border: 1px solid var(--c-border); border-radius: var(--radius-control); background-color: var(--c-surface-raised); color: var(--c-text); }
/* The chevron's clearance at the two toolbar densities — see the `select` rule above for why this
   cannot be stated there. After BOTH rules, so it survives either `padding` shorthand. */
.filter-bar select, .inline-field select { padding-right: var(--sp-6); }

/* A bare input sitting in an action row rather than under a `.form-grid` label — the chamber filters
   on Groups and Users. Same box as a `.form-grid` input; it just has no label above it, so it cannot
   inherit one. Wide enough that a chamber code plus a word of its name is visible while typing. */
.input-inline {
    min-width: 240px;
    padding: var(--sp-2) var(--sp-3);
    border: 1px solid var(--c-border); border-radius: var(--radius-control);
    background: var(--c-surface-raised); color: var(--c-text);
}
/* A step away from the card it sits in, so it reads as the result the buttons above produced rather
   than as more of the card. That used to mean --c-surface-raised against a --c-surface card body;
   `.settings-card` is white now, so the step goes the other way to keep the same distinction.

   A flex COLUMN, so the count line above the grid holds still and the grid does the scrolling — see
   the `:has()` rule below for the height it scrolls within. */
.preview-panel { margin-top: var(--sp-4); padding: var(--sp-3); border: 1px solid var(--c-border); border-radius: var(--radius-card); background: var(--c-surface); display: flex; flex-direction: column; min-height: 0; }

/* The GRID inside the preview scrolls, not the panel around it.
   The panel used to be the scroll host itself (`max-height: 26rem; overflow: auto`), which cost two
   things: the "N record(s) in range" line lives in that panel, so it scrolled away with the very rows
   it was counting; and the table's `position: sticky` headings had a host whose background is the same
   `--c-surface` they are, so there was no band and nothing to separate them from the data.

   The budget was then a flat 26rem on the grid, which is what a preview of a hundred rows now
   outgrows: on a tall screen it left the bottom third of the canvas empty and scrolled the rows in a
   letterbox anyway. Inside `.fill-canvas` the panel takes the height left over instead, and the rows
   scroll within it — the behaviour every other long list on the site already has (Audit Trail,
   Alarms, Event Log, the admin lists). `.table-viewport` supplies the `flex: 1 1 auto; min-height: 0;
   overflow: auto` that does it, so nothing here has to restate them.

   `:has()`, so ONLY a panel holding a grid claims the canvas: the same panel renders "Loading
   preview" and "No records in the selected range" as one line of text, and a one-line box stretched
   to the full height of the screen reads as a failure to draw something. A browser without `:has()`
   matches neither rule and the panel sizes to its content, flowing and scrolling with the page — the
   behaviour before this rule existed, which is the same fallback `.fill-canvas` itself takes. */
.preview-panel:has(> .table-viewport) { flex: 1 1 auto; }

/* Trend chart (docs/16 §5.2) — the SVG is server-rendered; JS adds the crosshair and the navigation
   gestures (scroll to zoom, drag to pan, Shift+drag to select a range).
   The chart draws its own white paper and frame, so the shell is a thin mount rather than a padded
   card — two nested borders read as a mistake. `color` is the chart's own ink, not the theme's: the
   crosshair sits on white paper in every theme (see --c-chart-ink). */
.chart-shell { position: relative; margin-top: var(--sp-3); padding: var(--sp-1); border: 1px solid var(--c-border); border-radius: var(--radius-card); background: var(--c-surface-raised); color: var(--c-chart-ink); }
/* The default cursor advertises the primary gesture: the chart is a sheet of paper you drag. */
.chart-shell .trend-chart { cursor: grab; user-select: none; display: block; }
.chart-shell.is-panning .trend-chart { cursor: grabbing; }
.chart-shell.is-selecting .trend-chart { cursor: crosshair; }
/* A gesture has moved the marks but the server has not re-rendered the axes yet, so the picture is
   provisional: dimmed to say so, and the crosshair readout stays hidden until it is honest again. */
.chart-shell.is-adjusting .trend-chart { opacity: 0.72; }
.trend-tooltip {
    position: absolute; z-index: 5; pointer-events: none;
    background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-control);
    padding: var(--sp-2); font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.5;
    box-shadow: 0 2px 8px rgb(0 0 0 / 0.15); white-space: nowrap;
}
.trend-tooltip-row { display: flex; align-items: center; gap: var(--sp-1); }
.trend-swatch { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }

/* Per-series summary above the trend (docs/05 R10). Deliberately the same stat-card anatomy the PDF
   prints — the screen and the controlled document it exports to should be read the same way, and the
   figures are what gets signed. The swatch ties each card to its trace so a reader never has to count
   legend entries to work out which line is which. */
.series-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: var(--sp-3); margin-top: var(--sp-2); }
.series-card { border: 1px solid var(--c-border); border-radius: var(--radius-card); background: var(--c-surface-raised); overflow: hidden; }
.series-card-head { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--c-surface); border-bottom: 1px solid var(--c-border); }
.series-swatch { width: 10px; height: 10px; border-radius: 2px; flex: none; }
.series-name { font-family: var(--font-display); font-size: 0.8125rem; font-weight: 600; }
.series-card-head .wf-badge { margin-left: auto; }
.series-stats { display: flex; padding: var(--sp-3) var(--sp-1); }
.series-stat { flex: 1; text-align: center; }
.series-stat + .series-stat { border-left: 1px solid var(--c-border); }
.series-stat-label { display: block; font-size: 0.625rem; letter-spacing: 0.08em; color: var(--c-text-muted); }
.series-stat-value { display: block; font-family: var(--font-mono); font-size: 1.125rem; font-weight: 600; color: var(--c-primary); }
.series-limit { padding: 0 var(--sp-3) var(--sp-2); font-size: 0.6875rem; color: var(--c-text-muted); }
.series-empty { padding: var(--sp-4); text-align: center; font-size: 0.8125rem; font-style: italic; color: var(--c-text-muted); }

/* Shared in-app PDF viewer (docs/05 §7). */
.pdf-frame { display: block; width: 100%; height: calc(100vh - 220px); min-height: 480px; border: 1px solid var(--c-border); border-radius: var(--radius-card); background: var(--c-surface); }

/* ---- Titled card grid -----------------------------------------------------
   Peer cards side by side, each sizing to its own content — `align-items: start` stops a two-row card
   being stretched to the height of a five-row one beside it. Used by ManageGeography for its pair of
   equal-weight lists. (The Settings screen was the original caller; it is `.settings-columns` below
   now, which is a rail and two panes rather than a grid of peers.) */
.settings-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
    gap: var(--sp-4);
    align-items: start;
}

/* The same grid inside a `.fill-canvas`, where it is the page's scrolling half rather than a block in
   a flow. Three changes work together and none is useful alone:
     `flex: 1 1 auto` + `min-height: 0`  — take the height the header leaves, and be allowed to be
                                           shorter than the content so the cards can scroll inside it;
     `align-items: stretch`              — overrides the `start` above, which sizes each card to its
                                           own rows and is exactly what leaves a short card floating
                                           with dead space beneath it;
     the card as a flex column           — so its head stays put and only `.settings-card-rows` moves.
   Without the stretch the two columns also end up different heights, which is what made the pair read
   as two loose cards rather than one split view. */
.fill-canvas > .settings-grid {
    flex: 1 1 auto;
    min-height: 0;
    align-items: stretch;
}
.fill-canvas > .settings-grid > .settings-card {
    display: flex;
    flex-direction: column;
    min-height: 0;
}

/* .panel supplies the border, radius and background; the card overrides its spacing because the
   rows carry their own padding and the grid gap replaces the panel's top margin. */
/* --c-surface-raised overrides the --c-surface `.panel` supplies, so a titled card is built the same
   way round as every other titled surface in the app: WHITE CONTENT UNDER A TINTED BAND.

   It used to be the inverse — a white head on a --c-surface body — which made a card and a grid read
   as two different components sitting next to each other on the same screen. Compare:
     .table-viewport  white,  .data-table th   --c-surface   ← tinted band
     .series-card     white,  .series-card-head --c-surface  ← tinted band
     .settings-card   --c-surface, .settings-card-head white ← the odd one out
   Two of the three already agreed; this makes it three. The band is now the same token on a grid
   header, a series card and a settings card, so "this strip names what is below it" looks the same
   everywhere. */
.settings-card { margin-top: 0; padding: 0; overflow: hidden; background: var(--c-surface-raised); }
/* …except while a ComboBox inside it has its list open. The clip above cut the Country and State
   panels off at the Address card's bottom edge — those two are the card's last row, so the list
   opened straight into it.

   Safe to switch here in a way it is not on `.modal-body`: this card clips, it does not SCROLL, so
   there is no scroll position for the browser to drop when the property changes. It goes back to
   clipping the moment the list closes, and nothing else in the card can overflow in the meantime —
   a wide table inside one of these is already held by `.table-scroll`. */
.settings-card:has(.combo.open) { overflow: visible; }

/* ---- Settings: categories | configuration | current -----------------------
   Three columns, left to right: pick a category, edit it, see what is actually stored.

   This replaced a twelve-card grid of every category at once. The grid answered "show me everything
   about reports" by making the reader find the reports card among twelve; a rail answers it in one
   click and gives the edit card the width the hints need — a `.setting-row` hint is two or three
   sentences of consequence, and at 420px it set as a narrow ribbon beside its input.

   The third column exists because the second one is an edit buffer. Once a row is typed into, the
   screen no longer shows what the system is actually running on, and the only ways to find out were
   to save or to reload the page. Side by side, "what I have typed" and "what is in force" are one
   glance apart.

   Middle column widest: it carries the labels, the hints and the controls. The rail needs enough to
   set "Authentication & session" on one line beside its icon and count — at 15rem it wrapped to two,
   which made a list of twelve titles read as a list of twenty lines. */
.settings-columns {
    display: grid;
    grid-template-columns: minmax(16rem, 21rem) minmax(0, 1.5fr) minmax(0, 1fr);
    gap: var(--sp-4);
    align-items: start;

    /* Tracks the viewport rather than being a flat 34rem, which is what makes zooming behave. Browser
       zoom shrinks the viewport in CSS px, so a fixed height that fitted at 100% pushes the Save
       footer off-screen at 150% — the one thing these cards are a fixed height to prevent. The clamp
       keeps it useful at both ends: never so short the rows are a slit, never so tall on a big monitor
       that the eye has to travel to compare two cards. Declared here rather than on `:root` so the
       three breakpoints below can all refer to one number; a page overriding it must target
       `.settings-columns` itself, since a value inherited from an ancestor would lose to this one.

       The 19rem subtrahend is the chrome above and below: 56px topbar + 24px `.content-inner` top
       padding + ~50px `.page-header` + ~82px filter bar + 24px bottom padding ≈ 236px. Rounded up to
       304px because the filter bar's hint wraps to a second line at narrower widths, and a card that
       is 40px short costs nothing while one that is 40px long puts a scrollbar on the whole page. */
    --settings-card-height: clamp(19rem, calc(100vh - 19rem), 46rem);
}

/* A height so only the middle of a card scrolls; otherwise the foot with the Save button walks off
   the bottom of the page on a long category. Equal heights also keep a row of the edit card level
   with the same row of the current-values card beside it, which is the comparison this layout
   exists for. */
.settings-columns > .settings-card {
    display: flex;
    flex-direction: column;
    height: var(--settings-card-height);
}
.settings-columns > .settings-card > .settings-card-head,
.settings-columns > .settings-card > .settings-card-foot { flex: none; }

/* The rail's own padding. Its buttons are `.assign-cat`, shared with the subscription picker below —
   same job (choose which category the pane shows), so same control rather than a second one that
   drifts a pixel at a time. */
.settings-cat-list { padding: var(--sp-2) 0; }

/* A category holding an unsaved edit, and a Current-settings row whose saved value is about to
   change. One dot, two places, because it is one fact: what is on screen is not what is stored. */
.settings-cat-dot { color: var(--c-primary); font-size: 0.5rem; line-height: 1; }

/* Greyed, not hidden: a filter that matched nothing in a category should say so in place. Removing
   the row would make the rail re-flow under the pointer as the reader types. */
.assign-cat.is-empty { opacity: 0.45; }

/* Stored values, in the third column. Mono and tabular like every other value in the app — "25.0"
   and "26.0" have to line up for the difference to be the thing you notice. */
.settings-current .fact-value {
    font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 0.75rem;
}
/* "not set" and "a value is stored" are prose, not values — they must not read as a stored string. */
.settings-current .fact-value.is-prose {
    font-family: var(--font-body); font-style: italic; color: var(--c-text-muted);
}
/* Stacked, unlike the two-column `.fact-row` this borrows: at a third of the width the label column
   would leave a time zone's display name three characters wide. */
.settings-current .fact-row { grid-template-columns: 1fr; gap: var(--sp-1); align-items: start; }
.settings-current .fact-label { display: flex; align-items: center; gap: var(--sp-1); }

/* ---- Reflow ladder ---------------------------------------------------------
   Three columns → two → one, rather than one jump straight from three to a stack. The middle step is
   what makes zooming tolerable: browser zoom narrows the viewport in CSS px, so zooming in on a
   1440px monitor walks down this ladder, and without the middle rung 125% zoom threw away a layout
   that still had room for two columns.

   What gives way is chosen, not incidental. The rail and the edit card are the working pair — you
   pick a category and change it — so they hold their side-by-side arrangement longest. The
   current-values card is the reference, so it is the one that moves: first to a full-width strip
   under the pair, then into the stack. It is never dropped, only relocated. */

/* Two columns: rail | configuration, with current values as a full-width strip beneath. */
@media (max-width: 1240px) {
    .settings-columns {
        grid-template-columns: minmax(15rem, 20rem) minmax(0, 1fr);
        /* The pair keeps the tall row; the strip below takes only what it needs, up to the same cap. */
        grid-template-rows: var(--settings-card-height) auto;
    }
    /* Child selector, not a bare `.settings-current`: it has to out-specify (or at least match, and
       come later than) `.settings-columns > .settings-card` above, which sets the fixed height. */
    .settings-columns > .settings-current {
        grid-column: 1 / -1;
        height: auto;
        max-height: var(--settings-card-height);
    }
    /* Back to `.fact-row`'s own two columns. The stacked override exists because column three is
       narrow; full width it would leave a label alone on a line with 900px of nothing beside it. */
    .settings-current .fact-row {
        grid-template-columns: minmax(8rem, 13rem) 1fr;
        gap: var(--sp-4);
        align-items: baseline;
    }
}

/* One column: categories, then edits, then current values, in reading order. Cards size to their
   content up to the cap — a shared height means nothing once they are not beside each other. */
@media (max-width: 860px) {
    .settings-columns { grid-template-columns: 1fr; grid-template-rows: none; }
    .settings-columns > .settings-card { height: auto; max-height: var(--settings-card-height); }
    .settings-columns > .settings-current { grid-column: auto; }
    /* The rail is now a horizontal-ish strip above the rows rather than a full-height column; capping
       it stops twelve categories pushing the card you are editing off the first screen. */
    .settings-cat-list { max-height: 14rem; }
}

/* The scrolling middle of a fixed-height card — the rail in column one, the rows in columns two and
   three, and the same part of an `.assign-card`. Scoped to the class and NOT to `.settings-card`:
   fifteen screens use that one for a titled card that sizes to its content (the theme editor, the
   profile, the company form), and only a card that has been given a height may scroll inside itself.

   min-height: 0 is load-bearing: a flex item's default `min-height: auto` refuses to shrink below its
   content, so without it the rows would push the card taller instead of scrolling inside it. */
.settings-card-rows { flex: 1 1 auto; min-height: 0; overflow-y: auto; }

/* The plain body of a titled card, for content that is not a .setting-row — a filter grid, an action
   row, a preview. The family had a head, rows and a foot but nothing to put ordinary content in, so
   the Compliance screen was reaching for a bare .form-grid on the page background instead. */
.settings-card-body { padding: var(--sp-4); }

/* A detail page's two columns: the record itself on the left, its live state and wiring on the right.
   2fr/1fr rather than `.settings-grid`'s equal auto-fit, because the two sides are not peers — the
   left holds the tables somebody came to read and the right holds short fact lists that only need
   enough width for an IP address or a reading.

   The breakpoint is on the NARROW column, not the page: at 900px the 1fr side is about 290px, which is
   where "Operator log file" stops fitting beside its own label. Below that the pair stacks, and the
   right-hand cards read as a continuation rather than as a squeezed sidebar. */
.detail-columns {
    display: grid;
    grid-template-columns: 2fr 1fr;
    gap: var(--sp-4);
    align-items: start;
}
@media (max-width: 900px) {
    .detail-columns { grid-template-columns: 1fr; }
}

/* The stored chamber photo at detail size. Distinct from `.image-picker-preview`, the square admin
   thumbnail inside the upload zone ("here is what is currently stored"), and from `.chamber-photo`,
   the dashboard card's cover crop. This one is the picture itself, as large as its card allows. */
.detail-photo {
    display: block;
    max-width: 100%;
    border-radius: var(--radius-control);
    border: 1px solid var(--c-border);
}

/* Full-width titled cards stacked down a page, where .settings-grid would sit them side by side.
   Used where each card is wide in its own right — a filter over a preview table, an edit form split
   into its concerns. The bottom margin separates the stack from whatever follows it (the chamber
   list); it is simply trailing space where the stack is the last thing on the page. */
.card-stack { display: flex; flex-direction: column; gap: var(--sp-4); margin-bottom: var(--sp-6); }

/* ---- ImagePicker ------------------------------------------------------------------------------
   The drop zone that replaced the browser's "Choose file / No file chosen" on the three screens that
   store a picture — the theme logo, the company's report logo, a chamber photo.

   It also absorbed `.image-thumb`, the bordered 120px preview those three used to sit above their
   file input. All three now show the stored image INSIDE the zone (`.image-picker-preview`), so the
   preview and the control that replaces it are one object rather than two stacked ones, and the old
   rule had no markup left.

   The dashed edge is the whole convention: a solid box on this page is a container, a dashed one is
   somewhere to put something. It goes solid the moment a file is over it, which is the only cue that
   the drop will be caught — nothing else on the screen moves. */
.image-picker { display: flex; flex-direction: column; gap: var(--sp-3); margin-top: var(--sp-3); }

/* position: relative anchors the stretched input below. min-height keeps the empty and filled states
   the same height, so a page does not jump by 40px when a logo is removed. */
.image-picker-zone {
    position: relative;
    display: flex; align-items: center; gap: var(--sp-4);
    min-height: 104px; padding: var(--sp-4);
    border: 1px dashed var(--c-border); border-radius: var(--radius-card);
    background: var(--c-surface);
    cursor: pointer;
    transition: border-color var(--motion-fast) var(--motion-ease),
                background var(--motion-fast) var(--motion-ease);
}
.image-picker-zone:hover { border-color: var(--c-primary); background: var(--c-selection); }

/* Solid, not merely tinted: dashed-to-solid reads at a glance and does not depend on the operator
   being able to tell two close background tints apart. */
.image-picker-zone.dragging {
    border-style: solid; border-color: var(--c-primary); background: var(--c-selection);
}

/* The ring belongs to the zone, because the input it would otherwise land on is invisible — without
   this the control is unreachable-looking for a keyboard, though it is perfectly reachable. */
.image-picker-zone:focus-within {
    outline: var(--focus-width) solid var(--c-focus); outline-offset: var(--focus-offset);
}

/* Stretched over everything, transparent, and still a real file input: this is what makes the whole
   zone both the click target and the drop target, with no JS. */
.image-picker-input {
    position: absolute; inset: 0; width: 100%; height: 100%;
    opacity: 0; cursor: pointer;
}
/* The zone already carries the ring for it. */
.image-picker-input:focus, .image-picker-input:focus-visible { outline: none; }
.image-picker-input:disabled { cursor: default; }

/* Fixed square whatever the picture's shape — a wordmark and a chamber photo have nothing in common
   in aspect ratio, and a zone that resized to each would make the two screens look unrelated.
   `contain` because these are logos as often as photographs: cropping one is worse than letterboxing. */
.image-picker-preview {
    flex: 0 0 auto; width: 88px; height: 88px;
    object-fit: contain;
    padding: var(--sp-2);
    border: 1px solid var(--c-border); border-radius: var(--radius-control);
    background: var(--c-surface-raised);
}

/* The empty state's stand-in, at the same 88px, so the prompt beside it does not shift left when the
   first image lands. */
.image-picker-mark {
    flex: 0 0 auto;
    display: inline-flex; align-items: center; justify-content: center;
    width: 88px; height: 88px;
    border: 1px solid var(--c-border); border-radius: var(--radius-control);
    background: var(--c-surface-raised);
    color: var(--c-text-muted);
}

.image-picker-copy { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; }
.image-picker-lead {
    display: inline-flex; align-items: center; gap: var(--sp-2);
    font-size: 0.8125rem; color: var(--c-text);
}
.image-picker-hint, .image-picker-note { font-size: 0.75rem; color: var(--c-text-muted); }

/* Sits outside the zone: the transparent input covers the zone and would swallow a click on Remove.
   See the note at the top of ImagePicker.razor. */
.image-picker-foot { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; }

/* Forced colours paint the input as a real control again, so the overlay stops being invisible and
   starts covering the prompt. Take it out of the flow and let the zone's own label do the work. */
@media (forced-colors: active) {
    .image-picker-input { opacity: 1; position: static; width: auto; height: auto; }
    .image-picker-zone { flex-wrap: wrap; }
}

/* Narrow: the picture over the prompt rather than a 88px column squeezing it into two words a line. */
@media (max-width: 600px) {
    .image-picker-zone { flex-direction: column; align-items: flex-start; }
}

/* --c-surface: the same token as `.data-table th` and `.series-card-head`, so a card's title band and
   a grid's column headings are one colour rather than two. */
.settings-card-head {
    display: flex; align-items: center; gap: var(--sp-3);
    padding: var(--sp-3) var(--sp-4);
    background: var(--c-surface);
    border-bottom: 1px solid var(--c-border);
    /* Its own, rather than borrowed from the card's `overflow: hidden` — that clip is lifted while a
       ComboBox panel is open (see `.settings-card`), and a band relying on it would square its
       corners for exactly as long as the dropdown was showing. */
    border-radius: var(--radius-card) var(--radius-card) 0 0;
}
.settings-card-head .icon { color: var(--c-primary); }
.settings-card-title {
    flex: 1 1 auto; margin: 0;
    font-family: var(--font-display); font-size: 0.8125rem;
    letter-spacing: 0.06em; text-transform: uppercase;
}
/* How many keys the card holds — answers "is this the short group or the long one" before it is opened. */
.settings-card-count {
    font-family: var(--font-mono); font-size: 0.6875rem; color: var(--c-text-muted);
    border: 1px solid var(--c-border); border-radius: var(--radius-control);
    padding: 0 var(--sp-2);
}

/* Label and hint left, control right. The control column is capped so a long hint wraps rather than
   squeezing the input to nothing on a narrow card. */
.setting-row {
    display: grid;
    grid-template-columns: 1fr minmax(140px, 38%);
    gap: var(--sp-4);
    align-items: start;
    padding: var(--sp-3) var(--sp-4);
    border-bottom: 1px solid var(--c-border);
}
.setting-row:last-of-type { border-bottom: 0; }

/* A row whose value is long by nature — an OS time-zone identifier, a comma-separated list of date
   layouts. The two-column row caps the control at 38% of a ~450px card, which rendered
   "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi" as "(UTC+05:30) Chen…": unreadable, on the one
   setting that moves every printed timestamp in the system. Stacking gives it the card's full width. */
.setting-row.stacked { grid-template-columns: 1fr; gap: var(--sp-2); }

.setting-name { display: block; font-size: 0.8125rem; }
.setting-row .hint { display: block; margin-top: 2px; }

/* `select` as well as `input`: the boolean Yes/No rows and the three time-zone pickers were the only
   controls on this screen rendering as bare native drop-downs, in among fifty boxed inputs. */
.setting-control input, .setting-control select {
    width: 100%;
    padding: var(--sp-2) var(--sp-3);
    border: 1px solid var(--c-border);
    border-radius: var(--radius-control);
    background: var(--c-surface-raised);
    color: var(--c-text);
    font-family: var(--font-body);
    font-size: 0.8125rem;
}
.setting-clear { margin-top: var(--sp-2); font-size: 0.75rem; padding: 2px var(--sp-2); }

/* A touched row is tinted and dotted, so an unsaved edit is visible from the top of the card rather
   than only in the footer count. */
.setting-row.dirty { background: color-mix(in srgb, var(--c-primary) 6%, transparent); }
.setting-row.dirty .setting-name::after { content: " ●"; color: var(--c-primary); font-size: 0.625rem; }

/* A read-only fact and its label, one per row, inside a titled card — what the profile knows about
   you, what a record was published with. Same anatomy as `.setting-row` above but with no control:
   these were a `.data-table` with `<th>` down the left, which is a definition list wearing a table's
   sticky headers and zebra rules. `.ceremony-facts` is the other shape — a tight `<dl>` block inside
   a signature panel, not a card's full width. */
.fact-row {
    display: grid;
    /* A capped label column, not a percentage: these labels are one or two words, and at 28% of a
       full-width card "Role" sat 380px from its value with nothing in between. */
    grid-template-columns: minmax(8rem, 13rem) 1fr;
    gap: var(--sp-4);
    align-items: baseline;
    padding: var(--sp-3) var(--sp-4);
    border-bottom: 1px solid var(--c-border);
    font-size: 0.8125rem;
}
.fact-row:last-of-type { border-bottom: 0; }
/* `:last-of-type` matches by ELEMENT type, so it only fires when nothing else of the same tag follows.
   A card whose foot is a `<div class="settings-card-foot">` (My Profile) makes that foot the last div,
   the final fact row keeps its bottom border, and it stacks with the foot's own border-top into a 2px
   rule. Settings escapes it only by writing its foot as `<footer>`. Stated explicitly here so the
   divider does not depend on which tag someone reached for. */
.fact-row:has(+ .settings-card-foot) { border-bottom: 0; }
.fact-label { color: var(--c-text-muted); }
/* min-width: 0 so a long address wraps inside the column instead of widening the grid track, which
   in a card that cannot scroll would push the row past the card's edge. */
.fact-value { min-width: 0; overflow-wrap: anywhere; display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }

/* `.mono` on a fact value: a reading, an IP address, a file name, a table name — anything read
   character-by-character or compared against the value on the row below it.
   The table rules for `.num`/`.mono` are scoped to `.data-table`, so they do nothing out here; this is
   the same treatment for the list vocabulary. It stays LEFT-aligned, matching `.settings-current`'s
   fact values, which are mono for exactly this reason and are not pushed right — a value flushed to
   the far edge of a wide card ends up nowhere near the label that names it. */
.fact-value.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* The closing band, matching the opening one — a card that is tinted at the top and white at the
   bottom looks unfinished rather than deliberate. */
.settings-card-foot {
    display: flex; align-items: center; gap: var(--sp-2);
    padding: var(--sp-3) var(--sp-4);
    border-top: 1px solid var(--c-border);
    background: var(--c-surface);
}

/* Applies to every .btn: a disabled control must look disabled, not merely refuse to work. */
.btn:disabled { opacity: 0.5; cursor: default; }
/* A link styled as a button has no `disabled`; it is switched off by dropping its href and saying so
   with aria-disabled. Same look, and no pointer events, so it cannot be followed. */
a.btn[aria-disabled="true"] { opacity: 0.5; cursor: default; pointer-events: none; }

/* The working indicator, for a button whose action has been accepted but not finished — a modal's
   Save while the write is in flight.

   16px to the pixel, because it takes the place of the button's 16px <Icon>: a spinner even a
   fraction smaller makes the row twitch at the exact moment the reader is watching to see whether
   anything happened. currentColor so one rule serves the primary button (light ring on the brand
   fill) and the plain one (dark ring on the card) without a variant.

   The gap in the ring is what reads as motion — a complete circle spinning looks stationary. */
.spinner {
    display: inline-block;
    flex: none;
    width: 16px; height: 16px;
    border: 2px solid currentColor;
    border-right-color: transparent;
    border-radius: 50%;
    animation: spin 600ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* Slowed rather than stopped. Everything else in this file drops its animation under
   prefers-reduced-motion, because everything else is decoration that has already finished saying what
   it had to say. A spinner IS the message — a frozen one claims the save has hung. */
@media (prefers-reduced-motion: reduce) {
    .spinner { animation-duration: 1800ms; }
}

/* A table inside a card, scrolling on BOTH axes.

   Horizontally because a table can overrun its card: `.settings-card` sets `overflow: hidden` to clip
   rows to the rounded corners, which would cut the last columns off with no way to reach them — the
   theme version history is ten columns.

   Vertically because the row count is unbounded and was not being contained at all. Every publish adds
   a version and none are ever removed, so a theme republished fifty times grew its card to fifty rows
   and pushed the rest of the page below the fold — while the column headings, already `position:
   sticky`, had no scrolling ancestor to stick to and simply scrolled away with the page.

   60vh is the ceiling `.table-viewport.capped` uses for the same situation — a list that must leave
   room for what sits after it — so a capped grid is one height throughout the app rather than two. */
.table-scroll { overflow: auto; max-height: 60vh; }

/* ---- Theme palette editor (docs/16 §9.1.2) ------------------------------
   Eleven Layer-2 tokens. Each is a swatch bound to the same value as its hex box: the swatch is how
   a colour is chosen, the hex is how it is checked and pasted, and having only the second meant
   picking a palette by typing hexadecimal. Both write the same field, so they cannot disagree. */
.token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--sp-3); margin: var(--sp-3) 0; }
.token-field { display: flex; flex-direction: column; gap: var(--sp-1); }
.token-name { font-family: var(--font-mono); font-size: 0.75rem; color: var(--c-text-muted); }
.token-input { display: flex; align-items: center; gap: var(--sp-2); }
.token-swatch {
    flex: none; width: 36px; height: 32px; padding: 2px; cursor: pointer;
    border: 1px solid var(--c-border); border-radius: var(--radius-control);
    background: var(--c-surface-raised);
}
.token-hex {
    flex: 1 1 auto; min-width: 0;
    padding: var(--sp-2) var(--sp-3);
    border: 1px solid var(--c-border); border-radius: var(--radius-control);
    background: var(--c-surface-raised); color: var(--c-text);
    font-family: var(--font-mono); font-size: 0.8125rem;
}
/* Named by a pair the contrast gate rejected. The failure list says which tokens are at fault; this
   puts the same answer on the field that has to change, so fixing it is not a scan of eleven boxes. */
.token-field.bad .token-hex, .token-field.bad .token-swatch { border-color: var(--s-alarm); }
.token-field.bad .token-name { color: var(--s-alarm); }

/* ---- Theme editor live preview (docs/16 §9.1.3) -------------------------
   These rules consume --tp-* properties written inline by ManageThemes from the palette being
   edited, NOT the page's own tokens — the preview shows a theme that is not applied. Status colours
   deliberately use the locked --s-* tokens: they are identical in every theme (docs/16 §4), and the
   point of showing them is to check they still read against the surfaces being edited. */
.theme-preview-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--sp-4); margin-top: var(--sp-3); }
.theme-preview { display: flex; border: 1px solid var(--tp-border); border-radius: var(--tp-radius-card); overflow: hidden; background: var(--tp-bg); color: var(--tp-text); font-size: 0.8125rem; }
/* Narrow, and fixed: the pane is only ~280px at its smallest and the point of the rail here is the
   colour meeting the header's, not a usable menu. --tp-main takes whatever is left. */
.theme-preview .tp-rail { flex: 0 0 88px; display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-2) 0; background: var(--tp-rail); border-right: 1px solid var(--tp-border); }
.theme-preview .tp-rail .tp-brand { padding: 0 var(--sp-2); }
.theme-preview .tp-rail-item { padding: 2px var(--sp-2); font-size: 0.75rem; color: var(--tp-muted); border-left: 2px solid transparent; }
.theme-preview .tp-rail-item.is-current { color: var(--tp-primary); background: var(--tp-selection); border-left-color: var(--tp-primary); }
.theme-preview .tp-main { flex: 1 1 auto; min-width: 0; }
.theme-preview .tp-topbar { display: flex; justify-content: space-between; align-items: center; padding: var(--sp-2) var(--sp-3); background: var(--tp-topbar); border-bottom: 1px solid var(--tp-border); }
.theme-preview .tp-brand { font-family: var(--tp-font-display); font-weight: 600; letter-spacing: 0.04em; }
.theme-preview .tp-muted { color: var(--tp-muted); font-family: var(--tp-font-mono); font-size: 0.75rem; }
.theme-preview .tp-body { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3); }
.theme-preview .tp-card { padding: var(--sp-3); background: var(--tp-raised); border: 1px solid var(--tp-border); border-radius: var(--tp-radius-card); }
.theme-preview .tp-title { font-family: var(--tp-font-display); font-weight: 600; }
.theme-preview .tp-value { font-family: var(--tp-font-mono); font-size: 1.5rem; font-variant-numeric: tabular-nums; }
.theme-preview .tp-badges { display: flex; gap: var(--sp-2); margin-top: var(--sp-2); }
.theme-preview .tp-badge { font-family: var(--tp-font-display); font-size: 0.6875rem; font-weight: 600; padding: 2px var(--sp-2); border-radius: var(--tp-radius-control); border: 1px solid currentColor; }
.theme-preview .tp-normal { color: var(--s-normal); }
.theme-preview .tp-alert { color: var(--s-alert); }
.theme-preview .tp-alarm { color: var(--s-alarm); }
.theme-preview .tp-table { width: 100%; border-collapse: collapse; }
.theme-preview .tp-table th, .theme-preview .tp-table td { text-align: left; padding: var(--tp-cell-pad) var(--sp-3); height: var(--tp-row-height); border-bottom: 1px solid var(--tp-border); }
.theme-preview .tp-table th { font-family: var(--tp-font-display); font-weight: 600; background: var(--tp-surface); }
.theme-preview .tp-selected td { background: var(--tp-selection); }
.theme-preview .tp-actions { display: flex; gap: var(--sp-2); }
.theme-preview .tp-btn { padding: var(--sp-2) var(--sp-4); border-radius: var(--tp-radius-control); border: 1px solid var(--tp-border); background: var(--tp-raised); color: var(--tp-text); font-size: 0.8125rem; }
.theme-preview .tp-primary { background: var(--tp-primary); border-color: var(--tp-primary); color: var(--tp-primary-ink); }

/* Nav badge: reports awaiting this user's signature (docs/06 §3). */
.nav-badge {
    display: inline-block; margin-left: var(--sp-2); padding: 0 var(--sp-2);
    font-family: var(--font-display); font-size: 0.6875rem; font-weight: 600;
    border-radius: var(--radius-control); background: var(--s-alert); color: var(--c-surface);
}

/* ---- Subscription picker (docs/16 §10.3) ----------------------------------
   Assigning one user to N of a long list — today the ~130 event types of a migrated legacy master.

   A flat `.perm-grid` of checkboxes is the right shape for a dozen permissions and the wrong one for a
   hundred and thirty events: nothing tells you where you are in the list, the categories that give the
   list its meaning are invisible, and the save button sits a screen and a half below the first row.

   So this is the card anatomy the settings screens already use — head, a body that scrolls, a foot
   that does not — with the body split into a category rail and a row pane. The rail answers "how much
   of each group is on" without opening anything; the pane is one row per event, full width, so an
   event's name, code and warnings have somewhere to live. */

/* Fixed height for the same reason `.settings-columns` cards have one: only the rows may scroll, or
   the foot with the count and the Save button walks off the bottom of the page. */
.assign-card { display: flex; flex-direction: column; height: var(--assign-card-height, 36rem); }
.assign-card > .settings-card-head,
.assign-card > .settings-card-foot,
.assign-card > .assign-notice { flex: none; }

/* A standing notice inside the card, above the split — "notifications are switched off for this user".
   `.status-msg` is for outcomes of an action and carries vertical margins that would break the card's
   flush edges. */
.assign-notice {
    padding: var(--sp-2) var(--sp-4);
    border-bottom: 1px solid var(--c-border);
    font-size: 0.8125rem; color: var(--c-text-muted);
}
.assign-notice.warn { color: var(--s-alert); }

.assign-split {
    display: grid;
    grid-template-columns: minmax(11rem, 14rem) 1fr;
    flex: 1 1 auto;
    min-height: 0;          /* lets the two panes scroll instead of growing the card */
}

/* The one breakpoint in this file besides reduced-motion, and it earns it: a rail plus a pane cannot
   be expressed with intrinsic sizing, and below ~640px the rail would leave the pane too narrow to
   read an event name in. Stacking puts the categories above the rows as an ordinary scrolling strip. */
@media (max-width: 640px) {
    .assign-split { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
    .assign-rail { border-right: 0; border-bottom: 1px solid var(--c-border); max-height: 6.5rem; }
    /* Stacked, the rail and a wrapped toolbar eat the height the rows need, so the card grows. Even
       then this is a degraded view — the app shell itself is desktop-first (a fixed 240px nav rail and
       tables that overflow well before this width), so the aim is "usable if you get here", not parity. */
    .assign-card { height: var(--assign-card-height, 44rem); }
    .assign-toolbar { gap: var(--sp-1); padding: var(--sp-2); }
}

.assign-rail {
    border-right: 1px solid var(--c-border);
    overflow-y: auto;
    min-height: 0;
    padding: var(--sp-2) 0;
}

/* A button, not a link: this selects which rows the pane shows, it does not navigate. */
/* Tighter than a `.setting-row`: this is a list to scan, and a subsystem-per-category master runs to
   twenty of them. The extra rows that fit are worth more here than the breathing room.
   Shared with the Settings screen's category rail (`.settings-cat-list`) — same job, so the same
   control rather than a second one that drifts a pixel at a time. */
.assign-cat {
    display: flex; align-items: center; gap: var(--sp-2);
    width: 100%;
    padding: var(--sp-1) var(--sp-3);
    border: 0; border-left: 2px solid transparent;
    background: none; color: inherit; font: inherit; text-align: left;
    cursor: pointer;
}
/* Tinted, not raised. A hover has to be a step AWAY from whatever it sits on, and what it sits on is
   now white in both places this rail appears — the Settings category list inside a `.settings-card`,
   and the event picker inside a form modal. Raised-on-white was no hover at all.
   `.seg button:hover` has always used --c-surface for the same reason. */
.assign-cat:hover { background: var(--c-surface); }
.assign-cat.is-current { background: var(--c-selection); border-left-color: var(--c-primary); }
.assign-cat-name { flex: 1 1 auto; min-width: 0; font-size: 0.8125rem; overflow-wrap: anywhere; }

/* n/total for the group. Monospace and tabular so the counts line up down the rail — that column is
   read as a column, not as a series of labels. */
.assign-cat-count {
    font-family: var(--font-mono); font-size: 0.6875rem;
    font-variant-numeric: tabular-nums;
    color: var(--c-text-muted);
}
/* A group with something subscribed is the thing an admin is scanning for, so it is the one that
   takes the accent. Nothing subscribed stays muted rather than turning a colour of its own. */
.assign-cat.has-some .assign-cat-count { color: var(--c-primary); }

.assign-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; }

/* A band over the rows it filters, so it takes the band token — the same one the card head above it
   and the grid headers elsewhere use. Its own input flips the other way for the same reason: a
   control on a tinted band is white, like every other input in the app. */
.assign-toolbar {
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
    padding: var(--sp-2) var(--sp-3);
    border-bottom: 1px solid var(--c-border);
    background: var(--c-surface);
}
.assign-toolbar input[type="text"] {
    flex: 1 1 10rem; min-width: 8rem;
    padding: var(--sp-1) var(--sp-2);
    border: 1px solid var(--c-border); border-radius: var(--radius-control);
    background: var(--c-surface-raised); color: var(--c-text);
    font-family: var(--font-body); font-size: 0.8125rem;
}

.assign-rows { flex: 1 1 auto; min-height: 0; overflow-y: auto; }

/* The category heading inside the pane, shown when the rail is on "All". Sticky, because the whole
   point of grouping a hundred rows is knowing which group the row under the pointer belongs to. */
.assign-group-head {
    position: sticky; top: 0; z-index: 1;
    display: flex; align-items: center; gap: var(--sp-2);
    padding: var(--sp-1) var(--sp-3);
    background: var(--c-surface);
    border-bottom: 1px solid var(--c-border);
    font-family: var(--font-display); font-size: 0.6875rem;
    letter-spacing: 0.06em; text-transform: uppercase;
    color: var(--c-text-muted);
}
.assign-group-head .assign-cat-count { margin-left: auto; }

/* The whole row is the label, so the hit target is the row and not a 13px box at its left edge. */
.assign-row {
    display: flex; align-items: center; gap: var(--sp-3);
    padding: var(--sp-2) var(--sp-3);
    border-bottom: 1px solid var(--c-border);
    font-size: 0.8125rem;
    cursor: pointer;
}
.assign-row:hover { background: var(--c-surface); }
/* Subscribed rows are tinted and take an accent edge — the state has to be legible while scrolling
   past, not only by looking at each checkbox in turn. */
.assign-row.is-on { background: color-mix(in srgb, var(--c-primary) 7%, transparent); }
.assign-row.is-on .assign-row-name { font-weight: 600; }

.assign-row-main { flex: 1 1 auto; min-width: 0; }
.assign-row-name { display: block; overflow-wrap: anywhere; }
.assign-row-meta {
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
    margin-top: 2px;
}
/* The panel's own event code — what the collector matches an imported row on, so it is worth showing
   beside every event rather than only on the master screen. */
.assign-code {
    font-family: var(--font-mono); font-size: 0.6875rem; color: var(--c-text-muted);
}

/* ---- Segmented filter -----------------------------------------------------
   Two or three mutually exclusive views of one list ("all / subscribed / alarms"). A row of `.btn`s
   cannot show which one is active, and a `select` hides the choices behind a click for a set this
   small. */
.seg { display: inline-flex; border: 1px solid var(--c-border); border-radius: var(--radius-control); overflow: hidden; }
.seg button {
    border: 0; background: none; color: var(--c-text-muted);
    font-family: var(--font-display); font-size: 0.75rem;
    padding: 2px var(--sp-2); cursor: pointer;
}
.seg button + button { border-left: 1px solid var(--c-border); }
.seg button:hover { color: var(--c-text); background: var(--c-surface); }
.seg button.is-active { background: var(--c-selection); color: var(--c-text); }

/* Unsaved changes, stated in the foot beside the Save button it applies to — the same idea as
   `.setting-row.dirty`, which marks WHICH row changed; this one says that something did. */
.assign-dirty { font-size: 0.75rem; color: var(--c-primary); }
.assign-dirty::before { content: "● "; font-size: 0.625rem; }

/* The selected category, named in the pane's toolbar and removable by clicking it. The rail's
   highlight is the primary indicator, but a master with a category per subsystem scrolls that rail —
   a selection below the fold otherwise left the pane filtered with nothing on screen saying to what. */
.assign-scope {
    display: inline-flex; align-items: center; gap: var(--sp-1);
    padding: 2px var(--sp-2);
    border: 1px solid var(--c-primary); border-radius: var(--radius-control);
    background: color-mix(in srgb, var(--c-primary) 10%, transparent);
    color: var(--c-primary);
    font-family: var(--font-display); font-size: 0.75rem;
    cursor: pointer;
}
.assign-scope:hover { background: color-mix(in srgb, var(--c-primary) 18%, transparent); }
