/* Shared component surface. Began as a lift from ProDAQ's shell.css (2622 lines) per
   docs license-manager-ui-prompt.md §1.1 — the rule groups that file names, not the file.

   READ THIS BEFORE EDITING. Every rule below is written in tokens — --c-* semantic colour, --sp-*
   spacing, --radius-*, --field-*, --s-* status — and NOTHING else. tokens.css defines them and must
   load first. An undefined custom property is invalid at computed-value time: it does not fall back
   to a browser default, it drops the whole declaration. So a missing token layer does not mis-colour
   these components, it strips them to transparent, unpadded boxes.

   The Razor components in Components/Shared/ carry almost no var() of their own — they are class
   names, and all of their appearance is here. component -> class -> this file -> tokens.css.

   WHAT WAS CHANGED IN THE LIFT
   - Selectors for surfaces this app does not have were dropped from shared selector lists:
     .nav-list and .settings-card-rows (ProDAQ's nav rail and settings cards — §2 says do not copy
     the rail, this app has four destinations) and .table-scroll (its two-axis card table).
     .filter-bar likewise, leaving .inline-field to carry that rule alone.
   - ProDAQ's .modal-change* group is NOT here — those classes are written by ProDAQ's page markup,
     not by ConfirmDialog, so nothing in this app emits them. .modal-section-title IS here: the Add
     client form uses it to head its two halves (the company, then the machine it runs on).
   - One rule is an ADDITION rather than a lift, marked where it appears: the .form-grid collapse to
     one column under 640px, which §3 asks for on every form grid.

   WHAT THE BRAND PASS CHANGED AFTERWARDS, and where to look before undoing any of it
   - There is a LEFT RAIL. §2 above says not to build one; it was asked for and built, and the
     shell-chrome section at the foot of this file is the tie-break. The four destinations live
     there as pills, and the topbar keeps only the account zone — no nav, and no clock.
   - The RAIL AND THE TOPBAR share one gradient (--brand-stops) at two angles, 180deg and 90deg,
     so they begin on the same stop at the corner they meet. The stops are pastel, so everything on
     both is DARK ink — --c-on-brand / --c-on-brand-muted, never --c-text-muted and never white.
     A hover or fill on either surface is frosted WHITE for the same reason: --c-selection is a
     tint mixed against a white page and does not show on a violet one.
   - The MODAL head carries the same gradient, the card takes --radius-panel, and it floats on a
     WHITE frosted scrim rather than a dark one — which is why that card keeps a border and a
     three-layer shadow: on a white veil they are the only things drawing its edge. Its danger
     variant drops the gradient for a red wash rather than wearing both.
   - EVERY FIELD is an underline — no border, no fill, a rule that thickens and takes the accent on
     focus. .form-grid controls, .inline-field controls, native <select>, .combo-control and
     .select-trigger all say it, and --field-* holds the values so they cannot drift from
     .login-input. Do not reintroduce an `outline` focus ring on any of them: it draws back the box
     the design exists to remove, and it fires on whatever field a modal autofocuses.
   - Rules nothing in this app emitted were DELETED rather than left as dead weight: .fill-canvas,
     .bare-canvas, .table-viewport.capped, .filter-actions, .form-grid.wide, .form-grid.filter-grid,
     .selected-row, .select-cell, .expiry-notice, .assign-modal-split, and the .panel/.hint/table
     overrides inside a form modal. If a screen ever needs one, take it from ProDAQ again.

   LOAD ORDER: fonts.css, boxicons (CDN), tokens.css, app.css, then this. It loads LAST deliberately:
   it is the authority for .form-grid, .btn, .data-table and the modal/dropdown surface, and app.css
   is the older sheet being migrated away from screen by screen. */


/* =============================================================================
   Scrollbars
   ============================================================================= */

/* ---- 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. */
.content-canvas::-webkit-scrollbar,
.table-viewport::-webkit-scrollbar,
.table-wrap::-webkit-scrollbar,
.combo-panel::-webkit-scrollbar { width: 8px; height: 8px; }

.content-canvas::-webkit-scrollbar-track,
.table-viewport::-webkit-scrollbar-track,
.table-wrap::-webkit-scrollbar-track,
.combo-panel::-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. */
.content-canvas::-webkit-scrollbar-thumb,
.table-viewport::-webkit-scrollbar-thumb,
.table-wrap::-webkit-scrollbar-thumb,
.combo-panel::-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. */
.content-canvas::-webkit-scrollbar-thumb:hover,
.table-viewport::-webkit-scrollbar-thumb:hover,
.table-wrap::-webkit-scrollbar-thumb:hover,
.combo-panel::-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-wrap::-webkit-scrollbar-corner { background: transparent; }

@supports not selector(::-webkit-scrollbar) {
    .content-canvas, .table-viewport, .table-wrap, .combo-panel {
        scrollbar-width: thin;
        scrollbar-color: color-mix(in srgb, var(--c-text) 14%, transparent) transparent;
    }
}


/* =============================================================================
   Topbar — the account zone, and nothing else
   ============================================================================= */

/* THE BAR HAS NO BACKGROUND OF ITS OWN RIGHT NOW — its `background` line below is commented out, so
   it shows the page's own --c-bg with a hairline and a soft shadow under it. It used to carry the
   brand gradient at 90deg, meeting the rail's 180deg on the stop they share at the corner. That join
   is no longer being preserved: the rail has its own dark greys (--chrome-stops) and light ink, and
   the bar is light with dark ink. Uncomment the line and the gradient comes back — nothing else here
   depends on which of the two it is, because --c-on-brand is dark either way.

   It holds one zone. The four destinations moved to the rail and the live clock was removed on
   request — everything that clock answered is still answered where the question is actually asked,
   since every timestamp on every screen below is rendered UTC and labelled "(UTC)" at the point of
   use. What is left is who you are, as a menu, pushed to the right. */
.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;
    display: flex;
    align-items: center;
    padding: 0 var(--sp-6);
    /*background: linear-gradient(90deg, var(--brand-stops));*/
    color: var(--c-on-brand);
    border-bottom: 1px solid rgb(27 32 50 / 0.10);
    /* A soft drop under the bar, so the canvas reads as passing beneath it rather than butting
       against it. Paired with the rail's, the two make one L-shaped plane over the page. */
    box-shadow: 0 1px 3px rgb(45 10 80 / 0.10);
    /* The gradient must paint over the canvas as it scrolls under, not be overlapped by a card.
       10, one above the rail's 9, so the corner resolves in the bar's favour. */
    position: relative;
    z-index: 10;
}

/* `margin-left: auto` rather than `justify-content: flex-end` on the bar: the zone is the bar's
   only child now, and an auto margin keeps that true whether or not a second one is ever added to
   its left. */
.topbar-right { display: flex; align-items: center; gap: var(--sp-3); margin-left: auto; }

/* THE ACCOUNT MENU — a <details>, back where it was before the flat button.

   It is a native disclosure, not a Blazor one, and that is load-bearing: the layout renders
   STATICALLY (only routed pages opt into InteractiveServer), so an @onclick here would render
   perfectly and never fire. <summary> opens its own panel with no circuit and no script, which is
   the "element that already behaves" App.razor asks for. js/user-menu.js only adds the two things
   the element does NOT do — close on an outside click and close on Escape — so with the file
   blocked the menu still opens and closes on its own trigger.

   The bar's own gradient is currently commented out in .topbar, so this chrome has to read on a
   near-white bar AND on the brand gradient if that line comes back. Everything below is therefore
   inked --c-on-brand (dark either way) with a DARK translucent wash for hover rather than the
   frosted white the rail uses — white at 30% is invisible on a white bar. */
.account-menu { position: relative; }

/* The marker is removed on both selectors: ::marker is the modern one, ::-webkit-details-marker is
   what Safari still uses, and missing either leaves a stray triangle beside the avatar. */
.account-trigger { list-style: none; }
.account-trigger::-webkit-details-marker { display: none; }
.account-trigger::marker { content: ""; }

.account-trigger {
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    padding: var(--sp-1) var(--sp-2);
    border-radius: var(--radius-control);
    color: var(--c-on-brand);
    cursor: pointer;
    user-select: none;
    transition: background var(--motion-fast) var(--motion-ease);
}
.account-trigger:hover { background: rgb(27 32 50 / 0.06); }
.account-trigger:focus-visible {
    outline: var(--focus-width) solid var(--c-focus);
    outline-offset: 2px;
}

/* A disc, not a bare glyph: it gives the trigger a fixed left edge, so the menu does not shift
   position when a longer display name is signed in. */
.account-avatar {
    display: grid;
    place-items: center;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    background: rgb(27 32 50 / 0.08);
    flex: none;
}

.account-id { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.account-name { font-size: 0.8125rem; font-weight: 600; white-space: nowrap; }
/* The identifier, under the display name rather than in brackets beside it — the pair is a stack
   in a 32px-tall trigger, which is what makes room for the caret without the bar growing. */
.account-username { font-size: 0.6875rem; color: var(--c-on-brand-muted); white-space: nowrap; }

/* The caret is the only part that MOVES on open, and it is the whole affordance: it says the
   trigger is a menu rather than a label, and its rotation says which state you are in. */
.account-caret { opacity: 0.6; transition: transform var(--motion-fast) var(--motion-ease); }
.account-menu[open] .account-caret { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) {
    .account-trigger, .account-caret { transition: none; }
}

/* The panel. Anchored to the trigger's RIGHT edge, because the trigger is the last thing on the bar
   and a left-anchored panel would hang off the window.

   z-index above the topbar's own 10 so it paints over the canvas below rather than being clipped by
   the next stacking context down. */
.account-panel {
    position: absolute;
    top: calc(100% + var(--sp-2));
    right: 0;
    z-index: 20;
    width: 288px;
    padding: var(--sp-4);
    border: 1px solid var(--c-border);
    border-radius: var(--radius-card);
    background: var(--c-surface-raised);
    color: var(--c-text);
    box-shadow: var(--elev-lg);
}

.account-panel-head { display: flex; flex-direction: column; gap: 2px; }
.account-panel-name { font-weight: 600; font-size: 0.875rem; }
.account-panel-username { font-size: 0.75rem; color: var(--c-text-muted); font-family: var(--font-mono); }

/* Where the change-password answer lives. It was a `title` tooltip while the menu was gone, which
   is a weaker home for it: credentials are set in appsettings (build prompt §9), so there is no
   page to link to, and a menu that simply omitted it left the question answered nowhere. */
.account-panel-note {
    margin: var(--sp-3) 0 0;
    padding-top: var(--sp-3);
    border-top: 1px solid var(--c-border-soft);
    font-size: 0.75rem;
    line-height: 1.5;
    color: var(--c-text-muted);
}
.account-panel-note code { font-family: var(--font-mono); font-size: 0.6875rem; }

/* Sign out is in BOTH places on purpose: the rail's foot is where it now lives for the hand that
   knows the app, and this row is where a reader who opened the account menu will look for it. Both
   are the same POST to the same endpoint, each with its own antiforgery token. */
.account-panel-form { margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--c-border-soft); }
.account-signout {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--sp-2);
    width: 100%;
    padding: var(--sp-2) var(--sp-3);
    border: 1px solid var(--c-border);
    border-radius: var(--radius-control);
    background: var(--c-surface);
    color: var(--c-text);
    font-family: var(--font-body);
    font-size: 0.8125rem;
    font-weight: 500;
    cursor: pointer;
}
.account-signout:hover { background: var(--c-selection); border-color: var(--c-primary); color: var(--c-primary); }
.account-signout:focus-visible { outline: var(--focus-width) solid var(--c-focus); outline-offset: 2px; }

/* Under 900px the canvas is tight, so the trigger keeps the avatar and the caret and drops the
   words. The panel still names who is signed in, so nothing is lost — it is one click away instead
   of on the bar. The panel itself stops being 288px wide beside a 320px viewport. */
@media (max-width: 900px) {
    .topbar { padding: 0 var(--sp-4); }
    .account-id { display: none; }
    .account-panel { width: min(288px, calc(100vw - var(--sp-8))); }
}


/* =============================================================================
   Content canvas — full width, no max-width (§3)
   ============================================================================= */

/* The one scrolling region on an application page — full width on purpose.

   docs/16 §2 originally capped this at 1600px as a reading measure. That is the right instinct for
   prose and the wrong one here, and the cap was on the SCROLLING element: 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 rather than at the edge of the window. */
.content-canvas {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    /* A flex COLUMN, so the card below can be told to fill it. Without this the card is a block that
       shrink-wraps its content, which is why a two-row list left two thirds of the window painted in
       bare canvas with a small white sheet floating at the top of it. */
    display: flex;
    flex-direction: column;
}

/* 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.

   `flex: 1 0 auto` — GROW to the canvas, never SHRINK below the card. The grow half is what makes a
   short page's card reach the bottom of the window; the no-shrink half is what keeps a long page's
   card from being squeezed to viewport height with its own content hanging out of the bottom of it.
   The frame rules further down flip this to `1 1 auto` for the one shape where shrinking is exactly
   what is wanted: a card whose last element is a grid that can scroll on its own. */
.content-inner {
    flex: 1 0 auto;
    display: flex;
    flex-direction: column;
    padding: var(--sp-6);
}

/* ---- The page card ----------------------------------------------------------
   Every routed page renders inside one of these (AdminLayout wraps @Body in it), so a screen is a
   single sheet carrying its heading, its actions, its filters and its grid rather than three
   unrelated things sitting on the canvas.

   It is the SHEET the whole app is read off, and it was a 6px-radius rectangle with a 4%-black
   hairline shadow — which is a box, not a card. What makes it one now, and what each part is for:

     --radius-panel      18px, the same radius the modal takes. The two surfaces a screen is made
                         of should be the same shape; at 6px they were both just rectangles.
     no border           A hairline AND a shadow is a box drawn on top of a shadow. The edge is now
                         drawn by light instead: a 1px white inset along the top, where a raised
                         sheet catches it, and the shadow doing the rest.
     two-step shadow     A tight contact shadow for the edge and a wide soft one for the lift.
                         One radius cannot do both — a single soft shadow leaves the card looking
                         unattached, and a single tight one looks printed on.
     violet-tinted ink   --elev-* are mixed with the brand's ink rather than pure black, so the
                         shadow reads as depth under this palette's light instead of as grey soot.

   Still restrained on purpose: this card is the background to EVERY screen, so anything heavier is
   paid twelve times over, and a heavy shadow under a full-width surface reads as a seam.

   No max-width. Same argument as .content-inner above: the card holds tables and filter rows, which
   use whatever width they are given, so a cap only re-creates the dead gutter the cap on the canvas
   was removed to fix.

   IT FILLS THE WINDOW. `flex: 1 0 auto` in the column above: a screen with three rows on it is still
   a full sheet of paper rather than a business card floating on a field of background. Grow, but do
   not shrink — a page taller than the window grows the card and scrolls the canvas, which is the
   behaviour every form screen here depends on.

   It is also a flex COLUMN itself, which is what lets the frame rules below hand the leftover height
   to a grid. That is a layout change with one consequence worth knowing: flex items do not collapse
   margins with each other. Nothing in this app relies on that — every gap between the card's own
   children is a `margin-bottom` on the upper one — but a component added later with a top margin
   will sit lower here than it would in normal flow. */
.content-card {
    flex: 1 0 auto;
    display: flex;
    flex-direction: column;
    background: var(--c-surface-raised);
    border-radius: var(--radius-panel);
    box-shadow:
        0 1px 2px rgb(27 32 50 / 0.05),
        0 10px 30px -12px rgb(45 10 80 / 0.14),
        inset 0 1px 0 rgb(255 255 255 / 0.9);
    padding: var(--sp-6);
}

/* The card supplies the page's outer spacing, so the first and last things in it must not add their
   own on top — an h1 with a top margin pushes the heading off the padding it was given, and a
   trailing table pager leaves a band of empty card under itself. Stated on the CARD's own first and
   last child rather than on .page-head and .table-pager, because it is a fact about being at the
   edge of a surface, not about what those two components are. */
.content-card > :first-child:not(.page-head) { margin-top: 0; }
.content-card > :last-child { margin-bottom: 0; }

/* Head band. The title and its actions sit ON the surface with everything else, so without a band
   the card is one undifferentiated block of white with a big word at the top. Pulled out to the
   card's edges with negative margins so it crosses the full width — a divider that stops short of
   the corners reads as an underlined heading, not as the seam between a card's head and its body.

   A GRADIENT WASH, not a flat grey. The band carries the brand's violet at a few percent, fading
   out across the row, plus a 3px stripe of the real gradient down its leading edge. Both are almost
   nothing on their own, and together they are what makes a page look like it belongs to the login
   screen and the topbar rather than to a different application: the topbar states the gradient
   loudly, the page card echoes it at a whisper, and the modal states it loudly again.

   The stripe is a background LAYER, not a border-left and not a pseudo-element. border-image is the
   obvious way to colour one border with a gradient and it is a trap here: it replaces all four
   borders at once, so it would take the bottom hairline with it and the band would lose its seam.
   A pseudo-element would work, but this band already positions itself with negative margins, and
   absolutely positioning a child on top of that is one more thing to get wrong at a corner radius.
   Two background layers need neither — and the radius clips the stripe's top corner for free.

   Scoped to `.content-card >` on purpose: .page-head also appears inside modals and inside the
   login shell, where there is no card edge for the negative margin to reach and it would hang the
   rule out over the dialog's padding. */
.content-card > .page-head:first-child {
    margin: calc(var(--sp-6) * -1) calc(var(--sp-6) * -1) var(--sp-6);
    padding: var(--sp-4) var(--sp-6);
    border-bottom: 1px solid var(--c-border);
    border-radius: var(--radius-panel) var(--radius-panel) 0 0;
    background:
        linear-gradient(180deg, var(--brand-stops)) left center / 3px 100% no-repeat,
        linear-gradient(100deg,
            color-mix(in srgb, var(--c-primary) 7%, var(--c-surface-raised)),
            var(--c-surface) 65%);
}

/* ---- THE FRAME: a list page is exactly the window, and the grid scrolls inside it ----------------
   What this replaces: the page grew as long as its list, the whole canvas scrolled, and the column
   headings scrolled away with it — `position: sticky` on a `th` sticks to the nearest SCROLLING
   ancestor, and .table-wrap's `max-height: min(70vh, 44rem)` was the only thing making one. So a
   fifty-row list was a page you scrolled twice: the canvas to reach the table, the table to read it,
   with the page heading pushed off screen and 30% of the window height left as dead canvas below the
   card's bottom edge.

   Framed, the chain is exactly the viewport — canvas, card, and grid — and the ONLY thing that
   scrolls is the grid's own box. The heading, the filters and the pager stay put; the column headers
   stick to the top of the rows they name; and the card reaches the bottom of the window on two rows
   or two hundred.

   WHICH PAGES. Not all of them, and the discrimination is the whole design here. A card is framed
   only when it ends in something that can scroll on its own:

     > .table-wrap / > .table-viewport            the list pages — Clients, Expiring, Verify log,
                                                  Undelivered, Unlicensed
     > section:only-of-type > (either)            the Dashboard, whose grid is wrapped in the one
                                                  section on the card

   `:only-of-type` is doing real work in that second selector. The detail pages — a client, an
   installation, Generate key — stack THREE OR MORE sections in one card, and framing those would
   cap the card at the window while the sections above the grid refused to shrink, hanging their
   content out of the bottom of a sheet that had stopped painting. Those cards stay `1 0 auto`: they
   grow, and the canvas scrolls, exactly as before. `:has()` is already used in this sheet (see
   .form-modal .modal-head and .combo-control), so this is the established mechanism rather than a
   new dependency.

   All three levels have to be told, and this is the part that looks redundant and is not: a flex
   item only shrinks if its own parent gave it room to. Cap the card alone and .content-inner has
   already grown to the card's full content height, so there is nothing to shrink INTO and the cap
   never binds. */
.content-inner:has(> .content-card > .table-wrap),
.content-inner:has(> .content-card > .table-viewport),
.content-inner:has(> .content-card > section:only-of-type > .table-wrap),
.content-inner:has(> .content-card > section:only-of-type > .table-viewport),
.content-card:has(> .table-wrap),
.content-card:has(> .table-viewport),
.content-card:has(> section:only-of-type > .table-wrap),
.content-card:has(> section:only-of-type > .table-viewport) {
    flex: 1 1 auto;
    min-height: 0;
}

/* The Dashboard's grid is one level deeper, so its section becomes a column too and passes the
   height through. `:only-of-type` again — this must never match a detail page's third section. */
.content-card > section:only-of-type:has(> .table-wrap),
.content-card > section:only-of-type:has(> .table-viewport) {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
}

/* And the grid itself takes what is left.

   `max-height: none` overrides app.css's `min(70vh, 44rem)` on .table-wrap. That cap was this
   problem's previous answer — it gave the table a scrolling box, which is why the headers stuck at
   all — but 70vh is a guess at "the window minus the chrome above me" that is wrong by exactly the
   height of the page heading and the filter row, and it left the card's bottom edge floating above
   the fold on every screen. The flexbox answer is the measurement rather than the guess.

   `min-height` so the grid cannot be squeezed to nothing by a very short window — at that point the
   card overflows and the canvas scrolls, which is a worse failure than a stub of a table but a
   better one than a table with no rows visible at all. 6rem is the header row plus two rows. */
.content-card > .table-wrap,
.content-card > .table-viewport,
.content-card > section:only-of-type > .table-wrap,
.content-card > section:only-of-type > .table-viewport {
    flex: 1 1 auto;
    min-height: 6rem;
    max-height: none;
}


/* =============================================================================
   Tables: viewport, sticky headers, pager (§3/§4)
   ============================================================================= */


/* 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);
}

/* `.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.
   `.table-viewport` is the only scrolling host a table sits in here. ProDAQ has two more —
   `.settings-card-rows` and `.table-scroll` — and neither was lifted, because neither surface
   exists in this app; if one is ever added, it joins this selector rather than getting its own. */
.table-viewport .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; }
.field-error { color: var(--s-alarm); font-size: 0.75rem; line-height: 1.4; }


/* =============================================================================
   Icons, buttons, spinner
   ============================================================================= */

/* Icons: one set (Boxicons), rendered as a FONT. Each one is an <i class="bx bx-…"> whose glyph is
   a character, so it inherits `color` exactly as text does — a .danger button's icon turns red and
   a topbar icon turns dark-on-gradient with no rule of their own. That is the same property the
   inlined SVGs' `stroke="currentColor"` gave, kept deliberately when the set was swapped.

   `line-height: 1` is what makes the size honest: an icon font inherits the surrounding line-height
   otherwise, so a 16px glyph in a button with 1.5 line-height claims 24px of vertical space and
   pushes the button taller than the one beside it. The size itself is an inline font-size from
   Icon.razor's Size parameter.

   -0.05em rather than the SVG's -0.15em: Boxicons' glyphs are drawn centred on the em box, so they
   sit lower in the line than a 24×24 SVG viewBox did and need much less lifting.

   flex-shrink guards the glyph when a long label wraps beside it. */
.icon { display: inline-block; vertical-align: -0.05em; line-height: 1; flex-shrink: 0; }

/* 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;
    font-weight: 500;
    text-decoration: none;
    white-space: nowrap;
    box-shadow: var(--elev-sm);
    transition: background var(--motion-fast) var(--motion-ease),
                border-color var(--motion-fast) var(--motion-ease),
                box-shadow var(--motion-fast) var(--motion-ease);
}
/* Hover moves the BORDER and the fill, never the box: these sit in 40px grid rows and in a modal
   footer, and a button that grows or lifts on hover shifts the row it is in. */
.btn:hover:not(:disabled) { border-color: var(--c-text-muted); background: var(--c-surface); }

.btn.primary {
    background: var(--c-primary);
    color: var(--c-primary-ink);
    border-color: var(--c-primary);
    /* A coloured shadow in the button's own hue. On a violet primary this reads as the control
       being lit rather than as a grey drop under it, and it is what separates the commit button
       from the Cancel beside it at a glance. */
    box-shadow: 0 2px 8px -2px color-mix(in srgb, var(--c-primary) 55%, transparent);
}
.btn.primary:hover:not(:disabled) {
    background: color-mix(in srgb, var(--c-primary-alt) 22%, var(--c-primary));
    border-color: color-mix(in srgb, var(--c-primary-alt) 22%, var(--c-primary));
    box-shadow: 0 4px 14px -2px color-mix(in srgb, var(--c-primary) 60%, transparent);
}

.btn.danger { border-color: var(--s-alarm); color: var(--s-alarm); box-shadow: var(--elev-sm); }
/* Fills on hover rather than darkening its outline: a destructive commit should look different
   under the pointer from a button that merely navigates. */
.btn.danger:hover:not(:disabled) {
    background: var(--s-alarm);
    border-color: var(--s-alarm);
    color: var(--c-primary-ink);
}

/* Applies to every .btn: a disabled control must look disabled, not merely refuse to work. */
.btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
@media (prefers-reduced-motion: reduce) {
    .btn { transition: 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; }
}



/* =============================================================================
   Form grid (§3)
   ============================================================================= */

.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); max-width: 720px; }
.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; }
/* ---- The field: a caption, a rule, and nothing else -------------------------
   THE ONE CHANGE THAT TOUCHES EVERY FORM IN THE APP. These were boxed fields — 1px border, control
   radius, raised fill — and the sign-in page's fields have always been underlines. Two kinds of
   input in one product, on either side of a single click.

   The underline won because it is the one the customer sees first, and because a form is easier to
   read without them: `.form-grid label` is already a flex column with the caption ABOVE its field,
   so the box was drawing a second boundary around something the layout had already separated. On
   the verify log's filter row that is a dozen rectangles removed; in a modal it is what lets the
   fields breathe at the wider body padding.

   `--field-*` (tokens.css) carries the rule, shared with .login-input and .input, so the three
   cannot drift. background-color, not the `background` shorthand — `select` below paints its
   chevron as a background IMAGE, and the shorthand would wipe it out on every drop-down in the app.

   `textarea` joins the list for multi-line notes: it needs a line height an input does not, and it
   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`, not `both` — widening it past its grid column breaks the
   card. It keeps a faint fill, because a box you can DRAG needs an edge to say where its corner is.

   The focus indicator is the RULE — thicker, accent-coloured, with a soft glow. Not an outline: an
   outline around an underline field draws back the box this design exists to remove, and it fires
   on whichever field a modal autofocuses, so a form would open looking boxed. */
.form-grid input:not([type="checkbox"]), .form-grid select, .form-grid textarea {
    padding: var(--sp-2) 2px;
    border: none;
    border-bottom: 1px solid var(--field-line);
    border-radius: 0;
    background-color: transparent;
    color: var(--c-text);
}
.form-grid input::placeholder, .form-grid textarea::placeholder { color: var(--field-placeholder); }
.form-grid input:not([type="checkbox"]):hover, .form-grid select:hover, .form-grid textarea:hover {
    border-bottom-color: var(--field-line-hover);
}
.form-grid input:not([type="checkbox"]):focus, .form-grid select:focus, .form-grid textarea:focus,
.form-grid input:not([type="checkbox"]):focus-visible, .form-grid select:focus-visible {
    outline: none;
    border-bottom: 2px solid var(--c-focus);
    padding-bottom: calc(var(--sp-2) - 1px);   /* hold the baseline as the rule thickens */
    box-shadow: 0 6px 10px -8px var(--c-focus);
}
.form-grid textarea {
    font: inherit;
    line-height: 1.5;
    resize: vertical;
    min-height: 4.5rem;
    padding: var(--sp-2) var(--sp-3);
    border: 1px solid var(--c-border);
    border-radius: var(--radius-control);
    background-color: color-mix(in srgb, var(--c-surface) 55%, var(--c-surface-raised));
}
.form-grid textarea:focus { border: 1px solid var(--c-focus); padding-bottom: var(--sp-2); }

/* A field shown for reference but not editable here. Recessed rather than raised, so "you cannot
   type in this" is visible before it is clicked — which on an underline field means bringing a fill
   BACK, since there is no border left to soften. Its rule goes dotted for the same reason: with no
   box, the line under the field is the only place a state can be shown at rest. */
.form-grid input[readonly] {
    background-color: color-mix(in srgb, var(--c-text-muted) 7%, var(--c-surface-raised));
    border-bottom-style: dotted;
    color: var(--c-text-muted);
    cursor: default;
    padding-left: var(--sp-2);
    padding-right: var(--sp-2);
}
.form-grid input[readonly]:hover { border-bottom-color: var(--field-line); }

/* ---- Field-level validation (FieldError.razor) -----------------------------
   `.invalid` on the control. Marked on submit and cleared as the box is corrected, so the state
   must stay visible while the reader is typing the fix INTO the focused box — which is why the
   focus rule below repeats the red rather than letting the accent take over.
   The rule alone carries it at rest, doubled in weight: on an underline field a colour change on a
   1px line is not enough of a signal on its own, and the message under it says the rest. */
.form-grid input.invalid, .form-grid select.invalid, .form-grid textarea.invalid {
    border-bottom: 2px solid var(--s-alarm);
    padding-bottom: calc(var(--sp-2) - 1px);
}
.form-grid textarea.invalid { border: 1px solid var(--s-alarm); padding-bottom: var(--sp-2); }
.form-grid input.invalid:focus, .form-grid select.invalid:focus, .form-grid textarea.invalid:focus {
    border-bottom-color: var(--s-alarm);
    box-shadow: 0 6px 10px -8px var(--s-alarm);
}

/* ---- 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; }

.inline-field { display: flex; align-items: center; gap: var(--sp-2); font-size: 0.8125rem; }
/* The same underline as a form field, at toolbar density: a filter row sits beside the result set
   it narrows, and a row of boxed controls above a bordered table is three kinds of rectangle
   stacked. `select` as well as `input` — the pickers on this pattern were unstyled native
   drop-downs sitting beside styled boxes before the rule named them. */
.inline-field input:not([type="checkbox"]), .inline-field select {
    padding: var(--sp-1) 2px;
    border: none;
    border-bottom: 1px solid var(--field-line);
    border-radius: 0;
    background-color: transparent;
    color: var(--c-text);
}
.inline-field input:not([type="checkbox"]):hover, .inline-field select:hover { border-bottom-color: var(--field-line-hover); }
.inline-field input:not([type="checkbox"]):focus, .inline-field select:focus,
.inline-field input:not([type="checkbox"]):focus-visible, .inline-field select:focus-visible {
    outline: none;
    border-bottom: 2px solid var(--c-focus);
    padding-bottom: calc(var(--sp-1) - 1px);
}
/* The chevron's clearance at toolbar density — see the `select` rule below for why this cannot be
   stated there. After the rule above, so it survives that shorthand `padding`. */
.inline-field select { padding-right: var(--sp-6); }


/* =============================================================================
   The toggle switch — every checkbox in the app
   ============================================================================= */

/* Asked for as "a toggle instead of a checkbox, everywhere we use a checkbox", and built by
   RESTYLING the native control rather than by replacing it with divs and a click handler. The
   element is still <input type="checkbox">: it still announces as a checkbox with a checked state,
   still toggles on Space, still binds through @bind, still posts in a form with JavaScript off, and
   the component tests still drive it with .Change(false). A div-and-JS switch gives up all five and
   buys nothing this rule cannot draw.

   Element selector, not a class, and on purpose: the ask was every checkbox, and a class would only
   be every checkbox somebody remembered to mark. The four that exist today — Active and Air-gapped
   on the client forms, Air-gapped on Add client, Unregistered-only on the verify log — all become
   toggles from this one block, and so does the fifth whenever it is written.

   The underline-field rules above (.form-grid input, .inline-field input) now exclude
   [type="checkbox"] so this is not fighting a border-bottom it never wanted. That exclusion is what
   makes this block work; do not drop the :not() without moving the rule. */
input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;
    flex: none;               /* .inline-field is a flex row; the track must not stretch or squash */
    position: relative;
    width: 38px;
    height: 22px;
    margin: 0;
    padding: 0;
    border-radius: 999px;
    cursor: pointer;
    vertical-align: middle;

    /* The OFF state needs a visible boundary, not just a pale fill. A switch is a control whose
       shape carries its meaning, so its edge is held to 3:1 (WCAG 1.4.11) — and --field-line, which
       is right for a hairline under text, is about 1.6:1 on white and would leave the track a
       ghost. The border is --field-line-hover, ~3.5:1, and the fill sits inside it. */
    background-color: var(--c-selection);
    border: 1px solid var(--field-line-hover);
    transition: background-color var(--motion-fast) var(--motion-ease),
                border-color var(--motion-fast) var(--motion-ease);
}

/* The thumb. Dark when off and white when on, rather than white throughout: white on the pale off
   track is invisible, and the two states have to differ by more than the position of one disc. */
input[type="checkbox"]::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 2px;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background-color: var(--s-offline);
    transform: translateY(-50%);
    transition: transform var(--motion-base) var(--motion-ease),
                background-color var(--motion-fast) var(--motion-ease);
}

input[type="checkbox"]:hover { border-color: var(--c-primary); }

input[type="checkbox"]:checked {
    background-color: var(--c-primary);
    border-color: var(--c-primary);
}
input[type="checkbox"]:checked::before {
    /* 38 track − 2 border − 2 gap × 2 − 16 thumb = 16px of travel. */
    transform: translate(16px, -50%);
    background-color: var(--c-primary-ink);
}

/* A RING here, and it does not contradict the no-outline rule the field blocks above state twice.
   That rule exists because an outline redraws the box an underline field was designed to remove,
   and because it fires on whatever field a modal autofocuses. A switch has no underline to thicken
   instead — the track IS the box — so a ring is the only indicator left, and no form autofocuses
   one. */
input[type="checkbox"]:focus-visible {
    outline: var(--focus-width) solid var(--c-focus);
    outline-offset: 2px;
}

input[type="checkbox"]:disabled {
    cursor: default;
    opacity: 0.55;
}

/* The thumb slides. That is the one piece of motion the control has, and it is the piece that says
   a switch was thrown rather than a box ticked — so under reduced motion the state still changes
   instantly, it just does not travel. */
@media (prefers-reduced-motion: reduce) {
    input[type="checkbox"],
    input[type="checkbox"]::before { transition: none; }
}

/* appearance:none opts the control out of the platform's high-contrast painting, so in forced
   colours this would otherwise be an invisible track with an invisible thumb. Redrawn here in
   system colours, where the CHECKED state is a filled track — colour alone is all forced-colors
   leaves to work with, and the thumb still moves. */
@media (forced-colors: active) {
    input[type="checkbox"] {
        border-color: CanvasText;
        background-color: Canvas;
    }
    input[type="checkbox"]::before { background-color: CanvasText; }
    input[type="checkbox"]:checked { background-color: Highlight; }
    input[type="checkbox"]:checked::before { background-color: HighlightText; }
    input[type="checkbox"]:disabled { border-color: GrayText; }
    input[type="checkbox"]:disabled::before { background-color: GrayText; }
}

/* ADDITION, not a lift. ProDAQ collapses .form-grid to one column only inside a modal; every other
   form of its lives in a card wide enough for two. §3 asks for it on every form grid — "at 640px:
   .form-grid is one column ... the page never scrolls sideways" — and this app's forms appear on
   bare pages as well as in modals. Stated once here rather than per context, so a form added later
   cannot miss it, and at the same 640px the modal rules use, so a form does not collapse at one
   width on a page and a different one in a dialog. */
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }


/* =============================================================================
   Native <select> — defensive, for the ones not yet replaced (§5)
   ============================================================================= */

/* ---- Native <select> -------------------------------------------------------
   Every drop-down that is not a ComboBox or a Select. Two rules already agreed on the BOX
   (`.form-grid select` and `.inline-field select`) and neither 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 two 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 neither of those two would be left as bare text with a
   chevron after it. The rule (now a bottom line, not a 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, and it
   would then be wrong on a disabled field, which is the one other state this control has. Two
   diagonal bands meeting corner to corner draw the same "v" as the ComboBox's boxicon chevron, and
   being `currentColor` they follow the field's own ink 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) 2px;
    padding-right: var(--sp-6);
    border: none;
    border-bottom: 1px solid var(--field-line);
    border-radius: 0;
    background-color: transparent;
    color: var(--c-text);
    cursor: pointer;

    /* Two 5px squares meeting corner to corner — "\" then "/" — ending 2px in from the right edge.
       They used to end 7px in, to clear a border that no longer exists: on an underline field the
       chevron belongs at the end of the RULE, not inset from a box edge that is not drawn.

       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, and would
       then be wrong on a disabled field, which is the one state this control actually has. */
    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 7px center, right 2px center;
}

/* Room for the chevron at each density. Stated per context, and not once above, because all the
   rules that size a select set `padding` as a shorthand — which resets any right-hand padding this
   one asked for. `.form-grid` is the roomy one; the toolbar density is set after its own rule. */
.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 same rule-thickens-and-takes-the-accent focus every other field in the app answers a Tab
   with. NOT an outline any more: it is the one control that had a box left, and a ring around a
   field with no border is a rectangle appearing out of nowhere.
   `:focus-visible` rather than `:focus`: a select takes focus on a mouse press, and marking it for
   the length of a click is noise on a control the pointer is already on. */
select:focus-visible {
    outline: none;
    border-bottom: 2px solid var(--c-focus);
    padding-bottom: calc(var(--sp-1) - 1px);
    box-shadow: 0 6px 10px -8px var(--c-focus);
}


/* =============================================================================
   Dropdowns: the one .combo surface — Select, ComboBox, DatePicker (§5/§6)
   ============================================================================= */

/* ---- 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 a raised surface with a border, a card radius and an overlay shadow. It used to be
   described as "the account menu's panel at a different anchor"; that menu is gone (sign-out is a
   button on the bar now), so this is the only dropdown surface left in the app and the rules below
   are its definition rather than a second copy of one. z-index 40 — above the content canvas, far
   below the modals and the reconnect banner. */
/* The BOX is the wrapper and the rule is the wrapper's, exactly as it was when this was a bordered
   control — only now the box is a single bottom rule. That still has to live on the wrapper and not
   on the input inside it: the caret is a sibling button, and a rule under the text half alone would
   stop short of the chevron and read as a broken underline. */
.combo { position: relative; }
.combo-control {
    display: flex; align-items: stretch;
    border: none;
    border-bottom: 1px solid var(--field-line);
    border-radius: 0;
    background: transparent;
}
.combo-control:hover { border-bottom-color: var(--field-line-hover); }
/* The focus state belongs to the whole control, so it is drawn here off :focus-within and
   suppressed on the input — two indicators, one inside the other, is what happens if you forget the
   second half. `padding-bottom` on the CONTROL holds the caret and the text on their baseline as
   the rule thickens, which is the same trick every other field in the app uses. */
.combo-control:focus-within {
    outline: none;
    border-bottom: 2px solid var(--c-focus);
    padding-bottom: 0;
    box-shadow: 0 6px 10px -8px var(--c-focus);
}
/* Bare, and it has to say so explicitly. `.form-grid input` now paints a rule under every input in
   a form, and `.form-grid input:hover` outranks `.form-grid .combo-input` on specificity — so
   without these the ComboBox would carry TWO underlines, one under the whole control and a second
   under the text half of it. The `:is()` list is the cheapest way to state at-rest, hover and focus
   in one place. */
.form-grid .combo-input,
.form-grid .combo-input:hover,
.form-grid .combo-input:focus,
.form-grid .combo-input:focus-visible {
    flex: 1; min-width: 0;
    border: 0; background: none; border-radius: 0; box-shadow: none;
    padding: var(--sp-2) 2px;
    color: var(--c-text);
    outline: none;
}
.combo-control:has(.combo-input.invalid) { border-bottom: 2px solid var(--s-alarm); padding-bottom: 0; }
.combo-control:has(.combo-input.invalid):focus-within {
    border-bottom-color: var(--s-alarm);
    box-shadow: 0 6px 10px -8px var(--s-alarm);
}

.combo-caret {
    display: flex; align-items: center;
    padding: 0 var(--sp-1) 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 var(--motion-fast) ease; }
@media (prefers-reduced-motion: reduce) {
    .combo-caret .icon { transition: none; }
}
.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 12px 32px -8px rgb(45 10 80 / 0.26);
}
.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. */
}
/* NO ring of its own any more. `.combo-control` is the control, and now that its state is a bottom
   RULE rather than an outline, `:focus-within` catches a focused button exactly as it catches a
   focused input — so the Select and the ComboBox answer a Tab identically without this rule
   restating it. What it used to draw, an `outline` offset from a bordered box, would now be a
   rectangle appearing around a field that has no box at all. */
.select-trigger:focus-visible { outline: none; }
.select-trigger:disabled { cursor: default; color: var(--c-text-muted); }

/* Truncates rather than widening the field: a company 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.
   Its padding matches `.combo-input`'s to the pixel — the two controls sit in the same grid and
   their values have to start on the same vertical line. */
.select-value {
    flex: 1 1 auto; min-width: 0;
    padding: var(--sp-2) 2px;
    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; }

.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;
}

/* EVERY panel inside a form modal floats OVER the form, and none of them is clipped by it.

   Two rules used to live here and they disagreed. The calendar was `fixed` and escaped; the list was
   left absolute and its height capped to 11rem instead, on the reasoning that a shorter panel reaches
   the body's edge later. That only moved the fault: `.modal-body` scrolls, a scrolling box clips on
   BOTH axes, and a six-row list opened from the lower half of a form still lost its last rows —
   which on Generate key is the term a reader came to pick. Capping the height paid for the clip in
   the one currency the control could not spare and did not remove it.

   `fixed` is the fix for both, for the reason the calendar already had it: it escapes the clip
   WITHOUT joining the flow. The dialog does not grow taller every time a row is clicked, and the
   form under the panel stays exactly where it was. Being one rule rather than two is the other half
   — a dropdown in a modal is a dropdown, and there was never a reason for the list and the calendar
   to behave differently.

   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/popover.js then refines it: restates the list's width from its field (a fixed box with
   auto offsets shrinks to its content), 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 .combo-panel {
    position: fixed;
    inset: auto;
    margin-top: var(--sp-1);
}
/* The calendar is a fixed six rows and sets its own width; only its time columns scroll. */
.modal-body .datepick-panel { max-height: none; }


/* =============================================================================
   Modal card — ConfirmDialog's base (§4)
   ============================================================================= */


/* Modal confirmation (ConfirmDialog.razor). The base card FormModal also builds on.

   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.

   THE SCRIM IS WHITE, and that decides two other rules further down — read this before darkening it.

   A dark wash was the obvious choice and it is what stood here: it dims the page, and the white card
   then needs nothing at all to look like it is in front. The trouble is what it does to the ROOM the
   dialog is in. This app is a light, near-white product with a pastel gradient across the top; a 52%
   violet-black scrim turned the whole page a mid grey-violet the moment any dialog opened, so every
   modal read as the lights going out rather than as a sheet being laid on the desk.

   White at 72% with a real blur does the same job the other way up. The blur is what actually does
   the work — it is what says "this is not the thing you are reading" — and the white lifts the page
   towards the card instead of away from it, which keeps the screen feeling like the same screen.
   The saturation lift stops the blurred page going flat grey underneath, the same trick .login-card
   uses on its own frosted glass, so the two surfaces are made of the same material.

   WHAT IT COSTS, and where it is paid: a white card on a white veil has no contrast to sit on, so
   the elevation that was optional under a dark scrim is now load-bearing. .modal-card carries a
   hairline border AND a two-step shadow for exactly this reason. Put a dark scrim back and those
   become belt-and-braces; take them away while the scrim is white and the dialog loses its edges.

   `backdrop-filter` degrades to nothing on a browser that lacks it. That matters more here than it
   did with a dark wash: without the blur, 72% white over the page is a faint haze rather than a
   scrim, and the card's border and shadow become the ONLY things separating the two. They are
   enough — that is the fallback this is designed around, not an accident. */
.modal-backdrop {
    position: fixed;
    inset: 0;
    z-index: 950;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--sp-4);
    background: rgb(255 255 255 / 0.50);
  /*  backdrop-filter: blur(6px) saturate(1.4);
    -webkit-backdrop-filter: blur(6px) saturate(1.4);*/
    animation: modal-fade var(--motion-base) var(--motion-ease);
}

.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%);
    /* --radius-panel, the app's big radius, shared with the page card. A dialog and the sheet it
       floats over are the two surfaces a screen is made of, and they should be the same shape. */
    border-radius: var(--radius-panel);
    /* THE BORDER IS LOAD-BEARING NOW. It was deliberately absent while the scrim was dark — the
       page dimmed, the card stayed white, and elevation alone said which was in front. Against the
       white scrim above there is no such contrast to borrow: white card, white veil, and the only
       thing left to draw the edge is the edge. */
    border: 1px solid var(--c-border);
    background: var(--c-surface-raised);
    color: var(--c-text);
    /* Three layers, and each is doing a different job on a ground this pale:
         contact    a tight, relatively dark shadow right under the rim. This is what actually
                    reads as "lifted" on white — the wide soft one below is nearly invisible there
                    and was carrying the whole load on its own before.
         ambient    the wide violet spread, for the sense of height rather than the edge.
         highlight  the 1px inset white along the top, where a real raised sheet catches the light.
       Ordered near-to-far, which is also the order they matter in on this backdrop. */
    box-shadow:
        0 2px 6px rgb(45 10 80 / 0.10),
        var(--elev-overlay),
        inset 0 1px 0 rgb(255 255 255 / 0.9);
    animation: modal-rise var(--motion-base) var(--motion-ease);
    /* The card owns focus while open, but it is not itself a control — no ring on the container. */
    outline: none;
    /* Clips the head's gradient and the foot's tinted band to the rounded corners; without it they
       square off the top and bottom of the card, which at an 18px radius is not subtle. */
    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. A dialog mounted in the row it belongs to sits in a
       cell that is `text-align: right`, 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. It sits ON the
   gradient head below, which is why that head is suppressed for the danger variant — a brand
   gradient under a red warning rule says two different things at once. */
.modal-card.danger { border-top: 3px solid var(--s-alarm); }

/* ---- The head band --------------------------------------------------------
   THE CHROME GRADIENT — the same dark stops the rail wears (--chrome-stops in tokens.css), with
   light ink. It used to be --c-surface: a grey band on a white card, which is a header only because
   it is a different grey. What a dialog actually needs from its head is to be UNMISTAKABLY the top
   of a thing that has taken over the screen, and a dark band does that at a glance.

   IT USED TO WEAR --brand-stops WITH DARK INK, and that is what broke. Those stops span a
   three-to-one luminance range, and the band runs 100deg — so the title and subtitle, which sit at
   the LEFT, sat on the darkest part of it. The subtitle measured about 1.4:1 there. No ink fixes a
   sweep that wide; the surface had to change. See the CHROME block in tokens.css.

   Everything on this band therefore takes --c-on-chrome / --c-on-chrome-muted. Do not reach for
   --c-on-brand here: it is dark, chosen for the topbar, and it is invisible on this ground. The one
   exception is .modal-head-icon, whose frosted plate is LIGHT — dark ink is correct on the plate.

   The gradient runs 100deg across a shallow band rather than the rail's 180deg: a steep angle across
   a 56px-tall box shows one stop and loses the other two. */
.modal-head {
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    padding: var(--sp-4);
    background: linear-gradient(100deg, var(--chrome-stops));
    color: var(--c-on-chrome);
    border-bottom: 1px solid rgb(255 255 255 / 0.14);
}
/* `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. 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.

   `color: inherit` and `text-transform: none` ARE LOAD-BEARING, and the two of them are a bug fix.

   This is an `<h2>`, and app.css styles the bare element: `color: var(--text-dim)` and
   `text-transform: uppercase`, because in that sheet an h2 is a small-caps section label inside a
   card. `.modal-title` outranks it on specificity for every property it DECLARES — and it declared
   neither of those two, so a dialog title was silently rendered UPPERCASE in muted grey, on a
   coloured head band that grey was never chosen against. On the old pastel stops that read as washed
   out; on the current greys "NEW CLIENT" all but vanished into its own header.

   `inherit` rather than `--c-on-brand` on purpose: the head decides its own ink, and the danger
   variant repaints that head red. A title that named the token would stay dark on a red band.

   And the note this replaces still stands: a dialog title is a SENTENCE — "Delete this
   installation?" — and small-capping a question mark is how a heading stops reading as one. */
.modal-title {
    margin: 0;
    font-family: var(--font-display);
    font-size: 1.0625rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    color: inherit;
    text-transform: none;
}
/* A destructive dialog drops the gradient entirely and takes a red wash instead. Not "gradient plus
   red text": the head is the one element on the card that says what kind of question this is, and
   it cannot say "brand" and "irreversible" in the same 56 pixels. */
.modal-card.danger .modal-head {
    background: color-mix(in srgb, var(--s-alarm) 9%, var(--c-surface-raised));
    color: var(--s-alarm);
    border-bottom-color: color-mix(in srgb, var(--s-alarm) 22%, transparent);
}

/* 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-6); 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; }

/* The foot is a tinted plinth, not a second white band: it is the only part of the card that
   commits anything, and the seam above it is what stops Cancel and Save reading as the last row of
   the form rather than as the answer to it. */
.modal-foot {
    display: flex;
    justify-content: flex-end;
    gap: var(--sp-2);
    padding: var(--sp-4) var(--sp-6);
    background: var(--c-surface);
    border-top: 1px solid var(--c-border);
}

/* 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; }


/* =============================================================================
   Form modal (§4)
   ============================================================================= */

/* ---- 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 `.combo-control:has(.combo-input.invalid)` above sets the precedent. */
.form-modal .modal-head:has(.modal-subtitle) { align-items: flex-start; }

/* A FROSTED square rather than a tinted one. The 10%-primary tile this used to be was drawn for a
   grey head band; on the gradient it is a violet wash on violet and disappears. White at 55% with a
   white edge is the same object the login page's .float-shape is — glass over the gradient — and it
   reads on all three stops rather than on one.

   IT IS THE ONE LIGHT OBJECT ON A DARK BAND, which is why its ink stays DARK while everything else
   in the header went light: 55% white over the chrome greys is a mid-light plate, and --c-text on
   that plate measures about 7:1. Ink it --c-on-chrome and the glyph vanishes into its own tile.

   32px, not a bare 18px glyph: at 18px an icon beside 1.06rem display text reads as a stray mark,
   and the plate is what makes "what kind of form is this" legible before the title is. */
.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: rgb(255 255 255 / 0.55);
    border: 1px solid rgb(255 255 255 / 0.75);
    color: var(--c-text);
}
/* A destructive form's head is a red wash rather than the gradient (see .modal-card.danger
   .modal-head), so its tile goes back to the tinted-square recipe against that ground. */
.form-modal.danger .modal-head-icon {
    background: color-mix(in srgb, var(--s-alarm) 10%, transparent);
    border-color: color-mix(in srgb, var(--s-alarm) 25%, 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. It must not compete with the title, and a reader who
   already knows why they opened the modal should be able to skip it — but --c-text-muted is a grey
   picked against a WHITE CARD, so on this band it does its stepping-back by disappearing. It takes
   the chrome's own muted ink instead and steps back with size and weight rather than with contrast.
   That is the whole trick with a coloured header: the hierarchy is type, not opacity. */
.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-on-chrome-muted);
}
.form-modal.danger .modal-subtitle { 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 X 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 heavy on one end.
   Its hover fill is frosted white for the same reason the rail's items are: --c-selection is a
   violet tint mixed against white and is invisible on this ground. A LIGHT wash, at 0.16 — the
   tile's 0.55 is a plate you read dark ink on, and this is a hover state that has to keep the light
   glyph it started with. */
.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-on-chrome-muted);
    cursor: pointer;
    transition: background var(--motion-fast) var(--motion-ease);
}
.modal-close:hover:not(:disabled) {
    background: rgb(255 255 255 / 0.16);
    border-color: rgb(255 255 255 / 0.3);
    color: var(--c-on-chrome);
}
.modal-close:focus-visible {
    outline: var(--focus-width) solid var(--c-on-chrome);
    outline-offset: 2px;
}
.form-modal.danger .modal-close { color: color-mix(in srgb, var(--s-alarm) 75%, var(--c-text)); }
.form-modal.danger .modal-close:hover:not(:disabled) {
    background: color-mix(in srgb, var(--s-alarm) 10%, transparent);
    border-color: color-mix(in srgb, var(--s-alarm) 25%, transparent);
    color: var(--s-alarm);
}
.modal-close:disabled { opacity: 0.5; cursor: default; }
@media (prefers-reduced-motion: reduce) {
    .modal-close { transition: none; }
}

/* A form is denser than a confirmation's paragraph, so the body gets the page's own text size back.
   The generous side padding is what lets a column of underline fields read as a form: with no boxes
   left to define the columns, the white space around them is the only thing that does. */
.form-modal .modal-body { padding: var(--sp-6); 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. */

/* 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; } }
/* 8px and a 2% scale, up from a bare 2px slide. The card is a big rounded panel on a white frosted scrim
   now, and at 2px it did not read as arriving — it read as a repaint. Still short and still one
   direction: the point is to say "this came from somewhere", not to perform. */
@keyframes modal-rise {
    from { opacity: 0; transform: translateY(8px) scale(0.98); }
    to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
    .modal-backdrop, .modal-card { animation: none; }
}


/* =============================================================================
   Shell chrome — the rail, the topbar, and the grid that holds them
   =============================================================================

   THIS SECTION REVERSES §2. That section said: do not copy ProDAQ's 240px rail, this app has four
   destinations and a rail beside four items is furniture. A rail was asked for and built; §2 still
   reads the other way, so this note is the tie-break. The rest of §2 — the 56px bar, the account
   zone at its right — is unchanged.

   TWO SURFACES, TWO PALETTES, AND THEY NO LONGER MEET. The rail used to run the brand stops
   top-to-bottom while the topbar ran the same three left-to-right, so both began on the same stop at
   the corner they touch — one gradient, one join. That arrangement died with the move to greys: a
   single set of stops cannot be dark enough for a navigation column in light ink and pastel enough
   for a login stage in dark ink at the same time, and the version that tried had four unreadable nav
   labels to show for it.

   So the rail owns --chrome-stops (dark, light ink) and the bar is currently unpainted (light, dark
   ink). Restoring the bar's gradient line restores a pastel band above a dark column — a deliberate
   contrast rather than a seam, but not the old join, and there is no way back to that join without
   giving up the rail's dark stops.

   INK IS PER SURFACE. The rail takes --c-on-chrome / --c-on-chrome-muted; anything on the topbar takes
   --c-on-brand / --c-on-brand-muted, which are dark and stay correct whether or not the bar's
   gradient is on. Do not mix the two sets — they are chosen against opposite backgrounds. */

/* Rail beside canvas, exactly as ProDAQ's is: two columns, the rail fixed and the rest fluid.

   The shell is exactly the viewport and clips, so the rail and the topbar stay put while a long
   page moves under them, rather than the whole window scrolling and taking both off screen.
   .content-canvas (lifted, above) is the one thing that scrolls.

   `min-width: 0` on the second column is not optional. A grid track defaults to `min-content`, and
   a wide table inside .content-canvas would otherwise push that track wider than the viewport and
   make the PAGE scroll sideways — which is §9's one hard rule about width. */
.app-shell {
    display: grid;
    grid-template-columns: 240px minmax(0, 1fr);
    height: 100vh;
    overflow: hidden;
    background: var(--c-bg);
    color: var(--c-text);
    font-family: var(--font-body);
}

/* THE RAIL IS DARK, AND ITS INK IS LIGHT. That is a reversal, and it is the point.

   The rail used to wear --brand-stops — the login page's pastel violet — with DARK ink on it,
   because white on a pastel is about 1.6:1. The palette has since been retuned to greys, and a
   mid-grey is the one background on which neither choice works: dark ink washes out against it and
   white ink washes out against its lighter end. That is what made the four labels unreadable.

   So the rail no longer borrows the brand gradient at all. It has --chrome-stops of its own, three
   steps of the SAME grey family kept dark enough that white ink clears AA at every point of the
   sweep: white on the lightest stop measures ~7:1, and --c-on-chrome-muted (the inactive label)
   ~4.8:1 there, which is the value that matters because those labels are 13px.

   If the greys are retuned again, retune them HERE and keep the lightest stop at or below #5A5761.
   The check is the muted ink against the BOTTOM of the rail, not the top — the top is always the
   darkest and always passes, which is how the last version looked fine in a screenshot of the
   header and failed on the items below it. */
.rail {
    display: flex;
    flex-direction: column;
    min-height: 0;
    padding: var(--sp-4) var(--sp-3);
    gap: var(--sp-4);
    background: linear-gradient(180deg, var(--chrome-stops));
    color: var(--c-on-chrome);
    /* Cast onto the canvas rather than a border: a hairline on a gradient is either invisible at
       the light end or a hard line at the dark one, and the shadow is correct at every stop. */
    box-shadow: 1px 0 3px rgb(45 10 80 / 0.10);
    /* Above the canvas, so the shadow falls ON the page rather than under it. Below the topbar's
       10 for the same reason at the corner. */
    position: relative;
    z-index: 9;
}

/* The wordmark heads its own column, so it needs no hairline to be a zone — the gap under it does
   what the topbar version needed a border for.

   It is now the LOGO, not type — `.rail-brand strong`/`span` are gone with the text they styled.
   See AdminLayout.razor for why the lockup wears a white plate on this dark rail and the round mark
   does not. */
.rail-brand {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--sp-2) var(--sp-3);
    color: var(--c-on-chrome);
    text-decoration: none;
}

/* `width: 100%` + `height: auto` against the intrinsic width/height on the <img> itself, so the row
   reserves its final height from the first paint and the nav below it does not jump when the file
   lands. The plate's padding is asymmetric on purpose: the lockup's own art has a little air at its
   left and right ends already, and none above or below after the crop. */
.rail-brand__lockup {
    display: block;
    width: 100%;
    height: auto;
    padding: 10px 12px;
    background: #FFFFFF;
    border-radius: var(--radius-card);
    box-shadow: 0 1px 2px rgb(20 8 40 / 0.25);
}

/* Swapped in by the 900px collapse at the foot of this file. */
.rail-brand__mark { display: none; width: 36px; height: 36px; }

/* `min-height: 0` + `overflow-y: auto` so a fifth and sixth destination scroll inside the rail
   rather than pushing the column past the viewport. Nothing needs it at four items; it costs
   nothing and it is the failure that otherwise shows up only once somebody adds a screen. */
.rail-nav {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-height: 0;
    overflow-y: auto;
}

/* A FILLED PILL, not an underline. That is the whole reason the rail was worth building: in a
   horizontal bar the active marker had to be a 2px rule under one word, because a filled item in a
   56px band reads as a button. In a column there is room for the strongest "you are here" a nav can
   give, and the eye finds it without reading a single label.

   Inactive items are NOT dimmed into unreadability — --c-on-chrome-muted is a light ink chosen to
   clear AA against the LIGHTEST stop of the rail, so it holds at the foot of the column as well as
   at its head. ACTIVE is said with a fill, full-strength ink and weight instead of with brightness
   alone, which is what keeps the marker legible for a reader who cannot separate the two greys. */
.rail-item {
    display: flex;
    align-items: center;
    gap: var(--sp-3);
    padding: var(--sp-2) var(--sp-3);
    border-radius: var(--radius-control);
    font-size: 0.8125rem;
    color: var(--c-on-chrome-muted);
    text-decoration: none;
    white-space: nowrap;
    transition: background var(--motion-fast) var(--motion-ease);
}
/* Frosted white. On a dark rail it lightens all three stops equally, which a tint mixed against a
   picked grey does not — that is why --c-selection is not used anywhere on this surface. The active
   fill is heavier than it was on the pastel (0.90 against 0.62) because a 62% white veil over a
   dark stop still reads as grey; the pill has to become a light SLAB for the dark ink on it to
   work. */
.rail-item:hover { background: rgb(255 255 255 / 0.14); color: var(--c-on-chrome); }
.rail-item.active {
    background: rgb(255 255 255 / 0.92);
    color: var(--c-text);
    font-weight: 600;
}
/* The icon follows the label's weight of attention, so an inactive row is quiet as a whole rather
   than a bright glyph beside faded words. */
.rail-item .icon { opacity: 0.85; }
.rail-item.active .icon { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
    .rail-item { transition: none; }
}

/* THE FOOT — sign out, at the bottom of the column where an account action belongs.

   `margin-top: auto` and not a fixed position: it sits under the nav on a tall window and is pushed
   down by nothing on a short one, so it can never overlap the last destination. The hairline is the
   zone marker — the gap that separates the brand from the nav does not work here, because a reader
   scanning down a column reads "last item" long before they read "different group".

   The button wears .rail-item so it is the same row as the four above it, plus .rail-logout for the
   handful of properties a <button> does not inherit. A button and not a link, because signing out
   changes state: it stays inside its POST form with its antiforgery token, and a GET sign-out is
   one a prefetcher or a link-scanner can fire on the operator's behalf. */
.rail-foot {
    margin-top: auto;
    padding-top: var(--sp-3);
    border-top: 1px solid rgb(255 255 255 / 0.14);
}
.rail-logout {
    width: 100%;
    border: 0;
    background: none;
    font-family: var(--font-body);
    text-align: left;
    cursor: pointer;
}
.rail-logout:focus-visible {
    outline: var(--focus-width) solid var(--c-on-chrome);
    outline-offset: 2px;
}

.app-main {
    display: flex;
    flex-direction: column;
    min-width: 0;
    min-height: 0;
}

/* Under 900px the labels stop fitting beside a table without squeezing the canvas, so the rail
   goes to icons only. It does NOT become a horizontal strip: the destinations keep their order and
   their position, and only the words go — a nav that moves at a breakpoint is one the hand has to
   re-learn. The title attribute is not added here because each item still has its glyph in the same
   row it always occupied; if a fifth destination ever makes two glyphs ambiguous, it needs one.
   The sign-out row is the exception and DOES carry one: it is the only rail item whose glyph has no
   duplicate elsewhere on the screen once the account menu's label is hidden too. */
@media (max-width: 900px) {
    .app-shell { grid-template-columns: 64px minmax(0, 1fr); }
    .rail { padding: var(--sp-4) var(--sp-2); align-items: stretch; }
    /* The lockup is 4:1; inside a 48px content column it would be 12px tall and "LICENSE" would be
       a smear. The round mark is what the collapsed rail shows, and it needs no white plate. */
    .rail-brand { padding: var(--sp-2) 0; }
    .rail-brand__lockup { display: none; }
    .rail-brand__mark { display: block; }
    .rail-item { justify-content: center; padding: var(--sp-3) 0; gap: 0; }
    .rail-item span { display: none; }
    .rail-logout { text-align: center; }
}


/* =============================================================================
   Expiry watch bands (§7) — ADDITIONS
   =============================================================================

   The dashboard's hero groups its rows into the bands build prompt §8 names, with a heading
   row between groups rather than a separate table each. One table means one set of column
   widths, so "days left" lines up down the whole list and the eye can run the column.       */

/* A heading row inside the body. Sticky is deliberately NOT set on it: .data-table th is,
   and two sticky rows in one table stack on each other and eat the viewport. */
.data-table tr.band-head th {
    position: static;
    padding-top: var(--sp-4);
    background: var(--c-bg);
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--c-text-muted);
    box-shadow: none;
}
.data-table tr.band-head:first-child th { padding-top: var(--sp-2); }

/* Colour carries EMPHASIS, never the meaning on its own (WCAG 1.4.1): every band also states
   its name in words in the heading row above the group, and every row states its own date. */
.data-table tr.band-head.band-expired th    { color: var(--s-alarm); }
.data-table tr.band-head.band-critical th   { color: var(--s-alarm); }
.data-table tr.band-head.band-warning th    { color: var(--s-alert); }
.data-table tr.band-head.band-perpetual th  { color: var(--c-text-muted); }

/* Perpetual rows are collapsed by default — there is nothing to act on and they would
   otherwise be most of the list on an air-gapped estate. `display: none` rather than a
   height animation: a table row cannot be transitioned without breaking its own layout. */
.data-table tr.is-hidden { display: none; }
