﻿/* ============================================================
   FinOS Admin — design system
   Two themes via [data-theme] on .admin-shell:
     light  → clean neutral, blue accent
     dark   → near-black, navy-light accent

   Caching — which stylesheet is safe for what ([exec/1033]):
     this file, app.css, tokens.css → linked from index.html and stamped
       "?v=<sha256-of-contents>" by the StampCssVersion target at publish, so the
       URL changes when the bytes change. Safe to move rules here.
     FinOS.Wasm.styles.css (scoped .razor.css bundle) → also stamped by that target.
       It was assumed to be SDK-fingerprinted; verified on a Release publish, it is not.
     /_content/<Assembly>/... (RCL component assets) → NOT content-addressed; kept fresh
       only by the /_content/* no-cache rule in _headers.
   Before [exec/1033] the stamp was the release version, which sat still for weeks, and
   the scoped bundle was not busting either — so ObservationList rendered unstyled after
   [exec/992] until a hard refresh whichever file the rules had landed in. Both are
   content-addressed now; the note is kept so the next "scoped or global?" decision is
   made knowing how each file ships, not only how it is structured.
   ============================================================ */

/* [exec/2229] T2 — under the admin surface the DOCUMENT does not scroll. `.al-content` is the one
   scroll container (MainLayout.razor.css) and `.admin-shell` clips to its own box, but nothing
   ever said so to `html`/`body`: they were `height: 100%` with `overflow: visible`, so the whole
   model held only for as long as no box in the document happened to be bigger than the viewport.
   When one was, the page scrolled, and because the shell is a fixed-height box anchored at y=0 it
   scrolled off the top — leaving a band of bare `body` under it, with the end of `.al-content` and
   the rail's Cài đặt slot inside the part that had gone. This states the invariant instead of
   relying on it: there is nothing to scroll, so nothing can scroll away.

   Scoped with `html:has(.admin-shell)` on purpose. The TV host and the EmptyLayout pages (/login, which is `min-height: 100vh` and MUST
   still scroll on a short window) share this stylesheet and are all viewport-scrolling surfaces;
   keying on the presence of the admin shell leaves every one of them exactly as it was. An engine
   without `:has()` simply keeps today's behaviour rather than getting a broken one.
   `overscroll-behavior: none` stops the rubber-band/scroll-chaining that a nested scroller
   (`.al-content`, a table, a modal body) would otherwise hand up to a page that has nowhere to go.
   The `@media print` block near the end of this file releases both properties — printing needs the
   document to flow onto sheet 2, not to be clipped at one viewport. */
html:has(.admin-shell),
html:has(.admin-shell) body {
    height: 100%;
    overflow: hidden;
    overscroll-behavior: none;
}

/* [exec/1782] The admin alias vocabulary belongs to BOTH shells, not just .admin-shell.
   ──────────────────────────────────────────────────────────────────────────────────
   `.empty-layout` (Layout/EmptyLayout.razor) is the layout used by every page that
   renders OUTSIDE the admin chrome — /login and /login/device-code today.
   Those pages still use the admin vocabulary (`var(--panel)`, `var(--border)`,
   `var(--text)`, `var(--radius)` …), but a custom property inherits from the element
   that DECLARES it: with the aliases scoped to `.admin-shell` alone, every one of them
   was invalid-at-computed-value-time on a page EmptyLayout hosted, and the element fell
   back to the UA default. The canonical `:root` tokens (`hsl(var(--primary))`,
   `hsl(var(--background))`) kept resolving, which is why the login page rendered with a
   correct blue button on an uncoloured, unstyled box — the tell that it was the aliases,
   not the stylesheet, that were missing.

   [exec/1748] hit this and fixed it one page at a time, by giving `.cds-shell` its own
   copy of three aliases. That left /login broken and the next EmptyLayout page
   after it. Declaring the vocabulary on the layout is the fix that does not need doing
   again — a page under EmptyLayout now inherits exactly what a page under MainLayout
   does.

   Selector list, NOT a duplicated block: one declaration, two hosts, so the two can
   never drift.

   The `[data-theme="dark"]` block below is deliberately NOT extended to `.empty-layout`.
   It carries only the content-type accents and media-grid extras (`--video`, `--read`,
   `--inter`, `--media-*`), and no EmptyLayout page references any of them (checked:
   zero `var(--video|--read|--inter|--media-*)` in Login.razor.css). Everything those pages DO use flips light↔dark
   through the canonical `:root` / `[data-theme]` tokens in tokens.css, which is why the
   aliases above are aliases rather than values. Add `.empty-layout` there only when a
   page under it genuinely reads one of those vars. */
.admin-shell,
.empty-layout {
    /* ── Core palette: ALIASES of the canonical tokens (tokens.css) ──
       One source of truth. These resolve per-element, and because the
       same .admin-shell carries data-theme, the canonical channels flip
       light↔dark and these aliases flip with them — no duplicate values. */
    /* ── Core palette: ALIASES of the canonical primitives (tokens.css) ──
       One source of truth. Surfaces layer by lightness (--surface-1..3) and
       flip light↔dark with the theme; admin names map onto them. */
    --bg:           hsl(var(--background));
    --panel:        var(--surface-1);
    --panel-2:      var(--surface-2);
    --panel-hover:  var(--state-hover);
    --border:       var(--border-subtle);
    --text:         var(--text-primary);
    --text-muted:   var(--text-secondary);
    --text-faint:   var(--text-muted);
    --accent:       hsl(var(--primary));
    --accent-fg:    hsl(var(--primary-foreground));
    --warn:         var(--warning);
    --ring:         hsl(var(--ui-ring) / 0.38);
    --radius:    12px;
    --radius-sm: 8px;

    /* Content-type accents (Video / Reading / Interactive) */
    --video:      #2563eb; --video-soft:#e8f0ff;
    --read:       #d97706; --read-soft: #fdf1e1;
    --inter:      #0d9488; --inter-soft:#e3f6f3;

    /* #468: media library grid — selection ring + video-duration chip background */
    --media-select-ring: color-mix(in srgb, var(--accent) 35%, transparent);
    --media-duration-bg: rgba(0, 0, 0, 0.65);

    /* #474: active folder pill's count-badge background (over --accent). */
    --media-folder-count-active-bg: rgba(255, 255, 255, 0.28);

    /* #951: camera viewfinder letterbox background — deliberately theme-invariant (a viewfinder is
       always black, like --media-duration-bg above), not a fallback for a missing token. */
    --media-viewfinder-bg: #000000;

    background: var(--bg);
    color: var(--text);
    font-family: var(--font-sans);
}

.admin-shell[data-theme="dark"] {
    /* ── Admin-only extras — dark overrides. Core vars (surfaces, text,
       semantic, shadow) already flip via the canonical tokens; only the
       content-type accents (no shadcn equivalent) live here. ── */

    /* Content-type accents — dark */
    --video:      #60a5fa; --video-soft:#16233b;
    --read:       #fbbf24; --read-soft: #2a200d;
    --inter:      #2dd4bf; --inter-soft:#10241f;

    /* #468: same formula as light — color-mix already tracks --accent's dark value */
    --media-select-ring: color-mix(in srgb, var(--accent) 35%, transparent);
    --media-duration-bg: rgba(0, 0, 0, 0.65);

    /* #474: active folder pill's count-badge background (over --accent). */
    --media-folder-count-active-bg: rgba(255, 255, 255, 0.28);
}

/* ════ [exec/2257] T4 — brand sidebar tokens ═══════════════════════════════════════════════
   The `brand` nav rail: a solid block in the domain's own colour, white text and icons, and a
   white pill for the active item. Every value below is DERIVED from --brand with color-mix();
   no colour arithmetic happens in JS (index.html computes one pass/fail contrast check and
   nothing else — see that file's block).

   THESE DECLARATIONS ARE INERT UNLESS SOMETHING READS THEM, and the only rules that read them
   are under `[data-sidebar="brand"]` in PortalNavRail.razor.css. That is what makes the `plain`
   rail byte-for-byte what it was before this change: defining a custom property that no matching
   selector consumes changes no computed style and paints no pixel. Do not "simplify" this by
   pointing an existing `plain` declaration at an --sb-* token.

   Two blocks, one per theme, both explicitly `[data-theme=…]`-qualified rather than a base block
   plus a dark override: PortalSidebarBrandTokenTests parses exactly these two and fails if either
   defines a token the other does not, so a light-only token can never ship again (the #1241
   defect, where three nav tokens were dark-only and the light rail fell through to a `var(…, …)`
   fallback that rendered off-palette).

   --sb-fg is a literal #ffffff in both themes on purpose, for the same reason
   --primary-foreground is (#793): it is not a surface that flips, it is the one colour that is
   legible on --brand — which the contrast guard in index.html has already proven it is, at
   4.5:1 or better, for whatever colour the customer picked. Contrast is symmetric, so the same
   proof covers the inverse pairing used by the active pill (--brand text on white).
   ═══════════════════════════════════════════════════════════════════════════════════════════ */
.admin-shell[data-theme="light"] {
    --sb-bg:          var(--brand);
    --sb-fg:          #ffffff;
    /* Tier headings and the collapse control: white, dimmed. `transparent` in the mix rather than
       a second colour, so it dims toward whatever --sb-bg happens to be instead of toward a grey
       that would look bruised on a saturated brand. */
    --sb-fg-muted:    color-mix(in srgb, #ffffff 68%, transparent);
    --sb-border:      color-mix(in srgb, #ffffff 16%, transparent);
    --sb-hover-bg:    color-mix(in srgb, #ffffff 14%, transparent);
    --sb-active-bg:   #ffffff;
    --sb-active-fg:   var(--brand);
    /* Hovering the already-active pill must still move, but only slightly — a white pill has
       nowhere brighter to go, so it tints toward the brand instead. */
    --sb-active-hover-bg: color-mix(in srgb, #ffffff 88%, var(--brand));
    --sb-scrollbar:   color-mix(in srgb, #ffffff 34%, transparent);
    /* The app-wide --focus-ring is built from --control-ring-bg (a near-white page surface) and
       would disappear into a white pill and read weakly on a saturated block. Same two-ring shape,
       drawn in the rail's own two colours. */
    --sb-focus-ring:  0 0 0 2px var(--sb-bg), 0 0 0 4px var(--sb-fg);
}

.admin-shell[data-theme="dark"] {
    /* The one token that differs between themes. A saturated brand block that is right at midday
       glares against a near-black page (--bg #050607) at night, so it is mixed 35% toward black —
       dark enough to sit in the page, still unmistakably the customer's colour. Everything else
       below is identical to light because it is defined RELATIVE to --sb-bg or to white, so it
       follows this one value down on its own. */
    --sb-bg:          color-mix(in srgb, var(--brand) 65%, black);
    --sb-fg:          #ffffff;
    --sb-fg-muted:    color-mix(in srgb, #ffffff 62%, transparent);
    --sb-border:      color-mix(in srgb, #ffffff 14%, transparent);
    --sb-hover-bg:    color-mix(in srgb, #ffffff 12%, transparent);
    --sb-active-bg:   #ffffff;
    /* NOT the darkened --sb-bg: the pill is white, and --brand is the colour the contrast guard
       actually cleared against white. Mixing it toward black here would only make it darker, so
       the guard's guarantee still holds either way — using --brand keeps the pill's text the
       school's own colour rather than a night-time variant of it. */
    --sb-active-fg:   var(--brand);
    --sb-active-hover-bg: color-mix(in srgb, #ffffff 88%, var(--brand));
    --sb-scrollbar:   color-mix(in srgb, #ffffff 28%, transparent);
    --sb-focus-ring:  0 0 0 2px var(--sb-bg), 0 0 0 4px var(--sb-fg);
}

/* ── Page header ───────────────────────────────────────────── */
/* [exec/1094] T1: `flex-wrap: wrap` was here, and it is what put /finance/reliefs' "Thêm khoản
   miễn giảm" button at x 428, y 222 — flush LEFT, 98px below the title — while /finance/fees,
   with identical markup and classes, kept its at x 1563, y 124. Nobody positioned it there: the
   title block is a flex item with no basis, so its hypothetical size is the max-content width of a
   two-line description. That overflowed the row, and a wrapping row does the only thing it can —
   it puts the actions on a second line, where `justify-content: space-between` has nothing to push
   them against. One extra sentence in a resx value moved a button, on any of the 80 pages that use
   this class, which is the actual defect.
   The mechanism, not the instance: the row does not wrap on a desktop-width screen. The title block
   shrinks (its min-content is one word, so the description simply takes another line) and the
   actions block is told not to, so the corner is a structural property rather than something that
   holds while the copy stays short. Below 768px the wrap is restored deliberately — see the
   `.ad-page__head` rule in the #559 mobile block, which is now load-bearing rather than a repeat of
   this declaration: a 375px screen has no corner to keep a button in. */
.ad-page__head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
    margin-bottom: 22px;
    flex-wrap: nowrap;
}
/* The title block yields the width; `min-width: 0` overrides flex's `auto` minimum so a long
   unbroken token (an org name, a URL in a hint) cannot re-establish the overflow above. */
.ad-page__head > *:first-child { min-width: 0; }

/* [exec/2220] T1 — the WORKSPACE opt-in marker, declared globally because it is a contract between a
   page and the shell rather than a style of any one page.
   A page root carrying this class asks MainLayout to drop the 1280px reading cap and the content
   padding and to pass the shell's height through to it — the selector that does that, and the reason
   it is written the way it is, live in `MainLayout.razor.css` next to the two rules it overrides.
   This rule is the PAGE side of the same contract: a workspace page is a column that fills whatever
   height it is handed, and `min-height: 0` is what lets its panes scroll instead of it.
   Nothing else in the app carries this class; adding it to a page is the whole opt-in. */
.ad-page--workspace {
    display: flex;
    flex-direction: column;
    min-height: 0;
}
.ad-page__title {
    font-size: 24px;
    font-weight: 700;
    letter-spacing: -0.01em;
    color: var(--text);
}
.ad-page__title:focus,
.ad-page__title:focus-visible {
    outline: none;
}
.ad-page__hint {
    font-size: 14px;
    color: var(--text-muted);
    margin-top: 4px;
}
/* [exec/2263] T2: a hint that is a warning rather than background information — the MCP keys page's
   "credit pool is empty, the next tool call will be refused" line. Colour only: it inherits every
   other property from `.ad-page__hint` above, and `--warning` is defined in both themes
   (tokens.css:155 light, :276 dark) so it needs no `prefers-color-scheme` block of its own. */
.ad-page__hint--warn {
    color: var(--warning);
}
/* [exec/1746] `.ad-page__hint--afterbutton` (was here, for AddonsPage's MCP card — the one card that
   used to render both a button and a trailing hint) is deleted: [exec/1746] T2 made MCP follow the
   same `@if` entitlement-gate/`else`-hint shape every other add-on card already used, so no card
   renders a hint directly under a button any more and the class has no call site left. Confirmed via
   `grep -rn "afterbutton" wasm/` before deleting — the one site was this one. */
/* [exec/1094] `flex: 0 0 auto` is the other half of the pair above: the actions keep their natural
   width and give none of it back, so the title block is the only thing that can absorb a long
   description. Without it the row would stop wrapping and start squashing the button instead. */
.ad-page__head-acts {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
    flex: 0 0 auto;
}

/* [exec/1965] T1/F1's `.ad-page__head-acts:has(.rc-print-size)` alignment rule stood here and is
   DELETED by [exec/2224] T2, not moved. It existed only because `Receipt.razor`'s paper-size
   `UiSelect` carried a visible `Label`, making that one box a line taller than the print button
   beside it, which `align-items: stretch` then mis-aligned. That select is gone — the paper size is
   a two-segment `UiSegmented` strip inside the receipt's own single-row toolbar now (`.rc-toolbar`
   below), which has no label box and no height mismatch — so the class the `:has()` hooked onto,
   `.rc-print-size`, has no call site left anywhere in the repo. Leaving the rule would have left an
   unused selector matching nothing. */

/* ── Receipt toolbar ([exec/2224] T2) ───────────────────────────
   One compact row above the printable document: icon back, title, then the paper-size strip and the
   icon print button grouped at the right end of the SAME row. This is NOT `.ad-page__head`: that
   class is a two-block (title-block / actions-block) header whose actions sit on the far right of
   the page, which is exactly the eye-travel the owner filed. A row of its own keeps the three
   controls and the title reading as one toolbar.
   `.rc-actions` (already in the `@media print` hide list further down) is carried alongside, so the
   toolbar never prints. */
.rc-toolbar {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    margin-bottom: 16px;
}

/* `flex: 0 1 auto` + `min-width: 0` is what lets the title be the thing that truncates when the row
   runs out of width, rather than the controls shrinking or the row wrapping to two lines — the
   mobile requirement (one row, title ellipsises) with no breakpoint needed. */
.rc-toolbar__title {
    flex: 0 1 auto;
    min-width: 0;
    margin: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* `margin-inline-start: auto` rather than `justify-content: space-between` on the row: the title
   must stay adjacent to the back button (they read as one unit), and only this trailing cluster is
   pushed to the far end. */
.rc-toolbar__end {
    display: flex;
    align-items: center;
    gap: 8px;
    flex: 0 0 auto;
    margin-inline-start: auto;
}

/* The icon back button sits flush with the page's left edge, the same optical alignment
   `.uibacklink` uses for the text back link. */
.rc-toolbar__back {
    flex: 0 0 auto;
    margin-inline-start: -6px;
}

.rc-toolbar__size {
    flex: 0 0 auto;
}

/* [exec/2224] T3 — the "this may need a second A5 sheet" hint. Warning-coloured but not an error:
   the document still prints.
   Token spelling, both halves deliberate and both checked by CssPortalShellScopeGuardTests: `--warning`
   and `--warning-soft` are plain hex values in tokens.css and are read bare, while the text colour is
   `hsl(var(--foreground))` and NOT `var(--text)` — `--text` is an admin-shell alias, so a rule outside
   `.admin-shell` reading it renders the text transparent (the [exec/1748] device-shell
   defect). `--foreground` is a :root/[data-theme] primitive, and it is an HSL triplet, hence the
   `hsl()` wrapper. */
.rc-a5-hint {
    margin: -8px 0 16px;
    padding: 6px 10px;
    border: 1px solid var(--warning);
    border-radius: var(--radius-sm);
    background: var(--warning-soft);
    color: hsl(var(--foreground));
    font-size: 13px;
}

@media (max-width: 560px) { /* phone */
    .rc-a5-hint {
        font-size: 12px;
    }
}

/* ── Card ──────────────────────────────────────────────────── */
.ad-card {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    /* [exec/1739] T1: `.ad-card` sits inside `.al-content` (a flex child) and is itself the direct
       flex item of `.ad-chat-thread`/`.lr-grid` in a few places. A flex item's `min-width: auto`
       default floors it at its content's intrinsic width, so a wide table/pre inside the card body
       just widens the card instead of scrolling inside itself — see MainLayout.razor.css's `.al-content`
       for the matching link in the chain. */
    min-width: 0;
}
.ad-card__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 16px 20px;
    border-bottom: 1px solid var(--border);
    /* [exec/1274] LOI-268, measured by the reporter: five 70.6px segments ≈ 475px inside a 400px
       viewport, and this row did not wrap while `.ad-segmented` below refuses to shrink — so the
       last filters were simply cut off with nothing to scroll. Wrapping the ROW is the fix that
       keeps every filter reachable without touching the pill's own geometry. */
    flex-wrap: wrap;
    row-gap: 8px;
}
/* [exec/1260] T1 (LOI-234): the sibling `.ad-segmented__btn` already has `white-space: nowrap`
   (portal.css:1424) and refuses to shrink, so all squeeze pressure landed on this title, which had
   no shrink guard and collapsed word-by-word. Match the nowrap convention already used on
   buttons/badges throughout this file. */
.ad-card__title { font-size: 15px; font-weight: 700; white-space: nowrap; flex-shrink: 0; }
/* Money and number cells: keep the amount on one line (a wide column scrolls the table, it never wraps the figure). */
.ad-nowrap { white-space: nowrap; }
/* Status pills and money in a card stay on one line in every list (lending lists and others). */
.ad-card .ad-badge, .ad-card .ad-money { white-space: nowrap; }
/* Lending lists (Cầm đồ, Tín chấp, Thu nhanh, Phải thu, Nợ xấu, Gói vay): codes and names, and table headers, stay on one line. */
.lending-nowrap { white-space: nowrap; }
.lending-list .ui-table__head th { white-space: nowrap; }
/* [exec/1739] T1: same reason as `.ad-card` above — this is a plain block box, but it still sits
   under the flex chain, so it must not be the one link that stays uncapped. */
.ad-card__body { padding: 20px; min-width: 0; }
/* [exec/1054] T1: two stacked cards used to touch — .ad-card carried no outer spacing at all, so
   every page that stacked one card on another either bolted an inline margin onto that page or
   shipped the seam (/announcements did). The rule lives on the shared class, on the adjacent
   sibling only, so it adds spacing exactly where two cards meet and never adds a trailing margin
   to the last card on a page. */
.ad-card + .ad-card { margin-top: 16px; }
/* A shared UiCard stacked against an ad-card (or another UiCard) keeps the same 16px seam. */
.ad-card + .ui-card, .ui-card + .ad-card, .ui-card + .ui-card { margin-top: 16px; }
/* [exec/1056] #1054 flagged that the rule above loosens the superadmin support surfaces, and left
   them to this prompt. A message thread is a flex column whose `gap` already sets the rhythm, so
   the adjacent-sibling margin stacked on top of it and every bubble sat 26px from the last. Inside
   a thread the gap is the single authority; between ordinary stacked cards the 16px still applies. */
.ad-chat-thread { display: flex; flex-direction: column; gap: 10px; }
.ad-chat-thread > .ad-card + .ad-card { margin-top: 0; }
/* [exec/2223] T2 — the same reset, one container over, for the SAME reason: `.lr-grid` (:1521) is a
   CSS Grid with `gap: 16px`, so the gap is already the single authority on spacing, and the
   adjacent-sibling margin above fires a second time inside it.

   What that looked like, measured on this stylesheet in Chrome at 1920×1080 (the marketplace, nine
   cards, three per row): every card except the FIRST resolves `margin-top: 16px`. The margin is
   taken out of the grid area, not added to it, so the row track sizes to the tallest card PLUS
   16px and `align-items: stretch` then stretches the first card — the one with no margin — across
   the whole track. Row 1 came out `top = 278, 294, 294` and `height = 219, 203, 203`: the first
   card started 16px higher and rendered 16px taller than its two neighbours, on every row, which is
   the "first card is offset / looks taller" in the owner's screenshot (y≈440 vs 455). After this
   reset all three are `top = 294, height = 203`. */
.lr-grid > .ad-card + .ad-card { margin-top: 0; }

/* [exec/1485] T4: the staff thread had no sender lanes, so the school's messages and the family's
   were the same card and a manager scanning a thread could not tell who said what. Two lanes, one
   tone each; the system event stays centred and neutral because ADR-83 D16.3 says it is not a
   person. Side comes from the server's seat read, never from a client guess. */
.ad-chat-msg { max-width: 78%; padding: 8px 12px; }
.ad-chat-msg--school { align-self: flex-end; border-color: var(--accent, #2f6feb); background: var(--accent-soft, #eef3fe); }
.ad-chat-msg--family { align-self: flex-start; }
.ad-chat-msg--system { align-self: center; max-width: 100%; text-align: center; background: transparent; border-style: dashed; }
.ad-chat-msg__meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; margin-bottom: 4px; }
.ad-chat-msg__name { font-size: 13px; }
.ad-chat-msg__time { font-size: 12px; color: var(--text-muted, #6b7280); }
.ad-chat-msg__text { white-space: pre-wrap; overflow-wrap: anywhere; }

/* [exec/1501] T5b: the staff thread panel is a CHAT — identity pinned at the top, the message list
   as the only scroller, the composer pinned at the bottom. Before this the dialog was one long
   scrolling column (context card, thread, composer, footer buttons), so on any thread longer than
   the viewport a staff member scrolled to the top to see WHO they were talking to and past the
   whole conversation to reach the input. Neither is acceptable on either surface, which is why the
   family thread (T4b) has the same three regions. */
.ad-chat { display: flex; flex-direction: column; min-height: 0; max-height: min(70vh, 640px); }
.ad-chat__head {
  flex: 0 0 auto; display: flex; flex-wrap: wrap; align-items: flex-start;
  justify-content: space-between; gap: 10px;
  padding-bottom: 10px; margin-bottom: 10px; border-bottom: 1px solid var(--border);
}
.ad-chat__ident { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
/* The one line that answers "who is this about?" — child, or "general enquiry". */
.ad-chat__who { font-size: 16px; overflow-wrap: anywhere; }
/* Group · guardian (relationship) · assignee. Separated by rendered dots rather than by a
   joined string, so a name containing the separator cannot read as two fields. */
.ad-chat__meta { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--text-muted); }
.ad-chat__meta > span + span::before { content: "·"; margin-right: 12px; color: var(--border-strong, var(--border)); }
.ad-chat__head-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
/* [exec/1603] The staff-only conversation title, edited in place in the thread header. */
.ad-chat__title { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 14px; }
.ad-chat__title .ad-input { max-width: 320px; }
.ad-chat__assign { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.ad-chat__assign .ui-select { min-width: 160px; }

.ad-chat__scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 4px; }

.ad-chat__composer {
  flex: 0 0 auto; display: flex; align-items: flex-end; gap: 8px;
  padding-top: 10px; margin-top: 10px; border-top: 1px solid var(--border);
}
/* Auto-growing within bounds, matching the family chat bar. `field-sizing` grows it natively where
   supported; max-height is the ceiling everywhere. 44px is the admin surface's touch floor. Where
   `field-sizing` is unsupported (e.g. older Safari), min-height/max-height already on this rule are
   the fallback: the textarea renders at the 44px floor instead of the UA default, so no separate
   fallback declaration is needed. */
.ad-chat__input {
  flex: 1 1 auto; min-width: 0; min-height: 44px; max-height: 132px; padding: 10px 12px;
  border: 1px solid var(--border); border-radius: var(--radius-sm, 6px);
  background: var(--panel); color: var(--text);
  font-family: inherit; font-size: 14px; line-height: 1.4; resize: none; field-sizing: content;
}
.ad-chat__input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.ad-chat__send {
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; padding: 0; border: none; border-radius: 50%;
  background: var(--accent); color: var(--accent-fg); cursor: pointer;
}
.ad-chat__send:hover:not(:disabled) { background: var(--accent-hover); }
.ad-chat__send:disabled { opacity: 0.5; cursor: default; }
.ad-chat__send svg { width: 20px; height: 20px; }
/* [exec/1059] T1: the same seam one element over. `.ad-card + .ad-card` above does not fire when the
   next sibling is a filter/tab row, so a header card followed by a tab strip touched it with no gap
   at all (/payroll/rules/{id}: the totals card welded to the four-tab segmented strip).
   `.ad-filterbar` sets only `margin-bottom`, deliberately — its rhythm below is its own. Written as
   the adjacency rule rather than a margin on that one page, matching the idiom above, so any page
   that stacks a card on a filter row gets the same 16px. `.ad-card__head--toolbar` is unaffected: the
   filterbar there is a DESCENDANT of the card head, not its next sibling. */
.ad-card + .ad-filterbar { margin-top: 16px; }

/* [exec/2704] `.ad-banner` carries no margin of its own (69 call sites, several with an inline margin-bottom, so a
   global margin would double up), and `.ad-filterbar` sets only `margin-bottom`: a banner followed directly by a
   tab/filter row, or by a second banner, touched it with 0px (/addons: the "add-on expires in 7 days" banner welded to
   the tab strip). Written as the adjacency rule, like the `.ad-card + .ad-filterbar` one above. Adjacent sibling
   margins collapse, so a banner that also has an inline 12px margin-bottom still leaves 12px, not 24px. */
.ad-banner + .ad-filterbar, .ad-banner + .ad-banner { margin-top: 12px; }

/* ── List header toolbar cluster (#791): title left, tool cluster (segmented +
   search + filter [+ extra selects]) flush right before "Refresh", divider
   between the cluster and the refresh button, everything same height. */
.ad-card__head--toolbar .ad-card__title { margin-right: auto; }
.ad-toolbar-cluster { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ad-toolbar-divider { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; }
.ad-card__head--toolbar .ad-filterbar { margin-bottom: 0; }
.ad-card__head--toolbar .ui-search { width: 180px; max-width: 100%; height: 32px; min-width: 0; flex: 0 0 auto; }
.ad-card__head--toolbar .ui-select { height: 32px; }
/* #1031 (LOI-054): a bare <select class="ad-input"> in a toolbar head kept .ad-input's 42px next to
   32px siblings. Normalised here, in the toolbar context only — .ad-input's base 42px is correct for
   the five form-body call sites and is not changed. */
.ad-card__head--toolbar .ad-input { height: 32px; }
.ad-card__head--toolbar .ad-segmented { height: 32px; }
.ad-card__head--toolbar .ad-segmented__btn { height: 100%; display: flex; align-items: center; }
@media (max-width: 640px) {
    .ad-card__head--toolbar .ui-search { flex: 1 1 160px; width: auto; }
}
/* [exec/2307] T2 — the tuition preview's billed-class filter, beside the search box. Fixed width
   on desktop; on phones it takes the whole row so it stacks under the title/search. */
.ad-preview-classfilter { flex: 0 1 240px; min-width: 180px; }
.ad-preview-classfilter .ui-select { height: 32px; }
/* [exec/2335] T3 — the head is one centred row; the deselect notice floats under it (absolute, so it
   never shifts the table). Theme tokens only. */
.ad-preview-head { position: relative; align-items: center; }
.ad-preview-notice { position: absolute; left: 16px; top: 100%; margin: 4px 0 0; z-index: 2;
    padding: 4px 10px; font-size: 13px; color: var(--text-primary); background: var(--warning-soft);
    border: 1px solid var(--warning); border-radius: 6px; box-shadow: var(--shadow); }
@media (max-width: 640px) {
    .ad-preview-head { flex-wrap: wrap; }
    .ad-preview-head .ad-card__title { flex: 1 1 100%; margin-right: 0; }
    .ad-preview-head .ui-search { flex: 1 1 100%; width: 100%; }
}
@media (max-width: 640px) {
    .ad-preview-classfilter { flex: 1 1 100%; min-width: 0; }
}
/* [exec/1053] T1: an opt-in modifier that lets the search box take the row's slack instead of
   sitting at a fixed 180px. 180px is narrower than the placeholder "Tìm theo tên phụ huynh" renders
   at 14px, so the text was ellipsised while the toolbar had several hundred px of empty space —
   which is exactly what the ≤640px rule above already fixes for narrow screens and nothing fixed
   for wide ones. A modifier rather than a change to the base rule: five other pages sit on that
   180px today and this prompt is not their sweep. The max-width stops it eating a 1920px row. */
.ad-card__head--toolbar-grow .ui-search { flex: 1 1 240px; width: auto; max-width: 480px; }
/* ── [exec/2221] T1 — the list-page card head, the successor to `.ad-card__head--toolbar` ───────
   Three children only: the card title, its count, and ONE `UiFilterSearch` that owns every control ([exec/2570]; it was `UiListToolbar` before).
   `--toolbar` above is left untouched: ~33 admin pages still sit on it and adopt this shape in a
   later prompt (the People trio is the first three adopters). Two differences matter:
     • `justify-content: flex-start`, because the base `.ad-card__head` spreads its children with
       `space-between` — with three children that would park the count in the middle of the row,
       away from the title it belongs to.
     • the toolbar, not the title, is what takes the slack: it is the only child that can use it. */
.ad-card__head--list { justify-content: flex-start; }
/* The count that used to be baked into the title string ("Danh sách học sinh (124)") or shown as a
   toolbar item. The separator is CSS content on purpose — it is punctuation, has no translation, and
   an `@L[...]` format key whose vi and en values are both "{0} · {1}" is a resx parity failure. */
.ad-card__count { font-size: 15px; font-weight: 700; color: var(--text-muted); white-space: nowrap; flex-shrink: 0; }
.ad-card__count::before { content: "·"; margin: 0 6px 0 2px; color: var(--text-muted); }
/* The tab strip at the top of a long create/edit modal. Was an inline `style="margin-bottom:16px"`
   repeated per form; `Class=` rather than a scoped rule because UiSegmented renders the element. */
.ad-modal-tabs { margin-bottom: 16px; }

/* ── Stat cards ────────────────────────────────────────────── */
.ad-stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 16px;
    margin-bottom: 24px;
}
/* A stat strip that ends its card owns no space below it: the card padding is the only bottom edge. */
.ui-card .ad-stats:last-child,
.ad-card__body > .ad-stats:last-child { margin-bottom: 0; }
.ad-stat {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 18px 20px;
    box-shadow: var(--shadow);
}
.ad-stat__label {
    font-size: 12px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-muted);
}
.ad-stat__value {
    font-size: 30px;
    font-weight: 800;
    margin-top: 6px;
    color: var(--text);
    letter-spacing: -0.02em;
}
.ad-stat__sub { font-size: 12.5px; color: var(--text-faint); margin-top: 2px; }
.ad-stat__icon {
    width: 40px; height: 40px; border-radius: 10px;
    display: flex; align-items: center; justify-content: center;
    background: var(--accent-soft); color: var(--accent);
    float: right;
}

/* ── Buttons ───────────────────────────────────────────────── */
.ad-btn {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    height: 38px;
    padding: 0 16px;
    border-radius: var(--radius-sm);
    /* [exec/2326] A bare .ad-btn had no colours, so the UA `buttonface` (~#efefef) showed through:
       ~1.1:1 on the light card and an off-palette grey in dark. It now reads as the ghost button
       (transparent, --foreground, a --control-border edge; --text is an .admin-shell alias and .ad-btn is also
       used outside the shell, so the :root primitive is used); every variant below overrides it. */
    background: transparent;
    color: hsl(var(--foreground));
    border: 1px solid var(--control-border);
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    transition: background 130ms ease, border-color 130ms ease, transform 90ms ease;
    white-space: nowrap;
    text-decoration: none; /* [exec/2472] an <a> used as a button kept the UA underline */
}
.ad-btn:active { transform: translateY(1px); }
.ad-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.ad-btn--primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
.ad-btn--primary:hover { background: var(--accent-hover); }
.ad-btn--ghost { background: transparent; color: var(--text); border-color: var(--control-border); }
.ad-btn--ghost:hover { background: var(--panel-hover); }
.ad-btn--danger { background: transparent; color: var(--danger); border-color: var(--control-border); }
.ad-btn--danger:hover { background: var(--danger-soft); border-color: var(--danger); }
.ad-btn--icon { width: 38px; padding: 0; justify-content: center; }
.ad-btn--sm { height: 32px; padding: 0 12px; font-size: 13px; }
.ad-btn--xs { height: 26px; padding: 0 10px; font-size: 12px; }
.ad-btn--eye { width: 32px; height: 32px; padding: 0; flex-shrink: 0; justify-content: center; }
/* Filter-bar action buttons: match .ad-input height (42px) so Apply/Clear sit flush with the inputs
   above their bottom edge instead of a shorter 38px button (#447). */
.ad-filter-actions { display: flex; gap: 12px; align-self: flex-end; margin-bottom: 16px; flex-wrap: wrap; }
.ad-filter-actions .ad-btn { height: 42px; }

/* [exec/1185] A secret shown exactly once and read back character by character — the MCP key at
   #980 and now the guardian activation code. It had no rule anywhere (allowlisted at [exec/992] as a
   known bug), so it rendered as the browser's default <pre>: the one place 0/O and 1/I must not be
   confusable was the one place with no monospaced face. */
.ad-code-block {
    font-family: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace;
    font-variant-numeric: tabular-nums;
    background: var(--panel-2);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    padding: 12px 14px;
    margin: 8px 0;
    user-select: all;
}

/* ── TV logout code (#828: back in its own column, restored from d793b00e^) ─ */
.ad-tv-code-cell { display: inline-flex; align-items: center; gap: 8px; height: 32px; }
.ad-tv-code {
    display: inline-flex;
    align-items: center;
    height: 32px;
    min-width: 7ch;
    font-family: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace;
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.05em;
    text-align: left;
    line-height: 1;
}

/* ── TV identity cell (#822: one column — device name, class beneath when they differ) ── */
.ad-tv-identity { display: inline-flex; flex-direction: column; gap: 2px; }
.ad-tv-identity__sub { font-size: 12px; color: var(--text-muted); }

/* [exec/1162] (LOI-148/149) `.ad-tv-acts-cell` is gone. #822 styled the TV overflow cell locally
   with a declaration block byte-identical to the shared `.ad-col-actions--fit` below, and because it
   was written as a one-table class only the `<td>` got it — the `<th>` half of the convention came
   with the shared class and was never applied, so the header wrapped. The table now uses the shared
   class on both cells; there is nothing left for a local rule to say. */

/* ── Table ─────────────────────────────────────────────────── */
.ad-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.ad-table th {
    text-align: left;
    font-size: 11.5px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-muted);
    padding: 10px 16px;
    border-bottom: 1px solid var(--border);
    background: var(--panel-2);
}
.ad-table td {
    padding: 13px 16px;
    border-bottom: 1px solid var(--border);
    color: var(--text);
    vertical-align: middle;
}
.ad-table tr:last-child td { border-bottom: none; }
.ad-table tbody tr:hover { background: var(--panel-hover); }
/* Standardized actions column width across Campus/Group/Staff/Child list pages (#797 T2/T5) */
/* [exec/1060] T4: 64px was the standardised width, but `.ad-table th` adds `padding: 10px 16px`
   and the global reset is `box-sizing: border-box` — so the header content box was 64 − 32 = 32px
   for an 11.5px/700/uppercase/0.05em "THAO TÁC", measured at ~58px. It could not fit on one line
   and wrapped to two on all four pages. 96px total → 64px content box, which clears 58px with
   room for a longer translation. The four call sites (Campus/Group/Staff/Child) all have
   auto-width neighbours, so the extra 32px comes out of the widest text column, not out of a
   fixed one. `white-space: nowrap` makes the intent explicit rather than relying on the width. */
.ad-table th.ad-col-actions, .ad-table td.ad-col-actions { width: 96px; text-align: right; }
/* [exec/1198] (LOI-167/171/185) The mirror of what [exec/1162] fixed for the TV table, in the same
   week: `nowrap` was on the `<th>` alone, so the `<td>` stayed `white-space: normal`. Completing the
   convention, with one correction to the ticket's diagnosis, measured at 882px in headless Chrome
   against this stylesheet: the `<td>` half changes NO geometry today (row 186px, column 96px, ⋯
   position identical with and without it), because every current call site puts a single
   `.ad-overflow` wrapper in the cell and there is no second inline box to break between. The
   reported "⋯ floats mid-row" is not a wrap at all — it is `.ad-table td { vertical-align: middle }`
   centring a 32px control in a 186px row (measured offset 77px from the cell top; forcing
   `vertical-align: top` puts it at 13px). It is cured by the row getting shorter, not by `nowrap`.
   The rule stays because the convention should be stated on both halves — the next call site that
   puts a label or a second button beside the trigger would otherwise wrap silently — but it is not
   the fix for the float, and a future reader should not credit it with one. */
.ad-table th.ad-col-actions, .ad-table td.ad-col-actions { white-space: nowrap; }
/* #1031 (LOI-060): the hint follows a <b> title inside the same cell, and the only node between them
   is whitespace — which the Blazor compiler removes (#980's `Chờ duyệtTrạng thái:`). Separation has to
   come from the rule, not the markup, or the two other call sites (Announcements, AuditLog) stay broken.
   `display:block` also makes the existing `max-width` meaningful, which it is not on an inline box.
   AuditLog puts this class on the <td> itself, which must remain a table cell — hence the override. */
.ad-table__hint { display: block; margin-top: 4px; color: var(--text-muted); font-size: 14px; max-width: 360px; }
/* [exec/1093] T1 sweep — same specificity trap as `.ad-th--money`: on AuditLog the class sits on the
   <td> itself, where `.ad-table td { color: var(--text) }` (0,1,1) beat `.ad-table__hint`'s muted
   colour (0,1,0), so that one cell rendered at full contrast while every <span> call site was muted.
   Restated at (0,2,1) rather than relying on source order. */
.ad-table td.ad-table__hint { display: table-cell; margin-top: 0; color: var(--text-muted); }
.ad-table__hint summary { cursor: pointer; color: var(--text); }
.ad-audit-diff {
    margin-top: 6px; padding: 8px 10px; border-radius: var(--radius-sm);
    background: var(--panel-2); font-size: 12px; white-space: pre-wrap; word-break: break-word;
    max-height: 220px; overflow: auto;
}
/* Human-readable "field: value" change list (#447), replacing the raw-JSON dump by default. */
.ad-audit-changes { margin-top: 6px; padding: 8px 10px; border-radius: var(--radius-sm); background: var(--panel-2); }
.ad-audit-change-row { font-size: 12.5px; padding: 2px 0; word-break: break-word; }
.ad-audit-change-row b { font-weight: 700; color: var(--text); }
.ad-audit-raw-link { display: inline-block; margin-top: 6px; font-size: 11.5px; color: var(--text-muted); cursor: pointer; text-decoration: underline; }
/* [exec/1082] (LOI-070) The wrapper that carries the flex, so the <td> around it never has to.
   `display:flex` on a <td> replaces its `display: table-cell`, which drops the cell out of the row's
   height distribution and makes `.ad-table td { vertical-align: middle }` inert — `vertical-align`
   only applies to table-cell and inline-level boxes. The visible result is an action column whose
   buttons sit at the top while the sibling cells stay centred (Events.razor:311 before this change).
   `align-items:center` on the <td> masks that and is NOT the fix: the cell is still not a table cell.
   Same reasoning as `td.ad-table__hint { display: table-cell }` above.
   [exec/1116] (LOI-085.2) `--start` is gone. [exec/1082] added it so its alignment fix would not
   change any page's appearance, which froze the defect it was standing next to: the three columns
   that used it (Events, WeekTopicsPanel, SchoolYearAnchorsPanel) are now on the flex-end default
   the other five already used. Nothing references the modifier, so it is deleted rather than left
   as an unused opt-out that the next copy-the-neighbour edit would pick up. */
.ad-table__actions { display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
/* [exec/1116] (LOI-085.2) flex-end alone moves nothing in a `table-layout: auto` column whose <th>
   is empty — the column is already shrink-to-fit around the buttons. `.ad-col-actions` (96px above)
   was sized by [exec/1060] for ONE button and is too narrow for two. `width: 1%` + `nowrap` asks the
   table for the column's min-content width instead of a magic number, so it fits two buttons on one
   line at any translation length and still yields the leftover width to the text columns. */
/* [exec/2211] `.ui-table` added to the selector list. Three UiTable callers (ConversationsPanel,
   SchoolYearAnchorsPanel, WeekTopicsPanel) already wrote `ad-col-actions--fit` on their actions
   column and got NOTHING from it — UiTable renders `<table class="ui-table">`, so the `.ad-table`
   ancestor the rule required was never there (ConversationsPanel.razor.css even documents the
   width as if it applied). The taxonomy and curriculum tables converted by this prompt are all
   UiTable callers and need the same shrink-to-fit + right-align the `.ad-table` reference gets, so
   the rule is widened rather than duplicated under a parallel `ui-` name.
   [refactor/ui-table-unify] UiTable now renders `.ad-table` itself, so the `.ui-table` half of the
   selector list is redundant and removed. */
.ad-table th.ad-col-actions--fit, .ad-table td.ad-col-actions--fit { width: 1%; white-space: nowrap; text-align: right; }
/* [exec/1132] (LOI-116) Same `width: 1%` + `nowrap` trick as `--fit` above, for a column whose only
   content is a fixed-vocabulary status label. `.ad-badge` sets no `white-space`, so in `table-layout:
   auto` the column's min-content is its widest single WORD ("Đang" ~40px); when a neighbouring text
   column grows — e.g. the fee-name cell with an `.ad-table__hint` capped at 360px — the status column
   is squeezed to that min-content and the label wraps on EVERY row. [exec/1142] correction: the pill
   itself no longer overflows — `[exec/1133.2]` replaced `.ad-badge`'s fixed `height: 24px` with
   `min-height: 24px`, so a wrapped badge grows its background instead of spilling out of it (measured:
   the CampusPanel status cell at 1024/1280/1366px renders a 28px pill around 28px of text, zero
   spill). What survives without this class is the wrap itself — a two-line label on every row where
   one line would fit — which is what the class is for.
   `nowrap` on the CELL raises the column's min-content to the whole phrase, which auto layout treats
   as a floor; it does not claim more than that, so the text columns still take the leftover.
   Deliberately NOT on `.ad-badge` itself: six sites put operator-supplied names in a badge
   (FinanceBalances, FinanceTuition, FinanceChildLedger, IngredientsPanel, RecipesPanel) where nowrap
   would turn a wrap into a horizontal overflow. Apply this only to columns holding a fixed label. */
.ad-table th.ad-col-status, .ad-table td.ad-col-status { width: 1%; white-space: nowrap; }

/* [exec/1680] T2: the staff photo column — shrink-to-fit around the UiAvatar, same `width: 1%` trick. */
.ad-table th.ad-col-avatar, .ad-table td.ad-col-avatar { width: 1%; white-space: nowrap; }

/* [exec/1198] (LOI-167/171/185) T1's width floor, for a column of short unbounded text that auto
   layout has starved to one word per line. Generalises `.ad-col-category` (#1133, :2789 below) from
   its single expense-ledger call site to a named member of this family, which its own comment asked
   for: "move it up to the column-modifier family when a second table adopts it." Three adopters
   arrive at once — StaffPanel's Họ tên, Vai trò and Phạm vi.

   88px, and the value is measured, not inherited. #1133's `.ad-col-category` is 136px because its
   own column needed that much; copying the number here would have been the copy-the-neighbour trap.
   Swept 72→200px in headless Chrome at 882px over this stylesheet, on the reporter's row (five
   groups, org-wide scope, all ten columns):

     floor   72    80    84    88    96    136   200
     row    137   137   104   104   104   104   104     ← saturates at 84-88px
     scrollW 917  925   932   943   967  1087  1266     ← grows without bound

   Every pixel above 88 buys ZERO height and costs ~3px of horizontal scroll per column. The 104px
   residual is Lớp's own three line boxes, which no floor on a different column can touch — verified
   by holding the floor at 200px and finding every cell in the row still at scrollHeight 104. 88px is
   the knee plus one step of headroom (84 is where the drop lands; 88 is the first value at which all
   three columns, not just two, clear their own longest word).

   NOT `.ad-col-status`'s `width: 1%` + `nowrap`, which prompt #1198 T1 proposed for Vai trò and
   Phạm vi on the grounds that both are fixed vocabulary. nowrap and a floor produce the same row
   height here — the residual is Lớp either way — so nowrap's only effect would be width, and neither
   column is in fact bounded:
     - Vai trò prints `RoleLabelList`, and `RoleLabel` returns the OPERATOR's own name verbatim for a
       tenant-created role (StaffViewModel.Editor.cs:516-519). A tenant may name a role anything.
     - Phạm vi prints campus names in `.ad-selected-chip`, and is already recorded as bucket B —
       may-never-be-nowrap — in StatusColumnClassificationDriftTests.cs:73, with the reason "the
       campus-scope cell prints campus names". nowrap there would have converted a wrap into a
       horizontal overflow and broken that guard.
   #1133's rule stands and is the one this ticket re-confirms: nowrap for a fixed label, a floor for
   text a human types. A floor still lets the text wrap past it, which all three of these must.

   Horizontal scroll: 911px → 943px at an 882px viewport. The table was ALREADY wider than the
   viewport before this change and stays scrollable after — this trades 32px of a scroll that exists
   either way for 33px of height on every over-constrained row. Below 768px both are moot: the table
   is inside the `overflow-x` block at :377. Not `table-layout: fixed` and no `overflow: hidden` —
   #1198 CONSTRAINTS, and the mobile block above is exactly what `hidden` would have clipped. */
.ad-table th.ad-col-text, .ad-table td.ad-col-text { min-width: 88px; }

/* [exec/1147] The column-width convention's third case: a column that must give width
   BACK. #1132 gave a starving column a nowrap floor, #1133 gave one a `width` floor — both add, and
   both worked because there was slack to take. StaffPanel has no slack: measured in headless Chrome
   against this stylesheet, its ten-column row asked for 1261px of min-content while three columns
   sat exactly on theirs (Lớp 69.8px, Phạm vi 82.0px, Họ tên 78.2px) and one — the email — held
   425.5px at every viewport from 820px to 1366px. An email address has no space in it, so under
   `table-layout: auto` its min-content IS its preferred width and no floor anywhere else can pry a
   pixel loose. A fourth floor would only have made the table wider.
   `overflow-wrap: anywhere` is the one property that lowers a column's min-content contribution
   (`break-word` deliberately does not — it wraps without changing intrinsic sizing, which is why it
   is the wrong choice here). The address may now break mid-string when the viewport is tight, which
   is the correct trade: an unread wrapped email beats five neighbours crushed to one word per line.
   `min-width` stops the give-back from overshooting — without it the column collapses toward zero
   below ~950px and the email itself becomes the 249px-tall cell (measured). 180px = 32px of `td`
   padding + a 148px content box, about 22 characters, enough that the local part stays on line one
   for typical addresses. It is a floor BELOW the column's own min-content, so unlike `.ad-col-status`
   and `.ad-col-category` it releases width instead of claiming it.
   Measured on one staff member with five groups, whole row, before → after (with the C# list
   truncation in StaffPanel.razor): 1366px 131px → 71px; 1280px 236px → 92px; 1024px 257px → 186px;
   900px and 820px 257px → 186px. Document scroll width: 1261px → 1024px at 1024px and → 1012px at
   900/820px, i.e. the horizontal scroll is gone at the 1366px admin laptop and at 1024px, and what
   remains between 769px and ~1010px is 249px shorter than before.
   Not `table-layout: fixed` (portal.css:2603-2608, FinanceSummary.razor:148-153) and not
   `nowrap` + `text-overflow`, which widens rather than narrows under auto layout. */
.ad-table th.ad-col-email, .ad-table td.ad-col-email { overflow-wrap: anywhere; min-width: 180px; }

/* [exec/1158] A sub-line under a table cell, for a value that had a column of its own and did not
   earn one. It follows `.fin-ledger__meta` (:2685-2692 below), which [exec/1045] created for exactly
   this trade — three ledger fields went under the detail cell instead of opening three columns that
   would have pushed the table past the 1366px admin laptop. Same geometry (4px top gap, 12px, muted)
   under a different name because the `fin-` family is the finance ledger's and this is the `ad-`
   admin-table family; the two are duplicated deliberately rather than one page reaching into the
   other's namespace. Promote to a shared rule when a third table wants it.
   The flex row is this `<div>`, never the `<td>` — `display: flex` on a table cell drops it out of
   the row's column box. The key is what makes the value read as a METHOD rather than a time. */
.ad-cell-meta {
    display: flex; flex-wrap: wrap; gap: 4px 16px;
    margin-top: 4px;
    font-size: 12px;
    color: var(--text-muted);
}
.ad-cell-meta__item { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
.ad-cell-meta__key { color: var(--text-faint); }

/* LOI-184 — the read-only label/value list in the staff detail modal. A real <dl>: the pairs are a
   description list, and the modal is where the four columns removed from the table now live. The
   label column is a fixed 140px rather than `max-content` so the values line up down the modal
   regardless of locale; below 520px it collapses to stacked rows, because a 140px label beside a
   value inside an already-narrow modal is the wrap defect this change exists to remove. */
.ad-detail-list {
    display: grid; grid-template-columns: 140px 1fr; gap: 10px 16px;
    margin: 0; align-items: baseline;
}
.ad-detail-list > dt { color: var(--text-muted); font-size: var(--font-size-sm); }
.ad-detail-list > dd { margin: 0; min-width: 0; }

@media (max-width: 520px) {
    .ad-detail-list { grid-template-columns: 1fr; gap: 4px; }
    .ad-detail-list > dd { margin-bottom: 8px; }
}

/* T2 (#559) fallback: pages with a raw <table class="ad-table"> (not UiTable) get horizontal
   scroll on mobile instead of card mode — avoids per-page rewrites while preventing overflow. */
/* [exec/1206] T1 (LOI-175/176) The reporter asked for `ad-col-hide-mobile` on three device columns.
   Declined: hidden data is unreachable, scrolled data is reachable — the same ground `[exec/1162]`
   declined it on. What was actually missing is the third option he raised himself in LOI-176: the
   scroll is INVISIBLE. `overflow-x: auto` paints no scrollbar on a touch device until the finger
   moves, so a table that scrolls and a table that is simply cut off look identical, and a column
   nobody knows to scroll to is exactly as unreachable as one we deleted.
   Fix is the four-layer scroll-shadow: two `--panel` covers pinned to the edges with
   `background-attachment: local` (they ride the content, so they slide out of view the moment that
   edge is reached) and two `--text` fades pinned with the default `scroll` attachment (they stay put
   against the viewport). The cover hides the fade at rest and uncovers it when there is more content
   that way, so the shadow appears on exactly the edges that have something beyond them.
   No JS, no markup, no new class, and no state to keep in sync. Colours are tokens, so it tracks
   both themes; `--panel` is the `.ad-card` background this table always sits on (portal.css:136-137).
   Scoped to the 768px block because that is the only place the table scrolls — the breakpoint is
   hard-coded per the `tokens.css` convention (custom properties are invalid inside @media).
   [exec/1260] T2 (LOI-235): 768px is a *viewport* width, but the admin shell reserves a persistent
   224px nav rail (PortalNavRail.razor.css:23) beside the content column, so on a laptop/tablet wider
   than 768px the content column itself can already be ≤768px while this query never fires — the
   table clips its last column with no scroll affordance. Widened to 992px (768 + 224 rail width) so
   the query fires on the content-column width the fallback actually targets. No container queries
   exist elsewhere in this file yet, so a media-query widen — not a `@container` rewrite — is the
   minimal fix consistent with the current convention. */
@media (max-width: 992px) {
    .ad-table {
        display: block;
        overflow-x: auto;
        white-space: nowrap;
        background-image:
            linear-gradient(to right, var(--panel), transparent 12px),
            linear-gradient(to left,  var(--panel), transparent 12px),
            linear-gradient(to right, color-mix(in srgb, var(--text) 22%, transparent), transparent 10px),
            linear-gradient(to left,  color-mix(in srgb, var(--text) 22%, transparent), transparent 10px);
        background-position: left center, right center, left center, right center;
        background-repeat: no-repeat;
        background-size: 24px 100%, 24px 100%, 10px 100%, 10px 100%;
        background-attachment: local, local, scroll, scroll;
    }
}

/* T3 (#559): filter bar → "Filters" button + sheet on mobile. Desktop bar hidden below the
   breakpoint; the mobile trigger button is hidden above it (default: not displayed at all). */
.ad-filter-btn--mobile { display: none; }
@media (max-width: 768px) {
    .ad-filter-card--desktop { display: none; }
    .ad-filter-btn--mobile { display: inline-flex; margin-bottom: 1rem; min-height: 40px; }
}

/* T5 (#559): general mobile tightening — buttons stay tappable, badges/meta ids get out of the
   way. Desktop (> 768px) untouched. */
@media (max-width: 768px) {
    .ad-btn { min-height: 40px; }
    /* [exec/1094] This `flex-wrap: wrap` used to restate the base rule; it is now the only place the
       page header wraps, and it is the intended behaviour here — a phone has no top-right corner to
       hold a button in, so the actions belong under the title rather than squeezed beside it. */
    .ad-page__head { flex-wrap: wrap; gap: 10px; }
}

/* ── Forms ─────────────────────────────────────────────────── */
.ad-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; min-width: 0; }
/* [exec/1054] T3/T4b: two-column form body. A modal that scrolls vertically while both flanks of
   the screen are empty is not short of scroll — it is short of columns: every short field
   (a date, a select, a checkbox) was claiming a full row in a 420px card. Short fields pair up
   here; a field that genuinely needs the width takes .ad-form-grid__full. Collapses to one column
   below 720px, where two columns would be narrower than the controls themselves. */
.ad-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; align-items: start; }
.ad-form-grid__full { grid-column: 1 / -1; }
@media (max-width: 720px) {
    .ad-form-grid { grid-template-columns: 1fr; }
}
.ad-label { font-size: 13px; font-weight: 600; color: var(--text); }
.ad-label__hint { font-weight: 400; color: var(--text-faint); }
/* Keeps inputs in a 2-column field row aligned even when one label wraps to 2 lines (#439).
   [exec/1133.1] (LOI) When it APPLIES: a label in one cell of a PAIRED `.ad-form-row` (two or more
   `.ad-field` children), where the sibling cell's label may render on a different number of lines.
   Measured on the 480px `.uimodal-card` default (SchoolResourcePanel campus/name pair, vi): the
   campus label is 2 lines / 39.0px and the name label 1 line / 19.5px, so without this floor the two
   inputs sit 19.5px apart. That is the whole job — the floor is a two-line reservation, nothing else.
   Do NOT apply it to:
   (a) a label inside `.ad-form-row--full` — a single-column block has no partner cell to align
       against, and `.ad-form-row--full > label` (below) already supplies the block display and the
       font treatment, so the class contributes only dead space (measured 19.5px → 39.0px, of which
       7.8px was added when [exec/1126] raised the floor from 2.4em/31.19px to calc(2 * 1.5em));
   (b) a label that wraps its own `<input type="checkbox">` / `<input type="radio">` — the control is
       inside the label's line box, so a height floor pushes the control down inside an over-tall box
       instead of aligning anything.
   Enforced by AdLabelRowApplicabilityTests (tests/FinOS.UnitTests/Wasm/). */
.ad-label--row { display: block; min-height: calc(2 * 1.5em); }
/* [exec/2223] T1 — opt-in: a <UiSelect> standing in a `.ad-field` cell next to plain `.ad-input`
   cells (PlatformAddons' Biểu tượng / Giá / Thời hạn row).

   Two controls, two label systems. `.ad-label--row` above reserves a 39px label box (13px/600 at
   the inherited line-height 1.5, doubled) and `.ad-input` is 42px tall, so a plain field puts its
   control at y=39. UiSelect renders its OWN label — `.ui-field__label` at 14px/500/line-height 1
   plus `.ui-field`'s 0.375rem gap — which puts its trigger at y≈20 and its 2.25rem (36px) trigger
   6px short of its neighbours. Left alone the row misaligns by ~19px vertically and 6px in height,
   which is why the page used to bolt a second `<label>` above the select and end up one label-box
   TALLER instead. Normalising the label metrics and the trigger height is the fix; duplicating the
   label was the symptom.

   This is opt-in via `Class=` on the UiSelect (which lands on its `.ui-field` root) rather than a
   blanket `.ad-form-row .ui-field` rule, so no other UiSelect in the app changes.

   EVERY selector here carries THREE classes, and that is not decoration. UiSelect.razor.css is
   CSS-ISOLATED, so its own declarations resolve at `.ui-field[b-xxxxxxxxxx]` — one class plus one
   attribute, specificity (0,2,0) — and the scoped bundle is linked AFTER portal.css in index.html.
   A two-class selector here TIES and therefore LOSES on source order. Same trap
   `.ad-filterbar .ui-field__label` (:1795) lives next to.

   NOTHING here sets `gap`, and that is deliberate — an earlier draft set it to 0 and left the
   trigger 6px ABOVE its neighbours instead of below them. `.ui-field`'s own `gap: 0.375rem` is
   6px, and `.ad-field` (:731) is itself a 6px-gap flex column: a plain cell spends that gap
   between its `<label>` and its `<input>`, while this cell's label and control both live inside
   `.ui-field` and spend `.ui-field`'s. The two gaps are already the same number, so the correct
   action on this one property was to leave it alone.

   Measured on these stylesheets, the three controls of the Biểu tượng / Giá / Thời hạn row:
   BEFORE `top = 0, -20, -20`, `height = 36, 42, 42`, and TWO labels stacked over the select;
   AFTER `top = 0, 0, 0`, `height = 42, 42, 42`, one label. */
/* METRICS ONLY — no `color` here on purpose. `.ad-label` paints with `var(--text)`, an alias only
   `.admin-shell` assigns, and CssPortalShellScopeGuardTests correctly refuses that read from a rule
   outside an `.admin-shell`-headed section. It is also unnecessary: `.ui-field__label`'s own
   isolated rule already paints `hsl(var(--foreground))`, the `:root` primitive `--text-primary`
   (and therefore `--text`) resolves to inside the shell. Only the BOX had to be matched. */
.ad-form-row .ui-field.ad-field__uiselect > .ui-field__label {
    display: block;
    min-height: calc(2 * 1.5em);
    font-size: 13px;
    font-weight: 600;
    line-height: 1.5;
}
.ad-form-row .ui-field.ad-field__uiselect .ui-select { height: 42px; }
/* The option row: glyph + localized name on one line, in the popover AND in the closed trigger
   (UiOption's ChildContent is rendered verbatim in both). `.ui-select__option-label` is
   `white-space: nowrap; overflow: hidden`, so this must be an inline-flex box rather than two
   free-floating inline children, or the icon and its name break apart under the ellipsis. */
.ad-iconopt { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.ad-input, .ad-select, .ad-textarea {
    width: 100%;
    max-width: 100%;
    box-sizing: border-box;
    height: 42px;
    padding: 0 12px;
    border-radius: var(--radius-sm);
    border: 1px solid var(--control-border);
    background: var(--control-bg);
    color: var(--text);
    font-size: 14px;
    font-family: inherit;
    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--control-border) 42%, transparent);
    transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.ad-textarea { height: auto; min-height: 84px; padding: 10px 12px; resize: none; }
/* #852: read-only identity chip (e.g. org URL slug) — distinct from .ad-input, high-contrast, monospace. */
.ad-slug-chip {
    display: inline-flex; align-items: center; gap: 8px;
    height: 42px; padding: 0 12px; width: fit-content; max-width: 100%;
    border-radius: var(--radius-sm); border: 1px solid var(--border);
    background: var(--panel-2); color: var(--text);
}
.ad-slug-chip svg { flex-shrink: 0; color: var(--text-muted); }
.ad-slug-chip__value { font-family: var(--font-mono, ui-monospace, monospace); font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.ad-input:hover:not(:disabled), .ad-select:hover:not(:disabled), .ad-textarea:hover:not(:disabled) {
    background: var(--control-bg-hover);
    border-color: var(--control-border-hover);
}
.ad-input:focus, .ad-select:focus, .ad-textarea:focus {
    outline: none;
    background: var(--control-bg-hover);
    border-color: var(--accent);
    box-shadow: var(--focus-ring), inset 0 0 0 1px var(--accent);
}
.ad-input::placeholder, .ad-textarea::placeholder { color: var(--text-faint); }
/* [exec/1115] LOI-094 — opt-in removal of the UA number spinner.

   Deliberately NOT a blanket `.ad-input[type=number]` rule. There are 71 number inputs in the app
   and the spinner is a working affordance on some of them: `PlatformLimits.razor` /
   `PlatformDefaults.razor` "max presets" is a small integer a platform admin nudges by one, where
   step-1 arrows are exactly right. A blanket rule would delete that to satisfy one money form.
   Money and percentage fields are the opposite case — the step there is 1000 VND or 1%, the value
   is typed, and the arrows only add two click targets that silently mutate a figure.
   Opt in per field with `.ad-input--no-spin`. */
.ad-input--no-spin { -moz-appearance: textfield; appearance: textfield; }
.ad-input--no-spin::-webkit-outer-spin-button,
.ad-input--no-spin::-webkit-inner-spin-button { -webkit-appearance: none; appearance: none; margin: 0; }
.ad-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
/* A multiline field spans both columns of its form row, so its box is not stranded beside an empty cell. */
.form-wide { grid-column: 1 / -1; }
/* A status pill stays on one line; the cell widens instead of wrapping its label. */
.status-nowrap { white-space: nowrap; }
/* Long table values wrap inside their cell, so a phone card never runs past its edge. */
td[data-label] { overflow-wrap: break-word; min-width: 0; }
/* [exec/1060] T1: three short fields that belong to one decision (leave from / leave to / leave
   kind) on ONE row. The 1fr 1fr default would auto-flow the third field onto a second row at half
   width, which is the "Loại nghỉ spans the card" shape the owner reported. minmax(0,1fr) — not
   1fr — so a long <option> label cannot push a column past its track. Collapses to one column at
   the same 720px breakpoint the plain row uses. */
.ad-form-row--triple { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ad-form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 8px; }
.ad-form-row--full { display: block; margin-bottom: 16px; }
.ad-form-row--full > label { font-size: 13px; font-weight: 600; display: block; margin-bottom: 8px; }
.ad-form-toggle { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; cursor: pointer; }
.ad-audio-ico { flex: none; color: var(--accent); }
.ad-form-error { font-size: 13px; color: var(--danger); background: var(--danger-soft); border-radius: var(--radius-sm); padding: 10px 12px; margin-bottom: 12px; }
.ad-check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 6px 16px; }
.ad-check-item { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; padding: 4px 0; }
.ad-check-item input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--accent); flex: none; cursor: pointer; }

/* ── Badge ─────────────────────────────────────────────────── */
/* [exec/1133.2] (LOI-117) The 24px is a FLOOR, not a fixed height. This class carries
   operator- and content-supplied text at several sites (IngredientsPanel / RecipesPanel
   allergen group names, FinanceChildLedger period labels, FinanceBalances group names),
   and no rule sets `white-space` on it — deliberately, see `.ad-col-status` above. With a
   fixed height an `inline-flex` pill keeps its 24px box while `align-items: center` centres
   a two-line line box inside it, so the text spills out of the top AND the bottom of the
   pill: measured 44px of spill for a wrapped allergen name in a squeezed cell. `min-height`
   keeps every single-line badge byte-identical (verified: 24px before and after in all five
   variants, with `__dot`, in a table cell, under both `style=` overrides, and in a
   `.ad-col-status` column, both themes) and lets only the wrapped pill grow.
   Do not turn this back into `height`, and do not "fix" the wrap with `nowrap`, `max-width`
   or ellipsis — those strings are the content. Guarded by AdBadgeMinHeightGuardTests.

   [exec/1142] (LOI-131) The floor above stops the spill; these two finish the wrapped state.
   Vertical padding: with `padding: 0 10px` a wrapped pill is exactly its two line boxes tall (36px
   for 32px of text), so the glyphs sit against a `border-radius: 999px` edge with nothing between
   them and the fill boundary. `3px` is the one value that costs nothing on the common path — under
   `box-sizing: border-box` (app.css) a single 12px line is 18px + 6px = 24px, which is the floor
   itself, so every one-line badge is byte-identical. Measured in headless Chrome over the shipped
   tokens.css + app.css + portal.css: one line 24.0px → 24.0px in all five tone variants, with and
   without `__dot`, inside an `.ad-table` cell, and at the two inline `style=` shapes in the tree
   (`font-size:10px;padding:0 4px`, which overrides this and is unaffected, and
   `display:inline-block`); wrapped 36.0px → 42.0px, 54.0px → 60.0px for a three-line status label.
   `padding: 4px 10px` would make every one-line badge 26px and move all ~126 of them — do not.

   `align-items` stays `center`, deliberately. `flex-start` would put the dot on the first line only
   with a magic top offset (~5.5px) to meet that line's optical centre, and nine sites override
   `font-size` inline, so the magic number would be wrong at each of them. Centring the dot against
   the whole pill needs no constant and degrades gracefully at any font size. */
.ad-badge {
    display: inline-flex; align-items: center; gap: 6px;
    min-height: 24px; padding: 3px 10px;
    border-radius: 999px;
    font-size: 12px; font-weight: 700;
    background: var(--panel-2); color: var(--text-muted);
}
.ad-badge--ok      { background: color-mix(in srgb, var(--success) 16%, transparent); color: var(--success); }
/* [exec/2258] T2 — `color: var(--warn)` measured 2.56:1 on its own tint in light theme (#d97706 text
   on the composited rgb(243,228,212)), less than the 4.5:1 AA floor for the 12px/700 pill and the
   worst contrast of the six tones. Amber is the problem: --warn is chosen to be legible as a BAR or a
   DOT, where 3:1 is the bar, not as 12px text on a 16% wash of itself.
   The fix pushes the text 40% toward the theme's own body-text colour rather than inventing a hex:
   --text-primary is near-black in light and near-white in dark, so ONE declaration darkens the light
   pill (2.56 -> 4.94) and lightens the dark one (8.34 -> 9.14) — both measured from rendered pixels,
   both directions correct by construction, and the background tint is left exactly as it was so
   nothing about the pill's shape or weight moves. 60% is the ratio-critical number, and it is close:
   measured at the same fixture, 55% gives 5.37, 60% 4.94, 63% 4.67 and 65% 4.49 — i.e. 65% is already
   BELOW the floor, so this is not a value to nudge upward for a warmer amber. Kept byte-identical with family.css:1082 — the same control emits
   this class in both apps, so the two declarations must never drift. */
.ad-badge--warn    { background: color-mix(in srgb, var(--warn) 16%, transparent); color: color-mix(in srgb, var(--warn) 60%, var(--text-primary)); }
.ad-badge--danger  { background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); }
.ad-badge--accent  { background: var(--accent-soft); color: var(--accent); }
.ad-badge--info    { background: var(--info-soft); color: var(--info); }
.ad-badge--muted   { background: var(--panel-2); color: var(--text-muted); }
/* [exec/2365] Age group the school marked "Không sử dụng": dim the data cells, keep the actions cell crisp. */
.ad-row--age-hidden > td:not(.ad-col-actions--fit) { opacity: .55; }
.ad-age-name { display: flex; gap: 8px; align-items: center; }
/* [exec/1142] `flex: none` — the dot is a flex item with a `width`, which under the default
   `flex-shrink: 1` is only a preferred size. In a squeezed cell the browser shrinks the dot before it
   wraps the text, so the status marker silently becomes an oval: measured 7.00px → 4.06px in a 93px
   cell, 3.91px at 120px, 5.11px at 160px. `.ad-audio-ico` above carries the same declaration for the
   same reason. With it, the dot holds 7.00px at every width and the text wraps instead. */
.ad-badge__dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }

/* ── Quota usage rows (PortalLimits, #806) ─────────────────────
   Grid keeps label/bar/value columns aligned across every row, regardless
   of label length — flex-per-row let long labels wrap and desync rows. */
.ad-quota-group__title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); padding: 12px 0 4px; }
.ad-quota-group__title:first-child { padding-top: 0; }
/* [exec/2333] The PARENT owns the three columns and every row is a subgrid onto them. [exec/2188] gave
   each row its own grid with a max-content label track; max-content resolves per grid, so each
   row sized its label track to its own label and the bars started at different x (the long "Dung
   lượng ảnh/video trẻ" row pushed its bar right). With one shared grid the label track is the widest
   label of ALL rows and the value track the widest value ("26,18 GB / 40 GB"), so every bar starts
   and ends at the same x. Phone (<=480px) caps the label track, see the media query below. */
.ad-quota { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; }
/* [exec/2188] Exactly ONE element (the row) owns and paints the single continuous hairline — the old
   `display: contents` row made three cells fake it with separately-composited borders that tore
   apart while scrolling. [exec/2333] The row is now a subgrid (a real box, never `display: contents`). */
.ad-quota-row {
    display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;
    border-bottom: 1px solid var(--border);
}
.ad-quota-row:last-child { border-bottom: none; }
.ad-quota-row__label,
.ad-quota-row__bar,
.ad-quota-row__value {
    display: flex; flex-direction: column; justify-content: center;
    padding: 10px 0;
}
.ad-quota-row__label {
    font-weight: 600; font-size: 14px; padding-right: 16px; line-height: 1.35;
}
/* #812: the bar cell owns ONLY the row padding/border (shared rule above); the track height lives
   on an inner element. Before, one element carried both: with the global `box-sizing: border-box`
   reset, `height: 8px` was the BORDER-box height, so content height = 8 - 20 (padding) - 1 (border)
   clamped to 0, and the fill's `height: 100%` resolved to 0px — a permanently empty track. */
.ad-quota-row__bar { padding-right: 16px; }
.ad-quota-row__track { height: 8px; border-radius: 999px; background: var(--panel-2); overflow: hidden; }
/* min-width keeps a 1-4% value visibly non-zero; the fill element is omitted entirely at 0%, so an
   empty quota stays genuinely empty and is never confused with "a little". */
.ad-quota-row__fill { height: 100%; min-width: 4px; border-radius: 999px; background: var(--success); transition: width 0.2s ease; }
.ad-quota-row__fill--warn { background: var(--warn); }
.ad-quota-row__fill--danger { background: var(--danger); }
.ad-quota-row__value { text-align: right; font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* [exec/2333] Value and status share ONE line (a row of flex items) so the status never adds a second
   line and every row has the same height. `__status` itself is untouched (reused standalone). */
.ad-quota-row__value { flex-direction: row; align-items: center; justify-content: flex-end; gap: 6px; }
.ad-quota-row__value .ad-quota-row__status { display: inline; }
@media (max-width: 480px) {
    .ad-quota { grid-template-columns: minmax(0, 34%) minmax(0, 1fr) max-content; }
    .ad-quota-row__value { font-size: 12px; gap: 4px; }
    .ad-quota-row__label { padding-right: 10px; }
    .ad-quota-row__bar { padding-right: 10px; }
    .ad-quota-row__label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
.ad-quota-row__status { display: block; font-size: 11px; font-weight: 700; }
.ad-quota-row__status--warn { color: var(--warn); }
.ad-quota-row__status--danger { color: var(--danger); }
.ad-quota-row__sub { display: block; font-size: 11px; font-weight: 400; color: var(--text-faint); margin-top: 2px; }

/* Unlimited resources have nothing to fill, so they render as number cards, not empty bars. */
.ad-quota-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; }
.ad-quota-card { background: var(--panel-2); border-radius: var(--radius-sm); padding: 12px 14px; }
.ad-quota-card__label { font-size: 12px; color: var(--text-muted); }
.ad-quota-card__value { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 2px; }
.ad-quota-card__sub { font-size: 12px; color: var(--text-faint); margin-top: 2px; }

.ad-quota-skel-row { display: contents; }
.ad-quota-skel-row .ad-skel { height: 8px; margin: 15px 0; }
.ad-quota-skel-row .ad-skel--label { width: 150px; height: 14px; margin: 12px 16px 12px 0; }
.ad-quota-skel-row .ad-skel--value { width: 96px; height: 12px; margin: 15px 0 15px 16px; }

/* ── Used/limit table cells (Platform Organizations, #395) ───── */
.ad-usage { white-space: nowrap; font-variant-numeric: tabular-nums; }
.ad-usage--warn { color: var(--warn); font-weight: 600; }
.ad-usage--danger { color: var(--danger); font-weight: 700; }

/* ── Platform: inline usage cell with mini-bar (#739) ────────── */
/* Table-cell only (Platform Organizations LIST, one row per org) — narrow, 5px bar. Kept for that
   context only; the org-DETAIL modal has its own three-part block below (#2188) instead of reusing
   this, which had no label/value/bar alignment when dropped into a grid cell. */
.ad-ucell { display: flex; flex-direction: column; gap: 5px; min-width: 92px; }
.ad-ucell__val { font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.ad-ucell__bar { height: 5px; border-radius: 999px; background: var(--panel-2); overflow: hidden; }
.ad-ucell__fill { height: 100%; border-radius: 999px; background: var(--accent); transition: width 0.2s ease; }
.ad-ucell--warn .ad-ucell__val { color: var(--warn); font-weight: 600; }
.ad-ucell--warn .ad-ucell__fill { background: var(--warn); }
.ad-ucell--danger .ad-ucell__val { color: var(--danger); font-weight: 700; }
.ad-ucell--danger .ad-ucell__fill { background: var(--danger); }

/* ── Platform: org-detail modal usage block (#2188) ──────────
   Three distinct visual parts, label on its own line: label / right-aligned used-of-cap value on
   one line, then a full-width 12px bar beneath both. Same percentage/near-limit-colour semantics
   as .ad-ucell above, just laid out for a modal grid cell instead of a dense table cell. */
.ad-detail-usage { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.ad-detail-usage__head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
/* [exec/2188] `--text`/`--panel-2`/`--warn` are aliases only `.admin-shell` assigns (portal.css:22-63)
   — a rule outside that ancestor reading them renders transparent/inherited (the [exec/1748]
   defect this file's own CssPortalShellScopeGuardTests polices). This block uses the real :root
   primitives (`--text-primary`/`--surface-2`/`--warning`, tokens.css) instead. */
.ad-detail-usage__label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.ad-detail-usage__val { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--text-muted); white-space: nowrap; }
.ad-detail-usage__bar { height: 11px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.ad-detail-usage__fill { height: 100%; border-radius: 999px; background: var(--accent); transition: width 0.2s ease; }
.ad-detail-usage--warn .ad-detail-usage__val { color: var(--warning); font-weight: 600; }
.ad-detail-usage--warn .ad-detail-usage__fill { background: var(--warning); }
.ad-detail-usage--danger .ad-detail-usage__val { color: var(--danger); font-weight: 700; }
.ad-detail-usage--danger .ad-detail-usage__fill { background: var(--danger); }

/* ── Platform: credit wallet card inside the org-detail modal (#2188) ──────────
   Matches the tenant-facing wallet card (AddonsPage.razor .lr-card__head layout): large balance,
   green when non-negative, amber/red with the debt figure + overdraft limit when negative. */
.ad-credit-card__balance { font-size: 1.75rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.ad-credit-card__balance--ok { color: var(--success, #16a34a); }
.ad-credit-card__debt { margin-top: 4px; font-size: 13px; color: var(--danger); }

/* ── Platform: org-detail add-on table, responsive card fallback (#2188) ──────
   Below ~700px each add-on becomes its own labelled block instead of a horizontally-scrolled
   table row — the nested-scrollbar problem this replaces must be gone at every width, not just
   reduced at desktop. */
@media (max-width: 700px) {
    /* [exec/2251] Undo the ≤992px `.ad-table` scroll fallback (portal.css:831-846) BEFORE stacking —
       the same three declarations `.ui-table--cards` pairs with its own `display: block`
       (UiTable.razor.css:16-21), which was the only card mode in the file that did this.
       The rule below re-declares `display` and `width`, but `white-space: nowrap` and the four
       scroll-shadow `background-image`s survived into the cards: measured at 390px the
       credit-history "Nội dung" cell rendered as ONE line clipped mid-word at the card's edge, and
       the shadows painted a scroll affordance onto a column that no longer scrolls.
       Deliberate per-column `nowrap` (amounts, badges, `.ad-col-actions--fit`) is declared on the
       CELL, so it still wins over this inherited value — the point is only that prose is no longer
       forced onto one line by a rule meant for a horizontally scrolled table. */
    .ad-addon-table { overflow-x: visible; white-space: normal; background-image: none; }
    .ad-addon-table thead { display: none; }
    .ad-addon-table, .ad-addon-table tbody, .ad-addon-table tr, .ad-addon-table td { display: block; width: 100%; }
    .ad-addon-table tr { border: 1px solid var(--border); border-radius: var(--radius-sm); margin-bottom: 10px; padding: 8px 12px; }
    .ad-addon-table td { border-bottom: none; padding: 6px 0; }
    .ad-addon-table td::before {
        content: attr(data-label);
        display: block;
        font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
        color: var(--text-muted); margin-bottom: 2px;
    }
}

/* ── Toolbar row (search + primary action on one line, #739) ── */
.ad-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.ad-toolbar__grow { flex: 1 1 220px; min-width: 0; }
.ad-toolbar__spacer { flex: 1; }

/* ── Switch toggle (add-on enable, #739) ─────────────────────── */
/* [exec/2315] position: relative anchors the visually hidden input below inside the switch; without it the
   input sits against the initial containing block and overflows <html>, so focusing it scrolls the document. */
.ad-switch { position: relative; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--text); }
.ad-switch > input { position: absolute; width: 1px; height: 1px; opacity: 0; }
/* [exec/2329] Global form of the #2315 fix: any label directly wrapping a checkbox/radio/file input anchors it, so a hidden
   absolutely positioned input can never sit against the initial containing block and scroll the page on focus. */
.admin-shell label:has(> input[type="checkbox"], > input[type="radio"], > input[type="file"]) { position: relative; }
/* [exec/2329] Global form of the #2315 fix: any label directly wrapping a checkbox/radio/file input anchors it, so a hidden
   absolutely positioned input can never sit against the initial containing block and scroll the page on focus. */
.admin-shell label:has(> input[type="checkbox"], > input[type="radio"], > input[type="file"]) { position: relative; }
.ad-switch__track {
    position: relative; flex: none; width: 40px; height: 22px; border-radius: 999px;
    background: var(--panel-2); border: 1px solid var(--control-border);
    transition: background 0.15s ease, border-color 0.15s ease;
}
.ad-switch__track::after {
    content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
    border-radius: 50%; background: var(--text-muted);
    transition: transform 0.15s ease, background 0.15s ease;
}
.ad-switch > input:checked + .ad-switch__track { background: var(--accent); border-color: var(--accent); }
.ad-switch > input:checked + .ad-switch__track::after { transform: translateX(18px); background: var(--accent-fg); }
.ad-switch > input:focus-visible + .ad-switch__track { box-shadow: var(--focus-ring); }
/* [exec/2483] Disabled look: a read-only switch must not advertise itself as clickable. */
.ad-switch:has(> input:disabled) { cursor: not-allowed; opacity: .55; }

/* ── Config card (grouped form, #739) ────────────────────────── */
.ad-cfg { padding: 18px 20px; }
.ad-cfg + .ad-cfg { margin-top: 16px; }
.ad-cfg__title { font-size: 15px; font-weight: 700; margin: 0; }
.ad-cfg__desc { font-size: 13px; color: var(--text-muted); margin: 4px 0 14px; line-height: 1.5; }
.ad-cfg__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px 16px; align-items: start; }
/* [exec/1929] auto-fit(150px) let six unequal limit fields land on one row, wrapping the two-line
   storage labels beside single-line neighbours. Fixed 3-column grid — same idiom as .cp-form's
   fixed 2-column grid (CampusPanel.razor.css) — forces 2 rows of 3 at desktop and collapses to one
   column at the same 700px breakpoint .cp-form uses. */
.ad-cfg__grid--limits { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 700px) {
    .ad-cfg__grid--limits { grid-template-columns: minmax(0, 1fr); }
}
.ad-cfg__foot { display: flex; justify-content: flex-end; margin-top: 16px; }
.ad-cfg__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.ad-cfg__head .ad-cfg__title { display: inline-flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ad-cfg__savebar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-top: 16px; padding: 14px 16px; border-radius: var(--radius, 10px); background: var(--panel-2); }
.ad-cfg .ad-input--num { text-align: right; font-variant-numeric: tabular-nums; }
.ad-cfg__fieldhint { font-size: 11.5px; color: var(--text-faint); margin-top: 4px; }

/* ── Editing-org banner (Limits, #739) ───────────────────────── */
.ad-editing { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.ad-editing__name { font-weight: 700; color: var(--text); }

/* ── Empty / loading ───────────────────────────────────────── */
.ad-empty {
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 12px; padding: 48px 24px; text-align: center;
    color: var(--text-muted);
}
.ad-empty svg { opacity: 0.5; }
.ad-skel {
    display: block; border-radius: 6px;
    background: linear-gradient(90deg, var(--panel-2) 25%, var(--panel-hover) 37%, var(--panel-2) 63%);
    background-size: 400% 100%;
    animation: ad-shimmer 1.3s ease-in-out infinite;
}
@keyframes ad-shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
/* [exec/1237] LOI-206: the inline form of .ad-skel, for a count/total inside a line of text
   (UiCountPlaceholder). The base rule is display:block and full-width, which inside a card title
   would push the closing paren to its own line. Sized in `ch` and `em` so it tracks the font of
   whatever line it sits on — a card title (15px/700) and .att-summary (14px/600) both get a box
   the width of the digits they are standing in for, with no per-site width guess. */
.ad-skel--inline { display: inline-block; vertical-align: -0.12em; height: 1em; border-radius: 4px; }
/* The error counterpart. Deliberately NOT a skeleton: a shimmer after a failed request reads as
   "still loading" forever. #916's rule (never a number with no rows behind it) is unchanged — this
   is a placeholder too, just a terminal-looking one. */
.ad-count-dash { color: var(--text-faint); }

/* ── Toast / inline message ────────────────────────────────── */
.ad-note { font-size: 13px; padding: 10px 14px; border-radius: var(--radius-sm); }
.ad-note--ok { background: color-mix(in srgb, var(--success) 12%, transparent); color: var(--success); }
.ad-note--err { background: var(--danger-soft); color: var(--danger); }
/* #1036: a disclosure, not an error — the thu-chi summary uses it to state that its expense
   figure does not yet include salaries. Same token pair as .ad-badge--warn, so it reads as a
   caution in both themes. */
.ad-note--warn { background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); }
.ad-note--warn ul { margin: 6px 0 0; padding-left: 18px; }
.ad-note--warn p { margin: 0; font-weight: 600; }
/* #1036: a secondary figure alongside a card title — the expense list's period total. */
.ad-card__meta { font-size: 13px; color: var(--text-muted); margin-left: auto; }

/* ── Inline-edit (#775 T5) ─────────────────────────────────── */
.ad-inline-editable { cursor: pointer; border-radius: 4px; padding: 2px 4px; margin: -2px -4px; }
.ad-inline-editable:hover { background: var(--panel-hover); }
.ad-inline-editable:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.ad-input--sm { padding: 4px 8px; font-size: inherit; }

/* ── TV commands (#796): two-column form/preview layout ──────── */
.ad-tv-cmd-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 340px); gap: 24px; align-items: start; }
@media (max-width: 900px) {
    .ad-tv-cmd-layout { grid-template-columns: 1fr; }
}
.ad-tv-cmd-form { min-width: 0; }

/* #801: severity + language sit in one row, capped width so they don't stretch full-row. */
@media (max-width: 640px) {
    .ad-tv-cmd-severity-lang { grid-template-columns: 1fr !important; }
}

.ad-select-empty { font-size: 13px; padding: 10px 12px; border: 1px dashed var(--border); border-radius: var(--radius-sm); color: var(--text-secondary); background: var(--surface-2); }

.ad-toggle-group { display: flex; flex-direction: column; gap: 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; margin-bottom: 16px; }

.ad-form-hint { font-size: 12px; color: var(--text-secondary); margin: 4px 0 0; }
.ad-form-hint--over { color: var(--warning); font-weight: 600; }

.ad-tv-cmd-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-top: 8px; }
.ad-tv-cmd-actions__clear,
.ad-tv-cmd-actions__send { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ad-tv-cmd-actions__send { margin-left: auto; }

.ad-tv-cmd-preview { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 8px; }
.ad-tv-cmd-preview__label { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin: 0; }
.ad-tv-cmd-preview__screen {
    aspect-ratio: 16 / 9; width: 100%; border-radius: var(--radius-sm); border: 1px solid var(--border);
    display: flex; align-items: center; justify-content: center; text-align: center; padding: 16px;
    background: #111; color: #fff; overflow: hidden;
}
.ad-tv-cmd-preview__screen--low { background: color-mix(in srgb, var(--accent) 30%, #111); }
.ad-tv-cmd-preview__screen--medium { background: color-mix(in srgb, #b58900 45%, #111); }
.ad-tv-cmd-preview__screen--high { background: color-mix(in srgb, var(--danger) 45%, #111); }
.ad-tv-cmd-preview__screen--critical { background: var(--danger); }
.ad-tv-cmd-preview__screen--neutral { background: #222; }
.ad-tv-cmd-preview__empty { font-size: 12px; opacity: .6; }
.ad-tv-cmd-preview__text { font-size: 15px; font-weight: 600; white-space: pre-wrap; word-break: break-word; }
.ad-tv-cmd-preview__badges { display: flex; gap: 6px; flex-wrap: wrap; }
.ad-tv-cmd-preview__note { font-size: 11px; color: var(--text-secondary); margin: 0; font-style: italic; }

/* [exec/1771] T4: the scheduled-commands card, always outside the tab strip (see the comment above
   its call site) — only its own presentation (former inline `style` attrs on the head row/list/item)
   moves to classes here. */
.ad-tv-scheduled__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.ad-tv-scheduled__list { list-style: none; margin: 8px 0 0; padding: 0; }
.ad-tv-scheduled__item { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }

/* [exec/2475] The "Gọi trẻ ra cổng" tab: layout only; every control inside is a shared Ui* one. */
.ad-tv-arrive { margin-bottom: 16px; }
.ad-tv-arrive__head { flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
.ad-tv-arrive__hint { margin: 0; min-width: 0; font-size: 13px; color: var(--text-muted); }
.ad-tv-arrive__filters { display: grid; grid-template-columns: 1fr; gap: 12px; margin-bottom: 12px; }
.ad-tv-arrive__filters > * { min-width: 0; }
@media (min-width: 640px) { .ad-tv-arrive__filters { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: end; } }
.ad-tv-arrive__results { display: flex; flex-direction: column; gap: 6px; min-width: 0; margin-bottom: 12px; }
.ad-tv-arrive__reconfirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.ad-tv-arrive__reconfirm-text { min-width: 0; color: var(--text-muted); }
.ad-tv-arrive__subtitle { margin: 16px 0 8px; font-size: 15px; font-weight: 700; }

/* ── Segmented control ─────────────────────────────────────── */
.ad-seg {
    display: inline-flex;
    background: var(--control-bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    padding: 3px;
    gap: 3px;
    width: fit-content;
}
.ad-seg__btn {
    height: 32px;
    padding: 0 20px;
    border: none;
    border-radius: 6px;
    background: transparent;
    color: var(--text-muted);
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    transition: color 120ms ease, background 120ms ease;
}
.ad-seg__btn:hover { color: var(--text); }
.ad-seg__btn.is-on { background: var(--panel); color: var(--text); box-shadow: var(--shadow); }

/* ── Scope bar (class + week) ───────────────────────────────── */
.ad-scope {
    display: flex;
    align-items: flex-end;
    gap: 20px;
    flex-wrap: wrap;
    margin-bottom: 16px;
}
.ad-weeknav { display: flex; align-items: center; gap: 10px; }
.ad-weeknav__label {
    display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 175px;
}
.ad-weeknav__range { font-size: 14px; font-weight: 700; }

/* ── Day tabs ───────────────────────────────────────────────── */
.ad-daytabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.ad-daytab {
    display: flex; flex-direction: column; align-items: center; gap: 3px;
    min-width: 80px; padding: 10px 14px;
    border: 1px solid var(--border); border-radius: 10px;
    background: var(--panel); color: var(--text-muted);
    cursor: pointer;
    transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
}
.ad-daytab:hover { border-color: var(--border-strong); color: var(--text); }
.ad-daytab__day { font-size: 13px; font-weight: 700; }
.ad-daytab__date { font-size: 11px; }
.ad-daytab.is-on { background: var(--state-selected); border-color: var(--accent); color: var(--accent); }
.ad-daytab.is-today .ad-daytab__day::after { content: " •"; color: var(--accent); }

/* ── Control bar (class + week) as a card ──────────────────── */
.ad-bar {
    display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
    background: var(--panel); border: 1px solid var(--border);
    border-radius: var(--radius); padding: 12px 16px;
    box-shadow: var(--shadow); margin-bottom: 14px;
}
.ad-bar__sep { width: 1px; align-self: stretch; background: var(--border); margin: 2px 0; }
.ad-bar__right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.ad-weeknav__now {
    display: inline-flex; align-items: center; gap: 5px;
    font-size: 11.5px; font-weight: 700; color: var(--accent); margin-top: 2px;
}
.ad-weeknav__now::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }

/* ── Day tabs: count badge ─────────────────────────────────── */
.ad-daytab { position: relative; }
.ad-daytab__count {
    position: absolute; top: 7px; right: 8px;
    font-size: 10px; font-weight: 800; line-height: 1;
    color: var(--accent); background: var(--panel);
    border: 1px solid var(--border); border-radius: 20px; padding: 2px 6px;
}
.ad-daytab.is-on .ad-daytab__count { border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
.ad-daytab__count.is-zero { color: var(--text-faint); }

/* ── Settings list (row + divider) ─────────────────────────── */
.ad-setlist { display: flex; flex-direction: column; }
.ad-setrow {
    display: flex; align-items: center; justify-content: space-between;
    gap: 24px; padding: 20px 0; border-bottom: 1px solid var(--border);
}
.ad-setrow:first-child { padding-top: 4px; }
.ad-setrow:last-child { border-bottom: none; padding-bottom: 4px; }
.ad-setrow__info { min-width: 0; }
/* [exec/2032]: (a) Razor's ComponentWhitespacePass removes the whitespace-only text node between
   an @(…) expression and a following @if, so a title's "@(expr) @if { <span class="ad-badge">…" ⏎
   in the .razor source never reaches the DOM as a space — this rule is load-bearing, deleting it
   re-flattens the four affected titles back into one run of text. (b) The gap lives here and not on
   .ad-badge: that class is applied at ~123 sites, many already inside a container that supplies its
   own gap (.ad-checklist--preview at :3761, .lr-card__head > .ad-badge at :1490, the inline-flex row
   at Events.razor:298), and a global margin would shift every one of them, doubling the gap at the
   ones that already have one. */
.ad-setrow__title {
    display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
    min-width: 0;
    font-size: 14.5px; font-weight: 700;
}
.ad-setrow__hint { font-size: 12.5px; color: var(--text-muted); margin-top: 4px; line-height: 1.5; }

/* #923: two-column .ad-field forms (e.g. .cp-form) auto-flow fields into grid rows; when one
   field in a row carries helper text between its label and control and its row sibling does
   not, the flex-column .ad-field stacks its control lower, breaking the row's input baseline.
   Reserve a fixed-height hint slot so every field in a shared row costs the same header height
   — render an empty .ad-field__hint on the sibling that has none, rather than a per-field margin. */
.ad-field__hint { min-height: 1.5em; margin: 0 0 6px; font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
.ad-setrow__control { flex: none; }

/* #808 T4: session rows — equal height regardless of whether the row has a button (current
   session's control is `visibility:hidden`, not removed, so both rows stay the same height). */
.ad-setrow--session .ad-setrow__control { min-width: fit-content; }
/* Footer action (log out all others): same left alignment as in-row controls, divider above,
   no extra bottom border since it's the last element in the card. */
.ad-setrow--footer {
    justify-content: flex-end; padding-top: 16px; margin-top: 4px;
    border-top: 1px solid var(--border); border-bottom: none;
}

/* [exec/2478] Collapsed "Advanced" group at the bottom of the settings General tab. */
.ad-adv { max-width: 720px; margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-1); }
.ad-adv__summary { padding: 12px 16px; font-weight: 600; color: var(--text-muted); cursor: pointer; }
.ad-adv__summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ad-adv[open] > .ad-adv__summary { border-bottom: 1px solid var(--border); }
.ad-adv > .ad-setlist { padding: 0 16px; }

/* Text-size sample buttons (A small → large), TV-style */
.ad-sizebtns { display: inline-flex; align-items: center; gap: 7px; }
.ad-sizebtn {
    width: 46px; height: 46px; display: flex; align-items: center; justify-content: center;
    border: 1px solid var(--border-strong); border-radius: 11px; background: var(--panel);
    color: var(--text-muted); font-weight: 800; line-height: 1; cursor: pointer;
    transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.ad-sizebtn:hover { background: var(--panel-hover); color: var(--text); }
.ad-sizebtn.is-on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }

/* ── Confirm modal (in-page) ───────────────────────────────── */
.ad-backdrop {
    position: fixed; inset: 0; z-index: 80;
    background: rgba(0, 0, 0, 0.5);
    display: flex; align-items: center; justify-content: center; padding: 24px;
    animation: ad-fade 120ms ease;
}
.ad-modal {
    width: min(440px, 96vw);
    background: var(--panel); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: var(--shadow-lg);
    overflow: hidden; animation: ad-pop 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ad-modal__body { padding: 22px 22px 6px; }
.ad-modal__icon {
    width: 44px; height: 44px; border-radius: 12px; margin-bottom: 14px;
    display: flex; align-items: center; justify-content: center;
    background: var(--accent-soft); color: var(--accent);
}
.ad-modal__title { font-size: 17px; font-weight: 800; letter-spacing: -0.01em; }
.ad-modal__text { font-size: 13.5px; color: var(--text-muted); margin-top: 8px; line-height: 1.55; }
.ad-modal__foot { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 22px; }
@keyframes ad-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes ad-pop { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }

/* ── Pager (ad-pager) ──────────────────────────────────────── */
.ad-pager {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 12px 20px;
    border-top: 1px solid var(--border);
    justify-content: flex-end;
    flex-wrap: wrap;
}
.ad-pager > span { font-size: 13px; color: var(--text-muted); margin-right: auto; }

/* ── UiSearchBox ───────────────────────────────────────────── */
.ui-search {
    position: relative;
    display: inline-flex;
    align-items: center;
    height: 36px;
    min-width: 160px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    background: var(--panel);
    transition: border-color 120ms ease, box-shadow 120ms ease;
}
.ui-search--focus {
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--ring);
}
.ui-search--disabled { opacity: 0.55; pointer-events: none; }
.ui-search__icon {
    flex: none;
    width: 16px; height: 16px;
    margin-left: 10px;
    color: var(--text-muted);
    pointer-events: none;
}
.ui-search__input {
    flex: 1;
    height: 100%;
    padding: 0 8px;
    border: none;
    background: transparent;
    color: var(--text);
    font-size: 13px;
    font-family: inherit;
    outline: none;
    min-width: 0;
}
.ui-search__input::placeholder { color: var(--text-faint); }
.ui-search__input::-webkit-search-cancel-button { display: none; }
.ui-search__clear {
    flex: none;
    width: 28px; height: 28px;
    margin-right: 4px;
    border: none;
    border-radius: 6px;
    background: transparent;
    color: var(--text-muted);
    cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    transition: background 100ms ease, color 100ms ease;
}
.ui-search__clear:hover { background: var(--panel-hover); color: var(--text); }

/* ── UiPager ───────────────────────────────────────────────── */
.ui-pager {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 12px 20px;
    border-top: 1px solid var(--border);
    justify-content: flex-end;
    flex-wrap: wrap;
}
.ui-pager__info {
    font-size: 13px;
    color: var(--text-muted);
    margin-right: auto;
}
.ui-pager__size {
    height: 30px;
    padding: 0 6px;
    border: 1px solid var(--border-strong);
    border-radius: 6px;
    background: var(--panel);
    color: var(--text);
    font-size: 12px;
    font-family: inherit;
    cursor: pointer;
}
.ui-pager__btn {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    height: 30px;
    padding: 0 10px;
    border: 1px solid var(--border-strong);
    border-radius: 6px;
    background: transparent;
    color: var(--text);
    font-size: 12px;
    font-weight: 600;
    font-family: inherit;
    cursor: pointer;
    transition: background 120ms ease, border-color 120ms ease;
    white-space: nowrap;
}
.ui-pager__btn:hover:not(:disabled) { background: var(--panel-hover); }
.ui-pager__btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--ring); }
.ui-pager__btn:disabled { opacity: 0.4; cursor: default; }

/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 720px) {
    .ad-form-row { grid-template-columns: 1fr; }
    .ad-form-row--triple { grid-template-columns: 1fr; }
    .fin-as-perfee-triple { grid-template-columns: 1fr; }
    .ad-scope { gap: 12px; }
    .ad-slot { grid-template-columns: 70px 1fr; }
    .ad-setrow { flex-direction: column; align-items: flex-start; gap: 12px; }
}

/* ── Hub link cards (LearningHub + ManagementHub) ──────────── */
.lr-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
    gap: 16px;
    margin-top: 8px;
}

.lr-card {
    display: block;
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 20px;
    cursor: pointer;
    text-decoration: none;
    color: inherit;
    transition: box-shadow 0.15s, border-color 0.15s;
}

.lr-card:hover {
    border-color: var(--accent);
    box-shadow: var(--shadow);
}

.lr-card:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
}

.lr-card__priority {
    display: inline-block;
    font-size: 11px;
    font-weight: 700;
    padding: 2px 8px;
    border-radius: 4px;
    background: var(--accent);
    color: var(--accent-fg);
    margin-bottom: 8px;
}

.lr-card__title {
    margin: 0 0 4px;
    font-size: 16px;
    font-weight: 600;
    color: var(--text);
}

/* [exec/1746] Card-internal title row for the add-on cards (AddonsPage) — NOT `.ad-page__head`.
   That class is page-scoped: `margin-bottom: 22px` is sized for a 24px `.ad-page__title`, and
   `flex-wrap: nowrap` (:105) is sized for a page-level action button that never wraps. Reusing it
   here as a card-internal title row made every card's badge compete with the title for space under
   that `nowrap`, and since `.ad-badge` deliberately carries no `white-space` (six operator-name call
   sites depend on it wrapping — see the note at :493-495 above, never add one there), a long card
   title squeezed the row and the badge itself line-wrapped, while a short title left the badge full
   room and it never did. Same class, five different head heights.
   The fix pins "never wraps" on the BADGE alone (`flex-shrink: 0; white-space: nowrap`), not on
   `.ad-badge` globally. The title gets `min-width: 0` so it is the side that yields — the same role
   `.ad-page__head > *:first-child` plays at :109 — just without the page-scale bottom margin, which
   belongs to a 24px page title, not a 16px card title sitting a few pixels above its own
   `.lr-card__desc`. */
.lr-card__head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 6px;
}
.lr-card__head > .lr-card__title { min-width: 0; margin: 0; }
.lr-card__head > .ad-badge { flex-shrink: 0; white-space: nowrap; }

.lr-card__desc {
    margin: 0 0 8px;
    font-size: 13px;
    color: var(--text-muted);
}

.lr-card__route {
    font-size: 12px;
    color: var(--text-faint);
}

/* ── Add-on cards (AddonsPage): equal height, trailing action pinned to a common
   baseline ── */
/* [exec/1054] `.ad-card + .ad-card` gives adjacent stacked cards a 16px gap; `.lr-grid`
   is a CSS Grid whose default `align-items: stretch` already equalizes every card's
   height to the tallest in its row — that part needed no fix. */
.lr-grid > .ad-card { display: flex; }
.lr-grid > .ad-card > .ad-card__body {
    display: flex;
    flex-direction: column;
    width: 100%;
}
/* [exec/1413] The misalignment was never card height — it was that a purchased card
   ends in `.ad-btn--primary` and an unpurchased one ends in `.ad-page__hint`, and only
   the button had `margin-top: auto`. Targeting the trailing element generically (not
   the button specifically) puts whichever one a card actually renders — button or
   hint — on the same bottom baseline, and holds at any card count (grid auto-fill) and
   at mobile width (the rule never references a card index or a fixed column count). */
.lr-grid > .ad-card > .ad-card__body > *:last-child {
    margin-top: auto;
}
.lr-grid > .ad-card > .ad-card__body > .ad-btn--primary {
    width: 100%;
    justify-content: center;
}

/* ── Role + campus scope selector (PortalStaff) ─────────────── */
.ad-role-scope-list { display: flex; flex-direction: column; gap: 8px; }
/* Fixed min-height so a role-scope card renders consistently whether it holds a bare
   org/campus toggle or the campus picker underneath (#908: no per-row reflow jumps). */
.ad-role-scope-row {
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    padding: 10px 12px;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 8px;
}
.ad-role-scope-row__check { font-weight: 600; }
.ad-role-scope-row__name { font-weight: 600; }
.ad-role-scope-row__scope {
    display: flex;
    flex-wrap: wrap;
    gap: 4px 16px;
    padding-top: 6px;
    border-top: 1px solid var(--border);
}
/* #908: quiet placeholder shown in the always-present scope region before any role is
   selected — occupies the same slot so selecting the first role never inserts a block. */
.ad-role-scope-hint {
    border: 1px dashed var(--border);
    border-radius: var(--radius-sm);
    padding: 12px;
    color: var(--text-faint);
    font-size: 13px;
}
.ad-check-item input[type="radio"] { width: 15px; height: 15px; accent-color: var(--accent); flex: none; cursor: pointer; }
.ad-btn--danger { color: var(--danger); }
.ad-btn--danger:hover { background: var(--danger-soft); }

/* ── Direct-pick role chips (#279) ─────────────── */
.ad-role-chip-list { display: flex; flex-wrap: wrap; gap: 8px; }
.ad-role-chip {
    border: 1.5px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--panel);
    color: var(--text);
    padding: 6px 12px;
    font-size: 13px;
    cursor: pointer;
    transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.ad-role-chip:hover { border-color: var(--accent); }
.ad-role-chip.is-selected {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-contrast, #fff);
    font-weight: 500;
}
min-width: 0; }
.ad-empty-hint { color: var(--text-faint); font-size: 13px; padding: 8px 0; }
.ad-selected-chip {
    display: inline-flex;
    align-items: center;
    background: hsl(var(--primary) / 0.12);
    color: var(--accent);
    border-radius: var(--radius-sm);
    padding: 3px 8px;
    font-size: 13px;
    font-weight: 500;
    gap: 6px;
}
.ad-selected-chip__remove {
    background: none;
    border: none;
    color: inherit;
    display: flex;
    align-items: center;
    padding: 0;
    cursor: pointer;
    opacity: 0.65;
}
.ad-selected-chip__remove:hover { opacity: 1; }

/* ── Segmented control (e.g. "This campus | All org") ────────── */
.ad-segmented {
    display: flex;
    align-items: center;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    overflow: hidden;
    background: var(--surface-2);
    flex-shrink: 0;
    /* [exec/1274] LOI-268: `flex-shrink: 0` stays — a segmented control that shrinks squeezes its
       labels into ellipses, which is the LOI-234 defect. On a viewport too narrow for the whole
       strip it SCROLLS instead, which is the same answer `.ad-table` already gives at :539 and the
       one [exec/1206] argued for over hiding: scrolled content is reachable, cut content is not.
       `max-width: 100%` is what lets the overflow happen inside the strip rather than pushing the
       card wider than the screen. */
    max-width: 100%;
    overflow-x: auto;
}
.ad-segmented__btn {
    padding: 5px 14px;
    border: none;
    background: transparent;
    color: var(--text-secondary);
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    transition: background 100ms ease, color 100ms ease;
    white-space: nowrap;
}
.ad-segmented__btn + .ad-segmented__btn { border-left: 1px solid var(--border); }
/* [exec/2669] A segment that is a route (UiSegment Href) is an <a>: same box as the button, no link underline. */
a.ad-segmented__btn { display: inline-flex; align-items: center; text-decoration: none; }
.ad-segmented__btn:hover { background: var(--panel-hover); color: var(--text); }
.ad-segmented__btn.is-active { background: var(--accent); color: var(--accent-fg); }
/* [exec/2258] T2 — the base rule above is deliberately NOT touched: it measures 8.06:1 in light
   (white on the composited rgb(28,82,135)) and `FinanceFilterPlacementTests
   .PortalCss_HasTheFilterModifierRule_AndTheBaseActiveFillIsUntouched` pins it as an exact string.
   The defect is dark-only: dark --accent is #3f83b6, a mid-tone blue on which white text measures
   4.27:1 — under the 4.5:1 AA floor, and it cannot be fixed from the foreground side because white
   is already the lightest thing there is. So the FILL deepens, by 20% toward --nav-active-bg, the
   dark navy this app already uses (with white) for a selected nav row. Measured: 4.27 -> 5.11:1,
   with the pill still unmistakably the same accent blue rather than the flat navy a straight
   `background: var(--nav-active-bg)` would have made it.
   Scoped on the bare [data-theme="dark"] attribute, not `.admin-shell[data-theme="dark"]`, so the
   one declaration also covers EmptyLayout pages (portal.css:78 — `.empty-layout` shares the alias
   block but not the dark section) and stays byte-identical to family.css's copy, where the host
   element is `.family-shell` instead. Every shell in every app stamps a concrete light/dark value,
   so there is no third "unset" state for this to miss. */
[data-theme="dark"] .ad-segmented__btn.is-active { background: color-mix(in srgb, var(--accent) 80%, var(--nav-active-bg)); }
.ad-segmented__btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ad-segmented--inline { display: inline-flex; width: fit-content; align-self: flex-start; flex-shrink: 0; }

/* [exec/2447] The outline filter-strip modifier is retired: every segmented toggle uses the filled active style above. */

/* ── Tab-host panel visibility ([exec/1588]) ──────────────────────
   The four Finance catalogue hosts mount a tab's panel the first time it is opened and then KEEP it
   mounted, hiding it with this class instead of removing it from the render tree. Unmounting is what
   would make a return to a tab refetch and drop its filter state, which T2 of that prompt forbids;
   `display: none` costs one rule and keeps the guarantee in one place rather than in a load-once
   flag inside every view-model. */
.ad-tabpanel--hidden { display: none; }

/* ── Hybrid status filter bar (#772) ──────────────────────────── */
.ad-filterbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
/* [exec/1052] T4 — a filter row mixing <input class="ad-input"> with <UiSelect> did not line up:
   .ad-input is 42px tall and .ui-select is 2.25rem (36px); .ad-field carries a 16px bottom margin
   that .ui-field does not; and the two labels use different metrics (13/600 vs 14/500), so even
   after the controls matched, the flex centring put their baselines 3px apart. The select was also
   narrower than the date inputs because it shrinks to its selected text.

   Fixed HERE rather than on /finance/summary and /finance/expenses separately: the
   next filter row somebody writes gets it for free instead of drifting again. Every rule is scoped
   under .ad-filterbar, so nothing outside a filter row is touched, and each one closes a specific
   gap between the two control families rather than restyling either. */
.ad-filterbar .ad-field { margin-bottom: 0; }
/* [exec/1616] The compose button is the row's only unlabelled field, so its box is the button alone
   (~40px) against a labelled field's label + 6px gap + 42px control (~64px). The row centres its
   items (`align-items: center` above), which put the button's centre on the ROW centre — about 11px
   above the controls' centre. `align-self: flex-end` opts this one field out of that centring and
   pins its bottom edge to the labelled fields' bottom edge, i.e. the bottom of their controls; the
   height override then makes the button exactly as tall as the 42px controls beside it, so the two
   centres coincide by construction rather than by arithmetic. Per-item, so it survives wrapping —
   each flex line aligns on its own cross axis — and it never touches `.ad-filterbar` itself.
   The rule this replaces (`justify-content: flex-end`, [exec/1603]) was inert: `justify-content` is
   the MAIN axis, and `.ad-field` is a column with a single child that already fills it. */
.ad-filterbar .ad-field--action { min-width: 0; align-self: flex-end; }
.ad-filterbar .ad-field--action .ui-btn { height: 42px; }
.ad-filterbar .ad-field,
.ad-filterbar .ui-field { min-width: 160px; }
.ad-filterbar .ui-field { gap: 6px; }
.ad-filterbar .ui-field__label { font-size: 13px; font-weight: 600; line-height: normal; color: var(--text); }
.ad-filterbar .ui-field .ui-select { height: 42px; font-size: 14px; }
/* [exec/1058] — `.ad-filterbar .ad-label { line-height: normal }` was here ([exec/1057] T2). It is
   gone. Measured on the deployed build, it took the .ad-field/.ui-field height error from 5.5px to
   2px and no further, because the two labels still disagreed on font-size (13px vs the .ui-field
   metric) — it patched a hand-rolled control that Ui/ already provides. The only misaligned fields
   on this bar were the hand-rolled from/to date pair; those are now one UiDateRangePicker whose
   label carries `.ui-field__label`, so every label in the row resolves through the SAME rule
   (`.ad-filterbar .ui-field__label`, above) and the residual is 0 by construction rather than by
   a second typography override. No `.ad-label` remains inside `.ad-filterbar`. */
/* The campus/category select shrinks to its selected text, so 160px left it visibly narrower than
   its neighbours. A larger minimum only; `flex-wrap` on .ad-filterbar still wraps it. Kept at 220px
   after [exec/1058]: the range picker's trigger reads "09/08 00:00 – 09/09 23:59", wider than
   either date input it replaced, so the wider floor is now the width the row is built around. */
.ad-filterbar .ui-field { min-width: 220px; }
/* [exec/1058] The range picker's trigger is 2.25rem like .ui-select; the bar runs 42px controls.
   Same override, same reason, same scope as `.ad-filterbar .ui-field .ui-select` above. */
.ad-filterbar .ad-field--drp .ui-drp,
.ad-filterbar .ad-field--drp .ui-drp-wrap,
.ad-filterbar .ad-field--drp .ui-drp__trigger { width: 100%; }
.ad-filterbar .ad-field--drp { min-width: 220px; }
.ad-filterbar .ad-field--drp .ui-drp__trigger { height: 42px; font-size: 14px; justify-content: flex-start; }
/* Lightly disabled state when the filter bar is shown but there's no data to filter yet (#797 T4) */
.ad-filterbar__filter { position: relative; }
/* #821: the badge is now the ONLY always-visible signal that rows are hidden, so it is solid
   (accent fill + accent-fg text) rather than the soft .ad-badge--accent tint, in both themes.
   It is always rendered and only made invisible at count 0 — the box keeps its width, so
   toggling a filter 0 → 1 → 0 cannot re-flow the toolbar or resize the search box. */
.ad-filterbar__count {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: 18px; height: 18px; margin-left: 6px; padding: 0 5px;
    border-radius: 999px; font-size: 11px; font-weight: 800; line-height: 1;
    background: var(--accent); color: var(--accent-fg);
}
.ad-filterbar__count.is-off { visibility: hidden; }
.ad-filterbar__popover {
    position: absolute; right: 0; top: calc(100% + 4px); z-index: 20;
    display: flex; flex-direction: column; gap: 6px; min-width: 220px;
    background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); padding: 10px;
}
/* #821 removed .ad-filterbar__chips / .ad-chip / .ad-chip__x with the chip strip.
   .ad-chip__clear stays — a capacity panel uses it as an inline "set capacity" link. */
.ad-chip__clear { border: none; background: none; cursor: pointer; color: var(--accent); font-size: 12px; text-decoration: underline; padding: 0; }

/* ── Row overflow menu (mirrors Planner's ap-overflow, #772) ──── */
.ad-overflow { position: relative; }
/* [exec/2240] The trigger is a true square icon button, and its glyph cannot be squeezed.

   MEASURED failure (Chromium 143, /finance/child/{id} at 1440x900, both themes): the ⋯
   button rendered 28x32 with its 18x18 `<svg>` laid out at 2x18 — three r=1.8 circles in a 24-unit
   viewBox scaled to a 2px box, i.e. an empty bordered square. Two rules combined to produce it and
   BOTH are fixed here:

   1. The trigger never cancelled `.ad-btn--sm`'s `padding: 0 12px`, and `box-sizing: border-box`
      counts that padding plus the 1px borders INSIDE the box. Any context that lays the button out
      at its `min-width: 28px` floor therefore leaves 28 - 2 - 24 = 2px of content box. The ledger's
      `.fin-ledger__actions` (a flex row in a `width: 1%` fit-column, added by [exec/2204]
      a1bc8c0e9) is exactly such a context. The other seven users put the trigger in a non-flex
      cell, where it sized to its content instead (44x32, measured on /tv and three more) —
      which is why only this page was reported. Below 768px the [exec/2228] 40px touch floor left
      14px of content box and the glyph was squeezed there too (measured: 14x18).
   2. An `<svg>` flex item defaults to `flex-shrink: 1` with `min-width: auto`, which resolves to 0
      for a replaced element — so it absorbed the whole shortfall silently instead of overflowing.

   `width`/`height` follow `--sm` (32px, the height `.ad-btn--sm` already gives every one of these
   triggers), so the trigger is now the same 32x32 square as the `.ad-btn--icon` print button beside
   it on the ledger. The `min-width`/`min-height: 28px` floor STAYS: it is what the [exec/2228]
   mobile rule below raises to 40px, and `RowActionTouchTargetGuardTests` pins both halves. */
.ad-overflow__trigger {
    width: 32px; height: 32px; padding: 0; flex-shrink: 0;
    min-width: 28px; min-height: 28px; box-sizing: border-box;
    display: inline-flex; align-items: center; justify-content: center;
    background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
    cursor: pointer; font-weight: 700; line-height: 1; color: var(--text);
}
/* The glyph keeps its intrinsic size whatever the button is squeezed to — see (2) above. Applies to
   the inline three-dot `<svg>` the eight hand-rolled kebabs carry and to `UiRowActions`' own. */
.ad-overflow__trigger svg { flex-shrink: 0; }
.ad-overflow__trigger:hover { background: var(--panel-hover); }
.ad-overflow__menu {
    position: absolute; right: 0; top: calc(100% + 4px); z-index: 20;
    display: flex; flex-direction: column; min-width: 180px;
    background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); padding: 4px;
}
.ad-overflow__item {
    text-align: left; padding: 8px 10px; border: none; background: none; cursor: pointer;
    border-radius: 6px; font-size: 13px; color: var(--text);
}
/* [exec/2346] T5 — neutral one-line note under the "Show to parents" menu item. */
.ad-overflow__hint { padding: 0 10px 6px; font-size: 12px; color: var(--text-secondary); }
.ad-overflow__item:hover { background: var(--panel-hover); }
.ad-overflow__item:disabled { opacity: 0.45; cursor: not-allowed; }
.ad-overflow__item--danger { color: var(--danger); }
/* [exec/1808] T2 — the assignments ⋯ menu (and its per-fee breakdown rows, .ad-overflow__row /
   .ad-overflow__row-label, along with the :has() rule that widened the popover for them) is gone:
   the breakdown now lives on /finance/tuition/child/{id} with headers and room. */

/* ── [exec/2002] UiRowActions — the fixed-position menu escape ────────────────────────────────
   `.ad-overflow--fixed` is the modifier UiRowActions adds alongside `.ad-overflow`; the eight
   hand-rolled kebabs above keep the plain `position: absolute` menu unchanged, so converting one
   table at a time never moves another table's popover. Below 992px `.ad-table` becomes
   `overflow-x: auto`, which is exactly where the eight `position: absolute` menus get clipped
   today — this modifier is what a table adopting UiRowActions stops losing to that clip.
   [exec/2092] `.ad-overflow__anchor` (the zero-size static-position marker) and the `transform`
   offsets are gone: the menu's `top`/`left` are now written inline from a real
   `getBoundingClientRect()` measurement of the trigger (`UiRowActions.razor`/`.razor.js`) instead
   of being derived from CSS static position, which was only ever correct at scroll offset 0
   ([exec/2047], [exec/2059] — both patched a consequence of that fallback, not the mechanism).
   `inset: auto` below STAYS ([exec/2047]): the base `.ad-overflow__menu` rule above still sets
   physical `right`/`top` for the eight `position: absolute` kebabs, and without neutralising them
   here the inherited `right: 0` would fight the inline `left` this modifier now sets. Only `top`
   and `left` are ever written inline, so clearing all four with `inset: auto` first and letting the
   inline `top`/`left` win by specificity is correct — `right`/`bottom` simply stay `auto`.
   [exec/2052] `pointer-events: auto` on the menu below is still mandatory: it inherits
   `pointer-events` from an ancestor along the way, and without this reset every menu item would be
   unreachable by mouse. */
.ad-overflow--fixed { position: relative; }
.ad-overflow--fixed .ad-overflow__menu { position: fixed; inset: auto; pointer-events: auto; }

/* [exec/2002] Icon-mode row actions (≤ 2 actions): a tight row of icon buttons, no borders between
   them, sized to match `.ad-overflow__trigger` (28px). Danger uses the `--danger` token, never a
   hardcoded colour, so it reads correctly in both themes. */
.ad-rowaction { display: inline-flex; align-items: center; gap: 4px; }
.ad-rowaction__icon {
    min-width: 28px; min-height: 28px; box-sizing: border-box;
    display: inline-flex; align-items: center; justify-content: center; padding: 0;
}
.ad-rowaction__icon--danger { color: var(--danger); }

/* [exec/2228] Touch target. 28px is a comfortable desktop hit area and a poor thumb one; once a
   row's actions are icon-only (no text label to aim at) the button IS the whole target, so on a
   phone both the inline icons and the ⋯ trigger grow to 40×40. Desktop is untouched — this is a
   min-width/min-height floor inside a mobile media query, so the 28px rules above still decide
   above the breakpoint.
   The prompt wrote this as `max-width: 767.98px`; every existing breakpoint in this file and in
   tokens.css spells the tablet cutoff `max-width: 768px` (tokens.css:232-237, ADR-68 §8 restricts
   the set to 560/640/768), so it is spelled the same way here rather than introducing the only
   fractional media query in the stylesheet. The two differ by a hundredth of a CSS pixel; the
   house convention is worth more than that. */
@media (max-width: 768px) {
    .ad-rowaction__icon,
    .ad-overflow__trigger {
        min-width: 40px;
        min-height: 40px;
    }
}

/* ── Bulk selection sticky bar (#772, docked to the end of the list [exec/1060] T3) ──────────
   `position: sticky` does NOT take an element out of flow — it only pins one once scrolled. The
   bar therefore used to insert ~39px + 8px margin BETWEEN `.ad-card__head` and the table, so
   ticking one row pushed the whole table down and unticking pulled it back (the jump the owner
   reported). Two ways out: reserve the space permanently (a dead 47px band above every table), or
   move the bar off the "above the table" path entirely.
   Chosen: the bar is rendered AFTER the table/pager at all four call sites and sticks to the
   BOTTOM (the owner's "để cuối"). Nothing above the table changes height in either direction —
   selecting or deselecting — because the bar is no longer a preceding sibling of the table.
   It cannot hide the last row either: sticky-bottom settles into its natural in-flow position at
   the end of the card once that position is scrolled into view, so at the bottom of the list the
   last row and the pager sit ABOVE the bar rather than under it. */
.ad-bulkbar {
    position: sticky; bottom: 0; z-index: 5; display: flex; align-items: center; gap: 12px;
    padding: 8px 14px; margin: 8px 0 0; background: var(--accent-soft); color: var(--text);
    border-radius: var(--radius-sm); border: 1px solid var(--accent);
}
.ad-bulkbar__count { font-weight: 600; font-size: 13px; }

/* ── Busy schedule section (PortalStaff editor, #584) ──────────── */
.ad-ta-section { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border); }
.ad-ta-section__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.ad-ta-section__title { font-size: 15px; font-weight: 700; margin: 0; }
.ad-ta-empty {
    padding: 16px; text-align: center; font-size: 13px; color: var(--text-muted);
    background: var(--surface-2); border: 1px dashed var(--border); border-radius: var(--radius-sm);
}
.ad-ta-table td { padding: 8px 10px; }

/* ── Card scope hint (PortalStaff) ─────────────────────────────── */
.ad-card__scope-hint {
    padding: 6px 20px;
    font-size: 12px;
    color: var(--text-muted);
    border-bottom: 1px solid var(--border-subtle);
    background: var(--surface-2);
    margin: 0;
}

/* ── Shared editor shell (EditorShell.razor) — extracted from ActivityEditor
   so ActivityEditor / WeekTopicEditor share one look ── */
/* ── Two-column layout: tab form (left) + persistent Curriculum/Taxonomy (right) ── */
.ae-layout {
    display: grid;
    grid-template-columns: 1fr 340px;
    gap: 24px;
    align-items: start;
}

.ae-layout__side {
    display: flex;
    flex-direction: column;
    gap: 16px;
    position: sticky;
    top: 16px;
}

@media (max-width: 900px) {
    .ae-layout {
        grid-template-columns: 1fr;
    }
}

/* #476 T1: Basic tab's 2-col rows (Title+Cover, Age+Code, taxonomy 2x2) stack on narrow panes. */
@media (max-width: 560px) {
    .ae-grid-2 {
        grid-template-columns: 1fr;
    }
}

/* ── Full-page tab bar ────────────────────────────────────────── */
.ae-tabbar {
    display: flex;
    gap: 4px;
    border-bottom: 1px solid var(--border);
    margin-bottom: 20px;
}

.ae-tab {
    background: none;
    border: none;
    border-bottom: 2px solid transparent;
    padding: 10px 16px;
    font-size: 14px;
    font-weight: 600;
    color: var(--text-muted);
    cursor: pointer;
    transition: color 0.1s, border-color 0.1s;
}

.ae-tab:hover {
    color: var(--text);
}

.ae-tab--active {
    color: var(--accent);
    border-bottom-color: var(--accent);
}

/* ── Tab panel content (full-width, single tab visible) ─────────── */
.ae-tabpanel {
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.ae-tabpanel .ui-field {
    margin-bottom: 0;
}

.ui-field--err input,
.ui-field--err input {
    border-color: crimson !important;
    box-shadow: 0 0 0 2px color-mix(in srgb, crimson 25%, transparent) !important;
}

/* Two-field inline row (e.g. age from/to) */
.ae-form-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
}

/* #301 T5: dense taxonomy grid — 2-3 short-select columns instead of one pair per row. */
.ae-form-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    gap: 12px;
}

/* #303 T1: Basic tab layout — fixed 2-column grid (minmax(0,1fr) so long option
   text never forces the row wider than the tab). Used for Age group|Code and the
   Pathway/Topic/Group/Type 2x2 block. */
.ae-grid-2 {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 12px 16px;
}

/* #303 T1: hairline break between Basic tab sections — no heading, just a visual gap. */
.ae-basic-divider {
    border-top: 1px solid var(--border);
    margin: 4px 0;
}

/* #303 T1: read-only mono field (activity code, computed end time). */
.ae-code-input input {
    font-family: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace;
    text-align: right;
}

/* #301 T2 / #303 T1: Start time · Duration · → · Kết thúc (computed) sit on one row. */
.ae-time-row {
    display: flex;
    align-items: flex-end;
    gap: 10px;
    flex-wrap: wrap;
}

.ae-time-row__duration {
    width: 140px;
}

.ae-time-row__end {
    flex: 1 1 120px;
    min-width: 0;
}

.ae-time-row__sep {
    padding-bottom: 10px;
    color: var(--text-muted);
}

/* Generic field wrapper (e.g. rich-text description) */
.ae-field {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

/* #497 B1: ActivityEditor.razor has no scoped CSS of its own, so its manual
   <label class="ui-field__label"> (Ảnh bìa, Materials, Description, …) picked up no rule at all
   here — rendering at the browser-default label size and misaligning against UiInput's own
   scoped 0.875rem/500 label (e.g. "Tên hoạt động"). Mirror UiInput.razor.css/UiSelect.razor.css
   exactly so every manual label in this page matches a Ui-kit label. */
.ui-field__label {
    font-size: 0.875rem;
    font-weight: 500;
    line-height: 1;
    color: var(--text);
}

/* ── Section card ──────────────────────────────────────────────── */
.ae-section {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: var(--radius, 10px);
    padding: 16px;
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.ae-section__title {
    font-size: 14px;
    font-weight: 700;
    color: var(--text);
    margin: 0;
    padding-bottom: 8px;
    border-bottom: 1px solid var(--border);
}

.ae-section__summary {
    margin: 0;
    font-size: 12px;
    color: var(--text-muted);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* ── Milestone search box ──────────────────────────────────────── */
.ae-ms-search {
    margin-bottom: 0;
    flex-shrink: 0;
}

.ae-ms-search .ui-field {
    margin-bottom: 0;
}

/* ── Open-picker button ────────────────────────────────────────── */
.ae-ms-open {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    text-align: left;
    background: var(--panel-2, var(--panel));
    border: 1px solid var(--border);
    border-radius: var(--radius-sm, 8px);
    padding: 8px 12px;
    font-size: 13px;
    font-weight: 500;
    color: var(--text);
    cursor: pointer;
    transition: background 0.1s;
}

.ae-ms-open:hover {
    background: var(--border);
}

/* ── Picker modal card width ───────────────────────────────────── */
.ae-ms-modal {
    min-width: 420px;
    max-width: 560px;
}

.ae-ms-modal--wide {
    max-width: 640px;
}

/* ── #301 T4 / #303 T2: capped-height modal — header/search/Done stay pinned,
   only the tree region scrolls. Overrides UiModal's default whole-card scroll.
   max-height (not a fixed height) so a short list doesn't force a tall card. */
.ae-ms-modal.uimodal-card {
    max-height: 80vh;
    overflow: hidden;
}

.ae-ms-modal .uimodal-body {
    flex: 1 1 auto;
    min-height: 0;
    overflow: hidden;
}

.ae-ms-modal .uimodal-head,
.ae-ms-modal .uimodal-actions {
    flex-shrink: 0;
}

/* ── Taxonomy block inside the milestone picker modal ─────────────── */
.ae-tax-block {
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding-bottom: 12px;
    margin-bottom: 4px;
    border-bottom: 1px solid var(--border);
}

.ae-tax-link {
    color: var(--accent);
    text-decoration: underline;
    margin-left: 4px;
}

.ae-ms-code {
    font-size: 11px;
    font-weight: 700;
    color: var(--text-muted);
    white-space: nowrap;
    flex-shrink: 0;
}

.ae-ms-title {
    flex: 1;
    font-weight: 500;
}

/* ── Tree view ─────────────────────────────────────────────────── */
/* #301 T4: this is the ONLY scrolling region inside the milestone-picker
   modal — flex:1 fills the remaining fixed-height card so header/search/Done
   never move. max-height is a fallback for hosts without the flex parent. */
.ae-ms-tree {
    display: flex;
    flex-direction: column;
    gap: 4px;
    flex: 1 1 auto;
    min-height: 0;
    max-height: min(60vh, 420px);
    overflow-y: auto;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm, 8px);
    padding: 4px;
    background: var(--panel-2, var(--panel));
}

/* #450: children must not shrink — let the tree scroll instead of
   squashing rows/headers into overlapping, clipped text. */
.ae-ms-tree > * {
    flex-shrink: 0;
}

.ae-ms-area {
    border: 1px solid var(--border);
    border-radius: 8px;
    overflow: hidden;
    flex-shrink: 0;
}

.ae-ms-area__hd {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    min-height: 34px;
    text-align: left;
    background: var(--panel-2, var(--border));
    border: none;
    padding: 8px 10px;
    font-size: 13px;
    font-weight: 600;
    color: var(--text);
    cursor: pointer;
    transition: background 0.1s;
}

.ae-ms-area__hd:hover {
    background: var(--border);
}

.ae-ms-area__hd.ae-ms-row--sel {
    background: color-mix(in srgb, var(--accent) 14%, var(--panel-2, var(--border)));
}

.ae-ms-area__pick {
    display: flex;
    align-items: center;
    gap: 8px;
    flex: 1;
    min-width: 0;
    background: none;
    border: none;
    padding: 0;
    font: inherit;
    font-weight: 600;
    color: inherit;
    cursor: pointer;
    text-align: left;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

.ae-ms-area__hd.ae-ms-row--sel .ae-ms-area__pick {
    color: var(--accent);
}

.ae-ms-area__dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    flex-shrink: 0;
}

.ae-ms-chevron {
    margin-left: auto;
    font-size: 11px;
    color: var(--text-muted);
}

/* ── Milestone list inside an area ────────────────────────────── */
.ae-ms-list {
    list-style: none;
    margin: 0;
    padding: 4px 0;
    display: flex;
    flex-direction: column;
}

.ae-ms-node {
    display: block;
    width: 100%;
    box-sizing: border-box;
    flex-shrink: 0;
}

.ae-ms-row {
    display: flex;
    align-items: center;
    gap: 4px;
    min-height: 28px;
    padding: 2px 8px;
    border-radius: 6px;
    transition: background 0.1s;
    flex-shrink: 0;
}

.ae-ms-row:hover {
    background: var(--border);
}

.ae-ms-row--sel {
    background: color-mix(in srgb, var(--accent) 10%, transparent);
}

.ae-ms-expand {
    background: none;
    border: none;
    padding: 2px 4px;
    font-size: 10px;
    color: var(--text-muted);
    cursor: pointer;
    flex-shrink: 0;
}

.ae-ms-pick {
    background: none;
    border: none;
    text-align: left;
    font-size: 13px;
    color: var(--text);
    cursor: pointer;
    padding: 4px 0;
    flex: 1;
    min-width: 0;
    display: flex;
    align-items: baseline;
    gap: 6px;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

.ae-ms-row--sel .ae-ms-pick {
    color: var(--accent);
    font-weight: 500;
}

/* ── Selected chips label ──────────────────────────────────────── */
.ae-ms-chips-label {
    font-size: 12px;
    font-weight: 700;
    color: var(--text-muted);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    padding-top: 4px;
    border-top: 1px solid var(--border);
}

/* ── Card resources (ADR-38) ───────────────────────────────────── */
.ae-res-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.ae-res-row {
    display: grid;
    grid-template-columns: 1fr 1.4fr auto;
    gap: 6px;
    align-items: center;
}

/* #369: upload-file picker under a card resource row. */
.ap-upload {
    display: flex;
    align-items: center;
    gap: 10px;
    margin: 2px 0 6px;
}
.ap-upload__input { font-size: 13px; }
.ap-upload__status { font-size: 12px; color: var(--text-faint); }

/* #965 T2: .ui-btn/.ui-btn--default/.ui-btn--size-default are scoped to UiButton.razor.css and
   never reach a plain <label class="ui-btn …"> written outside UiButton, so it falls through to
   the browser-default 16px. TvAlertPresetsPage.razor has no .razor.css of its own — this file is
   the only place a fix for its upload label can live. Same pattern as WeeklyPlanner's local
   .ap-upload-btn and MediaGrid.razor.css's .mg-upload-btn.
   [exec/1139.2]: the dead `ui-btn ui-btn--default ui-btn--size-default` tokens have now been
   removed from those four elements — this rule is what styles the label, and always was. The
   only thing the tokens still did was inherit the @media print hide, which is preserved by
   naming these companion classes in the print block. */
.ad-upload-btn {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    cursor: pointer;
    font-size: 13px;
}

/* #478 T1: cover control — no preview box, just a single button. Empty state is a plain button;
   once a cover is set the SAME element becomes a two-region split button (left = change, right =
   remove) instead of a separate preview + two stacked buttons (#469/#476 predecessor removed). */
.ae-cover-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    width: 100%;
    height: 2.5rem;
    background: var(--panel-2, var(--panel));
    border: 1px solid var(--border);
    border-radius: var(--radius-sm, 8px);
    padding: 0 12px;
    font-size: 13px;
    font-weight: 500;
    color: var(--text);
    cursor: pointer;
    transition: background 0.1s;
}
.ae-cover-btn:hover {
    background: var(--border);
}
.ae-cover-btn--split {
    justify-content: stretch;
    padding: 0;
    gap: 0;
}
.ae-cover-btn__half {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    flex: 1;
    height: 100%;
    background: none;
    border: none;
    font: inherit;
    color: inherit;
    cursor: pointer;
    padding: 0 10px;
}
.ae-cover-btn__half:hover {
    background: var(--border);
}
.ae-cover-btn__half--danger:hover {
    background: var(--danger-soft);
    color: var(--danger);
}
.ae-cover-btn__div {
    width: 1px;
    align-self: stretch;
    margin: 8px 0;
    background: var(--border);
}

/* #377 T3: uploaded-file display replacing the Link field on a resource row. */
.ae-res-file {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    height: 40px;
}
.ae-res-file__name {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 13px;
}

/* #390: link input + inline upload-icon trigger, same row as the source icons. */
.ae-res-link-row {
    position: relative;
    display: flex;
    align-items: center;
    gap: 4px;
}
.ae-res-link-row .usf { flex: 1; min-width: 0; }

/* #390: shared square icon button (remove-file / upload trigger) — same size as UrlSourceField's
   .usf__icon so everything on the row lines up. */
.ae-res-icon-btn {
    width: 30px;
    height: 30px;
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    border: none;
    background: transparent;
    border-radius: 7px;
    color: var(--text-muted);
    cursor: pointer;
    opacity: 0.72;
    transition: opacity 120ms ease, background 120ms ease;
}
.ae-res-icon-btn:hover { opacity: 1; background: var(--panel-hover); }
.ae-res-icon-btn--disabled { opacity: 0.4; pointer-events: none; }

/* #390 T1: native InputFile stays in the DOM (for the label[for] click target) but is invisible;
   the icon button above is the only thing the user sees/clicks. */
.ae-res-upload-input {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    overflow: hidden;
    pointer-events: none;
}

.ae-res-spin { animation: ae-res-spin 0.8s linear infinite; }
@keyframes ae-res-spin { to { transform: rotate(360deg); } }

/* ── Chips container ───────────────────────────────────────────── */
.ae-ms-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
}

.ae-ms-chip {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    background: color-mix(in srgb, var(--accent) 14%, transparent);
    border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
    color: var(--accent);
    border-radius: 6px;
    padding: 3px 6px 3px 8px;
    font-size: 12px;
    font-weight: 600;
    max-width: 100%;
}

.ae-ms-chip > span:first-child {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Code badge inside chip */
.ae-ms-chip__code {
    font-size: 10px;
    font-weight: 800;
    letter-spacing: 0.03em;
    background: color-mix(in srgb, var(--accent) 22%, transparent);
    border-radius: 4px;
    padding: 1px 4px;
    white-space: nowrap;
    flex-shrink: 0;
}

/* Title text inside chip */
.ae-ms-chip__title {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    flex: 1;
    min-width: 0;
}

.ae-ms-chip__del {
    background: none;
    border: none;
    cursor: pointer;
    padding: 0 2px;
    font-size: 14px;
    line-height: 1;
    color: var(--accent);
    opacity: 0.6;
    border-radius: 3px;
    flex-shrink: 0;
    transition: opacity 0.1s;
}

.ae-ms-chip__del:hover {
    opacity: 1;
    background: color-mix(in srgb, var(--accent) 20%, transparent);
}

/* ── Empty hint ────────────────────────────────────────────────── */
.ae-ms-empty {
    font-size: 12px;
    color: var(--text-muted);
    margin: 0;
    padding: 4px 0;
}

/* ── ADR-35 D3: approval status badge ─────────────────────────────── */
.ae-approval-badge {
    display: inline-block;
    margin-top: 4px;
    padding: 2px 10px;
    border-radius: 999px;
    font-size: 12px;
    font-weight: 600;
}

.ae-approval-badge--draft { background: color-mix(in srgb, var(--text-muted) 20%, transparent); color: var(--text-muted); }
.ae-approval-badge--review { background: color-mix(in srgb, orange 20%, transparent); color: darkorange; }
.ae-approval-badge--approved { background: color-mix(in srgb, seagreen 20%, transparent); color: seagreen; }
.ae-approval-badge--archived { background: color-mix(in srgb, gray 20%, transparent); color: gray; }
.ae-approval-badge--donotuse { background: color-mix(in srgb, crimson 20%, transparent); color: crimson; }

/* ── Collapsible card sections (native <details>) ─────────────────── */
.ae-section > summary.ae-section__title {
    cursor: pointer;
    list-style: none;
}

.ae-section > summary.ae-section__title::-webkit-details-marker {
    display: none;
}

/* Single-column variant when EditorShell has no Side content */
.ae-layout--full {
    grid-template-columns: 1fr;
}

/* Manage-owners popup: owner rows as avatar cards (#760) */
.pl-owner-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.pl-owner-row {
    display: flex;
    align-items: center;
    gap: 12px;
    border: 0.5px solid var(--border);
    border-radius: 10px;
    padding: 10px 12px;
}

.pl-owner-avatar {
    flex: none;
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-weight: 600;
    background: var(--accent-soft);
    color: var(--accent);
}

.pl-owner-meta {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
}

.pl-owner-name {
    font-weight: 500;
}

.pl-owner-email {
    font-size: 12px;
    color: var(--text-muted);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.pl-owner-del {
    flex: none;
    display: inline-flex;
    align-items: center;
    gap: 6px;
}

/* ── Roles/permissions matrix (#792) ──────────────────────────────────────── */
/* #803 T3: no independent vertical scroll region here — the page (.al-content) is the only
   scroller; sticky header/column below resolve against it. Horizontal scroll stays local
   (Matrix mode can have many role columns). */
.rl-matrix-wrap { overflow-x: auto; padding-left: 8px; }
.rl-matrix { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.rl-matrix th, .rl-matrix td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.rl-matrix thead th {
    position: sticky; top: 0; z-index: 3;
    background: var(--panel-2); text-align: center;
    font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
    color: var(--text-muted); white-space: nowrap;
}
/* Sticky permission-name column: opaque background so scrolled content never shows through.
   #803 T2: width no longer 100% — a trailing filler column now absorbs the remaining space,
   so this column stays compact and the checkbox column sits close to it. */
.rl-matrix .rl-col-perm {
    position: sticky; left: 0; z-index: 2;
    background: var(--panel); text-align: left; min-width: 260px;
}
.rl-matrix thead .rl-col-perm { z-index: 4; }
.rl-matrix tbody tr:nth-child(even) .rl-col-perm { background: var(--panel-2); }
.rl-matrix tbody tr:nth-child(even) { background: var(--panel-2); }
.rl-matrix tbody tr:hover, .rl-matrix tbody tr:hover .rl-col-perm { background: var(--panel-hover); }
.rl-matrix th.rl-col-system, .rl-matrix td.rl-col-system { background: var(--panel-2); opacity: 0.72; }
.rl-matrix tbody tr:hover td.rl-col-system { background: var(--panel-hover); opacity: 1; }
/* Role columns: fixed width, do not stretch (#800 T2 — was min-width:84px only, which let the
   browser hand it half the table when there were just 1-2 columns, e.g. SingleRole mode). */
.rl-matrix .rl-col-role { text-align: center; width: 110px; min-width: 110px; }
.rl-lock-icon { vertical-align: middle; margin-left: 4px; opacity: 0.8; }

/* [exec/2194] Below ~992px (the same content-column breakpoint the .ad-table fallback above uses),
   a `.rl-matrix` stops being a horizontally-scrolled table inside `.rl-matrix-wrap` — a scroller
   inside the page's own scroller — and each row becomes a labelled card instead. This table never
   carries `.ad-table` (GovernanceDocs.razor picked `.rl-matrix` alone, see that file's own note), so
   it never inherited the `.ad-table` fallback's `white-space: nowrap` in the first place — nothing
   here needs to un-set it. `data-th` on each `<td>` supplies the label a hidden `<thead>` can no
   longer provide. */
@media (max-width: 992px) {
    .rl-matrix-wrap { overflow-x: visible; padding-left: 0; }
    .rl-matrix, .rl-matrix tbody, .rl-matrix tr, .rl-matrix td { display: block; width: 100%; }
    .rl-matrix thead { display: none; }
    .rl-matrix tr:not(.rl-group-row) {
        border: 1px solid var(--border); border-radius: var(--radius-sm);
        margin-bottom: 10px; padding: 10px 12px; background: var(--panel);
    }
    .rl-matrix td { border-bottom: none; padding: 4px 0; }
    .rl-matrix .rl-col-perm { position: static; background: transparent; font-weight: 700; }
    .rl-matrix td[data-th]::before {
        content: attr(data-th); display: block;
        font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
        color: var(--text-muted); margin-bottom: 2px;
    }
}

/* #921 T4: locked matrix cells are a glyph, not a checkbox — granted (filled, accent) reads
   differently from not-granted (outline, muted), so the grant state stays visible under a lock. */
.rl-lock-cell { display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted); opacity: 0.9; }
.rl-lock-cell.is-granted { color: var(--accent); }
.rl-lock-cell.is-granted svg { fill: var(--accent); fill-opacity: 0.18; }

/* #803 T2: trailing filler column absorbs remaining width so the row divider runs the full
   table width while rl-col-perm/rl-col-role stay compact (checkbox still sits near the label).
   Replaces #800's rl-matrix--single max-width hack — only one width mechanism now. */
.rl-matrix .rl-col-filler { width: auto; border-left: none; }
.rl-matrix thead .rl-col-filler { border-bottom: 1px solid var(--border); }

/* Group header row: deliberately lower/denser than a permission row (30 groups / 59 perms — #792 T3). */
.rl-group-row td {
    padding: 4px 12px;
    /* #975: uppercase dropped here and off the matching single-role <div> (Roles.razor) — the
       Module heading is now localized Vietnamese, and uppercase Vietnamese at this size cramps
       stacked diacritics (Ổ, Ữ, Ệ...). Letter-spacing also pulled back for the same reason: it
       was tuned for uppercase Latin tracking, not natural-case Vietnamese with tone marks. */
    font-size: 11px; font-weight: 700; letter-spacing: .02em;
    color: var(--text-muted); background: var(--panel-2);
    cursor: pointer;
}
/* NOTE: the cell itself (`<td>`) must stay `display: table-cell` (the default) so it keeps
   participating in the table's column-width algorithm. Flex layout goes on the child wrapper
   below, never on the `<td>`/`<th>` (#800 T1 — display:flex on the td used to desync the group
   row's column boundaries from the header/permission rows and its own row height). */
.rl-group-row .rl-col-perm { background: var(--panel-2); }
.rl-group-label { display: inline-flex; align-items: center; gap: 6px; }
/* #814 T3: single-role mode puts the group select-all/clear-all buttons in the role column,
   right-aligned above that column's checkboxes. `text-align: right` only — never `display: flex`
   on a `<td>` (#800 T1). */
.rl-matrix .rl-group-row td.rl-group-actions { text-align: right; white-space: nowrap; }
.rl-group-toggle { display: inline-flex; align-items: center; transition: transform .12s ease; }
.rl-group-row.is-collapsed .rl-group-toggle { transform: rotate(-90deg); }
.rl-perm-key { font-size: 0.82em; font-family: monospace; }
.rl-perm-desc { margin-left: 8px; font-size: 0.78em; color: var(--text-muted); }

.rl-toolbar-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.rl-toolbar-tools .ad-check-item { height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius); }
.rl-toolbar-tools .ad-check-item:has(input:checked) { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.rl-diff-count { font-size: 12.5px; color: var(--text-muted); margin-left: 8px; }

/* #910: two-pane list + detail layout. Sidebar and detail pane each own their scroll region so a
   long permission list in the detail pane never pushes the role list (or the page) around. */
.rl-layout { display: flex; gap: 14px; align-items: flex-start; }
.rl-sidebar {
    flex: 0 0 280px;
    width: 280px;
    max-height: calc(100vh - 220px);
    overflow-y: auto;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--panel);
}
.rl-detail {
    flex: 1 1 auto;
    min-width: 0;
    max-height: calc(100vh - 220px);
    overflow-y: auto;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--panel);
}
.rl-sidebar-search .ui-search { display: flex; width: 100%; }
.rl-sidebar-search { padding: 10px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--panel); z-index: 2; }
.rl-section-head {
    position: sticky;
    top: 0;
    z-index: 1;
    padding: 6px 12px;
    min-height: 38px; /* 26px .ad-btn--xs + 2x6px padding: system and custom headers match */
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.04em;
    color: var(--text-muted);
    background: var(--panel-2);
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    justify-content: space-between;
}
.rl-sidebar-search + .rl-section-head, .rl-section-head:first-child { top: 0; }
.rl-role-item {
    display: flex;
    align-items: center;
    gap: 6px;
    width: 100%;
    padding: 8px 12px;
    border: none;
    background: none;
    text-align: left;
    font-size: 13.5px;
    cursor: pointer;
    color: var(--text);
}
.rl-role-item:hover { background: var(--panel-hover); }
.rl-role-item.is-active { background: var(--accent); color: var(--accent-fg); }
.rl-role-item .rl-role-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rl-role-item .rl-role-icon { flex: 0 0 14px; display: inline-flex; justify-content: center; }
.rl-role-item .rl-role-icon .rl-lock-icon { margin-left: 0; }
.rl-role-item .rl-role-count { font-size: 11px; flex: 0 0 auto; min-width: 12ch; text-align: right; font-variant-numeric: tabular-nums; }
.admin-shell .rl-role-item .rl-role-count { color: var(--text-muted); }
.admin-shell .rl-role-item.is-active .rl-role-count { color: inherit; }
.rl-section-head .ad-btn--icon { width: 26px; }
.rl-sidebar-empty-hint { padding: 14px 12px; font-size: 12.5px; color: var(--text-muted); }
.rl-sidebar-footer { padding: 8px 12px; border-top: 1px solid var(--border); }
.rl-sidebar-footer__btn { width: 100%; justify-content: center; }

.rl-detail-head { padding: 14px 16px; border-bottom: 1px solid var(--border); }

/* #2586: role editor bar. One row on desktop; the actions align to the input bottom, not padded down.
   Stacks on phones with Lưu full width and the two icon buttons square on the right. */
.rl-editor-bar { display: grid; grid-template-columns: minmax(160px, 1fr) minmax(200px, 2fr) auto; gap: 10px; align-items: end; }
.rl-editor-actions { display: flex; align-items: flex-end; gap: 4px; }
.rl-editor-secondary { display: flex; gap: 6px; margin-left: 6px; }
.rl-spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-right-color: transparent; animation: rl-spin 0.8s linear infinite; }
@keyframes rl-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .rl-spin { animation-duration: 2.4s; } }
@media (max-width: 640px) {
    .rl-editor-bar { grid-template-columns: 1fr; }
    .rl-editor-actions { width: 100%; }
    .rl-editor-actions > .ui-btn--primary { flex: 1 1 auto; }
}
.rl-detail-title { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 600; }
.rl-tabs { display: flex; gap: 4px; padding: 0 16px; border-bottom: 1px solid var(--border); }
.rl-tab-btn { padding: 10px 6px; border: none; background: none; font-size: 13px; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent; }
.rl-tab-btn.is-active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.rl-tab-body { padding: 14px 16px; }
@media (max-width: 768px) {
    .rl-layout { flex-direction: column; }
    .rl-sidebar { flex: none; width: 100%; max-height: none; }
    .rl-detail { width: 100%; }
}

/* [exec/2483] Single-role permission list: description + key on the left, .ad-switch on the right; each module is one
   bordered card under a tinted header. Scoped to .admin-shell, which is where /roles always renders. */
.admin-shell .rl-perm-group {
    display: flex; align-items: center; justify-content: space-between;
    margin-top: 12px; padding: 8px 12px; border-radius: var(--radius-sm);
    background: var(--panel-2); color: var(--text-muted); font-weight: 600; cursor: pointer;
}
.admin-shell .rl-perm-group:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.admin-shell .rl-perm-list {
    margin-top: 6px; border: 1px solid var(--border);
    border-radius: var(--radius-sm); overflow: hidden;
}
.admin-shell .rl-perm-row {
    display: flex; align-items: center; justify-content: space-between; gap: 16px;
    padding: 10px 12px; border-bottom: 1px solid var(--border);
}
.admin-shell .rl-perm-row:last-child { border-bottom: none; }
.admin-shell .rl-perm-row:hover { background: var(--panel-hover); }
.admin-shell .rl-perm-row .rl-perm-text { display: flex; flex: 1 1 auto; min-width: 0; flex-direction: column; gap: 1px; }
.admin-shell .rl-perm-row .rl-perm-primary { font-size: 13.5px; color: var(--text); overflow-wrap: anywhere; }
.admin-shell .rl-perm-row.is-granted .rl-perm-primary { color: var(--accent); }
.admin-shell .rl-perm-row .rl-perm-secondary { font-size: 11.5px; font-family: monospace; color: var(--text-muted); overflow-wrap: anywhere; }
.admin-shell .rl-perm-row .ad-switch { flex: none; }

.rl-assignee-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.rl-assignee-row:last-child { border-bottom: none; }
.rl-assignee-name { font-weight: 500; }
.rl-assignee-email { font-size: 12px; color: var(--text-muted); }
.rl-assignee-scope { margin-left: auto; font-size: 12px; color: var(--text-muted); flex: 0 0 auto; }

/* ============================================================
   ol-* — shared "observation-list-style" list screen vocabulary.
   [exec/992]: promoted from ObservationList.razor.css to global scope
   because AssessmentList.razor (a copy-pasted sibling) uses the same
   class names but scoped CSS from one component cannot reach markup
   in another. Two consumers today; treat as the canonical rule set
   for any future list screen copied from this pattern.
   ============================================================ */
/* [exec/1979] T7/F10: `.ol-layout`'s fixed 240px column and `.ol-rail` (a permanent aside that
   held only three status checkboxes) are retired — the checkboxes moved into `.ol-picker-row`
   (ObservationList.razor), which already wraps, and the rail underneath them is now empty.
   `AssessmentList.razor` (F10a's other `ol-*` consumer) never referenced either class — grepped
   before removing. */

.ol-filter-label {
    display: block;
    font-size: 12px;
    font-weight: 600;
    color: var(--text-muted);
    margin-bottom: 6px;
    text-transform: uppercase;
    letter-spacing: 0.03em;
}

.ol-check {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 13px;
    color: var(--text);
    padding: 3px 0;
    cursor: pointer;
}

.ol-check input { margin: 0; }

.ol-main {
    min-width: 0;
}

.ol-picker-row {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 16px;
    flex-wrap: wrap;
}

/* [exec/1983] T2: the two group/child `UiSelect`s used `style="width:200px"` inline — an inline
   width beats any stylesheet media query, so the phone-width collapse below could never reach them
   until the width moved here. */
.ol-picker-select {
    width: 200px;
}

/* [exec/1983] T3: sticky-bottom, copying `.ad-bulkbar`'s SHAPE (portal.css:1759-1776) rather than
   reusing that class outright — see the comment in ObservationList.razor at the bar's new call
   site for why. `env(safe-area-inset-bottom)` is the FIRST occurrence in this file (F5): nothing in
   portal.css docked to the bottom edge before this, so nothing needed it; `family.css:272` is the
   in-repo form this copies. Without it the bar sits under the iOS home indicator on a notched
   phone. */
.ol-bulk-bar {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 8px 0 0;
    padding: 8px 12px;
    padding-bottom: calc(8px + env(safe-area-inset-bottom));
    border-radius: 8px;
    background: var(--panel);
    border: 1px solid var(--border);
    position: sticky;
    bottom: 0;
    z-index: 5;
}

.ol-obs-cell {
    display: flex;
    gap: 12px;
    align-items: flex-start;
}

/* [exec/2203] T6: the clickable list row is a real <button>, not a <div style="cursor:pointer">.
   The div was unreachable by keyboard and announced as nothing; these rules strip the native button
   chrome so it still reads exactly like the cell it replaces. The inline `cursor:pointer` the
   markup used to carry lives here now, where a media query can reach it. */
.ol-obs-cell--btn {
    width: 100%;
    background: none;
    border: none;
    padding: 0;
    margin: 0;
    font: inherit;
    color: inherit;
    text-align: left;
    cursor: pointer;
}

.ol-obs-cell--btn:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    border-radius: 8px;
}

/* [exec/2203] T5: the group row keeps its count but wears UiAvatar's styling — the first two
   children's avatars overlapped, with the count beside them — instead of the hand-rolled
   `.ol-avatar` circle. No new component: UiAvatar renders each one. */
.ol-avatar-stack {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
}

.ol-avatar-stack__item:not(:first-child) {
    margin-left: -12px;
}

.ol-avatar-stack__count {
    margin-left: 6px;
    font-size: 13px;
    font-weight: 600;
    color: var(--text-muted);
}

/* [exec/2203] T6: the observation editor's drag-and-drop zone stretches its <InputFile> over the
   whole zone at opacity 0, so a native file DROP lands on the same control a click opens and runs
   the same OnChange handler — one intake path, one set of type/size/20-item checks. This rule is
   global rather than in ObservationEditor.razor.css because Blazor's scoped CSS cannot reach into a
   child component (<InputFile>); a scoped rule of the same name would silently never apply. */
.oe-media-dropzone__input {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: pointer;
}

/* [exec/2203] T6: was `style="position:relative"` inline on the <td>. */
.ol-menu-cell {
    position: relative;
}

/* [exec/2203] T6: the outside-click surface for the open row menu — an inert transparent sibling
   rather than a document-level JS listener, so there is nothing to register, unregister or leak
   when the row is disposed mid-interaction. Sits below the menu (z-index 19 vs 20) and above
   everything else, so the very next click anywhere lands here and closes it. */
.ol-row-menu__backdrop {
    position: fixed;
    inset: 0;
    z-index: 19;
}

/* [exec/2203] T5 kept this rule deliberately. ObservationList's mockup avatar (a hand-rolled div
   showing `ChildName[..1]`) was replaced by the shared UiAvatar, but `.ol-avatar` is NOT dead:
   AssessmentList.razor renders the same mockup and is out of this prompt's scope. Deleting the
   rule would silently unstyle that page. */
.ol-avatar {
    flex-shrink: 0;
    width: 36px;
    height: 36px;
    border-radius: 50%;
    background: var(--accent);
    color: var(--accent-fg);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 14px;
    font-weight: 600;
}

.ol-obs-who {
    font-size: 14px;
    color: var(--text);
}

.ol-obs-preview {
    margin: 2px 0 4px;
    font-size: 13px;
    color: var(--text-muted);
    line-height: 1.4;
}

.ol-obs-areas {
    display: flex;
    gap: 4px;
}

.ol-area-count {
    font-size: 12px;
    color: var(--text-muted);
}

.ol-date {
    font-size: 13px;
    color: var(--text-muted);
    white-space: nowrap;
}

/* [exec/1979] T6/T7: the "⋯" real menu (F7) — a small absolutely-positioned dropdown anchored to
   the menu button's <td> (position:relative, set inline in the markup). */
.ol-row-menu {
    position: absolute;
    right: 0;
    top: 100%;
    z-index: 20;
    min-width: 160px;
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 8px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
    display: flex;
    flex-direction: column;
    padding: 4px;
}

.ol-row-menu__item {
    background: none;
    border: none;
    text-align: left;
    font-size: 13px;
    color: var(--text);
    padding: 8px 10px;
    border-radius: 6px;
    cursor: pointer;
}

.ol-row-menu__item:hover {
    background: var(--border);
}

.ol-row-menu__item--danger {
    color: var(--danger);
}

/* [exec/1979] T6: the read-only quick-review peek panel (inside UiModal). */
.ol-peek {
    min-width: 0;
}

.ol-peek__body {
    white-space: pre-wrap;
    font-size: 14px;
    color: var(--text);
    margin: 8px 0 12px;
}

.ol-peek__media {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
}

.ol-peek__thumb {
    width: 96px;
    height: 96px;
    object-fit: cover;
    border-radius: 8px;
    background: var(--media-viewfinder-bg);
}

/* [exec/1993] T3: batch-detail modal listing a collapsed group's children. */
.ol-batch-group {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.ol-batch-group__item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 8px 10px;
    border-radius: 8px;
    border: 1px solid var(--border);
}

.ol-batch-group__item:hover {
    background: var(--panel-hover, var(--panel));
}

/* [exec/2203] T6: same <div> → <button> treatment as the list rows above, for the same reason. */
.ol-batch-group__btn {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    background: none;
    border: none;
    padding: 0;
    font: inherit;
    color: inherit;
    text-align: left;
    cursor: pointer;
}

/* [exec/1983] F2/T1: reconciled to 768px — UiTable's own card-mode breakpoint
   (UiTable.razor.css:86, `@media (max-width: 768px)`) — instead of the 560px this block used
   before. Between 560px and 768px the list was ALREADY in card mode (UiTable's rule fires at 768)
   but these floors did not yet apply, so a tablet-width tap landed on a desktop-sized hit area in a
   stacked card — the exact mismatch F2 names. `.ol-check`/`.ol-main` bare checkboxes
   are ONLY used on this list (and AssessmentList, the other ol-* consumer), so widening the
   trigger width does not touch AttendanceClassDay.razor.css:205-225 or CareDayBoard.razor.css:
   213-217, which keep their own independent 560px (unrelated files, unrelated rule sets — this is
   the same "floor, not a fixed size" idiom, not the same media query). No third breakpoint is
   introduced: 768px now owns BOTH card layout and touch sizing for this list; 640px (below) stays
   the editor's own, separate breakpoint. */
@media (max-width: 768px) {
    /* 27px tall (13px x line-height 1.5 + 3px padding, twice) → 44px. The label is the whole hit
       area, which is why the height goes here and not on the box. */
    .ol-check {
        min-height: 44px;
    }

    /* The browser default is 13x13px. 18px is the size ActivityLibrary.razor.css:450-455 already
       gives a checkbox that a person taps; the .ol-check rule above supplies the rest of the
       44px, and .ol-main reaches the two bare boxes UiTable renders in the select column. */
    .ol-check input,
    .ol-main input[type="checkbox"] {
        width: 18px;
        height: 18px;
    }

    /* [exec/1983] T2: the two `UiSelect`s and their wrapping row stack full-width instead of
       wrapping raggedly at a fixed 200px each (F3). The three status checkboxes are left alone —
       `.ol-picker-row`'s flex-wrap already lets them wrap onto their own line reasonably. */
    .ol-picker-row {
        flex-direction: column;
        align-items: stretch;
    }

    .ol-picker-select {
        width: 100%;
    }

    /* [exec/1983] T1: `UiTable` itself is untouched (F1a) — this reaches its rendered markup only
       through the `.ol-main` ancestor, the same technique `.ol-main input[type="checkbox"]` above
       already uses, because a rule in UiTable.razor.css cannot see call-site `data-*` attributes
       and a rule in ObservationList's own (scoped) CSS cannot see markup UiTable itself renders —
       portal.css is the one file both are visible from (F2's whole reason to exist). `.ui-table__row`
       gets `position: relative` and headroom so the select checkbox and the "⋯" menu float as a
       small top-corner overlay instead of becoming the card's first two full-width rows (T1). */
    .ol-main .ui-table__row {
        position: relative;
        padding-top: 40px;
    }

    /* !important on border-bottom/padding: UiTable.razor.css's own scoped
       `.ui-table__body ::deep td` rule (:99-105) carries the same specificity (one class + one
       element, the scope attribute Blazor appends counts the same as our `[data-role]` attribute)
       — load order between a global stylesheet and a component's isolated CSS bundle is not a
       contract this file can rely on, so the override is made unconditional rather than hoping
       portal.css always wins. */
    .ol-main td[data-role="select"],
    .ol-main td[data-role="menu"] {
        position: absolute;
        top: 4px;
        border-bottom: none !important;
        padding: 0 !important;
        width: auto;
    }

    .ol-main td[data-role="select"] {
        left: 8px;
    }

    .ol-main td[data-role="menu"] {
        right: 8px;
    }
}

/* ============================================================
   oe-* — ObservationEditor phone-width overrides that CANNOT live in
   ObservationEditor.razor.css (F6/T4). `.ui-input`/`.ui-select`/`.ume-textarea` are rendered by
   UiInput/UiSelect, not by ObservationEditor itself — a scoped rule in
   ObservationEditor.razor.css compiles to e.g. `.oe-main .ui-input[b-yyyy]`, where `b-yyyy` is
   ObservationEditor's OWN scope id. It never matches, because the compiler "stamps a [b-xxxxx]
   attribute only onto markup [that component] itself renders" (UiButton's own comment about
   the identical limitation). portal.css is global/unscoped, so a descendant
   selector here reaches into a child component's markup regardless of scope attributes — the same
   reason F2 put the whole ol-* vocabulary here instead of in a scoped file.
   index.html's viewport locks `maximum-scale=1.0` (F6) — pinch-zoom is off, so an iOS focus-zoom
   triggered by a sub-16px field can never be zoomed back out by the user. Before this rule:
   .ui-input (14px/0.875rem), .ui-select (14px/0.875rem) and .ume-textarea (14px) were all below the
   16px floor on every field in this editor (group/child/date/curriculum/activity-group selects,
   next-step text inputs, the markdown body textarea). ============================================================ */
@media (max-width: 640px) {
    .oe-main .ui-input,
    .oe-main .ui-select,
    .oe-main .ume-textarea {
        font-size: 16px;
    }

    /* Same 44px touch floor as portal.css's ol-* block above (a floor, not a fixed size) — UiInput
       is 2.5rem (40px) and UiSelect is 2.25rem (36px) by default, both under the floor. */
    .oe-main .ui-input,
    .oe-main .ui-select {
        min-height: 44px;
    }
}

/* ═══════════════════════════════════════════════════════════════
   Print (#1016 T2) — "export" for /reports and
   /assessment/portfolio is the browser's own print-to-PDF.
   These rules live in the GLOBAL sheet, not a scoped .razor.css,
   for two reasons: the chrome to hide (sidebar, topbar) belongs to
   MainLayout's scope and is unreachable from a page's scoped
   sheet, and a scoped rule (.x[b-abc]) outranks a global one, so
   a hide/show pair split across the two would never toggle.
   ═════════════════════════════════════════════════════════════ */

/* Print-only headers: the paper has no page chrome, so it carries
   its own title and the child or group it was produced for. */
/* [exec/1038] The payslip joins the SAME print path #1016 built. ADR-66 D3 makes paper handed over
   the only valid delivery channel for a payslip, so printing is not a convenience here — it is the
   feature. Three added selectors; no second stylesheet, no PDF library. */
/* [exec/1551] The receipt joins the same print path — ADR-70 D2/D74 D5/ADR-88 D6: a "chứng từ nội
   bộ", never a VAT invoice, printed the same way a payslip is. One added selector; no second
   stylesheet, no PDF library. */
.pf-print-head,
.rp-print-head,
.ps-print-head {
    display: none;
}

/* [exec/1778] T6: `.rc-print-head` moved out of the rule above into
   `_content/FinOS.Wasm.Controls/css/receipt-print.css`'s Screen section. It was the only one of
   the four print-heads here that belongs to a SHARED FinOS.Wasm.Controls component
   (ReceiptDocument.razor) rather than an admin-only surface — `.pf-`/`.rp-`/`.ps-` print-heads all
   belong to admin-only payslip/roster/… surfaces and stay admin-only, here. Before this move,
   Family (which does not load portal.css) never hid `.rc-print-head`, so its paper-only title/doc-no/
   date line rendered on screen above an otherwise-unstyled card — exactly the "Mở ra trắng trơn"
   symptom reported. Admin's rendering is unchanged: receipt-print.css is still linked after
   portal.css in wasm/FinOS.Wasm/wwwroot/index.html, so the cascade order (and the computed value,
   `display: none` either way) is identical to before the move. */

/* [exec/1551] Screen layout for the receipt document card moved to [exec/1763]: this file is not
   loaded by the Family app (index.html links tokens/family/scoped only), so the receipt rendered
   with none of these rules there — same defect [exec/1611] found and fixed for the PAPER half.
   The 9 rules now live in `_content/FinOS.Wasm.Controls/css/receipt-print.css`'s Screen section,
   the one sheet both apps' index.html already load LAST for this component. Nothing here declares
   `.rc-doc*` any more; admin's rendering is unchanged because that sheet is still linked after this
   one in wasm/FinOS.Wasm/wwwroot/index.html, so the cascade order (and every property value) is
   identical to before the move. */

@media print {
    /* A school prints in mono on A4. Say so, rather than hoping the dialog defaults match. */
    @page { size: A4 portrait; margin: 12mm; }

    /* [exec/1102] Swap the screen grid for the door sheet. .nut-screen-only is the grid that
       carries allergy warnings; .nut-actions is the print button printing itself.
       [exec/1633] .ap-screen-only/.ap-actions: same swap for the planner (T1.1) — reused on
       MenuTemplates.razor too (T2.2), so the chrome around the open rotation hides the same way. */
    .nut-screen-only,
    .nut-actions,
    .ap-screen-only,
    .ap-actions {
        display: none !important;
    }

    .nut-print { display: block !important; }

    /* [exec/1633] T1.6: A4 LANDSCAPE, scoped to the planner sheet only via the CSS `page`
       property below — the GLOBAL @page above (A4 portrait) stays the default for every other
       print surface in the app. A 6-day-wide grid in portrait would need type too small to read
       at a photocopier. */
    .ap-print { display: block !important; page: ap-planner; }
    @page ap-planner { size: A4 landscape; margin: 10mm; }

    .ap-print__head { margin-bottom: 10px; }
    .ap-print__title { display: block; font-size: 18px; font-weight: 700; }
    .ap-print__meta { display: inline-block; margin-right: 12px; font-size: 12px; }

    /* Black and white only, and no ellipsis: the screen grid truncates (WeeklyPlanner.razor.css
       :420 etc.) precisely because it must fit a 64px cell — this table has no such limit, so
       every cell shows its full text (T1.4). */
    .ap-print__table {
        width: 100%;
        border-collapse: collapse;
        font-size: 9pt;
    }

    .ap-print__table th,
    .ap-print__table td {
        border: 1px solid #000;
        padding: 4px 6px;
        text-align: left;
        vertical-align: top;
        color: #000;
        background: none;
        white-space: normal;
        overflow: visible;
    }

    .ap-print__table th { font-weight: 700; }

    .ap-print__table tr {
        break-inside: avoid;
        page-break-inside: avoid;
    }

    .ap-print__sub { display: block; font-size: 8pt; }
    .ap-print__event { display: block; font-size: 8pt; }

    .nut-print__head { margin-bottom: 10px; }

    .nut-print__title {
        display: block;
        font-size: 18px;
        font-weight: 700;
    }

    .nut-print__meta {
        display: inline-block;
        margin-right: 12px;
        font-size: 12px;
    }

    /* Black and white only: structure comes from borders and weight, never from a fill. */
    .nut-print__table {
        width: 100%;
        border-collapse: collapse;
        font-size: 11pt;
    }

    .nut-print__table th,
    .nut-print__table td {
        border: 1px solid #000;
        padding: 5px 7px;
        text-align: left;
        vertical-align: top;
        color: #000;
        background: none;
    }

    .nut-print__table th { font-weight: 700; }

    /* A day's row split across two sheets is a menu somebody misreads at a door. */
    .nut-print__table tr {
        break-inside: avoid;
        page-break-inside: avoid;
    }

    .nut-print__date {
        display: block;
        font-size: 9pt;
    }

    .nut-print__dish { display: block; }

    /* Page chrome — nothing navigational belongs on paper.

       [exec/1611] T1. `.al-tabbar` was missing, which is the menu the owner saw at the top of the
       printed receipt: the module tab strip is a SIBLING of .al-topbar (MainLayout.razor:187), not
       a child, so hiding the topbar never reached it.

       Audited every top-level child of MainLayout rather than adding one selector and stopping —
       what the read found beyond .al-tabbar:
         - `.al-rail` (PortalNavRail.razor:20) — the module rail. NOT hidden by anything here either.
           The two dead selectors below are why nobody noticed: `.al-sidebar` and `.al-collapse`
           match no element in the repo (grep: this file is their only occurrence). They are the
           pre-#1026 sidebar's class names, left behind when the rail replaced it. Kept, with this
           note, only because PortfolioMediaAndPrintRenderTests asserts `.al-sidebar` is in this
           list; `.al-rail` is the selector that actually does the work now.
         - `.al-hamburger` — inside .al-topbar, already covered.
         - `.ui-toast-host` (UiToastHost) and `.uicp-overlay` (UiCommandPalette) — mounted as
           siblings of the shell, outside .al-main entirely. A toast that happens to be on screen
           when someone hits print lands on the paper today. Added.
         - `.uiconfirm-*` (UiConfirm) — a confirm dialog is modal; nobody prints behind one, and it
           has no stable root class to name from here. Deliberately left.
         - the `<div style="zoom: …">` at MainLayout.razor:220 — see .al-content below. */
    .al-sidebar,
    .al-topbar,
    .al-tabbar,
    .al-rail,
    .al-backdrop,
    .al-collapse,
    .al-hamburger,
    .ui-toast-host,
    .uicp-overlay,
    .al-reconnect-banner {
        display: none !important;
    }

    /* Filters and controls: the sheet records the filter's RESULT;
       an inert dropdown printed next to it is noise. */
    .pf-actions,
    .rp-actions,
    .pf-selectors,
    .pf-filter,
    .rp-selectors,
    .rp-cards,
    .rp-child-tabs,
    .ps-actions,
    /* [exec/2524] the KPI print page's own title/button row; the paper form carries its own header. */
    .kp-actions,
    .rc-actions,
    /* [exec/2224] T3 — the A5 overflow hint is advice about paper for the person at the screen;
       printing it onto the document itself would be absurd. */
    .rc-a5-hint,
    .ad-page__hint,
    .ui-btn,
    /* [exec/1139.2]: these four upload/open controls used to carry a dead `ui-btn` token
       written outside UiButton (see the note above .ad-upload-btn). The token styled nothing
       on screen — UiButton.razor.css is scoped — but it DID pick up this print-hide rule.
       Removing the token would have made an upload button print, so the companion classes
       that actually style those elements are named here to keep print output unchanged. */
    .ad-upload-btn,
    .ap-upload-btn,
    .mg-upload-btn,
    .mg-preview__openlink,
    /* [exec/2002] UiRowActions' own root classes — an edit/delete icon row or a ⋯ trigger printed
       next to a row is exactly as inert as the .ui-btn/.ad-upload-btn tokens above. */
    .ad-rowaction,
    .ad-overflow {
        display: none !important;
    }

    .pf-print-head,
    .rp-print-head,
    .ps-print-head,
    .rc-print-head {
        display: block;
        margin-bottom: 12px;
    }

    .pf-print-head__title,
    .rp-print-head__title,
    .ps-print-head__title,
    .rc-print-head__title {
        display: block;
        font-size: 18px;
        font-weight: 700;
    }

    .pf-print-head__meta,
    .rp-print-head__meta,
    .ps-print-head__meta,
    .rc-print-head__meta {
        display: inline-block;
        margin-right: 12px;
        font-size: 12px;
    }

    /* [exec/1551] The relief/total table must not split across a page break either — same
       reasoning as .ad-table above, just scoped to the receipt's own line-item table. */
    .rc-doc__table tr {
        break-inside: avoid;
        page-break-inside: avoid;
    }

    /* The receipt's logo prints at a fixed cap so a large upload never pushes the header onto
       its own page. */
    .rc-doc__logo {
        max-height: 60px !important;
    }

    /* [exec/2229] T2 — release the screen-mode viewport lock added at the top of this file.
       `html`/`body` are `overflow: hidden; height: 100%` there so the admin shell owns the viewport
       and the document cannot scroll. In print that is exactly the F6 failure the block below
       already fixes one level down: a long document clipped at one viewport box instead of
       paginating. Same `!important` reasoning as the three shell selectors after it. */
    html:has(.admin-shell),
    html:has(.admin-shell) body {
        height: auto !important;
        overflow: visible !important;
    }

    /* [exec/1776] T6 — the diagnosed mechanism for F6 (a long document truncates with a scrollbar
       instead of paginating). `.al-content` is declared in MainLayout.razor.css, a Blazor
       CSS-ISOLATED (scoped) sheet: the framework rewrites every one of its selectors to append the
       component's scope attribute (`.al-content[b-xxxxxxxxxx] { overflow-y: auto; min-height: 0; … }`,
       MainLayout.razor.css:195), which raises its specificity to one class + one attribute. This
       block's plain `.al-content` selector is one class alone, so — with no `!important` — it LOST
       to the scoped rule regardless of source order or being inside `@media print`. The screen
       scroll box (`overflow-y: auto`, `flex: 1; min-height: 0`) therefore survived into print,
       clipping the pane to the viewport height instead of letting the document flow onto a second
       sheet: exactly "a scrollbar, and the bottom rows cut off" (Findings F6). `!important` here is
       what makes an unscoped override actually win over a scoped one; it is not decoration. Same
       fix needed for `.admin-shell`/`.al-main` should either ever gain an isolated sheet of their
       own — cheap to keep on all three now rather than rediscovering this per surface. */
    .al-main,
    .al-content,
    .admin-shell {
        display: block !important;
        margin: 0 !important;
        padding: 0 !important;
        width: auto !important;
        height: auto !important;
        min-height: 0 !important;
        max-height: none !important;
        overflow: visible !important;
        background: none !important;
    }

    /* [exec/1611] T1 — MainLayout.razor:225 wraps the page slot in `<div style="zoom: @ScaleCss">`,
       the global admin text zoom. It has no class, so it is addressed by position: the only
       element child of .al-content. It survives into print, which means an operator who has set
       the app zoom to 1.25 gets a receipt 25% larger than the A4 box this sheet sizes for, and one
       at 0.9 gets the "too small" the owner reported — the paper size must come from @page and the
       pt sizing, not from a screen preference.

       #526 checked before touching it, and it does not bite: the rule bans a zoom/transform/filter
       value on an ANCESTOR OF an <iframe>/<video>, because a non-identity value breaks the video
       surface on the TV WebView. This declaration sets the identity value (`zoom: 1` is a no-op
       that removes a scale rather than applying one), it is inside `@media print` where no video
       plays, and this is the admin shell, not the TV — /tv/media, the surface #526 is about, does
       not load this stylesheet at all. Neutralising a zoom is the opposite of the hazard. */
    .al-content > div {
        zoom: 1 !important;
    }

    /* A timeline entry or a table row must not be split across a
       page break — a half-printed observation is worse than a
       shorter page. */
    /* [exec/1038] and a payslip row: half a withholding line on one sheet with its amount on the
       next is a document somebody will query. */
    .pf-entry,
    .pf-media,
    .rp-tally-table tr,
    .ad-table tr {
        break-inside: avoid;
        page-break-inside: avoid;
    }

    /* The tally table scrolls on screen; on paper it must not clip. */
    .rp-tally-scroll {
        overflow: visible !important;
    }

    /* [exec/2524] The KPI paper form's tables (grid, Part 2). An A4 page is ~794px wide, under the 992px breakpoint that turns
       every .ad-table into a horizontally scrolling block with nowrap -- which clips the week columns on paper. The paper
       form is a real table again, wrapping and bordered, black on white; structure comes from borders, never a fill. */
    .kp-paper .ad-table {
        display: table;
        overflow: visible;
        white-space: normal;
        background-image: none;
        font-size: 8.5pt;
    }

    .kp-paper .ad-table th,
    .kp-paper .ad-table td {
        padding: 4px 6px;
        border: 1px solid currentColor;
        background: none;
        color: inherit;
        text-transform: none;
        letter-spacing: normal;
        white-space: normal;
        min-width: 0;
    }

    .kp-paper .ad-table th {
        font-weight: 700;
    }

    /* A week header ("Tuan 1 (01/09 - 06/09)") wraps on paper; the money header class is nowrap on screen. */
    .kp-paper .ad-table th.ad-th--money {
        white-space: normal;
    }

    /* The weekly grid: fixed layout so the week columns are equal and the page width is never exceeded; the four leading columns
       take explicit shares of the first row, whatever the number of weeks. */
    .kp-paper .ad-table[data-testid="kpi-print-grid"] {
        table-layout: fixed;
        width: 100%;
    }

    .kp-paper .ad-table[data-testid="kpi-print-grid"] th:nth-child(1) { width: 14%; }
    .kp-paper .ad-table[data-testid="kpi-print-grid"] th:nth-child(2) { width: 4%; }
    .kp-paper .ad-table[data-testid="kpi-print-grid"] th:nth-child(3) { width: 24%; }
    .kp-paper .ad-table[data-testid="kpi-print-grid"] th:nth-child(4) { width: 6%; }
}

/* ── Announcement read surface (#1029 T1) ────────────────────── */
/* Announcement bodies are plain text authored in a bare <textarea>, so the read surface must
   preserve the author's line breaks without reinterpreting the text as markup. */
/* [exec/1054] T2: the four metadata fields used to render as four bare <span>s with no separator,
   so they read as one unbroken string ("Toàn trườngTrạng thái…"). One flex row, a real interpunct
   between items, muted label + solid value, and wrapping instead of overflow when narrow. */
.an-detail__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px 10px;
    margin-bottom: 14px;
    font-size: 14px;
}
.an-detail__meta-item {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
}
.an-detail__meta-label { color: var(--text-muted); }
.an-detail__meta-value { font-weight: 600; color: var(--text); }
/* Separator is a real element rather than a ::before on the item: it must disappear at the end of
   a wrapped line's run, and it must never be read out by a screen reader (aria-hidden in markup). */
.an-detail__meta-sep {
    color: var(--text-faint);
    user-select: none;
}

/* [exec/1898] T1 — the read page's attachment grid. Thumbnails only; the full-size media loads
   only once UiPhotoViewer opens (see AnnouncementDetail.razor). */
.an-detail__attachments {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-top: 16px;
}
.an-detail__attachment {
    width: 132px;
    height: 132px;
    border-radius: var(--radius);
    overflow: hidden;
    border: 1px solid var(--border-subtle);
    background: var(--surface-2);
}
.an-detail__attachment--tap {
    padding: 0;
    cursor: pointer;
}
.an-detail__attachment--placeholder {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 8px;
    text-align: center;
    font-size: 12px;
    color: var(--text-muted);
}
.an-detail__attachment-media {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

/* [exec/1133] (LOI-117) A width FLOOR for the Danh mục column of the expense ledger, which
   `table-layout: auto` was starving: measured at 108px in a headless Chrome harness with the real
   Be Vietnam Pro webfont and a long note in the Người nhận cell, where "Chưa phân loại" broke onto
   two lines. The payee cell wins the slack because `.fin-ledger__ref`'s `overflow-wrap: anywhere`
   (below) makes its min-content tiny while its preferred width stays large.
   136px = 32px of `.ad-table td` horizontal padding + 104px content box. The widest thing that must
   fit on one line is the fallback value "Chưa phân loại" at 14px/400 = 102.4px (the "DANH MỤC"
   header at 11.5px/700 + 0.05em tracking is 69.5px; English "Uncategorised" is 101.9px) — advance
   widths summed from the shipped `be-vietnam-pro-*-400/700.woff2`, confirmed by the render.
   NOT `white-space: nowrap` — that is #1132's fix for a fixed-vocabulary label; a category name is
   operator-supplied and unbounded, so it must still be allowed to wrap past this floor (it does:
   a 152px name still takes two lines here, which is correct). NOT `table-layout: fixed` — see the
   `.ad-td--money` note below and FinanceSummary.razor.
   `width` rather than `min-width`, matching `.ad-col-actions` above, which [exec/1060] proved out
   against exactly this defect: under auto layout it is a floor the column keeps and still grows
   from when content demands, without claiming the slack the payee column needs.
   Lives here rather than beside `.ad-col-actions` because the expense ledger is its only call
   site; move it up to the column-modifier family when a second table adopts it. */
.ad-table th.ad-col-category, .ad-table td.ad-col-category { width: 136px; }

/* ── Finance ledger: what the row already stored ([exec/1045]) ────────────────
   ExternalReference / PayerName / Note travelled to the browser from #1035 and #1036 and were
   never rendered. They go on a sub-line under the detail cell rather than in three more columns,
   which would push the ledger past the 1366px admin laptop and force a horizontal scroll on the
   one table people read left-to-right. */
.fin-ledger__meta {
    display: flex; flex-wrap: wrap; gap: 4px 16px;
    margin-top: 4px;
    font-size: 12px;
    color: var(--text-muted);
}
.fin-ledger__meta-item { display: inline-flex; align-items: baseline; gap: 4px; min-width: 0; }
/* [exec/1997] Razor's ComponentWhitespacePass strips the whitespace-only text node between
   `</b>` and an `@if` block, so a name and its identity code/badge that sit on separate source
   lines render with zero space between them (e.g. "Hoàng Gia PhátVAN-HOANGGIAPHAT-11102022").
   This is a markup problem, not a missing margin — do NOT "fix" it by adding margin-inline-start
   to `.ad-label__hint` or `.fin-ledger__ref` (that would double-space the many call sites that
   already sit on one source line with a literal space). Wrap the identity content of the cell in
   this inline-flex container instead; it supplies the gap Razor ate. Do not delete this thinking
   it's decorative — removing it re-flattens the name and code back together. */
.ad-identity-group { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 8px; min-width: 0; }
.fin-ledger__meta-key { color: var(--text-faint); }
/* The reference is a code an operator reads back character by character against a bank statement,
   so it gets a monospaced face — 0/O and 1/l are the whole reason. */
.fin-ledger__ref {
    font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
    font-size: 12px;
    letter-spacing: 0.02em;
    overflow-wrap: anywhere;
}

/* ── Child ledger row readability ([exec/2313]) ───────────────────────────────────────────────
   T1 — one visual hierarchy per row. The detail cell is: a title line (label + ONE info chip for
   the period + the two warn markers), an optional muted relief line, one muted small-text line of
   secondary facts joined by " · ", then the reference/payer/note block above. The two muted lines
   are built by FinanceChildLedger.JoinedLine (a RenderTreeBuilder fragment, so they are styled here
   in the global sheet — a component-scoped rule cannot reach elements the builder emits).
   `--text-muted` (= --text-secondary) computed from the real tokens: 5.33:1 on --panel and 4.73:1 on
   the row hover / --panel-2 in light (#5f6976 on #f8fafc / #e8edf3); 7.55:1 and 7.32:1 in dark (#9aa4b0
   on #0d1014 / #101419) — all above the 4.5:1 AA floor for 12-14px text. `--text-faint` is NOT used
   for text here: it is lighter than AA on these surfaces. */
.fin-ledger__title { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-width: 0; }
.fin-ledger__label { min-width: 0; }
/* The detail cell is the one column that carries prose: between 720px and 992px `.ad-table` is a
   nowrap horizontal scroller (above), and without this the label, chips and muted lines would all
   run on one line there. The four short columns keep their own nowrap (`.fin-ledger__col-fit`). */
.ad-table td.fin-ledger__detail { white-space: normal; }
.fin-ledger__flags, .fin-ledger__relief {
    display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 6px;
    margin-top: 4px; min-width: 0;
    font-size: 12px; color: var(--text-muted);
}
.fin-ledger__sep { flex: none; }

/* T1 — a reversed original and a reversal row are dimmed as a whole through the row class. Dimming
   is a TOKEN swap (body text -> --text-muted), never `opacity`: opacity would also fade the two
   warn badges that must stay legible, and would push muted text under AA. The red "Dư có" of the
   running balance is a per-row artefact of a pair that nets to zero, so it is not repeated here. */
/*
   [exec/2204] T1 — the per-row actions cell.

   The cell is `.ad-col-actions--fit` (width:1%, nowrap, right-aligned). Its buttons used to be bare
   inline-flex boxes, so once the print button carried the full "In chứng từ" label the column grew
   wide enough that the "⋯" trigger wrapped onto a second line. Print is now icon-only, and the
   buttons live in one flex row so they stay on a single line whatever the row offers.

   The flex container is a <div> inside the <td>, not the <td> itself: `display:flex` on a table cell
   takes it out of table layout and the column would stop aligning with its header.
*/
.fin-ledger__actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 6px;
}

/* `.ad-btn--icon` (portal.css) is the shared square icon-button modifier, but it is sized for the
   38px default button; paired with `.ad-btn--sm` (32px tall) the later `--sm` rule also re-applies
   horizontal padding. Both are corrected here so the small print button is a true 32px square. */
.fin-ledger__actions .ad-btn--icon {
    width: 32px;
    padding: 0;
    flex-shrink: 0;
}

.ad-table .fin-ledger__row--reversed td { color: var(--text-muted); }
.ad-table .fin-ledger__row--reversed .ad-money--credit { color: inherit; }

/* T4 — the four short columns never wrap ("PT2026-" / "000038", "Phải" / "thu"): shrink-to-fit, so
   the detail cell takes all remaining width. Same shape as `.ad-col-status` above. */
.ad-table th.fin-ledger__col-fit, .ad-table td.fin-ledger__col-fit { width: 1%; white-space: nowrap; }

/* T5 — at <=720px the ledger stacks into labelled cards instead of scrolling sideways. This is the
   existing `.ad-addon-table` card mode (<=700px, above) applied to this table: header hidden, every
   cell a block with its `data-label` as a prefix, and the three resets a card mode owes the <=992px
   `.ad-table` scroll fallback (AdTableCardModeNowrapResetGuardTests). Its own block, not a widened
   `.ad-addon-table` one, because the requirement here is 720px and that block is shared with the
   credit-history dialog. A voucher's repeat rows leave the number cell empty, so that cell is hidden
   in card mode rather than showing a bare label, and so is any other empty cell (a row with no due date). */
@media (max-width: 720px) {
    .fin-ledger__table { display: block; overflow-x: visible; white-space: normal; background-image: none; }
    .fin-ledger__table thead { display: none; }
    .fin-ledger__table, .fin-ledger__table tbody, .fin-ledger__table tr, .fin-ledger__table td { display: block; width: 100%; }
    .fin-ledger__table tr { border: 1px solid var(--border); border-radius: var(--radius-sm); margin-bottom: 10px; padding: 8px 12px; }
    .fin-ledger__table td { border-bottom: none; padding: 6px 0; text-align: left; }
    .fin-ledger__table td::before {
        content: attr(data-label);
        display: block;
        font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
        color: var(--text-muted); margin-bottom: 2px;
    }
    .fin-ledger__table td.fin-ledger__col-fit, .fin-ledger__table td.ad-col-actions--fit { width: 100%; text-align: left; }
    .fin-ledger__table td.fin-ledger__cell--repeat, .fin-ledger__table td:empty { display: none; }
    .fin-ledger__table .fin-ledger__actions { justify-content: flex-start; }
}

/* A balance row navigates, so it must look and behave like it does — it was clickable and
   completely undiscoverable, with no pointer and no tab stop. */
.ad-table__row--link { cursor: pointer; }
.ad-table__row--link:hover { background: var(--panel-hover); }
.ad-table__row--link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* Finding a child who has no ledger yet ([exec/1045] T1). Sits above the balance table and is
   deliberately quieter than it — it is a way in, not the page's subject. */
.fin-childfind { margin-bottom: 16px; }
.fin-childfind__list { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.fin-childfind__hit { justify-content: flex-start; gap: 8px; width: 100%; text-align: left; }

/* ── Money cells ([exec/1046] T1) ─────────────────────────────────────────────
   A new, narrow class rather than a change to `.ad-table td`: right alignment and tabular
   figures are correct for an amount and wrong for the twenty-odd other tables in the app that
   share that selector (#1046 CONSTRAINTS). Global rather than a `.razor.css`, because ten pages
   in two folders put the class on their own <td> and a scoped rule cannot reach a sibling
   component (#996). */
.ad-td--money {
    text-align: right;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}
/* [exec/1057] T3 — the header half of the pair. `.ad-table th` is left-aligned for every other
   column and stays that way (a global change would move twenty-odd non-money tables); the money
   header opts in per cell, the same way the cell does. A right-aligned column under a left-aligned
   header reads as two different columns.
   [exec/1093] T1 — the selector must out-specify `.ad-table th` (0,1,1), which a bare `.ad-th--money`
   (0,1,0) does not: every money header computed `text-align: left` from #1057 until now. Qualified to
   `.ad-table th.ad-th--money` (0,2,1), the same shape `.ad-col-actions` above already uses. Not
   `!important`, and `.ad-table th`'s default is untouched. */
.ad-table th.ad-th--money {
    text-align: right;
    white-space: nowrap;
}
.ad-money { font-variant-numeric: tabular-nums; }
/* Money cells never wrap: "1.250.000 đ" stays on one line in every list. */
.ad-table .ad-money { white-space: nowrap; }
/* Money owed BY the school reads as an exception, so it is coloured like one. */
.ad-money--credit { color: var(--danger); }
/* Zero is not news. It was rendered bold and full-width and looked exactly like a large debt. */
.ad-money--zero { color: var(--text-faint); font-weight: 400; }
/* The emphasis is UiMoney's own, not a <b> wrapper, so that it can be withheld at zero. */
.ad-money--strong { font-weight: 700; }

/* [exec/1633] The planner's own print-only sheet — and a
   9-blocker screen grid (WeeklyPlanner.razor.css) that makes forcing the interactive grid to
   print a worse option than a second block. See .ap-screen-only below. */
.ap-print { display: none; }

/* ── [exec/1128] `.ad-banner` — the class 14 call sites used and no stylesheet defined ────
   LOI-112: `.ad-banner`, `--info`, `--warn` and `--error` had zero rules anywhere in the repo,
   so every banner rendered as a bare `<div>` in UA default flow: no background, no border, no
   padding, and — the worse half — no visual difference between an error and an informational
   notice. The drift guard had caught it and the finding was allowlisted at #980 rather than
   fixed; those entries are deleted in this same change.

   The base MUST stay usable alone: `WeekTopicsPanel.razor:23` uses `.ad-banner` with no modifier.

   The base MUST stay in flow layout. `PlatformOrganizations.razor:60` and `:141` hold
   `<strong>…</strong><br />text<br />text`; under `display: flex` the `<br />` stops producing
   line boxes and each text run becomes its own flex item laid out side by side, collapsing both
   banners into one squashed row. So no flex here — the lone inline icon is handled below by
   overriding the `app.css:171` `svg { display: block }` reset locally instead. That also avoids
   `:has()`, whose support floor on the admin SPA is undocumented (#1119). */
.ad-banner {
    padding: var(--space-3);
    border: 1px solid var(--border-subtle);
    border-left: 3px solid var(--text-muted);
    border-radius: var(--radius-sm);
    background: var(--surface-2);
    color: var(--text-primary);
    font-size: 0.875rem;
    line-height: 1.5;
}

/* The only icon-bearing banner is `Roles.razor:43`, whose `<svg style="flex-shrink:0">` is a
   fossil of a flex parent that was never written. Returning the icon to inline flow puts it on
   the text baseline without changing layout for the other 13 sites, which contain no svg. */
.ad-banner svg {
    display: inline-block;
    vertical-align: text-bottom;
    margin-right: var(--space-2);
}

/* Severity is carried by three things at once — left-border colour, tinted background and text
   colour — because a single tint is too weak to separate error from info at a glance, which is
   the defect the reporter actually hit. */
.ad-banner--info {
    border-left-color: var(--info);
    background: var(--info-soft);
    color: var(--info);
}
.ad-banner--warn {
    border-left-color: var(--warning);
    background: var(--warning-soft);
    color: var(--warning);
}
.ad-banner--error {
    border-left-color: var(--danger);
    background: var(--danger-soft);
    color: var(--danger);
}

/* [exec/2222] The fourth severity, added because the credits card's top-up/deduct result is a
   success/failure pair and `--info` (navy, the same colour as the accent) cannot carry "it worked"
   opposite an `--error` red. Built from the same three tokens as the other three modifiers, so it
   is the existing pattern extended rather than a new one. Sole call site today:
   PlatformLimits.razor's credits banner. */
.ad-banner--success {
    border-left-color: var(--success);
    background: var(--success-soft);
    color: var(--success);
}

/* [exec/2157] T3 — the tuition page's not-billed banner (`tuition-not-billed-hint`,
   FinanceTuition.razor) rendered flush against the card above it: `.ad-banner` above declares
   padding/border/background but no margin at all, and the banner is a bare sibling with nothing
   between it and whatever precedes it. Reuses the exact spacing value the page's own card rhythm
   already uses (`.ad-card + .ad-card { margin-top: 16px; }`, :225) rather than inventing a new
   number — same 16px gap the page uses everywhere else two blocks stack. Sole call site:
   FinanceTuition.razor's `tuition-not-billed-hint` banner. */
.ad-banner--spaced {
    margin-top: 16px;
}

/* [exec/1275] The human-quotable support ticket code. Global rather than scoped because it is
   printed on three pages (org list, org thread sidebar, platform list) and must read identically
   on all of them — a code that looks different in two places reads as two different codes. */
.sup-code {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 12px;
    letter-spacing: .02em;
    color: var(--text-secondary);
    white-space: nowrap;
}

/* [exec/1776] T7 — window.print opens an OS print sheet with no printer on a phone (owner decision,
   Findings F7: "cấm in trên đt luôn"). Precedent and breakpoint copied verbatim from
   wasm/FinOS.Wasm.Family/Pages/ReceiptDetail.razor.css:22-26 rather than choosing a new number: 440px
   is comfortably inside "phone", checked against 375/414 (hide) and 768/1024+ (show). One shared
   class (`ad-print-btn`) on all seven portal.css print buttons (Receipt, Payslip, MenuTemplates,
   MenuWeek, LearningReports, Portfolio, WeeklyPlanner) means one rule reaches every site instead of
   seven repeats. Hidden, not disabled — a disabled button on a phone invites a tap and explains
   nothing. This is a screen-mode rule; it must stay OUTSIDE the `@media print` block above, which
   already hides `.ap-actions` etc. for print output, a different concern entirely. */
@media (max-width: 440px) {
    /* [exec/2224] T2 — `!important` added, and it is load-bearing rather than lazy. The receipt's
       print control is a shared `<UiButton>` now, and `UiButton.razor.css` is Blazor CSS-isolated:
       its `.ui-btn` rule compiles to `.ui-btn[b-xxxxx] { display: inline-flex }`, an attribute
       selector this bare class cannot outrank on specificity, so without `!important` the button
       stayed visible on a phone and [exec/1776] T7's owner decision ("cấm in trên đt luôn") silently
       regressed — measured at a 400px viewport before this line was added. The other six call sites
       are raw `<button class="ad-btn …">` with no competing scoped rule, so `!important` changes
       nothing for them. */
    .ad-print-btn {
        display: none !important;
    }
}

/* [exec/1776] T3 — the tuition wizard's two-tab child picker (step 1). Rules copied verbatim from
   ConversationsPanel.razor.css's `.cv-pickgrid*` (values, spacing, grid shape all identical) rather
   than a literal class-name reuse: that sheet is Blazor CSS-isolated (scoped to ConversationsPanel's
   own render tree), so a bare `.cv-pickgrid` class on a FinanceTuition-rendered element would carry
   no scope attribute and match nothing there — the same specificity/isolation mechanism T6 diagnosed
   for portal.css's own print override. FinanceTuition has no scoped .razor.css of its own (every one
   of its existing rules already lives here, in the global sheet), so the copy lives here too, under
   this page's own name — the same "repeat, name it, say why" choice [exec/1743] made for Attendance's
   own photo-tile grid rather than reusing cv-pickgrid literally either. The one addition over
   cv-pickgrid: `is-selected`/`is-blocked` states and a corner checkmark, because this grid is a
   MULTI-select (ticking several children) where cv-pickgrid is a single navigate-away pick. */
.fin-as-pickgrid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
    gap: 12px;
}

.fin-as-pickgrid__tile {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    padding: 10px 6px;
    border: 1px solid transparent;
    border-radius: 10px;
    background: none;
    cursor: pointer;
    text-align: center;
}

.fin-as-pickgrid__tile:hover:not(:disabled),
.fin-as-pickgrid__tile:focus-visible {
    border-color: var(--border);
    background: var(--control-bg-hover);
}

.fin-as-pickgrid__tile.is-selected {
    border-color: var(--accent);
    background: var(--panel-hover);
}

.fin-as-pickgrid__tile.is-blocked,
.fin-as-pickgrid__tile:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

.fin-as-pickgrid__name {
    font-size: 0.85rem;
    line-height: 1.2;
    overflow: hidden;
    text-overflow: ellipsis;
    /* [exec/1792] T4 — missing since this rule was copied from .cv-pickgrid__name
       (ConversationsPanel.razor.css:31-38); without it text-overflow:ellipsis is inert and a name
       wraps to two or three lines and is hard-clipped by overflow:hidden with NO ellipsis at all. */
    white-space: nowrap;
    max-width: 100%;
}

/* [exec/1893] T3 — the fee search-result chips (FinanceTuition.razor, tuition-fee-search-result)
   sit inside .ad-check-grid's `repeat(auto-fill, minmax(160px, 1fr))` track as .ad-btn buttons.
   .ad-btn's white-space: nowrap (portal.css) has no overflow/min-width/text-overflow handling, so a
   fee label longer than ~160px cannot wrap and cannot be clipped — it paints across the next grid
   cell. Confirmed in a browser before this rule was written. Same four properties [exec/1792] T4
   established above for .fin-as-pickgrid__name are the fix; min-width: 0 (not max-width: 100%,
   which .fin-as-pickgrid__name uses) because THIS element is itself the grid item — a flex/grid
   item's default min-width is auto (its content size), which is what let the text escape in the
   first place. A dedicated class rather than editing .ad-btn (11 other call sites in this file) or
   .ad-check-grid (4 other genuine checkbox grids with short labels elsewhere: McpScopePicker.razor,
   TvCommands.razor). The full label stays reachable via the button's title attribute. */
.fin-as-feechip {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}

/* [exec/1940] T3 — the bulk assignment modal a bit wider than the shared `.uimodal-card--wide`
   10 other call sites depend on (UiModal.razor.css:69, untouched) — same page-scoped-override
   idiom as `.ae-ms-modal.uimodal-card` above (portal.css:2010): two classes beat one in specificity,
   so this wins over `--wide`'s width regardless of source order, without editing the shared rule. */
.fin-as-modal.uimodal-card {
    width: min(880px, 94vw);
}

/* [exec/2068] T1 — the grid item is now this wrapper, not the tile button directly: the tile stays
   disabled while FullyBlocked, and the close offer below it is a DISTINCT clickable element (a
   button nested inside a disabled button is both invalid HTML and unreachable). Flex column with
   the default stretch cross-axis keeps the tile button's own width exactly what it was before this
   wrapper existed. */
.fin-as-pickgrid__cell {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.fin-as-pickgrid__closeoffer {
    font-size: 0.78rem;
    padding: 2px 8px;
    white-space: normal;
    text-align: center;
}

.fin-as-pickgrid__closenote {
    font-size: 0.72rem;
    margin: 0;
    text-align: center;
}

.fin-as-pickgrid__check {
    position: absolute;
    top: 4px;
    right: 4px;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: var(--accent);
    color: var(--accent-fg);
    font-size: 12px;
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: center;
    line-height: 1;
}

/* [exec/1956] T3 — the inline "stack another relief" panel ([exec/1941] T3) was a bare .ad-card
   with no internal layout rules of its own: raw borders around an unspaced select, scope toggle,
   two date inputs and two buttons, all stacked full-width. This turns it into a coherent sub-panel
   without touching .ad-card/.ad-form-row/.ad-btn themselves (all shared elsewhere in this file). */
.fin-as-reliefpicker {
    display: flex;
    flex-direction: column;
    gap: 12px;
    margin-top: 8px;
}

.fin-as-reliefpicker__dates {
    margin-bottom: 0;
}

.fin-as-reliefpicker__footer {
    display: flex;
    gap: 8px;
    justify-content: flex-end;
}

/* [exec/1978] T6/F7 — page-scoped override of the per-fee triple row's track sizing, NOT an edit to
   .ad-form-row--triple itself (that class is shared with RecipesPanel.razor and
   AttendanceClassDay.razor, grepped before this was written — [exec/1956] measured the equal-thirds
   split for those callers too). The middle column here is always Số lượng (a 1-3 digit integer);
   giving it a narrow fixed-ish track and splitting the freed space between the amount/discount
   fields on either side is the owner's "số lượng nhỏ lại, tăng 2 kia". Collapses to one column at
   the same 720px breakpoint .ad-form-row--triple already uses (below), where a fixed middle track
   would otherwise starve the other two on a narrow screen. */
.fin-as-perfee-triple {
    grid-template-columns: minmax(0, 1fr) 88px minmax(0, 1fr);
}

/* [exec/1978] T3/F3 — the discount field's unit control sits beside its label as a sibling, not
   nested inside the <label> element (the structural half of the fix; T8's render guard watches
   this). A plain flex row keeps the label and the small unit <select> on one line without touching
   .ad-label or .ad-field. */
.fin-as-unitrow {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
}
.fin-as-unitrow > .ad-label { margin-bottom: 0; }

/* [exec/2009] T8 — page-scoped label-ROW height match for fin-as-perfee-triple. Measured: the plain
   Số lượng <label class="ad-label"> is 19.5px tall; Giảm trực tiếp (đ)'s label wrapped in
   .fin-as-unitrow (the flex row hosting the % ⇄ đ unit <select>) measures 23px, so inputs on the row
   started at different y. Floors BOTH label rows to the taller measurement, on the LABEL ROW only —
   not the inputs, and not by nudging with margin. Deliberately not .ad-label--row: that class is a
   two-line wrap floor for a DIFFERENT problem (see its own comment) and is not a height-matcher for
   this one. .ad-label itself is untouched (shared repo-wide); this only reaches inside the
   page-scoped .fin-as-perfee-triple row. flex + align-items keeps the unit <select> on the label's
   line and usable. */
.fin-as-perfee-triple .ad-label,
.fin-as-perfee-triple .fin-as-unitrow {
    min-height: 23px;
    display: flex;
    align-items: center;
}

/* [exec/2009] T7 — a caption word above the fee name inside the per-fee money block, so an operator
   can tell it apart from the identical name already shown one line up on the multi-select chip.
   Page-scoped: .ad-label itself is untouched. */
.fin-as-perfee-heading { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.fin-as-perfee-heading__caption {
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-faint);
}

/* [exec/2055] T5 — the per-fee effective-date override row, its own row under the money/quantity/
   discount triple rather than squeezed into it (that grid was measured for exactly three fields —
   see fin-as-perfee-triple above). Plain .ad-form-row's default two-column split already fits a
   from/to date pair; this only tightens the gap above it so the pair reads as belonging to the
   fee block it sits under rather than as a stray row. */
.fin-as-perfee-dates {
    margin-top: 4px;
}

/* [exec/2198] T4 — the per-fee note override row, same "its own row under the triple, not squeezed
   into it" reasoning as .fin-as-perfee-dates immediately above. */
.fin-as-perfee-note {
    margin-top: 4px;
}

/* [exec/2009] T4 — the collapsed-by-default billed-days disclosure trigger/panel. A small text
   trigger, not a button styled like a primary action — opening it is optional and rare. */
.fin-as-billeddays-trigger {
    background: none;
    border: none;
    padding: 0;
    font-size: 12px;
    font-weight: 600;
    color: var(--accent, #2563eb);
    cursor: pointer;
    text-decoration: underline;
}
.fin-as-billeddays-panel { margin-top: 8px; }
.fin-as-billeddays-warning { color: var(--warning, #b45309); font-size: 12px; margin-top: 4px; }
/* [exec/2195] T1 — forwarded onto UiSelect's trigger <button> itself via the new TriggerClass
   parameter (UiSelect.razor), not onto the outer wrapping <div> the old Class parameter reached.
   That wrapper was never the visible control: UiSelect.razor.css is CSS-isolated and unconditionally
   sets the trigger button's own width:100%/height:2.25rem, so tuning this rule while it targeted the
   wrapper could never change what actually renders — that was the bug this replaces.

   The unit control sits beside its OWN <label> inside .fin-as-unitrow (the "label row"), not beside
   the amount/percent <input> below it (that input is .ad-input, 42px tall, in its own row — already
   consistent between the đ and % states and not the source of the misalignment). The visible defect
   is the label row itself: the sibling column's plain <label class="ad-label"> is one text line
   (~19.5px, floored to 23px by the :3982 equalizer below), while the isolated .ui-select button is a
   fixed 2.25rem/36px regardless — taller than its own label row's floor, so the discount column's
   input starts lower than the quantity column's input beside it. Flooring the button down to the
   SAME 23px the equalizer already uses (rather than up to the input's 42px) is what actually lines
   the three columns' inputs back up.

   !important: UiSelect.razor.css's own `.ui-select` rule (:24-48) carries the SAME specificity here
   (one class each — the scope attribute Blazor appends to isolated selectors counts the same as this
   class), so load order between this global stylesheet and the component's isolated CSS bundle is
   not a contract this file can rely on; the override has to be unconditional. */
.fin-as-unit-select {
    flex: 0 0 auto !important;
    width: auto !important;
    height: 23px !important;
    min-width: 56px;
    padding: 0 6px !important;
    font-size: 12px;
    font-weight: 600;
    border: 1px solid var(--border) !important;
    border-radius: var(--radius);
    background: var(--panel);
    color: var(--text);
    cursor: pointer;
}
.fin-as-unit-select:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* [exec/1877] The slip photo inline on the accountant's payment-slip review queue. */
.ad-payment-slip__photo {
    max-width: 240px;
    max-height: 240px;
    border-radius: var(--radius);
    border: 1px solid var(--border);
    object-fit: contain;
    display: block;
    margin: 0.5rem 0;
}

/* [exec/2072] T1: the thumbnail's trigger -- a real button (keyboard-reachable, accessible name),
   not a bare @onclick on the <img>. Reset to look identical to the plain <img> it replaces; only
   the focus ring below is new chrome. */
.ad-payment-slip__photo-trigger {
    display: inline-block;
    padding: 0;
    border: none;
    background: none;
    cursor: pointer;
    border-radius: var(--radius);
}
.ad-payment-slip__photo-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* [exec/2072] T2: while the approve form is open on a row, the receipt and the reconciliation
   fields must both be visible on a normal laptop width -- photo left, fields right. A collapsed
   row never renders this container (FinancePaymentSlips.razor), so the queue's usual layout is
   untouched. Below the breakpoint this collapses to one column; the photo pane is written first in
   the markup so it lands first without any DOM reordering. */
.fin-payment-slip__split {
    display: grid;
    /* [exec/2427] ~55/45: the proof photo is the wider column; the approve modal is now --xl (980px). */
    grid-template-columns: minmax(0, 11fr) minmax(0, 9fr);
    gap: 16px;
    align-items: start;
}
.fin-payment-slip__split-photo,
.fin-payment-slip__split-fields {
    min-width: 0;
}
/* The 240px cap at :4019 above is right for a queue thumbnail but not for the pane an accountant is
   actively transcribing a bank reference out of -- give it real size here; UiPhotoViewer (T1) still
   covers anything still unreadable at this size. */
.fin-payment-slip__split-photo .ad-payment-slip__photo {
    max-width: 100%;
    max-height: 560px;
}
/* [exec/2427] Labels in the approve form stay on one line, and the amount/date row never clips: the
   grid keeps two tracks only while each can hold 180px (auto-fit), otherwise the date drops below the
   amount instead of being squeezed. min-width:0 lets a track shrink to its track, never past it. */
.fin-payment-slip__split-fields .ad-form-row {
    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.fin-payment-slip__split-fields .ad-field {
    min-width: 0;
}
.fin-payment-slip__split-fields .ui-field__label {
    white-space: nowrap;
}
.fin-slip__views { margin-bottom: 12px; }
@media (max-width: 768px) {
    .fin-payment-slip__split { grid-template-columns: 1fr; }
}

/* [exec/2371] The compact review table: a 56px proof thumbnail that opens the viewer, the outstanding
   total highlighted when it differs from the claimed amount, and aligned action buttons. */
.ad-payment-slip__photo.fin-slip__thumb {
    width: 56px;
    height: 56px;
    max-width: 56px;
    max-height: 56px;
    margin: 0;
    object-fit: cover;
}
.fin-slip__debt--differs { color: var(--warning); font-weight: 600; }
.fin-slip__actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }

/* [exec/1915] T2b: front-camera live preview is mirrored (conventional selfie-view expectation) --
   applied ONLY to the <video> preview element, on whichever camera surface currently reports
   facing 'user'. The frame finosCamera.capture() reads off the <video> element is NOT mirrored (the
   canvas draws the raw video frame, untouched by this CSS) -- a mirrored stored photo would
   corrupt face embeddings and confuse a parent looking at their own child's photo. Global (not
   scoped) because every live-camera surface (class-day check-in/probe, walk-up, face enrollment,
   observation) shares this one rule. */
.finos-camera-modal video.finos-camera-mirrored {
    transform: scaleX(-1);
}


/* ══ [exec/2212] T3 — the staff/child attendance CELL vocabulary, shared ══════════════════════
   Moved here verbatim from AttendanceRegister.razor.css, which is a Blazor-isolated stylesheet and
   therefore could never be shared: /profile's month strip carried a second, drifted copy of
   these rules. This file is global (non-scoped), so both AttendanceRegister.razor and
   Profile.razor now inherit ONE definition. The class names are unchanged, so no markup moved.
   Grid layout, column widths and the closed-day column wash stay in AttendanceRegister.razor.css —
   they are that page's table, not a shared chip.

   One substitution during the move: the three filled chips' text colour was `var(--panel)`, an
   .admin-shell alias. In a scoped .razor.css that resolved because the register always renders under
   MainLayout, but portal.css is global and CssPortalShellScopeGuardTests refuses a read of an
   admin-only alias from a section that does not assign it. `--panel` IS `var(--surface-1)`
   (portal.css:.admin-shell) and --surface-1 is a genuine :root/[data-theme] primitive in tokens.css,
   so this is the same colour in both themes, named at the level the rule actually needs. */
/* ── Cells ──
   The status accent uses the same tokens AttendanceClassDay.razor.css uses for
   .att-card--present/absent/leave/unmarked (success/danger/info/border-strong; [exec/2300] moved leave
   from warning to info on both screens), so the two screens cannot drift into two colour languages for
   one enum. No literal colour appears here; all come from theme tokens. */

.att-reg-cell {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    border-radius: 4px;
    font-weight: 600;
    font-size: 0.75rem;
    line-height: 1;
    border: 1px solid transparent;
}

/* [exec/2091] Staff cells hold an hours string ("8:46", "10:30") rather than the child grid's
   single status glyph, so the fixed 22px square above clips the text. This modifier keeps the
   child chip's shape (height, radius, weight, line-height) but swaps the fixed width for a
   content-driven width with a floor, so every hours value — and the "—" placeholder — sits fully
   inside the coloured background instead of being cut off. */
.att-reg-cell--hours {
    width: auto;
    min-width: 22px;
    padding: 0 4px;
}

.att-reg-cell--present {
    background: var(--success);
    color: var(--surface-1);
}

.att-reg-cell--absent {
    background: var(--danger);
    color: var(--surface-1);
}

.att-reg-cell--leave {
    /* [exec/2300] Info family, never warning: leave is an approved, planned absence needing no action,
       so it must not look like the actionable --missedout alarm below. */
    background: var(--info-soft);
    color: var(--info);
    border-color: var(--info);
}

/* [exec/2106] T4 — a staff shift clocked in but not yet closed, distinct from BOTH "closed day"
   (a fill with the glyph text "H:MM") and "unmarked" (an outline with no glyph). Colour alone must
   not be the only signal (accessibility), so the glyph text itself also differs — see the razor
   markup's Att_RegGlyphOpenShift string — and the border style differs from every other cell too. */
.att-reg-cell--open {
    background: transparent;
    color: var(--warning);
    border-color: var(--warning);
    border-style: dashed;
    font-weight: 600;
}

/* [exec/2267] T3 — "Quên ra ca": a shift whose clock-out is missing and can no longer be recorded by
   a tap. A FILLED warn chip, deliberately louder than .att-reg-cell--open's dashed outline, because
   open means "in progress, nothing to do" while this means "payroll is wrong until somebody fixes it".
   It is rendered as a <button>, so the reset below strips the browser's button chrome and restores the
   pointer; focus-visible keeps a keyboard ring, since this is the one actionable cell on the grid.
   Colour is never the only signal — the chip's text differs too (Att_RegGlyphMissedOut), matching the
   rule .att-reg-cell--open already follows. */
.att-reg-cell--missedout {
    background: var(--warning);
    color: var(--surface-1);
    border-color: var(--warning);
    font: inherit;
    font-weight: 600;
    font-size: 0.75rem;
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
}

.att-reg-cell--missedout:hover {
    filter: brightness(1.08);
}

/* --focus-ring is a box-shadow value (tokens.css), not a colour — the whole shell's focus treatment. */
.att-reg-cell--missedout:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
}

/* [exec/2285] Every filled staff cell is a button that opens the day popover. The base chip
   (.att-reg-cell--present / --open / --unmarked) already carries the colour and border; this modifier
   only strips the browser's button chrome, restores the pointer, and gives a keyboard user the same
   focus ring the missed-out chip has. */
.att-reg-cell--action {
    font: inherit;
    font-weight: 600;
    font-size: 0.75rem;
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
}

.att-reg-cell--action:hover {
    filter: brightness(1.08);
}

.att-reg-cell--action:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
}

/* [exec/2285] The staff day popover: three or four facts in a row, then the event log. */
.att-day {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
    gap: 0.75rem;
    margin: 0 0 1rem;
}

.att-day__item {
    min-width: 0;
}

.att-day__item dt {
    font-size: 0.75rem;
    color: var(--text-secondary);
}

.att-day__item dd {
    margin: 0;
    font-weight: 600;
}

.att-day__heading {
    margin: 0 0 0.5rem;
    font-size: 0.875rem;
}

.att-day__events {
    list-style: none;
    margin: 0 0 1rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.att-day__event {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.25rem 0.75rem;
    padding: 0.5rem 0.75rem;
    border: 1px solid var(--border-strong);
    border-radius: 6px;
}

/* A superseded event is history, not a current fact: struck through and quieter, but still listed. */
.att-day__event--superseded .att-day__event-kind,
.att-day__event--superseded .att-day__event-time {
    text-decoration: line-through;
    color: var(--text-secondary);
}

.att-day__event-kind {
    font-weight: 600;
}

.att-day__event-time {
    font-variant-numeric: tabular-nums;
}

.att-day__event-flag {
    font-size: 0.75rem;
    color: var(--warning);
}

.att-day__event-meta {
    flex-basis: 100%;
    min-width: 0;
    font-size: 0.75rem;
    color: var(--text-secondary);
    overflow-wrap: anywhere;
}

.att-day__form {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}

/* [exec/2268] T3 — "Có mặt (có đơn nghỉ)": the child came in on a day an approved leave covered.
   Present's fill, because that is what the day IS, plus an info-coloured border ([exec/2300], the one "on leave" colour) so the exception is
   visible while scanning a month. Colour is never the only signal: the glyph text differs too
   (Att_RegGlyphPresentOnLeave), the rule .att-reg-cell--open and --missedout already follow. */
.att-reg-cell--presentleave {
    background: var(--success);
    color: var(--surface-1);
    border-color: var(--info);
    border-width: 2px;
    font-size: 0.6875rem;
}

/* [exec/2268] T2 — "Chưa điểm danh ra": a past day with an arrival and no departure. Present's fill
   again (the child WAS here) with a dashed warn border, matching .att-reg-cell--open's "incomplete,
   not wrong" vocabulary on the staff grid. Its glyph text differs as well. Nothing here auto-fills the
   missing time — the cell says the day is incomplete and stops. */
.att-reg-cell--presentnoout {
    background: var(--success);
    color: var(--surface-1);
    border-color: var(--warning);
    border-style: dashed;
    border-width: 2px;
    font-size: 0.6875rem;
}

/* [exec/2605] T1 — "Ở lớp khác": the child was recorded in another class that day. Neutral by design:
   a muted hatch, not a fill, so it is neither Present nor absent and reads as "not here, but not missing".
   The glyph (Att_RegGlyphOtherClass) carries the meaning too, so the hatch is never the only signal. */
.att-reg-cell--otherclass {
    background: repeating-linear-gradient(45deg, var(--surface-2) 0 4px, var(--surface-1) 4px 8px);
    color: var(--text-muted);
    border: 1px dashed var(--text-muted);
    font-size: 0.6875rem;
}

/* Unmarked is missing data, not absence (ADR-59 D3) — so it is deliberately the quietest cell on
   the grid, an outline rather than a fill. It must never read as "absent" at a glance. */
.att-reg-cell--unmarked {
    background: transparent;
    color: var(--text-secondary);
    border-color: var(--border-strong);
}

/* [exec/1889] T3/T4, [exec/2444] T3 — Closed is a fact about the date and campus, not about a child, so it
   is a quiet grey block (same --muted wash as the day column) rather than a status colour. It must be
   visible, otherwise the legend swatch renders as an empty box. */
.att-reg-cell--closed {
    background: hsl(var(--muted));
    color: var(--text-secondary);
    border-color: var(--border);
}

/* [exec/2453] Shared pill select: the topbar campus switcher look, applied to a UiSelect via Class="ui-select-pill".
   Scoped under .admin-shell (where --surface-2 etc. resolve) and outranks UiSelect's scoped .ui-select rule. */
.admin-shell .ui-field.ui-select-pill .ui-select {
    height: 34px;
    padding: 0 28px 0 10px;
    border-radius: 999px;
    background-color: var(--surface-2);
    border-color: var(--border-subtle);
    color: var(--text);
    font-size: 13px;
    font-weight: 600;
}
.admin-shell .ui-field.ui-select-pill .ui-select:hover:not(:disabled),
.admin-shell .ui-field.ui-select-pill .ui-select[aria-expanded="true"] {
    border-color: var(--accent);
    background-color: var(--accent-soft);
    color: var(--accent);
}

/* [exec/2666] Workflow group identity. A step, card or list row carries ONE of these classes (WorkflowVisuals.GroupClass) and reads
   --wf-group / --wf-group-soft for its bar, icon tile or tint, so the same group looks the same on the simple view, the marketplace and
   the runs step list. The tokens themselves live in tokens.css. */
.wf-grp--when     { --wf-group: var(--wf-when);     --wf-group-soft: var(--wf-when-soft); }
.wf-grp--get      { --wf-group: var(--wf-get);      --wf-group-soft: var(--wf-get-soft); }
.wf-grp--control  { --wf-group: var(--wf-control);  --wf-group-soft: var(--wf-control-soft); }
.wf-grp--approval { --wf-group: var(--wf-approval); --wf-group-soft: var(--wf-approval-soft); }
.wf-grp--action   { --wf-group: var(--wf-action);   --wf-group-soft: var(--wf-action-soft); }

/* A phone list head keeps the search bar on one row, as the reference does. Only a bar whose trailing slot holds more than
   the refresh button (a date range, an export) lets that group wrap under the search. */
@media (max-width: 640px) {
    .ad-card__head--list > .ui-fs--grow .ui-fs__bar:has(.ui-fs__trailing > :nth-child(2)) { flex-wrap: wrap; height: auto; row-gap: 8px; }
    .ad-card__head--list > .ui-fs--grow .ui-fs__bar:has(.ui-fs__trailing > :nth-child(2)) > .ui-fs__filter,
    .ad-card__head--list > .ui-fs--grow .ui-fs__bar:has(.ui-fs__trailing > :nth-child(2)) > .ui-fs__box,
    .ad-card__head--list > .ui-fs--grow .ui-fs__bar:has(.ui-fs__trailing > :nth-child(2)) > .ui-fs__go { height: 36px; }
}
