/* ==========================================================================
   General Shared Styles
   ========================================================================== */

/*-------- Google Fonts Import -------- */
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200;300;400;500;600;700;800&display=swap');

/* ---- Control design tokens (2026-07-22 button/control system) ----
   Single source of truth for interactive-control dimensions, typography, focus
   and semantic button colors. Sizes drive .btn / .btn-card-action + the shared
   controls; semantic colors were tuned so white / dark label text meets WCAG AA
   (>=4.5:1) in both normal and hover states (see the CSS review, 2026-07-22).
   The brand owner may re-approve exact hues — change them HERE, not per rule. */
:root {
    --control-h-sm: 30px;
    --control-h-md: 34px;
    --control-h-touch: 44px;
    --control-px-sm: 11px;
    --control-px-md: 14px;
    --control-font-sm: 12px;
    --control-font-md: 13px;
    --control-radius: 5px;
    --control-gap: 6px;
    --control-border: #cbd3dc;
    --control-focus: #2563eb;
    --control-focus-dark: #7dd3fc;
    --control-disabled-opacity: .48;
    --control-transition: 150ms ease;

    --btn-primary-bg: #0f3460;
    --btn-primary-bg-hover: #1a4a7a;
    --btn-danger-bg: #c0392b;
    --btn-danger-bg-hover: #a93226;
    --btn-info-bg: #2471a3;        /* was #2980b9 (4.30:1 fail) -> 5.30:1 pass */
    --btn-info-bg-hover: #1f608b;
    --btn-success-bg: #18703e;     /* was #27ae60 (2.87:1 fail) -> 6.13:1 pass */
    --btn-success-bg-hover: #135c33;
    --btn-warning-bg: #f39c12;     /* brand orange kept; dark text carries AA */
    --btn-warning-bg-hover: #e08e0b;
    --btn-warning-fg: #2b1c00;     /* 7.55:1 on the warning fill */
    /* Secondary is BRANDED, not neutral (operator, 2026-08-18: the grey fill
       "looks too basic against the rest of the application") — a white button
       with a navy hairline and navy ink. The hue is a lift of the brand navy
       (#0f3460), light enough to read as the quieter half of a primary/secondary
       pair rather than as a second primary. MEASURED: ink 8.45:1 on the white
       fill, 7.53:1 on --page-bg, 7.57:1 on the hover fill; hover ink 10.26:1 on
       it. The border is 8.45:1, far above the 3:1 WCAG 1.4.11 floor for a
       non-text boundary, and hover moves all THREE channels (fill, border, ink)
       so it is not carried by the 1.12:1 fill change alone. */
    --btn-secondary-fg: #234f7d;
    --btn-secondary-fg-hover: #183b60;
    --btn-secondary-bg-hover: #eef3f8;

    /* ---- QA-ONLY marker (`.qa-only`, 2026-08-18) ----
       The one orange a developer-only control is allowed to be. See the
       `.qa-only` block below the button variants for the rule and the standard.
       --qa-accent is the BRAND orange (--accent, repeated here rather than
       aliased so the token reads as a decision and not as a coincidence): 3.96:1
       on white, above the 3:1 WCAG 1.4.11 floor for a non-text boundary, which is
       all the outline is. It is NOT text-safe, so the ink / pill fill is the
       darkened --qa-accent-ink at 5.76:1 on white (and white ON the pill is the
       same 5.76:1). MEASURE before changing either — an orange that fails is
       worse than a grey that passes, because this marker's whole job is to be
       noticed. */
    --qa-accent: #e8451c;
    --qa-accent-ink: #ba3716;
    --qa-accent-wash: #fff4f0;

    /* ---- Brand palette tokens (2026-07-23 CSS consolidation) ----
       Single source for the app-wide colors. The values are the EXACT hexes the
       app already used everywhere (zero visual change) — new rules must use the
       tokens, not raw hexes. Feature files were migrated to the tokens the same
       day (54 files, equivalence-proven); the literals that remain there are
       deliberate: per-feature accent colors (hero/button accents), the designed
       pill families, and #eef1f5/#f0f2f5/#f7f9fb outside their token's property
       context (borders vs backgrounds). */
    --navy: #0f3460;            /* brand navy — headings, primary, active */
    --navy-hover: #1a4a7a;
    --accent: #e8451c;          /* brand orange — accents, active underline */

    /* ---- Sub-nav band (the family section bar) — 2026-08-17 LIGHT redesign ----
       Operator brief: the dark-blue band "feels visually dated and creates
       unnecessary visual weight". It was a second SLAB of chrome competing with
       the topbar for the eye, and every tab paid for it — the label had to fight
       a saturated field, so it ran small (12.5px) and dim (72% white) and the
       active state needed a fill AND an underline to be legible.

       The band is now a clean white surface: the near-black topbar keeps its 3px
       navy rule as the hard edge that SEPARATES the two rows (it no longer gets
       repainted to blend them into one block), and below it the tabs sit on
       white as plain text. That buys three things the dark slab could not:
         · hierarchy — one dark chrome row, not two, so the topbar reads as the
           app frame and the section bar reads as part of the page beneath it;
         · legibility — the idle label goes from 3.96:1 (72% white on --navy) to
           8.56:1, which is what lets it carry the tab at 13.5px/500 instead of
           needing a fill behind it to be seen. MEASURED, both of them;
         · one language — a light surface with an --accent underline is exactly
           what .app-tab.is-active already does for in-page tabs, so the portal
           now marks "you are here" the SAME way at both levels.
       That last point is why the marker is --accent and not the mockup's blue:
       the 2026-08-05 recolour moved the marker OFF --accent only because it was
       then sitting on navy, where warm-on-cool at 3.15:1 shimmered. On white
       there is no clash to avoid, and the note that survived that change said as
       much — "--accent is still the active underline on LIGHT surfaces, where
       warm-on-white has no clash to cause". The band is now a light surface, so
       it takes the light-surface marker.
       Marker contrast is 3.96:1 on white. That clears the 3:1 WCAG 1.4.11 floor
       for a non-text indicator, it is the value .app-tab.is-active has always
       shipped, and it is not carrying the state alone in any case — the active
       tab also changes weight (500 -> 700) and colour (--subnav-tab -> --navy),
       so "you are here" survives colour-blindness and greyscale. */
    --subnav-surface: #ffffff;  /* the band itself */
    --subnav-edge: #dde3ea;     /* its bottom hairline */
    --subnav-tab: #3f4d63;      /* idle tab label (8.56:1 on white) */
    --subnav-tab-icon: #7a8899; /* idle tab icon — a step back from the label,
                                   but held at 3.61:1 so it clears 3:1 too */
    --subnav-hover: #f4f7fb;    /* hover / open-door wash */
    --subnav-marker: var(--accent); /* active-tab underline (= .app-tab.is-active) */
    --hairline: #e3e7ec;        /* standard card/table border */
    --hairline-soft: #eef1f5;   /* lighter inner divider */
    --text: #1a2332;            /* primary text */
    --text-soft: #33415c;
    --text-muted: #667085;      /* secondary text */
    --page-bg: #f0f2f5;
    --head-bg: #f7f9fb;         /* flat panel-head / table-head fill */

    /* Status-pill palettes — the six standard combos for the BORDERED pill
       family (drifted near-miss shades were unified onto these 2026-07-23,
       operator-approved). A second, BORDERLESS soft badge family (audit_fs /
       onboarding / questions / implementation_tasks) keeps its own literals —
       keep each family internally consistent. */
    --pill-green-bg: #e7f5ec;  --pill-green-fg: #1e7e34;  --pill-green-bd: #bfe2cb;
    --pill-amber-bg: #fff1e6;  --pill-amber-fg: #c04a12;  --pill-amber-bd: #f6cfae;
    --pill-blue-bg: #eaf1fb;   --pill-blue-fg: #24558c;   --pill-blue-bd: #c6d9f1;
    --pill-red-bg: #fcebea;    --pill-red-fg: #b3261e;    --pill-red-bd: #f2c4c0;
    --pill-gray-bg: #eef1f5;   --pill-gray-fg: #52627a;   --pill-gray-bd: #d8dee7;
    --pill-yellow-bg: #fdf6e3; --pill-yellow-fg: #8a6d1a; --pill-yellow-bd: #ecd9a6;

    /* Borderless soft badge family — the audit_fs / onboarding / questions /
       implementation_tasks status language (tokenized 2026-07-23, values
       unchanged). A SECOND palette by design — distinct from the bordered
       --pill-* set above; keep each family internally consistent. */
    --badge-green-bg: #eaf6ec;  --badge-green-fg: #1e7e34;
    --badge-amber-bg: #fdf6ec;  --badge-amber-fg: #a07414;
    --badge-red-bg: #fdecec;    --badge-red-fg: #b03a2e;
    --badge-gray-bg: #eef1f5;   --badge-gray-fg: #5a6b7f;
    /* BLUE joined the borderless family 2026-08-25 (app_audit_fs.php): a status
       ladder with a waiting-on-us step — submitted, in review, in progress — has
       no colour for it in green/amber/red/gray, and reaching for the BORDERED
       --pill-blue-* would put a ring around one badge in a column of four that
       have none. Same lightness as its siblings. */
    --badge-blue-bg: #eaf1fa;   --badge-blue-fg: #2c5f92;

    /* Validation palette (2026-07-29) — one source for every invalid-field cue:
       the control border, its whisper of fill, the focus halo and the message
       text. The border/fill values are the reds the validation rules already
       used (#dc2626 / #fef2f2, so controls are visually unchanged); the message
       text is --pill-red-fg, which is what the app's red text is everywhere
       else. See the Validation section for how the four are applied. */
    --field-error: #dc2626;                      /* invalid border + accents */
    --field-error-tint: #fef2f2;                 /* invalid control fill */
    --field-error-text: #b3261e;                 /* message text (= --pill-red-fg) */
    --field-error-ring: rgba(220, 38, 38, .13);  /* focus halo while invalid */
}

/* Normalize native form-control typography so <button>/<input>/<select>/<textarea>
   inherit Manrope instead of a UA system font (anchors already inherit it). This
   is font/appearance normalization ONLY — visual button styling stays opt-in via
   the component classes below. */
button, input, select, textarea { font: inherit; }

/* ---- Reset & Base ---- */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
    font-family: 'Manrope', sans-serif;
    /* font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; */
    font-size: 14px;
    line-height: 1.5;
    color: #1a1a2e;
    background: var(--page-bg);
    /* Full-width shell: the topbar/footer/QA banner are flex children of <body>,
       so the body must span the viewport at any zoom level. Content width is
       capped/centered by inner wrappers (.page-wrapper/.page-wrap = 1750px,
       .app-footer-inner = 1140px), not by the body. */
    width: 100%;
    /* Desktop minimum width (operator standard 2026-07-15): the layout never
       compresses below 1200px. Below that the WHOLE page (topbar + content +
       footer, since they all stretch to the body's width) scrolls horizontally
       instead of cramming controls into a narrow column and wrapping dates/IDs/
       labels — horizontal scroll reads as more polished than compression. The
       floor is released to fully fluid at the 700px mobile breakpoint (see the
       responsive block below), where the layout switches to stacked/card mode.
       Login and embedded (iframe/modal) pages opt out — see below. */
    min-width: 1200px;
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}
/* Fully-fluid pages (no 1200px desktop floor): the standalone login screen and
   any embedded page (e.g. email-preferences shown inside a modal/iframe) render
   at their container's width, never forcing horizontal scroll. */
body.login-body,
body.app-embed-body,
body.ep-embedded { min-width: 0; }
/* `app-embed-body` is the GENERIC one, added 2026-09-11 with the Investors
   Authenticator step-up — the third framed panel of the shape `.app-embed-wrap`
   already serves — rather than a fifth per-family copy of a one-line rule that
   is not about any family. `.wt-embed-body`, `.dr-embed-body` and
   `body.mp-authn-embed` predate it and still declare their own; each of those
   also carries family rules keyed on that class, so converging them is a
   separate change and not a rename. The white ground is part of the contract:
   the page behind an embed is a dialog, not the grey portal shell. */
body.app-embed-body { background: #fff; }
/* The topbar is position:fixed (out of flow), so reserve its 56px height on any
   page that actually renders one. `:has(nav.topbar)` auto-targets exactly the
   pages with a topbar — the shared include AND the few bespoke inline navs
   (pre-login my_profile) — and skips chrome-less pages (login, PDF renders,
   access-denied, step-up, embedded) so none gets a blank 56px strip. Global
   box-sizing:border-box keeps min-height:100vh correct alongside this padding. */
body:has(nav.topbar) { padding-top: 56px; }

/* ---- Topbar ---- */
.topbar {
    background: #1d1d1b;
    height: 56px;
    display: flex;
    align-items: center;
    padding: 0 24px;
    border-bottom: 3px solid var(--navy);
    /* FIXED (was sticky): pinned to the viewport on BOTH axes. A sticky topbar
       stays put vertically but PANS horizontally with the page — on a wide grid
       (page scrolls right) it slid away and left a bare strip. Fixed keeps the
       bar + its controls locked to the viewport. left/right:0 size it to the
       viewport content box (excludes the scrollbar — no 100vw overflow). Pages
       that render a topbar reserve its height with `body:has(nav.topbar)` below. */
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 100;
}
.topbar-back {
    display: flex;
    align-items: center;
    gap: 4px;
    font-size: 13px;
    color: #999;
    text-decoration: none;
    margin-right: 18px;
    padding-right: 18px;
    border-right: 1px solid rgba(255,255,255,.12);
}
.topbar-back:hover { color: #fff; }
.topbar-back svg { opacity: .7; }
.topbar-brand {
    font-size: 15px;
    font-weight: 600;
    color: #e0e0e0;
    letter-spacing: .3px;
}
/* Brand as the main-menu link (legacy logo -> welcome/menu behavior). */
a.topbar-brand-link { text-decoration: none; transition: color .15s ease; }
a.topbar-brand-link:hover { color: #fff; }
/* ---- The brand carrying a HOME glyph ----
   OPUS VIEW ONLY today (2026-09-03, operator: "instead of 'Client Portal' …
   I want it to say 'Opus View' and have a home icon so it's more obvious it can
   be clicked"). Plain text at the far left of a dark bar does not read as a
   control; the glyph is the cheapest fix that does not turn the brand into a
   button competing with the workspace switch on the right.

   ⚠ ONE CLASS, deliberately not `a.topbar-brand--home`. The <=700px block far
   below hides `.topbar-brand` outright, and a media query adds NO specificity —
   an element+class selector here would out-specify that rule and put the brand
   back on a phone. At equal specificity source order decides, and the media
   block is later. */
.topbar-brand--home {
    display: inline-flex;
    align-items: center;
    gap: 7px;
}
.topbar-brand--home svg { opacity: .8; flex-shrink: 0; transition: opacity .15s ease; }
.topbar-brand--home:hover svg { opacity: 1; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }

/* ---- User Dropdown ---- */
.user-dropdown { position: relative; display: inline-block; }
.user-btn {
    background: none;
    border: 1px solid rgba(255,255,255,.15);
    border-radius: 4px;
    padding: 7px 14px;
    font-size: 13px;
    color: #ccc;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 8px;
}
.user-btn:hover { background: rgba(255,255,255,.08); color: #fff; }
.user-btn svg { opacity: .7; flex-shrink: 0; }
.user-name {
    text-transform: uppercase;
    color: #fff;
    /* DELIBERATE exception to the never-wrap/just-widen label standard below
       (~line 875): every other control may widen itself and, if needed, the
       page — the topbar cannot. It is fixed to the viewport, so a login long
       enough to outgrow it has nowhere to go but off the right edge (operator
       report 2026-08-17; `UserName` is an email for most accounts, and
       uppercase makes it wider still). Truncate instead: the full value stays
       in the button's title + aria-label and in the dropdown head. */
    max-width: 220px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.caret { font-size: 9px; color: rgba(255,255,255,.4); }

/* Styled to echo the left main-menu drawer: rounded icon chips, item titles,
   chevrons and the same hover treatment (see .menu-drawer-item). */
.dropdown-menu {
    display: none;
    position: absolute;
    right: 0;
    top: calc(100% + 6px);
    background: #fff;
    border: 1px solid var(--hairline);
    border-radius: 12px;
    list-style: none;
    min-width: 252px;
    /* …but never wider than the viewport. min-width alone is a FLOOR: the head's
       "Signed in as" lines are nowrap, so a long login grew the whole menu past
       252px (measured 325px at 45 chars) and, anchored right:0, it grew LEFT off
       the screen on a phone. The cap makes the head's own ellipsis engage —
       which matters more now that the head is the mobile way to read the name. */
    max-width: min(320px, calc(100vw - 24px));
    padding: 7px;
    margin: 0;
    z-index: 1000;
    box-shadow: 0 14px 34px rgba(15,28,54,.18);
}
.dropdown-menu.open { display: block; }
.dropdown-menu li { list-style: none; }
.dropdown-head {
    display: flex;
    flex-direction: column;
    gap: 2px;
    padding: 7px 10px 10px;
    margin-bottom: 5px;
    border-bottom: 1px solid #e8ebef;
}
.dropdown-head-eyebrow {
    font-size: 10px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .8px;
    color: #8a909a;
}
.dropdown-head-name {
    font-size: 13.5px;
    font-weight: 700;
    color: #1a1a2e;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* Secondary line under the full name: the username/display name, styled to read
   as quieter metadata (lighter weight + muted color) so it's clearly distinct
   from the bold full name above it. */
.dropdown-head-user {
    margin-top: 1px;
    font-size: 12px;
    font-weight: 500;
    color: #707682;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.dropdown-menu li a {
    display: flex;
    align-items: center;
    gap: 11px;
    padding: 8px 9px;
    border-radius: 9px;
    color: #1a1a2e;
    text-decoration: none;
    font-size: 13px;
}
.dropdown-menu li a:hover { background: #f2f6fb; }
.dropdown-ico {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    border-radius: 9px;
    background: #f0f4ff;
    color: var(--navy);
}
.dropdown-label { flex: 1 1 auto; min-width: 0; font-weight: 600; }
.dropdown-chev {
    flex-shrink: 0;
    display: inline-flex;
    color: #c2c8cf;
    transition: color .15s, transform .15s;
}
.dropdown-menu li a:hover .dropdown-chev { color: var(--navy); transform: translateX(2px); }
/* Takes the chevron's slot on an item that opens a NEW TAB (Diagnostics) — the
   chevron promises "forward, in this tab", which that item does not do. */
.dropdown-newtab {
    flex-shrink: 0;
    display: inline-flex;
    color: #c2c8cf;
    transition: color .15s;
}
.dropdown-menu li a:hover .dropdown-newtab { color: var(--navy); }
.dropdown-menu .divider { background: #e8ebef; margin: 5px 8px; }
.dropdown-menu li a.dropdown-item--danger:hover { background: #fdf2f2; }
.dropdown-menu li a.dropdown-item--danger:hover .dropdown-ico { background: #fde8e8; color: #c0392b; }
.dropdown-menu li a.dropdown-item--danger:hover .dropdown-chev { color: #c0392b; }
.divider { height: 1px; background: #eee; }

/* ---- Page Wrapper ---- */
.page-wrapper {
    width: 100%;
    max-width: 1750px;
    margin-left: auto;
    margin-right: auto;
    padding: 32px 20px 0 24px;
    flex: 1;
}
.page-wrap {
    width: 100%;
    max-width: 1750px;
    margin-left: auto;
    margin-right: auto;
    flex: 1;
    padding: 32px 20px 0 24px;
}
/* NARROW page tier (the README's two-width model): forms / detail / reading
   pages cap at 1100px. Goes on the SAME element as .page-wrap/.page-wrapper —
   `class="page-wrap <feat>-page page-narrow"`. Declared after .page-wrap so it
   wins the max-width at equal specificity (consolidates the 31 per-feature
   `.<feat>-page-narrow { max-width: 1100px }` copies, 2026-07-23). */
.page-narrow { max-width: 1100px; }
/* FULL tier — the page column fills the WINDOW, with no cap at all (operator,
   2026-09-04, on the Launch Pad: "make the page use the full width of the
   browser"). Goes on the SAME element as .page-wrap/.page-wrapper, like
   .page-narrow, and is declared after both so it wins the max-width at equal
   specificity.

   ⚠ IT IS HERE RATHER THAN IN A FEATURE STYLESHEET ON PURPOSE. The standing rule
   is that a screen never authors its own width (README §2: two tiers, and the
   per-feature caps were all deleted in 2026-07-15) — so the answer to "this one
   should be wider" is a shared TIER, not a `max-width` in one screen's CSS. The
   1920px exceptions two dashboards once held are exactly what this replaces: a
   number picked per screen is a number that drifts, and 1920 still letterboxes a
   wider monitor, which is the thing being asked about.

   WHO IT IS FOR: a board of self-sizing WIDGETS whose cells get better with room —
   charts, tiles, panels. NOT for a page of prose or forms (line length is why
   .page-narrow exists) and NOT needed for a wide GRID, which already un-caps
   itself through the `:has(.app-grid)` rule further down. It aligns the content
   column with the topbar and footer, which are full-bleed at any viewport, so
   above 1750px it removes a mismatch rather than creating one.

   Nothing else changes: the 1200px body floor, the 700px breakpoint and the
   page's own padding are untouched, so a narrow window behaves exactly as before. */
.page-full { max-width: none; }

/* ---- Page Header ---- */
.page-header { margin-bottom: 24px; }
.page-title {
    font-size: 22px;
    font-weight: 700;
    color: #1a1a2e;
    margin: 0 0 6px 0;
}
.page-info {
    font-size: 13px;
    color: #666;
    line-height: 1.6;
}

/* ---- Page Hero ----
   THE hero accent is APP-WIDE and is NOT a per-feature choice (operator
   standard, 2026-07-24): the navy `border-left` below AND the navy icon chip
   (`.page-hero-icon`) are both owned here, so **a feature never writes a hero
   rule at all** — no `.<feat>-hero`, no `.<feat>-hero .page-hero-icon`.

   History, so this does not drift back: the border was already shared, but the
   icon default used to be the brand ORANGE while 18 features + `app_standards.php`
   each carried their own byte-identical `background:#eef2f7; color:var(--navy)`
   override. An earlier version of this comment said "add feature accents to the
   icon" — that sentence is what licensed the drift, and it is gone. The 18
   redundant copies were deleted the same day and the default here was corrected
   to the navy every polished page was already opting into.

   A feature that genuinely needs its own identity colour (a dashboard, a
   deliberately-coded queue) is an operator decision, not a build-time choice —
   see the remaining `.<feat>-hero` colour rules listed by
   `tools/check_shared_css.php`. */
.page-hero {
    display: flex;
    align-items: flex-start;
    gap: 16px;
    padding: 20px 24px;
    background: #f7f8fa;
    border: 1px solid #e8e8e8;
    border-left: 4px solid var(--navy);
    border-radius: 10px;
    margin-bottom: 26px;
}
.page-hero-icon {
    flex-shrink: 0;
    width: 46px;
    height: 46px;
    border-radius: 10px;
    /* The navy chip is the standard (was #fdeee8 / --accent orange until
       2026-07-24, which 18 features + app_standards.php each overrode to exactly
       this pair). Do NOT re-declare it per feature. */
    background: #eef2f7;
    color: var(--navy);
    display: flex;
    align-items: center;
    justify-content: center;
}
.page-hero-text { flex: 1; min-width: 0; }
.page-hero-title {
    font-size: 22px;
    font-weight: 700;
    margin: 0 0 4px 0;
    color: #1a1a2e;
}
.page-hero-desc {
    font-size: 13px;
    line-height: 1.6;
    color: #666;

    /* A LONG HERO INTRO MUST NOT SIZE A GRID PAGE.
       Same hazard, same technique and the same reasoning as .callout-banner
       above: on a grid page .page-wrap is sized to width:max-content so a wide
       TABLE can page-scroll, and under that intrinsic sizing this paragraph
       lays out on ONE unwrapped line and contributes its whole length to the
       page width. inline-size containment makes it contribute ~0 and instead
       FILL the grid-driven column, so it wraps. No-op on normal
       (definite-width) pages.

       Added 2026-09-03 when app_opus_user_management.php measured a 1,870px
       hero against a 1,761px grid - the PROSE was sizing the page, not the
       table it exists to introduce. The Client Dashboard heroes sit just
       under the 1,750px cap and were one sentence from the same result, which
       is what makes this shared rather than a feature rule. */
    contain: inline-size;
}
.page-hero-desc p { margin: 0 0 6px 0; }
.page-hero-desc p:last-child { margin-bottom: 0; }
.page-hero-desc a { color: var(--accent); }
/* A hero description may carry a short bullet list (the questionnaire intro, the
   standards page's own hero). Promoted VERBATIM 2026-07-24 from the byte-identical
   .af-hero / .q-hero copies — the browser's 1em list margins are far too loose
   inside the hero band. */
.page-hero-desc ul { margin: 4px 0 8px; padding-left: 20px; }
.page-hero-desc li { margin: 2px 0; }
/* ---- .page-hero--dashboard: the one sanctioned hero VARIANT ----
   A raised, gradient hero with a navy-FILLED icon chip, for the two landing
   dashboards a user arrives on (Launch Pad, Investor Capital Summary) — it reads
   as a destination rather than a work queue. Promoted VERBATIM 2026-07-24 from
   the byte-identical .ib-hero / .lp-hero pair (a pattern needed by a SECOND
   feature moves here — README §2).
   This is a DESIGN variant, not an accent colour: the left rule stays the shared
   navy. It is opt-in on the hero root — `class="page-hero page-hero--dashboard"`
   — and it is the ONLY hero variant. Do not add a second one without the
   operator; and note the `border` shorthand below re-asserts the shared
   `border-left`, which the shorthand would otherwise clear. */
.page-hero--dashboard {
    background: linear-gradient(135deg, #ffffff 0%, #f4f7fb 100%);
    border: 1px solid var(--hairline);
    border-left: 4px solid var(--navy);
    box-shadow: 0 1px 3px rgba(15, 28, 54, .05);
}
.page-hero--dashboard .page-hero-icon {
    background: linear-gradient(135deg, var(--navy) 0%, var(--navy-hover) 100%);
    color: #fff;
    box-shadow: 0 4px 10px rgba(15, 52, 96, .28);
}

/* .page-hero-aside is for a STATUS BADGE / indicator ONLY (e.g. an "Editing" or
   "Live" pill). Page-level ACTION BUTTONS must NOT live in the hero — they belong
   in the left-aligned .page-actions row BELOW the hero (see next block). App-wide
   standard since 2026-07-16; do not reintroduce buttons into the hero aside. */
.page-hero-aside { flex-shrink: 0; align-self: center; }

@media (max-width: 700px) {
    .page-hero {
        flex-wrap: wrap;
        padding: 16px 18px;
        gap: 12px;
    }
    .page-hero-aside { flex: 1 1 100%; align-self: stretch; }
}

/* ---- Hub tiles (.app-hub-*) — THE shared "pick a destination" landing ----
   A page whose whole body is a small set of large CHOICE tiles, each one an
   anchor to another screen: the Data Rooms hub (app_data_rooms.php?page=landing)
   and the Cap Raising hub (app_connect_insights.php?page=cap_raising). The whole
   tile is the link, so nothing inside it is interactive; it sits directly under
   the shared `.page-hero` and needs no card wrapper.

   ⚠ NO PAGE RENDERS THIS BLOCK TODAY. Both hubs named above are retired (Data
   Rooms 2026-08-21, Cap Raising 2026-09-23 — see app/_archive/README.md). It is
   kept, not deleted, as the shared vocabulary for the next page that is a CHOICE
   between a few large products; a page that lists MANY destinations wants
   `.app-menucard` below instead.

   PROMOTED 2026-08-14 from `.dr-hub-*` in assets/data_rooms/data_rooms.css, the
   block the Data Rooms hub shipped with on 2026-07-25, when the Cap Raising hub
   was asked to "look like that page". Copying it into a second feature stylesheet
   is exactly the drift this file exists to prevent (see the row-action button
   history in README §2), so there is now ONE definition and no feature-local hub
   tile. `.dr-hub-*` no longer exists — do not reintroduce it.

   TWO COLUMNS, capped. `auto-fit` would put a third tile in the SAME row on the
   1750px wide tier (four 360px tracks fit), which is not what a hub of three
   reads as: the grid is explicitly 2-up so tiles 3+ start a new row at the same
   size as the first two, and the 700px breakpoint stacks them.

   Palette follows the app's tile convention (main_menu's per-tile CSS vars): set
   ONE variant class per tile and the bar / chip / wash all follow. */
.app-hub-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 18px;
    align-items: stretch;
    margin-top: 2px;
}

/* ---- Hub SECTION heading (shared, 2026-08-28) ----
   A caption over one run of hub tiles, for a hub whose tiles have started to
   form families. Added when the Opus workspace gained its second family (Client
   Invoicing) — until then it was one bare run of rows and a heading over it
   would have classified nothing.

   SHARED, NOT OPUS-LOCAL, on purpose: views/opus/opus_hub_view.php's own comment
   asked for exactly that, because the client hubs (Data Rooms, Cap Raising) will
   want the same caption the moment either grows a second family, and a
   `.opus-hub-section` would have been copied rather than reused — the drift the
   `.dr-hub-*` promotion above exists to prevent.

   A BARE SECTION RENDERS NO HEADING AT ALL. The markup omits this element
   rather than emitting an empty one, so the run of tiles sits flush and there is
   no stray gap where a title would have been.

   The rule is a hairline that stops at the text, not a full-width divider: the
   tile grid below already carries strong edges, and a second full rule reads as
   a second border on the first tile. */
.app-hub-section {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 26px 0 12px;
}
.app-hub-section:first-of-type { margin-top: 4px; }
.app-hub-section-title {
    font-size: 12px;
    font-weight: 700;
    letter-spacing: .07em;
    text-transform: uppercase;
    color: var(--text-muted);
    white-space: nowrap;
}
.app-hub-section-rule {
    flex: 1;
    height: 1px;
    background: var(--hairline);
}
.app-hub-tile {
    position: relative;
    display: flex;
    flex-direction: column;
    padding: 24px 24px 22px;
    background: #fff;
    border: 1px solid var(--hairline);
    border-radius: 14px;
    box-shadow: 0 1px 3px rgba(15, 28, 54, .05);
    text-decoration: none;
    color: inherit;
    overflow: hidden;
    transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
}
/* The colour signature: a 4px top rule that widens into a soft wash behind the
   icon chip, so the tiles read as different products at a glance. */
.app-hub-tile::before {
    content: "";
    position: absolute;
    top: 0; left: 0; right: 0;
    height: 4px;
    background: var(--hub-bar, var(--navy));
}
.app-hub-tile::after {
    content: "";
    position: absolute;
    top: 0; right: 0;
    width: 260px; height: 220px;
    background: radial-gradient(circle at 100% 0, var(--hub-wash, #eef2f8), rgba(255, 255, 255, 0) 72%);
    pointer-events: none;
}
.app-hub-tile:hover {
    box-shadow: 0 12px 28px rgba(15, 52, 96, .13);
    transform: translateY(-3px);
    border-color: #d3d8df;
}
.app-hub-tile:focus-visible {
    outline: 2px solid var(--hub-bar, var(--navy));
    outline-offset: 2px;
}
.app-hub-head {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 15px;
}
.app-hub-ico {
    flex-shrink: 0;
    width: 50px;
    height: 50px;
    border-radius: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--hub-chip-bg, #e9eef6);
    color: var(--hub-chip-fg, var(--navy));
}
.app-hub-ico svg { width: 26px; height: 26px; }
.app-hub-kicker {
    font-size: 10.5px;
    font-weight: 800;
    text-transform: uppercase;
    letter-spacing: .8px;
    color: var(--hub-chip-fg, var(--navy));
    background: var(--hub-chip-bg, #e9eef6);
    border-radius: 999px;
    padding: 4px 11px;
    white-space: nowrap;
}
.app-hub-title {
    position: relative;
    z-index: 1;
    margin: 0 0 8px;
    font-size: 20px;
    font-weight: 700;
    color: var(--navy);
    line-height: 1.25;
}
.app-hub-desc {
    position: relative;
    z-index: 1;
    margin: 0 0 14px;
    font-size: 13.5px;
    line-height: 1.6;
    color: var(--text-soft);
}
.app-hub-points {
    position: relative;
    z-index: 1;
    list-style: none;
    margin: 0 0 20px;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 8px;
}
.app-hub-points li {
    position: relative;
    padding-left: 22px;
    font-size: 12.5px;
    line-height: 1.5;
    color: var(--text-muted);
}
/* Check glyph drawn in CSS — one concept, one mark, no extra inline SVG per bullet. */
.app-hub-points li::before {
    content: "";
    position: absolute;
    left: 2px;
    top: 4px;
    width: 9px;
    height: 5px;
    border-left: 2px solid var(--hub-bar, var(--navy));
    border-bottom: 2px solid var(--hub-bar, var(--navy));
    transform: rotate(-45deg);
}
.app-hub-cta {
    position: relative;
    z-index: 1;
    margin-top: auto;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    font-size: 13px;
    font-weight: 700;
    color: var(--hub-bar, var(--navy));
    white-space: nowrap;
}
.app-hub-cta-ico { display: inline-flex; transition: transform .18s ease; }
.app-hub-cta-ico svg { width: 16px; height: 16px; }
.app-hub-tile:hover .app-hub-cta-ico { transform: translateX(3px); }

/* The variants. Same three families as the menu cards (.app-menucard--*), so a
   hub tile and a menu tile for the same product read as the same colour. */
.app-hub-tile--navy   { --hub-bar: var(--navy);   --hub-chip-bg: #e9eef6; --hub-chip-fg: var(--navy);   --hub-wash: #eef2f8; }
.app-hub-tile--accent { --hub-bar: var(--accent); --hub-chip-bg: #fdeee8; --hub-chip-fg: var(--accent); --hub-wash: #fdf1ec; }
.app-hub-tile--green  { --hub-bar: #417a3f;       --hub-chip-bg: #e7f2e7; --hub-chip-fg: #2f6f3e;       --hub-wash: #edf5ed; }

@media (max-width: 700px) {
    .app-hub-grid { grid-template-columns: 1fr; gap: 14px; }
    .app-hub-tile { padding: 20px 18px 18px; }
    .app-hub-title { font-size: 18px; }
    .app-hub-cta { white-space: normal; }
}

/* ---- Menu cards (.app-menugroup / .app-menugrid / .app-menucard) ----
   THE shared "here is the whole application" navigation card: an icon chip, a
   title, one line of description and an arrow, with the family's own sections
   listed underneath it as links. A destination with no sub-menu is the SAME
   card with no list, which is the point — two tile shapes on one page is what
   makes a hub read as a pile rather than a map.

   Use this, NOT `.app-hub-tile` above, when the page is the MENU: many
   destinations, each of which may open onto several screens. Use `.app-hub-tile`
   when the page is a CHOICE between a small number of large products. The two
   consumers today are the Client Portal hub (app_main_menu.php) and the Opus
   View hub (app_opus.php).

   PROMOTED 2026-09-02 from `.mm-*` in assets/main_menu/main_menu.css, where this
   component was built on 2026-08-14. The Opus hub was asked to use "the same
   font sizes / tile sizes" as the Main Menu, and copying a component into a
   second feature stylesheet is exactly the drift this file exists to prevent —
   the same move `.dr-hub-*` -> `.app-hub-*` made above. Rules are carried over
   verbatim; only the names changed. main_menu.css keeps its own control strip,
   filter box and empty states and nothing else.

   The WHOLE CARD CANNOT BE ONE ANCHOR (a card with sub-links would nest
   anchors), so the head is the link and the card is a <section> — which is also
   what lets a sub-link be tabbed to directly.

   Accents are per-card CSS vars; ONE class sets the bar, the chip and the link
   hover. Set the same variant on the group and its cards. */
/* ---- Group ----
   ⚠ THIS MARGIN IS THE ONLY THING BETWEEN TWO RUNS OF CARDS, and a run does not
   need a heading to need it. A grid's `gap` separates the cards INSIDE it and
   nothing else, so two bare `.app-menugrid` siblings render flush — the last row
   of one hard against the first row of the next (operator, 2026-09-03, on the
   Opus View hub: "the tiles on the first row are nearly touching the tiles on
   the second row"). WRAP EVERY RUN IN `.app-menugroup`, and emit the
   `.app-menugroup-head` only when the run is actually titled; a heading over
   rows belonging to no family classifies nothing, but the band around them is
   layout, not a caption. 30px against the grid's own 14px gap is what makes the
   two read as separate runs rather than one four-card grid. */
.app-menugroup { margin-bottom: 30px; }
.app-menugroup:last-child { margin-bottom: 0; }
/* Hidden by the filter when none of its cards match. */
.app-menugroup[hidden] { display: none; }

.app-menugroup-head {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 14px;
}
.app-menugroup-ico {
    flex-shrink: 0;
    width: 34px;
    height: 34px;
    border-radius: 9px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--mcard-chip-bg, #e9eef6);
    color: var(--mcard-chip-fg, var(--navy));
}
.app-menugroup-title {
    font-size: 16px;
    font-weight: 700;
    color: #1a1a2e;
    margin: 0;
    white-space: nowrap;
}
.app-menugroup-count {
    font-size: 11px;
    font-weight: 700;
    color: #8a93a0;
    background: #eef1f5;
    border-radius: 999px;
    padding: 2px 9px;
    line-height: 1.6;
}
/* A hairline that runs from the heading to the right edge, so the eye reads the
   groups as bands rather than as a wall of cards. */
.app-menugroup-head::after {
    content: "";
    flex: 1;
    height: 1px;
    background: linear-gradient(to right, #e2e6ec, rgba(226, 230, 236, 0));
}

/* ---- Card grid ----
   auto-fill so a group of three does not stretch to fill a 1750px row.

   EVERY CARD IN A GROUP IS THE SAME HEIGHT (operator, 2026-08-14: "it looks
   unpolished when they are side by side with different heights"). Two rules do
   it together and both are needed:
     · `grid-auto-rows: 1fr` makes every implicit ROW the same height, so the
       match holds across the whole group and not merely within one row — a
       2-link card wrapping onto row 2 still lines up with the 8-link card above
       it. Default `align-items: stretch` (do NOT reinstate `start`) then makes
       each card fill its row.
     · `.app-menucard { height: 100% }` below — a grid ITEM stretched by the row does
       not pass that height to its own flex column without it, so the card's
       border and accent rule would stop short of the track.
   The cost is trailing whitespace inside the shortest card of a group; that is
   the trade the request makes, and it reads as a deliberate grid rather than a
   ragged one. */
.app-menugrid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
    grid-auto-rows: 1fr;
    gap: 14px;
}

/* ---- Card ---- */
.app-menucard {
    position: relative;
    display: flex;
    flex-direction: column;
    height: 100%;   /* fill the equal-height grid row — see .app-menugrid */
    background: #fff;
    border: 1px solid #e6e8ec;
    border-radius: 12px;
    overflow: hidden;
    transition: box-shadow .16s ease, transform .16s ease, border-color .16s ease;
}
/* The colour signature: a 3px rule down the leading edge (the `.app-hub-tile`
   top rule, turned to match this component's denser grid). */
.app-menucard::before {
    content: "";
    position: absolute;
    top: 0; bottom: 0; left: 0;
    width: 3px;
    background: var(--mcard-bar, var(--navy));
}
.app-menucard:hover {
    box-shadow: 0 8px 22px rgba(15, 52, 96, .11);
    transform: translateY(-2px);
    border-color: #d3d8df;
}
.app-menucard[hidden] { display: none; }

.app-menucard-top {
    display: flex;
    align-items: flex-start;
    gap: 13px;
    padding: 15px 15px 15px 18px;
    text-decoration: none;
    color: inherit;
}
.app-menucard-top:focus-visible {
    outline: 2px solid var(--mcard-bar, var(--navy));
    outline-offset: -3px;
    border-radius: 12px;
}
.app-menucard-ico {
    flex-shrink: 0;
    width: 40px;
    height: 40px;
    border-radius: 9px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--mcard-chip-bg, #e9eef6);
    color: var(--mcard-chip-fg, var(--navy));
}
.app-menucard-ico svg { width: 21px; height: 21px; }

.app-menucard-toptext { flex: 1; min-width: 0; }
.app-menucard-titlerow {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
}
.app-menucard-title {
    font-size: 14.5px;
    font-weight: 700;
    color: #1a1a2e;
    line-height: 1.3;
}
.app-menucard-desc {
    display: block;
    margin-top: 4px;
    font-size: 12.5px;
    line-height: 1.5;
    color: #6a7280;
}
.app-menucard-badge {
    font-size: 9.5px;
    font-weight: 800;
    text-transform: uppercase;
    letter-spacing: .5px;
    padding: 2px 7px;
    border-radius: 999px;
    background: var(--mcard-chip-bg, #e9eef6);
    color: var(--mcard-chip-fg, var(--navy));
    white-space: nowrap;
}
/* ---- Open-item count pill ----
   Deliberately NOT the soft `.app-menucard-badge` chip beside it: this is a live
   number, and it is the SAME number the topbar bell / messages icon carries, so
   it takes that pill's solid `--accent` fill (`.topbar-alerts-count` in
   general.css) rather than the card's own accent — a reader should recognise it
   as the figure they just saw up there. Rendered `hidden` by the server and
   un-hidden by the page's own script only at a count above zero, exactly like the
   topbar's. */
.app-menucard-count {
    /* Never shrink: it is a flex item on `.app-menucard-titlerow` beside a title that
       can be long ("Profile and Transaction Deficiencies" is in this component),
       and a squeezed "99+" is a wrong number rather than a tight one. Same
       reason `.app-menucard-badge` above carries white-space: nowrap. */
    flex: none;
    min-width: 18px;
    padding: 0 6px;
    border-radius: 999px;
    background: var(--accent);
    color: #fff;
    font-size: 10.5px;
    font-weight: 700;
    line-height: 18px;
    text-align: center;
    white-space: nowrap;
}
/* An author `display` beats the UA's [hidden] — the pill is inline by default,
   but say it anyway so a future flex/grid on the title row cannot resurrect it
   (the `hidden` needs a display companion rule). */
.app-menucard-count[hidden] { display: none; }
.app-menucard-arrow {
    flex-shrink: 0;
    align-self: center;
    color: #c8ccd4;
    display: flex;
    transition: color .16s ease, transform .16s ease;
}
.app-menucard-arrow svg { width: 16px; height: 16px; }
.app-menucard-top:hover .app-menucard-arrow,
.app-menucard-top:focus-visible .app-menucard-arrow {
    color: var(--mcard-bar, var(--navy));
    transform: translateX(2px);
}

/* ---- Sub-menu links ----
   The family's own sections. One column: these cards sit in a ~320-420px grid
   track, and a two-column list at that width wraps the long labels ("Profile
   and Transaction Deficiencies") onto three lines each. */
.app-menucard-links {
    display: flex;
    flex-direction: column;
    padding: 4px 10px 10px 14px;
    border-top: 1px solid #f0f2f5;
    margin-top: 2px;
}
.app-menucard-link {
    position: relative;
    display: block;
    padding: 6px 8px 6px 20px;
    border-radius: 7px;
    font-size: 12.5px;
    line-height: 1.4;
    font-weight: 500;
    color: #55607a;
    text-decoration: none;
    transition: background .13s ease, color .13s ease;
}
/* Caret drawn in CSS — one mark per row, no inline SVG per sub-link (the
   `.app-hub-points li::before` precedent in general.css). */
.app-menucard-link::before {
    content: "";
    position: absolute;
    left: 7px;
    top: 50%;
    width: 5px;
    height: 5px;
    margin-top: -3px;
    border-top: 1.5px solid #b6bdc9;
    border-right: 1.5px solid #b6bdc9;
    transform: rotate(45deg);
    transition: border-color .13s ease, transform .13s ease;
}
.app-menucard-link:hover,
.app-menucard-link:focus-visible {
    background: var(--mcard-wash, #f2f5fa);
    color: var(--mcard-bar, var(--navy));
    outline: none;
}
.app-menucard-link:hover::before,
.app-menucard-link:focus-visible::before {
    border-color: var(--mcard-bar, var(--navy));
    transform: rotate(45deg) translate(1px, -1px);
}
/* The filter dims a sub-link that did not match, rather than removing it: the
   card is still the same family and a row vanishing mid-list reads as a
   permission change. `is-dim` is set by main_menu.js only. */
.app-menucard-link.is-dim { opacity: .35; }

/* ---- A caption INSIDE the sub-link list (2026-09-02) ----
   For one case only: a card whose family bar folds its screens behind NAMED
   dropdowns. The Client Invoicing suite is the first — its `.app-subnav` band
   is one plain tab plus "Invoice Maintenance" and "Metering / Automation", and
   a hub card that listed all eleven routes flat would be the one surface that
   dropped the names the operator just gave them.

   NOT a general grouping mechanism. A card whose sub-links have no names of
   their own stays a flat list — that is what every Client Portal card is, and
   a caption over a run that classifies nothing is the anti-pattern this app
   keeps re-learning. The first caption sits flush with the list's own top
   padding; the rest get air above them. */
.app-menucard-linkcap {
    display: block;
    padding: 9px 8px 3px 12px;
    font-size: 9.5px;
    font-weight: 800;
    text-transform: uppercase;
    letter-spacing: .6px;
    color: #98a1ae;
}
.app-menucard-linkcap:first-child { padding-top: 2px; }

/* ---- Accent palettes ---- */
.app-menugroup--default,
.app-menucard--default   { --mcard-bar: var(--navy); --mcard-chip-bg: #e9eef6; --mcard-chip-fg: var(--navy); --mcard-wash: #f1f5fa; }
.app-menugroup--action,
.app-menucard--action    { --mcard-bar: #f36f4d;     --mcard-chip-bg: #fdeee8; --mcard-chip-fg: var(--accent); --mcard-wash: #fdf3ef; }
.app-menugroup--green,
.app-menucard--green     { --mcard-bar: #417a3f;     --mcard-chip-bg: #e7f2e7; --mcard-chip-fg: #2f6f3e; --mcard-wash: #f0f7f0; }
.app-menugroup--teal,
.app-menucard--teal      { --mcard-bar: #1f6f7a;     --mcard-chip-bg: #e2f0f2; --mcard-chip-fg: #1a5c66; --mcard-wash: #eef7f8; }
.app-menugroup--gold,
.app-menucard--gold      { --mcard-bar: #9a7420;     --mcard-chip-bg: #f6efdd; --mcard-chip-fg: #7d5d15; --mcard-wash: #faf5e9; }
/* ---- Two RETAINED variants, currently unused ----
   `accent` is a per-group / per-card key in the tree behind the cards, so one with no
   rule here renders the element unstyled (no bar, no chip colour) rather than
   falling back — see the "a declared value needs every consumer list" lesson.
   Both of these were live until 2026-08-14 and both could return with one array
   entry, so the rules stay:
     --new        the green "(new)" features (CRM / Digital Subs / Capital Calls),
                  which moved onto family cards;
     --configure  the "Configure" group, removed as a duplicate of the drawer's
                  "My Profile & Settings" row.
   Delete them only together with the `accent` key that could name them. */
.app-menucard--new       { --mcard-bar: #417a3f;     --mcard-chip-bg: #e7f2e7; --mcard-chip-fg: #2f6f3e; --mcard-wash: #f0f7f0; }
.app-menugroup--configure,
.app-menucard--configure { --mcard-bar: #3e3f3a;     --mcard-chip-bg: #ececea; --mcard-chip-fg: #3e3f3a; --mcard-wash: #f3f3f1; }

/* ---- TWO PER ROW, MATCHED WITHIN THE ROW (`--pairs`) ----
   For a hub of a FEW destinations where the operator has said which belong on
   which line — the Opus View hub (2026-09-03: "the 2 tiles view as on their own
   dedicated row … both the same height so they look symmetrical", "the other 2
   tiles on a new row … also the same height"). Two full-width columns, so a
   4-tile page reads as two balanced lines rather than one short run hugging the
   left of a 1750px page.

   THE HEIGHT MATCH IS PER ROW, and that is the whole modifier:
     · `grid-auto-rows: auto` sizes each row to ITS OWN tallest card — unlike the
       base grid's `1fr`, which makes every row in the group the same height and
       would pad the two short preview tiles out to the height of a card carrying
       eleven sub-links;
     · the default `align-items: stretch` plus `.app-menucard { height: 100% }`
       (inherited from the base rules — do NOT re-declare `height: auto` here)
       then fills each card to its own row.

   ⚠ IT DOES NOT DECIDE WHICH TILES SHARE A LINE. Column count alone breaks the
   moment a caller lacks one grant: three tiles in two columns pair the survivors
   arbitrarily. The pairing is DECLARED in the tree (`hub_row` in
   data/general/menu_opus.php) and rendered as one grid per run; this modifier
   only shapes the run it is put on.

   ⚠ AND IT PUTS NO SPACE BETWEEN RUNS. `gap` is inside the grid; the air
   between one run and the next comes from the `.app-menugroup` wrapper each run
   must carry — see its rule above.

   RETIRED WITH IT: `--natural`, which released the height match entirely. It had
   one consumer, this same grid, and only while it held two cards of wildly
   different length; the operator has now asked for the opposite. */
.app-menugrid--pairs {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-auto-rows: auto;
}

@media (max-width: 700px) {
    /* One column => no card is ever BESIDE another, which is the whole reason the
       heights are matched on desktop. Keeping `1fr` rows here would stretch every
       card in a group to the tallest one's height and turn Operations into six
       screens of trailing whitespace, so the match is released with the columns.
       `--pairs` is named EXPLICITLY: a media query adds no specificity, so it
       would otherwise be a coin toss on source order which `grid-template-columns`
       won. */
    .app-menugrid,
    .app-menugrid--pairs { grid-template-columns: 1fr; grid-auto-rows: auto; }
    .app-menugroup-title { white-space: normal; }
    .app-menugroup-head::after { display: none; }
}

/* ---- Menu-hub CONTROL STRIP and FILTER BOX ----
   (`.app-menutools` / `.app-menusearch` / `.app-menunoresults`)

   The strip under a menu hub's hero, and the box that filters its cards. PROMOTED
   2026-09-03 out of `.mm-toolbar` / `.mm-search` / `.mm-noresults` in
   assets/main_menu/main_menu.css, when the Opus View hub was asked for "a search
   box (like we have on the client menu page)" — the same move that brought the
   card itself here on 2026-09-02, and made for the same reason: a second copy is
   how the first two drift. Rules are carried over verbatim; only the names
   changed, plus a self-contained icon chip on the no-results block (it used to
   borrow `.mm-empty-ico`, which stays behind because the Main Menu's "no menu
   items at all" CARD is page-local).

   Behaviour is assets/general/app_menu_filter.js, driven by `data-menufilter*`
   hooks — see that file for the DOM contract. Progressive: with JS off the box is
   inert and every card is visible.

   Quick Launch (`.mm-quick`) deliberately did NOT come with it: it is the legacy
   welcome page's shortcut row, and the Main Menu is its only surface.

   PAGE-LEVEL CONTROLS BELONG HERE, not in `.page-hero-aside` — that slot is a
   status badge only (the standard further up this file). */
.app-menutools {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 12px 20px;
    margin: -8px 0 26px;
}

.app-menusearch {
    flex: 0 1 300px;
    position: relative;
    display: flex;
    align-items: center;
}
.app-menusearch-ico {
    position: absolute;
    left: 12px;
    color: #98a1ae;
    pointer-events: none;
}
.app-menusearch-input {
    width: 100%;
    font: inherit;
    font-size: 13px;
    color: var(--text);
    background: #fff;
    border: 1px solid #d8dee6;
    border-radius: 999px;
    padding: 9px 34px 9px 34px;
    transition: border-color .15s, box-shadow .15s;
    /* The UA search-cancel button would sit on top of .app-menusearch-clear. */
    -webkit-appearance: none;
    appearance: none;
}
.app-menusearch-input::-webkit-search-cancel-button { display: none; }
.app-menusearch-input::placeholder { color: #98a1ae; }
.app-menusearch-input:focus {
    outline: none;
    border-color: var(--navy);
    box-shadow: 0 0 0 3px rgba(15, 52, 96, .12);
}
.app-menusearch-clear {
    position: absolute;
    right: 9px;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 20px;
    height: 20px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: #eef1f5;
    color: #667085;
    cursor: pointer;
    transition: background .15s, color .15s;
}
.app-menusearch-clear:hover { background: #e2e6ec; color: var(--navy); }
.app-menusearch-clear[hidden] { display: none; }

/* ---- "Nothing matches" ----
   The filter's own empty state, NOT a page empty state: it replaces the grid
   only while a query is active, so it is a light block rather than the bordered
   `.empty-state` card. */
.app-menunoresults {
    padding: 34px 20px;
    text-align: center;
}
.app-menunoresults[hidden] { display: none; }
.app-menunoresults-ico {
    width: 56px;
    height: 56px;
    margin: 0 auto 12px;
    border-radius: 14px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #eef1f5;
    color: #8a93a0;
}
.app-menunoresults-text {
    font-size: 13.5px;
    color: #6a7280;
    margin: 0;
}
.app-menunoresults-text span { font-weight: 700; color: var(--navy); }

@media (max-width: 700px) {
    .app-menutools { gap: 12px; }
    .app-menusearch { flex: 1 1 100%; }
}

/* ---- Page Actions (left-aligned action row under the hero) ----
   Standard home for a page-level primary action (e.g. an "Add"/"Request"
   button) that sits BELOW the hero rather than in the hero aside — left-aligned
   so it stays reachable on a narrow viewport without scrolling. Reusable; a
   feature never needs its own class for this.
   The strip is PAGE-LEVEL: it renders on the page background, OUTSIDE every
   card — a card's own buttons are .app-card-actions. The one sanctioned in-card
   reuse is a form's submit/cancel CTA row at the foot of the form.
   The full page order is: [.app-subnav-bar chrome] -> hero -> .page-actions ->
   content. Since 2026-07-31 the sub-nav is no longer page content at all — it is
   a full-bleed chrome band ABOVE the hero, a body-level sibling of the topbar
   (see "App Subnav" below), so .page-actions is simply the FIRST thing after the
   hero. It used to sit between the hero and an in-page .app-subnav strip.
   The distinction that drove that old ordering still holds and still decides
   which component you reach for: these buttons act on the screen you are ON,
   while the sub-nav navigates BETWEEN the screens of the family. A row of links
   to sibling SCREENS is therefore sub-nav tabs, not a .page-actions row.
   Contract + live demo: README §2 and app_standards.php#std-page-actions. */
.page-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    margin-bottom: 20px;
}
@media (max-width: 700px) {
    .page-actions { align-items: stretch; }
    /* One full-width action per row on mobile (was flex:1 1 auto, which let short
       labels share a row — see the CSS review, 2026-07-22) + touch min-height. */
    .page-actions .btn {
        flex: 1 1 100%;
        width: 100%;
        justify-content: center;
        min-height: var(--control-h-touch);
    }
}

/* ---- Buttons & shared controls ----
   The foundation below is shared by .btn and .btn-card-action (a second button
   base used in list/grid record cards). VISUAL VARIANTS set colors/borders only;
   SIZE MODIFIERS (.btn-sm / .btn-lg / the dense card-action) own dimensions and
   are declared AFTER the variants (below) so `btn btn-secondary btn-sm` is small
   regardless of class order — the long-standing secondary-small cascade bug.
   Dimensions / type / colors come from the --control-* / --btn-* tokens above. */
.btn,
.btn-card-action {
    -webkit-appearance: none;
    appearance: none;
    box-sizing: border-box;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--control-gap);
    min-height: var(--control-h-md);
    padding: 0 var(--control-px-md);
    border: 1px solid transparent;
    border-radius: var(--control-radius);
    font-family: inherit;
    font-size: var(--control-font-md);
    font-weight: 600;
    line-height: 1;
    letter-spacing: .2px;
    text-decoration: none;
    white-space: nowrap;
    cursor: pointer;
    transition: background-color var(--control-transition),
                border-color var(--control-transition),
                color var(--control-transition),
                box-shadow var(--control-transition);
}
/* [hidden] companion — LOAD-BEARING, and global on purpose. `display: inline-flex`
   above is an AUTHOR rule, so it outranks the UA's [hidden]{display:none} and every
   `<button class="btn" hidden>` / `<a class="btn" hidden>` in the app painted anyway.
   Three features had already hit this and each patched it behind its OWN ancestor
   (`.dr-page .btn[hidden]`, `.wr-linkrow .btn[hidden]`, `.wr-actionbar .btn[hidden]`)
   — which left every button outside those three scopes broken, e.g. the shared doc
   viewer's Download button (app_doc_viewer.js builds it `hidden` and unhides it only
   when the caller passes onDownload; its overlay is appended to <body>, so no feature
   scope reaches it, and the button offered a download that did not exist). One rule
   here retires that whole class of bug; the three scoped copies are now redundant but
   harmless. Convention + why: see .callout-banner[hidden] / .app-fields[hidden] /
   .app-filter-panel[hidden] / .app-totals[hidden] in this file. */
.btn[hidden], .btn-card-action[hidden] { display: none !important; }

/* Visual variants — colors/borders/shadows only (no dimensions or typography). */
.btn-primary { background: var(--btn-primary-bg); border-color: var(--btn-primary-bg); color: #fff; }
.btn-primary:hover { background: var(--btn-primary-bg-hover); border-color: var(--btn-primary-bg-hover); }
.btn-warning { background: var(--btn-warning-bg); border-color: var(--btn-warning-bg); color: var(--btn-warning-fg); }
.btn-warning:hover { background: var(--btn-warning-bg-hover); border-color: var(--btn-warning-bg-hover); color: var(--btn-warning-fg); }
.btn-success { background: var(--btn-success-bg); border-color: var(--btn-success-bg); color: #fff; }
.btn-success:hover { background: var(--btn-success-bg-hover); border-color: var(--btn-success-bg-hover); }
.btn-info { background: var(--btn-info-bg); border-color: var(--btn-info-bg); color: #fff; }
.btn-info:hover { background: var(--btn-info-bg-hover); border-color: var(--btn-info-bg-hover); }
.btn-danger { background: var(--btn-danger-bg); border-color: var(--btn-danger-bg); color: #fff; }
.btn-danger:hover { background: var(--btn-danger-bg-hover); border-color: var(--btn-danger-bg-hover); color: #fff; }

/* BRANDED OUTLINE — .btn-default is an alias of .btn-secondary (was undefined).
   Re-coloured 2026-08-18 (operator: the old grey fill #f0f0f0/#ccc/#333 "looks
   too basic against the rest of the application"). White fill + navy hairline +
   navy ink, from the --btn-secondary-* tokens above; `border-color` rather than
   the `border` shorthand because the .btn foundation already declares
   `1px solid transparent` and every variant here sets colour ONLY.

   It is now one of THREE white outline buttons, and they are not interchangeable:
     .btn-secondary     navy border + navy ink — the quiet half of a page-actions
                        pair, beside a filled .btn-primary
     .btn-outline       #ccc border + #555 ink — a NEUTRAL affordance with no
                        brand weight (a Cancel that must not compete)
     .btn-card-action   navy border, INVERTS to a navy fill on hover — the dense
                        record-card / in-panel action
   Reach for -secondary at page level, -card-action inside a card. */
.btn-secondary,
.btn-default {
    background: #fff;
    border-color: var(--btn-secondary-fg);
    color: var(--btn-secondary-fg);
}
.btn-secondary:hover,
.btn-default:hover {
    background: var(--btn-secondary-bg-hover);
    border-color: var(--btn-secondary-fg-hover);
    color: var(--btn-secondary-fg-hover);
}

/* White w/ neutral border — .btn-ghost is an alias of .btn-outline (was undefined). */
.btn-outline,
.btn-ghost {
    background: #fff;
    border-color: #ccc;
    color: #555;
}
.btn-outline:hover,
.btn-ghost:hover { background: #f5f7fa; border-color: #999; color: #333; }

/* Link-style action — intentionally compact & transparent; overrides the
   foundation box so it reads as an inline text action. Add .btn-sm for a sized
   variant that aligns with other small buttons. */
.btn-link {
    background: transparent;
    border-color: transparent;
    color: var(--navy);
    font-weight: 500;
    min-height: 0;
    padding: 5px 6px;
}
.btn-link:hover { text-decoration: underline; }

/* Standard "View/Details" action button for record cards (list + grid cards).
   Dense outline; goes full-width inside a mobile card (see the 700px rules). Uses
   the shared foundation above + the dense size in the size group below. */
.btn-card-action {
    background: #fff;
    border-color: var(--navy);
    color: var(--navy);
}
.btn-card-action:hover { background: var(--navy); color: #fff; }
/* Solid-fill primary variant of the card action button. Promoted here from a
   feature CSS (it was defined only in implementation_tasks.css, but is injected by
   many features' grids — bank_activity/issues, bulk_wire, wire_requests,
   wire_templates, smartwire, holdback_settings — which do not load that file).
   implementation_tasks.css keeps its own shade via source order on its own pages. */
.btn-card-action--primary { background: var(--navy); border-color: var(--navy); color: #fff; }
.btn-card-action--primary:hover { background: #0c2a4d; border-color: #0c2a4d; color: #fff; }
/* SEMANTIC card actions — the dense base is declared AFTER the visual variants,
   so on equal specificity `btn-card-action btn-danger` was repainted by the base
   and rendered as a plain navy outline button (the standards page's own Delete
   demo proved it, 2026-07-28). These compound selectors (0,2,0) restore the
   variant's colour without !important — the same "compose, don't fork" shape as
   the .app-rowbtn.pt-act-log accent override. TWO semantic combos only, matching
   the row-action variants: danger = Delete, warning = Disable/deactivate. */
.btn-card-action.btn-danger { background: var(--btn-danger-bg); border-color: var(--btn-danger-bg); color: #fff; }
.btn-card-action.btn-danger:hover { background: var(--btn-danger-bg-hover); border-color: var(--btn-danger-bg-hover); color: #fff; }
.btn-card-action.btn-warning { background: var(--btn-warning-bg); border-color: var(--btn-warning-bg); color: var(--btn-warning-fg); }
.btn-card-action.btn-warning:hover { background: var(--btn-warning-bg-hover); border-color: var(--btn-warning-bg-hover); color: var(--btn-warning-fg); }
/* SIZE MODIFIERS — declared AFTER every visual variant so they always win on
   equal specificity. .btn-card-action is dense by default (same size as .btn-sm). */
.btn-sm,
.btn-card-action {
    min-height: var(--control-h-sm);
    padding-left: var(--control-px-sm);
    padding-right: var(--control-px-sm);
    font-size: var(--control-font-sm);
}
.btn-lg {
    min-height: var(--control-h-touch);
    padding-left: 18px;
    padding-right: 18px;
    font-size: var(--control-font-md);
}

/* Shared disabled / pending / loading states — cover BOTH button bases.
   `.btn-pending` stays clickable (handlers explain why it's not ready) so it is
   only dimmed; `:disabled` / `[aria-disabled]` are made inert (which also
   suppresses their variant hover). `[aria-busy]` marks an in-flight request. */
.btn:disabled,
.btn[aria-disabled="true"],
.btn.btn-pending,
.btn-card-action:disabled,
.btn-card-action[aria-disabled="true"] {
    opacity: var(--control-disabled-opacity);
    cursor: not-allowed;
    box-shadow: none;
}
.btn:disabled,
.btn[aria-disabled="true"],
.btn-card-action:disabled,
.btn-card-action[aria-disabled="true"] {
    pointer-events: none;
}

/* VIEW ONLY (AppViewOnly in general.js, ClickUp 868mmj55v). A `[data-app-save]`
   control on a page an Opus employee is viewing: it LOOKS disabled, but keeps
   pointer events — unlike every other aria-disabled control above — because its
   hover text is the whole point ("View only: Opus employees can't save…") and a
   control with pointer-events:none never shows a title. AppViewOnly stops the
   click itself, in the capture phase. A non-button control (an icon link, a
   switch) gets the same dimmed, not-allowed look. */
.btn.is-view-only[aria-disabled="true"],
.btn-card-action.is-view-only[aria-disabled="true"] {
    pointer-events: auto;
}
.is-view-only[aria-disabled="true"] {
    cursor: not-allowed;
}
.is-view-only[aria-disabled="true"]:not(.btn):not(.btn-card-action) {
    opacity: var(--control-disabled-opacity);
}
.btn[aria-busy="true"],
.btn-card-action[aria-busy="true"] {
    cursor: progress;
    opacity: .85;
}

/* ---- `.app-btn-menu` — ONE BUTTON THAT REVEALS RELATED BUTTONS -------------
   The shared affordance for "several related actions that belong under one
   label": a parent button that opens a small panel of sibling actions beneath
   it. First call site 2026-08-24 — app_investor_transactions.php?page=view,
   "Download Subscription Application" over the digital subscription's two PDFs.

   It is a native <details>, for the same reason .app-subnav-group is: the
   open/close state, the keyboard operation and the collapsed default all come
   from the element rather than from script. general.js adds only what markup
   cannot — DISMISSAL (a click elsewhere, or ESC), delegated on document so a
   panel opened by mistake does not sit over the page until the reader finds the
   summary again.

   THE <summary> IS THE BUTTON. Give it the same `btn btn-… btn-…` classes the
   row's other buttons carry and it sits in a .page-actions band as an equal;
   `list-style: none` + the WebKit marker rule drop the disclosure triangle,
   because the chevron is inline SVG at the call site and therefore inherits the
   button variant's own ink instead of the UA's black.

   ⚠ THE PANEL IS position:absolute, so an ancestor with `overflow: hidden`
   swallows it — the documented .app-tip clipping trap. Put the menu in a band
   that does not clip (.page-actions, .detail-modal-actions), or release the
   ancestor the way .itx-panel--tips does. */
.app-btn-menu { position: relative; display: inline-block; }
.app-btn-menu-summary { list-style: none; }
.app-btn-menu-summary::-webkit-details-marker { display: none; }
.app-btn-menu-summary::marker { content: ''; }
.app-btn-menu-chev { flex: none; transition: transform .15s ease; }
.app-btn-menu[open] .app-btn-menu-chev { transform: rotate(180deg); }
/* THE PANEL IS A SURFACE, NOT A BORDERED BOX (polish pass 2026-09-04). The
   elevation is three layers — a 1px contact shadow that seats it, a mid drop that
   gives it height, and a wide soft ambient that lets the page fall away behind it
   — and the hairline is a cool translucent navy rather than a solid grey, so it
   defines the edge without competing with the shadow underneath it. */
.app-btn-menu-panel {
    position: absolute;
    z-index: 30;              /* over page content + grid chrome (20-50) */
    top: calc(100% + 7px);
    left: 0;
    min-width: 100%;
    width: max-content;
    max-width: min(380px, 88vw);
    /* A menu longer than the viewport scrolls inside itself instead of running off
       the bottom of the page. Members carry an INSET focus ring, so nothing a
       keyboard user needs to see is clipped by the scroll box. */
    max-height: min(70vh, 440px);
    overflow-y: auto;
    padding: 6px;
    background: #fff;
    border: 1px solid rgba(15, 52, 96, .09);
    border-radius: 12px;
    box-shadow: 0 1px 2px rgba(16, 32, 54, .06),
                0 8px 16px -6px rgba(16, 32, 54, .12),
                0 20px 44px -16px rgba(16, 32, 54, .24);
    /* The panel arrives FROM its trigger: the origin follows the anchored side, so
       a right-anchored menu does not appear to grow out of the wrong corner. */
    transform-origin: top left;
    animation: app-btn-menu-in 140ms cubic-bezier(.2, .7, .3, 1);
}
@keyframes app-btn-menu-in {
    from { opacity: 0; transform: translateY(-4px) scale(.985); }
    to   { opacity: 1; transform: none; }
}
/* Right-anchored variant, for a menu sitting at the right edge of its band. */
.app-btn-menu--right .app-btn-menu-panel { left: auto; right: 0; transform-origin: top right; }
/* ⚠ A MEMBER IS AN <a>, A <button> OR AN INERT <span>, AND UNTIL 2026-09-04 THE
   THREE DID NOT LOOK ALIKE. Nothing here reset the UA's button chrome, so a
   <button> member rendered on a #f0f0f0 plate, in the UA font (font-family is not
   inherited by form controls), CENTRE-aligned (the UA's `text-align: center`) and
   only as wide as its own text — so its hover tint stopped short of the panel
   edge. Every symptom read as a one-off layout bug and none of it was: it was on
   every <button> member of every menu in the app, most visibly the client
   dashboard's cut picker, whose members are ALL buttons. The five resets below
   are what make the three element types one component. */
.app-btn-menu-item {
    display: flex;
    width: 100%;
    align-items: flex-start;
    gap: 9px;
    margin: 0;
    padding: 9px 11px;
    border: 0;
    border-radius: 8px;
    background: none;
    font-family: inherit;
    font-size: 13px;
    font-weight: 600;
    line-height: 1.35;
    text-align: left;
    color: #22303f;
    text-decoration: none;
    white-space: normal;
    cursor: pointer;
    transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.app-btn-menu-item + .app-btn-menu-item { margin-top: 2px; }
.app-btn-menu-item:hover { background: #eef4fb; color: var(--navy); }
.app-btn-menu-item svg { flex: none; margin-top: 1px; color: #7a8798; }
.app-btn-menu-item:hover svg { color: var(--navy); }
/* A member had NO keyboard focus ring: it is not in the shared :where() focus
   list, and that list's outline sits OUTSIDE the element, which inside a padded
   panel would overlap the panel edge and its neighbours. So the ring is INSET —
   the same treatment the hand-rolled menus in the tree already use. */
.app-btn-menu-item:focus-visible {
    outline: 2px solid var(--control-focus);
    outline-offset: -2px;
}
/* A hairline the CALL SITE places before a destructive member (or between any two
   groups of members). Separating a destructive action is the most conventional
   thing an action menu does, and it stays the call site's decision because a menu
   whose only member is destructive should not carry one. */
.app-btn-menu-sep {
    display: block;
    height: 1px;
    margin: 5px 7px;
    background: rgba(15, 52, 96, .10);
}
/* THE SELECTED member, for a menu used as a PICKER. This class was already being
   written by app_client_dashboard's chart-cut picker (`.is-active` on the current
   cut) and styled NOWHERE, so that menu showed no selection at all. Tint + navy
   ink + an inset left accent bar, which needs no extra markup and rounds with the
   member's own radius. */
.app-btn-menu-item.is-active {
    background: #eef4fb;
    color: var(--navy);
    box-shadow: inset 3px 0 0 var(--navy);
}
.app-btn-menu-item.is-active svg { color: var(--navy); }
/* A member that is not available YET stays in the menu and says why — the
   operator's "gated, not hidden" rule, at menu-row scale. It is inert, so it
   cannot be clicked or tabbed into, and the reason is the .app-btn-menu-note
   under its label rather than a title the keyboard never sees. */
.app-btn-menu-item--off { color: #97a3b4; cursor: not-allowed; pointer-events: none; }
.app-btn-menu-item--off:hover { background: none; color: #97a3b4; }
.app-btn-menu-item--off svg,
.app-btn-menu-item--off:hover svg { color: #c3cbd5; }
/* The DESTRUCTIVE member. Here rather than in a feature sheet because
   `.app-rowbtn--danger` is already part of the shared button vocabulary, so a
   local copy of the same notion is exactly the forked shared component
   tools/check_shared_css.php exists to catch. Ink only at rest — the fill lands
   on hover, the .app-rowbtn--danger contract — so one red row cannot dominate a
   menu of ordinary ones. Added 2026-09-04 for the invitation list's row menu
   ("Cancel invitation"), the control's first use inside a grid row. */
.app-btn-menu-item--danger { color: #c0392b; }
.app-btn-menu-item--danger svg { color: #c0392b; }
.app-btn-menu-item--danger:hover { background: #c0392b; color: #fff; }
.app-btn-menu-item--danger:hover svg { color: #fff; }
.app-btn-menu-item--danger:hover .app-btn-menu-note { color: #fdd; }
.app-btn-menu-note { display: block; margin-top: 3px; font-size: 11.5px; font-weight: 500; line-height: 1.42; color: #97a3b4; }
/* A media query adds NOTHING to a selector's weight, so this has to sit AFTER the
   rules it overrides and match their specificity — which it does. */
@media (prefers-reduced-motion: reduce) {
    .app-btn-menu-panel { animation: none; }
    .app-btn-menu-item { transition: none; }
    .app-btn-menu-chev { transition: none; }
}

/* ---- `.qa-only` — THE marker for a control or value that exists ONLY on a
   QATESTING url (operator standard, 2026-08-18: "any button or value that is
   only for display in QA_TESTING should be styled ... outlined in an orange
   color ... very obvious and consistent").

   IT IS THE SECOND HALF OF A TWO-PART CONTRACT, AND THE FIRST HALF IS SERVER-SIDE.
   The class SAYS "developer-only"; `app_is_qa_url()` (data/general/app_db.php) is
   what MAKES it developer-only. A control the browser is merely told to conceal is
   still in the page source and still clickable from a console, and these controls
   open panels that publish per-proc database timings — so the gate must be a PHP
   `if` that renders nothing, exactly as app_connect_insights.php's $ciShowPerf
   does. Never `.qa-only { display: none }` in production, and never this class on
   an element a client can see: an orange ring that a client CAN reach is a lie
   about the environment, which is worse than no ring at all.

   THREE FORMS, one vocabulary — picked by what the element ALREADY IS, never by
   the call site, which writes `qa-only` and nothing else:
     · a BUTTON (`btn qa-only`) keeps its variant's fill — the operator kept these
       as ghost buttons on purpose, they are not promoted to actions — and spends
       its existing border on the orange, widened to 2px. `box-sizing: border-box`
       + `min-height` means that extra pixel costs no layout;
     · a BLOCK (`app-panel qa-only` / `app-card qa-only`) takes the accent as a 3px
       LEFT RULE, the shape `.page-hero` has used all along. A 2px ring round a
       full-width card is loud without being informative;
     · anything ELSE (a value, a pill, a menu row) has no border to spend, so it
       takes an `outline`, which reserves no space and therefore cannot move a
       layout that was signed off without it.
   All three carry the same "QA" pill, which is what makes them read as one marker
   rather than as three orange things.

   The pill is a `::after`, so it needs no markup change at the call site and
   cannot be forgotten — but that also means it does NOT reach an element whose
   content is replaced by JS. Put the class on the stable wrapper, not on the node
   a renderer rewrites. `content` IS announced by most screen readers; that is
   wanted here (a developer control should say so), which is why the string is the
   word "QA" and not a glyph.

   ⚠ THE BASE CLASS SETS THE PILL AND NOTHING ELSE. Every box property belongs to
   one of the three forms below, because this class lands on elements that already
   have a signed-off box: an innocent-looking `border-radius: 3px` on `.qa-only`
   would out-order `.btn`'s 5px (this block is BELOW it) and quietly re-corner
   every QA button, and it would race `.app-card, .app-panel`'s 12px on source
   order alone. */
.qa-only::after {
    content: 'QA';
    display: inline-block;
    margin-left: 6px;
    padding: 0 5px;
    border-radius: 3px;
    background: var(--qa-accent-ink);
    color: #fff;
    font-size: 9.5px;
    font-weight: 800;
    line-height: 15px;
    letter-spacing: .6px;
    text-transform: uppercase;
    vertical-align: 1px;
    /* The pill is a LABEL, never a target — a click on it must reach the control
       it sits inside (it is inside the button's own box). */
    pointer-events: none;
}
/* Form 1 — anything with no border of its own (a value, a pill, a menu row): the
   layout-neutral `outline`.
   THE EXCLUSION LIST IS THE WHOLE MECHANISM, and it is written as `:not()` rather
   than as a second opt-in class so a call site never has to CHOOSE a form — one
   word, `qa-only`, and the right treatment falls out of what the element already
   is. It also has to carry every form below it: `:not()` contributes its
   argument's specificity, so this selector is (0,5,0) and would otherwise beat
   the (0,2,0) button and block rules and paint a second ring around them. */
.qa-only:not(.btn):not(.btn-card-action):not(.app-panel):not(.app-card) {
    outline: 2px solid var(--qa-accent);
    outline-offset: 2px;
    /* ⚠ AND NOTHING ELSE — in particular NO `border-radius`. `outline` already
       follows whatever radius the element has, and this selector is (0,5,0): a
       "harmless" 3px here out-ranks the 9px on `.dropdown-menu li a` (0,1,2) and
       silently re-corners the topbar's UI Standards row. An element with no radius
       of its own gets a square ring, which reads perfectly well as a marker. Do
       not add a box property to a rule whose only job is to annotate. */
}
/* The button form. (0,2,0) compounds, declared AFTER every variant and every size
   modifier so `btn btn-ghost qa-only` / `btn btn-outline qa-only` / a card action
   all land on the orange without !important — the same "compose, don't fork" shape
   as `.btn-card-action.btn-danger` above. The ink moves too: a 2px ring alone
   still reads as "a button with a border" at a glance, and the ticket asked for
   very obvious. */
.btn.qa-only,
.btn-card-action.qa-only {
    border-width: 2px;
    border-color: var(--qa-accent);
    color: var(--qa-accent-ink);
}
.btn.qa-only:hover,
.btn-card-action.qa-only:hover {
    background: var(--qa-accent-wash);
    border-color: var(--qa-accent-ink);
    color: var(--qa-accent-ink);
}
/* A QA-only PANEL (the "Where the time goes" splits) is a whole block, and a 2px
   ring round a full-width card is loud without being informative — it takes the
   accent as a left rule instead, the shape the app already uses for a hero, plus
   the pill on its head. Same class, so a screen still marks a QA surface with one
   word. */
.app-panel.qa-only,
.app-card.qa-only {
    border-left: 3px solid var(--qa-accent);
}
.app-panel.qa-only::after,
.app-card.qa-only::after { content: none; }
/* ⚠ THE PILL HANGS ON THE HEAD'S FIRST CHILD, NOT ON THE HEAD. `.app-panel-head`
   is `flex-direction: column; gap: 10px` (one child = one row, the 2026-07-24
   standard), so an `::after` on the head itself is a FLEX ITEM and lands on its
   own row 10px BELOW the title — a floating orange chip under the heading. Hung on
   the head's first child (`<h2>` on the three dashboards, `<span>` on the
   standards page) it is an inline box at the end of the heading text, which is
   where it belongs. `:first-child` rather than `h2` so both shapes are covered. */
.app-panel.qa-only > .app-panel-head > :first-child::after,
.app-card.qa-only > .app-card-head > :first-child::after {
    content: 'QA';
    display: inline-block;
    vertical-align: 2px;
    margin-left: 8px;
    padding: 0 5px;
    border-radius: 3px;
    background: var(--qa-accent-ink);
    color: #fff;
    font-size: 9.5px;
    font-weight: 800;
    line-height: 15px;
    letter-spacing: .6px;
    text-transform: uppercase;
    pointer-events: none;
}

/* Shared keyboard focus ring for every shared interactive control. :where()
   contributes ZERO specificity, so it never fights a variant / feature rule — it
   only adds a visible focus-visible outline where none was authored. */
:where(
    .btn, .btn-card-action, .app-rowbtn, .btn-clear-filters, .file-upload-btn,
    .app-grid-export, .app-grid-pgbtn, .app-grid-loadmore, .app-tab, .contact-tab,
    .app-select-trigger, .app-select-toggle-btn, .app-filter-toggle,
    .app-tile--link, .app-stat,
    .app-filter-head[role="button"], .modal-close, .alerts-close, .app-drill-back,
    .topbar-alerts-btn, .topbar-menu-btn, .user-btn, .menu-drawer-close,
    .menu-drawer-search-clear, .menu-drawer-section-toggle
):focus-visible {
    outline: 2px solid var(--control-focus);
    outline-offset: 2px;
}
/* Lighter ring for controls sitting on the dark topbar. */
:where(.topbar-back, .topbar-alerts-btn, .topbar-menu-btn, .user-btn):focus-visible {
    outline-color: var(--control-focus-dark);
}
/* Active (keyboard-highlighted) AppSelect option (see general.js AppSelect). */
.app-select-item--focus { background: #eef3fb; }

/* Button / tab / control labels NEVER wrap (operator standard, 2026-07-15) — a
   wrapped button label reads as unpolished. The .btn / .btn-card-action foundation
   already sets this; these are the OTHER shared clickable controls that need it.
   An over-long label just widens the control (and, if needed, the page — see the
   1200px floor). Feature-specific button/tab classes carry the rule themselves. */
.btn-card-action,
.btn-outline,
.btn-secondary,
.btn-link,
.app-grid-export,
.app-grid-pgbtn,
.app-grid-loadmore,
.app-tab,
.contact-tab,
.file-upload-btn,
.app-filter-toggle,
.radio-pill-text { white-space: nowrap; }

/* ---- Back Link ---- */
.back-link {
    font-size: 13px;
    color: var(--navy);
    text-decoration: none;
    font-weight: 500;
}
.back-link:hover { text-decoration: underline; }

/* ---- Desktop/Mobile Visibility ---- */
.mobile-only { display: none; }
.desktop-only { display: inline; }

/* ---- Empty State ---- */
.empty-state {
    text-align: center;
    padding: 80px 24px;
    color: #888;
    font-size: 15px;
    background: #fff;
    border-radius: 8px;
    border: 1px solid #d5d5d5;
}
.empty-state-icon { margin-bottom: 16px; }
.empty-state-icon svg { display: block; margin: 0 auto; }
.empty-state h3 {
    font-size: 17px;
    color: #555;
    font-weight: 600;
    margin-bottom: 8px;
}
.empty-state p { max-width: 420px; margin: 0 auto; line-height: 1.6; color: #888; font-size: 14px; }

/* ---- Collapsible Sections ---- */
.detail-sections { margin-bottom: 48px; }

.detail-section {
    background: #fff;
    border: 1px solid #d5d5d5;
    border-radius: 8px;
    margin-bottom: 12px;
    overflow: hidden;
}

.section-heading {
    display: flex;
    align-items: center;
    padding: 14px 20px;
    font-size: 14px;
    font-weight: 700;
    color: #1a1a2e;
    cursor: pointer;
    user-select: none;
    list-style: none;
    border-left: 4px solid var(--navy);
    background: #fafbfc;
}
.section-heading::-webkit-details-marker { display: none; }
.section-heading::before {
    content: "\25B6";
    font-size: 9px;
    color: #999;
    margin-right: 10px;
    transition: transform 0.15s ease;
    display: inline-block;
}
details[open] > .section-heading::before {
    transform: rotate(90deg);
}
.section-heading:hover { background: var(--page-bg); }

.section-body { padding: 16px 20px 20px; }

/* ---- Subsection Heading ---- */
.subsection-heading {
    font-size: 14px;
    font-weight: 700;
    color: var(--navy);
    margin: 20px 0 12px;
    padding-bottom: 6px;
    border-bottom: 1px solid #e0e0e0;
}

/* ---- Field Table ---- */
.field-table {
    width: 100%;
    border-collapse: collapse;
}
.field-table tr { border-bottom: 1px solid #f4f4f4; }
.field-table tr:last-child { border-bottom: none; }
.field-table td {
    padding: 7px 8px;
    vertical-align: top;
    font-size: 13px;
}
.field-table td.lbl {
    font-weight: 600;
    color: #555;
    width: 260px;
    white-space: nowrap;
}
.field-table td.val { color: #222; }

/* ---- Section Note ---- */
.section-note {
    background: #f8f9fb;
    border-left: 3px solid var(--navy);
    padding: 10px 14px;
    margin-bottom: 14px;
    font-size: 13px;
    color: #555;
    line-height: 1.5;
}
.section-note p { margin: 0 0 6px 0; }
.section-note p:last-child { margin-bottom: 0; }
.section-note a { color: var(--navy); }

/* ---- File Link ---- */
.file-link {
    color: var(--navy);
    text-decoration: none;
    font-weight: 500;
}
.file-link:hover { text-decoration: underline; }

/* ---- Form System ---- */
.form-row {
    margin-bottom: 16px;
}
.form-label {
    display: block;
    font-size: 13px;
    font-weight: 600;
    color: #444;
    margin-bottom: 5px;
}
.req-star {
    color: #c0392b;
    font-weight: 700;
}

.form-field {
    display: block;
    width: 100%;
    max-width: 520px;
    padding: 8px 12px;
    font-size: 13px;
    color: #222;
    background: #fff;
    border: 1px solid #ccc;
    border-radius: 4px;
    line-height: 1.4;
    /* Native inputs/selects do not inherit the body font — opt in so form
       fields (and the native <select> dropdown text) render in Manrope, not
       the browser default. */
    font-family: inherit;
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
}
.form-field:focus {
    outline: none;
    border-color: var(--navy);
}
.form-field[readonly] {
    background: #f5f6f8;
    color: #666;
}
select.form-field {
    max-width: 520px;
    padding-right: 28px;
    background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 10px center;
}
.form-textarea {
    resize: vertical;
    min-height: 60px;
}
.form-value-readonly {
    font-size: 13px;
    color: #555;
    padding: 8px 0;
    line-height: 1.4;
}
.form-help-text {
    font-size: 12px;
    color: #888;
    margin-top: 4px;
    line-height: 1.4;
}
/* A hint that carries operator/legacy markup can hold paragraphs — tighten them
   from the browser's 1em default (added 2026-07-24 with the .co-field-help
   retirement, which had the same rule). */
.form-help-text p { margin: 2px 0; }

/* ---- Field-group rule (.form-divider) ----
   A hairline between two groups of `.form-row`s INSIDE one section, for a long
   section whose fields answer two different questions and whose hints are long
   enough that the change of subject is otherwise invisible. Added 2026-07-30 for
   the New Capital Call form's Notification Details section (delivery answers
   above the rule, notice content below it).
     <hr class="form-divider">
   NOT a substitute for a section: when a group earns a heading it earns its own
   `.app-card` (app_standards.php#std-formsections). Reset from the UA's beveled
   default so it is one hairline in the shared token, and margin-collapsing is not
   in play (the neighbours are block rows with their own bottom margin). */
.form-divider {
    height: 0;
    margin: 4px 0 20px;
    border: 0;
    border-top: 1px solid var(--hairline);
}

/* ---- Choice rows (label-wraps-input radio / checkbox groups) ----
   THE shared radio/checkbox group inside a .form-row: a wrapping row of
   label-wraps-input choices, or a vertical stack with the --col modifier for a
   long multi-select. Promoted VERBATIM 2026-07-24 from the byte-identical
   audit_fs (.af-choice*) and onboarding (.co-choice*) copies — do NOT re-declare
   a per-feature choice row. Markup:
     <div class="app-choice-row [app-choice-row--col]">
       <label class="app-choice"><input type="radio" name="x" value="Yes"> Yes</label>
       …
     </div>
   Features whose row is deliberately different (a 3-column grid, a locked
   variant, per-status accents) keep their own class — the drifted
   .cc-radio-row / .wr-choice-row / .q-radio-row / .it-radio-row / .wt-radio-row
   set is unchanged. (`.ds-radio-row` left that list 2026-09-22: its base + stacked
   TILE shape is shared now, see "Stacked radio tiles" below.) (`.cdd-radio-row` was in that list until
   2026-08-25, when the Due Diligence respond form's Yes/No became a pair of
   `.cdd-choice` CARDS — the `.wr-choice` shape, not this row.) */
.app-choice-row {
    display: flex;
    flex-wrap: wrap;
    gap: 8px 18px;
    padding-top: 3px;
}
.app-choice-row--col { flex-direction: column; gap: 8px; }
.app-choice {
    display: inline-flex;
    align-items: flex-start;
    gap: 7px;
    font-size: 14px;
    color: #22303f;
    cursor: pointer;
}
.app-choice input { margin-top: 2px; }

/* ---- Choice CARDS — the same group, when each option needs explaining ----
   A bordered tile per option, carrying a short title and the consequence under
   it, one per line. Use this instead of `.app-choice` wherever the option cannot
   be labelled in two words: a decision whose options are sentences renders as a
   run of paragraphs 8px apart, which reads as one block of prose rather than as a
   set of things to choose between.

   PROMOTED 2026-08-26, as the shape's FIFTH consumer. The four that got here
   first are `.wr-choice` (wire_requests), `.bw-choice` (bulk_wire),
   `.mp-landing-option` (my_profile) and `.cdd-choice` (cdd_documents) — and
   cdd_documents.css says in as many words that the fourth consumer is where this
   should be promoted "not by re-skinning three families in a request about one
   screen". So: promoted here from that copy, used by the wire-template approval,
   and the four existing copies are LEFT ALONE. They are now forks of a shared
   component and each should converge onto this the next time its own screen is
   worked on; check_shared_css.php's `choice-row` rule already names them.

   Markup:
     <div class="app-choice-row app-choice-row--cards">
       <label class="app-choice app-choice--card">
         <input type="radio" name="x" value="…">
         <span class="app-choice-body">
           <span class="app-choice-title">Approve</span>
           <span class="app-choice-sub">What that means, in a sentence.</span>
         </span>
       </label>
       …
     </div>

   ONE COLUMN by default (a grid with no template), because "one option per line"
   is the reason to reach for a card at all — a consumer that genuinely wants them
   side by side declares its own `grid-template-columns`.
   `:has(input:checked)` paints the chosen card; general.css already relies on
   `:has()` for `body:has(nav.topbar)`, so it is settled vocabulary here. */
.app-choice-row--cards { display: grid; gap: 10px; }
.app-choice--card {
    display: flex;
    align-items: flex-start;
    gap: 10px;
    padding: 13px 15px;
    /* A CONTROL border, not a card hairline — the tile IS the control. The four
       feature copies each wrote #d0d5dd; the token is the same weight of line. */
    border: 1px solid var(--control-border);
    border-radius: 10px;
    background: #fff;
    transition: border-color .12s, box-shadow .12s, background .12s;
}
.app-choice--card:hover { border-color: #98a2b3; }
/* The keyboard ring goes on the CARD, because the radio it belongs to is the
   card's own leading control and a 16px ring inside a full-width card is
   invisible. */
.app-choice--card:focus-within { border-color: var(--navy); box-shadow: 0 0 0 3px rgba(15, 52, 96, .13); }
.app-choice--card input { flex: 0 0 auto; width: 16px; height: 16px; margin: 1px 0 0; }
.app-choice-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.app-choice-title { font-size: 14px; font-weight: 600; color: #1d2939; }
.app-choice-sub { font-size: 12.5px; line-height: 1.45; color: var(--text-muted); }
.app-choice--card:has(input:checked) {
    border-color: var(--navy);
    background: #f7f9fc;
    box-shadow: inset 0 0 0 1px var(--navy);
}
.app-choice--card:has(input:checked) .app-choice-title { color: var(--navy); }
/* An UNANSWERED required card group. AppFormErrors writes `.app-field-invalid` on
   the field row, and the generic cue for that (a red outline on every radio,
   "Invalid field" below) is a 16px ring inside each tile — easy to miss, because
   the TILE is the control. So the tile takes the cue and the dots do not. A
   CHECKED card keeps its navy (the `:has()` rule above out-specifies this one), the
   same "an answered option is not wrong" call the radio pills make. Added
   2026-09-23 with the Empery trade-allocation Fund tiles (ticket 868m8jz69). */
.app-field-invalid .app-choice--card { border-color: var(--field-error); background-color: var(--field-error-tint); }
.app-field-invalid .app-choice--card input[type="radio"] { outline: none; }

/* ---- Stacked radio TILES (.ds-radio-row / .ds-radio, --stack) ----
   A bordered full-width tile per option, one per line, the radio dot visible —
   the shape Digital Subs' long-option questions use. PROMOTED 2026-09-22 from
   digital_subs.css VERBATIM on its second consumer: the New Capital Call form's
   Override Attachment Handling, whose two options are whole sentences, asked for
   BY NAME (ticket 868m826de §8.3: "update the field styling from app-choice-row
   app-choice-row--col to ds-radio-row ds-radio-row--stack … consistent with other
   stacked radio button fields throughout the application"). The class names keep
   their `ds-` prefix because that is the name the operator uses for the shape and
   the name Digital Subs' three forms already render; renaming would only have
   moved the fork. Markup:
     <div class="ds-radio-row ds-radio-row--stack">
       <label class="ds-radio"><input type="radio" name="x" value="0"> <span>…</span></label>
       …
     </div>
   The CHECKED paint is `:has(> input:checked)` here, so a consumer needs no JS
   (the `.app-choice--card` precedent above). Digital Subs' `.is-checked` class —
   set by its wireRadioRows() — is kept as the other selector, and its segmented
   `.ds-radio-row--toggle` variant stays in digital_subs.css: that one has a single
   consumer. */
.ds-radio-row { display: flex; flex-wrap: wrap; gap: 8px; }
.ds-radio { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #d7dce4; border-radius: 8px; padding: 8px 12px; cursor: pointer; font-size: 13px; background: #fff; }
.ds-radio input { margin: 0; }
.ds-radio.is-checked { border-color: #2563eb; background: #eff6ff; }
/* Its OWN rule, never a second selector in the list above: a browser without :has()
   drops a whole selector list it cannot parse, and would take Digital Subs'
   long-standing .is-checked paint down with it (the same reason general.css keeps
   its other :has() rules apart). */
.ds-radio:has(> input:checked) { border-color: #2563eb; background: #eff6ff; }
.ds-radio-row--stack { flex-direction: column; flex-wrap: nowrap; align-items: stretch; gap: 6px; }
.ds-radio-row--stack .ds-radio { width: 100%; justify-content: flex-start; padding: 10px 12px; }
.ds-radio-row--stack .ds-radio:hover { border-color: #b9c4d4; background: #fbfdff; }

/* ---- Pick list (bordered, scrollable, searchable choice list) ----
   THE shared "choose from a bounded list of records" control: a scrolling bordered
   box of label-wraps-input rows (radios for single-pick, checkboxes for multi),
   with an optional search box above and a footer bar carrying a Select-all action
   and a live count. Use it wherever a native <select multiple> or a long
   `.app-choice-row--col` would otherwise appear — a multi-select is NOT an AppDrill
   case (see the drill-through rules), and a native multiple-select needs a
   Ctrl+click instruction no one reads.
   Promoted VERBATIM 2026-07-29 from data_rooms' `.dr-picker-*` (its add / bulk-add
   access picker) when capital_calls' Subsequent Investors field became the second
   consumer — do NOT re-declare a per-feature pick list. Markup:
     <div class="app-picklist-search"><input type="search" class="form-field" …></div>
     <div class="app-picklist">
       <label class="app-picklist-item" data-label="<lowercased haystack>">
         <input type="checkbox" name="x[]" value="…"><span>…</span></label>
       …
       <div class="app-picklist-empty">Nothing available.</div>
     </div>
     <div class="app-picklist-bar">
       <button type="button" class="app-picklist-selectall">Select all</button>
       <span class="app-picklist-count">3 of 11 selected</span>
     </div>
   The `[hidden]` companion is load-bearing: the search filter hides rows with the
   attribute, and the item's own `display: flex` would otherwise beat the UA rule.
   A feature whose rows carry extra detail composes on top of the item base
   (data_rooms' `.dr-room-item` is the reference). */
.app-picklist-search { margin-bottom: 10px; }
.app-picklist {
    max-height: 320px;
    overflow-y: auto;
    border: 1px solid #e3e8ee;
    border-radius: 8px;
    padding: 4px;
    background: #fff;
}
.app-picklist-item {
    display: flex;
    align-items: flex-start;
    gap: 9px;
    padding: 7px 9px;
    border-radius: 6px;
    font-size: 13px;
    color: #22313f;
    line-height: 1.4;
    cursor: pointer;
}
.app-picklist-item:hover { background: #f2f7fc; }
.app-picklist-item input { margin-top: 2px; flex: 0 0 auto; }
.app-picklist-item[hidden] { display: none !important; }
.app-picklist-empty { color: #6a7a8a; font-size: 13px; padding: 14px; text-align: center; }
.app-picklist-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    margin-top: 10px;
}
.app-picklist-count { font-size: 12.5px; color: #5b6b7c; }
.app-picklist-selectall {
    font-size: 12.5px;
    color: var(--navy);
    background: none;
    border: none;
    cursor: pointer;
    padding: 0;
    text-decoration: underline;
}

/* ---- Conditional-reveal hide helper ----
   The shared "this field is currently hidden by the form's reveal logic" class.
   `!important` because it must beat the component display rules of whatever it
   wraps (a .form-row, a .app-panel section, a callout). Promoted 2026-07-24 from
   the byte-identical .af-hidden / .co-hidden / .is-hidden copies. Use the native
   `hidden` attribute for markup that is hidden at RENDER time and toggled once;
   use this class for a field the page shows/hides repeatedly as answers change. */
.app-hidden { display: none !important; }

/* ---- Info tooltip (reusable label/field affordance) ----
   Discoverable "(i)" icon that reveals an explanatory bubble on hover, keyboard
   focus, or tap. Markup:
     <span class="app-tip" tabindex="0" role="note" aria-label="...explanatory text...">
        <svg class="app-tip-ico" ...>...</svg>
        <span class="app-tip-bubble" role="tooltip">...explanatory text...</span>
     </span>
   Keep aria-label and the bubble text identical so AT and sighted users match.
   NOTE: the bubble is position:absolute, so an ancestor with overflow:hidden
   (e.g. .detail-section) clips it. Place the trigger where there is room above
   and to the sides; for a trigger that can sit near a clipped edge, a
   JS-positioned tooltip is the more robust choice. */
.app-tip {
    position: relative;
    display: inline-flex;
    align-items: center;
    margin-left: 2px;
    padding: 3px;            /* roomier hover/tap target around the 14px glyph */
    color: #8b8ba3;
    cursor: help;
    vertical-align: middle;
}
.app-tip:hover,
.app-tip:focus { color: var(--navy); outline: none; }
.app-tip-ico { display: block; }
.app-tip-bubble {
    position: absolute;
    left: 50%;
    bottom: calc(100% + 9px);
    transform: translateX(-50%) translateY(4px);
    z-index: 50;
    width: 240px;
    max-width: 70vw;
    background: #1a1a2e;
    color: #e8e8ef;
    border: 1px solid #2d2d44;
    border-radius: 6px;
    padding: 9px 12px;
    font-size: 12px;
    font-weight: 400;
    line-height: 1.5;
    text-align: left;
    white-space: normal;
    /* THE BUBBLE IS PROSE, WHATEVER IT HANGS OFF. The trigger sits inside its
       label, so the bubble inherits that label's typography — and a read-only
       field's `<dt>` is uppercase and letter-spaced (.app-field dt), which
       rendered a whole explanatory sentence in caps: unreadable, and matching no
       other tooltip in the app. The bubble already re-declares size, weight,
       line-height and alignment for exactly this reason; these two close the
       last gap. */
    text-transform: none;
    letter-spacing: normal;
    box-shadow: 0 4px 14px rgba(0, 0, 0, .22);
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity .18s ease, transform .18s ease, visibility .18s;
}
.app-tip-bubble::after {
    content: '';
    position: absolute;
    top: 100%;
    left: 50%;
    margin-left: -6px;
    border: 6px solid transparent;
    border-top-color: #1a1a2e;
}
/* Reveal on hover/focus of the tip itself, and (convenience) when hovering the
   whole form label that contains a tip — so the user never has to land on the
   small icon or click to see it. */
.app-tip:hover .app-tip-bubble,
.app-tip:focus .app-tip-bubble,
.app-tip:focus-within .app-tip-bubble,
.form-label:hover .app-tip-bubble {
    opacity: 1;
    visibility: visible;
    transform: translateX(-50%) translateY(0);
}

/* ---- Rich tooltip variant ----
   For a bubble that carries BLOCK content (paragraphs, a definition list, a
   link) rather than one plain sentence — e.g. legacy field help that was
   authored as <ul><li><strong>Term</strong> - meaning</li></ul>. Wider, and the
   nested typography is reset so it doesn't inherit page margins.
     <span class="app-tip app-tip--rich" tabindex="0" role="note" aria-label="...plain-text equivalent...">
   Keep aria-label a plain-text flattening of the bubble so AT users get the
   same content. */
.app-tip--rich .app-tip-bubble { width: 320px; }
.app-tip--rich .app-tip-bubble p { margin: 0 0 7px; }
.app-tip--rich .app-tip-bubble p:last-child { margin-bottom: 0; }
.app-tip--rich .app-tip-bubble ul { margin: 0; padding-left: 15px; }
.app-tip--rich .app-tip-bubble ul + p { margin-top: 7px; }
.app-tip--rich .app-tip-bubble li { margin: 0 0 6px; }
.app-tip--rich .app-tip-bubble li:last-child { margin-bottom: 0; }
.app-tip--rich .app-tip-bubble strong { color: #fff; font-weight: 600; }
.app-tip--rich .app-tip-bubble a { color: #9ec5fe; text-decoration: underline; }
.app-tip--rich .app-tip-bubble a:hover,
.app-tip--rich .app-tip-bubble a:focus-visible { color: #d3e5ff; }

/* Left-anchored variant — for a trigger that sits near the left edge of the
   page column, where a 320px bubble centred on the 14px icon overhangs (the
   invitation form's "Class" label does, at the 1200px body min-width). Anchors
   the bubble's left edge to the trigger and re-seats the arrow under the icon
   instead of under the bubble's centre. */
.app-tip--start .app-tip-bubble {
    left: -10px;
    transform: translateX(0) translateY(4px);
}
.app-tip--start .app-tip-bubble::after { left: 20px; margin-left: -6px; }
.app-tip--start:hover .app-tip-bubble,
.app-tip--start:focus .app-tip-bubble,
.app-tip--start:focus-within .app-tip-bubble,
.form-label:hover .app-tip--start .app-tip-bubble { transform: translateX(0) translateY(0); }
/* NOTE (2026-07-29): a mirrored right-anchored `--end` variant was tried for the
   digital-subs "Populate Terms & Disclaimers…" tip, whose trigger trails a ~400px
   checkbox label, and was REVERTED. A static anchor cannot win there: that label
   wraps between 375px and 700px, so the icon swings from the right of the column
   to the left of it, and right-anchoring merely trades a 53px right overhang at
   560px for a 189px LEFT overhang at 375px (both measured). Sub-700px overhang is
   the shared tooltip's existing behaviour anyway — 10 of the 30 tips on that page
   do it today — so the tip was left on the plain centred model, consistent with
   all of them. The robust answer stays the JS-positioned tooltip noted above —
   shipped 2026-08-10 as the `--float` variant below. */

/* Floating variant — the bubble is placed in VIEWPORT coordinates, so NO
   ancestor overflow can clip it. Use it for a trigger inside a container that
   clips or SCROLLS, where neither the centred model nor `--start` can win:
   `overflow-y:auto` forces `overflow-x:auto` on the same box, so an absolutely
   positioned bubble is both cut off at the container's edge AND adds a
   horizontal scrollbar the user has to drag to read the text. The report
   builder's field rail is the reference — a 271px-wide scrolling rail hid 54px
   of every bubble and scrolled sideways to 340px to show it (measured).

     <span class="app-tip app-tip--float" tabindex="0" role="note" aria-label="…">

   Nothing to wire up per page: general.js hears the reveal (hover, focus, or a
   hover of the enclosing .form-label), writes `left`/`top` from the trigger's
   rect, clamps the bubble inside the viewport, flips it BELOW the icon when
   there is no room above (`.is-tip-below`), and re-seats the arrow under the
   icon (`--tip-arrow-x`). The bubble is fixed from the START — not switched on
   first hover — because a bubble that is out of flow from the beginning is what
   keeps it out of the scroll container's scrollWidth, i.e. what removes the
   stray scrollbar rather than just the clipping. */
.app-tip--float .app-tip-bubble {
    position: fixed;
    /* Parked off-screen until general.js writes the real coordinates: an
       unpositioned FIXED bubble would otherwise sit in a corner of the
       viewport, which is worse than not showing. */
    left: -9999px;
    top: -9999px;
    right: auto;
    bottom: auto;
    transform: translateY(4px);
    /* Above the 9000 modal band (a tip inside a modal must not be trapped
       under it) and the topbar (100); below appConfirm (9700). */
    z-index: 9300;
}
.app-tip--float:hover .app-tip-bubble,
.app-tip--float:focus .app-tip-bubble,
.app-tip--float:focus-within .app-tip-bubble,
.form-label:hover .app-tip--float .app-tip-bubble { transform: translateY(0); }
/* The arrow follows the ICON, not the bubble's centre — a bubble clamped
   against a viewport edge no longer sits centred under its trigger. */
.app-tip--float .app-tip-bubble::after { left: var(--tip-arrow-x, 50%); }
/* Flipped below the trigger: the arrow moves to the bubble's TOP edge. */
.app-tip--float .app-tip-bubble.is-tip-below::after {
    top: auto;
    bottom: 100%;
    border-top-color: transparent;
    border-bottom-color: #1a1a2e;
}

/* Interactive variant — the bubble holds something the user must be able to
   REACH (a link). Two things are needed beyond the plain tooltip:
     1. pointer-events on the revealed bubble, so the link is clickable;
     2. a transparent bridge across the 9px icon->bubble gap, so travelling to
        the link doesn't cross a dead zone and dismiss it.
   The bubble is a descendant of .app-tip, so hovering it keeps :hover true.
   Deliberately NOT wired to the `.form-label:hover` convenience reveal: the
   bubble must only swallow the pointer when the user aimed at the icon itself,
   never merely because the label underneath it happened to be hovered. */
.app-tip--interactive:hover .app-tip-bubble,
.app-tip--interactive:focus .app-tip-bubble,
.app-tip--interactive:focus-within .app-tip-bubble { pointer-events: auto; }
.app-tip--interactive .app-tip-bubble::before {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    top: 100%;
    height: 9px;
}
/* A `--float` bubble that flipped below its trigger needs the bridge on top,
   or the pointer crosses a dead zone on the way up to the link. */
.app-tip--interactive .app-tip-bubble.is-tip-below::before { top: auto; bottom: 100%; }

/* ---- Callout Banner (reusable) ---- */
.callout-banner {
    display: flex;
    align-items: flex-start;
    gap: 12px;
    padding: 14px 18px;
    margin-bottom: 16px;
    border: 1px solid #d5d5d5;
    border-left: 4px solid #888;
    border-radius: 6px;
    background: #f9f9f9;
    /* Fill (don't inflate) a grown page column. On a grid page the .page-wrap
       is sized to width:max-content so wide TABLES can page-scroll; under that
       intrinsic sizing a long callout paragraph would otherwise lay out on ONE
       line and drive the page width (a narrow-grid page then runs very wide).
       inline-size containment makes the banner contribute ~0 to the parent's
       max-content and instead fill the grid-driven column, so its text wraps.
       No-op on normal (definite-width) pages. Same technique as the bespoke
       grid-card inline-size firewall further below. */
    contain: inline-size;
}
.callout-banner-icon {
    flex-shrink: 0;
    color: #666;
    margin-top: 1px;
}
.callout-banner-content {
    min-width: 0;
}
.callout-banner-label {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: #666;
    margin-bottom: 3px;
}
.callout-banner-value {
    font-size: 15px;
    font-weight: 600;
    color: #1a1a2e;
    line-height: 1.4;
}
/* ---- COMPACT callout (the single-line message banner) ----
   The app-wide form-error / status-banner idiom is a callout whose ONLY child is
   the labelled span the page writes its message into:

     <div class="callout-banner callout-banner--error" id="xFormError" hidden>
       <span class="callout-banner-label" id="xFormErrorText"></span>
     </div>

   33 views ship this shape (every respond/edit form's validation banner, the
   audit_fs copy-confirmation, …). Without this rule the SENTENCE inherits the
   11px UPPERCASE letter-spaced -label typography, which is meant for a 1–2 word
   caption, so the message renders as unreadable small-caps with no icon.
   Recognised structurally with :only-child — a properly structured banner (icon +
   .callout-banner-content > -label + -value) puts the label inside -content, so
   it is NOT a direct-child-only label and keeps the caption look.
   Documented 2026-07-24; this is a deliberate, operator-approved restyle of those
   33 banners (they were previously all-caps 11px). Prefer the FULL structure for
   new banners that have a real caption; this compact form stays valid for a bare
   one-line message. */
.callout-banner > .callout-banner-label:only-child {
    font-size: 15px;
    font-weight: 600;
    text-transform: none;
    letter-spacing: normal;
    line-height: 1.4;
    margin-bottom: 0;
}

/* Optional bulleted list inside a callout (e.g. a "Scroll down to see" index). */
.callout-banner-list {
    margin: 6px 0 0;
    padding-left: 18px;
    font-size: 13.5px;
    color: var(--text-soft);
}
.callout-banner-list li { margin: 2px 0; }

/* ---- PROSE inside a callout ----
   README §2 makes .callout-banner the ONE shared FYI box, so it has to be able
   to carry a multi-paragraph notice — otherwise features keep inventing their own
   (.af-fyi / .co-fyi / .it-saveas-note …). These rules give bare <p>/<ul>/<ol> in
   the content column the same metrics as .callout-banner-list, tightened from the
   browser's 1em margins. Added 2026-07-24 with the audit_fs .af-fyi retirement.

   ⚠ <ol> JOINED THEM 2026-09-17 (ticket 868m6bfnj r1.4) AND HAD TO — it was named
   in this file's own retirement note for .ds-fyi and in digital_subs.css's, but no
   selector here ever listed it. The reset at the top of this sheet zeroes every
   element's padding, so an <ol> in a callout kept NONE: list-style-position is
   `outside` by default, which puts each marker in the padding box, and with no
   padding to sit in the numbers hung off the left edge of the text column, over
   the gap the flex `gap` leaves beside the icon. They also missed the 13.5px
   --text-soft metrics, so they were bigger and darker than the paragraphs they
   were numbering. The three cost lists on the Digital Feature Settings form were
   the only bare <ol>s in a callout in the tree, and they showed all of it at once.
   22px rather than <ul>'s 20px: a decimal marker is `n.`, wider than a bullet. */
.callout-banner-content > p,
.callout-banner-content > ul,
.callout-banner-content > ol {
    margin: 6px 0;
    font-size: 13.5px;
    color: var(--text-soft);
    line-height: 1.5;
}
.callout-banner-content > ul { padding-left: 20px; }
.callout-banner-content > ol { padding-left: 22px; }
.callout-banner-content > p:first-child,
.callout-banner-content > ul:first-child,
.callout-banner-content > ol:first-child { margin-top: 0; }
.callout-banner-content > p:last-child,
.callout-banner-content > ul:last-child,
.callout-banner-content > ol:last-child { margin-bottom: 0; }
.callout-banner-content li { margin: 2px 0; }
/* A numbered item usually carries a bold lead-in and then a sentence, so it wraps.
   Keep the wrapped lines under the text and clear of the marker. */
.callout-banner-content > ol li { padding-left: 2px; }
.callout-banner--warn {
    background: #fffbeb;
    border-color: #f0d77c;
    border-left-color: #d4a017;
}
.callout-banner--warn .callout-banner-icon { color: #b8860b; }
.callout-banner--warn .callout-banner-label { color: #92711a; }
.callout-banner--info {
    background: #eff6ff;
    border-color: #b3cde8;
    border-left-color: #2c5282;
}
.callout-banner--info .callout-banner-icon { color: #2c5282; }
.callout-banner--info .callout-banner-label { color: #2c5282; }
.callout-banner--success {
    background: #f0fdf4;
    border-color: #a3d5b3;
    border-left-color: #16a34a;
}
.callout-banner--success .callout-banner-icon { color: #16a34a; }
.callout-banner--success .callout-banner-label { color: #155724; }
.callout-banner--error {
    background: #fef2f2;
    border-color: #f5c6cb;
    border-left-color: #c0392b;
}
.callout-banner--error .callout-banner-icon { color: #c0392b; }
.callout-banner--error .callout-banner-label { color: #721c24; }
/* The native `hidden` attribute must win over the component's own `display`.
   Because `.callout-banner { display: flex }` is an author rule, it overrides
   the UA `[hidden] { display: none }` — so a `<div class="callout-banner"
   hidden>` (used as a toggle-on-error banner) would otherwise render VISIBLE
   (empty) on page load. Restore the hide. */
.callout-banner[hidden] { display: none !important; }

/* ---- The MESSAGE NOTE (.wr-note) ----
   A short block of guidance sitting inside a form, next to the control it is
   about: quieter than a `.callout-banner` (which announces a page-level state)
   and calmer than a `.pta-notice` (which warns). Written for the legacy
   `Opus_Message_FYI` family on the wire screens, hence the name — kept verbatim
   on promotion because it is already on two dozen call sites across
   wire_requests, and renaming those is a change of its own.

   PROMOTED from assets/wire_requests/wire_requests.css 2026-09-08: the investor
   transaction approval form asked for the same block above its Value Date field
   (ClickUp 868kxrm4c request 5), and a second feature must not load another
   feature's stylesheet to reach a component.

   Markup: <div class="wr-note"><p>…</p><p>…</p></div>  (+ --warn / --good). */
.wr-note {
    background: #f0f6ff; border: 1px solid #cfe1fb; color: #1d3a63;
    border-radius: 8px; padding: 10px 14px; margin: 0 0 12px; font-size: 13.5px;
}
.wr-note p { margin: 0 0 6px; }
.wr-note p:last-child, .wr-note ul:last-child { margin-bottom: 0; }
.wr-note ul { margin: 0 0 6px; padding-left: 20px; }
.wr-note--warn { background: #fffaeb; border-color: #fedf89; color: #93540a; }
.wr-note--good { background: #ecfdf3; border-color: #abefc6; color: #067647; }
.wr-note .btn { margin-top: 2px; }
/* Same reason as .callout-banner[hidden] above — an author `display` is not set
   here, but a note is routinely toggled and the guard travels with the block so
   a future layout rule cannot quietly un-hide one. */
.wr-note[hidden] { display: none !important; }
/* A note sitting in a DRILL / PICKER header block, where the container already
   supplies the gutters and the gap above the grid — so the note drops its own
   outer margin and nothing else. PROMOTED from wire_requests.css 2026-09-17
   (ticket 868m64c9u item 7.3): the Digital Subscriptions "Select a fee class"
   picker asked for exactly this pair of classes by name, and a second feature
   must not load another feature's stylesheet to reach a component — the same
   rule that brought `.wr-note` itself here. */
.wr-drill-note { margin: 0; }

/* ---- The operator SYSTEM NOTICE — a FLOATING DOCK in the bottom corner ----
   `viSymphony_System_Messages`, rendered by views/general/app_system_notice.php
   and included by views/general/app_topbar.php so that every screen carrying the
   shared chrome gets it (ClickUp 868m069pv, 2026-09-02; moved here by 868m1j9kz,
   2026-09-04).

   HISTORY, because this is the second move and the reason is the design. The
   notice was the teal top band of the shared footer; 868m069pv made it an
   in-flow `.callout-banner--info` directly below each screen's page heading.
   That is where the operator found it — "appearing above all grids everywhere",
   an announcement about, say, a wire cutoff wedged between every screen's title
   and its content. It is now a dock pinned to the bottom-right of the VIEWPORT:
   expanded when the page loads, collapsed to a round icon once the reader
   minimizes it, and absent entirely when no message is published.

   The panel is still the shared `.callout-banner--info` component plus
   `.app-sysnotice`, so its background, borders, label typography and prose
   metrics are the same ones `portal_alert_banner.php` and the login skin
   (`.login-sysnotice`, assets/login/login.css — unchanged, still in flow below
   the sign-in card) render. `.app-sysnotice-panel` adds only what floating
   costs: a definite width, a lift shadow, and room for the minimize control.

   THREE measured problems of the in-flow band that `position: fixed` ends:
     - it can no longer be pushed off screen. On a grid page
       `body:has(.app-grid…)` sizes the page column to `max-content` so a wide
       TABLE can page-scroll, and everything in `.page-wrap` grew with it: at a
       1280px viewport the band came out 1440px wide with its dismiss control at
       x=1425, i.e. unreachable without scrolling right. The
       `max-width: calc(100vw - 56px)` cap that bought that back is retired with
       the band.
     - it can no longer inflate that column either — an out-of-flow box
       contributes nothing to a parent's max-content size. (`.callout-banner`'s
       own `contain: inline-size` stays as the belt to this braces.)
     - it needs no placement script. The old module had to find each screen's
       heading block, and those differ across the tree (`.page-hero` on most,
       `.page-header` on a handful); the dock is positioned against the viewport
       and does not care what the page looks like. */
.app-sysnotice-dock {
    position: fixed;
    right: 22px;
    bottom: 22px;
    /* Above page content and the grid's floating header (20); BELOW the alerts
       drawer (1100) and the menu drawer (1201), and far below the modal band
       (9000+) — an operator notice must never sit over a dialog. */
    z-index: 1050;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 10px;
    /* Let clicks through the dock itself so the corner of the page is never a
       dead zone; the two controls take them back below. */
    pointer-events: none;
}
.app-sysnotice-panel,
.app-sysnotice-fab { pointer-events: auto; }

.app-sysnotice-panel {
    position: relative;                 /* anchors the minimize control */
    width: 360px;
    /* 44px = the dock's own 22px gutter, twice. Keeps the whole panel on screen
       in a narrow window without a media query. */
    max-width: calc(100vw - 44px);
    /* Clear of the minimize control at the top right. */
    padding: 15px 46px 15px 16px;
    margin-bottom: 0;                   /* `.callout-banner` ships 16px */
    box-shadow: 0 12px 30px rgba(15, 52, 96, 0.16);
    /* `visibility` is timed ASYMMETRICALLY, and it has to be. Opening flips
       it at 0s so the minimize control is focusable in the same tick as the
       click that revealed it — transitioned over .16s the button is still
       `visibility: hidden` when the module calls `.focus()`, which fails
       silently and drops focus to <body> (measured). Closing delays the flip
       by the length of the fade instead, so the fade is actually seen. */
    transition: opacity .16s ease, transform .16s ease, visibility 0s;
}
/* COLLAPSED. `visibility`, not `display`: the panel animates out, and being
   `hidden` it leaves the tab order and the accessibility tree with it. */
.app-sysnotice-dock.is-collapsed .app-sysnotice-panel {
    opacity: 0;
    visibility: hidden;
    transform: translateY(10px) scale(.98);
    pointer-events: none;
    transition: opacity .16s ease, transform .16s ease, visibility 0s linear .16s;
}
/* A long notice scrolls INSIDE the panel rather than growing up the viewport.
   Scoped to the dock — the login skin renders in flow and must not clip. */
.app-sysnotice-panel .app-sysnotice-text {
    max-height: 42vh;
    overflow-y: auto;
    overscroll-behavior: contain;
}

/* The collapsed representation: a round navy icon button. It is rendered ONLY in
   that state — the expanded panel carries its own minimize control, so the two
   are never on screen together and the corner holds one affordance at a time. */
.app-sysnotice-fab {
    width: 46px;
    height: 46px;
    flex: 0 0 auto;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: var(--navy);
    color: #fff;
    cursor: pointer;
    display: none;
    align-items: center;
    justify-content: center;
    box-shadow: 0 6px 18px rgba(15, 52, 96, 0.28);
    transition: background-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.app-sysnotice-dock.is-collapsed .app-sysnotice-fab { display: inline-flex; }
.app-sysnotice-fab:hover {
    background: #14477f;
    box-shadow: 0 8px 22px rgba(15, 52, 96, 0.34);
    transform: translateY(-1px);
}
.app-sysnotice-fab:focus-visible {
    outline: 2px solid var(--navy);
    outline-offset: 3px;
}

/* ---- The notice SKIN, shared with the login panel ----
   `.app-sysnotice` is what the dock panel and `.login-sysnotice` have in common:
   the stronger left rule, the rounder corner, the FILLED navy icon badge and the
   body-copy metrics. Padding, width and margin deliberately do NOT live here —
   they belong to the two hosts, one of which pins itself to the viewport and the
   other of which sits in flow under a 400px card. */
.app-sysnotice {
    border-left-width: 5px;
    border-radius: 9px;
}
.app-sysnotice .app-sysnotice-icon {
    /* Two-selector specificity, and later in source order than
       `.callout-banner--info .callout-banner-icon`, which sets the colour this
       badge has to invert. */
    width: 34px;
    height: 34px;
    border-radius: 50%;
    background: var(--navy);
    color: #fff;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    margin-top: 0;
}
.app-sysnotice .callout-banner-label {
    font-size: 11.5px;
    letter-spacing: 1px;
    margin-bottom: 4px;
}
.app-sysnotice-text {
    font-size: 13.5px;
    line-height: 1.55;
    color: var(--text-soft);
}
/* Operator-authored markup — give its paragraphs and lists the same metrics the
   shared callout gives its own prose, so a multi-paragraph notice stays tight. */
.app-sysnotice-text p { margin: 0 0 6px; }
.app-sysnotice-text p:last-child { margin-bottom: 0; }
.app-sysnotice-text ul,
.app-sysnotice-text ol { margin: 4px 0 6px; padding-left: 20px; }
.app-sysnotice-text li { margin: 2px 0; }
.app-sysnotice-text a { color: var(--navy); text-decoration: underline; }

/* MINIMIZE — the control that collapses the panel to the icon. It was a DISMISS
   × until 868m1j9kz, and the difference is not cosmetic: dismissing removed the
   notice for the rest of the page view and left nothing on screen to bring it
   back. The request asks for the icon to remain. */
.app-sysnotice-min {
    position: absolute;
    top: 10px;
    right: 10px;
    width: 26px;
    height: 26px;
    padding: 0;
    border: 0;
    border-radius: 6px;
    background: none;
    color: #4a6382;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    transition: background-color .15s ease, color .15s ease;
}
.app-sysnotice-min:hover {
    background: rgba(15, 52, 96, 0.08);
    color: var(--navy);
}
.app-sysnotice-min:focus-visible {
    outline: 2px solid var(--navy);
    outline-offset: 1px;
}

@media (max-width: 700px) {
    /* Phone: the panel spans the width as a bottom sheet. The dock stays
       right-aligned, so the collapsed icon still lands in the bottom corner. */
    .app-sysnotice-dock {
        right: 14px;
        left: 14px;
        bottom: 14px;
    }
    .app-sysnotice-panel {
        width: 100%;
        max-width: none;
        padding: 14px 42px 14px 14px;
    }
    .app-sysnotice-panel .app-sysnotice-text { max-height: 38vh; }
}
@media (prefers-reduced-motion: reduce) {
    .app-sysnotice-panel,
    .app-sysnotice-fab { transition: none; }
    /* Equal specificity to the collapsed rule above and LATER in the file,
       which is the whole reason this wins — a media query adds nothing to a
       selector's weight. */
    .app-sysnotice-dock.is-collapsed .app-sysnotice-panel {
        transition: none;
        transform: none;
    }
    .app-sysnotice-fab:hover { transform: none; }
}

/* ---- Form Actions ---- */
.form-actions {
    padding-top: 16px;
    border-top: 1px solid #eee;
    margin-top: 8px;
}

/* ---- The two-column FORM GRID (.app-formgrid) ----
   A run of `.form-row` cells laid out two-up on a wide surface and one-up below
   the shared 700px breakpoint. It exists because a form whose every field is a
   full-width row is TALL — the Digital Subscriptions invitation form ran to five
   screenfuls, with a 180px text box for a five-character minimum and five
   e-mail fields stacked one under the other (ticket 868m64c9u item 6.6).

   ⚠ IT IS FOR PAIRS THAT BELONG TOGETHER, not for halving a form. Put two
   fields side by side when a reader reads them as one question (the two Deal
   Names, a run of Authorized Users); leave a textarea, a pick-field and anything
   whose label wraps on `.app-formgrid-full`, which spans both columns. A field
   dropped into a cell FILLS it — the shared "dropdown/date must fill its cell"
   rule — so the max-widths `.form-field` and `.app-select` carry for the
   single-column case are released here and the cell decides the width.

   `.crm-form-grid` (assets/crm/crm.css) is the earlier, CRM-local twin of this,
   with the same two tracks and the same 700px collapse plus its own span / pair
   helpers. It predates this rule and is left where it is: converging it is a
   change to three CRM views with nothing to gain in this ticket. A THIRD copy
   should not be written — use this one. */
.app-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 22px; align-items: start; }
.app-formgrid-full { grid-column: 1 / -1; }
.app-formgrid > .form-row > .form-field,
.app-formgrid > .form-row > .app-select,
.app-formgrid > .form-row > .app-date-input { max-width: none; }
@media (max-width: 700px) {
    /* A media query adds NO specificity, so this restates the (0,1,0) base
       selector and wins on source order alone. */
    .app-formgrid { grid-template-columns: 1fr; }
}

/* ---- Modern form variant (.form-modern) ----
   The Auto-Pay add/edit form style, promoted VERBATIM from Opus Invoices (was
   .oi-form-modern, 2026-07-23): a left-aligned, readable form column with
   ALL-CAPS field labels; controls (switch / segmented toggle / searchable
   dropdowns / inputs) sit left at consistent widths. Put the class on the
   <form> — the plain .form-row vocabulary underneath is unchanged. A dialog
   host may lift the column cap (e.g. `.xx-dialog .form-modern { max-width:
   none; }`). Reference: views/opus_invoices/autopay_form_partial.php. */
.form-modern { max-width: 640px; }
.form-modern .form-label {
    display: block;
    text-transform: uppercase;
    letter-spacing: .6px;
    font-size: 11px;
    font-weight: 700;
    color: var(--text-soft);
    margin-bottom: 7px;
}
.form-modern .form-field { max-width: 420px; }
/* block + max-width so a searchable dropdown fills the field width (not just
   its label) and left-aligns like the text inputs. */
.form-modern .app-select { display: block; max-width: 420px; }
/* The pick-field too (2026-09-23): it "carries the same width cap" as a field
   (see .app-pickfield), and in this skin that cap is 420px, not the base 520 —
   so a picker came out 100px wider than the dropdown above it. The row form
   (a picker beside its Clear button) takes the same cap; its field already
   fills the row. A host that sizes a picker on purpose (.tr-wizard,
   .bw-table) is later in the cascade and keeps its own width. */
.form-modern .app-pickfield,
.form-modern .app-pickfield-row { max-width: 420px; }
.form-modern .app-switch-text {
    text-transform: uppercase;
    letter-spacing: .6px;
    font-size: 11px;
    font-weight: 700;
}

/* Compact numeric/short-value field cap (was .oi-field-narrow / .dr-field-narrow).
   The 2nd and 3rd selectors carry no new intent — they exist ONLY to outrank two
   more-specific base caps that would otherwise silently beat this single class and
   leave the field at the full column width: `.form-modern .form-field` (0,2,0,
   above) and `select.form-field` (0,1,1, in the base field block). Same trick, and
   same reason, as .app-date-input's `.form-modern` twin further down. Keep the three
   together: a narrow field inside a sectioned form is the COMMON case, and the
   failure is invisible in review — nothing breaks, the field just renders wide.
   (Measured 2026-07-30: without the .form-modern selector every narrow field on
   app_capital_calls.php?page=add, the Auto-Pay form and the standards page's own
   narrow demo rendered at 420px.) */
.form-field-narrow,
select.form-field-narrow,
.form-modern .form-field-narrow { max-width: 220px; }

/* ---- Account radio cards (.app-acct) ----
   The pick-an-account field format (radio + label + a right-aligned figure;
   the checked card is highlighted). Promoted VERBATIM from Opus Invoices (was
   .oi-acct*, 2026-07-23). Feature JS toggles .is-checked on the labels as the
   radio changes; --static is the read-only (no radio) presentation. Markup:
     <div class="app-acct-list">
       <label class="app-acct [is-checked]">
         <input type="radio" name="…" value="…">
         <span class="app-acct-label">Account name</span>
         <span class="app-acct-balance">Last Balance<br><strong>$1,234.56</strong></span>
       </label>
     </div>
   (bulk_wire's .bw-acct* is the drifted pre-promotion sibling — blue accent —
   left local on purpose; see the decisions log.) */
.app-acct-list { display: flex; flex-direction: column; gap: 8px; max-width: 520px; }
.app-acct {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 14px;
    border: 1px solid #d8dee6;
    border-radius: 8px;
    background: #fff;
    cursor: pointer;
    transition: border-color .12s, box-shadow .12s;
}
.app-acct:hover { border-color: #9fb6d4; }
.app-acct input { margin: 0; flex: 0 0 auto; }
.app-acct.is-checked { border-color: var(--navy); box-shadow: 0 0 0 2px rgba(15, 52, 96, .12); }
.app-acct-label { flex: 1 1 auto; min-width: 0; font-size: 13.5px; color: #1f2a44; }
.app-acct-balance { flex: 0 0 auto; text-align: right; color: #38445a; font-size: 12.5px; white-space: nowrap; }
.app-acct-balance strong { font-variant-numeric: tabular-nums; }
/* Read-only account card (no radio) — e.g. in a view modal. */
.app-acct--static { cursor: default; margin-top: 8px; }
.app-acct--static:hover { border-color: var(--navy); }
.save-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
    padding-top: 16px;
    border-top: 1px solid #eee;
    margin-top: 8px;
}

/* ---- Radio Pills ---- */
.radio-group {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
}
.radio-pill {
    display: inline-flex;
    cursor: pointer;
    margin: 0;
}
.radio-pill input[type="radio"] {
    position: absolute;
    opacity: 0;
    width: 0;
    height: 0;
}
.radio-pill-text {
    display: inline-block;
    padding: 6px 16px;
    font-size: 13px;
    font-weight: 500;
    color: #555;
    background: var(--page-bg);
    border: 1px solid #d0d0d0;
    border-radius: 4px;
    line-height: 1.4;
    white-space: nowrap;
}
.radio-pill input[type="radio"]:checked + .radio-pill-text {
    background: var(--navy);
    color: #fff;
    border-color: var(--navy);
}
.radio-pill:hover .radio-pill-text {
    border-color: #999;
}
/* STACKED variant, for a set whose labels are long enough that a wrapping row
   reads worse than a column — the `.app-choice-row--col` twin. */
.radio-group--col { flex-direction: column; align-items: flex-start; }
/* The radio itself is visually hidden (0x0, opacity 0), so keyboard focus had
   NOTHING to show — tabbing through a pill group moved an invisible caret. The
   ring goes on the pill, which is the control as far as the user is concerned;
   same token and offset as the shared control-focus band above. */
.radio-pill input[type="radio"]:focus-visible + .radio-pill-text {
    outline: 2px solid var(--control-focus);
    outline-offset: 2px;
}

/* ---- Lookup Fields ---- */
.lookup-field {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    padding: 6px 10px;
    border: 1px solid transparent;
    border-radius: 6px;
    transition: border-color .15s, background-color .15s;
}
/* The one WRAPPER that takes the invalid-control state (the state normally
   lands on a control) — the Validation section's message-slot rules exclude
   `.lookup-field` for exactly this reason. */
.lookup-field.form-field-error {
    border-color: var(--field-error) !important;
    background-color: var(--field-error-tint) !important;
}
.lookup-display {
    font-size: 14px;
    color: #222;
    font-weight: 500;
    min-height: 20px;
}
.lookup-display:empty::before {
    content: "None selected";
    color: #aaa;
    font-weight: 400;
    font-style: italic;
}
.lookup-btn { flex-shrink: 0; }
.lookup-clear-btn {
    font-size: 18px;
    line-height: 1;
    padding: 2px 6px;
    color: #c00;
}

/* ---- AppCopy (shared copy-to-clipboard field) ----
   A readonly field whose value the user has to move somewhere else (a secure
   link, an API key). The button sits INSIDE the field's box on desktop, which
   is what keeps a long credential on one line and the affordance next to the
   value it copies. Behaviour is window.AppCopy in general.js. */
.app-copy-row {
    position: relative;
    display: block;
    width: 100%;
}
/* Two-class selector ON PURPOSE: it has to outrank BOTH `.form-field`'s
   `max-width: 520px` (0,1,0) and `.form-modern .form-field`'s 420px (0,2,0,
   declared above at :1274) — a truncated 40-character key with a Copy button
   next to it is unreadable. Same trap as the .form-field-narrow twin. */
.app-copy-row .app-copy-input {
    width: 100%;
    max-width: none;
    /* Room for the absolutely-positioned button (84px min-width + 4px offset),
       plus a gap so the value ellipsises BEFORE the divider instead of touching
       it. Keep in step with .app-copy-btn's min-width. */
    padding-right: 98px;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 12.5px;
    text-overflow: ellipsis;
}
/* The button reads as a SEGMENT of the field, not a box sitting on top of one.
   A filled+bordered .btn inside a bordered input gives two nested boxes; here the
   fill and the outer border are dropped and a single divider rule separates the
   action from the value. Only the .btn base (flex/gap/type/transition) is kept. */
.app-copy-row .app-copy-btn {
    position: absolute;
    top: 4px;
    right: 4px;
    bottom: 4px;
    z-index: 1;
    /* Both label states must occupy the SAME width: the button is right-anchored,
       so a wider label would expand leftward across the value it just copied.
       Sized for the LONGER state — measured on QA: "Copy" + clipboard glyph is
       70.13px, "Copied" + check is 81.63px, so anything under 82 still jitters. */
    min-width: 84px;
    min-height: 0;
    padding: 0 10px;
    background: transparent;
    border: 0;
    border-left: 1px solid #e3e8ee;
    border-radius: 0 4px 4px 0;
    color: var(--navy);
    font-size: 12px;
    letter-spacing: .1px;
}
.app-copy-row .app-copy-btn:hover {
    background: #eef3f9;
    color: var(--navy);
}
.app-copy-row .app-copy-btn:active { background: #e2eaf4; }
.app-copy-ico { flex-shrink: 0; opacity: .85; }

/* Confirmed. Green is the shared pill-green family, so the success cue matches
   every other "this worked" surface in the app. */
.app-copy-row .app-copy-btn.is-copied,
.app-copy-row .app-copy-btn.is-copied:hover {
    background: var(--pill-green-bg);
    border-left-color: var(--pill-green-bd);
    color: var(--pill-green-fg);
}
.app-copy-row .app-copy-btn.is-copied .app-copy-ico { opacity: 1; }

/* Nothing to copy (the target field is empty — AppCopy.sync() sets this).
   aria-disabled, not [disabled]: the control stays focusable and announced. */
.app-copy-row .app-copy-btn[aria-disabled="true"] {
    color: #9aa4b1;
    cursor: default;
}
.app-copy-row .app-copy-btn[aria-disabled="true"]:hover,
.app-copy-row .app-copy-btn[aria-disabled="true"]:active { background: transparent; }
.app-copy-row .app-copy-btn[aria-disabled="true"] .app-copy-ico { opacity: .5; }
/* Touch: stop stealing the field's tap target — stack the button underneath at
   full width and give the value its whole line back. No base-rule source-order
   tie to lose here (these are new class names), so the query lives with the
   component instead of at end-of-file. */
@media (max-width: 700px) {
    .app-copy-row .app-copy-input { padding-right: 12px; }
    /* Stacked below the field there is no field edge to divide, so the segment
       look is wrong here — go back to an ordinary outlined touch button. */
    .app-copy-row .app-copy-btn {
        position: static;
        display: flex;
        width: 100%;
        min-width: 0;
        margin-top: 8px;
        min-height: var(--control-h-touch);
        background: #fff;
        border: 1px solid var(--control-border);
        border-radius: var(--control-radius);
        font-size: var(--control-font-md);
    }
    .app-copy-row .app-copy-btn:hover { background: #f5f7fa; }
    .app-copy-row .app-copy-btn.is-copied,
    .app-copy-row .app-copy-btn.is-copied:hover {
        background: var(--pill-green-bg);
        border-color: var(--pill-green-bd);
    }
    .app-copy-row .app-copy-btn[aria-disabled="true"] { background: #fff; }
}

/* ---- File Uploads ---- */
.file-upload-area {
    display: flex;
    flex-direction: column;
    gap: 6px;
}
.form-field-file {
    font-size: 0;
    width: 0;
    height: 0;
    overflow: hidden;
    position: absolute;
    opacity: 0;
}
.file-upload-btn {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 7px 16px;
    font-size: 13px;
    font-weight: 500;
    color: #2962cc;
    background: #f0f5ff;
    border: 1px dashed #a0bfee;
    border-radius: 5px;
    cursor: pointer;
    transition: background 0.15s, border-color 0.15s;
    width: fit-content;
}
.file-upload-btn:hover {
    background: #e2edff;
    border-color: #6e9fe0;
}
.file-upload-btn svg {
    width: 16px;
    height: 16px;
    fill: currentColor;
}
.file-upload-hint {
    font-size: 12px;
    color: #8a93a0;
}
.file-upload-area.drag-over,
.form-row.drag-over {
    outline: 2px dashed #6e9fe0;
    outline-offset: 3px;
    background: #f0f5ff;
    border-radius: 6px;
}
.file-upload-area.drag-over .file-upload-btn,
.form-row.drag-over .file-upload-btn {
    background: #e2edff;
    border-color: #6e9fe0;
}
.file-upload-area.drag-over .file-upload-hint,
.form-row.drag-over .file-upload-hint {
    color: #2962cc;
}

.upload-file-list {
    margin-bottom: 4px;
}
.upload-file-list:empty {
    display: none;
}
.upload-file-item {
    display: flex;
    align-items: center;
    padding: 7px 10px;
    margin-bottom: 4px;
    background: #f8f9fb;
    border: 1px solid #e2e5ea;
    border-radius: 5px;
    font-size: 13px;
    transition: background 0.12s;
}
.upload-file-item:hover {
    background: var(--hairline-soft);
}
.upload-file-item .file-icon {
    margin-right: 8px;
    color: #6b7a8d;
    flex-shrink: 0;
}
.upload-file-item .file-name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: #333;
}
.upload-file-item .file-remove {
    background: none;
    border: none;
    color: #aab2bd;
    cursor: pointer;
    font-size: 18px;
    font-weight: 400;
    padding: 0 2px;
    margin-left: 10px;
    line-height: 1;
    transition: color 0.15s;
}
.upload-file-item .file-remove:hover {
    color: #c33;
}
.upload-file-item.existing-file .file-name a {
    color: #2962cc;
    text-decoration: none;
}
.upload-file-item.existing-file .file-name a:hover {
    text-decoration: underline;
}
.upload-file-item.pending-file .file-name {
    color: #555;
    font-style: italic;
}
.upload-file-item.pending-file .file-name::after {
    content: ' (pending upload)';
    font-size: 11px;
    color: #999;
    font-style: italic;
}

/* -- Rejection notice (bad file type, or an unsupported character in the name).
   Built by AppFileUpload, one per `.file-upload-area`, replaced rather than
   stacked. It used to carry its colours in an inline `style.cssText` inside
   file_upload.js with its own hard-coded reds; it grew a second variant on
   2026-09-09 (ticket 868m3b3dk) and the styling came here, onto the shared
   --field-error tokens, so an attachment rejection looks like every other
   invalid field in the portal. `.file-type-error` is kept alongside as a
   second class on the same element: it is the name the module shipped with
   and resetPendingFiles() and existing callers still query for it. */
.file-upload-error {
    margin-top: 6px;
    padding: 8px 10px;
    border: 1px solid var(--field-error);
    border-radius: 6px;
    background: var(--field-error-tint);
    color: var(--field-error-text);
    font-size: 12px;
    line-height: 1.5;
}
.file-upload-error strong { color: var(--field-error-text); }
.file-upload-error-list {
    margin: 4px 0 0;
    padding-left: 18px;
}
.file-upload-error-list li { margin-top: 2px; }
/* The rejected name, so the eye can find it in the sentence. `break-all`
   because a file name has no spaces to wrap at and this box sits inside
   narrow form columns and an 1100px dialog alike. */
.file-upload-error-name {
    font-weight: 600;
    word-break: break-all;
}
.file-upload-error-hint {
    display: block;
    margin-top: 6px;
    color: #666;
}

/* ---- Checkbox List ---- */
.checkbox-list {
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 8px 12px;
    max-height: 200px;
    overflow-y: auto;
    background: #fff;
}
.checkbox-list label {
    display: block;
    padding: 4px 0;
    font-size: 13px;
    cursor: pointer;
}
.checkbox-list label:hover {
    background: #f5f7fa;
}
.checkbox-list input[type="checkbox"] {
    margin-right: 8px;
}
.checkbox-list-loading {
    color: #999;
    font-size: 13px;
    padding: 6px 0;
}

/* ---- Validation ----------------------------------------------------------
   `.form-field-error` carries TWO roles. They never collide, because the state
   always lands on a CONTROL and the message is always a bare <div>:

     1. INVALID CONTROL — the class on the input / select / textarea (or on the
        `.lookup-field` wrapper — see Lookup Fields above), written by the
        shared AppFormErrors engine, not by feature JS (see below).
        Red border + a whisper of red fill, plus a soft red focus halo so the
        field still reads as focused while the user is fixing it (the navy
        focus border would read as "this is fine now").

     2. MESSAGE SLOT — <div class="form-field-error" data-for="Field"></div>,
        shipped EMPTY in the markup on every form; JS writes `textContent` into
        it and clears it on the next submit. Filled, it is an alert glyph + one
        red line under the field; empty, it collapses to nothing.

   Since 2026-08-19 role 2 DRIVES role 1: the shared **AppFormErrors** engine
   (general.js) watches every message slot and writes the invalid state onto
   that field's control(s) for you — this class, `.app-select--error` on an
   AppSelect root, `.form-field-error-file` on a file drop area, `.app-field-invalid`
   on the row — then scrolls to and focuses the first invalid field after a save
   attempt. Feature JS only ever writes the MESSAGE. Do not re-implement either
   half locally; `.field-error` / `.cc-field-error` / `.ds-field-error` were
   per-feature aliases of this slot and are retired.

   Palette: the four --field-error-* tokens (see :root) — change reds THERE.
   `.validation-summary` deliberately stays a heavier plate: the form-level
   summary and the per-field line should read as different altitudes.
   (Polished 2026-07-29 — the slot used to inherit the invalid-CONTROL rule, so
   a message rendered as body-coloured text on a full-width pink strip with no
   padding. The per-feature patches that worked around that are retired.) */
.validation-summary {
    background: var(--field-error-tint);
    border: 1px solid #fca5a5;
    border-left: 4px solid var(--field-error);
    border-radius: 6px;
    padding: 14px 18px;
    margin-bottom: 20px;
    font-size: 13px;
    color: #991b1b;
}
.validation-summary strong { display: block; margin-bottom: 6px; }
.validation-summary ul { margin: 0; padding-left: 18px; }
.validation-summary li { margin-bottom: 2px; }

/* -- 1. Invalid control -- */
.form-field-error:not(div),          /* input / select / textarea */
.form-field.form-field-error,        /* …that also carries .form-field */
.form-field.is-invalid {
    border-color: var(--field-error) !important;
    background-color: var(--field-error-tint) !important;
    transition: border-color var(--control-transition), box-shadow var(--control-transition);
}
.form-field-error:not(div):focus,
.form-field.form-field-error:focus,
.form-field.is-invalid:focus {
    outline: none;
    box-shadow: 0 0 0 3px var(--field-error-ring);
}
/* AppSelect-enhanced field: the native select is hidden, so the state goes on
   the trigger via .app-select--error on the widget root (feature JS toggles it
   alongside .form-field-error). */
.app-select--error .app-select-trigger,
.app-select--error .app-select-trigger:hover {
    border-color: var(--field-error) !important;
    background-color: var(--field-error-tint) !important;
}
.app-select--error.open .app-select-trigger,
.app-select--error .app-select-trigger:focus-visible {
    box-shadow: 0 0 0 3px var(--field-error-ring);
}
/* A `.radio-pill` GROUP. `.app-field-invalid` is what AppFormErrors writes on
   the field wrapper, and the generic `input[type="radio"] { outline }` cue below
   is invisible on a pill group — the input it outlines is 0x0 and transparent by
   design. So the state goes on the pill. `.radio-group.is-invalid` predates the
   shared engine (no engine writes it; bulk_wire's own `is-invalid` is on inputs)
   and is kept as the alias. A CHECKED pill is deliberately left navy: it is an
   ANSWERED option, and a red wash over it would read as "this answer is wrong"
   rather than "this question still needs an answer". */
.app-field-invalid .radio-pill-text,
.radio-group.is-invalid .radio-pill-text {
    border-color: var(--field-error);
    background-color: var(--field-error-tint);
}
.app-field-invalid input[type="radio"]:checked + .radio-pill-text,
.radio-group.is-invalid input[type="radio"]:checked + .radio-pill-text {
    border-color: var(--navy);
    background-color: var(--navy);
}
/* AppSelect's SEGMENTED TOGGLE variant has no trigger button, so the rule above
   painted nothing on it — and a required toggle (New Capital Call: Expenses to
   Call, Notice Owner, Reminder Email) showed a red message under an untouched
   control. The state rings the whole group instead. */
.app-select-toggle.app-select--error {
    border-color: var(--field-error);
    background-color: var(--field-error-tint);
    box-shadow: 0 0 0 3px var(--field-error-ring);
}
.app-select-toggle.app-select--error .app-select-toggle-btn { border-left-color: var(--field-error); }

/* -- 1b. Invalid field, when the CONTROL is not an input --
   `.app-field-invalid` lands on the field WRAPPER (`.form-row` / `[data-field]`
   / `[data-co-field]`) and is written by AppFormErrors alongside the message.
   It exists for the fields whose control is not something the invalid-control
   rule can reach: a radio / checkbox group, the shared pick list, a "Browse…"
   pick button. Everything here is a CUE on the real control — never a tint on
   the whole row, which would read as a disabled section. */
.app-field-invalid input[type="radio"],
.app-field-invalid input[type="checkbox"] {
    outline: 2px solid var(--field-error);
    outline-offset: 2px;
}
.app-field-invalid .app-picklist,
.app-field-invalid .app-picklist-search .form-field {
    border-color: var(--field-error);
    background-color: var(--field-error-tint);
}
/* …but NOT the pick list's own rows. A required pick list can hold a hundred
   items, and ringing every one of them turns "choose an investor" into a wall
   of red. The bordered list IS the control; its rows are its value. */
.app-field-invalid .app-picklist input[type="radio"],
.app-field-invalid .app-picklist input[type="checkbox"] { outline: none; }
/* A control-shaped button (the wire_requests "Browse" pick field). `.btn` is
   excluded: an ACTION button inside an invalid row is not itself invalid. */
.app-field-invalid > button:not(.btn) {
    border-color: var(--field-error) !important;
    background-color: var(--field-error-tint) !important;
}
/* The <input type="file"> is visually hidden, so this rings its CONTAINER (the
   drop area) — a ring rather than an outline, so it follows that element's own
   rounding instead of drawing a hard rectangle around it. */
.form-field-error-file {
    border-radius: 6px;
    box-shadow: 0 0 0 1.5px var(--field-error), 0 0 0 5px var(--field-error-ring);
}

/* -- 2. Message slot -- */
div.form-field-error:not(.form-field):not(.lookup-field) {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    margin-top: 6px;
    font-size: 12px;
    font-weight: 600;
    line-height: 1.45;
    color: var(--field-error-text);
}
/* The glyph is MASKED from the text colour rather than drawn with a hex baked
   into the data URI, so it follows --field-error-text (and any page that
   overrides the message colour) for free. */
div.form-field-error:not(.form-field):not(.lookup-field)::before {
    content: "";
    flex: 0 0 auto;
    width: 13px;
    height: 13px;
    margin-top: 1px;
    background-color: currentColor;
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9.5'/%3E%3Cpath d='M12 7.2v6'/%3E%3Cpath d='M12 16.8h.01'/%3E%3C/svg%3E") center / 13px 13px no-repeat;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9.5'/%3E%3Cpath d='M12 7.2v6'/%3E%3Cpath d='M12 16.8h.01'/%3E%3C/svg%3E") center / 13px 13px no-repeat;
}
/* An empty slot must leave NO trace — every form ships one per field and most
   of them never fill. !important is load-bearing twice over: the flex rule
   above is the more specific selector, and it also lets an explicit [hidden]
   win over display:flex. */
div.form-field-error:empty,
div.form-field-error[hidden] { display: none !important; }

/* ---- Info Blocks ---- */
.section-info-block {
    background: #f0f4ff;
    border: 1px solid #c9d6f0;
    border-radius: 6px;
    padding: 14px 18px;
    margin-bottom: 18px;
    font-size: 13px;
    line-height: 1.6;
    color: #333;
}
.section-info-block p { margin: 0 0 8px; }
.section-info-block p:last-child { margin-bottom: 0; }
.section-info-block ul { margin: 4px 0 8px; }
.section-info-block li { margin-bottom: 2px; }
.section-info-block a { color: #4a6fa5; text-decoration: underline; }
.section-info-block-elevated {
    background: #fff8e1;
    border-color: #e8d48a;
}

.section-sub-heading {
    font-size: 14px;
    font-weight: 600;
    color: #1a1a2e;
    margin: 0 0 14px;
    padding-bottom: 6px;
    border-bottom: 1px solid #e0e0e0;
}

/* ---- Context Banners ---- */
.context-banner {
    display: flex;
    align-items: flex-start;
    gap: 12px;
    background: #f0f4ff;
    border: 1px solid #c9d6f0;
    border-left: 4px solid #4a6fa5;
    border-radius: 6px;
    padding: 14px 18px;
    margin-bottom: 24px;
}
.context-banner-icon {
    flex-shrink: 0;
    color: #4a6fa5;
    margin-top: 1px;
}
.context-banner-body {
    flex: 1;
    min-width: 0;
}
.context-banner-line {
    font-size: 13.5px;
    color: #1a1a2e;
    line-height: 1.6;
}
.context-banner-line .context-label {
    font-weight: 600;
    color: #333;
}
.context-banner-note {
    font-size: 12px;
    color: #667;
    margin-top: 6px;
    line-height: 1.5;
    font-style: italic;
}
.context-banner-link {
    display: inline-block;
    margin-top: 8px;
    font-size: 13px;
    font-weight: 600;
    color: #4a6fa5;
    text-decoration: none;
}
.context-banner-link:hover {
    color: #2d4f7e;
    text-decoration: underline;
}

/* ---- Modals ---- */
.modal-overlay {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 9000;
    background: rgba(0, 0, 0, .45);
    justify-content: center;
    align-items: center;
}
.modal-overlay.open { display: flex; }
.modal-dialog {
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 8px 30px rgba(0,0,0,.25);
    width: 560px;
    max-width: 94vw;
    max-height: 85vh;
    display: flex;
    flex-direction: column;
    animation: modalIn .2s ease;
}
@keyframes modalIn {
    from { opacity: 0; }
    to   { opacity: 1; }
}
/* ---- "this one closes on purpose" nudge (AppModal, assets/general/general.js)
   A click on the backdrop no longer dismisses a dialog anywhere in the portal:
   it used to throw away whatever was typed into an add/edit form. But a click
   that does nothing at all reads as a frozen page, so the dialog answers with
   one short ring pulse that takes the eye to the close button. AppModal puts
   this class on the dialog CARD, whichever family it belongs to, and takes it
   off again — so it is deliberately a lone class with no descendant selectors.

   A ring, drawn with `outline`, and never a transform or a filter: either of
   those makes the dialog the containing block for a position:fixed descendant,
   and `.app-tip--float` places its bubble in VIEWPORT coordinates (general.js),
   so a tip open over the dialog would jump for the length of the animation.
   `outline` also leaves each family's own box-shadow alone. */
.app-modal-nudge {
    outline: 0 solid rgba(15, 52, 96, 0);       /* --btn-primary-bg, the app navy */
    animation: appModalNudge .32s ease-out;
}
@keyframes appModalNudge {
    0%   { outline-width: 0;   outline-offset: 0;   outline-color: rgba(15, 52, 96, .55); }
    40%  { outline-width: 4px; outline-offset: 3px; outline-color: rgba(15, 52, 96, .45); }
    100% { outline-width: 0;   outline-offset: 0;   outline-color: rgba(15, 52, 96, 0); }
}
/* Reduced motion still owes the reader an answer, so the pulse becomes a static
   ring for the same moment rather than nothing at all. */
@media (prefers-reduced-motion: reduce) {
    .app-modal-nudge {
        animation: none;
        outline: 3px solid rgba(15, 52, 96, .45);
        outline-offset: 3px;
    }
}
.modal-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 16px 20px;
    border-bottom: 1px solid #e8e8e8;
}
.modal-header h2 { font-size: 16px; font-weight: 600; margin: 0; }
.modal-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 28px;
    height: 28px;
    background: none;
    border: none;
    border-radius: 6px;
    font-size: 22px;
    color: #888;
    cursor: pointer;
    padding: 0 4px;
    line-height: 1;
}
.modal-close:hover { color: #333; }
.modal-body {
    padding: 20px;
    overflow-y: auto;
    position: relative;
}
.modal-body .doc-row {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    padding: 12px 0;
    border-bottom: 1px solid #f0f0f0;
}
.modal-body .doc-row:last-child { border-bottom: none; }
.modal-body .doc-label {
    font-weight: 500;
    color: #555;
    min-width: 160px;
    flex-shrink: 0;
}
.modal-body .doc-value { color: #333; }
.modal-body .doc-value a { color: #1a73e8; text-decoration: none; }
.modal-body .doc-value a:hover { text-decoration: underline; }
.modal-body .doc-empty { color: #999; font-style: italic; }

.modal-md {
    width: 640px;
    max-width: 94vw;
}
.modal-lg {
    width: 840px;
    max-width: 94vw;
    max-height: 90vh;
}
/* The PAGE tier — a dialog hosting a record whose own page is `.page-narrow`.
   1100px is not a new number: it is that page's cap ("Layout widths" above)
   reused as a dialog width, so the record lays out in the dialog exactly as it
   does on its page. Between the two tiers either side of it: `.modal-lg` puts a
   record's one embedded grid behind a horizontal scrollbar, and `.modal-xl` is
   for a record whose payload is several WIDE grids. Author `size: 'page'` on
   AppRecordDialog. First adopter: the Capitalization Table record (2026-09-24),
   whose History grid scrolled sideways at 840. The height is lg's 90vh ON
   PURPOSE, so the shared `.app-recdlg .modal-body` scroll cap below needs no
   per-tier twin. Also on it: the two client_invoicing dossier dialogs, the fee
   agreement and the Admin Fee True-Up fund, which reached 1100px by overriding
   `.modal-xl` locally until they moved here the same day. */
.modal-page {
    width: 1100px;
    max-width: 94vw;
    max-height: 90vh;
}
/* The WIDE tier — a dialog hosting a whole record page, where the payload is
   grids and a figures band rather than a form. 1750px is not a new number: it is
   the app's wide-page cap (`.page-wrap`, "Layout widths" above) reused as a
   dialog width, the same reuse `.app-drill-dialog--xwide` already makes for a
   genuinely wide drill table. Author `size: 'xl'` on AppRecordDialog rather than
   a per-feature width — the modal tiers are shared (CLAUDE.md §4).
   96vw, not 94: at this width the two percentage points are ~30px of table, and
   nothing here needs the backdrop margin a form dialog uses to look inset. */
.modal-xl {
    width: 1750px;
    max-width: 96vw;
    max-height: 92vh;
}
/* The SHEET modifier — any tier above, but the WHOLE SCREEN on a phone. Put
   `modal-sheet` on the dialog and `modal-overlay--sheet` on its overlay. Above
   700px it changes nothing, so the dialog keeps its tier's width and height.
   Promoted here with its second user (2026-10-01): the document viewer
   (`.docv-dialog` / `.docv-overlay`, below) was the first and still carries its
   own copy; the "What's New" introduction (app_whats_new.php) is the second.
   `100dvh` on the overlay is the same iOS fix the doc viewer documents: `inset: 0`
   resolves against the LARGE viewport, so with Safari's bars showing the bottom
   of a full-height dialog — its action row — would sit behind them. A browser
   without `dvh` ignores the declaration and keeps `inset: 0`. Two classes on the
   dialog rule, so it outranks the shared `.modal-dialog { width: 95% }` phone
   rule wherever that sits in this file. */
@media (max-width: 700px) {
    .modal-overlay.modal-overlay--sheet {
        height: 100dvh;
        align-items: stretch;
    }
    .modal-dialog.modal-sheet {
        width: 100%;
        height: 100%;
        max-width: none;
        max-height: none;
        border-radius: 0;
    }
}
.modal-loading {
    text-align: center;
    padding: 40px 20px;
    color: #888;
    font-size: 15px;
}
.modal-body-scroll {
    max-height: 78vh;
    overflow-y: auto;
    padding: 16px 24px;
}
/* ---- Modal detail header/actions (shared by subscription + capital call modals) ---- */
.detail-modal-header-info {
    font-size: 14px;
    color: #555;
    margin-bottom: 14px;
}
/* The action row at the top of a record modal — the modal's answer to
   `.page-actions`, which needs a hero to sit under and so cannot be used here.
   Wraps, because a modal is narrower than a page. */
/* The dialog's answer to `.page-actions` (a hero is what `.page-actions` sits
   under, and a dialog has none).
   ⚠ IT BELONGS INSIDE `.modal-body`. It carries no padding of its own and relies
   on the body's; placed as a sibling of the body, directly under
   `.modal-dialog`, its buttons sit flush against the dialog's left edge
   (operator report 2026-08-27). Every consumer in the tree puts it inside. */
.detail-modal-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    margin-bottom: 20px;
}
/* A record body made of shared cards ends in an 18px card margin, which lands on
   top of `.modal-body`'s own 20px padding and reads as a gap the dialog cannot
   close. Zeroing it here does it for every feature at once — two features each
   carried their own `.x-view-sections .x-card:last-child` copy of this rule.
   Deliberately a descendant selector, not a child one: a fragment-loaded body is
   usually wrapped (e.g. in a `data-diag-node` div), so `>` would never match. */
.modal-body .app-card:last-child,
.modal-body .app-panel:last-child { margin-bottom: 0; }
/* A card inside a modal body is a section of ONE flow, not a floating panel: the
   overlay already provides the elevation, so a second shadow reads as a stack of
   loose sheets. The rhythm is the modal's, tighter than a page's. Was
   investor_transactions' own rule until 2026-08-25, when the wire request and
   wire template records became modals too — three copies is a shared rule. */
.modal-body .app-panel { box-shadow: none; margin-bottom: 14px; }
/* The header's RIGHT-HAND GROUP. `.modal-header` is a space-between flex row, so
   "Open full page" and the close button need a box of their own to sit together
   at the end of it. Same three record modals. */
.modal-head-actions { display: flex; align-items: center; gap: 8px; }
@media (max-width: 700px) {
    /* The dialog is already 94vw here: let the two controls wrap rather than squeeze. */
    .modal-head-actions { gap: 4px; }
}

.modal-subsection {
    font-size: 14px;
    font-weight: 700;
    color: var(--navy);
    margin: 20px 0 12px;
    padding-bottom: 6px;
    border-bottom: 1px solid #e0e0e0;
}
.modal-subsection:first-child { margin-top: 0; }

/* ---- Lookup Modal Elements ---- */
.lookup-search-row {
    margin-bottom: 12px;
}
.lookup-search-input {
    max-width: 100% !important;
}
.lookup-results {
    min-height: 80px;
    max-height: 320px;
    overflow-y: auto;
    border: 1px solid #e0e0e0;
    border-radius: 4px;
    background: #fafafa;
}
.lookup-loading,
.lookup-empty {
    text-align: center;
    padding: 24px 12px;
    font-size: 13px;
    color: #888;
}
.lookup-list {
    list-style: none;
    margin: 0;
    padding: 0;
}
.lookup-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 10px 14px;
    border-bottom: 1px solid #eee;
    font-size: 13px;
    color: #333;
}
.lookup-item:last-child { border-bottom: none; }
.lookup-item:hover { background: #f0f4ff; }
.lookup-item-name { flex: 1; margin-right: 10px; }
.lookup-select-btn { flex-shrink: 0; }
.lookup-add-new-row {
    padding: 10px 0 0;
    text-align: left;
}

/* ---- Tabs ---- */
.contact-tabs {
    margin-bottom: 16px;
}
.contact-tab-bar {
    display: flex;
    border-bottom: 2px solid #dee2e6;
    margin-bottom: 0;
}
.contact-tab {
    padding: 9px 20px;
    font-size: 13px;
    font-weight: 500;
    color: #666;
    background: none;
    border: none;
    border-bottom: 2px solid transparent;
    margin-bottom: -2px;
    cursor: pointer;
    transition: color 0.15s, border-color 0.15s;
}
.contact-tab:hover {
    color: #333;
}
.contact-tab.active {
    color: #2962cc;
    border-bottom-color: #2962cc;
}
.contact-tab-panel {
    display: none;
    padding-top: 16px;
}
.contact-tab-panel.active {
    display: block;
}

/* ---- App Tabs (THE standard list/section tab bar) ----
   The underline tab style app_questions.php originated. This is the standard
   for a page's whereTab-style bar (e.g. Valuation Policy Held / All
   Securities), and for the status bars the shared engine in
   data/general/app_status_tabs.php declares server-side — Questions itself
   moved onto that engine on 2026-09-10 (Action Required / Pending Opus /
   Closed). Markup: <div class="app-tabs"><button class="app-tab is-active">
   …</button><button class="app-tab">…</button></div>. Toggle .is-active in JS.
   (Modal/contact sub-tabs keep .contact-tab*; feature-scoped variants exist but
   new pages should use .app-tab.) */
.app-tabs {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    border-bottom: 2px solid var(--hairline);
    margin-bottom: 14px;
}
.app-tab {
    border: 0;
    background: transparent;
    padding: 8px 16px 7px;
    font-size: 13.5px;
    font-weight: 600;
    color: #5a6b7f;
    cursor: pointer;
    border-bottom: 2px solid transparent;
    margin-bottom: -2px;
    font-family: inherit;
}
.app-tab:hover { color: var(--navy); }
.app-tab.is-active { color: var(--navy); border-bottom-color: var(--accent); }

/* ---- App Tab Count (the per-tab population badge) ----
   The count chip beside a tab label:
     <button class="app-tab">Open <span class="app-tab-count">15</span></button>
   Promoted 2026-08-21 from NINE byte-identical feature copies (.af- / .cdd- /
   .co- / .def- / .ds- / .itx- / .ns- / .q- / .vp-tab-count). The chip is
   unscoped on purpose, so it can label something that is not a tab; only the
   ACTIVE skin is scoped to .app-tab.is-active, because that is the only place
   the state exists.

   #e9edf2 is a deliberate literal, NOT --badge-gray-bg (#eef1f5): this chip
   sits inside the tab bar over white, where the pill grey went invisible, so it
   is one step darker. Do not "fix" it onto the token.

   NOT every feature converged, and the six that did not are a DIFFERENT badge,
   not a drifted copy of this one — kept local deliberately (measured
   2026-08-21, each differs from this base on 6-9 computed properties):
     .ba- / .ct-tab-count  a 999px pill, no min-width, no active state at all
     .sw-tab-count         999px + a navy 10% wash at font-weight 600
                           (`.wt-tab-count` was its twin until 2026-08-26, when the
                            Wire Templates bar was rebuilt to four tabs and moved
                            onto THIS class — the active skin it used to lack is the
                            thing that bar most needed)
     .wr- / .it-tab-count  12px/18px text-align:center chip, min-width 20px
   Forcing those onto this class would override most of it and still change the
   box height (they set no line-height, so `line-height: 1` here would shrink
   them). If a NEW screen needs a count chip, use this one. */
.app-tab-count {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 18px;
    height: 18px;
    padding: 0 6px;
    margin-left: 7px;
    border-radius: 10px;
    background: #e9edf2;
    color: var(--badge-gray-fg);
    font-size: 11px;
    font-weight: 700;
    line-height: 1;
    vertical-align: 1px;
}
.app-tab.is-active .app-tab-count { background: var(--navy); color: #fff; }

/* --outline modifier: the same geometry with a hairline ring and the page fill
   instead of the grey chip. NAV Status carries it (was .ns-tab-count) because
   its tab bar sits on a white card directly above the grey summary band, where
   a flat grey chip lost its edge. Three properties — and the active state must
   re-state border-color, or the hairline survives the navy fill. */
.app-tab-count--outline {
    background: var(--page-bg);
    border: 1px solid var(--hairline);
}
.app-tab.is-active .app-tab-count--outline { border-color: var(--navy); }

/* ---- A tab that is a LINK ----
   `.app-tab` was written for a <button>, because most tab bars switch a row set
   inside one page. A tab bar whose tabs are real PAGE LOADS is an <a>, and an <a>
   needs exactly two things a <button> gives for free. Nothing else is restated.

   Promoted 2026-09-09 from `.yt-tabs .app-tab` (app_your_team.php, 2026-08-28)
   when app_investor_custom_attributes.php became the second screen to need it —
   both split their content one tab per CLIENT, which is a different page, not a
   filter. Element-qualified rather than a wrapper class, so a feature adds NO
   rule and cannot fork this into a third copy. */
a.app-tab {
    display: inline-block;
    text-decoration: none;
}

/* ---- Tab PICKER (the strip a tab bar becomes when there are too many tabs) ----
   Past ~8 tabs a strip wraps to several rows of chrome and stops reading as one
   control, so it becomes a labelled picker instead. A real <form method="get">
   with a real submit button, so it works with scripting off; the feature's own
   script upgrades the <select> to AppSelect and submits on change (⚠ an ENHANCED
   select is MANAGED and fires no `change` of its own — pass AppSelect's onChange,
   and keep the plain listener only as the un-enhanced fallback).

   Markup:
     <form class="app-tab-picker" method="get" action="…">
       <label class="app-tab-picker-label" for="…">Client</label>
       <select class="form-field app-tab-picker-select" id="…" name="client">…</select>
       <button type="submit" class="btn btn-secondary app-tab-picker-go">Show …</button>
     </form>

   It ends in the same 2px --hairline rule as `.app-tabs`, so a screen that shows
   the strip to one caller and the picker to another does not change shape below
   it. Promoted 2026-09-09 from `.yt-picker*` for the same reason as `a.app-tab`
   above; the two are one pattern and travel together. */
.app-tab-picker {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 10px;
    margin-bottom: 18px;
    padding-bottom: 14px;
    border-bottom: 2px solid var(--hairline);
}
.app-tab-picker-label {
    font-size: 12px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--text-muted);
}
.app-tab-picker-select { width: auto; min-width: 240px; }
@media (max-width: 700px) {
    /* A single control per row reads better than three squeezed onto one, and the
       button stops being a 40px-wide stub. */
    .app-tab-picker-select { width: 100%; min-width: 0; }
    .app-tab-picker-go { width: 100%; }
}

/* ---- App Statstrip (triage / KPI tiles) ----
   Status-count tiles that double as quick-filters, sitting BETWEEN the tabs and
   the filter panel, OUTSIDE the grid card (so they never become the grid's
   sticky containing block). Promoted from Questions (was .q-stat*, 2026-07-23)
   and RE-DESIGNED 2026-07-30 (operator brief: "too similar to the hero, too
   basic"). Class names, DOM structure and the JS contract are UNCHANGED — the
   only markup addition is the optional `.app-stat--lead` modifier below.

   Why it looks the way it does — the strip and the hero must never read as the
   same component. `.page-hero` is a wide light-grey band with a 4px navy LEFT
   rule, a 46px icon chip on the LEFT and a 22px title over prose. So a stat tile
   now differs on every one of those axes:
     - its accent is a 3px TOP bar — a tile NEVER carries a left rail;
     - it is a white 12px-radius card of the .app-tile KPI family, with a whisper
       of its own accent washed into the top-right corner for depth;
     - its icon chip is pinned TOP-RIGHT, not leading the content;
     - it reads LABEL over FIGURE (a data readout — the inverse of the hero's
       title-over-description);
     - and only a CLICKABLE tile lifts, shows the filter chevron and takes the
       pressed treatment, so "this is a control" is visible before the click.

   Per-tile accent contract — a feature modifier sets THREE custom properties and
   writes NOTHING else (this replaced 3 rules per status; the old
   `border-left-color` + `.app-stat-ico` + `.app-stat-num` overrides are gone):
     --stat-accent  top bar + pressed border   (may be the brighter hue)
     --stat-ink     glyph / figure / pressed text — must be READABLE, it also
                    becomes the fill behind the white glyph when pressed
     --stat-tint    the pale surface + chip fill
   Reference set: assets/standards/standards.css (.std-stat--*) — one line
   each. (assets/questions/questions.css was the other, until that screen's
   strip was replaced by a status tab bar on 2026-09-10.)

   Markup — a tile is a <button> when it filters, an <a> when it opens the screen
   that owns what it counts (2026-08-25), and a <div> when it is a headline number
   (that <div> takes `.app-stat--lead`, which is why a reader never mistakes the
   KPI for a control):
     <div class="app-statstrip" id="…">
       <div class="app-stat app-stat--lead <feat>-stat--x">…same body…</div>
       <button type="button" class="app-stat <feat>-stat--y" data-status="Y"
               aria-pressed="false" title="Filter: Label">
         <span class="app-stat-ico" aria-hidden="true">…svg…</span>
         <span class="app-stat-meta"><span class="app-stat-num">7</span>
         <span class="app-stat-label">Label</span></span>
       </button>
     </div>
   Reference wiring: assets/standards/standards.js renderStdStats / markStdStats /
   pickStatus (counts from the FULL dataset; a tile click sets the shared Status
   AppSelect and re-applies; clicking the pressed tile clears it — which is what
   the "×" hint on a pressed tile advertises, and what markStdStats says in the
   `title`). The keyboard focus ring comes from the shared :where() rule.

   ⚠ THE STRIP AND A STATUS TAB BAR ARE TWO CONTROLS FOR ONE AXIS. Where a
   screen's tiles quick-filter the Status dropdown, the shared status-tab
   engine (data/general/app_status_tabs.php) does the same job with badges
   that follow the filter, and the operator has now asked for the tabs over
   the tiles once (app_questions.php, 2026-09-10). A strip still earns its
   place when it counts something the tabs do NOT split on, or when it is a
   pure KPI readout. Do not ship both for the same statuses. */
.app-statstrip {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(184px, 1fr));
    gap: 12px;
    margin-bottom: 18px;
}
/* [hidden] COMPANION (2026-10-01) — the `display: grid` above beats the UA's
   [hidden] rule, so a strip a screen hides until its counts exist went on
   showing: stale tiles from the previous answer, or an empty 18px band. Same
   convention as .app-filter-panel[hidden] / .btn[hidden]; it retires the local
   patch's reason (.sp-scr-stats[hidden], screen_permissions.css). */
.app-statstrip[hidden] { display: none !important; }
.app-stat {
    /* Neutral defaults: an un-modified tile is grey. Navy belongs to the hero
       and to .app-stat--lead — never to a plain status tile. */
    --stat-accent: #b7c2ce;
    --stat-ink: var(--text);
    --stat-tint: var(--pill-gray-bg);

    position: relative;
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 12px;
    padding: 15px 16px 14px;
    background-color: #fff;
    /* Accent whisper in the top-right corner. Fade to rgba(255,255,255,0), never
       `transparent`, so Safari interpolates through white instead of black. */
    background-image: radial-gradient(115% 130% at 100% 0%,
                                      var(--stat-tint) 0%, rgba(255, 255, 255, 0) 58%);
    border: 1px solid var(--hairline);
    border-radius: 12px;
    box-shadow: 0 1px 3px rgba(15, 28, 54, .05);
    overflow: hidden;              /* clips the accent bar to the radius */
    text-align: left;
    font-family: inherit;
    transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease;
}
/* THE identity mark: the accent rides the TOP edge (the hero owns the left rule). */
.app-stat::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 3px;
    background: var(--stat-accent);
}
/* Only a real control behaves like one.
   TWO element types qualify, and they mean different things:
     <button>  filters the list below (aria-pressed, .is-active, the "×" hint);
     <a>       NAVIGATES to the screen that owns what the tile counts — added
               2026-08-25 for the Treasury Overview, whose strip is the family's
               six queues and where every figure's natural next step is the other
               screen. A link tile never takes .is-active: it has no pressed
               state because it does not filter anything here.
   They share every rule below rather than a link tile getting a near-copy of
   them in a feature stylesheet, which is the drift this file exists to prevent.
   An <a> additionally has to drop the anchor's own colour and underline, since
   the tile's typography comes from .app-stat-num / .app-stat-label. */
button.app-stat, a.app-stat { cursor: pointer; }
a.app-stat { text-decoration: none; color: inherit; }
button.app-stat:hover, a.app-stat:hover {
    border-color: #cfd8e3;
    box-shadow: 0 10px 20px -12px rgba(15, 52, 96, .38);
    transform: translateY(-2px);
}
button.app-stat:active, a.app-stat:active {
    transform: translateY(0);
    box-shadow: 0 1px 3px rgba(15, 28, 54, .05);
}
/* Filter affordance — a chevron slides in on hover/keyboard focus; a pressed
   tile swaps it for the "×" that says "click again to clear". Scoped to <button>
   so a headline tile never advertises a click it does not have. */
button.app-stat::after,
a.app-stat::after {
    content: '';
    position: absolute;
    right: 15px;
    bottom: 15px;
    width: 6px;
    height: 6px;
    border-right: 2px solid var(--stat-ink);
    border-bottom: 2px solid var(--stat-ink);
    transform: translateX(-5px) rotate(-45deg);
    opacity: 0;
    transition: opacity .16s ease, transform .16s ease;
}
button.app-stat:hover::after,
button.app-stat:focus-visible::after,
a.app-stat:hover::after,
a.app-stat:focus-visible::after {
    opacity: .75;
    transform: translateX(0) rotate(-45deg);
}
.app-stat-ico {
    order: 2;                      /* DOM-first, rendered top-RIGHT */
    display: flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border-radius: 10px;
    background: var(--stat-tint);
    color: var(--stat-ink);
    flex-shrink: 0;
    transition: background .16s ease, color .16s ease;
}
/* column-reverse renders the LABEL over the FIGURE while the DOM keeps
   figure-then-label, so a screen reader still reads "7, Outstanding". */
.app-stat-meta {
    display: flex;
    flex-direction: column-reverse;
    justify-content: flex-end;
    gap: 6px;
    min-width: 0;
}
.app-stat-num {
    font-size: 28px;
    font-weight: 800;
    line-height: 1;
    letter-spacing: -.02em;
    color: var(--text);
    font-variant-numeric: tabular-nums;
}
.app-stat-label {
    font-size: 10.5px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .07em;
    line-height: 1.2;
    color: var(--text-muted);
}
/* Pressed = "the list is filtered by THIS tile": the tile takes its own accent
   surface, figure and a filled chip — not the one-size navy ring it used to. */
.app-stat.is-active {
    background-color: var(--stat-tint);
    border-color: var(--stat-accent);
    box-shadow: 0 2px 10px -4px rgba(15, 28, 54, .22);
}
.app-stat.is-active .app-stat-num { color: var(--stat-ink); }
.app-stat.is-active .app-stat-ico { background: var(--stat-ink); color: #fff; }
button.app-stat.is-active::after {
    content: '\00D7';              /* × — clicking a pressed tile clears it */
    width: auto;
    height: auto;
    border: 0;
    right: 13px;
    bottom: 10px;
    font-size: 17px;
    font-weight: 700;
    line-height: 1;
    color: var(--stat-ink);
    opacity: .7;
    transform: none;
}
button.app-stat.is-active:hover::after { opacity: 1; transform: none; }
/* .app-stat--lead — the headline KPI tile (a <div>: a number, not a filter).
   A filled readout, so the eye separates "the number that matters" from the
   pressable status tiles beside it. */
.app-stat--lead {
    background-color: var(--stat-tint);
    background-image: none;
}
.app-stat--lead .app-stat-num { color: var(--stat-ink); }
.app-stat--lead .app-stat-ico { background: var(--stat-ink); color: #fff; }
/* Skeleton tiles while the data loads (rendered as <div>s — no chevron, no
   hover; the accent bar goes neutral so no status is implied before the counts
   exist). */
.app-stat.is-skeleton { cursor: default; pointer-events: none; }
.app-stat.is-skeleton::before { background: var(--hairline); }
.app-stat.is-skeleton .app-stat-ico { background: #eef1f4; }
@media (max-width: 700px) {
    .app-statstrip { grid-template-columns: repeat(2, 1fr); gap: 10px; }
    .app-stat { padding: 12px 13px 11px; border-radius: 10px; gap: 10px; }
    .app-stat-num { font-size: 23px; }
    .app-stat-ico { width: 30px; height: 30px; border-radius: 9px; }
    /* No hover on touch, so the chevron hint is dead weight — the "×" on a
       pressed tile is not, and stays. */
    button.app-stat:not(.is-active)::after { display: none; }
}
@media (prefers-reduced-motion: reduce) {
    .app-stat,
    .app-stat-ico,
    button.app-stat::after { transition: none; }
    button.app-stat:hover { transform: none; }
}

/* ---- App Selection Bar (.app-selbar) — the BULK-ACTION FOOTER --------------

   The bar a list shows once rows are ticked: an icon, a sentence naming what is
   selected, a sub-line carrying the TOTAL, and the action. Sits under the grid.

   PROMOTED FROM Client Invoicing (was .cim-selbar on ?page=mass_mail,
   2026-08-31) when ?page=srt_meter needed the same thing. Both routes are in one
   sub-nav band, so a second copy would have been a visible fork of a component a
   reader compares side by side — which is what tools/check_shared_css.php exists
   to catch. Its rule for this class is `-selbar`.

   THE ORDER OF THE PARTS IS THE POINT, not the styling: count, then the figure,
   then the button. A bulk action over money should never be one click from a
   checkbox with the amount off-screen.

   Markup:
     <div class="app-selbar" id="…" hidden aria-live="polite">
       <span class="app-selbar-ico" aria-hidden="true">…svg…</span>
       <span class="app-selbar-text">
         <span class="app-selbar-line">12 charges selected across 3 funds</span>
         <span class="app-selbar-sub">$1,240.00 would not be billed</span>
       </span>
       <button class="btn btn-secondary btn-sm">Clear</button>
       <button class="btn btn-primary btn-sm">Waive selected charges</button>
     </div>

   ⚠ [hidden] NEEDS ITS OWN DISPLAY COMPANION, and it is below. general.css has
   no global [hidden] reset, so `display: flex` here would beat the UA rule and
   the bar would keep showing with nothing selected. Every component that toggles
   with the attribute carries this pair. */
.app-selbar {
    display: flex;
    align-items: center;
    gap: 14px;
    flex-wrap: wrap;
    margin-top: 14px;
    padding: 13px 16px;
    background: #fff;
    border: 1px solid var(--navy);
    border-radius: 8px;
    box-shadow: 0 1px 3px rgba(15, 28, 54, .06);
}
.app-selbar[hidden] { display: none !important; }
.app-selbar-ico { display: inline-flex; color: var(--navy); flex-shrink: 0; }
.app-selbar-text { flex: 1; min-width: 190px; }
.app-selbar-line {
    display: block;
    font-size: 13.5px;
    font-weight: 600;
    color: var(--text);
}
.app-selbar-sub {
    display: block;
    margin-top: 2px;
    font-size: 12px;
    color: var(--text-muted);
    font-variant-numeric: tabular-nums;
}

/* ⚠ A MEDIA QUERY ADDS NO SPECIFICITY, so this block stays AFTER the base rules
   it overrides. Moved above them it fails PARTIALLY — the properties that happen
   to be unique still apply, which is what makes the mistake hard to spot. */
@media (max-width: 700px) {
    .app-selbar { align-items: stretch; }
    .app-selbar .btn {
        /* A full-width action starves a flex row of its other controls, so the
           two buttons share the row instead of each taking one. */
        flex: 1 1 0;
        min-width: 0;
    }
    .app-selbar-text { flex-basis: 100%; }
}

/* ---- App Subnav (the family section bar — a SECOND CHROME ROW) ----
   The section navigation shared across a feature family's landing pages (e.g.
   Invoices / Payments / Auto Pay / Recipients / Fees). Promoted VERBATIM from
   Opus Invoices (was .oi-subnav*, 2026-07-23); the reference server partial is
   views/opus_invoices/subnav_partial.php: tabs are permission-gated server-side
   and the ACTIVE tab is derived per page (aria-current="page"). Two
   presentations, swapped at the 700px breakpoint below: the desktop tab row
   (.app-subnav) and a compact <details> section dropdown (.app-subnav-select) —
   no horizontal scrolling on phones.

   ══ THE 2026-07-31 STANDARD: THIS BAR IS CHROME, NOT PAGE CONTENT ══
   It renders as a full-bleed band flush under the topbar. It REPLACES the
   previous order (hero -> .page-actions -> .app-subnav -> content), in which the
   bar was the first thing inside .page-wrap and left 32px of page background
   stranded between the two bars. The old look was a light "folder tab" strip
   sitting on a hairline baseline; that costume is retired.

   ══ THE 2026-08-17 REDESIGN: A LIGHT BAND, NOT A SECOND DARK SLAB ══
   The band's POSITION is the 2026-07-31 standard above and is unchanged; its
   SURFACE is not. It was a navy slab (a gradient from #0c2646 into --navy) that
   absorbed the topbar's rule so the two rows read as one block of chrome. The
   operator's brief: that "feels visually dated and creates unnecessary visual
   weight". It is now a WHITE surface under the topbar's own 3px navy rule, which
   is once again the hard edge between them — see --subnav-* at the top of this
   file for the full reasoning. What changed, and what to keep in mind:
     · The two rows now read as SEPARATE (dark app frame, then a light section
       bar belonging to the page) rather than as one graded chrome block. The
       `body:has(.app-subnav-bar) .topbar` rule that repainted the topbar's rule
       to blend them is GONE — a band page's topbar is now identical to every
       other page's.
     · Tabs are text-first: 13.5px/500 near-black on white, a 52px row for a
       comfortable click target, and the current tab marked by weight + navy +
       a 3px --accent underline with NO fill behind it. Do not put a fill back
       on .is-active — the underline IS the marker, and a fill on white reads as
       a second, competing one.
     · Everything painted for the dark band had to flip: the tab labels/icons,
       the group summary + chevron, the whole mobile <details> summary. The two
       white overlay PANELS (.app-subnav-group-menu, .app-subnav-select-menu)
       were already light and now sit on a light band, so they gained a top
       border and a stronger shadow to stay separable from it.

   REQUIRED MARKUP — a body-level SIBLING of nav.topbar, placed immediately
   after it and BEFORE .page-wrap (never inside it: a child of the width-capped
   wrap cannot go full-bleed without viewport hacks that the max-content
   grid-grow model at the foot of this file breaks):

       <nav class="topbar">…</nav>
       <div class="app-subnav-bar">
         <div class="app-subnav-bar-inner">
           <nav class="app-subnav">…tabs…</nav>
           <details class="app-subnav-select">…mobile…</details>
         </div>
       </div>
       <div class="page-wrap …">…hero, .page-actions, content…</div>

   FOUR load-bearing facts behind the geometry:
   1. Global `box-sizing: border-box` (top of this file) means .topbar's
      `height:56px` INCLUDES its 3px navy bottom rule, and
      `body:has(nav.topbar){padding-top:56px}` reserves exactly 56px — so the
      first IN-FLOW body child lands on that rule's bottom edge. Flush, with no
      magic number.
   2. The band is deliberately NOT position:fixed. 56px is hard-coded as a sticky
      offset in FOUR places — `body:has(nav.topbar)` above, the grid's sticky
      thead `top:56px` near the foot of this file, and app_grid.js:457
      (`topOffset` default) + :1390 (the float-head clone). A fixed band would
      tuck every grid's sticky header underneath itself, and since only SOME
      pages carry a sub-nav it would need topOffset plumbed per page. In flow it
      scrolls away exactly as the old in-page bar did.
   3. body is `display:flex; flex-direction:column`, so the band stretches to the
      body's width for free — including on a grid page where the body grows to
      max-content, so it never leaves a bare strip when the page scrolls right.
   4. The band sits DIRECTLY on .topbar's 3px navy rule, which since the
      2026-08-17 redesign is the separator between the dark app frame and the
      light section bar. There is no longer any band-page override of that rule
      (the 2026-08-05 `body:has(.app-subnav-bar) .topbar` recolour, which
      repainted it to the band's own top tone so the two blended, is retired) —
      every page's topbar now renders identically, band or no band. */
.app-subnav-bar {
    position: relative;   /* positioning context for the ≤700px menu overlay */
    z-index: 60;          /* over page content + grid chrome (20-50); under .topbar (100) */
    /* A clean white surface (see --subnav-* at the top of this file). The
       hairline + the soft drop shadow are what lift the band off the page
       beneath it now that it no longer has a dark fill to do that job — without
       them a white band on a #f0f2f5 page has no bottom edge at all. */
    background: var(--subnav-surface);
    border-bottom: 1px solid var(--subnav-edge);
    box-shadow: 0 2px 8px rgba(15, 28, 54, .07);
}
/* Line the tabs up with the page content below by reusing .page-wrap's own box
   (max-width + the asymmetric 24/20 gutters), so a tab's left edge and the
   hero's left edge share a column. */
.app-subnav-bar-inner {
    max-width: 1750px;
    margin: 0 auto;
    padding: 0 20px 0 24px;
}
/* Wide (page-scrolling) grid pages: keep the tabs parked at the viewport-left
   when the page scrolls right instead of panning away with the content. Same
   `sticky; left:<offset>` trick and the same `:has()` discriminator used for
   .app-footer-inner near the foot of this file — but applied to the NAV, not to
   the width-capped inner wrapper, which is the whole point:

   position:sticky is clamped to its CONTAINING BLOCK, so a sticky element can
   only slide (container width - own width). Sticking the 1750px inner inside a
   band grown to 2680px caps the slide at 930px, and a 1415px scroll left the
   tabs 485px off-screen — measured 2026-07-31, which is how this rule got
   rewritten. Sticking the NAV instead makes the slide range (band - nav), and
   since the nav is narrower than the viewport whenever it fits on screen at all,
   its range always EXCEEDS the page's scroll range (body - viewport). So the pin
   holds for the FULL scroll rather than most of it. NOTE: .app-footer-inner
   still carries the original clamp; the same rewrite would fix it.
   The offset re-adds .page-wrap's 24px gutter so the pinned position is
   continuous with the resting one — no 24px jump when sticking starts. */
body:has(.app-grid:not(.app-grid--scrollx)) .app-subnav-bar-inner {
    max-width: none;
    margin-inline: 0;
}
body:has(.app-grid:not(.app-grid--scrollx)) .app-subnav-bar-inner > .app-subnav,
body:has(.app-grid:not(.app-grid--scrollx)) .app-subnav-bar-inner > .app-subnav-select {
    position: sticky;
    left: max(24px, calc((100vw - 1750px) / 2 + 24px));
    /* Load-bearing: .app-subnav is a block-level flex container, so it would
       otherwise stretch to the whole grown band — width == container means a
       slide range of ZERO and sticky silently does nothing. Shrink-to-fit gives
       it the range. (It also stops the tabs wrapping on a grown page, which is
       what you want there: the page already scrolls horizontally.) */
    width: max-content;
}
/* With the band supplying the chrome edge, .page-wrap's 32px top padding reads
   loose against it — 24px sits the hero closer without crowding the shadow.
   Keyed on the BAND's presence, so a family's sub-pages that carry no sub-nav
   (record pages, forms, and chrome-less direct-access views) keep the shared
   32px: there is no band above them to tighten against. */
body:has(.app-subnav-bar) .page-wrap { padding-top: 24px; }

/* Desktop tabs. Full-height rows in the band, current section marked by a 3px
   --subnav-marker underline on the band's bottom edge — it points down into the
   page it opens. flex-wrap stays ON: a family with more or longer tabs than fit
   degrades to a two-row band rather than overflowing or clipping a door. */
.app-subnav {
    display: flex;
    flex-wrap: wrap;
    align-items: stretch;
    gap: 0;
    margin: 0;
}
/* TEXT-FIRST (2026-08-17). The label carries the tab; the icon is a quiet aid
   beside it and the row has no chrome of its own at rest. 13.5px/500 near-black
   on white is legible where 12.5px/600 at 72% white on navy was not, and the
   52px row (was 42) is a comfortable pointer target that also gives the band the
   breathing room the operator asked for. The 3px transparent inset is the active
   marker's rest state — it is declared here so the underline has a slot to
   animate into rather than appearing from nothing. */
.app-subnav-tab {
    display: inline-flex;
    align-items: center;
    gap: 9px;
    font-size: 13.5px;
    font-weight: 500;
    color: var(--subnav-tab);
    text-decoration: none;
    background: none;
    border: none;
    border-radius: 0;
    padding: 0 18px;
    min-height: 52px;
    white-space: nowrap;
    box-shadow: inset 0 -3px 0 transparent;   /* the active marker's rest state */
    transition: background .15s, color .15s, box-shadow .15s;
}
.app-subnav-tab svg { color: var(--subnav-tab-icon); flex-shrink: 0; }
/* The SECTION icon only (:first-of-type), sized up to 16px against the 13.5px
   label so the pair reads as a modern nav item rather than a glyph the same size
   as its own text. Deliberately NOT `.app-subnav-tab svg`, which would also
   catch the grouped tab's chevron (13px by design) — the off-site new-tab glyph
   is already safe, being wrapped in a .app-subnav-tab-newtab span rather than a
   direct child. Set in CSS because the icons are authored at 14px inside the
   partials' raw SVG markup, in menu_families.php and in eight other places. */
.app-subnav-tab > svg:first-of-type { width: 16px; height: 16px; }
/* Hover previews the marker at a quarter strength — the same 3px slot the active
   tab fills, so the underline reads as sliding in rather than appearing. */
.app-subnav-tab:hover {
    background: var(--subnav-hover);
    color: var(--navy);
    box-shadow: inset 0 -3px 0 #c8d3e0;
}
.app-subnav-tab:hover svg { color: var(--navy); }
/* .is-active is LAST at equal specificity, so it also wins on hover — the current
   tab never flickers to the hover marker. NO FILL: on white the underline alone
   is the marker (weight + navy do the rest), and a wash behind it would read as a
   second, competing one. That is also why the fill has to be reset explicitly —
   without it the tab would keep the hover wash while the pointer is on it. */
.app-subnav-tab.is-active {
    background: none;
    color: var(--navy);
    font-weight: 700;
    box-shadow: inset 0 -3px 0 var(--subnav-marker);
}
.app-subnav-tab.is-active svg { color: var(--navy); }
/* The band is a light surface now, so these take the app's standard focus ring
   (the white one they used to carry was for navy and is invisible here). Inset,
   because a full-height tab's ring would otherwise be clipped by the band edge. */
.app-subnav-tab:focus-visible,
.app-subnav-select-summary:focus-visible,
.app-subnav-group-summary:focus-visible {
    outline: 2px solid var(--control-focus);
    outline-offset: -2px;
}
/* ---- An OFF-SITE tab ----
   Added 2026-08-12 with the NAV consolidation, whose "Reports on ShareFile" item is
   specified as an external link rather than an in-app screen. Same tab chrome, plus
   a trailing new-tab glyph so a door that LEAVES the application says so before it
   is clicked (the topbar's own external entries carry the identical glyph as
   `.dropdown-newtab`). Such a tab never takes `.is-active` and never `aria-current`
   — you are never "on" it — so the marker slot below it simply stays empty.
   The negative pull cancels most of the parent's 9px flex gap, so the glyph reads as
   part of the label rather than as a second icon; on the white mobile panel it
   inherits navy from `.app-subnav-select-item svg`, like every other icon there. */
.app-subnav-tab-newtab {
    display: inline-flex;
    align-items: center;
    margin-left: -5px;
    opacity: .8;
}

/* ---- Grouped tab (a sub-menu INSIDE the band) ----
   Added 2026-08-10 with the Treasury consolidation, which folds a family's three
   configuration screens (SmartWire / Wire Templates / Holdback Settings) behind a
   single "Settings" door instead of spending three of the bar's tab slots on
   screens nobody visits daily.

   Use it SPARINGLY and only for a real sub-group: a section that is one of the
   family's working surfaces belongs on the bar as a plain tab, because a door
   behind a door costs a click and hides the label from a scan. One group of
   settings-ish screens at the END of the bar is the shape this exists for.

   MARKUP — a native <details> (no JS needed to open; general.js only adds the
   outside-click / ESC dismissal every dropdown owes the user):

       <nav class="app-subnav">
         <a class="app-subnav-tab …">…plain tabs…</a>
         <details class="app-subnav-group">
           <summary class="app-subnav-tab app-subnav-group-summary">
             <svg/> Settings <svg class="app-subnav-group-chevron"/>
           </summary>
           <div class="app-subnav-group-menu">
             <a class="app-subnav-select-item" href="…"><svg/> …</a>
           </div>
         </details>
       </nav>

   The summary IS a tab (it carries .app-subnav-tab, so it inherits the row
   geometry, hover and the active marker) and takes .is-active when the CURRENT
   page is one of its members — otherwise the band would show no marker at all on
   three of the family's screens. The panel reuses .app-subnav-select-item, the
   same white-overlay link the mobile section dropdown uses, because it is the
   same object: a section link on a light panel over the band. Only the size
   differs (mobile is a touch target; this is a pointer menu).

   Below 700px the whole group disappears with .app-subnav — the mobile <details>
   lists the members inline under an .app-subnav-select-label caption instead, so a
   phone never has to open two dropdowns to reach a screen. */
.app-subnav-group {
    position: relative;   /* positioning context for the panel */
    display: flex;
}
.app-subnav-group-summary {
    list-style: none;
    cursor: pointer;
}
.app-subnav-group-summary::-webkit-details-marker { display: none; }
.app-subnav-group-chevron {
    color: var(--subnav-tab-icon);
    transition: transform .15s;
}
.app-subnav-group[open] > .app-subnav-group-summary {
    background: var(--subnav-hover);
    color: var(--navy);
}
.app-subnav-group[open] > .app-subnav-group-summary svg { color: var(--navy); }
.app-subnav-group[open] .app-subnav-group-chevron { transform: rotate(180deg); }
/* Hangs off the band's bottom edge, left-aligned with its own tab. min-width
   keeps a two-word label from a ragged 90px panel; max-width stops a long one
   from running off a narrow viewport.
   The top border and the deeper shadow arrived with the 2026-08-17 light band:
   a white panel hanging off a NAVY band separated itself, but off a white one it
   would just be a shadow with no edge. `top:100%` lands the panel exactly on the
   band's own 1px bottom hairline, so the two coincide and still read as one
   line — do not add a margin-top to "clear" it, that reveals a double rule. */
.app-subnav-group-menu {
    position: absolute;
    top: 100%;
    left: 0;
    min-width: 210px;
    max-width: min(320px, 90vw);
    background: #fff;
    border: 1px solid #d8dee6;
    border-radius: 0 0 12px 12px;
    overflow: hidden;
    box-shadow: 0 10px 22px rgba(15, 28, 54, .13);
}
.app-subnav-group-menu .app-subnav-select-item {
    padding: 9px 13px;
    font-size: 12.75px;
    white-space: nowrap;
}

/* Mobile section dropdown (shown ≤700px; hidden on desktop). Lives in the same
   band, so it goes LIGHT with it (2026-08-17) — the summary was white-on-navy
   and would have been invisible on the new surface. The row is the phone's whole
   section navigation, so it keeps the heavier 14px/700 treatment: unlike a
   desktop tab it has no siblings to be read against. */
.app-subnav-select { display: none; }
.app-subnav-select-summary {
    list-style: none;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 12px 20px 12px 24px;
    min-height: 50px;
    background: none;
    border: none;
    border-radius: 0;
    font-size: 14px;
    font-weight: 700;
    color: var(--navy);
    cursor: pointer;
}
.app-subnav-select-summary::-webkit-details-marker { display: none; }
.app-subnav-select[open] .app-subnav-select-summary { background: var(--subnav-hover); }
.app-subnav-select-current { display: inline-flex; align-items: center; gap: 9px; min-width: 0; }
/* Navy, matching .app-subnav-select-item svg in the panel this row opens — the
   app's language for an icon on a light surface. (It used to take the marker
   colour because the marker was the band's white; --accent here would put an
   orange glyph beside a navy label for no reason.) */
.app-subnav-select-current svg { color: var(--navy); flex-shrink: 0; }
.app-subnav-select-chevron { color: var(--subnav-tab-icon); flex-shrink: 0; transition: transform .15s; }
.app-subnav-select[open] .app-subnav-select-chevron { transform: rotate(180deg); }
/* The section list OVERLAYS the page rather than pushing the hero down — the band
   is the positioning context and its z-index puts the panel over the content.
   Scrollable so a long family cannot run off a short viewport. Full border +
   deeper shadow since 2026-08-17, for the same reason as .app-subnav-group-menu:
   a white panel needs its own edge now that the band behind it is white too. */
.app-subnav-select-menu {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    margin-top: 0;
    background: #fff;
    border: 1px solid #d8dee6;
    border-radius: 0 0 12px 12px;
    overflow: hidden;
    max-height: calc(100vh - 150px);
    overflow-y: auto;
    box-shadow: 0 10px 22px rgba(15, 28, 54, .13);
}
.app-subnav-select-item {
    display: flex;
    align-items: center;
    gap: 9px;
    padding: 12px 14px;
    font-size: 14px;
    font-weight: 600;
    color: var(--text-soft);
    text-decoration: none;
    border-bottom: 1px solid var(--hairline-soft);
}
.app-subnav-select-item:last-child { border-bottom: none; }
/* Caption row for a grouped tab's members (see .app-subnav-group): the desktop
   sub-menu is flattened into this list on mobile, so the group name has to survive
   as a heading or its members read as three more top-level sections. Not a link —
   the group itself has no page. */
.app-subnav-select-label {
    display: block;
    padding: 10px 14px 4px;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: uppercase;
    color: var(--text-muted);
    background: var(--head-bg);
    border-bottom: 1px solid var(--hairline-soft);
}
/* The members themselves — indented so the caption reads as their heading. A
   modifier, not a sibling selector: a group is not necessarily last in the list. */
.app-subnav-select-item--sub { padding-left: 26px; }
/* These sit on the WHITE overlay panel, not the band, so the marker colour would
   be invisible — they take navy, matching .dropdown-ico in the topbar's own menu
   (the app's language for an icon on a light panel). */
.app-subnav-select-item svg { color: var(--navy); flex-shrink: 0; }
.app-subnav-select-item:hover { background: var(--head-bg); }
/* The panel's "you are here". The 3px --subnav-marker on the LEADING edge is the
   band's underline turned through 90° — same marker, same colour, rotated to the
   axis this list runs on (2026-08-17; before that the tint + weight were the only
   cue, which a scan can miss in a list of otherwise identical rows). */
.app-subnav-select-item.is-active {
    background: var(--subnav-hover);
    color: var(--navy);
    font-weight: 700;
    box-shadow: inset 3px 0 0 var(--subnav-marker);
}
@media (max-width: 700px) {
    /* Swap the tab row for the compact dropdown — no horizontal scroll. */
    .app-subnav { display: none; }
    .app-subnav-select { display: block; margin: 0; }
    /* The summary row spans the band edge-to-edge at this width. */
    .app-subnav-bar-inner { padding: 0; }
    /* Mobile is fully fluid and never page-scrolls horizontally (the body-grow
       release near the foot of this file), so drop the wide-page pin — mirrors
       what the same breakpoint does for .app-footer-inner. */
    body:has(.app-grid:not(.app-grid--scrollx)) .app-subnav-bar-inner > .app-subnav,
    body:has(.app-grid:not(.app-grid--scrollx)) .app-subnav-bar-inner > .app-subnav-select {
        position: static;
        width: auto;
    }
}

/* ---- Inner Sections ---- */
.inner-section {
    border: 1px solid #e0e0e0;
    border-radius: 6px;
    margin-bottom: 16px;
    overflow: hidden;
}
.inner-section-heading {
    display: flex;
    align-items: center;
    padding: 10px 16px;
    font-size: 13px;
    font-weight: 600;
    color: var(--navy);
    cursor: pointer;
    user-select: none;
    list-style: none;
    background: #f8f9fb;
}
.inner-section-heading::-webkit-details-marker { display: none; }
.inner-section-heading::before {
    content: "\25B6";
    font-size: 8px;
    color: #999;
    margin-right: 8px;
    display: inline-block;
}
details[open] > .inner-section-heading::before {
    transform: rotate(90deg);
}
.inner-section-body {
    padding: 12px 16px;
}

/* ---- Misc Shared ---- */
@keyframes fieldHighlight {
    0% { box-shadow: 0 0 0 0 rgba(204, 51, 51, 0.5); }
    50% { box-shadow: 0 0 0 4px rgba(204, 51, 51, 0.3); }
    100% { box-shadow: 0 0 0 0 rgba(204, 51, 51, 0); }
}
.field-highlight-pulse {
    animation: fieldHighlight 0.75s ease 2;
}

.saving-spinner {
    width: 32px;
    height: 32px;
    border: 3px solid #e0e0e0;
    border-top-color: var(--navy);
    border-radius: 50%;
    margin: 0 auto;
    animation: spin 0.8s linear infinite;
}
@keyframes spin {
    to { transform: rotate(360deg); }
}

.save-success-bar {
    background: #27ae60;
    color: #fff;
    text-align: center;
    padding: 10px 16px;
    font-size: 14px;
    font-weight: 500;
    transition: opacity 0.3s ease;
}

/* ---- Layout Shell (sidebar + main) ---- */
.layout {
    display: flex;
    gap: 28px;
    align-items: flex-start;
}
.summary-sidebar {
    width: 220px;
    flex-shrink: 0;
    position: sticky;
    top: 72px;
    max-height: calc(100vh - 90px);
    overflow-y: auto;
    background: #fff;
    border: 1px solid #e2e5ea;
    border-radius: 8px;
    padding: 0 0 8px 0;
    box-shadow: 0 1px 4px rgba(0,0,0,0.05);
    align-self: flex-start;
}
.summary-sidebar-header {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: #999;
    padding: 14px 16px 10px;
    border-bottom: 1px solid #eef0f3;
    margin-bottom: 4px;
}
.page-main {
    flex: 1;
    min-width: 0;
    max-width: 1150px;
    padding: 0 0 48px 0;
}

/* ---- Sidebar Summary Items ---- */
.summary-sidebar .summary-item {
    padding: 11px 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    border-left: 3px solid transparent;
    position: relative;
    transition: background 0.12s, color 0.12s;
}
.summary-item[data-filter-category=""]              { border-left-color: #8899aa; }
.summary-item[data-filter-category="action_needed"]  { border-left-color: #3498db; }
.summary-item[data-filter-category="in_progress"]    { border-left-color: #e67e22; }
.summary-item[data-filter-category="completed"]          { border-left-color: #27ae60; }
.summary-item[data-filter-category="approved_modified"]  { border-left-color: #d4880f; }
.summary-item[data-filter-category="approved"]           { border-left-color: #2980b9; }
.summary-item[data-filter-category="pending_approval"]   { border-left-color: #e67e22; }
.summary-clickable { cursor: pointer; transition: background-color 0.15s ease; }
.summary-clickable:hover { background: #f5f7fa; }
.summary-clickable.active[data-filter-category=""]              { background: #f4f6f8; border-left-color: #8899aa; }
.summary-clickable.active[data-filter-category="action_needed"]  { background: #eef6fc; border-left-color: #3498db; }
.summary-clickable.active[data-filter-category="in_progress"]    { background: #fef5ec; border-left-color: #e67e22; }
.summary-clickable.active[data-filter-category="completed"]      { background: #edfbf3; border-left-color: #27ae60; }
.summary-clickable.active[data-filter-category="approved_modified"] { background: #fef8ec; border-left-color: #d4880f; }
.summary-clickable.active[data-filter-category="approved"]       { background: #eef3fc; border-left-color: #2980b9; }
.summary-clickable.active[data-filter-category="pending_approval"] { background: #fef5ec; border-left-color: #e67e22; }
.summary-icon {
    display: flex;
    flex-shrink: 0;
}
.summary-icon svg { display: block; }
.summary-count {
    font-size: 20px;
    font-weight: 700;
    color: #1a1a2e;
    line-height: 1;
    min-width: 24px;
}
.summary-label {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: .4px;
    color: #888;
    font-weight: 500;
    line-height: 1.2;
}
.summary-meta {
    display: flex;
    flex-direction: column;
    gap: 2px;
}

/* ---- Filter Row ---- */
.filter-row {
    display: flex;
    gap: 12px;
    flex-wrap: wrap;
    align-items: center;
    margin-bottom: 18px;
    font-size: 13px;
    color: #888;
}
.filter-row .record-count {
    margin: 0;
    font-size: 13px;
}
.filter-row .record-count strong { color: #555; }
.filter-sep { color: #d0d0d0; }
.filter-select {
    padding: 6px 28px 6px 10px;
    border: 1px solid #ccc;
    border-radius: 5px;
    font-size: 13px;
    color: #333;
    background: #fff;
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
    background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 10px center;
    max-width: 220px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.filter-select:focus { outline: none; border-color: var(--navy); }

/* ---- AppSelect (shared searchable dropdown — the standard filter control) ---- */
.app-select { position: relative; display: inline-block; }
.app-select-trigger {
    display: inline-flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    width: 100%;
    padding: 6px 10px;
    font-size: 13px;
    font-family: inherit;
    color: #333;
    background: #fff;
    border: 1px solid #ccc;
    border-radius: 5px;
    cursor: pointer;
    text-align: left;
}
.app-select-trigger:hover { border-color: var(--navy); }
.app-select.open .app-select-trigger,
.app-select--active .app-select-trigger { border-color: var(--navy); color: var(--navy); }
.app-select--active .app-select-trigger { background: #f0f4ff; }
/* STANDARD: a filled FORM dropdown is NOT an "active filter" — the light-blue
   active highlight (bg + navy border/text) is ONLY for filter-panel dropdowns.
   Any AppSelect inside a form row keeps the neutral .form-field look when it
   holds a value; the open state still gets the navy focus border. Scoped to
   .form-row so filter-panel / grid-toolbar AppSelects are untouched. Feature
   form-card wrappers (e.g. .vp-form-card) carry the same rule for their own
   context. */
.form-row .app-select--active .app-select-trigger {
    background: #fff;
    border-color: #ccc;
    color: #222;
}
.form-row .app-select.open .app-select-trigger { border-color: var(--navy); }
/* Disabled (via AppSelect.setDisabled — a dependent picker waiting on its
   parent): greyed, no hover affordance, the native button blocks the click. */
.app-select--disabled .app-select-trigger,
.app-select--disabled .app-select-trigger:hover {
    background: #f4f6f9;
    color: #9aa3ad;
    border-color: #e2e6ea;
    cursor: not-allowed;
}
.app-select--disabled .app-select-caret { color: #c6ccd4; }
.app-select-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.app-select-caret { color: #999; flex-shrink: 0; transition: transform 0.15s, color 0.15s; }
.app-select.open .app-select-caret { color: var(--navy); transform: rotate(180deg); }
.app-select-panel {
    display: none;
    position: absolute;
    z-index: 40;
    top: calc(100% + 4px);
    left: 0;
    /* Grow to fit the widest option so long labels (e.g. full investor names)
       are not clipped, but never narrower than the trigger nor wider than the
       viewport. Anything still longer than the cap wraps (see -item-text). */
    width: max-content;
    min-width: 100%;
    max-width: min(440px, calc(100vw - 24px));
    background: #fff;
    border: 1px solid #d5d5d5;
    border-radius: 6px;
    box-shadow: 0 6px 20px rgba(20, 30, 50, 0.12);
}
.app-select.open .app-select-panel { display: block; }
/* When a widened panel would spill past the right edge of the viewport, JS adds
   this class so the panel right-aligns to the trigger and stays on-screen. */
.app-select--flip-right .app-select-panel { left: auto; right: 0; }
/* On mobile the filter grid is single-column and the trigger already spans the
   row, so keep the panel at the trigger width (long labels wrap within it)
   rather than growing — a wider panel can't be kept on-screen beside a
   near-full-width trigger. */
@media (max-width: 700px) {
    .app-select-panel { width: auto; max-width: 100%; }
}
.app-select-search { padding: 8px 10px; border-bottom: 1px solid #eef0f3; }
.app-select-search-input {
    width: 100%;
    padding: 6px 10px;
    font-size: 13px;
    border: 1px solid #ddd;
    border-radius: 5px;
    outline: none;
    box-sizing: border-box;
}
.app-select-search-input:focus { border-color: var(--navy); }
/* width:max-content lets the list (and thus the panel) size to its widest
   option even though it is a vertical scroll container; max-width:100% keeps it
   within the panel's cap so long labels wrap rather than scroll horizontally. */
.app-select-list { width: max-content; min-width: 100%; max-width: 100%; max-height: 240px; overflow-y: auto; padding: 4px 0; }
.app-select-item {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 8px;
    padding: 7px 12px;
    font-size: 13px;
    color: #333;
    cursor: pointer;
}
.app-select-item:hover { background: #f5f7fa; }
.app-select-item--active { color: var(--navy); font-weight: 600; background: #f0f4ff; }
/* Wrap long labels (full investor names etc.) instead of clipping them with an
   ellipsis, so the whole value is always readable. */
.app-select-item-text { min-width: 0; white-space: normal; overflow-wrap: break-word; word-break: break-word; line-height: 1.35; }
.app-select-check { flex-shrink: 0; margin-top: 2px; color: var(--navy); }
/* ---- AppSelect MULTI mode (every Search & Filter dropdown, 2026-08-20) ----
   AppFilters enhances its dropdowns with multi:true, so an option row is a
   CHECKBOX: clicking toggles it and the panel stays open for the next pick.
   The leading box is also what tells the reader more than one value is allowed,
   which is why the placeholder ("All") row carries one too — ticked while
   nothing is selected, and the way to clear the whole set. `space-between` is
   dropped here: with a leading box the label must sit NEXT to it, not across
   the panel from it. */
.app-select--multi .app-select-item { justify-content: flex-start; }
.app-select-box {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 15px;
    height: 15px;
    margin-top: 1px;
    border: 1px solid #c3cbd6;
    border-radius: 3px;
    background: #fff;
    color: #fff;
}
.app-select-item:hover .app-select-box { border-color: #97a3b3; }
.app-select-box.is-on { background: var(--navy); border-color: var(--navy); }
/* Section header for an AppSelect built from a <select> with <optgroup>s. */
.app-select-group {
    padding: 7px 12px 3px;
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: #94a3b8;
}
.app-select-group:not(:first-child) { margin-top: 2px; border-top: 1px solid #f1f5f9; }

/* ---- AppDate (shared date picker — the standard date control, 2026-07-29) ----
   window.AppDate (general.js) replaces every <input type="date"> with these two
   parts: .app-date-input is the VISIBLE field (a text input that inherits the
   native's own classes, so .form-field / a feature width class / a validator's
   error class all keep applying) and .app-date-panel is the calendar, rendered
   into <body> as position:fixed — body-portaled precisely so no ancestor's
   overflow can clip it (the documented AppSelect clip trap does not apply here).
   The native input itself is neutralized with inline styles by the module, not
   from this file, so feature CSS cannot un-hide it. */
.app-date-input {
    /* The calendar glyph rides in the field the same way select.form-field
       carries its caret — one element, no wrapper to break feature layouts. */
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23667085' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4.5' width='18' height='16.5' rx='2.5'/%3E%3Cpath d='M3 9.5h18M8 2.5v4M16 2.5v4'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 9px center;
    padding-right: 32px;
    cursor: pointer;
}
/* A date is 10 characters — never the 520px of a free-text field, nor the 420px
   of the .form-modern column. This is the width every screen used to set by hand
   (.wr-date / .bw-field-200 / .form-field-narrow all landed on 220px); the
   second selector is only there to outrank `.form-modern .form-field`. */
.app-date-input,
.form-modern .app-date-input { max-width: 220px; }
.app-date-input:focus { cursor: text; }
.app-date-input[readonly], .app-date-input:disabled { cursor: default; }
.app-date-input:disabled { background-color: #f4f6f9; color: #9aa3ad; border-color: #e2e6ea; }

.app-date-panel {
    position: fixed;
    z-index: 9200;              /* above the 9000 modal band, below appConfirm (9700) */
    display: none;
    width: 274px;
    padding: 8px;
    background: #fff;
    border: 1px solid #d5d5d5;
    border-radius: 8px;
    box-shadow: 0 10px 28px rgba(20, 30, 50, 0.16);
    font-family: 'Manrope', sans-serif;
}
.app-date-head { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
.app-date-nav {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    flex-shrink: 0;
    color: var(--text-soft);
    background: none;
    border: 1px solid transparent;
    border-radius: 5px;
    cursor: pointer;
}
.app-date-nav:hover:not(:disabled) { background: var(--hairline-soft); color: var(--navy); }
.app-date-nav:disabled { color: #cbd3dc; cursor: not-allowed; }
.app-date-title {
    flex: 1;
    height: 28px;
    font-family: inherit;
    font-size: 13px;
    font-weight: 700;
    color: var(--navy);
    background: none;
    border: 1px solid transparent;
    border-radius: 5px;
    cursor: pointer;
}
.app-date-title:hover { background: var(--hairline-soft); }
.app-date-dows, .app-date-week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.app-date-dow {
    text-align: center;
    font-size: 10.5px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-muted);
    padding: 4px 0 2px;
}
.app-date-grid { display: grid; gap: 2px; }
.app-date-cell {
    height: 32px;
    font-family: inherit;
    font-size: 12.5px;
    font-variant-numeric: tabular-nums;
    color: var(--text-soft);
    background: none;
    border: 1px solid transparent;
    border-radius: 5px;
    cursor: pointer;
}
.app-date-cell:hover:not(:disabled) { background: #eef3fb; color: var(--navy); }
.app-date-cell--out { color: #b6bfcb; }
.app-date-cell--today { border-color: #c6d9f1; font-weight: 700; color: var(--navy); }
.app-date-cell--sel, .app-date-cell--sel:hover:not(:disabled) {
    background: var(--navy);
    border-color: var(--navy);
    color: #fff;
    font-weight: 700;
}
.app-date-cell:disabled { color: #cdd4dd; cursor: not-allowed; }
.app-date-cell:focus-visible { outline: 2px solid var(--control-focus); outline-offset: 1px; }
/* Month / year pages: 3 columns of taller blocks in the same grid. */
.app-date-grid--blocks .app-date-week { grid-template-columns: repeat(3, 1fr); }
.app-date-cell--block { height: 40px; }
.app-date-foot {
    display: flex;
    justify-content: space-between;
    gap: 8px;
    margin-top: 6px;
    padding-top: 6px;
    border-top: 1px solid var(--hairline-soft);
}
.app-date-link {
    padding: 4px 8px;
    font-family: inherit;
    font-size: 12px;
    font-weight: 600;
    color: var(--navy);
    background: none;
    border: 1px solid transparent;
    border-radius: 5px;
    cursor: pointer;
}
.app-date-link:hover:not(:disabled) { background: var(--hairline-soft); }
.app-date-link--muted { color: var(--text-muted); }
.app-date-link:disabled { color: #cbd3dc; cursor: not-allowed; }
/* Single app breakpoint (700px): bigger touch targets, and the field spans the
   stacked form row like every other mobile control. */
@media (max-width: 700px) {
    .app-date-input { max-width: 100%; }
    .app-date-panel { width: 300px; }
    .app-date-cell { height: 38px; }
    .app-date-cell--block { height: 44px; }
}

.btn-clear-filters {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 5px 10px;
    font-size: 12px;
    color: #c0392b;
    background: none;
    border: 1px solid #e0c8c5;
    border-radius: 5px;
    cursor: pointer;
    white-space: nowrap;
    transition: border-color 0.15s, color 0.15s;
}
.btn-clear-filters:hover {
    color: #a93226;
    border-color: #c0392b;
}

/* Export CSV button standard: reuse the borderless, navy .app-grid-export style
   (defined with the AppGrid toolbar below). When placed in the outside
   .filter-row toolbar of a card list page, it sits at the top-right, opposite
   the record count; .app-grid-export already hides itself on mobile. */
.filter-row .app-grid-export { margin-left: auto; }

/* ---- Shared Sort control (.app-sort) ----
   The "Sort" dropdown + direction-toggle bar for hand-built card-list pages
   (Indicative Transactions, Initial Subscriptions, Onboarding, Additional Subs,
   Capital Calls). Markup: views/general/app_sort_control.php; behavior:
   window.AppSort (assets/general/app_sort.js). It is NOT a filter input, so it
   lives in the .filter-row toolbar OUTSIDE the collapsible AppFilters panel and
   stays a full-width sort bar on mobile (the Export button hides on mobile). */
.app-sort { display: inline-flex; align-items: center; gap: 6px; }
.app-sort-label { font-size: 12px; font-weight: 600; color: #8a94a0; }
.app-sort-select { max-width: 150px; }
.app-sort-dir {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border: 1px solid #d7dbe0;
    border-radius: 6px;
    background: #fff;
    color: var(--navy);
    cursor: pointer;
    flex-shrink: 0;
}
.app-sort-dir:hover { background: #f2f7fc; border-color: var(--navy); }
.app-sort-dir svg { transition: transform 0.15s ease; }
.app-sort-dir.is-asc svg { transform: rotate(180deg); } /* default arrow = desc; asc flips it up */
/* In a .filter-row toolbar, push Sort + Export to the right as a group: .app-sort
   owns the auto margin; an Export button that FOLLOWS it drops its own auto margin
   (the .filter-row .app-grid-export rule above) so the two sit together. */
.filter-row .app-sort { margin-left: auto; }
.filter-row .app-sort ~ .app-grid-export { margin-left: 0; }

/* The OUTSIDE (mobile) Clear Filters button that goes with an AppFilters panel:
   hidden until a filter is active (.is-on, toggled by JS), and never shown on
   desktop, where the panel header's "Clear all" is visible. Styling is
   .btn-clear-filters; this class is only the visibility wrapper.
   SYNTHESIZED since 2026-08-17 — AppFilters.create() appends one after every
   panel that has a desktop "Clear all" to mirror (mobileClearFor), because the
   700px block hides `.app-filter-head` and took the only clear-everything
   control with it. The slot had been declared here and used by nothing since the
   panel shipped. It sits AFTER the panel so it is reachable while the panel is
   still collapsed; the panel's 18px bottom margin would otherwise strand it in
   the gap, so in that position the button owns the spacing instead. */
.app-filter-clear-mobile { display: none; margin-bottom: 18px; }
.app-filter-clear-mobile.is-on { display: inline-flex; }
@media (min-width: 701px) { .app-filter-clear-mobile { display: none !important; } }

/* ============================================================================
 * Search & Filter panel (AppFilters) — shared, reusable. See assets/general/
 * app_filters.js. Desktop-collapsible header (count badge + Clear all), a
 * prominent search field, an auto-fitting dropdown grid of cascading AppSelects,
 * and removable active-filter chips. Mobile collapses behind .app-filter-toggle.
 * Features only style the cell/card content they inject — not this panel.
 * ========================================================================== */
.app-filter-panel {
    display: block;
    background: #fff;
    border: 1px solid #e4e8ee;
    border-radius: 12px;
    box-shadow: 0 1px 2px rgba(20, 30, 50, .04),
                0 12px 32px -24px rgba(20, 30, 50, .45);
    margin-bottom: 18px;
    overflow: visible; /* let AppSelect dropdown panels overflow the card */
}
/* The native `hidden` attribute must win over `display:block` (same reason as
   .callout-banner[hidden]) — lets a page hide the panel until it has data
   (e.g. NAV Pack, until a fund is picked). */
.app-filter-panel[hidden] { display: none !important; }

/* ---- Header strip (doubles as the desktop collapse toggle) ---- */
.app-filter-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 13px 18px;
    border-bottom: 1px solid var(--hairline-soft);
    background: linear-gradient(180deg, #fbfcfe 0%, #f4f7fa 100%);
    border-radius: 12px 12px 0 0;
}
.app-filter-head-l { display: inline-flex; align-items: center; gap: 10px; min-width: 0; }
.app-filter-title {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    font-size: 13px;
    font-weight: 700;
    color: var(--text);
    letter-spacing: .2px;
}
.app-filter-title svg { color: var(--navy); flex-shrink: 0; }
/* Active-filter count pill — revealed by JS (.is-on) when >0 filters apply.
   Lives both in the header (desktop) and the mobile toggle button. */
.app-filter-count {
    display: none;
    align-items: center;
    justify-content: center;
    min-width: 20px;
    height: 20px;
    padding: 0 7px;
    font-size: 11px;
    font-weight: 700;
    line-height: 1;
    color: #fff;
    background: var(--navy);
    border-radius: 20px;
}
.app-filter-count.is-on { display: inline-flex; }
.app-filter-head-r { display: inline-flex; align-items: center; gap: 8px; }
/* "Clear all" lives in the header on desktop (revealed by JS .is-on). */
.app-filter-clear {
    display: none;
    align-items: center;
    gap: 5px;
    font-size: 12px;
    font-weight: 600;
    color: #c0392b;
    background: none;
    border: none;
    cursor: pointer;
    padding: 4px 8px;
    border-radius: 6px;
    transition: background .15s, color .15s;
}
.app-filter-clear:hover { background: #fbecec; color: #a93226; }
.app-filter-clear.is-on { display: inline-flex; }
.app-filter-head-toggle { color: #9aa4b1; flex-shrink: 0; transition: transform .15s; }
.app-filter-panel.is-expanded .app-filter-head-toggle { transform: rotate(180deg); }

/* ---- Body ---- */
.app-filter-body { padding: 16px 18px 18px; }

/* ---- Search field (leading magnifier icon) ---- */
.app-filter-search { position: relative; margin-bottom: 16px; }
.app-filter-search-ico {
    position: absolute;
    left: 13px;
    top: 50%;
    transform: translateY(-50%);
    color: #9aa4b1;
    pointer-events: none;
}
.app-filter-search-input {
    width: 100%;
    max-width: 400px; /* capped on desktop; full width on mobile (see @media) */
    padding: 10px 14px 10px 38px;
    font-size: 13.5px;
    color: var(--text);
    border: 1px solid #d8dee6;
    border-radius: 9px;
    background: #fff;
    box-sizing: border-box;
    -webkit-appearance: none;
    transition: border-color .15s, box-shadow .15s;
}
.app-filter-search-input::placeholder { color: #9aa4b1; }
.app-filter-search-input:focus {
    outline: none;
    border-color: var(--navy);
    box-shadow: 0 0 0 3px rgba(15, 52, 96, .10);
}

/* ---- Dropdown grid (neat auto-fitting columns instead of ragged wrap) ---- */
.app-filter-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
    gap: 14px 16px;
}
.app-filter-field {
    display: flex;
    flex-direction: column;
    gap: 6px;
    min-width: 0;
}
.app-filter-field label {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: .5px;
    color: #8a93a0;
}
.app-filter-field .app-select { width: 100%; min-width: 0; }

/* Tune the AppSelect trigger to match this panel's taller, rounder controls
   (scoped to .app-filter-panel so the global widget is untouched elsewhere). */
.app-filter-panel .app-select-trigger {
    padding: 9px 12px;
    border-radius: 9px;
    border-color: #d8dee6;
    font-size: 13px;
    transition: border-color .15s, box-shadow .15s, background .15s;
}
.app-filter-panel .app-select-trigger:hover { border-color: #b9c3cf; }
/* Open dropdown gets the focus ring; the active (selected) state is signalled by
   the navy border + light fill from the AppSelect base, kept ring-free. */
.app-filter-panel .app-select.open .app-select-trigger {
    box-shadow: 0 0 0 3px rgba(15, 52, 96, .10);
}

/* ---- AppSelect toggle variant (segmented button group) ---- */
.app-select-toggle {
    display: inline-flex;
    /* WRAPS (2026-08-31). It could not, and a group of six options with real
       labels ("Transaction type", "Value-date month" — the SRT Charges cut
       selector) simply overflowed its row on a narrow viewport. Fixed here
       rather than overridden per feature: a control that cannot wrap is a bug at
       every option count, it just does not show until one screen has enough of
       them. Two- and three-option groups are unaffected — flex-wrap only acts
       where the row would otherwise overflow. The button's own `border-left`
       reads as a divider at the start of a second row, which is why no
       compensating rule is needed. */
    flex-wrap: wrap;
    border: 1px solid #d8dee6;
    border-radius: 9px;
    overflow: hidden;
    background: #f4f6f9;
}
.app-select-toggle-btn {
    appearance: none;
    border: 0;
    border-left: 1px solid #d8dee6;
    background: transparent;
    padding: 9px 14px;
    font-size: 13px;
    font-family: inherit;
    font-weight: 600;
    color: #5a6473;
    cursor: pointer;
    transition: background .12s, color .12s;
}
.app-select-toggle-btn:first-child { border-left: 0; }
.app-select-toggle-btn:hover:not(:disabled):not(.is-active) { background: #e9eef4; }
.app-select-toggle-btn.is-active { background: var(--navy); color: #fff; }
.app-select-toggle-btn:disabled { color: #b6bcc5; cursor: not-allowed; background: #f4f6f9; }
/* In a filter panel field, fill the column width and split evenly so it aligns
   with the other (full-width) dropdown controls. */
.app-filter-field .app-select-toggle { display: flex; width: 100%; }
.app-filter-field .app-select-toggle-btn { flex: 1 1 0; text-align: center; }

/* ---- Switch (checkbox restyled as an iOS-style toggle) ----
   Shared primitive: keep the real <input type="checkbox"> in the DOM (it still
   posts + stays keyboard-accessible); it is visually hidden and the .app-switch-track
   is the painted control. Markup:
     <label class="app-switch">
       <input type="checkbox" name="…" value="1">
       <span class="app-switch-track"><span class="app-switch-thumb"></span></span>
       <span class="app-switch-text">Label</span>
     </label>                                                                   */
.app-switch {
    display: inline-flex;
    align-items: center;
    gap: 10px;
    cursor: pointer;
    font-size: 13px;
    font-weight: 600;
    color: var(--text-soft);
}
.app-switch input[type="checkbox"] {
    position: absolute;
    opacity: 0;
    width: 0;
    height: 0;
}
.app-switch-track {
    position: relative;
    flex-shrink: 0;
    display: inline-block;
    width: 40px;
    height: 22px;
    border-radius: 999px;
    background: #d1d7e0;
    transition: background .15s;
}
.app-switch-thumb {
    position: absolute;
    top: 2px;
    left: 2px;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 2px rgba(15, 28, 54, .3);
    transition: transform .15s;
}
.app-switch input[type="checkbox"]:checked + .app-switch-track { background: var(--navy); }
.app-switch input[type="checkbox"]:checked + .app-switch-track .app-switch-thumb { transform: translateX(18px); }
.app-switch input[type="checkbox"]:focus-visible + .app-switch-track { box-shadow: 0 0 0 3px rgba(15, 52, 96, .18); }
.app-switch-text { line-height: 1.3; }
/* Disabled: the switch stays READABLE (it is still answering a question the user
   needs to see) and only reads as out of play. `cursor` lives on the label, so
   the label is what has to be reselected — same shape as the .atr-switch rule
   this generalises. ⚠ A disabled checkbox POSTS NOTHING, so disable a switch
   only where the endpoint does not read the field. */
.app-switch input[type="checkbox"]:disabled + .app-switch-track { opacity: .5; }
.app-switch input[type="checkbox"]:disabled ~ .app-switch-text { color: #98a2b3; }
.app-switch:has(input[type="checkbox"]:disabled) { cursor: not-allowed; }

/* ---- Switch ROW: one question, its explanation, and the answer ----
   The shape a preferences / settings page is made of, shared from 2026-08-31
   (My Profile Invoice Emails, System Settings options and Investor Onboarding
   Reminder Emails all asked for it in one ticket).

     <div class="app-switch-row">
       <div class="app-switch-row-main">
         <span class="app-switch-row-title">Label <span class="app-tip">…</span></span>
         <span class="app-switch-row-desc">What answering yes will do.</span>
       </div>
       <input type="hidden" name="Field" value="0">
       <label class="app-switch" for="fieldId">
         <input type="checkbox" id="fieldId" name="Field" value="1">
         <span class="app-switch-track"><span class="app-switch-thumb"></span></span>
         <span class="app-switch-state" aria-hidden="true">
           <span class="app-switch-state-on">Yes</span><span class="app-switch-state-off">No</span>
         </span>
       </label>
     </div>

   ⚠ The title is deliberately OUTSIDE the <label>: it may carry an .app-tip,
   and a click on a tooltip inside a label toggles the control it labels.
   ⚠ The hidden companion is not optional — an unchecked checkbox posts NOTHING,
   so without it an "off" answer arrives absent rather than as 0.                */
.app-switch-row {
    display: flex;
    align-items: flex-start;
    gap: 18px;
    padding: 13px 0;
    border-bottom: 1px solid var(--hairline-soft);
}
.app-switch-row:last-child { border-bottom: 0; }
.app-switch-row-main { flex: 1 1 auto; min-width: 0; }
.app-switch-row-title {
    display: block;
    font-size: 13px;
    font-weight: 600;
    color: var(--text);
}
.app-switch-row-desc {
    display: block;
    margin-top: 3px;
    font-size: 12px;
    line-height: 1.45;
    color: var(--text-muted);
}
.app-switch-row > .app-switch { flex: 0 0 auto; padding-top: 1px; }

/* The word twin. A switch states its answer by position; on a settings page
   that answer is also read as Yes / No, and the two must never disagree — so
   it is pure CSS off the checkbox's own :checked state, with nothing to wire
   and nothing to keep in step. */
.app-switch-state {
    min-width: 24px;
    font-size: 12px;
    font-weight: 700;
    color: var(--text-muted);
}
.app-switch-state .app-switch-state-on { display: none; }
.app-switch input[type="checkbox"]:checked ~ .app-switch-state { color: var(--navy); }
.app-switch input[type="checkbox"]:checked ~ .app-switch-state .app-switch-state-on { display: inline; }
.app-switch input[type="checkbox"]:checked ~ .app-switch-state .app-switch-state-off { display: none; }

/* A read-only answer keeps the row's shape, so a view page and its form line up
   column for column and the reader is looking at a VALUE rather than a control
   they are not allowed to touch. */
.app-switch-row-value {
    flex: 0 0 auto;
    padding-top: 1px;
    font-size: 12px;
    font-weight: 700;
    color: var(--text-muted);
}
.app-switch-row-value.is-on { color: var(--navy); }

/* Narrow: the answer drops under its question rather than squeezing the words.
   (A @media adds no specificity — this must stay AFTER the base rules above.) */
@media (max-width: 620px) {
    .app-switch-row { flex-direction: column; gap: 8px; }
    .app-switch-row > .app-switch { padding-top: 0; }
}

/* ---- Active-filter chips (revealed by JS .is-on) ---- */
.app-filter-chips {
    display: none;
    flex-wrap: wrap;
    gap: 8px;
    margin-top: 16px;
    padding-top: 14px;
    border-top: 1px dashed #e7ebf0;
}
.app-filter-chips.is-on { display: flex; }
.app-chip {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 5px 6px 5px 12px;
    background: #eef3f9;
    border: 1px solid #d9e3ef;
    border-radius: 20px;
    font-size: 12px;
    color: #1a3354;
    max-width: 100%;
}
.app-chip-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 260px;
}
.app-chip-key { font-weight: 700; color: var(--navy); }
.app-chip-x {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: rgba(15, 52, 96, .10);
    color: var(--navy);
    border: none;
    cursor: pointer;
    flex-shrink: 0;
    transition: background .15s, color .15s;
}
.app-chip-x:hover { background: var(--navy); color: #fff; }

/* ---- Mobile-only collapse toggle (a polished white card button) ---- */
.app-filter-toggle {
    display: none;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    width: 100%;
    margin-bottom: 12px;
    padding: 13px 16px;
    background: #fff;
    border: 1px solid #e4e8ee;
    border-radius: 10px;
    box-shadow: 0 1px 2px rgba(20, 30, 50, .04),
                0 10px 26px -22px rgba(20, 30, 50, .45);
    font-family: inherit;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: .2px;
    color: var(--text);
    cursor: pointer;
}
.app-filter-toggle:hover { border-color: #d3dae3; }
/* A panel that hides itself takes its toggle with it. Two of the app's panels
   are revealed only once there is something to filter (NAV Pack, until a fund +
   period are picked — `filters.hidden` in nav_pack.js showPick), and since
   AppFilters now SYNTHESIZES this button for every panel (mobileToggleFor in
   app_filters.js) the pairing has to hold at runtime, not just at first paint.
   `:has()` does that with no observer and no JS state to keep in step: the
   button follows the `hidden` attribute on the panel it precedes. It is always
   the immediately-preceding sibling — synthesized (insertBefore), built
   (panelHtml) and hand-authored (Report Builder) all put it there. */
.app-filter-toggle:has(+ .app-filter-panel[hidden]) { display: none !important; }
.app-filter-toggle-l { display: inline-flex; align-items: center; gap: 9px; min-width: 0; }
.app-filter-toggle-l > svg { color: var(--navy); flex-shrink: 0; }
.app-filter-toggle-ico { color: #9aa4b1; flex-shrink: 0; transition: transform .15s; }
.app-filter-toggle.is-open .app-filter-toggle-ico { transform: rotate(180deg); }

/* Desktop: the header strip is the collapse toggle; the body collapses beneath
   it. Default is collapsed (no .is-expanded); AppFilters remembers the user's
   choice in localStorage. Scoped to desktop widths (mobile uses the toggle). */
@media (min-width: 701px) {
    .app-filter-head { cursor: pointer; }
    .app-filter-head:hover { background: linear-gradient(180deg, #f7fafc 0%, #eef3f8 100%); }
    .app-filter-body { display: none; }
    .app-filter-panel.is-expanded .app-filter-body { display: block; }
    /* Collapsed: the header is the only visible part — fully round it + drop the
       divider so it reads as a single pill. */
    .app-filter-panel:not(.is-expanded) .app-filter-head {
        border-bottom: none;
        border-radius: 12px;
    }
}

/* Mobile (the standard 700px breakpoint): show the toggle, collapse the panel,
   hide the desktop header, stack one filter per full-width row. */
@media (max-width: 700px) {
    .app-filter-toggle { display: flex; }
    .app-filter-panel.is-collapsed { display: none; }
    /* Hand the gap below the panel to the synthesized "Clear all" when it is on,
       so the button sits with the panel instead of stranded in its 18px margin.
       MUST stay inside this block: above 700px the button is
       `display:none !important` but still a MATCHING sibling, so an unscoped
       `:has()` would silently shave 8px off every desktop panel the moment a
       filter went active (caught in QA the same day it was written). */
    .app-filter-panel:has(+ .app-filter-clear-mobile.is-on) { margin-bottom: 10px; }
    .app-filter-head { display: none; }
    .app-filter-body { padding: 14px; }
    .app-filter-grid { grid-template-columns: 1fr; }
    .app-filter-field .app-select { min-width: 0; width: 100%; }
    .app-filter-search-input { font-size: 16px; max-width: 100%; } /* 16px avoids iOS focus zoom */
}

/* ---- App Card (shared section card) ----
   The standard content card for feature pages: a white panel with a hairline
   border, soft shadow, and an optional icon/title/actions header. Promoted from
   the Opus Invoices feature (was .oi-card*) so every page renders cards the same
   way — a feature should NOT define its own card recipe. Use:
     <div class="app-card">
       <div class="app-card-head">
         <span class="app-card-ico">…icon…</span>
         <div class="app-card-heading">
           <h2 class="app-card-title">Title</h2>
           <span class="app-card-sub">optional subtitle</span>
         </div>
         <div class="app-card-actions">…optional buttons…</div>
       </div>
       <div class="app-card-body">…</div>
     </div>
   .app-card-actions keeps that markup position but renders on its OWN row below
   the icon/title, LEFT-aligned (operator standard, 2026-07-24 — see the rule on
   the class itself): the same reasoning as .page-actions under the page hero.
   .app-card-grid trims the body padding for a card whose body is ONLY an AppGrid
   (the grid brings its own toolbar padding). overflow stays visible so AppGrid /
   AppSelect dropdowns inside a card are never clipped by the rounded edge. */
/* .app-card and .app-panel share ONE look (converged 2026-07-23, operator-
   approved): the box + head-surface rules below are grouped so a restyle stays
   a one-place edit. Only layout/typography mechanics differ per family (see the
   App Panel section). */
.app-card, .app-panel {
    background: #fff;
    border: 1px solid var(--hairline);
    border-radius: 12px;
    box-shadow: 0 1px 3px rgba(15, 28, 54, .05);
    margin-bottom: 18px;
}
/* ⚠ `.app-pane-head` IS IN THIS GROUP TOO since 2026-09-11, when the workspace
   pane column became a card: the band at the top of `.app-work-col` is this same
   surface, and it overrides only the padding — down in THE WORKSPACE block. */
.app-card-head, .app-panel-head, .app-pane-head {
    padding: 14px 20px 12px;
    border-bottom: 1px solid var(--hairline-soft);
    background: linear-gradient(180deg, #fbfcfe 0%, #ffffff 100%);
    border-radius: 12px 12px 0 0;
}
.app-card-head {
    display: flex;
    align-items: center;
    gap: 12px;
    /* Wraps so .app-card-actions can take a row of its own below the
       icon/title (see the actions rule) — the icon + heading always share
       the first line. */
    flex-wrap: wrap;
}
.app-card-ico {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    border-radius: 9px;
    background: #eef3fb;
    color: var(--navy);
}
.app-card-heading { flex: 1; min-width: 0; }
.app-card-title {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 15.5px;
    font-weight: 700;
    color: var(--text);
    margin: 0;
    letter-spacing: .1px;
}
.app-card-sub {
    display: block;
    font-size: 12.5px;
    color: var(--text-muted);
    margin-top: 2px;
    line-height: 1.5;
}
/* Card actions get their OWN full-width row under the icon/title, LEFT-aligned
   (operator standard, 2026-07-24 — replaces the earlier top-RIGHT placement).
   Same reasoning as .page-actions under the page hero: on a WIDE page a
   right-floated button lands far from the content the reader is looking at (and
   off-screen entirely once the page scrolls horizontally under the wide-grid
   width model), and on a NARROW card it crowds or wraps oddly against the title.
   A full-width row is the one placement that reads the same at every width — so
   this is no longer a mobile-only reflow, it is the default. Markup is unchanged
   (the actions div stays the last child of .app-card-head); the head's
   flex-wrap + this flex-basis do the work. */
.app-card-actions {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px;
    flex: 1 1 100%;
    margin-left: 0;
    justify-content: flex-start;
}
.app-card-body, .app-panel-body { padding: 16px 20px 18px; }
.app-card-grid, .app-panel-grid { padding: 12px 16px 14px; }
/* Soft-tinted head for a conditional / secondary FORM SECTION card — e.g. the
   Auto-Pay method sections revealed by the Payment Method toggle (promoted
   from .oi-method-section, 2026-07-23). Compose: class="app-card app-card--soft". */
.app-card--soft .app-card-head { background: #f4f7fb; }

/* ---- App Panel (the span-title section card) ----
   Consolidated 2026-07-23 from the 17 byte-identical pre-.app-card feature
   cards (adh/aml/amlr/cct/cdd/def/dl/hut/ic/ica/ni/ns/pt/pta/pa/q/tr-card*),
   then visually CONVERGED onto the .app-card look (operator-approved, same
   day): the box + head-surface rules are grouped with .app-card above. What
   remains here is only what differs from .app-card:
   - the head carries the title typography DIRECTLY (markup is
     <div class="app-panel-head"><span>Title</span> …optional pill…</div>,
     not the .app-card-ico/.app-card-heading structure) and STACKS its children,
     so a trailing pill gets its own left-aligned row under the title (2026-07-24
     — it used to be space-betweened to the top-right; see the rule below);
   - the panel keeps the legacy overflow clip — partial-migration features'
     LOCAL heads (ic/ni/ns/tr/dl/pta…) rely on it to round their top corners.
     .app-panel--grid opts out: a panel wrapping an AppGrid must NOT clip
     (overflow:hidden would become the sticky containing block and break the
     grid's sticky top:56px header) — and AppSelect dropdowns inside a clipped
     panel need the same escape (the .tr-step pattern).
   NEW pages should prefer .app-card. */
.app-panel { overflow: hidden; }
.app-panel--grid { overflow: visible; }
/* The head STACKS its children: the title, then anything that follows it — a
   status pill, a tag group — on its OWN row underneath, LEFT-aligned
   (operator standard, 2026-07-24; this replaces `justify-content: space-between`,
   which pushed that trailing element to the top-RIGHT).
   EXCEPTION — an inline QUALIFIER of the title (an id/reference chip such as
   payable_transactions' "Transaction #48210") belongs ON the title line: nest it
   INSIDE the title span rather than adding a second child. That is the whole
   mechanism — one child = one row — so no modifier class is needed. Only a
   STATUS badge / tag group is a separate child. Same reasoning as
   .app-card-actions and .page-actions: a right-parked element sits far from the
   content it describes on a wide page (and can scroll off-screen entirely under
   the wide-grid max-content width model), and crowds the title on a narrow one.
   `flex-direction: column` + `align-items: flex-start` is what gives each child
   its own row WITHOUT stretching it — a pill is a shrink-wrapped inline-block, so
   the `flex-basis: 100%` trick used for the card's button row would balloon it to
   the full card width. Markup is unchanged everywhere (the pill stays the head's
   last child); every one of the 115 heads in the app opens with the title span,
   so a single-child head renders identically to before. */
.app-panel-head {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: flex-start;
    gap: 10px;
    width: 100%;
    font-size: 15.5px;
    font-weight: 700;
    color: var(--text);
    text-align: left;
}
/* The head IS the title, so a head that wraps its title in a HEADING element for
   document structure inherits the head's typography instead of the UA's h2/h3
   metrics. Added 2026-07-24 with the panel-head convergence: ten features write
   <div class="app-panel-head"><h2>Title</h2></div> rather than the <span> the
   examples use, and keeping the heading is worth more than matching the example
   (it is the only heading structure those record pages have). Scoped to
   :first-child so a TRAILING .app-pill / tag group in the head is untouched —
   `font: inherit` on a pill would blow it up to 15.5px. */
.app-panel-head > h2:first-child,
.app-panel-head > h3:first-child { font: inherit; color: inherit; margin: 0; }
/* A head that is also a COLLAPSE control keeps its chevron on the title line:
   the shared head stacks its children (that is right for a status badge, wrong
   for a disclosure affordance). Opt in with .app-panel-head--toggle. */
.app-panel-head--toggle {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
}
/* ...and a head that carries a TRAILING CONTROL rather than a disclosure: a cut
   selector, a period picker, a segmented switch. Same arrangement, different
   reason, so it gets its own name -- a head marked `--toggle` that toggles
   nothing is a lie to the next reader, and reusing that class for this was the
   alternative. Added 2026-09-03 by the Client Dashboard's analytics band
   (app_opus_client_dashboard.php), which is the second thing to need the row. */
.app-panel-head--actions {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
}
/* …and when that control is a <button>, drop the UA chrome the shared head does
   not reset (it only sets border-BOTTOM). */
button.app-panel-head { border: 0; cursor: pointer; font-family: inherit; text-align: left; }
/* (.app-panel-body / .app-panel-grid are aliased onto the .app-card rules above
   — identical paddings by construction.) */

/* ---- A COLLAPSIBLE panel (shared, 2026-08-24) ----
   Markup — a native <details>, so it needs no JS at all and Find-in-page still
   reaches a closed section in Chrome:

     <details class="app-panel app-panel--collapsible">
       <summary class="app-panel-head app-panel-head--toggle">
         <h2>Title</h2>
         <span class="app-panel-toggle-hint">Show</span>
       </summary>
       <div class="app-panel-body">…</div>
     </details>

   Promoted here on its SECOND consumer (the Tickets record page's implementation
   plan; the first was crm.css's `.crm-card--collapsible`, which was left alone at
   the time because its head class was entangled with that screen's own card
   vocabulary — and which was RETIRED on 2026-09-09 when the CRM record view moved
   to the shared read-only shapes and stopped collapsing anything, so this is now
   the only collapsible panel in the tree). Any feature that needs one uses this.

   The chevron is on the SUMMARY, not on the title, so a head carrying a count
   pill or a hint keeps the affordance at its right edge where every other
   disclosure in this app puts it. */
.app-panel--collapsible > summary { cursor: pointer; list-style: none; }
.app-panel--collapsible > summary::-webkit-details-marker { display: none; }
.app-panel--collapsible > summary::marker { content: ''; }
.app-panel--collapsible > summary::after {
    content: '';
    flex: 0 0 auto;
    width: 8px;
    height: 8px;
    margin-left: 4px;
    border-right: 2px solid #8494a7;
    border-bottom: 2px solid #8494a7;
    transform: rotate(45deg) translateY(-2px);
    transition: transform .15s;
}
.app-panel--collapsible[open] > summary::after { transform: rotate(225deg) translateY(-1px); }
.app-panel--collapsible > summary:hover::after { border-color: var(--navy); }
/* The word beside the chevron. Two states from ONE element via ::before, so the
   markup carries no copy that has to be kept in sync with the open state. */
.app-panel-toggle-hint {
    margin-left: auto;
    font-size: 12px;
    font-weight: 600;
    color: var(--text-muted);
}
.app-panel-toggle-hint::before { content: 'Show'; }
.app-panel--collapsible[open] .app-panel-toggle-hint::before { content: 'Hide'; }

/* ---- Panel head trailing note, and panel intro copy (shared, 2026-08-31) ----
   Two pieces of panel vocabulary features had been hand-rolling.
   .app-panel-note is the muted caption at the end of a panel head (a count
   sentence, a "last run" line); .app-panel-lede is a paragraph of intro copy at
   the TOP of a panel body, above whatever the panel actually shows. Both are
   typography only — no margins that would fight the panel's own padding. */
.app-panel-note {
    font-size: 11.5px;
    font-weight: 500;
    color: var(--text-muted);
}
.app-panel-lede {
    margin: 0 0 12px;
    font-size: 12.5px;
    line-height: 1.55;
    color: var(--text-muted);
    max-width: 78ch;
}

/* ---- App View Switch (shared, promoted 2026-08-31) ----
   The pill pair that picks ONE OF TWO VIEWS OF THE SAME DATA — not two tabs
   over two populations, which is what .app-tabs is for. If switching changes
   WHICH rows you see, you want tabs; if it changes the GRAIN or the SHAPE of
   the same rows, you want this.

     <div class="app-viewswitch" role="radiogroup" aria-label="How to list this">
       <button class="app-viewbtn is-active" role="radio" aria-checked="true">By fund</button>
       <button class="app-viewbtn" role="radio" aria-checked="false">All periods</button>
       <span class="app-viewswitch-hint">…</span>
     </div>

   A RADIOGROUP, not two buttons: it picks one of a set and a screen reader
   should hear it that way. Promoted here on its SECOND consumer — it began as
   .aft-viewswitch on the Admin Fee True-Up queue (by fund / all periods) and is
   now also the by-invoice / by-charge-type switch on Generate Monthly Invoices.
   The True-Up view was repointed at this copy in the same change; there is no
   aft- copy left to drift from. */
.app-viewswitch {
    display: flex;
    align-items: center;
    gap: 6px;
    flex-wrap: wrap;
    margin: 0 0 12px;
}
.app-viewbtn {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 5px 12px;
    border: 1px solid var(--hairline);
    border-radius: 999px;
    background: #fff;
    font-size: 12.5px;
    font-weight: 600;
    color: var(--text-soft);
    cursor: pointer;
    font-family: inherit;
    transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.app-viewbtn:hover { border-color: #cfd8e3; background: #fbfcfe; }
.app-viewbtn.is-active {
    background: var(--navy);
    border-color: var(--navy);
    color: #fff;
}
.app-viewswitch-hint {
    font-size: 11.5px;
    color: var(--text-muted);
    margin-left: 4px;
}

/* ---- App Steps (shared, 2026-08-31) ----
   A numbered sequence of actions that must happen IN ORDER, where a later step
   is not available until an earlier one has completed. Built for the monthly
   invoice run (calculate charges, then generate invoices) and written to be the
   shared answer for any multi-step operator workflow.

     <ol class="app-steps">
       <li class="app-step is-done">          <- or .is-current / .is-locked
         <span class="app-step-marker">1</span>
         <div class="app-step-body">
           <h3 class="app-step-title">…</h3>
           <p class="app-step-desc">…</p>
           <div class="app-step-status">…</div>
           <div class="app-step-actions"><button …></div>
         </div>
       </li>
     </ol>

   THE STATE CLASS GOES ON THE <li>, and it is the ONLY thing a caller changes:
     (none)      not started, and reachable
     .is-current the step to do next — the marker is filled navy
     .is-done    finished — the marker becomes a tick via ::after, so the number
                 in the markup does not have to be swapped out
     .is-locked  not yet available — dimmed, and its actions are inert

   ⚠ .is-locked DIMS AND DISABLES POINTER EVENTS ON THE ACTIONS ONLY, never on
   the whole step: the title and the description must stay readable, because
   "why can I not do this yet" is answered by that text. It is also NOT a
   substitute for disabling the button — a locked step whose button is still
   enabled is one keyboard Tab away from being pressed, so the caller disables
   it too. Gated, not hidden.

   The connector rail is drawn on the LI via ::before rather than as a border on
   the list, so the last step does not trail a line into nothing. */
.app-steps {
    list-style: none;
    margin: 0;
    padding: 0;
}
.app-step {
    position: relative;
    display: flex;
    gap: 14px;
    padding: 0 0 22px;
}
.app-step:last-child { padding-bottom: 0; }
/* The rail between one marker and the next. Inset to the marker's centre. */
.app-step:not(:last-child)::before {
    content: '';
    position: absolute;
    left: 13px;
    top: 30px;
    bottom: 6px;
    width: 2px;
    background: var(--hairline);
}
.app-step.is-done:not(:last-child)::before { background: var(--pill-green-bd); }

.app-step-marker {
    flex: 0 0 auto;
    position: relative;
    z-index: 1;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    border: 2px solid var(--hairline);
    background: #fff;
    color: var(--text-muted);
    font-size: 12.5px;
    font-weight: 700;
    line-height: 24px;
    text-align: center;
}
.app-step.is-current > .app-step-marker {
    border-color: var(--navy);
    background: var(--navy);
    color: #fff;
}
.app-step.is-done > .app-step-marker {
    border-color: var(--pill-green-fg);
    background: var(--pill-green-fg);
    /* The number is hidden rather than removed from the markup, so the step
       keeps its ordinal for a screen reader and for a caller that moves the
       step back to an earlier state. */
    color: transparent;
}
.app-step.is-done > .app-step-marker::after {
    content: '';
    position: absolute;
    left: 8px;
    top: 5px;
    width: 6px;
    height: 11px;
    border-right: 2px solid #fff;
    border-bottom: 2px solid #fff;
    transform: rotate(45deg);
}

.app-step-body { flex: 1 1 auto; min-width: 0; padding-top: 2px; }
.app-step-title {
    margin: 0 0 3px;
    font-size: 14px;
    font-weight: 700;
    color: var(--text);
}
.app-step.is-locked > .app-step-body > .app-step-title { color: var(--text-soft); }
.app-step-desc {
    margin: 0;
    font-size: 12.5px;
    line-height: 1.55;
    color: var(--text-muted);
    max-width: 78ch;
}
.app-step-desc strong { color: var(--text-soft); font-weight: 700; }
/* The live region a step writes its outcome into. Empty by default and taking
   no space, so a step that has not run yet does not reserve a blank line. */
.app-step-status { margin-top: 8px; font-size: 12.5px; line-height: 1.5; }
.app-step-status:empty { display: none; }
.app-step-actions {
    display: flex;
    align-items: center;
    gap: 10px;
    flex-wrap: wrap;
    margin-top: 10px;
}
.app-step-hint { font-size: 11.5px; color: var(--text-muted); }
.app-step-hint:empty { display: none; }
/* ⚠ ACTIONS ONLY. The step's own copy stays fully legible when locked. */
.app-step.is-locked > .app-step-body > .app-step-actions {
    opacity: .45;
    pointer-events: none;
}
/* The permission message that replaces the actions when a caller may read the
   step but not run it. Gated, not hidden — the step still says what is there. */
.app-step-denied {
    margin: 10px 0 0;
    font-size: 12px;
    font-weight: 600;
    color: var(--text-muted);
}

/* A component's @media goes AFTER its base rules — a media query adds no
   specificity of its own, so placed earlier it loses to the base and fails
   PARTIALLY, which is worse than failing outright. */
@media (max-width: 700px) {
    .app-step { gap: 10px; }
    .app-step-desc { max-width: none; }
    /* The hint drops to its own line rather than squeezing the pills. Moved
       here from client_invoicing.css with the promotion — a shared component
       owns its own responsive behaviour, or the next consumer inherits the
       desktop rules and none of the narrow ones. */
    .app-viewswitch-hint { flex-basis: 100%; margin-left: 0; }
}

/* ---- App Pill (shared status-pill base) ----
   Consolidated from the byte-identical feature status pills (aml/amlr/cdd/def/
   hut/pt, 2026-07-23). COLOR variants stay feature-local — each feature maps
   its own statuses to a palette (use the --pill-* tokens for the standard
   five). --sm is the 11.5px variant (the def/hut priority pills). */
.app-pill {
    display: inline-block;
    padding: 2px 10px;
    border-radius: 999px;
    font-size: 12px;
    font-weight: 700;
    line-height: 1.5;
    white-space: nowrap;
}
.app-pill--sm { font-size: 11.5px; }

/* ---- The YES / NO pair, shared (promoted 2026-09-16) ----
   `.os-badge--ok` / `.os-badge--muted` are the two colours a settled yes/no
   fact wears: a feature that is ON, and one that simply is not. They were
   declared once, in online_statements.css, and stayed there while they had one
   consumer. Digital Feature Settings is the second — ticket 868m5y3mj item 6.3
   names these two classes for its "Dual Authorization Enabled" badge — so they
   move here rather than being copied, which is the rule the whole
   check_shared_css.php exists to keep (there is a rule for this pair in it now).

   THE NAMES DID NOT CHANGE, and that is deliberate on two counts: the ticket
   names them, so the markup a reader was promised is the markup that renders;
   and renaming would mean editing the two online-statements views for no
   behavioural gain in a tree with no version control. `os-` is now a historical
   prefix, not a scope — read it as "the pair online_statements happened to
   define first".

   Colours unchanged from the originals. They are NOT the --pill-green-* /
   --pill-gray-* tokens: those carry a border colour for the bordered status
   families, and this pair is deliberately flat. */
.os-badge--ok    { background: #e5f6ee; color: #14764a; }
.os-badge--muted { background: #eef1f5; color: #5b6b7c; }

/* ---- App Meter (shared completion bar) ----
   The thin bar that says how much of a finite, countable job is done:

     <span class="app-meter-num">6 of 11 sections complete</span>
     <span class="app-meter"><span class="app-meter-fill" style="width:55%"></span></span>

   THE BAR IS NEVER THE ONLY PLACE THE NUMBER LIVES. It encodes a fraction that
   must already be readable as text beside it — the same rule the chart module
   is built on (a legend and a table view exist so no value is reachable only
   through a graphic). A bar with no twin is a value a screen reader and a
   printed page both lose, so the text span is part of the component, not a
   caption a caller may drop.

   Promoted 2026-08-26 from onboarding's `.co-progress` when the Implementation
   Tasks fund list and its per-fund workspace needed the same meter in three
   more places. It carries NO outer margin: the rail head, a grid cell and a
   page header each want a different gap, and a shared margin is the thing that
   gets overridden locally and then drifts.

   `is-full` is the only state, and it earns a colour of its own: "every one of
   them is in" is a different answer from "6 of 11", not a longer bar. */
.app-meter {
    display: block;
    height: 5px;
    border-radius: 3px;
    background: var(--hairline-soft);
    overflow: hidden;
}
.app-meter-fill {
    display: block;
    width: 0;
    height: 100%;
    border-radius: 3px;
    background: var(--navy);
    transition: width .25s ease;
}
.app-meter-fill.is-full { background: #1e7e34; }
@media (prefers-reduced-motion: reduce) { .app-meter-fill { transition: none; } }
/* The text twin. Not `.app-cell-sub` and not a `-label`: this is the FIGURE the
   bar draws, so it reads at value weight. */
.app-meter-num {
    display: block;
    font-size: 13px;
    font-weight: 700;
    color: var(--text);
    font-variant-numeric: tabular-nums;
}

/* ---- Two-line grid cell (shared) ----
   Bold primary line + muted secondary line inside one grid column — the
   dominant per-feature -cell-main/-cell-sub recipe, consolidated 2026-07-23.
   Features with a deliberately different cell recipe (oi/vp/ib/lp/ba/th main
   variants…) keep their local classes. */
.app-cell-main { display: block; font-weight: 600; color: #22303f; }
/* .app-cell-clamp--sub is the SECONDARY line when it is long enough to need the
   shared clamp (window.AppClamp) — same look, but a <div> block rather than a
   <span>, because AppClamp.html() emits one. Listed here rather than given its
   own values so the two can never drift. */
.app-cell-sub,
.app-cell-clamp--sub { display: block; font-size: 12px; color: #7a8798; margin-top: 1px; }
/* .app-cell-clamp-text pins the PRIMARY text colour; a sub line inherits the
   muted one above instead. */
.app-cell-clamp--sub .app-cell-clamp-text { color: inherit; }
/* .app-cell-quiet is the whole value of a ONE-fact cell that is absent or
   unremarkable — an em dash, a "none", a zero that is genuinely nothing.
   Not `.app-cell-sub`: that is the second LINE of a two-fact cell and carries
   its own size and spacing. Promoted out of opus_client_dashboard.css on
   2026-09-04 when Tech Team Services became the second feature to need it. */
.app-cell-quiet { color: var(--text-soft); }

/* ---- Read-only field grid (shared) ----
   The <dl> of label/value pairs used on detail & respond pages — the dominant
   per-feature -fields/-field recipe, consolidated 2026-07-23. Markup:
     <dl class="app-fields">
       <div class="app-field [app-field-wide]"><dt>Label</dt><dd>Value</dd></div>
     </dl>
   Features on the 230/240px variants (ni/ns/q, af/it/co/pt…) keep local. */
.app-fields {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 12px 24px;
    margin: 0;
}
.app-field dt {
    font-size: 12px;
    font-weight: 600;
    color: #7a8798;
    text-transform: uppercase;
    letter-spacing: 0.02em;
    margin-bottom: 2px;
}
.app-field dd { margin: 0; font-size: 14px; color: #22303f; word-wrap: break-word; }
.app-field-wide { grid-column: 1 / -1; }
/* FIGURE field (money / decimal / count) in a read-only field grid. Add the
   modifier to the .app-field wrapper, not to the <dd>.
   ⚠ IT DOES NOT ALIGN — it TYPESETS. A figure gets tabular numerals (so digits
   occupy equal width and stack cleanly when the grid wraps) and a heavier weight
   (so the amounts a reader scans for stand out from the reference text around
   them). Alignment stays whatever the card's other fields use, i.e. left.
   Why, in two operator rounds on app_payable_transactions.php?page=view /
   app_investor_transactions.php (2026-08-05):
     ROUND 1 — the page right-aligned only the <dd> through a page-local class, so
       in a 3-across grid each label sat at its cell's left edge while its number sat
       at the right: label and figure visibly unrelated. Fixed by right-aligning the
       label too.
     ROUND 2 — with both right-aligned, a card holding four text fields and three
       figures had its cells alternating left and right for no reason the reader
       could see. "They make the form look unpolished and unprofessional." Correct:
       right-alignment earns its keep in a TABLE, where figures stack in one column
       and decimals must line up (.app-grid-num, and the mechanism AppGrid's
       `numeric: true` drives). A <dl> field grid has no such column — every cell
       carries its own label — so right-aligning there buys nothing and costs the
       card's visual spine. Emphasis, not alignment, is what a figure needs here.
   A screen that genuinely wants figures to READ as figures should promote them out
   of the field grid into the shared key-figures band (.app-figures, below) rather
   than reach for alignment. */
.app-field--num dd {
    font-variant-numeric: tabular-nums;
    font-feature-settings: 'tnum' 1;
    font-weight: 600;
}
/* Width variants (consolidated 2026-07-23): same grid, wider min column. The
   700px stack rule below applies to these too (declared after = wins the tie). */
.app-fields--w230 { grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }
.app-fields--w240 { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
/* [hidden] companion — `display:grid` above is an AUTHOR rule and beats the UA's
   [hidden]{display:none}, so a conditionally-shown field strip (`<dl
   class="app-fields" hidden>`) rendered ANYWAY, as an empty band of labels. Same
   convention as .callout-banner[hidden] / .app-filter-panel[hidden] /
   .app-totals[hidden]. Found via the Data Rooms campaign form, whose room-facts
   strip was visible from first paint with nothing selected (2026-07-27). */
.app-fields[hidden] { display: none !important; }
@media (max-width: 700px) {
    .app-fields { grid-template-columns: 1fr; }
}

/* ---- Key-figures band (shared) ----
   The row of figure TILES that leads a detail page or modal — the amounts a reader
   opened the record to see, at figure weight, above the reference fields.

   WHY IT IS SHARED, and why it is the answer to "my numeric fields look ragged":
   money in a <dl> field grid has no good alignment (see .app-field--num's comment —
   right-alignment was tried and rejected in an operator round because it splits a
   card's cells between two alignments). The fix is not to align the money; it is to
   promote it OUT of the field grid, leaving the grid a clean single-spine list of
   reference values. This band is where it goes. Promoted from payable_transactions'
   `.pt-figures` on 2026-08-05, when the third screen needed it and two of the three
   could not see that stylesheet.

     <div class="app-figures [app-figures--compact]">
       <div class="app-figure [app-figure--accent|--warn|--ok]">
         <span class="app-figure-label">Estimated Balance</span>
         <span class="app-figure-value">3,160,000.00 <em>USD</em></span>
       </div>
     </div>

   --compact  reference scale, for figures that SUPPORT the headline ones rather
              than compete with them (a holdback %, what has already been paid).
   --accent   the one figure the decision turns on (teal).
   --warn / --ok  a COUNT whose value carries meaning: 0 outstanding is the good
              answer, non-zero blocks something. Colour says which without prose.
   <em> inside the value is the muted qualifier (a currency code, a unit). */
.app-figures {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
    margin-bottom: 18px;
}
/* FLEX BASIS 168px, not 180px (2026-08-19). The tiles carry `flex-grow: 1`, so on
   any surface with room the row looks IDENTICAL either way — the basis only
   decides HOW MANY fit before the band wraps. At 180px a FOUR-tile band needs
   756px and a `.modal-lg` record body offers ~743 once the dialog padding, the
   card padding and the scrollbar are taken out, so it wrapped 3 + 1: three tiles
   and then one stretched across the full width, which reads as a mistake. 168px
   needs 708px and fits. Found on the shared Capital Summary record body
   (views/general/capital_summary_partial.php), whose band is four tiles and whose
   two hosts are a 1100px page AND an 840px modal. `min-width: 158px` still stops
   a wide band from crushing its figures. */
.app-figure {
    flex: 1 1 168px;
    min-width: 158px;
    padding: 12px 14px;
    background: var(--head-bg);
    border: 1px solid var(--hairline);
    border-radius: 9px;
}
.app-figure-label {
    display: block;
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    color: #7a8798;
    margin-bottom: 4px;
    line-height: 1.3;
}
/* `overflow-wrap: anywhere` ON THE VALUE AND THE SUB (2026-09-02, ticket 868m070pm).
   A tile is `flex: 1 1 168px; min-width: 158px`, so in a four-tile band inside a
   `.modal-lg` body each is ~177px wide and its text has ~150px to live in. A figure
   is short, but a SUB is often "by <login> on <date>", and a login is one
   unbreakable token — on the wire record's Dual authorization tile it ran straight
   through the tile's right border and across the band. Wrapping inside the token
   keeps every tile's text inside its own box, and because the flex line stretches
   its tiles to the tallest one, the band stays aligned when one of them takes two
   lines. `anywhere` rather than `break-word`: the tile's min-width is explicit, so
   the min-content difference between the two never reaches the layout, and it
   matches `.app-rv-tl-note`. It never fires while the text fits. */
.app-figure-value {
    display: block;
    font-size: 18px;
    font-weight: 700;
    color: #22303f;
    font-variant-numeric: tabular-nums;
    font-feature-settings: 'tnum' 1;
    overflow-wrap: anywhere;
}
.app-figure-value em { font-style: normal; font-size: 12px; font-weight: 600; color: #8a939f; }
/* A QUALIFIER LINE under the figure — "mean 44 min", "3.8 per request", "67
   release notes". The third slot of the tile, after the label and the value.

   Added here rather than on a feature (2026-08-24) because the operator standard
   is explicit: "any customized CSS that is on the KPI boxes should be removed and
   incorporated into the general.css". It arrived as `.tka-figure-sub` on the
   Tickets analytics dashboard, tools/check_shared_css.php reported it under
   [kpi-figure], and the checker was right — a second line inside the shared tile
   is an app-wide statement about how a figure reads, not one screen's business.

   It is NOT the same thing as `.app-figure-value em`, which is a unit or suffix
   INSIDE the figure ("4.6M"). This is a separate fact about the figure, on its own
   line, and a tile without one is unchanged. */
.app-figure-sub {
    display: block;
    margin-top: 2px;
    font-size: 11px;
    font-weight: 600;
    color: #8a939f;
    line-height: 1.35;
    overflow-wrap: anywhere;
}
.app-figures--compact .app-figure-sub { font-size: 10.5px; }
.app-figure--accent { background: #e6f4f1; border-color: #b8e0d8; }
.app-figure--accent .app-figure-label { color: #0f766e; }
.app-figure--accent .app-figure-value { color: #0c5d57; }
/* ---- `.app-figures--fill` — an EVEN band when the tile count is DATA-DRIVEN ----
   (2026-09-04, the Launch Pad's cash band: one tile per currency the caller holds.)

   The default band is flex, and `.app-figure` carries `flex-grow: 1` so a row of
   tiles always fills its line. That is right when the count is FIXED and the author
   chose it — three tiles, one line, no ragged end. It is wrong when the count comes
   from the data, because the last line has whatever is left over and stretches it:
   eight currencies in a 1242px card wrapped 7 + 1 and drew the eighth tile across
   the full width of the card, which reads as a rendering fault rather than as one
   more currency. (The same shape, less loudly, is the 3 + 1 wrap the flex-basis note
   above is about.)

   `auto-fill` — not `auto-fit` — is the whole mechanism: it keeps the empty tracks,
   so every line has the same number of columns and a tile on the last line is one
   column wide like every other. Compose: `class="app-figures app-figures--fill"`
   (with or without `--compact`). A band with a fixed, authored tile count should
   NOT use it: there, filling the line is the point.

   ⚠ UNLESS THAT FIXED COUNT DOES NOT FIT AT EVERY WIDTH THE BAND IS RENDERED AT
   (2026-09-19). The rule above is really about whether the author could CHOOSE a
   count that fills one line — and a band with two hosts of different widths has no
   such count. The capital-call notice is the case: five tiles fit one line on its
   1100px page and wrapped 4 + 1 in its 840px dialog, drawing the fifth across the
   full 785px, i.e. exactly the fault this modifier exists to prevent. Compact does
   not rescue it — the wrap is decided from the flex BASIS before any shrinking, so
   150 + gap × 5 still overruns 785 by a few pixels. So: fixed count that fits
   everywhere → leave it off; fixed count that does not → this, and the band reads
   as 5 across on the page and an even 3 + 2 in the dialog.
   app_rv_figures()'s third argument is the server-side way to ask for it.

   ⚠ THE TRACK IS SIZED FOR A COMFORTABLE TILE, NOT FROM THE FLEX BASIS. The basis
   above (168 / 150) is a WRAP THRESHOLD for a band whose tiles then grow to fill the
   line; as a grid track it is the tile's actual width, and at that size a two-word
   label wraps while the card beside it is mostly empty. 200 is about what a four-tile
   band already gets on a standard 1750px page — so a band keeps the size it has
   always had and simply stops growing past it, which is the point on a `page-full`
   board where the flex behaviour would otherwise stretch two tiles to ~1,250px each. */
.app-figures--fill {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.app-figures--compact { gap: 10px; }
.app-figures--compact .app-figure {
    flex: 1 1 150px;
    min-width: 132px;
    padding: 9px 12px;
    background: #fff;
}
.app-figures--compact .app-figure-value { font-size: 15px; }
.app-figures--compact .app-figure-value em { font-size: 11px; }
/* The compact band's track, a little tighter than the default for the same reason
   its tiles are. Declared after both so it wins at equal specificity. */
.app-figures--compact.app-figures--fill { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }

/* ---- The band's scope note ----------------------------------------------
 * A statstrip computed SERVER-side over the whole population sits above a grid
 * the reader can filter, so the two describe different things the moment a
 * filter is on. Recomputing the tiles in the browser is what CLAUDE.md §1
 * forbids; leaving the mismatch unsaid is its own lie. This is the third
 * option: a true statement about two scopes on one page, with the remedy
 * beside it.
 *
 * Shared here 2026-09-02, promoted verbatim from `.ciu-band-note` on Unbilled
 * One-Time Charges when One-Time Charges became the second screen to need it.
 * Amber, not red: nothing is wrong, two figures simply have different scopes.
 *
 * Pair it with a scope pill that is on screen at ALL times — this note only
 * covers the filtered case, and a band with no statement of scope at all is
 * the thing both screens were avoiding.
 *
 * Put a `.btn-link` inside it for the remedy; do not fork a link-styled
 * button. */
.app-band-note {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    margin: 12px 0 0;
    padding: 8px 12px;
    background: var(--pill-amber-bg);
    border: 1px solid var(--pill-amber-bd);
    border-radius: 8px;
    font-size: 12.5px;
    line-height: 1.5;
    color: var(--pill-amber-fg);
}
.app-band-note svg { flex: 0 0 auto; }
/* ⚠ [hidden] NEEDS A DISPLAY COMPANION. There is no global
   `[hidden] { display: none }` here strong enough to beat this component's own
   `display: flex`, so a `hidden` note would stay on screen. */
.app-band-note[hidden] { display: none; }
.app-figure--warn { background: #fdf3e7; border-color: #f0d3a8; }
.app-figure--warn .app-figure-label { color: #8a5a12; }
.app-figure--warn .app-figure-value { color: #7a4a0c; }
.app-figure--ok .app-figure-value { color: #1d6b3a; }
/* [hidden] companion — `display:flex` is an AUTHOR rule and out-ranks the UA's
   [hidden]{display:none}, so a conditionally-shown band would paint anyway (the
   same trap .app-fields[hidden] and .callout-banner[hidden] carry). */
.app-figures[hidden] { display: none !important; }

/* ---- A figure that LINKS: .app-figure--link ----
   The KPI tile whose figure opens the screen that owns the records behind it.

   WHY IT LOOKS LIKE THIS. It used to be `color: inherit` + a 1px DOTTED bottom
   border, authored locally in assets/opus_connect/connect_insights.css. Operator,
   2026-08-17: "where the KPI boxes include a link - the value should look more
   like a clickable value. the dotted underline doesn't look obvious as clickable",
   with a suggestion of "a `View →`" or "an arrow in the kpi box".

   A `View →` row was declined: the band's whole job is LABEL over FIGURE at a
   glance, and a third line of chrome in every tile buys a word the arrow already
   says. What ships instead:
     · the figure takes the app's LINK colour instead of inheriting body text —
       "the same colour as every other link on the page" is the strongest signal
       available, and it is the one the dotted border threw away;
     · a `→` follows the figure, so the affordance survives a greyscale print and
       a reader who cannot separate navy from near-black;
     · THE WHOLE TILE is the hit area, not the ten characters of the number (the
       stretched `::after` below), and the tile's border and shadow answer the
       hover — a 158px-wide target instead of a 60px one;
     · the arrow slides 2px on hover, which is the cheapest possible "this goes
       somewhere" without animating the figure itself.

   Markup — the anchor wraps the VALUE, never the tile, and the tile carries the
   modifier (that is what keeps the label out of the link text a screen reader
   announces, and it is why this is not `.app-tile--link`, which linkifies a whole
   tile of prose):

     <div class="app-figure app-figure--link">
       <span class="app-figure-label">Total Commitments</span>
       <span class="app-figure-value"><a href="…">3,160,000</a></span>
     </div>

   The modifier is what draws the arrow, so a tile that has no link cannot grow
   one, and an `<a>` that lands in a plain figure still reads as a link.

   ⚠ An .app-figure--link may hold NOTHING else interactive. The stretched overlay
   covers the tile, so a second control inside it would be unreachable. Same rule
   .app-tile--link carries, same reason.

   TWO CONTROL TYPES, ONE APPEARANCE (2026-08-27). An `<a>` when the tile NAVIGATES
   — so it middle-clicks and opens in a new tab like every other link — and a
   `<button>` when it opens something on the page it is already on (a dialog).
   A button is the honest element there: an anchor to `#` is a link that goes
   nowhere, and it takes the keyboard and the context menu with it. The button
   needs its UA chrome stripped before it can pass for the figure it wraps.
   Both are emitted by the shared app_rv_figures() `link` option
   (views/general/_record_view.php) — a band had no way to say "this figure opens
   something" until then, so only client-side code could produce one. */
.app-figure--link { position: relative; transition: border-color .12s ease, box-shadow .12s ease; }
.app-figure--link:hover { border-color: var(--navy); box-shadow: 0 2px 8px rgba(15, 52, 96, .10); }
.app-figure-value a,
.app-figure-value button {
    color: var(--navy);
    text-decoration: none;
    font: inherit;
}
.app-figure-value button {
    background: none;
    border: 0;
    padding: 0;
    margin: 0;
    line-height: inherit;
    cursor: pointer;
}
/* The hit area. `inset: 0` over the TILE (the positioned ancestor), so a click
   anywhere in the box follows the link — including on the label. */
.app-figure--link .app-figure-value a::after,
.app-figure--link .app-figure-value button::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 9px;      /* matches .app-figure, so the focus ring fits it */
}
/* The arrow is on the VALUE, not on the anchor, because the anchor's ::after is
   already spent on the hit area above. */
.app-figure--link .app-figure-value::after {
    content: ' \2192';
    display: inline-block;
    margin-left: 6px;
    font-size: .78em;
    font-weight: 700;
    color: var(--navy);
    transform: translateX(0);
    transition: transform .12s ease;
}
.app-figure--link:hover .app-figure-value::after { transform: translateX(2px); }
.app-figure--link:hover .app-figure-value a,
.app-figure--link:hover .app-figure-value button { text-decoration: underline; }
/* Keyboard parity: the ring goes on the TILE, because the control's own box is
   just the text while the thing that acts is the whole tile. */
.app-figure--link .app-figure-value a:focus-visible,
.app-figure--link .app-figure-value button:focus-visible { outline: none; }
.app-figure--link:focus-within {
    border-color: var(--navy);
    outline: 2px solid var(--navy);
    outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
    .app-figure--link,
    .app-figure--link .app-figure-value::after { transition: none; }
}

/* ---- Direction states on a figure: .is-up / .is-down / .is-flat ----
   For a figure that MOVED — a period P&L, a return. Promoted from
   assets/daily_pnl/daily_pnl.css on 2026-08-17 with the KPI-box consolidation
   (operator: "any customized CSS that is on the KPI boxes should be removed and
   incorporated into the general.css").

   DIRECTION IS NEVER CARRIED BY COLOUR ALONE — the glyph is the primary signal
   and the colour reinforces it, because a colour-vision-deficient reader must
   still be able to tell a gain from a loss. The pair is steel/terracotta, never
   green/red: green/red fails this app's CVD gate (see the palette provenance in
   app_chart.css, and AppHC.COLORS.pos/neg, which use the same two hues).

   The class goes on the TILE and the server decides it — nothing in the browser
   inspects a number to pick one. */
.app-figure.is-up   .app-figure-value { color: #2b6ea1; }
.app-figure.is-down .app-figure-value { color: #b65c30; }
.app-figure.is-up   .app-figure-value::before { content: '\25B2 '; font-size: .72em; }
.app-figure.is-down .app-figure-value::before { content: '\25BC '; font-size: .72em; }
.app-figure.is-flat .app-figure-value::before { content: '\2013 '; color: var(--text-muted); }

/* A NEGATIVE figure, where the sign is the whole story and there is no direction
   to draw (a period P&L on the NAV dashboard). Promoted from `.nd-figure--neg`.
   The sign is ALSO written into the value by the formatter's explicit "+"/"−", so
   this is reinforcement and never the only cue. */
.app-figure--neg .app-figure-value { color: var(--pill-red-fg); }

/* "Not available" IN PLACE OF a figure — a real answer, and deliberately not a
   zero, which would read as a genuine nought. Smaller and muted so it cannot be
   mistaken for a number. Promoted from `.nd-figure-na`. */
.app-figure-na { font-size: 15px; font-weight: 600; color: var(--text-muted); }

/* The value's skeleton, sized ONCE. Three screens each carried their own
   (82×20, 96×22, 96×20) and the differences were accidental — a reserved height
   only has to stop the tile resizing when the figure lands, and .app-figure-value
   is an 18px/1.2 line, so 22px is the height that does it for all of them. */
.app-figure-skel { display: inline-block; width: 96px; height: 22px; }

/* Totals band — a right-aligned "N records / Total: $X" summary row under a grid
   (the PHPRunner grid-totals band). Shared so any list can show a footer total. */
.app-totals {
    display: flex;
    align-items: baseline;
    justify-content: flex-end;
    gap: 10px;
    border-top: 1px solid var(--hairline-soft);
    margin: 8px 0 0;
    padding: 12px 4px 2px;
    font-size: 13px;
    color: var(--text-muted);
}
.app-totals-count { font-weight: 600; color: var(--text-soft); }
.app-totals-amount {
    font-size: 16px;
    font-weight: 800;
    color: var(--navy);
    font-variant-numeric: tabular-nums;
}
/* Honor the `hidden` attribute the totals band is toggled with (e.g. Opus
   Invoices renderTotals hides it when a search/tab has no rows). Without this,
   the display:flex above overrides the UA [hidden] rule and the emptied band
   still shows its border-top + padding as a stray strip (general.css has no
   global [hidden] reset — each such component carries its own companion). */
.app-totals[hidden] { display: none !important; }

/* ---- Statement roll-forward (shared): .app-stmt ----
   THE rendering of a labelled sequence of figures that ADDS UP — a capital
   account roll-forward, a cash reconciliation — optionally shown for more than
   one period side by side so the reader can compare them.

   Promoted 2026-08-19, when the operator rejected the flat definition lists on
   `app_online_statements.php?page=view` and the Digital NAV Pack's Investor
   Account Summary View modal: "the fields are not displayed in a meaningful way
   … they should be organized better in this order" (Opening, Capital In, Capital
   Out, Net Profit, Ending, Period-End Activity, Adjusted Ending, Net Return).
   Both surfaces render ONE record of vInvestor_Balances_Opus, and both were
   built on their own copy of a two-column `<dl>` — which is what made the
   ordering impossible: a 2-across field grid flows label-pairs LEFT TO RIGHT, so
   "Opening | Capital In" landed on row 1 and "Capital Out | Net Profit" on row 2,
   and the sequence a reader has to follow was scrambled by the layout itself.
   A single-spine table restores the order and makes the arithmetic visible.

   WHY A TABLE MAY RIGHT-ALIGN ITS FIGURES when .app-field--num says a field grid
   may not: the two rules agree. That comment's own carve-out is "right-alignment
   earns its keep in a TABLE, where figures stack in one column and decimals must
   line up" — which is exactly this component, and NOT the field grid it replaced.

     <div class="app-stmt">
       <table class="app-stmt-table">
         <thead><tr><td class="app-stmt-lbl"></td>
                    <th scope="col">Period to Date</th>
                    <th scope="col">Year to Date</th></tr></thead>
         <tbody>
           <tr><th scope="row" class="app-stmt-lbl">Opening</th>
               <td class="app-stmt-val">1,132,869.75</td>
               <td class="app-stmt-val">646,987.11</td></tr>
           <tr class="app-stmt-row--sum">…</tr>     a SUBTOTAL: rule above, bolder
           <tr class="app-stmt-row--total">…</tr>   the ANSWER: navy, heavy rule
           <tr class="app-stmt-row--rate">…</tr>    NOT money (a rate, a unit
                                                    count) — sits below a rule so
                                                    it cannot read as part of the
                                                    addition above it
         </tbody>
       </table>
     </div>

   .app-stmt-na   the "this period has no such line" mark. A column that does not
                  carry a row gets a light en-dash, so the gap reads as
                  not-applicable rather than as a number that failed to load.
   .app-stmt-rev  a flow whose sign CONTRADICTS its own line — a positive
                  redemption, a negative deposit; both exist in the data. Those
                  print SIGNED and with no +/− prefix (the flowCell() convention
                  the two grid surfaces already use), and this marks them, so a
                  correction can never be read as an ordinary flow. See
                  assets/nav_pack/nav_pack.js and
                  assets/investor_balances/investor_balances.js.

   The WRAPPER scrolls horizontally rather than the table collapsing, because two
   periods that cannot be seen at once cannot be compared — which is the whole
   reason this component exists. Cells never wrap. */
.app-stmt { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.app-stmt-table { width: 100%; border-collapse: collapse; }
.app-stmt-table th, .app-stmt-table td { white-space: nowrap; }
.app-stmt-table thead th,
.app-stmt-table thead td {
    padding: 0 0 8px 22px;
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .03em;
    color: #7a8798;
    text-align: right;
    border-bottom: 1px solid var(--hairline);
}
.app-stmt-table thead .app-stmt-lbl { padding-left: 0; text-align: left; }
.app-stmt-table tbody th.app-stmt-lbl {
    padding: 9px 18px 9px 0;
    font-size: 12.5px;
    font-weight: 600;
    color: var(--text-soft);
    text-align: left;
}
.app-stmt-val {
    padding: 9px 0 9px 22px;
    font-size: 13.5px;
    font-weight: 600;
    color: var(--text);
    text-align: right;
    font-variant-numeric: tabular-nums;
    font-feature-settings: 'tnum' 1;
}
.app-stmt-table tbody th,
.app-stmt-table tbody td { border-bottom: 1px solid #f2f4f8; }
.app-stmt-table tbody tr:last-child th,
.app-stmt-table tbody tr:last-child td { border-bottom: none; }
/* A SUBTOTAL — the line the rows above it add up to. The rule has to out-read the
   row dividers around it (#f2f4f8) or the subtotal looks like just another row,
   which is what the first cut did at 1px/#cdd6e1: 2px of a mid grey is the
   lightest thing that says "this line closes the block above". */
.app-stmt-row--sum > th,
.app-stmt-row--sum > td { border-top: 2px solid #b9c4d2; }
.app-stmt-row--sum .app-stmt-lbl,
.app-stmt-row--sum .app-stmt-val { font-weight: 700; color: var(--text); }
/* THE ANSWER — the figure the record exists to state. */
.app-stmt-row--total > th,
.app-stmt-row--total > td { border-top: 2px solid var(--navy); }
.app-stmt-row--total .app-stmt-lbl { font-weight: 800; color: var(--navy); }
.app-stmt-row--total .app-stmt-val { font-weight: 800; color: var(--navy); font-size: 15px; }
/* A RATE or a unit count — separated from the money it was derived from. */
.app-stmt-row--rate > th,
.app-stmt-row--rate > td { border-top: 1px solid var(--hairline); padding-top: 11px; }
.app-stmt-row--rate + .app-stmt-row--rate > th,
.app-stmt-row--rate + .app-stmt-row--rate > td { border-top: 1px solid #f2f4f8; padding-top: 9px; }
/* THE LABEL COLUMN IS PINNED. The wrapper is the scroller, so on a phone (or a
   narrow dialog) the reader pans the periods sideways — and without this the
   labels are the FIRST thing to leave the screen, which turns the table into a
   grid of unattributed numbers. Same reasoning as the pinned row-action column in
   a wide AppDrill grid, further up this file. The opaque background is required:
   a sticky table cell does not visually inherit its ancestor's, so without it the
   figures slide underneath the labels. #fff because the shared card body this
   component lives in is white app-wide; a tinted host would need its own rule. */
.app-stmt-lbl {
    position: sticky;
    left: 0;
    z-index: 1;
    background: #fff;
}
.app-stmt-na { color: #ccd3dc; font-weight: 400; }
/* Terracotta, not red — the CVD-safe pair this app uses for direction
   (see .app-figure.is-down). The `title` carries the explanation. */
.app-stmt-rev { color: #b65c30; }
@media (max-width: 700px) {
    .app-stmt-table tbody th.app-stmt-lbl { padding-right: 12px; font-size: 12px; }
    .app-stmt-val { padding-left: 14px; font-size: 13px; }
    .app-stmt-table thead th, .app-stmt-table thead td { padding-left: 14px; }
    .app-stmt-row--total .app-stmt-val { font-size: 14px; }
}

/* ---- Hero Tiles (reusable, NON-clickable detail/KPI tiles) ----
   A responsive row of polished summary tiles for a record/detail page — an
   alternative to a dense field table. Each tile is an icon + heading + a short
   list of label/value rows; one value per tile can lead (.app-tile-value--lead).
   Tiles are presentational only (never wrap them in a link). Reusable app-wide.
     <div class="app-tiles">
       <div class="app-tile">
         <div class="app-tile-head"><span class="app-tile-ico">…</span>
           <span class="app-tile-title">Balance</span></div>
         <dl class="app-tile-list">
           <div class="app-tile-row app-tile-row--lead">
             <dt>Balance Due</dt><dd class="app-tile-value--lead">$…</dd></div>
           <div class="app-tile-row"><dt>Paid to Date</dt><dd>$…</dd></div>
         </dl>
       </div>
     </div> */
.app-tiles {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
    gap: 16px;
    margin: 0 0 22px;
}
.app-tile {
    background: #fff;
    border: 1px solid var(--hairline);
    border-radius: 12px;
    box-shadow: 0 1px 3px rgba(15, 28, 54, .05);
    padding: 16px 18px 14px;
    display: flex;
    flex-direction: column;
    min-width: 0;
}
.app-tile-head {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 12px;
    padding-bottom: 10px;
    border-bottom: 1px solid var(--hairline-soft);
}
.app-tile-ico {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border-radius: 9px;
    background: #eef3fb;
    color: var(--navy);
}
.app-tile-title {
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--text-muted);
}
.app-tile-list { margin: 0; display: flex; flex-direction: column; gap: 9px; }
.app-tile-row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 14px;
    min-width: 0;
}
.app-tile-row dt {
    font-size: 12.5px;
    color: var(--text-muted);
    flex-shrink: 0;
}
.app-tile-row dd {
    margin: 0;
    font-size: 13.5px;
    font-weight: 600;
    color: var(--text);
    text-align: right;
    min-width: 0;
    word-break: break-word;
    font-variant-numeric: tabular-nums;
}
/* The lead figure of a tile (e.g. Balance Due) — larger, navy, accented. */
.app-tile-row--lead {
    align-items: baseline;
    padding-bottom: 4px;
}
.app-tile-row--lead dt { font-weight: 600; color: var(--text-soft); }
.app-tile-value--lead {
    font-size: 20px !important;
    font-weight: 800 !important;
    color: var(--navy) !important;
}
/* Accent tile — draws the eye to the primary tile (e.g. amount due). */
.app-tile--accent { border-color: #f6cbb8; background: linear-gradient(180deg, #fff 0%, #fdf5f1 100%); }
.app-tile--accent .app-tile-ico { background: #fdeee8; color: var(--accent); }
.app-tile--accent .app-tile-value--lead { color: var(--accent) !important; }

/* ---- Linked tile (.app-tile--link) ----
   A tile that is itself a NAVIGATION target — the "attention tile" shape: a
   heading, a few status counts, and a click that opens the source screen
   prefiltered (promoted from the Investors list 2026-07-30). Opt in by making
   the tile an anchor: `<a class="app-tile app-tile--link" href="…">`.

   The DETAIL/KPI tile stays presentational, and that is still the default —
   don't wrap one in a link to make a summary figure "clickable". Two rules come
   with the variant:
     - an <a> may not contain interactive descendants, so a linked tile's rows
       carry text / pills only — never a button, link or input;
     - the tile needs a visible affordance for touch and keyboard, not hover
       alone: close it with an `.app-tile-cta` row (it pins to the tile's bottom
       edge via margin-top:auto, so CTAs line up across a row of tiles).
   The keyboard focus ring comes from the shared :focus-visible group. */
a.app-tile--link {
    text-decoration: none;
    color: inherit;
    transition: border-color .15s ease, box-shadow .15s ease;
}
a.app-tile--link:hover {
    border-color: var(--navy);
    box-shadow: 0 3px 12px rgba(15, 28, 54, .10);
}
a.app-tile--link:hover .app-tile-ico { background: var(--navy); color: #fff; }
.app-tile-cta {
    margin-top: auto;
    padding-top: 12px;
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 12.5px;
    font-weight: 700;
    color: var(--navy);
    white-space: nowrap;
}
.app-tile-cta svg { flex-shrink: 0; transition: transform .15s ease; }
a.app-tile--link:hover .app-tile-cta svg { transform: translateX(3px); }
/* A tile's list must not be squeezed by the CTA's auto margin — give the list
   the flexible space so the CTA sits on the real bottom edge. */
a.app-tile--link .app-tile-list { margin-bottom: 4px; }

@media (max-width: 700px) {
    .app-tiles { grid-template-columns: 1fr; gap: 12px; }
    .app-card-head { padding: 12px 14px 10px; }   /* flex-wrap is now the default */
    .app-card-heading { flex-basis: calc(100% - 44px); }
    /* .app-card-actions needs no mobile rule anymore — its own left-aligned row
       is the default at every width (see the rule). */
    .app-card-body { padding: 14px; }
    .app-card-grid { padding: 4px 12px 12px; }
}

/* ---- Card Hero Row ---- */
.card-hero {
    display: flex;
    gap: 1px;
    margin: 12px 20px 0;
    background: #e2e6ea;
    border-radius: 6px;
    overflow: hidden;
}
.card-hero-item {
    flex: 1;
    padding: 10px 16px;
    background: var(--head-bg);
}
.card-hero-item:first-child {
    border-radius: 6px 0 0 6px;
}
.card-hero-item:last-child {
    border-radius: 0 6px 6px 0;
}
.card-hero-label {
    font-size: 10px;
    font-weight: 600;
    color: #778;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    margin-bottom: 2px;
}
.card-hero-value {
    font-size: 16px;
    font-weight: 700;
    color: var(--text);
    font-variant-numeric: tabular-nums;
}
.card-hero-accent {
    background: #eef3ff;
}
.card-hero-accent .card-hero-value {
    color: #0d6efd;
}

/* ---- Card Action Buttons ---- */
.btn-view-detail {
    background: var(--navy);
    color: #fff;
}
.btn-view-detail:hover {
    background: #0b2a4e;
    color: #fff;
}
.btn-download-pdf {
    background: #f8f9fa;
    color: #333;
    border: 1px solid #ddd;
}
.btn-download-pdf:hover {
    background: #e9ecef;
    color: #333;
}

/* ---- Skeleton Placeholders ---- */
.skel-text {
    display: inline-block;
    width: 72px;
    height: 12px;
    background: #e8eaed;
    border-radius: 4px;
    vertical-align: middle;
    animation: skel-pulse 1.2s ease-in-out infinite;
}
.skel-sm { width: 28px; }
.skel-card { pointer-events: none; }
@keyframes skel-pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.4; }
}

/* ---- Responsive ---- */
/* Account control goes ICON-ONLY below the 1200px desktop floor (was 800px,
   raised 2026-08-17 on an operator report that the username ran off the page on
   mobile). The floor is the right line rather than the 700px mobile breakpoint:
   below 1200 the page is already too narrow for the desktop layout and scrolls
   horizontally (see `body{min-width}`), and the fixed topbar is the ONE strip
   that cannot scroll with it — so the widest optional thing in it is the first
   thing to give. That band is exactly the tablet / landscape-phone / narrow-
   window range where the label was still rendering.
   The name is not lost: it is one tap away in the dropdown head ("Signed in
   as"), and the button carries it as title + aria-label. */
@media (max-width: 1199px) {
    .user-name { display: none; }
    .user-btn { padding: 7px 8px; }
    /* The workspace components' matching rules are NOT here — they are declared
       with the components themselves, further down. A media query adds no
       specificity, so an override placed above the base rule loses on source
       order (caught in verification 2026-08-27: display:none applied, because
       the base rule sets no display, while max-width and padding silently did
       not). Keep a component's responsive rules AFTER its base rules. */
}

/* Mobile shell — the single 700px breakpoint (operator standard; matches the
   AppGrid card-swap matchMedia, the AppFilters panel, and the README). Below
   700px the whole shell goes fluid/stacked; above it the 1200px desktop floor
   applies (body, above) and the page scrolls horizontally rather than
   compressing. (Was 640px, which left a 640–700px dead zone where the grid had
   already switched to cards but the shell was still desktop.) */
@media (max-width: 700px) {
    /* Release the desktop minimum-width floor: mobile is fully fluid. */
    body { min-width: 0; }

    /* Topbar */
    .topbar { padding: 0 12px; position: static; }
    /* ...and release the height reservation with it. `body:has(nav.topbar){
       padding-top:56px}` (line ~139) exists ONLY to reserve space for the FIXED
       desktop topbar; the bar goes static here, so that padding was reserving a
       second 56px for an element already in flow — an empty page-background strip
       above the topbar on EVERY mobile page (measured 2026-07-31 on app_cap_table
       and app_bank_balances as well as the invoicing pages, all 56px). */
    body:has(nav.topbar) { padding-top: 0; }
    .topbar-brand { display: none; }
    .desktop-only { display: none; }
    /* .user-name / .user-btn padding are already covered by the <=1199px
       icon-only rule above (which also applies at <=700px) — the duplicate
       padding was removed 2026-07-22, the duplicate display:none 2026-08-17. */

    /* Page wrapper */
    .page-wrapper { padding: 20px 12px 0; }

    /* Layout shell */
    .layout { display: block; }
    .summary-sidebar { display: none; }
    .page-main { padding: 0 0 20px; }
    .mobile-only { display: block; }
    .filter-row { flex-direction: column; align-items: stretch; gap: 8px; }
    .filter-row .filter-sep { display: none; }
    .filter-select { width: 100%; max-width: 100%; }
    /* Sort control becomes a full-width sort bar on mobile (stacked, always
       visible — it's not a filter input, so it never collapses). */
    .filter-row .app-sort { margin-left: 0; width: 100%; }
    .app-sort-select { max-width: none; flex: 1; }
    /* Standard: Clear Filters is full-width with centered text on mobile (it
       sits on its own row in the stacked filter bar). */
    .btn-clear-filters { width: 100%; justify-content: center; box-sizing: border-box; }

    /* Card hero */
    .card-hero { flex-direction: column; }
    .card-hero-item:first-child { border-radius: 6px 6px 0 0; }
    .card-hero-item:last-child  { border-radius: 0 0 6px 6px; }

    /* Modals */
    .modal-dialog { width: 95%; min-width: 0; }
    .modal-body { padding: 14px; }
    .modal-header { padding: 14px 16px; }
    .modal-header h2 { font-size: 15px; }

    /* Field table */
    .field-table td.lbl {
        width: auto;
        white-space: normal;
        display: block;
        padding-bottom: 0;
    }
    .field-table td.val {
        display: block;
        padding-top: 0;
        padding-bottom: 10px;
    }
    .field-table tr { display: block; }

    /* Modal doc rows (Documents, Help, Funding Instructions) */
    .modal-body .doc-row {
        flex-direction: column;
        gap: 2px;
    }
    .modal-body .doc-label {
        min-width: 0;
    }

    /* Form fields */
    .form-field, select.form-field { max-width: 100%; }
    /* .radio-group { flex-direction: column; } */
    /* .lookup-field { flex-direction: column; align-items: flex-start; } */
    .lookup-display { flex: 1; min-width: 0; word-break: break-word; }
}

/* ============================================================================
 * Data Grid (AppGrid)  -- shared, reusable table grid. See assets/general/app_grid.js
 * Standard table styling: toolbar, sticky header, sortable headers, zebra/hover/
 * selected rows, skeleton loading, empty/no-results/error states, footer with
 * load-more or pagination, column menu, and a stacked-card mobile layout.
 * ========================================================================== */
.app-grid { display: block; }
/* [hidden] COMPANION — the same convention as .app-filter-panel[hidden] /
   .callout-banner[hidden] / .app-fields[hidden] in this file, and needed for the
   same reason: `display: block` above is an AUTHOR rule and beats the UA's
   [hidden]{display:none}, so a grid hidden with the attribute stayed on screen.
   AppGrid puts this class ON THE MOUNT, so the attribute a feature sets on its own
   `<div id="xGrid">` is the one that has to work.
   Found 2026-08-26 on app_wire_templates.php, whose four tabs run over TWO feeds
   (the templates list and the change history) and so host two grids in one panel,
   showing one at a time. Both rendered, one under the other. */
.app-grid[hidden] { display: none !important; }

/* Toolbar (quick-search / columns / export) */
.app-grid-toolbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 10px;
    flex-wrap: wrap;
}
.app-grid-toolbar-left {
    display: flex;
    align-items: center;
    gap: 10px;
    flex-wrap: wrap;
    /* TAKE THE FREE WIDTH (2026-08-24). This slot is where a feature injects its
       bulk-action bar (grid.toolbarLeft — online_statements' "Download Selected",
       nav_pack's bulk bar, the Tickets release cut), and at `flex: 0 1 auto` a
       WRAPPING flex container's own intrinsic size does not reach the sum of its
       children: the Tickets release bar measured 686px against 728px of content on
       a 1407px toolbar and wrapped its last button onto a second line for nothing.

       Inert for every existing consumer: the quick-search box is capped
       (.app-grid-search max-width 320px), the columns menu is content-sized, and
       .app-grid-toolbar-right keeps `margin-left: auto` so it stays pinned right
       whatever this slot does. `min-width: 0` so a long bar still shrinks on a
       narrow viewport instead of forcing the toolbar wider. */
    flex: 1 1 auto;
    min-width: 0;
}
.app-grid-toolbar-right {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-left: auto;
}

/* ---- The ACTION ROW inside the grid's box (AppGrid cfg.actionBar) ----
   The toolbar's two slots, rendered as the FIRST child of `.app-grid-scroll`
   instead of a band above it: inside the grid's own border, directly over the
   column headers. Opt-in per grid; added 2026-09-22 for the Capital Call record's
   Per-Investor Details (ticket 868m826de §1.2 — "Download Selected Notices"
   left-aligned and "Export CSV" right-aligned, one row, above the field labels,
   within the `app-grid-scroll app-grid-scroll--x` container).
   `position: sticky; left: 0` is load-bearing on a scrollX grid: the row is a
   block child of the horizontal scroller, so it is as wide as the VISIBLE box,
   and sticky keeps it there while the wide table pans underneath — otherwise
   Export would slide off-screen with the last column. The hairline under it is
   the header row's own divider colour, so row + headers read as one head. */
.app-grid-actionbar {
    position: sticky;
    left: 0;
    z-index: 3;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    flex-wrap: wrap;
    padding: 8px 12px;
    background: #fff;
    border-bottom: 1px solid #e3e7eb;
    border-radius: 8px 8px 0 0;
    /* The row is exempt from drag-to-pan (app_grid.js), so it must not inherit the
       box's `cursor: grab` — that would advertise a drag the grid refuses. */
    cursor: default;
}
.app-grid-actionbar-left {
    display: flex;
    align-items: center;
    gap: 10px;
    flex-wrap: wrap;
    flex: 1 1 auto;
    min-width: 0;
}
.app-grid-actionbar-right {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-left: auto;
}
/* Card mode drops the box's border (below), so the row stops being a head strip
   and becomes a plain line of controls over the cards. */
.app-grid--mobile .app-grid-actionbar { padding: 0 0 10px; background: transparent; border-bottom: 0; }

/* ---- Feature BULK-ACTION BAR (.app-bulkbar) ----
   The wrapper a feature parks its bulk control in and then HOISTS into the left
   slot above (`grid.toolbarLeft`), so a "Download Selected (N)" rides the grid's
   own top-left band beside the quick search instead of sitting on a strip of its
   own. Park it in the view, move it in the feature JS once AppGrid.create() has
   returned:

     <div class="app-bulkbar" id="xBulkBar">
       <button class="btn btn-primary btn-sm" id="…" disabled>… (<span>0</span>)</button>
       <span class="app-selcount" id="…" aria-live="polite"></span>
     </div>
     if (bulkBar && grid.toolbarLeft) { grid.toolbarLeft.appendChild(bulkBar); }

   SHARED 2026-09-09, when app_investor_balances.php's bulk Download moved into the
   toolbar and would have been the FIFTH copy. The four that existed were
   byte-identical: .os-bulkbar/.os-selcount (app_online_statements.php),
   .np-bulkbar/.np-selcount (app_nav_pack.php) and .dr-std-selcount (the Data Rooms
   Standard-files toolbar). nav_pack.css had already written the invitation out
   loud — "If a THIRD adopter of the grid.toolbarLeft slot needs this, it belongs
   in general.css instead of a third copy." tools/check_shared_css.php now has a
   rule for it (`bulk-bar`).

   `white-space: nowrap` on the button is the load-bearing line, not the gap: these
   labels are long ("Download Selected Statements (3)") and the toolbar is a
   WRAPPING flex row, so without it the button breaks mid-label rather than the bar
   wrapping as one piece.

   NOT this component, and staying local: app_wire_requests.php's .wr-bulkbar (a
   boxed tinted band ABOVE the grid) and the shared .app-selbar (the boxed
   bulk-action FOOTER below one). Both are bands in the page; this is a control in
   the toolbar. */
.app-bulkbar { display: flex; align-items: center; gap: 10px; }
.app-bulkbar .btn { white-space: nowrap; }
/* [hidden] COMPANION — LOAD-BEARING, the same pair as .app-grid[hidden] above and
   for the same reason: `display: flex` is an AUTHOR rule and beats the UA's
   [hidden]{display:none}. A feature that parks its bar HIDDEN and only reveals it
   when its tab first renders (nav_pack) painted the parked bar where it stood
   until the hoist moved it — a "Download Selected (0)" under the wrong panel.
   Operator QA, 2026-08-20. */
.app-bulkbar[hidden] { display: none !important; }
/* The live "N selected" note that rides beside the button — the aria-live region,
   so the count is announced and not only painted. */
.app-selcount { font-size: 12.5px; color: #5b6b7c; }
/* The count INSIDE the button's label, e.g. "Download Selected Statements (3)".
   It exists to be ONE flex item: `.btn` is a flex row with `gap: 6px`, so a bare
   `(<span>3</span>)` is three siblings and paints as "( 3 )". The wrapper puts the
   parentheses and the figure back in one box; the inner span keeps the id the
   feature JS writes. No styling of its own on purpose — it inherits the label. */
.app-selcount-inline { white-space: nowrap; }
/* ⚠ A MEDIA QUERY ADDS NO SPECIFICITY — this block stays AFTER the base rules it
   overrides.

   ONE FULL-WIDTH ACTION PER ROW, at the touch height — the same rule .page-actions
   carries, because this is the same thing: a page-level action button, just parked
   in the toolbar instead of on a strip. Three of the four copies this component was
   promoted from already carried `width: 100%` on the button and it was INERT: the
   bar is a flex item that shrink-wraps its content, so 100% of a shrink-to-fit box
   is the content width and nothing changed. `width: 100%` on the BAR is what makes
   the button rule mean what it always said.

   The touch height is the part that is NOT inherited from those copies (2026-09-09):
   none of them had it, and app_investor_balances.php's bulk Download would have LOST
   a 44px tap target by moving off .page-actions into this bar. A control does not get
   smaller on a phone because it changed hosts — so the shared component carries the
   convention rather than four screens each omitting it. */
@media (max-width: 700px) {
    .app-bulkbar { flex-wrap: wrap; width: 100%; }
    .app-bulkbar .btn {
        width: 100%;
        justify-content: center;
        min-height: var(--control-h-touch);
    }
}

.app-grid-search { position: relative; flex: 1 1 220px; max-width: 320px; }
.app-grid-search-input {
    width: 100%;
    padding: 7px 12px;
    font-size: 13px;
    border: 1px solid #d7dbe0;
    border-radius: 6px;
    color: #333;
    box-sizing: border-box;
}
.app-grid-search-input:focus { outline: none; border-color: var(--navy); }

/* Modern CSV export button -- borderless, no background, subtle hover */
.app-grid-export {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 6px 10px;
    font-size: 12px;
    font-weight: 600;
    color: var(--navy);
    background: none;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    transition: color .15s, background .15s;
}
.app-grid-export:hover { color: #0a2747; background: #eef3f9; }
.app-grid-export svg { flex-shrink: 0; }

/* Toolbar REFRESH (AppGrid cfg.onRefresh, 2026-08-24) — re-fetch the rows without
   reloading the page. It COMPOSES on .app-grid-export rather than declaring a
   second borderless-toolbar-button look: the two sit in the same slot and the day
   that look changes it must change for both. Only the busy state is its own.

   The spin is on the SVG, not the button, so the label stays legible while it
   turns. `prefers-reduced-motion` gets the same disabled+dimmed state without the
   rotation — the button being unclickable is what actually reports "working". */
.app-grid-refresh:disabled { cursor: default; opacity: 0.6; }
.app-grid-refresh.is-busy svg { animation: app-grid-refresh-spin 0.9s linear infinite; }
@keyframes app-grid-refresh-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
    .app-grid-refresh.is-busy svg { animation: none; }
}

/* Scroll container + table. Default keeps the page as the scroll context so the
   sticky header follows page scroll; opt into horizontal scrolling per grid with
   scrollX:true (which makes the wrapper the scroll context for wide tables). */
.app-grid-scroll {
    width: 100%;
    border: 1px solid #e8eaed;
    border-radius: 8px;
    background: #fff;
}
/* ---- Wide-grid width model (operator standard, 2026-07-15) ----------------
   When a grid is wider than the page, the CONTAINER fits the grid's natural
   width and the PAGE scrolls horizontally — never the grid. The table renders
   at its full natural width (no column-cramming / multi-line-wrapped headers);
   the AppGrid, its own scroll box, and the feature grid-card wrapper grow with
   it so the border wraps the whole table instead of the table running off it.
   min-width:100% keeps a narrow grid filling its card (and a narrow grid never
   forces page scroll). Scoped to the DEFAULT grid: a `scrollX` grid opts out
   (it keeps its own internal horizontal scroll — see below). */
.app-grid:not(.app-grid--scrollx),
.app-grid:not(.app-grid--scrollx) .app-grid-scroll { width: max-content; min-width: 100%; }
/* The feature grid-card wrapper grows too — but ONLY when it actually wraps a
   (default) AppGrid. The `:has()` guard is load-bearing: the four bespoke
   server-paged tables (ba/th-table, wr Released, bulk-wire) also carry a
   `*-card--grid` class yet own an internal `overflow:auto` scroller — growing
   THOSE to max-content defeats their own scroll, shifts h-scroll to the page and
   pushes the in-card pager off-screen. Matching on the contained `.app-grid`
   (which the bespoke cards never have, even when they share a feature card class
   with an AppGrid tab, e.g. wire_requests Pending vs Released) excludes them
   cleanly. Kept in its own rule so a browser without :has() degrades to the
   pre-fix border (not dropping the .app-grid grow rule above). */
:is([class*="-card--grid"], .app-panel--grid):has(.app-grid:not(.app-grid--scrollx)) { width: max-content; min-width: 100%; }
/* ...and the whole content column grows with it (operator 2026-07-16). Before
   this, only the grid + its card broke out past the 1750px cap while the hero /
   filter panel / tabs / action rows stayed pinned at 1750px — so scrolling a wide
   grid right revealed those sections ending short of the grid (the "grid is wide
   but the rest of the page isn't" mismatch). Sizing the page container to its
   widest child makes that chrome grow WITH the grid, sharing one right edge.
     - max-content (NOT fit-content): a grid wider than the viewport keeps its full
       natural width and the PAGE scrolls, per the width model above.
     - min-width keeps the normal centered 1750px column when content is narrow
       (and never narrower than the viewport below 1750).
     - The bespoke server-paged tables (ba/th-table, wr Released, bulk-wire) must
       NOT drive this — otherwise the page grows to THEIR natural width, defeating
       their own internal scroll and pushing their in-card pager off-screen (same
       reason they're excluded from the card grow rule). An overflow:auto scroller
       does NOT firewall intrinsic width from a max-content ancestor, so the
       bespoke cards are given explicit inline-size containment just below.
     - COROLLARY (the load-time width jump): this column is sized from the grid's
       CONTENT, which does not exist until the feed resolves — so a page whose table
       is wider than 1750px opened narrow and centred, then jumped wide and
       left-aligned the moment the rows landed (app_wire_requests.php measured
       1750 -> 2010px at a 1920 viewport: an 85px slide of the hero / filter panel /
       tab bar plus a new horizontal scrollbar). AppGrid now remembers each grid's
       natural width per gridId and floors its mount at it before the first paint,
       so the page opens at its final width — see _reserveWidth / _settleWidth in
       app_grid.js. Nothing here changes; the reservation is released as soon as
       real rows have rendered and these rules take over again. */
.page-wrap:has(.app-grid:not(.app-grid--scrollx)),
.page-wrapper:has(.app-grid:not(.app-grid--scrollx)) {
    width: max-content;
    min-width: min(1750px, 100%);
    max-width: none;
}
/* ---- `.page-wide-family` — ONE shared column width across a FAMILY's tabs ----
   (operator request 2026-08-10, the Treasury bar: "make all pages in this group
   have the wider setting … I want them to look uniform when I switch between tabs")

   The problem this solves is a side effect of the model above, not a second width
   tier. Every page in a family is already WIDE (no cap); what differs is that the
   column is sized from the GRID, so a family whose tabs have different column
   counts renders at different widths. Measured on QA at a 1920 viewport: six
   Treasury tabs sat at the 1750px FLOOR, centred with ~78px margins (Account
   Activity 7 columns, Bulk Wire 12, LOAs 9, SmartWire 8, Wire Templates 12,
   Holdback 6), while Wire Dashboard's 13-column table (1932px) grew its page to
   2010px and went full-bleed. Switching tabs therefore slid the hero, the filter
   panel and the action row sideways by ~85px.

   Opt in by adding `page-wide-family` beside `page-wrap` on EVERY page of the
   family (all or none — a partial adoption is the same jump with new numbers). It
   raises the FLOOR, so:
     - a viewport narrower than the family width  -> the column fills the window
       (min() keeps it at 100%, so nothing is forced to scroll that did not before,
       and mobile self-releases exactly as the 1750 floor does);
     - a viewport wider than the family width     -> every tab lands on the same
       centred column, the widest one included.
   It does NOT cap: a table genuinely wider than the family width still takes its
   natural width and page-scrolls, per the operator's standing "make the page
   scroll, not the grid" rule. So a family whose widest table exceeds
   --page-family-width stays ~that difference off uniform on a screen smaller than
   the table; the fix for that is the offending column's `wrap: <px>` cap, not a
   cap on the page.

   --page-family-width lives on :root because BOTH consumers need it and the second
   one is not inside .page-wrap: the `.app-subnav` sticky pin below is keyed to the
   content column (its offset centres the tabs over a 1750px column), so widening
   the column without retargeting the pin would leave the tab row ~85px right of
   the hero it belongs to — which is the very misalignment this was asked to fix. */
:root { --page-family-width: 2050px; }
.page-wrap.page-wide-family:has(.app-grid:not(.app-grid--scrollx)),
.page-wrapper.page-wide-family:has(.app-grid:not(.app-grid--scrollx)) {
    min-width: min(var(--page-family-width), 100%);
}
/* A family page with no AppGrid at all keeps the shared cap rather than the family
   width — a form or a settings panel does not want a 2050px measure — so nothing
   is declared for it by the rule above. When such a page is one of the family's
   own TABS, add `page-wide-family--always` as well: see that modifier below the
   pin rule. */
/* Repeat the base rule's own `:has(.app-grid…)` predicate rather than relying on
   source order: `:has()` takes the specificity of its most specific argument, so a
   plain `body:has(.page-wide-family) …` selector scores (0,3,1) against the base
   pin's (0,4,1) and loses NO MATTER where it sits in the file (measured — the tabs
   stayed pinned at the 1750-column offset while the hero had already moved to the
   wider column's gutter). It also scopes the override to the pages that HAVE the
   pin, which is the same set. */
body:has(.page-wide-family):has(.app-grid:not(.app-grid--scrollx)) .app-subnav-bar-inner > .app-subnav,
body:has(.page-wide-family):has(.app-grid:not(.app-grid--scrollx)) .app-subnav-bar-inner > .app-subnav-select {
    left: max(24px, calc((100vw - var(--page-family-width)) / 2 + 24px));
}

/* ---- `.page-wide-family--always` — a family TAB that has no grid to size it ----
   (operator, 2026-08-25: "the other pages in treasury automatically get the same
   page width … I noticed this overview page is not getting that page width")

   The rule above is gated on the page hosting an AppGrid, because that gate is
   what keeps a family's FORM and RECORD pages off a 2050px measure. But a family
   can also have a tab that is a DASHBOARD — cards, tiles and a chart, no grid
   anywhere — and that tab is exactly what the reader switches to and from.
   Measured on the Treasury bar at a 2300 viewport: every grid tab sat on the
   2050px column with its hero at x=142, while app_treasury.php (the family's
   landing tab) fell back to the 1750px cap with its hero at x=292. Switching tabs
   slid the hero, the action row and the tab row 150px sideways — the very jump
   `.page-wide-family` was asked to remove, reintroduced by the page that has no
   table in it.

   So this modifier is the explicit opt-in for that case: same floor, no grid
   required. Put it BESIDE `page-wide-family` (which is what the family declares)
   rather than instead of it, so a page carrying it is still visibly a member.
   Do NOT put it on a form, a record page or a settings panel — those are the
   pages the `:has(.app-grid…)` gate exists to protect.

   TWO consumers, and both are needed or the fix makes the misalignment worse:
     1. the content column itself (min-width beats .page-wrap's 1750 max-width,
        exactly as it does for a grid page);
     2. `.app-subnav-bar-inner`, which reuses .page-wrap's box to line the tabs up
        with the content below. A gridless page gets NONE of the wide-page subnav
        treatment above (no full-bleed band, no sticky pin — it does not
        page-scroll horizontally, so it needs neither), so widening the column and
        leaving the bar at 1750 would leave the tabs 150px off the hero. Both boxes
        take the same `min(family, 100%)`, so they centre on the same column and
        the 24px gutter each carries lines the two up to the pixel. */
.page-wrap.page-wide-family--always,
.page-wrapper.page-wide-family--always {
    min-width: min(var(--page-family-width), 100%);
}
body:has(.page-wide-family--always) .app-subnav-bar-inner {
    max-width: min(var(--page-family-width), 100%);
}
/* Full-bleed chrome on wide (page-scrolling) grid pages. The rule above grows the
   CONTENT column to the grid's natural width so the WINDOW scrolls horizontally,
   but the topbar/footer/QA bands are direct <body> children sized to the body — so
   they used to end at the first viewport, and scrolling right revealed a bare strip
   beside them. Grow the BODY to the same content width so those bands stretch across
   the whole scroll range. The topbar needs none of this (it is position:fixed); the
   footer stays IN FLOW (still scrolls away vertically) and only its inner content is
   pinned to the viewport-left (below). min-width:max(1200px,100%) keeps the 1200
   desktop floor AND guarantees body >= viewport, so even a NARROW grid's footer
   fills the viewport with no right-edge gap. The menu-drawer overflow:hidden lock
   still freezes the window scroll (overflow propagates from body to the viewport
   regardless of body width), so no scroll-lock regression. Same :has() discriminator
   as the page-grow rule (excludes scrollX / bespoke internal-scroll tables). */
body:has(.app-grid:not(.app-grid--scrollx)) {
    width: max-content;
    min-width: max(1200px, 100%);
}
/* Pin the footer + system-message content to the VIEWPORT CENTRE while the wide page
   scrolls right (it still scrolls away vertically — position:sticky only pins the
   horizontal axis here). This is viable ONLY because the body/footer are now
   page-wide: a sticky element cannot track scroll past its containing block, so a
   viewport-wide footer would make the offset inert. `.app-footer` ships
   overflow:hidden (app_footer.css) which would make the footer itself the sticky
   scroll-container and neutralise the pin — restore overflow:visible on these pages.
   margin-inline:0 left-origins the inner (killing the base `margin:0 auto`, which
   would otherwise centre it in the WIDENED band, i.e. off-screen to the right); the
   sticky `left` offset then places it, and because a sticky offset is measured from
   the SCROLLPORT edge it holds that same on-screen position at every scrollLeft.
     - left:0 (until 2026-07-29) parked the footer hard against the viewport-left on
       every page carrying a default AppGrid — including the many whose grid FITS and
       which therefore never scroll horizontally at all (e.g. the launch_pad dashboard
       panels at app_launch_pad.php?page=legacy). Against the centred footer of
       every non-grid page (app_messages.php et al) that read as a bug, because the
       pin was buying nothing there.
     - Centring the offset instead keeps the pin AND matches the non-grid footer, so
       the band no longer moves depending on whether a page happens to host a grid.
     - max(0px, …) collapses the offset rather than going negative below 1140px.
     - The 1140px here MUST track `.app-footer-inner`'s max-width in app_footer.css;
       they are the same measurement expressed in two files.
     - Residual: 100vw includes the vertical scrollbar, so on a grid page that also
       scrolls vertically this lands ~half a scrollbar (~7.5px) right of true centre.
       Measured, deliberate, and the reason this is NOT expressible as margin:auto —
       the containing block here is the widened band, not the viewport. */
body:has(.app-grid:not(.app-grid--scrollx)) .app-footer { overflow: visible; }
/* `.app-footer-sysmessage-inner` was pinned alongside this until 2026-09-02 —
   the operator system notice left the footer with ClickUp 868m069pv and now
   renders inside `.page-wrap`, where the shared callout's own
   `contain: inline-size` keeps it from inflating the grown page column. */
body:has(.app-grid:not(.app-grid--scrollx)) .app-footer-inner {
    position: sticky;
    left: max(0px, calc((100vw - 1140px) / 2));
    margin-inline: 0;
}
/* QA-only chrome: left-origin the "Test Environment" badge so it too stays at the
   viewport-left on a wide page (cosmetic; production never renders this band). */
body:has(.app-grid:not(.app-grid--scrollx)) .qa-test-banner { justify-content: flex-start; }
/* Inline-size firewall for the bespoke server-paged grid cards: a `*-card--grid`
   that does NOT wrap a default AppGrid (the ba/th-table, wr Released, bulk-wire
   internal-scroll tables, plus any `scrollX` grid) contains its inline size so its
   wide table contributes ZERO to the growing page column above — the card fills
   the (floored) page width and its table scrolls INSIDE, keeping the pager
   on-screen. A no-op when the page column isn't growing (parent width is already
   definite). Same `.app-grid` discriminator the card grow rule uses, negated, so
   the two never both match one card (Pending grows; Released stays contained). */
:is([class*="-card--grid"], .app-panel--grid):not(:has(.app-grid:not(.app-grid--scrollx))) {
    contain: inline-size;
}
/* Mobile release of the grow model (the standard 700px breakpoint). The
   max-content growth above is a DESKTOP behaviour — a wide TABLE page-scrolls
   instead of cramming. Below 700px the AppGrid renders stacked CARDS and the
   shell is fully fluid (body min-width floor released), but max-content still
   sized the grid/card/page column to the cards' widest unwrapped text line
   (~1000px at a 375px viewport), h-scrolling every AppGrid list page on
   phones. Release all three grow rules back to their base fluid widths — same
   selectors as the grow rules, so source order decides; the :has() rules stay
   separate so a browser without :has() (which never grew the card/page) still
   parses the .app-grid release. The bespoke cards' inline-size containment
   above stays in force (their tables keep scrolling internally at phone
   widths). Feature precedent: bulk_wire.css releases its bespoke
   .bw-editor-card the same way at 700px. */
@media (max-width: 700px) {
    .app-grid:not(.app-grid--scrollx),
    .app-grid:not(.app-grid--scrollx) .app-grid-scroll { width: auto; min-width: 0; }
    :is([class*="-card--grid"], .app-panel--grid):has(.app-grid:not(.app-grid--scrollx)) { width: auto; min-width: 0; }
    .page-wrap:has(.app-grid:not(.app-grid--scrollx)),
    .page-wrapper:has(.app-grid:not(.app-grid--scrollx)) {
        width: 100%;
        max-width: 1750px;
    }
    /* Release the body-grow + footer pin too: mobile stacks cards, is fully fluid,
       and never page-scrolls horizontally, so the footer returns to its centered
       in-flow layout. */
    body:has(.app-grid:not(.app-grid--scrollx)) { width: auto; min-width: 0; }
    body:has(.app-grid:not(.app-grid--scrollx)) .app-footer-inner {
        position: static;
        margin-inline: auto;
    }
}
.app-grid-scroll--x { overflow-x: auto; }
/* scrollX opt-in: the wrapper stays card-width and the table overflows INSIDE
   it (internal scroll), so undo the grow rule on that subtree. */
.app-grid--scrollx,
.app-grid--scrollx .app-grid-scroll { width: 100%; min-width: 0; }
/* Click-and-drag horizontal panning (set by JS when the table overflows). */
.app-grid-scroll--draggable { cursor: grab; }
.app-grid-scroll--grabbing { cursor: grabbing; user-select: none; }

/* SERVER-PAGED grids (AppGrid cfg.server): while the next page / new sort is in
   flight the rows already on screen DIM instead of being replaced by skeleton
   rows — on a ledger every Prev/Next click would otherwise flash the table empty.
   Scoped to the scroll box so the toolbar, the footer count and the pager stay
   solid and clickable; pointer-events off stops a second sort landing mid-request
   (the response sequence guard in app_grid.js catches it either way).
   Promoted 2026-08-04 from bank_activity's local `.ba-loading`, which was the
   same rule applied to its hand-rolled table body. */
.app-grid--busy .app-grid-scroll { opacity: 0.45; transition: opacity .1s; pointer-events: none; }
.app-grid-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 12px;
    color: #333;
}
.app-grid-th {
    position: sticky;
    /* Offset by the sticky topbar height (56px) so headers tuck beneath it. */
    top: 56px;
    z-index: 2;
    background: var(--head-bg);
    text-align: left;
    font-weight: 600;
    color: #5a6573;
    padding: 10px 12px;
    /* box-shadow keeps the divider visible while the header is stuck
       (collapsed-border tables drop the cell border on scroll). */
    box-shadow: inset 0 -1px 0 #e3e7eb;
    /* No-wrap standard (operator, 2026-07-15): a single word / token is NEVER
       broken mid-string (overflow-wrap:normal, not anywhere) — broken dates
       ("07/15/\n2026"), IDs and single words read as unpolished. The wide-grid
       width model above (width:max-content) gives the table its full natural
       width, so a column-heavy table takes that width and the PAGE scrolls rather
       than the label wrapping to fit the card; this rule just guarantees tokens
       aren't mangled if a subtree ever IS constrained. Opt a genuinely long
       free-text column into breaking with .app-grid-th--wrap /
       .app-grid-cell--wrap (below). */
    white-space: normal;
    overflow-wrap: normal;
    user-select: none;
}
/* THE SELECT-ALL BOX IS A HEADER CELL TOO (2026-09-28). AppGrid renders it as a
   bare `th.app-grid-check`, not `.app-grid-th` (_buildHeadRow), so it missed the
   rule above: on a selectable PAGE grid every data header stuck under the topbar
   while that one cell scrolled away with the rows, leaving a hole at the band's
   left edge for the row checkboxes to slide through and taking Select all out of
   reach (the Data Rooms resend page, Online Statements; inside a drill, the CRM
   Events dialog). It takes the five declarations that make a header cell HOLD and
   nothing else — the class itself would also bring the label's 10px vertical
   padding, its colour and weight, and user-select. Every rule below that
   re-offsets the header names this cell beside `.app-grid-th` (the scrollX and
   drill `top: 0`, the float clone's `static`), so the two cannot drift apart.
   `thead` because the body, skeleton and totals rows carry `td.app-grid-check`. */
thead .app-grid-check {
    position: sticky;
    top: 56px;
    z-index: 2;
    background: var(--head-bg);
    box-shadow: inset 0 -1px 0 #e3e7eb;
}
/* In horizontal-scroll mode the wrapper itself becomes the sticky containing
   block (overflow-x:auto forces overflow-y to compute to auto), so the 56px
   topbar offset is measured from the wrapper's top — pushing the header down
   and leaving a gap above the first row. Pin it to the wrapper's top instead.
   (scrollX already trades away page-scroll stickiness, per the AppGrid docs.) */
.app-grid-scroll--x .app-grid-th,
.app-grid-scroll--x thead .app-grid-check { top: 0; }
/* scrollX grids grow + scroll horizontally inside their own wrapper, so EVERY
   label/value (even multi-word) stays on one line — stronger than the default
   grid, which lets multi-word labels wrap at spaces to stay within the card. */
.app-grid-scroll--x .app-grid-th,
.app-grid-scroll--x .app-grid-cell { white-space: nowrap; overflow-wrap: normal; }
/* JS-driven floating header clone for horizontal-scroll grids: shown fixed
   under the topbar while the real header is scrolled past, restoring the
   page-scroll stickiness that overflow-x scrolling otherwise disables. */
.app-grid-floathead {
    position: fixed;
    z-index: 20;
    display: none;
    overflow: hidden;
    background: var(--head-bg);
    box-shadow: 0 4px 8px rgba(15, 52, 96, 0.08);
}
.app-grid-floathead .app-grid-table { table-layout: fixed; border-collapse: collapse; }
.app-grid-floathead .app-grid-th,
.app-grid-floathead thead .app-grid-check { position: static; }
.app-grid-center { text-align: center; }
.app-grid-right  { text-align: right; }
.app-grid-th.app-grid-center .app-grid-th-label,
.app-grid-th.app-grid-right  .app-grid-th-label { justify-content: center; }

/* NUMERIC column (decimal / money / count) — operator standard, 2026-07-24: a
   numeric column is right-aligned in the HEADER as well as the cells, so the
   header label can never drift left of the figures it describes. Opt in per
   column with `numeric: true` in the AppGrid config; app_grid.js then appends
   .app-grid-right to the th and .app-grid-right + .app-grid-num to the td and
   the totals cell (see isNumericCol / _buildHeadRow). `align:'right'` already
   did both sides — the bug this fixes was a feature right-aligning ONLY the cell
   through a local class. Tabular figures keep the digit columns lined up.
   Promoted VERBATIM 2026-07-24 from the byte-identical per-feature copies
   (.pt-num / .pta-num / .iad-num); those files may drop theirs as they migrate. */
.app-grid-num { font-variant-numeric: tabular-nums; }

.app-grid-sortable { cursor: pointer; }
.app-grid-sortable:hover { background: #eef2f6; }
.app-grid-th-label { display: inline-flex; align-items: center; gap: 4px; }
.app-grid-sort-ico {
    display: inline-block;
    width: 0; height: 0;
    margin-left: 4px;
    border-left: 4px solid transparent;
    border-right: 4px solid transparent;
    border-bottom: 5px solid #c2c9d1;
    opacity: 0.5;
    vertical-align: middle;
}
.app-grid-th.is-sorted-asc  .app-grid-sort-ico {
    border-bottom: 5px solid var(--navy); border-top: 0; opacity: 1;
}
.app-grid-th.is-sorted-desc .app-grid-sort-ico {
    border-top: 5px solid var(--navy); border-bottom: 0; opacity: 1;
}

.app-grid-cell {
    padding: 9px 12px;
    border-bottom: 1px solid #eef1f4;
    vertical-align: middle;
    /* No-wrap standard (see .app-grid-th): multi-word values may wrap at spaces,
       but single words / tokens (dates, IDs, emails) are never broken mid-string.
       Opt long free-text columns back into breaking with .app-grid-cell--wrap. */
    overflow-wrap: normal;
}
/* Opt-in: a genuinely long free-text column (a description / note / address)
   can wrap AND break long tokens to stay narrow. Set it per column with the
   AppGrid column option `wrap: true` (or `wrap: <px>`), which applies BOTH of
   these classes AND the max-width cap the wrap depends on.
   The cap is not optional: the width model above sizes the table to its
   MAX-content width — the widest single-line layout — so on a page-scrolling
   grid `white-space:normal` alone never wraps and one long description drives
   the whole page off-screen (the Data Rooms list, 2026-07-27). AppGrid sets the
   cap inline per column (see wrapPx in app_grid.js) rather than a fixed value
   here, so each column picks its own width; these two classes stay class-only so
   the older hand-rolled recipe (className + headerClassName + a local
   `max-width` rule, e.g. opus_invoices' .oi-desc-cell) keeps working. */
.app-grid-th--wrap,
.app-grid-cell--wrap { white-space: normal; overflow-wrap: anywhere; }
/* ⚠ THE SAME CAP INSIDE A `scrollX` GRID HAS TO CLIP, because the wrap above
   LOSES there: `.app-grid-scroll--x .app-grid-cell { nowrap }` (0,2,0)
   out-specifies `.app-grid-cell--wrap { normal }` (0,1,0) — while AppGrid's
   inline `max-width` from `wrap: <px>` still applies. A capped, nowrap,
   overflow-visible cell does not shorten: it OVERFLOWS, and its text is painted
   straight across the next column. On the invoice record's Lines grid every long
   description ran over the Charged-for fund beside it and the two read as one
   scrambled string (operator report 2026-09-02).
   So under scrollX the cap means "this much and no more": clip, and end in an
   ellipsis so the reader can see the value is cut rather than think it is short.
   The DRILL modal is deliberately not included — general.css re-wins the wrap
   rule there (see `.app-drill-body .app-grid-scroll--x .app-grid-cell--wrap`
   below), so those cells wrap and have nothing to clip. Nor is a non-drill
   dialog's grid body (`.app-modal-gridbody`), which takes the same three wrap
   rules since 2026-09-28. */
.app-grid-scroll--x .app-grid-th--wrap,
.app-grid-scroll--x .app-grid-cell--wrap {
    overflow: hidden;
    text-overflow: ellipsis;
}
.app-drill-body .app-grid-scroll--x .app-grid-th--wrap,
.app-drill-body .app-grid-scroll--x .app-grid-cell--wrap,
.app-modal-gridbody .app-grid-scroll--x .app-grid-th--wrap,
.app-modal-gridbody .app-grid-scroll--x .app-grid-cell--wrap { overflow: visible; }

/* ---- Clamped long free text in a grid cell (.app-cell-clamp) ----
   The escalation above `wrap` for a column whose stored text runs to hundreds or
   thousands of characters. Markup + behaviour is window.AppClamp (general.js):
   AppClamp.html(value, h.esc) in the column's render(), AppClamp.watch(mount)
   once after AppGrid.create().

   The three parts are NOT interchangeable:
     1. The WIDTH CAP, which does not live here: it is AppGrid's `wrap: <px>`
        column option (wrapPx in app_grid.js), which applies
        .app-grid-cell--wrap AND the inline max-width the wrap depends on. Under
        the max-content wide-grid width model the table sizes to its widest
        single-line layout, so white-space:normal on its own never wraps — the
        column keeps its full natural width and drives the whole PAGE wide.
        Do NOT "simplify" the cap into a max-width rule here: the th needs the
        same cap or it re-sets the column width.
     2. pre-line keeps the author's stored line breaks, so a "please provide the
        following" list reads as a list instead of one run-on paragraph.
     3. This 4-line clamp + the Show more / Show less toggle: a 2000-character
        value would otherwise lay out ~33 lines and bury the rest of the grid.
        AppClamp.watch() reveals the toggle only on cells that actually overflow
        and flips .is-open.
   Line height is pinned here (the .app-grid-table base sets 12px/normal) so the
   clamp is a predictable 4 lines rather than whatever the inherited leading
   happens to compute to.
   Promoted 2026-07-31 from implementation_tasks' .it-desc* on its second call
   site (app_amldocrequests' Opus Comments) — README §5. */
.app-cell-clamp-text {
    color: #22303f;
    white-space: pre-line;
    line-height: 1.5;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 4;
    line-clamp: 4;
    overflow: hidden;
}
.app-cell-clamp.is-open .app-cell-clamp-text {
    display: block;
    -webkit-line-clamp: none;
    line-clamp: none;
    overflow: visible;
}
/* Reads as a text link but IS a button (it toggles, it doesn't navigate). */
.app-cell-clamp-toggle {
    display: inline-flex;
    align-items: center;
    gap: 3px;
    margin-top: 3px;
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    font-size: 12px;
    font-weight: 700;
    color: #2f5fb3;
    cursor: pointer;
}
.app-cell-clamp-toggle:hover { text-decoration: underline; }
/* Companion for the `hidden` attribute — the display above would otherwise win
   over the UA's [hidden]{display:none} and every cell would show a toggle. */
.app-cell-clamp-toggle[hidden] { display: none !important; }
.app-cell-clamp-chev { transition: transform 0.12s ease; }
.app-cell-clamp.is-open .app-cell-clamp-chev { transform: rotate(180deg); }

/* Row-action column (per-row buttons). Operator standard (2026-07-15): AppGrid
   renders the action column as the LEFT-most data column (right after any
   selection checkbox — see isActionCol/_layoutColumns in app_grid.js) and tags
   its th/td with these classes. The cell NEVER wraps: two adjacent buttons stay
   side by side and the column simply widens (the page/grid scrolls per the
   1200px floor) rather than dropping a button to a second line. */
.app-grid-th--actions,
.app-grid-cell--actions { white-space: nowrap; }
/* Left-align the buttons within the (now left-most) action column, overriding
   any per-column align:'right'/'center' — or a feature `*-actions-cell` class —
   that was set back when the column lived on the right. The compound selector
   (0,2,0) beats those single-class rules without !important, so general.css
   stays the single source of truth for action-column alignment. */
.app-grid-cell.app-grid-cell--actions,
.app-grid-th.app-grid-th--actions { text-align: left; }

/* ---- Icon row-action buttons (.app-row-actions / .app-rowbtn) ----
   THE standard per-row action control (operator standard, 2026-07-24): row
   actions are ICONS, never text labels — the four standard actions are
   View (eye), Edit (pencil), Duplicate (copy) and Delete (trash, the --danger
   variant), and the glyphs live in one place, window.AppIcons (general.js), so
   no feature hand-rolls SVG. Square, outline, navy; the accessible name comes
   from title + aria-label on the <button>/<a> (the SVG is decorative,
   aria-hidden). Wrap the set in .app-row-actions inside the action column's cell.
   Promoted VERBATIM 2026-07-24 from the opus_invoices recipe (.oi-rowbtn /
   .oi-row-actions), which data_rooms (.dr-rowbtn) and atr_settings (.atr-rowbtn)
   had already copied byte-for-byte. All four pre-promotion local copies were
   migrated onto this class the same day and their blocks deleted — there is now
   ONE row-action button in the app. Three features keep a single colour-override
   rule on top of this class (never a forked block), and only where the accent is
   SEMANTIC: .app-rowbtn.ic-rowbtn--edit / --dash (investor_contacts' green Edit +
   purple Dashboard), .app-rowbtn.q-action-close (questions' green Review & Close)
   and .app-rowbtn.pt-act-log (payable's teal Log Payment). atr_settings had a
   fourth — .app-rowbtn.atr-rowbtn, ATR-green on plain View/Edit — deleted
   2026-07-31 (operator QA): a non-semantic accent is just a fork of the default,
   so ATR's row actions are the shared navy button. valuation_policy was the last
   feature restyling .btn-card-action into a third row-action look; it moved onto
   this class 2026-08-14 (operator QA), so a GRID ROW's actions are now this
   control everywhere. Note .btn-card-action is a DIFFERENT control — a
   text-labelled card / panel action (launch_pad's dashboard tiles,
   capital_calls) — not this one. */
.app-row-actions {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    white-space: nowrap;
}
.app-rowbtn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    flex-shrink: 0;
    padding: 0;
    border: 1px solid #d0d7e2;
    border-radius: 7px;
    background: #fff;
    color: var(--navy);
    cursor: pointer;
    text-decoration: none;
    transition: background .15s, border-color .15s, color .15s;
}
/* The glyph is decorative (aria-hidden) — make it click-TRANSPARENT so a
   delegated handler reading ev.target always sees the button/anchor itself,
   never the svg/path child (2026-07-24 icon rollout: several features'
   delegations read ev.target without a closest() walk). */
.app-rowbtn svg { display: block; pointer-events: none; }
.app-rowbtn:hover { background: var(--navy); border-color: var(--navy); color: #fff; }
/* Semantic variants — TWO, and only two: destructive and cautionary.
   --danger (red)  = Delete: the row is gone.
   --warn  (amber) = Disable / deactivate / suspend / revoke: the row stays but is
   turned OFF, so it reads as cautionary rather than destructive (operator request
   2026-07-28, pairs with the ban glyph AppIcons.disable). Amber is the bordered
   pill family's --pill-amber-fg, so the row-action accent, the "Disabled" status
   pill and .btn-warning all speak the same colour; white on it clears AA (4.9:1).
   Anything that is NOT one of these two concepts stays the plain navy button. */
.app-rowbtn--danger { color: #c0392b; }
.app-rowbtn--danger:hover { background: #c0392b; border-color: #c0392b; color: #fff; }
.app-rowbtn--warn { color: var(--pill-amber-fg); }
.app-rowbtn--warn:hover { background: var(--pill-amber-fg); border-color: var(--pill-amber-fg); color: #fff; }
/* Shared disabled contract (README §2): native :disabled for <button>,
   [aria-disabled] for link-actions; both inert + dimmed. */
.app-rowbtn:disabled,
.app-rowbtn[aria-disabled="true"] {
    opacity: var(--control-disabled-opacity);
    cursor: not-allowed;
    pointer-events: none;
}
.app-grid-row:nth-child(even) { background: #fbfcfd; }
.app-grid-row:hover { background: #f2f7fc; }
.app-grid-row.is-selected { background: #e8f1fb; }
.app-grid-row.is-selected:hover { background: #ddeafa; }

/* Optional per-record sub-row (rowDetailRender) — a full-width secondary row
   directly under its record. Zebra moves to explicit record-pair classes
   (nth-child would desync once sub-rows interleave). */
.app-grid--subrows .app-grid-row:nth-child(even) { background: none; }
.app-grid--subrows .app-grid-row--even,
.app-grid-subrow--even { background: #fbfcfd; }
.app-grid-row.has-subrow .app-grid-cell { border-bottom: 0; }
.app-grid-subcell {
    padding: 0 12px 9px;
    border-bottom: 1px solid #eef1f4;
}
/* Hovering the record highlights its sub-row too (and vice versa). */
.app-grid-row:hover + .app-grid-subrow,
.app-grid-subrow:hover { background: #f2f7fc; }

/* Optional group header bands (cfg.groupBy) — full-width rows above each run
   of records sharing the group value. Zebra moves to the explicit
   .app-grid-row--even class (restarted inside each group by JS). */
.app-grid--grouped .app-grid-row:nth-child(even) { background: none; }
.app-grid--grouped .app-grid-row--even { background: #fbfcfd; }
.app-grid-groupcell {
    padding: 9px 12px 8px;
    background: linear-gradient(180deg, #f4f7fa 0%, #eef2f7 100%);
    border-top: 1px solid #e3e7eb;
    border-bottom: 1px solid #e3e7eb;
}
.app-grid-grouprow:first-child .app-grid-groupcell { border-top: 0; }
.app-grid-groupcell,
.app-grid-groupcard {
    font-size: 12px;
    font-weight: 700;
    color: #2b3a4e;
}
.app-grid-group-label { letter-spacing: .2px; }
.app-grid-group-count {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 20px;
    height: 18px;
    padding: 0 7px;
    margin-left: 8px;
    border-radius: 18px;
    background: rgba(15, 52, 96, .10);
    color: var(--navy);
    font-size: 11px;
    font-weight: 700;
    line-height: 1;
    vertical-align: 1px;
}
/* Mobile card layout: the band sits between cards as a slim section label. */
.app-grid-groupcard {
    display: flex;
    align-items: center;
    padding: 10px 4px 6px;
}
.app-grid-groupcard:first-child { padding-top: 0; }

/* ── A band that OPENS, and a band that CARRIES THE GROUP'S SUBTOTALS ──────
   cfg.groupBy.collapsible / .totals / .pageUnit (app_grid.js). Built 2026-09-18
   for the Capitalization Table's per-fund roll-up; shared because a grouped grid
   wanting a total per group is the general case, not that screen's.

   The band row is CLICKABLE AS A WHOLE (a reader aims at the fund name, not at a
   12px chevron), so the cursor and the hover belong to the row, not the button.
   The chevron rotates rather than swapping glyphs — one element, no reflow. */
.app-grid-grouprow.is-open,
.app-grid-grouprow.is-collapsed { cursor: pointer; }
.app-grid-grouprow.is-open:hover > td,
.app-grid-grouprow.is-collapsed:hover > td { background: #e9eff6; }
.app-grid-grouptoggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 20px;
    height: 20px;
    margin: -2px 6px -2px -2px;
    padding: 0;
    border: 0;
    border-radius: 5px;
    background: none;
    color: #5a6b80;
    cursor: pointer;
    vertical-align: -4px;
    transition: transform .15s ease, background-color .15s ease;
}
.app-grid-grouptoggle:hover { background: rgba(15, 52, 96, .10); color: var(--navy); }
.app-grid-grouptoggle:focus-visible { outline: 2px solid var(--navy); outline-offset: 1px; }
.app-grid-grouptoggle[aria-expanded="true"] { transform: rotate(90deg); }

/* The band's subtotal cells. Same padding and rule as the grand-totals row they
   reconcile to, one shade lighter so the page still reads bands-then-total. */
.app-grid-groupsum {
    padding: 9px 12px 8px;
    background: linear-gradient(180deg, #f4f7fa 0%, #eef2f7 100%);
    border-top: 1px solid #e3e7eb;
    border-bottom: 1px solid #e3e7eb;
    font-size: 12px;
    font-weight: 700;
    color: #2b3a4e;
}
.app-grid-grouprow:first-child .app-grid-groupsum { border-top: 0; }

/* Mobile: no columns to line up under, so a subtotal is a label + value fact and
   the band WRAPS. `.app-grid-groupcard` is a non-wrapping flex row — a band of
   four money facts is how a rich group header pushes a phone viewport sideways
   (measured on app_capital_calls.php, 2026-08-17), so wrapping is released only
   for the bands that carry facts. */
.app-grid-groupcard--totals { flex-wrap: wrap; align-items: baseline; gap: 2px 14px; }
.app-grid-groupfacts { display: flex; flex-wrap: wrap; gap: 2px 14px; }
.app-grid-groupfact { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 5px; max-width: 100%; }
.app-grid-groupfact-k { font-size: 10px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: #7a8798; }
.app-grid-groupfact-v { font-size: 12px; font-weight: 700; color: #2b3a4e; font-variant-numeric: tabular-nums; }

/* Selection checkbox column */
.app-grid-check {
    width: 36px;
    text-align: center;
    padding-left: 12px;
    padding-right: 6px;
}
.app-grid-check input { cursor: pointer; }

/* Skeleton rows */
.app-grid-skelrow td { padding: 11px 12px; border-bottom: 1px solid #eef1f4; }
.app-grid-skelrow .skel-text { width: 80%; }

/* State rows (empty / no-results / error) */
.app-grid-state {
    text-align: center;
    padding: 40px 20px;
    color: #888;
}
.app-grid-state-icon { margin-bottom: 12px; color: #c4ccd4; }
.app-grid-state-title { font-size: 15px; font-weight: 600; color: #555; margin-bottom: 6px; }
.app-grid-state-msg { font-size: 13px; color: #8a94a0; line-height: 1.6; }
.app-grid-state--error .app-grid-state-title { color: #c0392b; }
.app-grid-retry { margin-left: 8px; }

/* Optional totals row (columns opting in with a `total` fn) */
.app-grid-totals td {
    font-weight: 600;
    color: #333;
    background: #f6f8fa;
    border-top: 2px solid #d5dbe2;
    white-space: nowrap;
}
.app-grid-totals-label { text-transform: uppercase; font-size: 11px; letter-spacing: .4px; color: #6b7480; }

/* Footer: count + pager */
.app-grid-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 10px 2px;
    flex-wrap: wrap;
}
.app-grid-count { font-size: 12px; color: #6b7480; }
.app-grid-count strong { color: #333; }
.app-grid-pager { display: flex; align-items: center; gap: 6px; }
.app-grid-pageinfo { font-size: 12px; color: #6b7480; padding: 0 4px; }
.app-grid-pgbtn {
    display: inline-flex;
    align-items: center;
    gap: 3px;
    padding: 4px 9px;
    font-size: 12px;
    font-weight: 600;
    color: var(--navy);
    background: #fff;
    border: 1px solid #d7dbe0;
    border-radius: 6px;
    cursor: pointer;
    transition: background .15s, border-color .15s;
}
.app-grid-pgbtn:hover:not(:disabled) { background: #f2f7fc; border-color: var(--navy); }
.app-grid-pgbtn:disabled { opacity: .45; cursor: not-allowed; }
.app-grid-pgbtn svg { flex-shrink: 0; }
.app-grid-pagesize {
    padding: 5px 8px;
    font-size: 12px;
    border: 1px solid #d7dbe0;
    border-radius: 6px;
    color: #333;
}
.app-grid-loadmore { min-width: 160px; }

/* Columns visibility menu */
.app-grid-cols { position: relative; }
.app-grid-cols-panel {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    margin-top: 4px;
    background: #fff;
    border: 1px solid #d7dbe0;
    border-radius: 8px;
    box-shadow: 0 6px 20px rgba(20, 30, 50, 0.12);
    padding: 8px;
    z-index: 30;
    min-width: 180px;
}
.app-grid-cols.open .app-grid-cols-panel { display: block; }
.app-grid-cols-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 8px;
    font-size: 13px;
    color: #333;
    border-radius: 6px;
    cursor: pointer;
}
.app-grid-cols-item:hover { background: #f2f7fc; }

/* ---- Export CSV MENU (AppGrid cfg.tabExport, 2026-09-10) ------------------
   A TABBED grid's Export CSV became a two-item menu — "This tab only" / "All
   tabs" — because the toolbar CSV only ever saw the open tab, which made a
   tabbed screen a screen the user could not export whole (ClickUp 868m3t5wq).

   It COMPOSES on .app-grid-cols-panel's geometry rather than declaring a second
   toolbar dropdown look: same white card, same hairline, same shadow, same
   z-index. Only three things differ and each earns it — the panel opens to the
   RIGHT edge (the control is the right-most in .app-grid-toolbar-right, so a
   left-anchored panel would hang off the grid), the items are two-line, and the
   trigger carries a caret.

   The trigger keeps .app-grid-export because that class is the capture-phase
   Export (P) hook in general.js, not merely a skin — see _buildExport(). */
.app-grid-exportmenu { position: relative; }
.app-grid-export--menu[aria-expanded="true"] { color: #0a2747; background: #eef3f9; }
.app-grid-export-caret { margin-left: -1px; opacity: .7; }
.app-grid-export--menu[aria-expanded="true"] .app-grid-export-caret { transform: rotate(180deg); }
.app-grid-exportmenu-panel {
    display: none;
    position: absolute;
    top: 100%;
    right: 0;
    margin-top: 4px;
    background: #fff;
    border: 1px solid #d7dbe0;
    border-radius: 8px;
    box-shadow: 0 6px 20px rgba(20, 30, 50, 0.12);
    padding: 6px;
    z-index: 30;
    min-width: 210px;
}
.app-grid-exportmenu.open .app-grid-exportmenu-panel { display: block; }
/* An item is a full-width two-line block, so it must undo the inline-flex row
   .app-grid-export sets up. It keeps that class for the permission gate alone. */
.app-grid-exportmenu-item {
    display: block;
    width: 100%;
    text-align: left;
    padding: 7px 10px;
    border-radius: 6px;
    font-weight: 600;
}
.app-grid-exportmenu-item + .app-grid-exportmenu-item { margin-top: 2px; }
.app-grid-exportmenu-title { display: block; font-size: 13px; color: var(--navy); }
/* The hint names the ACTIVE tab on the first item and the Group column on the
   second — the thing that makes the two read as a real choice. Empty on a grid
   that declares no activeTab, and an empty span must not leave a blank line. */
.app-grid-exportmenu-hint {
    display: block;
    font-size: 11px;
    font-weight: 500;
    color: var(--text-muted);
    margin-top: 1px;
}
.app-grid-exportmenu-hint:empty { display: none; }

/* ---- Mobile card layout (JS toggles .app-grid--mobile via matchMedia) ---- */
/* Layout swap is class-driven (not pure media query) so JS is the single source
   of truth for which layout is rendered. */
.app-grid-cards { display: none; }
.app-grid-stateblock { display: none; }
.app-grid--mobile .app-grid-table { display: none; }
.app-grid--mobile .app-grid-cards { display: block; }
.app-grid--mobile .app-grid-scroll { border: none; background: transparent; overflow: visible; }
.app-grid--state .app-grid-stateblock { display: block; }
.app-grid--state .app-grid-table,
.app-grid--state .app-grid-cards { display: none; }

/* Mobile sort / select-all bar (shown/hidden via inline display from JS) */
.app-grid-mobilebar {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 10px;
    flex-wrap: wrap;
}
.app-grid-mselect {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 13px;
    font-weight: 600;
    color: #2b3440;
}
.app-grid-mselect input { width: 16px; height: 16px; }
.app-grid-msort { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
.app-grid-msort-label { font-size: 12px; font-weight: 600; color: #8a94a0; }
.app-grid-msort-select {
    padding: 6px 28px 6px 10px;
    font-size: 13px;
    border: 1px solid #ccc;
    border-radius: 5px;
    color: #333;
    background: #fff;
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
    background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 10px center;
    max-width: 170px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.app-grid-msort-dir {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border: 1px solid #d7dbe0;
    border-radius: 6px;
    background: #fff;
    color: var(--navy);
    cursor: pointer;
}
.app-grid-msort-dir:hover { background: #f2f7fc; border-color: var(--navy); }

/* The cards themselves */
.app-grid-card {
    display: flex;
    gap: 10px;
    border: 1px solid #e3e7eb;
    border-radius: 10px;
    background: #fff;
    padding: 14px;
    margin-bottom: 10px;
    box-shadow: 0 1px 2px rgba(20, 30, 50, 0.04);
}
.app-grid-card.is-selected { border-color: var(--navy); background: #f3f8fd; }
.app-grid-card-check { flex: 0 0 auto; padding-top: 2px; }
.app-grid-card-check input { width: 18px; height: 18px; }
.app-grid-card-body { flex: 1 1 auto; min-width: 0; }
.app-grid-card-title {
    font-size: 15px;
    font-weight: 700;
    color: #1d2733;
    margin-bottom: 8px;
    word-break: break-word;
}
.app-grid-card-meta { display: flex; flex-direction: column; gap: 8px; }
/* Stacked label-over-value (standard): avoids the ragged wrapping of
   side-by-side label/value on narrow screens. */
.app-grid-kv { display: flex; flex-direction: column; gap: 1px; font-size: 13px; }
.app-grid-kv-k {
    color: #8a94a0;
    font-weight: 600;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.3px;
}
.app-grid-kv-v { color: #2b3440; word-break: break-word; }
/* Card action buttons (e.g. View): a full-width row at the bottom of the card. */
.app-grid-card-actions { margin-top: 12px; }
.app-grid-card-actions .btn-card-action,
.app-grid-card-actions .btn { width: 100%; }
.app-grid-skelcard { flex-direction: column; gap: 8px; }
.app-grid-skelcard .skel-text { height: 13px; }

/* Finger-friendly footer + export hidden on mobile (standard) */
@media (max-width: 700px) {
    .app-grid-export { display: none; }
    /* …and the tabExport MENU wrapper with it, or the hidden trigger leaves
       an empty flex child and its gap behind in the toolbar row. */
    .app-grid-exportmenu { display: none; }
    /* …but NOT Refresh, which composes on that class. Export is desktop work (a
       CSV a phone cannot open); Refresh is the opposite — a phone's only other way
       to see new rows is a full page reload, which is the thing this button exists
       to avoid. Touch height; the label stays, because with Export hidden this is
       the only control left in the toolbar row and it has the width. */
    .app-grid-refresh { display: inline-flex; padding: 9px 12px; }
    /* The OTHER search box. A list with filters puts its search in the
       AppFilters panel, which has bumped to 16px on mobile since it shipped;
       a search-ONLY list puts it here instead (app_valuation_policy,
       app_opus_invoices, app_cap_table…) and never got the same treatment, so
       the two halves of the same standard behaved differently on a phone.
       Below 16px iOS Safari zooms the page on focus and does not zoom back
       out — the user is left scrolled into a magnified table. */
    .app-grid-search-input { font-size: 16px; }
    .app-grid-footer { flex-direction: column-reverse; align-items: stretch; gap: 10px; }
    .app-grid-pager { justify-content: center; }
    .app-grid-pgbtn { padding: 9px 16px; font-size: 13px; }
    .app-grid-pageinfo { font-size: 13px; }
    .app-grid-count { text-align: center; }
    .app-grid-loadmore { width: 100%; }
    /* Standard: record-card View/Details buttons go full-width on mobile + touch height. */
    .btn-card-action { width: 100%; min-height: var(--control-h-touch); }
    /* Icon row actions keep their square shape but grow to a finger-friendly hit
       area (README icon-control rule: 40-44px on touch). They sit in the mobile
       card's bottom action row, so they must NOT be stretched full-width like a
       labelled .btn — .app-row-actions keeps them side by side. */
    .app-rowbtn { width: 40px; height: 40px; }
    .app-grid-card-actions .app-row-actions { display: flex; flex-wrap: wrap; }
    /* Standard: the mobile sort bar spans the full width (not right-aligned). */
    .app-grid-mobilebar { flex-direction: column; align-items: stretch; }
    .app-grid-msort { width: 100%; margin-left: 0; }
    .app-grid-msort-select { flex: 1; max-width: none; }
}


/* ==========================================================================
   Topbar Alerts (shared notification icons + items panel)
   Markup: one icon button per notification source in views/general/app_topbar.php
   (rendered from the registry data/general/app_notifications.php); the panel DOM
   is built by assets/general/app_notifications.js and appended to <body>.
   Desktop: anchored panel under the topbar. Mobile (700px): full-height slide-in
   drawer + dim overlay.
   ========================================================================== */
/* The notification section: the icons sit together, LEFT of the user dropdown,
   as one group rather than drifting apart as sources are added. */
.topbar-notify { display: flex; align-items: center; gap: 8px; }

.topbar-alerts-btn {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    background: none;
    border: 1px solid rgba(255,255,255,.15);
    border-radius: 4px;
    color: #ccc;
    cursor: pointer;
    flex-shrink: 0;
}
.topbar-alerts-btn:hover { background: rgba(255,255,255,.08); color: #fff; }
/* A 'link'-mode source renders the same square icon control as an <a> instead of
   a <button> (data/general/app_notifications.php). Everything above already
   applies; the ONE thing a button gave for free is the missing underline — which
   would otherwise strike through the count pill's digits. Same reset the Investor
   Portal's link-icons carry (.topbar-dru-btn / .topbar-stmts-btn, further down). */
.topbar-notify-link { text-decoration: none; }
.topbar-alerts-count {
    position: absolute;
    top: -6px;
    right: -6px;
    min-width: 17px;
    height: 17px;
    padding: 0 4px;
    border-radius: 999px;
    background: var(--accent);
    color: #fff;
    font-size: 10px;
    font-weight: 700;
    line-height: 17px;
    text-align: center;
    box-shadow: 0 0 0 2px #1d1d1b; /* ring matching the topbar background */
}
.topbar-alerts-count[hidden] { display: none; }

/* ---- Workspaces: Client View / Opus View (Opus staff only) ----------------
   TWO topbar components, rendered server-side by views/general/app_topbar.php.

   `.topbar-workspace` is the SWITCH, and it deliberately borrows the exact
   border/colour/hover of `.user-btn` and `.topbar-alerts-btn` above: it is one
   more piece of chrome, not a feature announcing itself. It is an <a> because
   the workspace is derived from the destination page, so switching IS
   navigation — hence the text-decoration reset, the same one
   `.topbar-notify-link` carries a few rules up and for the same reason.

   `.topbar-preview-chip` is STATE, not a control, so it reads differently: a
   filled pill rather than an outlined button. It says which client the employee
   is currently previewing.
   ⚠ It is NOT `qa-only` orange, on purpose. Orange is spoken for portal-wide as
   "this exists only in QA"; an active client preview is a production state and
   must not borrow that meaning. */
.topbar-workspace {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    padding: 7px 12px;
    background: none;
    border: 1px solid rgba(255,255,255,.15);
    border-radius: 4px;
    font-size: 13px;
    color: #ccc;
    text-decoration: none;
    white-space: nowrap;
    flex-shrink: 0;
}
.topbar-workspace:hover { background: rgba(255,255,255,.08); color: #fff; }
.topbar-workspace svg { opacity: .75; flex-shrink: 0; }
.topbar-workspace:hover svg { opacity: 1; }

.topbar-preview-chip {
    display: inline-flex;
    align-items: baseline;
    gap: 7px;
    max-width: 300px;
    padding: 6px 12px;
    background: rgba(255,255,255,.07);
    border: 1px solid rgba(255,255,255,.16);
    border-radius: 999px;
    font-size: 13px;
    color: #fff;
    text-decoration: none;
    white-space: nowrap;
    flex-shrink: 1;
    min-width: 0;
}
.topbar-preview-chip:hover { background: rgba(255,255,255,.13); }
.topbar-preview-chip svg { align-self: center; opacity: .7; flex-shrink: 0; }
.topbar-preview-chip-label {
    font-size: 10px;
    font-weight: 700;
    letter-spacing: .07em;
    text-transform: uppercase;
    color: #a9b4c4;
    flex-shrink: 0;
}
/* The client name is the one variable-length thing in a strip that cannot grow
   the page (see the `.user-name` note in the 1199px block). Clamp it here; the
   full name stays in the chip's title and aria-label. */
.topbar-preview-chip-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}
/* Below 1200 the topbar is the one strip that cannot scroll with the page, so
   the widest optional text goes first — the same trade the `.user-name` rule
   makes at the top of this file. Both components keep their icon (the switch
   stays operable, the chip stays noticeable) and both carry the full wording in
   title + aria-label.
   ⚠ DECLARED HERE, NOT IN THE 1199px BLOCK ABOVE `.user-name`. A media query
   adds no specificity, so these would lose to the base rules above on source
   order. */
@media (max-width: 1199px) {
    .topbar-workspace-label { display: none; }
    .topbar-workspace { padding: 7px 9px; gap: 0; }
    .topbar-preview-chip-label { display: none; }
    .topbar-preview-chip { max-width: 190px; }
}

/* ---- Permission View: the top-bar chip, its Exit, the details panel --------
   (ClickUp 868mbemtt, 2026-09-29). Rendered by views/general/app_perm_view_bar.php
   and app_perm_view_panel.php, both included by app_topbar.php and only while a
   simulation exists, so a normal page carries none of this markup.

   The chip is a MODE, not a fact, so it is louder than the "Previewing" chip:
   a filled pale-yellow pill, and the whole bar's bottom rule takes the same
   colour so the mode is visible at the edge of the eye on every screen. Paused
   is grey — the portal is showing the administrator's own permissions again.
   ⚠ Yellow, not orange: orange is `qa-only` everywhere, and Permission View is
   a production state. Tokens only (the --pill-yellow-* / --pill-gray-* sets). */
.topbar:has(.app-permview) { border-bottom-color: var(--pill-yellow-bd); }
.topbar:has(.app-permview.is-paused) { border-bottom-color: var(--pill-gray-bd); }
.app-permview {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
    flex-shrink: 1;
}
.app-permview-chip {
    display: inline-flex;
    align-items: baseline;
    gap: 7px;
    max-width: 320px;
    min-width: 0;
    padding: 6px 12px;
    background: var(--pill-yellow-bg);
    border: 1px solid var(--pill-yellow-bd);
    border-radius: 999px;
    font-size: 13px;
    font-weight: 600;
    color: var(--pill-yellow-fg);
    text-decoration: none;
    white-space: nowrap;
}
.app-permview-chip:hover { background: #fff; }
.app-permview-chip svg { align-self: center; flex-shrink: 0; }
.app-permview-chip-label {
    font-size: 10px;
    font-weight: 800;
    letter-spacing: .07em;
    text-transform: uppercase;
    flex-shrink: 0;
}
.app-permview-chip-name { overflow: hidden; text-overflow: ellipsis; min-width: 0; color: var(--text); }
.app-permview.is-paused .app-permview-chip {
    background: var(--pill-gray-bg);
    border-color: var(--pill-gray-bd);
    color: var(--pill-gray-fg);
}
.app-permview-exitform { display: inline-flex; margin: 0; }
.app-permview-exit {
    padding: 6px 11px;
    background: none;
    border: 1px solid rgba(255,255,255,.28);
    border-radius: 999px;
    font-size: 12.5px;
    font-weight: 600;
    color: #fff;
    cursor: pointer;
    white-space: nowrap;
}
.app-permview-exit:hover { background: rgba(255,255,255,.12); }
@media (max-width: 1199px) {
    .app-permview-chip-label { display: none; }
    .app-permview-chip { max-width: 200px; }
}
/* A phone: the bar cannot grow, so the chip keeps only its shield (still a
   control, still named in full by its title + aria-label) and Exit keeps its
   word — leaving is the one thing that must never be squeezed out. */
@media (max-width: 700px) {
    .app-permview-chip-name { display: none; }
    .app-permview-chip { padding: 6px 9px; gap: 0; }
    .app-permview-exit { padding: 6px 9px; }
    /* ...and, ONLY while Permission View is on, the "Previewing" chip beside it
       gives up its name too (the name stays in its title + aria-label) — the two
       chips, Exit and the notification icons do not fit a 375px bar otherwise,
       and the account menu is what falls off the end. */
    .topbar:has(.app-permview) .topbar-preview-chip-name { display: none; }
    .topbar:has(.app-permview) .topbar-preview-chip { gap: 0; padding: 6px 9px; }
}

/* The details panel — a NON-modal side sheet under the bar: the page stays
   readable (and clickable) beside it, which is the point of explaining it. */
.app-permview-panel {
    position: fixed;
    top: 59px;                     /* the 56px bar + its 3px rule */
    right: 0;
    bottom: 0;
    z-index: 1180;                 /* over the page and the alerts drawer, under the menu drawer (1200) */
    width: min(460px, 100vw);
    display: flex;
    flex-direction: column;
    background: #fff;
    border-left: 1px solid var(--hairline);
    box-shadow: -18px 0 40px -24px rgba(15, 28, 54, .45);
    font-size: 13px;
    color: var(--text);
}
.app-permview-panel[hidden] { display: none; }
.app-permview-panel:focus { outline: none; }
.app-permview-panel-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 12px;
    padding: 16px 18px 14px;
    background: var(--pill-yellow-bg);
    border-bottom: 1px solid var(--pill-yellow-bd);
}
.app-permview-eyebrow {
    font-size: 10.5px;
    font-weight: 800;
    letter-spacing: .07em;
    text-transform: uppercase;
    color: var(--pill-yellow-fg);
}
.app-permview-title { margin: 2px 0 0; font-size: 16px; font-weight: 700; line-height: 1.3; word-break: break-word; }
.app-permview-kind { margin-top: 2px; color: var(--text-muted); font-size: 12.5px; }
.app-permview-close {
    flex-shrink: 0;
    width: 30px;
    height: 30px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: 1px solid transparent;
    border-radius: 8px;
    color: var(--text-muted);
    cursor: pointer;
}
.app-permview-close:hover { background: #fff; border-color: var(--pill-yellow-bd); color: var(--text); }
.app-permview-body { flex: 1 1 auto; overflow-y: auto; padding: 4px 18px 18px; }
.app-permview-section { padding: 14px 0; border-bottom: 1px solid var(--hairline-soft); }
.app-permview-section:last-child { border-bottom: 0; }
.app-permview-h {
    margin: 0 0 8px;
    font-size: 11px;
    font-weight: 800;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--text-muted);
}
.app-permview-screen { font-weight: 700; }
.app-permview-url { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11.5px; color: var(--text-muted); word-break: break-all; }
.app-permview-note { margin: 6px 0 0; color: var(--text-muted); line-height: 1.5; }
ul.app-permview-note { padding-left: 18px; display: grid; gap: 6px; }
.app-permview-list { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; }
.app-permview-list li {
    display: grid;
    grid-template-columns: 22px 1fr;
    gap: 2px 8px;
    align-items: baseline;
    line-height: 1.4;
}
.app-permview-mark {
    grid-row: span 2;
    width: 20px;
    height: 20px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    font-size: 12px;
    font-weight: 800;
    background: var(--pill-gray-bg);
    color: var(--pill-gray-fg);
}
.app-permview-list li.is-yes .app-permview-mark { background: var(--pill-green-bg); color: var(--pill-green-fg); }
.app-permview-by { color: var(--text-muted); font-size: 12px; }
.app-permview-sub { margin-top: 10px; }
.app-permview-sub > summary { cursor: pointer; font-weight: 600; color: var(--text-soft); }
.app-permview-callout { margin: 12px 0 2px; padding: 10px 12px; border-radius: 10px; line-height: 1.5; }
.app-permview-callout--paused { background: var(--pill-gray-bg); border: 1px solid var(--pill-gray-bd); }
.app-permview-callout--tool { background: var(--pill-blue-bg); border: 1px solid var(--pill-blue-bd); }
.app-permview-foot {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    padding: 12px 18px;
    border-top: 1px solid var(--hairline);
    background: var(--head-bg);
}
.app-permview-foot form { margin: 0; }

/* The same notice, inline, on the Access Restricted interstitial (which has no
   top bar): a group was refused a screen, and the reader needs to know that the
   refusal is the simulation's — and how to leave it. */
.app-permview-denied {
    margin: 0 0 22px;
    padding: 12px 14px;
    text-align: left;
    background: var(--pill-yellow-bg);
    border: 1px solid var(--pill-yellow-bd);
    border-radius: 12px;
    font-size: 13px;
    line-height: 1.5;
    color: var(--text);
}
.app-permview-denied-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.app-permview-denied-actions form { margin: 0; }

/* Overlay: invisible click-catcher on desktop, dim scrim on mobile. */
.alerts-overlay {
    position: fixed;
    top: 0; right: 0; bottom: 0; left: 0;
    z-index: 1100;
    background: transparent;
}
.alerts-overlay[hidden] { display: none; }

/* THE PANEL CARD, declared once for BOTH hosts.
   `.alerts-drawer` is the topbar's floating drop-down; `.alerts-panel` is the
   same panel mounted INTO a page (AppNotify.mount() — the Launch Pad's board).
   Everything that makes it look like itself lives here, so the page panel cannot
   drift from the drawer; only the positioning differs below. */
/* ⚠ overflow is VISIBLE, and it has to be. The panel's filter row holds shared
   AppSelect dropdowns, whose list renders position:absolute INSIDE the trigger's
   wrapper (no body portal) — so ANY ancestor with overflow:hidden makes the
   dropdown "open" and show nothing. That is the documented AppSelect clip trap,
   and it is why the shared .app-card is overflow:visible too.
   The cost of visible overflow is that a child with its own background can paint
   over the card's rounded corners, so the two that touch an edge round
   themselves: .alerts-head at the top, .alerts-footer at the bottom. The scroll
   region between them never reaches a corner. */
.alerts-drawer,
.alerts-panel {
    display: flex;
    flex-direction: column;
    background: #fff;
    border: 1px solid #e3e7eb;
    border-radius: 12px;
    box-shadow: 0 18px 50px rgba(15, 23, 42, 0.22);
    overflow: visible;
}
.alerts-drawer {
    position: fixed;
    top: 64px;
    right: 14px;
    z-index: 1101;
    width: 390px;
    max-width: calc(100vw - 28px);
    max-height: calc(100vh - 84px);
    animation: alerts-drop 0.16s ease-out;
}
.alerts-drawer[hidden] { display: none; }

/* The in-page panel. It fills the board column it was given (the column owns the
   width, so a future wide widget does not have to fight a width declared here),
   and it CAPS ITS HEIGHT and scrolls internally — the whole point of the shape is
   that it stays a compact card beside its neighbours instead of growing to
   eighty-odd rows and pushing every other widget off the screen. */
.alerts-panel {
    width: 100%;
    max-height: 620px;
}
@keyframes alerts-drop {
    from { opacity: 0; transform: translateY(-8px); }
    to   { opacity: 1; transform: none; }
}

.alerts-head {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 13px 16px;
    border-bottom: 1px solid #eef1f4;
    border-radius: 12px 12px 0 0;   /* the card no longer clips it — see above */
    flex-shrink: 0;
}
.alerts-title {
    flex: 1;
    min-width: 0;
    font-size: 14px;
    font-weight: 700;
    color: #1a1a2e;
}
/* The heading's total for the source — the SAME number the topbar badge shows,
   so the pill a reader clicked and the pill at the top of what opened agree.
   Server-computed; the module never sums the rows on screen to fill it. */
.alerts-head-count {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 24px;
    padding: 2px 8px;
    border-radius: 999px;
    font-size: 11.5px;
    font-weight: 700;
    color: var(--badge-amber-fg);
    background: var(--badge-amber-bg);
}
.alerts-head-count[hidden] { display: none; }
.alerts-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    background: none;
    border: none;
    border-radius: 6px;
    color: #8a94a0;
    cursor: pointer;
    flex-shrink: 0;
}
.alerts-close:hover { background: var(--page-bg); color: #1a1a2e; }

/* Panel filters. A source DECLARES which dropdowns it offers (the `filters` key
   in data/general/app_notifications.php) and the shared module fills the options
   from the items it already has — so this block is generic and no feature CSS
   forks it.
   They are the SHARED SMART DROPDOWNS: AppSelect, multi:true, which is the
   portal standing rule for every dropdown a user sees and for every FILTER
   dropdown in particular. (The first cut used bare native single-selects and was
   corrected by the operator on 2026-08-26 — "the dropdowns should have been the
   standard smart dropdowns with searchable values and multi-select". Do not go
   back: a triage panel being narrow is not a reason to ship a lesser control.)
   The rules below style the field COLUMN and the native select that remains as
   the value holder; the widget itself is styled by the shared .app-select block. */
.alerts-filters {
    display: flex;
    gap: 8px;
    padding: 10px 12px;
    border-bottom: 1px solid #eef1f4;
    background: #fbfcfd;
    flex-shrink: 0;
    /* An AppSelect list opens DOWNWARD over the scroll region, which is a later
       sibling and would otherwise paint on top of it. Lifting the row is enough;
       the list carries its own z-index within it. */
    position: relative;
    z-index: 2;
}
.alerts-filters[hidden] { display: none; }
.alerts-filter {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 3px;
}
.alerts-filter-label {
    font-size: 10.5px;
    font-weight: 700;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    color: #8a94a0;
}
/* An AppSelect root is inline-block and shrink-wraps to its widest label, so
   inside a fixed-width filter column it has to be told to fill it — the same
   line general.css already carries for .app-filter-field. The native <select>
   below is the hidden value holder once enhanced, and the visible control when
   AppSelect is not on the page at all. */
.alerts-filter .app-select { width: 100%; }
.alerts-filter .app-select-trigger { width: 100%; }

.alerts-filter-select {
    width: 100%;
    min-width: 0;
    padding: 5px 7px;
    font-family: inherit;
    font-size: 12px;
    color: #1a1a2e;
    background: #fff;
    border: 1px solid #dfe4ea;
    border-radius: 6px;
    cursor: pointer;
}
.alerts-filter-select:focus {
    outline: none;
    border-color: var(--navy);
    box-shadow: 0 0 0 2px rgba(26, 26, 46, 0.08);
}

/* How much of the list a filter is hiding. A ROW count, never a re-total of the
   server's per-row figures — see renderPanel() in app_notifications.js. */
.alerts-shown {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 5px 12px 7px;
    font-size: 11.5px;
    color: #8a94a0;
}
.alerts-clear {
    padding: 0;
    font-family: inherit;
    font-size: 11.5px;
    font-weight: 600;
    color: var(--navy);
    background: none;
    border: none;
    cursor: pointer;
    text-decoration: underline;
}

/* THE SCROLL REGION AND ITS SOFT BOTTOM FADE.
   A capped panel cuts its last visible row in half, which reads as a rendering
   fault rather than as "there is more below". The wrapper exists only to give the
   fade something to pin to — an ::after on the scrolling element itself would
   scroll away with the content.
   `is-scrollable` is set by the module AFTER it fills the body, from a real
   scrollHeight/clientHeight read, so a short list that does not scroll is never
   veiled. The gradient is short and stops well before the row's text so the last
   line stays legible when scrolled to the end, and it is pointer-events:none so
   it can never eat a click on the row beneath it. */
.alerts-scroll {
    position: relative;
    flex: 1;
    min-height: 0;      /* without it a flex item refuses to shrink below content */
    display: flex;
    flex-direction: column;
}
.alerts-scroll::after {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 26px;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.18s ease-out;
    background: linear-gradient(to top, #fff 15%, rgba(255, 255, 255, 0));
}
.alerts-scroll.is-scrollable::after { opacity: 1; }

.alerts-body {
    flex: 1;
    overflow-y: auto;
    padding: 6px;
}
.alerts-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 12px;
    border-radius: 8px;
    text-decoration: none;
    color: inherit;
    cursor: pointer;
}
.alerts-item:hover { background: #f2f7fc; }
.alerts-item-ico {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    border-radius: 8px;
    color: var(--navy);
    background: #eef3f9;
}
.alerts-item-main { flex: 1; min-width: 0; }
.alerts-item-name {
    display: block;
    font-size: 13px;
    font-weight: 600;
    color: #1a1a2e;
    overflow-wrap: break-word;
}
.alerts-item-fund {
    display: block;
    font-size: 12px;
    color: #6b7480;
    margin-top: 1px;
    overflow-wrap: break-word;
}
.alerts-item-count {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 26px;
    padding: 2px 9px;
    border-radius: 999px;
    font-size: 12px;
    font-weight: 700;
    color: var(--navy);
    background: #eef3f9;
    white-space: nowrap;
}

/* Per-item TONE. A row's server-declared `tone` becomes .alerts-item--<tone>
   and recolours the leading glyph + the trailing chip together. The slugs are
   the app's shared status vocabulary (the soft --badge-* family), so a panel row
   carries the SAME colour the source screen gives that status — Questions'
   Reply/Review here match .q-status-reply / .q-status-review in questions.css.
   An unknown/absent tone keeps the neutral navy default above. */
.alerts-item--reply .alerts-item-ico,
.alerts-item--reply .alerts-item-count { color: var(--badge-red-fg); background: var(--badge-red-bg); }
.alerts-item--review .alerts-item-ico,
.alerts-item--review .alerts-item-count { color: var(--badge-green-fg); background: var(--badge-green-bg); }
.alerts-item--pending-opus .alerts-item-ico,
.alerts-item--pending-opus .alerts-item-count { color: var(--badge-amber-fg); background: var(--badge-amber-bg); }
/* `critical` — the Launch Pad's Pending Tasks panel gives it to a row the
   attention procedure marks critical_step = 1. Colour is not the only signal:
   those rows sort to the TOP of the panel (critical, then oldest), so the list
   still reads correctly to a reader who cannot tell the two chips apart. */
.alerts-item--critical .alerts-item-ico,
.alerts-item--critical .alerts-item-count { color: var(--badge-red-fg); background: var(--badge-red-bg); }

/* Loading / empty / error states inside the drawer body. */
.alerts-state {
    padding: 30px 16px;
    text-align: center;
}
.alerts-state-ico { display: inline-block; color: #16a34a; margin-bottom: 8px; }
.alerts-state-title { font-size: 13.5px; font-weight: 600; color: #555; }
.alerts-state-msg { font-size: 12.5px; color: #8a94a0; margin-top: 3px; }
.alerts-state .alerts-retry { margin-top: 12px; }

.alerts-footer {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 12px 16px;
    border-top: 1px solid #eef1f4;
    border-radius: 0 0 12px 12px;   /* the card no longer clips it — see above */
    background: #f9fafb;
    font-size: 13px;
    font-weight: 600;
    color: var(--navy);
    text-decoration: none;
    flex-shrink: 0;
}
.alerts-footer:hover { background: #f2f7fc; }

@media (prefers-reduced-motion: reduce) {
    .alerts-scroll::after { transition: none; }
}

@media (max-width: 700px) {
    .alerts-overlay { background: rgba(15, 23, 42, 0.45); animation: alerts-fade 0.18s ease-out; }
    .alerts-drawer {
        top: 0;
        right: 0;
        bottom: 0;
        width: 86%;
        max-width: 340px;
        max-height: none;
        border-radius: 0;
        border: none;
        animation: alerts-slide 0.2s ease-out;
    }
    body.alerts-open { overflow: hidden; }
    /* Two selects side by side in 86% of a phone leaves neither readable. */
    .alerts-filters { flex-direction: column; }
    /* The mobile drawer is full-bleed and square (border-radius: 0 above), so the
       corners its head and footer round for the desktop card must go with it. */
    .alerts-drawer .alerts-head,
    .alerts-drawer .alerts-footer { border-radius: 0; }
}
@keyframes alerts-slide {
    from { transform: translateX(100%); }
    to   { transform: none; }
}
@keyframes alerts-fade {
    from { opacity: 0; }
    to   { opacity: 1; }
}


/* ==========================================================================
   Topbar Main Menu Drawer (shared hamburger + left slide-in menu)
   Markup: hamburger button + drawer include in views/general/app_topbar.php;
   drawer DOM in views/general/app_menu_drawer.php (rendered from the shared
   menu definition data/general/menu_items.php); behavior in
   assets/general/app_menu_drawer.js. The backdrop/panel layer (1200/1201)
   sits above the topbar (100), user dropdown (1000) and alerts drawer
   (1100/1101), and below modals (9000).
   ========================================================================== */
.topbar-menu-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    background: none;
    border: 1px solid rgba(255,255,255,.15);
    border-radius: 4px;
    color: #ccc;
    cursor: pointer;
    flex-shrink: 0;
    margin-right: 16px;
}
.topbar-menu-btn:hover { background: rgba(255,255,255,.08); color: #fff; }

.menu-drawer-backdrop {
    position: fixed;
    top: 0; right: 0; bottom: 0; left: 0;
    z-index: 1200;
    background: rgba(15, 23, 42, 0.45);
    backdrop-filter: blur(2px);
    -webkit-backdrop-filter: blur(2px);
    opacity: 0;
    transition: opacity 0.25s ease;
}
.menu-drawer-backdrop.is-open { opacity: 1; }
.menu-drawer-backdrop[hidden] { display: none; }

.menu-drawer {
    position: fixed;
    top: 0; bottom: 0; left: 0;
    z-index: 1201;
    display: flex;
    flex-direction: column;
    width: 400px;
    max-width: calc(100vw - 48px);
    background: #fff;
    box-shadow: 0 18px 50px rgba(15, 23, 42, 0.28);
    transform: translateX(-102%);
    transition: transform 0.28s cubic-bezier(0.32, 0.72, 0.33, 1);
}
.menu-drawer.is-open { transform: none; }
.menu-drawer[hidden] { display: none; }

.menu-drawer-head {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 16px 20px;
    background: #1d1d1b;
    border-bottom: 3px solid var(--navy);
    flex-shrink: 0;
}
.menu-drawer-head-text { flex: 1; min-width: 0; }
.menu-drawer-eyebrow {
    display: block;
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 1.4px;
    text-transform: uppercase;
    color: #8a94a0;
}
.menu-drawer-title {
    display: block;
    font-size: 16px;
    font-weight: 700;
    color: #fff;
    letter-spacing: .3px;
}
.menu-drawer-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    background: none;
    border: none;
    border-radius: 6px;
    color: #9aa3ad;
    cursor: pointer;
    flex-shrink: 0;
}
.menu-drawer-close:hover { background: rgba(255,255,255,.1); color: #fff; }

/* v3 — search box pinned between the head and the scrolling body */
.menu-drawer-search {
    flex-shrink: 0;
    padding: 12px 14px;
    border-bottom: 1px solid #e8ebef;
    background: #fff;
}
.menu-drawer-search-box { position: relative; }
.menu-drawer-search-ico {
    position: absolute;
    left: 11px;
    top: 50%;
    margin-top: -7.5px;
    color: #8a909a;
    pointer-events: none;
}
.menu-drawer-search-input {
    display: block;
    width: 100%;
    box-sizing: border-box;
    padding: 9px 34px;
    border: 1px solid #d7dde4;
    border-radius: 8px;
    background: #f9fafb;
    font: inherit;
    font-size: 13.5px;
    color: #1a1a2e;
}
.menu-drawer-search-input::placeholder { color: #8a909a; }
.menu-drawer-search-input:focus {
    outline: none;
    border-color: var(--navy);
    background: #fff;
    box-shadow: 0 0 0 3px rgba(15, 52, 96, 0.12);
}
/* the module's own clear button replaces the inconsistent native one */
.menu-drawer-search-input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.menu-drawer-search-clear {
    position: absolute;
    right: 6px;
    top: 50%;
    margin-top: -12px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 24px;
    height: 24px;
    background: none;
    border: none;
    border-radius: 6px;
    color: #8a909a;
    cursor: pointer;
}
.menu-drawer-search-clear:hover { background: #eef1f4; color: var(--navy); }
.menu-drawer-search-clear[hidden] { display: none; }

.menu-drawer-body {
    flex: 1;
    overflow-y: auto;
    padding: 12px 14px 18px;
}

.menu-drawer-section { margin-top: 18px; }
.menu-drawer-section:first-child { margin-top: 4px; }
.menu-drawer-section[hidden] { display: none; }

/* v5 (2026-08-14) — a HEADING-LESS run of rows: a section whose title is the
   empty string (app_menu_sections) renders with no toggle button at all, for the
   items that belong to no family and sit at the top of the tree. It is still a
   real section element, so the search filter and the diagnostics probe treat it
   like any other; it just has no header row to space away from.
   The rule below draws a divider UNDER the bare run, so the first real heading
   after it does not read as that run's own label — which is exactly the
   confusion removing "General" was meant to end.
   Top spacing is left to `.menu-drawer-section:first-child` above — the bare run
   is the first thing in the tree, and that rule out-specifies a single class
   anyway, so declaring a margin here would be a dead declaration. */
.menu-drawer-section--bare {
    padding-bottom: 14px;
    border-bottom: 1px solid #e8ebef;
}
.menu-drawer-section--bare + .menu-drawer-section { margin-top: 14px; }

/* v3 — collapsible section header (a real button; replaces the static
   .menu-drawer-section-title div). Chevron treatment matches
   .menu-drawer-item-chev (muted grey, navy on hover, motion-guarded). */
.menu-drawer-section-toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    width: 100%;
    padding: 4px 10px 7px;
    background: none;
    border: none;
    border-bottom: 1px solid #e8ebef;
    border-radius: 0;
    margin-bottom: 7px;
    font-family: inherit;
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .8px;
    color: #8a909a;
    text-align: left;
    cursor: pointer;
}
.menu-drawer-section-toggle:hover { color: var(--navy); }

.menu-drawer-chev {
    flex-shrink: 0;
    color: #c2c8cf;
    transition: color 0.15s, transform 0.15s;
}
.menu-drawer-section-toggle:hover .menu-drawer-chev { color: var(--navy); }
.menu-drawer-section-toggle[aria-expanded="true"] .menu-drawer-chev { transform: rotate(90deg); }

/* v4 (2026-08-14): the nested `.menu-drawer-subgroup*` rules were REMOVED with
   the markup and app_menu_group_items() — no menu item has carried 'subgroup'
   since the seventh family flatten, so nothing had emitted these in weeks. */

/* Collapse/expand: visibility is driven by the native [hidden] attribute
   (the JS focus trap and the search filter both key off it). */
.menu-drawer-section-body[hidden] { display: none; }
@keyframes md-body-expand {
    from { opacity: 0; transform: translateY(-3px); }
    to   { opacity: 1; transform: none; }
}
.menu-drawer-section-body { animation: md-body-expand 0.18s ease; }

/* v3 — search empty state */
.menu-drawer-noresults {
    padding: 22px 12px;
    font-size: 13px;
    color: var(--text-muted);
    text-align: center;
}
.menu-drawer-noresults[hidden] { display: none; }

/* v6 — SECOND-TIER search results: the sub-nav screens behind the family rows
   (data/general/menu_families.php -> app_menu_search_index). Deliberately NOT
   styled as .menu-drawer-item: these are search output, not menu structure, and
   a caller needs to see at a glance which half of the panel they are reading.
   No icon chip either — the 36px chip is the family rows' signature and a
   second column of them would make the two tiers compete. */
.menu-drawer-results { margin-top: 18px; }
.menu-drawer-results[hidden] { display: none; }
.menu-drawer-results-head {
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .8px;
    color: var(--text-muted);
    padding: 0 10px 7px;
    border-bottom: 1px solid #e8ebef;
    margin-bottom: 7px;
}
.menu-drawer-result {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 7px 10px;
    border-radius: 8px;
    text-decoration: none;
    color: inherit;
}
.menu-drawer-result:hover { background: #f2f6fb; }
.menu-drawer-result-main { flex: 1; min-width: 0; }
.menu-drawer-result-label {
    display: block;
    font-size: 13px;
    font-weight: 600;
    color: #1a1a2e;
}
/* The trail ("Treasury › Settings") is what makes a bare leaf name legible —
   "Files" or "Settings" means nothing on its own, and four families have a tab
   called Settings. Clipped rather than wrapped: a two-line result would break
   the scan down the label column. */
.menu-drawer-result-path {
    display: block;
    font-size: 11px;
    color: var(--text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.menu-drawer-result:hover .menu-drawer-item-chev { color: var(--navy); transform: translateX(2px); }
/* An external destination gets the new-tab glyph instead of the chevron, the
   same signal the topbar dropdown gives Diagnostics. */
.menu-drawer-result-ext { flex-shrink: 0; color: #c2c8cf; display: inline-flex; }
.menu-drawer-result:hover .menu-drawer-result-ext { color: var(--navy); }
.menu-drawer-result-more {
    padding: 7px 10px 2px;
    font-size: 11px;
    color: var(--text-muted);
}

.menu-drawer-item {
    position: relative;
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px;
    border-radius: 8px;
    text-decoration: none;
    color: inherit;
}
.menu-drawer-item:hover { background: #f2f6fb; }
.menu-drawer-item[hidden] { display: none; }
.menu-drawer-item-ico {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    border-radius: 9px;
    background: #f0f4ff;
    color: var(--navy);
}
.menu-drawer-item--primary .menu-drawer-item-ico { background: var(--navy); color: #fff; }
.menu-drawer-item-main { flex: 1; min-width: 0; }
.menu-drawer-item-title {
    display: block;
    font-size: 13.5px;
    font-weight: 600;
    color: #1a1a2e;
}
.menu-drawer-item-chev {
    flex-shrink: 0;
    color: #c2c8cf;
    transition: color 0.15s, transform 0.15s;
}
.menu-drawer-item:hover .menu-drawer-item-chev { color: var(--navy); transform: translateX(2px); }

/* v7 — open-item count on a row that has one (Attention Required, Questions),
   filled by app_menu_drawer.js from the count the topbar bell already fetched.
   Same pill vocabulary as .topbar-alerts-count (accent fill, white, 999px) but
   IN FLOW between the title and the chevron — the topbar's is absolutely
   positioned over its icon and carries a ring in the topbar's own background
   colour, neither of which belongs on a white row. Hidden at 0 by the module. */
.menu-drawer-badge {
    flex-shrink: 0;
    min-width: 18px;
    height: 18px;
    padding: 0 5px;
    border-radius: 999px;
    background: var(--accent);
    color: #fff;
    font-size: 10.5px;
    font-weight: 700;
    line-height: 18px;
    text-align: center;
}
.menu-drawer-badge[hidden] { display: none; }

/* "You are here" highlight (set by app_menu_drawer.js) */
.menu-drawer-item.is-current { background: #eef3f9; }
.menu-drawer-item.is-current::before {
    content: '';
    position: absolute;
    left: 0;
    top: 9px;
    bottom: 9px;
    width: 3px;
    border-radius: 2px;
    background: var(--navy);
}
.menu-drawer-item.is-current .menu-drawer-item-title { color: var(--navy); }

/* Compact live-counts strip (hydrated from landing_counts.php) */
.menu-drawer-item-metrics {
    display: flex;
    flex-wrap: wrap;
    gap: 3px 10px;
    margin-top: 3px;
}
.menu-drawer-item-metrics[hidden] { display: none; }
.mdm-metric {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 11px;
    color: var(--text-muted);
    white-space: nowrap;
}
.mdm-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    flex-shrink: 0;
}
.mdm-dot--total     { background: #8899aa; }
.mdm-dot--action    { background: #3498db; }
.mdm-dot--funding   { background: #e67e22; }
.mdm-dot--completed { background: #27ae60; }
.mdm-count { font-weight: 700; }
.mdm-metric--attention {
    background: #fff3e0;
    color: #bf5b00;
    font-weight: 600;
    border-radius: 9px;
    padding: 1px 7px 1px 5px;
}
.mdm-metric--attention .mdm-dot { box-shadow: 0 0 0 2px #fff3e0; }
.mdm-skel {
    display: inline-block;
    width: 14px;
    height: 8px;
    border-radius: 4px;
    background: #e8ebef;
    animation: mdm-skel-pulse 1.2s ease-in-out infinite;
}
@keyframes mdm-skel-pulse {
    0%, 100% { opacity: .45; }
    50%      { opacity: 1; }
}

.menu-drawer-footer {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 7px;
    padding: 13px 16px;
    border-top: 1px solid #eef1f4;
    background: #f9fafb;
    font-size: 13px;
    font-weight: 600;
    color: var(--navy);
    text-decoration: none;
    flex-shrink: 0;
}
.menu-drawer-footer:hover { background: #f2f7fc; }
/* MENU-3: "you are here" highlight for the footer "Open full menu" link — applied
   by app_menu_drawer.js when on app_main_menu.php itself (the footer is the only
   drawer entry for the home page and is NOT a .menu-drawer-item, so the item
   highlight rule above never matches it). Footer is centered, so use the same
   #eef3f9 wash as the items plus a heavier weight rather than the items' left bar. */
.menu-drawer-footer.is-current { background: #eef3f9; font-weight: 700; }

/* Staggered section entrance while the panel slides in (CSS-only).
   v3: the search box (a body sibling) joins the fade; sections stay direct
   children of .menu-drawer-body so the nth-child delays below still apply. */
.menu-drawer-section,
.menu-drawer-footer,
.menu-drawer-search {
    opacity: 0;
    transform: translateX(-10px);
    transition: opacity 0.32s ease, transform 0.32s ease;
}
.menu-drawer.is-open .menu-drawer-section,
.menu-drawer.is-open .menu-drawer-footer,
.menu-drawer.is-open .menu-drawer-search {
    opacity: 1;
    transform: none;
}
.menu-drawer.is-open .menu-drawer-search { transition-delay: 0.03s; }
.menu-drawer.is-open .menu-drawer-section:nth-child(1) { transition-delay: 0.05s; }
.menu-drawer.is-open .menu-drawer-section:nth-child(2) { transition-delay: 0.11s; }
.menu-drawer.is-open .menu-drawer-section:nth-child(3) { transition-delay: 0.17s; }
.menu-drawer.is-open .menu-drawer-section:nth-child(4) { transition-delay: 0.23s; }
.menu-drawer.is-open .menu-drawer-section:nth-child(n+5) { transition-delay: 0.29s; }
.menu-drawer.is-open .menu-drawer-footer { transition-delay: 0.29s; }

body.menu-drawer-open { overflow: hidden; }

@media (max-width: 700px) {
    .menu-drawer { width: 88%; max-width: 360px; }
}

@media (prefers-reduced-motion: reduce) {
    .menu-drawer,
    .menu-drawer-backdrop,
    .menu-drawer-section,
    .menu-drawer-footer,
    .menu-drawer-search,
    .menu-drawer-item-chev,
    .menu-drawer-chev { transition: none; }
    .mdm-skel { animation: none; }
    .menu-drawer-section-body { animation: none; }
}
/* Reduced-motion parity for the adjacent alerts drawer (pre-existing section
   above had no guard; kept here so both topbar drawers behave alike). */
@media (prefers-reduced-motion: reduce) {
    .alerts-drawer,
    .alerts-overlay { animation: none; }
}

/* ============================================================
   Download progress buttons (AppDownload, assets/general/app_download.js)
   Applied to a .btn while a data-dl-progress download is running.
   ============================================================ */
.btn.is-downloading {
    position: relative;
    overflow: hidden;
    pointer-events: none;
    opacity: 0.92;
}
.btn .dl-spinner {
    display: inline-block;
    width: 13px;
    height: 13px;
    border: 2px solid currentColor;
    border-top-color: transparent;
    border-radius: 50%;
    vertical-align: -2px;
    margin-right: 6px;
    opacity: 0.9;
    animation: spin 0.8s linear infinite;
}
.btn .dl-bar {
    position: absolute;
    left: 0;
    bottom: 0;
    height: 3px;
    width: 0;
    background: currentColor;
    opacity: 0.75;
    transition: width 0.2s ease;
}
.btn.is-dl-error {
    background: #c0392b;
    border-color: #c0392b;
    color: #fff;
}
.dl-hidden-frame { display: none; }
@media (prefers-reduced-motion: reduce) {
    .btn .dl-spinner { animation-duration: 1.6s; }
    .btn .dl-bar { transition: none; }
}

/* ==========================================================================
   Unread Data Room files badge (topbar icon + menu pills)
   Markup: topbar icon link in views/general/app_topbar.php (reuses the
   .topbar-alerts-btn square-button look + .topbar-alerts-count pill); the
   [data-dru-badge] pills sit beside the Data Rooms title in
   views/general/app_menu_drawer.php and views/main_menu/menu_view.php
   (rendered from the 'badge' key in data/general/menu_items.php).
   Hydrated by assets/general/app_dr_unread.js from
   api/data_rooms/unread_count.php — everything stays hidden at count 0.
   ========================================================================== */
/* The Investor Statements topbar icon + menu pills (app_stmts_unread.js) reuse
   the same square-button look and count-pill styles below; they're grouped here
   so the two badges never drift. */
.topbar-dru-btn, .topbar-stmts-btn { text-decoration: none; }
.topbar-dru-btn[hidden], .topbar-stmts-btn[hidden] { display: none; }

/* Count pill on a menu entry title (drawer item / main-menu tile). For Data
   Rooms (.dru-badge, app_dr_unread.js) and Investor Statements (.stmts-badge,
   app_stmts_unread.js) the menu entry links to the full list and clicking the
   pill deep-links to that item's Unread-filtered list; the To-Do entry
   (.todo-badge, app_alerts.js) already links to its list, so its pill is
   display-only. All three are cursor:pointer below. */
.dru-badge, .stmts-badge, .todo-badge {
    display: inline-block;
    min-width: 18px;
    padding: 1px 7px;
    margin-left: 6px;
    border-radius: 999px;
    background: var(--accent);
    color: #fff;
    font-size: 11px;
    font-weight: 700;
    line-height: 16px;
    text-align: center;
    vertical-align: 1px;
    cursor: pointer;
}
.dru-badge:hover, .stmts-badge:hover, .todo-badge:hover { background: #c93a16; }
.dru-badge[hidden], .stmts-badge[hidden], .todo-badge[hidden] { display: none; }

/* ------------------------------------------------------------------ *
 * Access Restricted modal (window.appAccessDenied — general.js)
 * Shown when a user attempts a screen-permission-gated client action (the
 * Export buttons today). Mirrors the page-level access-denied interstitial
 * (views/general/access_denied_view.php) but as a dismissible overlay. z-index
 * sits above the app's stacked modals (launchpad modals ~9000-9100) so it shows
 * even when an export button is clicked inside another modal.
 * ------------------------------------------------------------------ */
.app-deny-overlay {
    position: fixed;
    inset: 0;
    z-index: 9600;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 24px;
    box-sizing: border-box;
    background: rgba(15, 52, 96, 0.45);
}
.app-deny-card {
    width: 100%;
    max-width: 420px;
    background: #fff;
    border-radius: 16px;
    box-shadow: 0 24px 60px rgba(15, 52, 96, 0.28);
    padding: 32px 28px 24px;
    text-align: center;
}
.app-deny-icon {
    width: 60px;
    height: 60px;
    margin: 0 auto 16px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #fdecea;
    color: #c0392b;
}
.app-deny-title {
    margin: 0 0 10px;
    font-size: 20px;
    font-weight: 700;
    color: var(--navy);
}
.app-deny-msg {
    margin: 0 0 22px;
    font-size: 14px;
    line-height: 1.6;
    color: #4a5568;
}
.app-deny-actions {
    display: flex;
    justify-content: center;
}
.app-deny-ok {
    min-width: 110px;
    justify-content: center;
}

/* ------------------------------------------------------------------ *
 * Confirm dialog (window.appConfirm — general.js)
 * A polished, reusable "are you sure?" modal — the standard replacement for the
 * native window.confirm() on destructive/irreversible actions (delete, cancel,
 * discard). Same overlay/card system as the Access Restricted / Idle modals.
 * `danger:true` uses the red icon + a .btn-danger confirm button; otherwise the
 * navy icon + a .btn-primary confirm. z-index above the app's modal band so it
 * can confirm an action initiated from inside another modal.
 * window.appNotice() reuses this exact card with the Cancel button omitted — the
 * standard replacement for window.alert(), so an action that asks in this chrome
 * also answers in it.
 * ------------------------------------------------------------------ */
.app-confirm-overlay {
    position: fixed;
    inset: 0;
    z-index: 9700;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 24px;
    box-sizing: border-box;
    background: rgba(15, 52, 96, 0.45);
}
.app-confirm-card {
    width: 100%;
    max-width: 440px;
    background: #fff;
    border-radius: 16px;
    box-shadow: 0 24px 60px rgba(15, 52, 96, 0.28);
    padding: 30px 28px 24px;
    text-align: center;
    animation: modalIn .2s ease;
    /* Containing block for the optional corner X below. Inert for every other
       caller: position:relative with z-index:auto starts no stacking context, so
       the overlay's own 9700 band is untouched. */
    position: relative;
}
/* `closeButton: true` — the corner X, for an acknowledgement the reader may
   dismiss without reading (appConfirm/appNotice). Absolutely placed so it does
   not disturb the card's centered icon/title/message column, and inset far
   enough to stay clear of a two-line title. */
.app-confirm-close {
    position: absolute;
    top: 12px;
    right: 12px;
    width: 30px;
    height: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 0;
    border-radius: 8px;
    background: transparent;
    color: #94a3b8;
    cursor: pointer;
    transition: background .15s ease, color .15s ease;
}
.app-confirm-close:hover { background: #eef3fb; color: var(--navy); }
.app-confirm-close:focus-visible { outline: 2px solid var(--navy); outline-offset: 2px; }
.app-confirm-icon {
    width: 60px;
    height: 60px;
    margin: 0 auto 16px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #eef3fb;
    color: var(--navy);
}
.app-confirm--danger .app-confirm-icon { background: #fdecea; color: #c0392b; }
/* `variant:'success'` — the one-button appNotice after a completed action. */
.app-confirm--success .app-confirm-icon { background: #e8f6ee; color: #1e7d47; }
.app-confirm-title {
    margin: 0 0 10px;
    font-size: 20px;
    font-weight: 700;
    color: var(--navy);
}
.app-confirm-msg {
    margin: 0 0 22px;
    font-size: 14px;
    line-height: 1.6;
    color: #4a5568;
    white-space: pre-line;   /* honor \n line breaks in the message text */
}
/* `messageAlign: 'left'` (2026-09-14) — a long consent paragraph (the Data Rooms
   "Invite Again" wording, now also raised from the CRM contact drill) reads as a
   wall of text when centred; the legacy control left-aligned exactly this one. */
.app-confirm-msg--left { text-align: left; }
/* `checkLabel` (2026-09-14) — the explicit-consent box that gates the confirm
   button (appConfirm, general.js). Left-aligned like a form control and pulled up
   a notch so it reads as part of the message it qualifies, not as a third button. */
.app-confirm-check {
    display: flex;
    align-items: flex-start;
    gap: 9px;
    margin: -6px 0 20px;
    text-align: left;
    font-size: 13px;
    line-height: 1.5;
    color: #344054;
    cursor: pointer;
}
.app-confirm-check input { flex: 0 0 auto; margin: 3px 0 0; }
.app-confirm-actions {
    display: flex;
    justify-content: center;
    gap: 10px;
    flex-wrap: wrap;
}
.app-confirm-actions .btn { min-width: 120px; justify-content: center; }
@media (max-width: 480px) {
    .app-confirm-actions { flex-direction: column-reverse; }
    .app-confirm-actions .btn { width: 100%; }
}

/* ---- Idle-session timeout warning modal (assets/general/app_session_timeout.js) ----
   Mirrors the Access Restricted modal's look (same overlay/card/icon system) with
   an amber "clock" treatment and a two-button action row. z-index matches the
   modal band (above the menu drawer at 1201; see README §6). */
.app-idle-overlay {
    position: fixed;
    inset: 0;
    z-index: 9600;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 24px;
    box-sizing: border-box;
    background: rgba(15, 52, 96, 0.45);
}
.app-idle-card {
    width: 100%;
    max-width: 420px;
    background: #fff;
    border-radius: 16px;
    box-shadow: 0 24px 60px rgba(15, 52, 96, 0.28);
    padding: 32px 28px 24px;
    text-align: center;
}
.app-idle-icon {
    width: 60px;
    height: 60px;
    margin: 0 auto 16px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #fef5e7;
    color: #d68910;
}
.app-idle-title {
    margin: 0 0 10px;
    font-size: 20px;
    font-weight: 700;
    color: var(--navy);
}
.app-idle-msg {
    margin: 0 0 22px;
    font-size: 14px;
    line-height: 1.6;
    color: #4a5568;
}
.app-idle-count {
    font-weight: 700;
    color: var(--navy);
}
.app-idle-actions {
    display: flex;
    justify-content: center;
    gap: 10px;
    flex-wrap: wrap;
}
.app-idle-actions .btn {
    min-width: 130px;
    justify-content: center;
}
@media (max-width: 480px) {
    .app-idle-actions { flex-direction: column-reverse; }
    .app-idle-actions .btn { width: 100%; }
}

/* ---- Document viewer modal (assets/general/app_doc_viewer.js) ----
   Rides the shared .modal-overlay backdrop (z-index 9000 band — above the
   topbar drawers, below nothing). The dialog owns the height; the PDF.js
   viewer.html iframe (PDFs) or the scrollable image wrap fills it. */
body.docv-open { overflow: hidden; }
.docv-dialog {
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 8px 30px rgba(0,0,0,.25);
    width: min(1100px, 96vw);
    height: min(900px, 92vh);
    display: flex;
    flex-direction: column;
    overflow: hidden;
    animation: modalIn .2s ease;
}
.docv-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 10px 14px;
    border-bottom: 1px solid #e8e8e8;
}
.docv-title {
    font-size: 15px;
    font-weight: 600;
    margin: 0;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.docv-actions {
    display: flex;
    align-items: center;
    gap: 10px;
    flex-shrink: 0;
}
/* Built `hidden` in app_doc_viewer.js and unhidden only when the caller passes
   onDownload — so it needs the [hidden] companion its `display` would otherwise
   outrank. Covered by the global `.btn[hidden]` above (this button carries .btn, and
   that rule is !important); kept as a comment rather than a duplicate rule so the
   dependency is visible if either side is ever refactored. */
.docv-download { display: inline-flex; align-items: center; gap: 6px; }
/* #525659 = the PDF.js viewer's own background, so the frame edges never
   flash white while a document loads. */
.docv-body {
    flex: 1;
    min-height: 0;
    display: flex;
    background: #525659;
}
.docv-frame { flex: 1; width: 100%; height: 100%; border: 0; }
.docv-imgwrap {
    flex: 1;
    overflow: auto;
    background: #fff;
    padding: 12px;
}
.docv-img { display: block; max-width: 100%; height: auto; margin: 0 auto; }
.docv-img-msg {
    margin: 24px auto;
    text-align: center;
    color: #666;
    font-size: 14px;
}
@media (max-width: 700px) {
    /* Full-bleed: no rounded corners, no backdrop margin — a document on a phone
       gets the whole screen. */
    .docv-dialog {
        width: 100%;
        height: 100%;
        max-width: none;
        border-radius: 0;
    }
    /* …and "the whole screen" has to mean the VISIBLE one. `.modal-overlay` is
       `position: fixed; inset: 0`, which on iOS Safari resolves against the LARGE
       viewport — the one with the URL bar and the bottom toolbar retracted. With
       the bars showing, the last ~60-110px of the dialog sits behind them, and
       because the dialog is a flex column whose header is pinned at the top, what
       falls off the bottom is the end of the document plus the PDF.js scrollbar.
       `dvh` is the dynamic viewport unit: it tracks the bars as they collapse and
       expand. `top` + `height` over-constrains the box, so `bottom: 0` is dropped
       and the height wins; a browser without `dvh` never applies the declaration
       and keeps the `inset: 0` behaviour exactly as before. */
    .docv-overlay { height: 100dvh; }
}

/* ---- Drill-through modal (assets/general/app_drill.js) ----
   The standard "a value in a grid opens a modal with another grid in it" host.
   Rides the shared .modal-overlay / .modal-dialog / .modal-header / .modal-body
   vocabulary (z-index 9000 band) and adds only what an in-modal GRID needs: a
   wide dialog, the Back button for a nested drill, and the sticky-header offset
   reset. Generalized from the NAV Pack drill modal (2026-07-28). */
body.app-drill-open { overflow: hidden; }
/* Wider than .modal-lg: the payload is a table, and its columns are the reason
   the user drilled. Still viewport-capped, so it never overflows a laptop. */
.app-drill-dialog {
    width: 1180px;
    max-width: 94vw;
    max-height: 88vh;
}
/* The shared width modifiers, passed as the view's `dialogClass`. Use one of
   these rather than authoring a per-feature drill width — the app already had
   eight different modal widths before this component (1180 / 1000 / 1400 / 1100
   / 580 / 480 / 560 / 440).
   --xwide is the app's WIDE PAGE tier (1750px, the .page-wrap cap) reused as a
   dialog width, for a drill whose payload is a genuinely wide table: the NAV Pack
   Position Details grid is 13 columns, and at 1180 the operator was reading it
   through a horizontal scrollbar (QA 2026-08-11). It is not a new number, and
   max-width: 94vw still keeps it on a laptop. */
.app-drill-dialog--xwide { width: 1750px; }
.app-drill-dialog--wide  { width: 1400px; }
.app-drill-dialog--md    { width: 760px; }
.app-drill-head-left {
    display: flex;
    align-items: center;
    gap: 12px;
    min-width: 0;
}
.app-drill-head-left h2 {
    font-size: 16px;
    font-weight: 700;
    color: var(--navy);
    margin: 0;
}
.app-drill-back {
    display: inline-flex;
    align-items: center;
    gap: 3px;
    border: 1px solid #d3dae2;
    background: #fff;
    color: var(--navy);
    border-radius: 6px;
    padding: 4px 10px 4px 6px;
    font-size: 12.5px;
    font-weight: 600;
    font-family: inherit;   /* a <button> otherwise renders in the UA font, not Manrope */
    cursor: pointer;
    white-space: nowrap;
}
.app-drill-back:hover { background: #eef2f7; }
/* Load-bearing: the base rule is display:inline-flex, which out-specifies the
   UA [hidden] rule — without this the Back button shows at the first level. */
.app-drill-back[hidden] { display: none; }

/* ---- The Search & Filter region (a view's `filters` option, 2026-08-11) ----
   A modal grid is still a LIST, so once it has real filters it owes the app-wide
   list anatomy: search box + dropdowns TOGETHER in one .app-filter-panel above the
   grid (README §2, app_standards.php#std-list). AppDrill GENERATES that panel — a
   drill has no view file to author it in — and puts it HERE, as a sibling of
   .app-drill-body rather than inside it.
   WHY OUT HERE, and do not "tidy" it back in: .app-drill-body is `overflow: hidden`
   (that is what makes the grid the bounded scroller, just above), and an AppSelect
   option panel is `position: absolute` — so a filter dropdown inside the body is
   clipped invisible at the body's edge, the AppSelect clip trap. .app-drill-dialog
   sets no overflow of its own, so out here a dropdown may overhang the dialog
   exactly as it overhangs a card on a page.
   No `display` is declared: the region is toggled with the `hidden` attribute, and
   an author `display` would out-specify the UA [hidden] rule (the .app-drill-back
   trap directly above). `flex` is a different property, so it is safe. */
.app-drill-filters { flex: 0 0 auto; padding: 14px 18px 0; }
/* The panel is the shared component, unchanged — only its PLACEMENT is scoped:
   its page-level 18px bottom margin would double up with .app-drill-body's own top
   padding, and inside a dialog the gap wants to be tighter than between page
   sections. */
.app-drill-filters .app-filter-panel { margin-bottom: 0; }
/* Mobile: the generated .app-filter-toggle is the only way to open the panel below
   700px (general.css hides the head strip and hides the panel while collapsed), so
   it must not be flush against the dialog header. */
@media (max-width: 700px) {
    .app-drill-filters { padding: 12px 12px 0; }
}

/* THE GRID SCROLLS, NOT THE MODAL BODY (2026-07-28) — load-bearing for the
   sticky header, and the one thing the hand-rolled drill modals got wrong.
   An AppGrid's sticky `<th>` pins to its nearest SCROLLING ancestor, which is
   `.app-grid-scroll`. Left alone, the shared `.modal-body { overflow-y: auto }`
   is the real vertical scroller while `.app-grid-scroll` (auto height) never
   scrolls vertically — so `top: 0` pins the header to a scrollport that never
   moves and the header scrolls clean out of view with the rows. (Nor does the
   JS float-head clone stand in: `_updateFloatHead` bails while the grid's
   `rect.top >= topOffset`, which is always true for a centered dialog.)
   So: stop the body scrolling, and give the grid's own scroller the bounded
   height instead. The header block, the toolbar/search and the pager then stay
   put and only the ROWS scroll — which is what a drill modal should do. */
.app-drill-body {
    padding: 14px 18px 18px;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    min-height: 0;
}
/* The optional block a view renders above its grid (a caption, a field list
   describing the record the rows belong to). Never shrinks. */
.app-drill-head-block { margin-bottom: 14px; flex: 0 0 auto; }
/* …and the optional block a view renders BELOW its grid (view.footer,
   app_drill.js, 2026-09-16) — today the multi-select picker's Done bar. Outside
   the grid mount, so an AppGrid re-render cannot take it away, and outside the
   bounded row scroller, so it holds its place while the rows move under it.
   Never shrinks. The gap above it is this margin PLUS the pager's own 10px
   padding, which is why 6px is enough. */
.app-drill-foot-block { margin-top: 6px; flex: 0 0 auto; }
.app-drill-caption { margin: 0 0 12px; font-size: 13px; color: #5a6b7f; }
/* THE SUBJECT CAPTION (2026-09-14, ticket 868m504zh). A drill that is ABOUT one
   record — every row belongs to the same contact — used to name that record in
   the 13px muted sentence above, and a reader with several contacts open in an
   afternoon could not tell at a glance which one a modal was showing. This
   variant is the record identity strip's own vocabulary (the `.app-rv-avatar`
   monogram + the `.app-rv-kicker` line, views/general/_record_view.php) at
   dialog scale, so a modal names its subject the way the record view does. The
   kicker keeps the sentence ("Events logged with"); the name is the loud part.
   Markup:
     <div class="app-drill-caption app-drill-caption--subject">
       <span class="app-rv-avatar" aria-hidden="true">AB</span>
       <span class="app-drill-subject-id">
         <span class="app-rv-kicker">Events logged with</span>
         <span class="app-drill-subject-name">Name</span>
       </span>
     </div> */
.app-drill-caption--subject {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 0 0 12px;
    padding: 10px 14px;
    border: 1px solid var(--hairline-soft);
    border-radius: 10px;
    background: var(--head-bg);
    color: var(--text);
}
.app-drill-caption--subject .app-rv-avatar { width: 40px; height: 40px; font-size: 13px; }
.app-drill-subject-id { display: block; min-width: 0; }
.app-drill-caption--subject .app-rv-kicker { display: block; margin-bottom: 1px; }
.app-drill-subject-name {
    display: block;
    font-size: 17px;
    font-weight: 700;
    line-height: 1.25;
    color: var(--navy);
    overflow-wrap: anywhere;
}
.app-drill-grid {
    flex: 1 1 auto;
    min-height: 120px;
    display: flex;
    flex-direction: column;
}
/* The tab strip a view declares (view.tabs, app_drill.js, 2026-09-15) — the
   shared `.app-tabs` vocabulary placed INSIDE the grid mount, directly above the
   rows and below the toolbar search when there is one. The list-page bottom
   margin is tightened for a dialog, and it never shrinks in the bounded column.
   Replaced `.crm-drill-tabs`, the CRM Data Room drill's hand-rolled strip. */
.app-drill-body .app-drill-tabs { flex: 0 0 auto; margin: 0 0 10px; }
/* A view.content body (a chart, not a list) — see app_drill.js. Same box as the
   grid gets, because the reason is the same: `.app-drill-body` is the bounded
   flex column, and a child that does not claim the free space renders at its
   content height in the middle of an empty dialog. `min-height: 0` is what lets a
   chart that measures its own host shrink INSIDE the box instead of pushing it. */
.app-drill-content {
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
}
/* The bounded vertical scrollport — this is what makes `top: 0` below real. */
.app-drill-body .app-grid-scroll { flex: 1 1 auto; min-height: 0; }
/* Chrome around the rows keeps its natural height. */
.app-drill-body .app-grid-toolbar,
.app-drill-body .app-grid-mobilebar,
.app-drill-body .app-grid-footer { flex: 0 0 auto; }
/* Pairs with the forced topOffset:0 in app_drill.js: the shared .app-grid-th
   offsets by the 56px FIXED page topbar, which does not exist in a dialog. The
   select-all header cell of a selectable drill (`thead .app-grid-check`) takes
   the same offset, so it holds beside them. */
.app-drill-body .app-grid-th,
.app-drill-body thead .app-grid-check { top: 0; }
/* Make AppGrid's per-column `wrap` REAL inside a drill (2026-07-28). Every drill
   grid is forced scrollX, and `.app-grid-scroll--x .app-grid-cell { nowrap }`
   (0,2,0) out-specifies `.app-grid-cell--wrap { normal }` (0,1,0) — so a long
   free-text column stayed on one line and the dialog scrolled sideways forever.
   These 0,3,0 selectors win it back; the max-width AppGrid already sets inline is
   what then does the capping. Scoped to DIALOGS ON PURPOSE: on a wide LIST page
   the nowrap-wins behaviour is the deliberate one (see the AppGrid wrap docs), and
   a dialog is the case where wrapping beats a horizontal scrollbar.
   The non-drill dialog grid body (`.app-modal-gridbody`, after this block) takes
   this rule AND the floor below since 2026-09-28 — ONE change, on the lesson the
   floor's own note records: a release without the floor is a latent one-letter
   column. First reader: the Data Rooms "Add a Contact" picker, whose `wrap: true`
   Company column stayed on one line and scrolled the table ~150px sideways. */
.app-drill-body .app-grid-scroll--x .app-grid-th--wrap,
.app-drill-body .app-grid-scroll--x .app-grid-cell--wrap,
.app-modal-gridbody .app-grid-scroll--x .app-grid-th--wrap,
.app-modal-gridbody .app-grid-scroll--x .app-grid-cell--wrap { white-space: normal; overflow-wrap: anywhere; }
/* …and give that column a FLOOR as well as the cap (2026-08-04). The cap above is
   only half of a width: it cannot stop the column being SQUEEZED. In here the table is
   already at its min-content total — every other column is nowrap, so there is no
   slack to distribute — and `overflow-wrap: anywhere` drops THIS column's min-content
   to one character (`break-word` would not: only `anywhere` shrinks the intrinsic
   minimum). Auto table layout then hands the column exactly that: the 25-column
   "Select a Wire Template" picker rendered "Bank Additional Details" as a 36px column,
   one letter per line — a 326px header row over 919px rows.
   AppGrid publishes the px it caps with as --app-grid-wrap-px on the th (see
   _renderHeadRow), so `wrap: <px>` means ONE definite width inside a drill. Measured
   on that picker: column 36px -> 260px, header row 326px -> 38px, row 919px -> 55px,
   and the whole table only 4021px -> 4245px.
   Two deliberate limits:
     - the th alone carries the floor (a column is as wide as its widest cell), so the
       rows keep their plain max-width markup and no per-row style is emitted;
     - only a NUMERIC `wrap` publishes the property, so a `wrap: true` column keeps
       sizing to its content instead of being pinned at the 420px default it never
       asked for. The var() default makes this rule inert for them — which is also
       why a `wrap: true` column can still collapse in a squeezed table: the two
       Data Rooms Company columns in grid bodies (the Add a Contact picker's
       `wrap: 240`, View Access's `wrap: 150`) are numbers for exactly that reason.
   NOTE: capital_calls.css keeps a twin of the wrap-release rule above for
   #ccApproveGrid. It needs no floor while that table still fits its scroller (the
   "KEPT now the table fits" note at its AppGrid.create) — the day it stops fitting,
   its `wrap: 320` Investor column collapses the same way and wants this line too. */
.app-drill-body .app-grid-scroll--x .app-grid-th--wrap,
.app-modal-gridbody .app-grid-scroll--x .app-grid-th--wrap { min-width: var(--app-grid-wrap-px, 0); }
/* PIN the row-action column while a wide drill grid scrolls sideways (2026-08-04).
   The app-wide rule is that row actions render LEFT-most so they stay reachable
   without scrolling right (see isActionCol() in app_grid.js) — but in a drill the
   scrolling happens INSIDE the dialog, so on a genuinely wide grid (the 25-column
   "Select a Wire Template" picker is ~4100px) the Select button scrolls out of
   view to the left and the user has to scroll all the way back to act on the row
   they just read. Sticky-left keeps it on screen; the `th` is already sticky for
   the vertical axis, so this only adds the horizontal one.
   The cell needs its OWN opaque background per row state or the columns sliding
   underneath show through — hence the four background rules (base / both zebra
   models / hover / selected). Scoped to the drill: a scrollX LIST page scrolls the
   PAGE, where the leftmost column is already the last thing to leave the viewport. */
.app-drill-body .app-grid-scroll--x .app-grid-th--actions,
.app-drill-body .app-grid-scroll--x .app-grid-cell--actions { position: sticky; left: 0; }
.app-drill-body .app-grid-scroll--x .app-grid-th--actions  { z-index: 4; }
/* Row-state backgrounds, in the SAME cascade order as the row rules they shadow
   (base -> ungrouped zebra -> grouped/subrow zebra -> hover -> selected), so a
   grouped row that is also nth-child(even) lands on its --even colour and not on
   the reset. */
.app-drill-body .app-grid-scroll--x .app-grid-cell--actions { z-index: 3; background: #fff; }
.app-drill-body .app-grid-scroll--x .app-grid-row:nth-child(even) > .app-grid-cell--actions { background: #fbfcfd; }
.app-drill-body .app-grid--grouped .app-grid-scroll--x .app-grid-row:nth-child(even) > .app-grid-cell--actions,
.app-drill-body .app-grid--subrows .app-grid-scroll--x .app-grid-row:nth-child(even) > .app-grid-cell--actions { background: #fff; }
.app-drill-body .app-grid--grouped .app-grid-scroll--x .app-grid-row--even > .app-grid-cell--actions,
.app-drill-body .app-grid--subrows .app-grid-scroll--x .app-grid-row--even > .app-grid-cell--actions { background: #fbfcfd; }
.app-drill-body .app-grid-scroll--x .app-grid-row:hover > .app-grid-cell--actions { background: #f2f7fc; }
.app-drill-body .app-grid-scroll--x .app-grid-row.is-selected > .app-grid-cell--actions { background: #e8f1fb; }
/* The trigger anchor: reads as a link, keeps its real href for ctrl-click. */
.app-drill-link { color: var(--navy); font-weight: 600; text-decoration: none; }
.app-drill-link:hover { text-decoration: underline; }

/* ---- Pick-field (the "browse a grid" form control) ----------------------------
   A BUTTON that stands where a <select> would, for a value the reader chooses
   out of a grid instead of a dropdown — the new-gen answer to the legacy
   lookupControlType=2 popup. It shows a search glyph, the current value or a
   placeholder, and a Browse / Change chip; clicking it opens the picker
   (AppDrill.pick(), app_drill.js — which also drives the button's states
   through AppDrill.pickField()).

     <button type="button" class="app-pickfield" id="x">
       <svg class="app-pickfield-ico" …></svg>
       <span class="app-pickfield-text">Select a wire template…</span>
       <span class="app-pickfield-cta">Browse</span>
     </button>

   Promoted 2026-09-01 (ticket 868kzk23j) from `.wr-pickfield` (wire_requests,
   the original, 2026-07-16) and `.ds-pickfield` (digital_subs, a drifted copy:
   different placeholder grey, different focus ring, no glyph slot) when the bulk
   wire line editor became the THIRD consumer. The look is the `.form-field` look
   (same border, radius and type size) so it reads as a field, not a button, and
   it carries the same width cap; a host that puts it in a table cell overrides
   the padding and the cap, nothing else. */
.app-pickfield {
    display: flex; align-items: center; gap: 9px;
    width: 100%; max-width: 520px;
    padding: 8px 12px;
    border: 1px solid #ccc; border-radius: 4px;
    background: #fff; cursor: pointer; text-align: left;
    font-size: 13px; color: var(--text-muted); font-family: inherit; line-height: 1.4;
    transition: border-color .12s, box-shadow .12s;
}
/* [hidden] COMPANION — LOAD-BEARING. `display: flex` above is an author rule and
   the `hidden` attribute is only a UA `display: none`, so without this a
   server-rendered pick-field that a consumer means to reveal later is visible
   from the first paint. Same trap, same fix as `.tr-radios[hidden]` and the
   other author-display controls in the tree; the attribute selector already
   out-specifies the base rule, so no !important is owed. (2026-09-11) */
.app-pickfield[hidden] { display: none; }
.app-pickfield:hover:not(:disabled) { border-color: var(--navy); }
.app-pickfield:focus-visible { outline: 2px solid #7fb0e0; outline-offset: 1px; }
.app-pickfield:disabled { background: #f4f6f9; color: #9aa4b2; cursor: default; }
.app-pickfield-ico { flex: 0 0 auto; color: #98a2b3; }
.app-pickfield-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.app-pickfield-cta {
    flex: 0 0 auto; font-size: 11px; font-weight: 700; letter-spacing: .04em;
    color: var(--navy); text-transform: uppercase;
    border: 1px solid #c9d7ea; background: #f4f8fe; border-radius: 5px; padding: 2px 8px;
}
.app-pickfield-cta:empty { display: none; }
.app-pickfield--picked { color: #1d2939; border-color: #b5c8e2; }
.app-pickfield--picked .app-pickfield-text { font-weight: 600; }
.app-pickfield--picked .app-pickfield-ico { color: var(--navy); }
/* A pick-field BESIDE ITS CLEAR BUTTON — for an OPTIONAL pick, where the reader
   needs a way to unset the value that does not open the picker. The row takes the
   field's own 520px cap and the field fills whatever the ✕ leaves.
   Promoted 2026-09-14 (ticket 868m504zh) from digital_subs' `.ds-pickfield-row`
   / `.ds-pickfield-clear` (the CRM invitation-form contact picker, 2026-07) when
   the CRM contact form's Introduced By / Agent Name / Related Contact pickers
   became the second consumer; the digital_subs markup was repointed here in the
   same change, so there is one copy.
     <div class="app-pickfield-row">
       <button type="button" class="app-pickfield" …>…</button>
       <button type="button" class="app-pickfield-clear" aria-label="Clear" hidden>✕ svg</button>
     </div> */
.app-pickfield-row { display: flex; align-items: stretch; gap: 8px; max-width: 520px; }
.app-pickfield-row .app-pickfield { max-width: none; flex: 1 1 auto; }
.app-pickfield-clear {
    flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 38px;
    border: 1px solid #d7dce4; border-radius: 4px; background: #fff; color: #64748b; cursor: pointer;
    transition: background .12s, border-color .12s, color .12s;
}
.app-pickfield-clear:hover { background: #c0392b; border-color: #c0392b; color: #fff; }
.app-pickfield-clear:focus-visible { outline: 2px solid var(--navy); outline-offset: 1px; }
.app-pickfield-clear[hidden] { display: none; }
/* The row's own [hidden] companion — the same trap `.app-pickfield[hidden]` above
   documents: `display: flex` is an author rule and beats the UA's [hidden]. The
   CRM contact form renders the row hidden and reveals it only once the picker
   module is confirmed on the page, so without this the button and the fallback
   <select> would both be visible from the first paint. */
.app-pickfield-row[hidden] { display: none; }
@media (max-width: 700px) { .app-pickfield-row { max-width: 100%; } }
/* The invalid cue, from the wrapper class AppFormErrors writes. A DESCENDANT
   rule on purpose: digital_subs nests the button inside a `-row` beside a Clear
   button, where a direct-child rule would not reach it. */
.app-field-invalid .app-pickfield { border-color: var(--field-error); background-color: var(--field-error-tint); }
@media (max-width: 700px) { .app-pickfield { max-width: 100%; } }

/* A picker row the reader may look at but not choose (AppDrill.pick's
   `rowDisabled`): muted text, no pointer, and a reason tag where the Select
   button would be. The tag = the shared .app-pill base + this colour only. */
.app-pick-row--off > .app-grid-cell { color: #98a2b3; }
.app-pick-row--off { cursor: default; }
.app-pick-off { background: #f2f4f7; color: #98a2b3; }
/* A picker row the reader HAS chosen (AppDrill.pick `multi`, 2026-09-15): the
   Select button reads "Selected" in the success tone and the row takes a faint
   wash, so a reader scrolling a long list can see their set at a glance. The
   Done button is the one commit control and carries the running count IN ITS
   LABEL — "Done (2)" — since 2026-09-15 (ticket 868m5ng91 ask 2); the
   `.app-pick-multicount` span that used to sit beside it is gone, and the
   button's classes are the shared `btn btn-primary btn-sm` alone. It sits
   DIRECTLY BELOW the grid, in the drill's `.app-drill-foot-block`, since
   2026-09-16 (ticket 868m5xrbb) — it rode the header block above the rows, and
   readers could not find it there. Right-aligned, and carrying no margin of its
   own: the footer block owns the gap.
   First consumer: the CRM event form's Additional Attendees. */
.app-pick-row--on > .app-grid-cell { background: #f3faf6; }
.app-pick-select.is-on { background: #e5f6ee; border-color: #9fd6b8; color: #14764a; }
.app-pick-select.is-on:hover { background: #d4efe1; border-color: #7fc7a2; color: #0f5c39; }
.app-pick-multibar { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin: 0; }
/* …and the GROUP BAND that holds those rows, where a picker bands its rows by a
   selectable/not axis: the band label in the warning tone, plus a quiet note
   naming the reason. Modifiers on `.app-grid-group-label` and a sibling of
   `.app-grid-group-count` — the band itself is AppGrid's. Feature-local as
   `.wr-pick-band-*` until 2026-09-03, when the bulk wire template picker became
   the second consumer of the same two bands (ticket 868m0dq95 §1). */
.app-pick-band--off { color: #b54708; }
.app-pick-band-note { margin-left: 10px; font-weight: 400; font-size: 12px; color: #98a2b3; }
@media (max-width: 700px) {
    .app-drill-dialog { max-height: 92vh; }
    /* Hand the scrolling BACK to the modal body below 700px. AppGrid switches to
       stacked cards here and releases the scroller's overflow to `visible`, so
       the bounded-scrollport model above would spill the whole card list over
       the pager. Nothing is lost: cards have no `<th>`, so there is no sticky
       header to preserve. */
    .app-drill-body {
        padding: 12px 12px 14px;
        display: block;
        overflow-y: auto;
    }
    .app-drill-grid { display: block; }
    .app-drill-body .app-grid-scroll { flex: none; min-height: 0; }
    /* Back is the dialog's only navigation affordance, so give it the app's
       DEFAULT 34px control height on touch instead of the .btn-sm 30px (the
       size ladder is 30 / 34 / 44 — this is not a new size). */
    .app-drill-back { min-height: 34px; padding-left: 8px; padding-right: 12px; }

    /* …UNLESS THE DRILL GRID KEPT ITS TABLE. `mobileCards: false` (app_grid.js)
       marks the grid `.app-grid--nocards`, and every premise of the release above
       is then false: there is no card list to spill over the pager, and there IS a
       sticky `<th>` to preserve — the one thing the release said it was losing
       nothing by dropping. So put the desktop bounded-scrollport model back for
       exactly those dialogs: body is the non-scrolling flex column again, the grid
       claims the free space, and only the ROWS scroll under a header that holds.
       Today that is the Digital NAV Pack's four drills (nav_pack.js drillGrid);
       daily_pnl, digital_subs and every chart "View as table" modal still stack
       cards and still take the release above.

       Same-block ON PURPOSE, after the rules it overrides: `:has()` costs no
       specificity, so `.app-drill-body:has(…)` and `.app-drill-body` are both
       (0,1,0) and source order is the entire tiebreak. Moving these up, or into a
       second @media block earlier in the file, silently reverts them. The padding
       is NOT re-declared — 12px is right at this width either way. */
    .app-drill-body:has(.app-grid--nocards) {
        display: flex;
        flex-direction: column;
        overflow: hidden;
    }
    .app-drill-body:has(.app-grid--nocards) .app-drill-grid {
        display: flex;
        flex-direction: column;
        flex: 1 1 auto;
    }
    .app-drill-body:has(.app-grid--nocards) .app-grid-scroll { flex: 1 1 auto; min-height: 0; }
}

/* ---- THE SAME MODEL, FOR A DIALOG THAT IS NOT AN AppDrill (2026-09-28) ----
   A hand-built dialog hosting an AppGrid under a max-height body has the drill's
   problem above, plus one the drill never meets. Its BODY is the scroller, so the
   `<th>` scrolls away with the rows — and once the grid's top passes the top of
   the VIEWPORT, AppGrid's float-head clone (position: fixed, pinned at topOffset 0,
   see _updateFloatHead) switches on up there, ABOVE the dialog. That is ClickUp
   868ma73x1, on the Data Rooms "Add a Contact" picker: "the column headers become
   misaligned and appear above the modal". (The note above says the clone "bails
   while rect.top >= topOffset, which is always true for a centered dialog" — true
   only while the body cannot scroll, which is exactly what this changes.)
   Put `.app-modal-gridbody` on the dialog's body and `.app-modal-grid` on the grid
   MOUNT, a direct child of it. The body stops scrolling (but see the last-resort
   note at the end), the grid's own scroller takes the bounded height, the header
   holds inside the dialog and the clone never has a reason to show. Everything
   else in the body (an intro line, a selection count, an error line) keeps its
   natural height. The body keeps whatever
   max-height its dialog gives it — that is what bounds the grid — and must not
   declare an overflow of its own, which would out-rank this one. The grid must be
   `scrollX: true` (every dialog grid must be anyway: without it the :has() width
   model grows the host page), because that is what makes `.app-grid-scroll` a
   scroll container at all. Below 700px AppGrid stacks cards and releases that
   scroller, so the body scrolls again — the drill's own release, same reason.
   `.app-modal-grid` may also sit on a CARD that DIRECTLY wraps the mount, where
   the body is shared with a page that shows its grid in one: the card is then
   the flex column and the mount inside it claims the space the same way. That is
   the Data Rooms resend picker (views/data_rooms/resend_picker_partial.php, in the
   shared `.modal-body` of the family's record modal). Deeper nesting is not
   supported — each extra wrapper would need to pass the height down too.
   A wrap column wraps in here too: see the drill's three wrap rules above, which
   name this class beside `.app-drill-body`.
   ⚠ `overflow-y: auto`, NOT `hidden` — a LAST RESORT, not a second scroller
   (2026-09-28). In every ordinary case the grid flexes to fill exactly what the
   rest leaves, so the body has no scroll range and never scrolls. But the rest is
   never shrunk, and the grid stops at its 120px floor: when fixed content is
   tall enough to push past that (the Data Rooms "View Access" roster heads its
   grid with the room's free-text description, which can run to a 400px block,
   and at a 650px-tall laptop viewport that body is capped at 468px), `hidden`
   CLIPPED the grid's rows and its whole pager out of reach. `auto` lets the body
   scroll by exactly that overflow instead. The float-head clone still
   cannot come back: the body can only scroll by the amount its fixed content
   over-runs, so the grid never rises past the body's own top edge, let alone the
   viewport's. The x axis stays clipped: the grid scrolls sideways inside itself.
   It is a safety net, not a layout: at the grid's 120px floor its scroller is
   only a sliver (the toolbar and footer take most of it), so tall fixed content
   is the ADOPTER's to bound — View Access caps its description for exactly this
   (data_rooms.css). The floor is not raised to cover it on purpose: a min-height
   also GROWS a short list's grid, and most rosters are a few rows. */
.app-modal-gridbody { display: flex; flex-direction: column; overflow-x: hidden; overflow-y: auto; min-height: 0; }
.app-modal-gridbody > * { flex: 0 0 auto; }
.app-modal-gridbody > .app-modal-grid { flex: 1 1 auto; min-height: 120px; display: flex; flex-direction: column; }
.app-modal-gridbody > .app-modal-grid > .app-grid { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.app-modal-gridbody .app-grid-scroll { flex: 1 1 auto; min-height: 0; }
.app-modal-gridbody .app-grid-toolbar,
.app-modal-gridbody .app-grid-mobilebar,
.app-modal-gridbody .app-grid-footer { flex: 0 0 auto; }
/* THE SELECT-ALL BOX IS A HEADER CELL TOO. AppGrid renders it as a bare
   `th.app-grid-check`, not `.app-grid-th`, so it misses the sticky rule every
   data header carries. While the whole header scrolled away with the body nobody
   could tell; once the header HOLDS, that one cell scrolled off with the first
   rows and left a hole at the head's left edge for the row checkboxes to slide
   through, taking Select all with it (both multi-select pickers, 2026-09-28). So
   pin it with the data headers: the scrollX `top: 0` they take here, their layer,
   their band and divider. PAGE grids got the same pin later the same day
   (`thead .app-grid-check` beside `.app-grid-th`, plus its scrollX twin), which
   now says all of this too. This rule stays as the grid body's own statement of
   it, so the dialog pin does not hang on the page rules. */
.app-modal-gridbody .app-grid-scroll--x thead .app-grid-check {
    position: sticky;
    top: 0;
    z-index: 2;
    background: var(--head-bg);
    box-shadow: inset 0 -1px 0 #e3e7eb;
}
@media (max-width: 700px) {
    .app-modal-gridbody { display: block; overflow-y: auto; }
    .app-modal-gridbody > .app-modal-grid,
    .app-modal-gridbody > .app-modal-grid > .app-grid { display: block; }
    .app-modal-gridbody .app-grid-scroll { flex: none; }
}

/* Touch-friendly icon targets on mobile (keeps the compact desktop look). Placed
   at end-of-file ON PURPOSE: the base .modal-close/.alerts-close/.menu-drawer-close
   rules live far below the buttons section, so an earlier @media block would lose
   the source-order tie to those base rules and never apply (2026-07-22). */
@media (max-width: 700px) {
    .modal-close { min-width: 40px; height: 40px; }
    .alerts-close { width: 40px; height: 40px; }
    .menu-drawer-close { width: 40px; height: 40px; }
}

/* ==========================================================================
   SHARED MARKDOWN SKIN (.app-md) — the default output of
   data/general/app_markdown.php (app_md_render / app_md).

   Added 2026-08-21 with the Tickets screen's record page, where authored prose
   (a dev request, its plan, its outcome, its per-page release notes) is written
   in Markdown and had been rendering as escaped pre-wrap — punctuation soup.

   ⚠ THE CONTAINER MUST NOT SET `white-space: pre-wrap`. The renderer emits real
   block elements, so preserving the whitespace BETWEEN those tags adds a blank
   line before every paragraph and list. Wrapping is the browser's job here.

   ⚠ The Screen Inspector's reference dialog renders the SAME markdown through
   the same renderer but passes its own `insp-*` class names, because its skin is
   entangled with tool-wide machinery (`.insp-t` is the Inspector's table
   everywhere, `.insp-pre` carries the per-block Copy button). One renderer, two
   skins, on purpose — see app_markdown.php's header.

   Type scale here is deliberately a SCALE: 14px body, headings stepping down
   from 16px, and a muted 13px for the quieter blocks. A uniform size was the
   specific complaint the record page got.
   ========================================================================== */

.app-md {
    font-size: 14px;
    line-height: 1.68;
    color: var(--text);
    overflow-wrap: anywhere;
}

/* First and last child never add outer space — the panel owns its padding. */
.app-md > *:first-child { margin-top: 0; }
.app-md > *:last-child  { margin-bottom: 0; }

.app-md p { margin: 0 0 11px; }

/* The renderer emits the document's own level as the TAG (h2 for `##`) and the
   level as the CLASS, so heading order stays honest for a screen reader while the
   visual weight is set here. */
.app-md-h2,
.app-md-h3,
.app-md-h4 {
    margin: 20px 0 8px;
    color: var(--navy);
    font-weight: 700;
    line-height: 1.35;
}
.app-md-h2 { font-size: 16px; }
.app-md-h3 { font-size: 14.5px; }
.app-md-h4 {
    font-size: 12.5px;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-muted);
}

.app-md-l { margin: 0 0 11px; padding-left: 22px; }
.app-md-l li { margin: 0 0 5px; }
.app-md-l li:last-child { margin-bottom: 0; }
/* A nested list tightens up rather than repeating the outer spacing. */
.app-md-l .app-md-l { margin: 5px 0 0; }

.app-md-c {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 12.5px;
    padding: 1px 5px;
    border-radius: 4px;
    background: var(--pill-gray-bg);
    color: var(--pill-gray-fg);
}

.app-md-pre {
    margin: 0 0 12px;
    padding: 11px 13px;
    border: 1px solid var(--hairline);
    border-radius: 7px;
    background: var(--page-bg);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 12.5px;
    line-height: 1.55;
    /* A code block is the ONE place long lines scroll rather than wrap — a wrapped
       command is a command you cannot copy correctly. */
    white-space: pre;
    overflow-x: auto;
}

.app-md-q {
    margin: 0 0 12px;
    padding: 2px 0 2px 13px;
    border-left: 3px solid var(--pill-blue-bd);
    color: var(--text-soft);
}

.app-md-hr { border: 0; border-top: 1px solid var(--hairline); margin: 18px 0; }

.app-md-tscroll { overflow-x: auto; margin: 0 0 12px; }
.app-md-t { width: 100%; border-collapse: collapse; font-size: 13px; }
.app-md-t th {
    text-align: left;
    padding: 7px 12px 7px 0;
    border-bottom: 1px solid var(--hairline);
    color: var(--text-muted);
    font-size: 11.5px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    white-space: nowrap;
}
.app-md-t td {
    padding: 7px 12px 7px 0;
    border-bottom: 1px solid var(--hairline-soft);
    vertical-align: top;
}
.app-md-t tr:last-child td { border-bottom: 0; }

.app-md strong { font-weight: 700; color: var(--navy); }
.app-md em { font-style: italic; }
.app-md a { text-decoration: underline; text-underline-offset: 2px; }

/* The quieter variant, for a sub-block inside a card (an item's plan, say). */
.app-md--sm { font-size: 13px; line-height: 1.6; color: var(--text-soft); }
.app-md--sm .app-md-h2 { font-size: 14px; }
.app-md--sm .app-md-h3 { font-size: 13px; }

/* ==========================================================================
   Rich text editor (AppRichText) — assets/general/app_richtext.js
   --------------------------------------------------------------------------
   A toolbar plus a contenteditable surface standing in for a <textarea> whose
   column is HTML by contract (the legacy CKEditor columns — the first is
   opus_connect_data_rooms.invite_message, which a stored procedure drops into
   the invitation email). The textarea is still the posted value and is simply
   .app-hidden; everything here dresses its stand-in.

   The surface deliberately CARRIES .form-field, so it inherits the shared
   field's width, ink, radius and font instead of re-declaring them — and the
   shared `.form-field.is-invalid` rule paints it for free once the module
   mirrors the class across (AppFormErrors marks the hidden textarea by name).
   ========================================================================== */
.app-rt {
    max-width: 520px;          /* the .form-field measure, so the two align */
    margin-top: 2px;
}
.app-rt-bar {
    display: flex;
    align-items: center;
    gap: 2px;
    flex-wrap: wrap;
    padding: 4px 5px;
    background: var(--hairline-soft);
    border: 1px solid #ccc;    /* the .form-field border, so bar + surface read as one control */
    border-bottom: 0;
    border-radius: 4px 4px 0 0;
}
.app-rt-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 26px;
    padding: 0;
    border: 1px solid transparent;
    border-radius: var(--control-radius);
    background: transparent;
    color: var(--text-soft);
    cursor: pointer;
    transition: background var(--control-transition), color var(--control-transition),
                border-color var(--control-transition);
}
.app-rt-btn:hover { background: #fff; border-color: var(--control-border); color: var(--text); }
.app-rt-btn:focus-visible {
    outline: none;
    border-color: var(--control-focus);
    box-shadow: 0 0 0 2px rgba(37, 99, 235, .25);
}
/* PRESSED, not merely hovered — queryCommandState drives .is-on, so this is the
   only thing telling the author the caret is inside bold text. */
.app-rt-btn.is-on {
    background: #fff;
    border-color: var(--btn-secondary-fg);
    color: var(--btn-secondary-fg);
}
.app-rt-btn svg { pointer-events: none; }
.app-rt-sep {
    width: 1px;
    height: 16px;
    margin: 0 4px;
    background: var(--control-border);
}
.app-rt-surface {
    min-height: 120px;
    max-width: none;           /* the .app-rt wrapper owns the measure now */
    border-radius: 0 0 4px 4px;
    overflow-y: auto;
    max-height: 420px;
}
.app-rt-surface:focus {
    outline: none;
    border-color: var(--control-focus);
    box-shadow: 0 0 0 3px rgba(37, 99, 235, .18);
}
/* The author is writing an EMAIL paragraph, so the block rhythm inside the box
   has to look like the message rather than like a form field with stray gaps. */
.app-rt-surface > :first-child { margin-top: 0; }
.app-rt-surface > :last-child { margin-bottom: 0; }
.app-rt-surface p { margin: 0 0 8px; }
.app-rt-surface ul,
.app-rt-surface ol { margin: 0 0 8px; padding-left: 22px; }
.app-rt-surface li { margin-bottom: 2px; }
.app-rt-surface blockquote {
    margin: 0 0 8px;
    padding-left: 10px;
    border-left: 3px solid var(--hairline);
    color: var(--text-soft);
}
.app-rt-surface a { color: var(--btn-secondary-fg); text-decoration: underline; text-underline-offset: 2px; }

@media (max-width: 700px) {
    .app-rt { max-width: none; }
    /* Touch targets, on the same floor the shared control system uses. */
    .app-rt-btn { width: 34px; height: 32px; }
}

/* ══════════════════════════════════════════════════════════════════════════
   RECORD VIEW (.app-rv-*) — the shapes a read-only record is built from
   Builders: views/general/_record_view.php  ·  Dialog: AppRecordDialog (general.js)

   WHY THESE EXIST. The operator keeps filing one ticket against record views:
   "this page needs significant polishing up. Make it more intuitive, modern,
   impressive and professional." What they are looking at each time is the same
   thing — a hero, then four or five identical panels of dt/dd pairs in
   stored-column order. The answer is that A RECORD HOLDS SEVERAL KINDS OF FACT
   AND A LABEL/VALUE GRID IS RIGHT FOR ONLY ONE OF THEM: an address is one fact
   with a written form, a person is a name with a standing, a lifecycle is a
   sequence, an attachment is a file, and audit stamps are provenance.

   Built 2026-08-25 for Client Onboarding's three related-record views (as
   `.co-*` in onboarding.css); EXTRACTED here the same week by the second
   adopter, app_audit_fs.php?page=view. Onboarding's copies are gone — its
   builders now call the shared ones and emit these classes.

   Everything below composes on shared vocabulary (.app-panel, .app-field,
   .app-pill, .modal-*). Nothing here re-declares a shared component.
   ══════════════════════════════════════════════════════════════════════════ */

/* ---- The section glyph on a panel head ---- */
.app-rv-ico {
    flex: 0 0 auto;
    margin-right: 8px;
    color: var(--text-muted);
    vertical-align: -2px;
}
.app-rv-panel .app-panel-head > span { display: flex; align-items: center; }
.app-rv-panel .app-panel-head .app-rv-ico { color: #8494a8; }
/* The per-section count rides the SHARED `.app-tab-count` badge, so a "4" beside
   Regulatory Filings is the same object as a tab count. */
.app-rv-panel .app-panel-head .app-tab-count { margin-left: 8px; }
/* A caption on a section head — what the section is FOR, when its title alone
   does not say (".app-panel-head" is a space-between row, so this lands right). */
.app-rv-panel-note {
    font-size: 11.5px;
    font-weight: 500;
    color: var(--text-muted);
    text-transform: none;
    letter-spacing: 0;
}

/* ---- Identity ----
   The monogram is what names the ROW you are on. Two surfaces: as the page
   hero's icon (--hero, which inherits the shared 48px chip and only replaces its
   glyph with letters) and as the leading square of the compact strip a dialog
   body opens with. */
.app-rv-avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 46px;
    height: 46px;
    border-radius: 12px;
    background: linear-gradient(135deg, #234b73 0%, #0f3460 100%);
    color: #fff;
    font-size: 15px;
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: uppercase;
    /* A two-letter monogram must never be broken across lines or hyphenated by a
       narrow container, and it is never read out — the record's name is right
       beside it. */
    white-space: nowrap;
}
.app-rv-avatar--hero { font-size: 16px; }

.app-rv-kicker {
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .07em;
    color: #8494a8;
    margin-bottom: 2px;
}
.app-rv-rec {
    display: flex;
    align-items: flex-start;
    gap: 14px;
    margin-bottom: 18px;
}
.app-rv-id { min-width: 0; flex: 1 1 auto; }
.app-rv-name {
    margin: 0;
    font-size: 19px;
    font-weight: 700;
    line-height: 1.25;
    color: var(--navy);
    word-wrap: break-word;
}
.app-rv-sub { margin: 3px 0 0; font-size: 13px; color: var(--text-soft); }

/* FAST FACTS. Not `.app-pill`: a pill in this portal carries a STATUS, and these
   are reference facts (a year-end, a count of filings). A different object, so it
   reads as a different kind of thing — which is also why the record's real status
   IS an .app-pill and leads the row. */
.app-rv-chips {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
    margin-top: 9px;
}
.app-rv-chip {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding: 3px 9px 3px 7px;
    border: 1px solid var(--hairline);
    border-radius: 999px;
    background: var(--head-bg);
    font-size: 11.5px;
    font-weight: 600;
    color: #55636f;
    white-space: nowrap;
}
.app-rv-chip .app-rv-ico { margin-right: 0; color: #8494a8; }
/* A chip that is carrying a WARNING rather than a reference fact (an overdue
   deadline, an archived record). Rare on purpose — a row of coloured chips is a
   row with no emphasis in it. */
.app-rv-chip--warn { border-color: #ecd9a6; background: var(--badge-amber-bg); color: var(--badge-amber-fg); }

/* The status pill on a record: the shared borderless badge family, so the pill on
   the record is the same object as the pill in the grid row that opened it. */
.app-rv-status--green { background: var(--badge-green-bg); color: var(--badge-green-fg); }
.app-rv-status--amber { background: var(--badge-amber-bg); color: var(--badge-amber-fg); }
.app-rv-status--red   { background: var(--badge-red-bg);   color: var(--badge-red-fg); }
.app-rv-status--blue  { background: var(--badge-blue-bg);  color: var(--badge-blue-fg); }
.app-rv-status--gray  { background: var(--badge-gray-bg);  color: var(--badge-gray-fg); }

/* ---- An address, typeset as an address ----
   The card, not the six labelled cells it replaced. `<address>` is the element
   for it, and it is italic by UA default — reset, because a postal address in
   italics reads as a quotation. */
.app-rv-addr-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 14px;
}
.app-rv-addr {
    padding: 13px 15px;
    background: var(--head-bg);
    border: 1px solid var(--hairline);
    border-radius: 9px;
}
.app-rv-addr-title {
    display: flex;
    align-items: center;
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .05em;
    color: #7a8798;
    margin-bottom: 7px;
}
.app-rv-addr-title .app-rv-ico { margin-right: 6px; color: #8494a8; }
.app-rv-addr-lines {
    font-style: normal;                 /* <address> is italic by UA default */
    font-size: 14px;
    line-height: 1.5;
    color: #22303f;
}
.app-rv-addr-line { display: block; }
.app-rv-addr-line--country { font-weight: 600; }
.app-rv-addr-sameline { margin: 0; font-size: 13.5px; color: var(--text-soft); font-style: italic; }
/* A card with a FIELD GRID in it instead of address lines — the same object,
   holding facts that each need a label. Two shapes use it: onboarding's
   contact card (`--contact`, so a firm's address and the person to call about
   it read as a pair) and the bulk wire Recipient Details dialog, which gives
   each banking leg of a wire template one card (2026-09-02). The gap is the
   CARD's, not the modifier's — a labelled grid inside this padding wants it
   tighter than a free-standing `.app-fields` on a page.
   Markup: <div class="app-rv-addr"><div class="app-rv-addr-title">…</div>
           <dl class="app-fields app-fields--w230 app-rv-addr-fields">…</dl></div> */
.app-rv-addr .app-rv-addr-fields { gap: 10px 20px; }

/* ---- A COORDINATE CARD — one hop of a payment route ----
   The address card again, holding a bank (or the party a for-further-credit line
   names) and the identifiers under it. PROMOTED 2026-09-08 from wire_templates'
   `.wt-coord*`, whose builder became app_rv_coord_card() in the same change:
   wire templates, the bulk-wire recipient dialog and the investor-transaction
   approval surface all typeset the same object, and three copies of it is how
   they start disagreeing about what a wire looks like.

   Markup (app_rv_coord_card): <div class="app-rv-addr app-rv-coord">
     <div class="app-rv-addr-title">…<span class="app-rv-coord-tag">US</span></div>
     <div class="app-rv-coord-lead">Bank name</div>
     <dl class="app-fields …">…</dl></div>                                      */
.app-rv-coord { min-width: 0; }
/* The card the reader checks first — the receiving bank — is white against its
   grey siblings, takes a firmer border and spans the full row of the grid. */
.app-rv-coord--primary { grid-column: 1 / -1; background: #fff; border-color: #cfd8e3; }
/* Rides inside the shared address title (a flex row), pushed to its far end. */
.app-rv-coord-tag {
    margin-left: auto;
    padding: 1px 8px;
    border: 1px solid var(--hairline);
    border-radius: 999px;
    background: #fff;
    font-size: 10px;
    letter-spacing: .04em;
    color: #5b6b7c;
}
.app-rv-coord-lead {
    margin: 0 0 10px;
    font-size: 15px;
    font-weight: 700;
    line-height: 1.3;
    color: var(--navy);
    word-wrap: break-word;
}
.app-rv-coord .app-fields { gap: 10px 20px; }
/* An identifier a reader checks digit by digit (an ABA, an IBAN, an account
   number) — monospaced so a transposition is visible. */
.app-rv-mono {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 13px;
    letter-spacing: .03em;
}

/* ---- People attached to a record ----
   The NAME leads and its slot ("Audit partner", "Person 3") is the caption,
   because that is which way round the two facts matter. */
.app-rv-roster { list-style: none; margin: 0; padding: 0; }
.app-rv-roster-item {
    display: flex;
    align-items: center;
    gap: 11px;
    padding: 9px 2px;
    border-bottom: 1px solid var(--hairline-soft);
}
.app-rv-roster-item:last-child { border-bottom: 0; }
.app-rv-roster-avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    background: #eef2f7;
    color: #3b5a7a;
    font-size: 11.5px;
    font-weight: 700;
    letter-spacing: .03em;
    white-space: nowrap;
}
.app-rv-roster-body { flex: 1 1 auto; min-width: 0; }
.app-rv-roster-name { display: block; font-size: 14px; font-weight: 600; color: #22303f; }
.app-rv-roster-slot { display: block; font-size: 11.5px; color: #8494a8; margin-top: 1px; }
/* A contact person is someone the reader wants to REACH, so their address and
   number are actionable lines under the name rather than two more `<dd>`s
   elsewhere on the page. They wrap as a group: an email and a phone side by side
   on a wide record, stacked in a dialog. */
.app-rv-roster-lines {
    display: flex;
    flex-wrap: wrap;
    gap: 2px 16px;
    margin-top: 3px;
}
.app-rv-roster-contact { font-size: 12.5px; font-weight: 500; }
/* A standing (an ownership %, a deadline) is a FIGURE, so it gets tabular
   numerals rather than a status pill's colouring — the same distinction
   .app-field--num makes in a field grid. */
.app-rv-roster-note {
    flex: 0 0 auto;
    font-size: 12px;
    font-weight: 600;
    color: #55636f;
    font-variant-numeric: tabular-nums;
    font-feature-settings: 'tnum' 1;
    white-space: nowrap;
}

/* ---- Attachments ----
   The document pack is the part of a record a reviewer opens, so a file gets a
   row: a glyph, its name, its type, and a download affordance that says the link
   downloads rather than navigates. */
.app-rv-filelist { list-style: none; margin: 0; padding: 0; }
.app-rv-filelist-item + .app-rv-filelist-item { margin-top: 6px; }
.app-rv-filelist-link {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 9px 12px;
    border: 1px solid var(--hairline);
    border-radius: 8px;
    background: #fff;
    text-decoration: none;
    color: #22303f;
    transition: border-color .12s, background-color .12s;
}
.app-rv-filelist-link:hover {
    border-color: #2c5f92;
    background: var(--subnav-hover);
    text-decoration: none;
}
.app-rv-filelist-ico { display: inline-flex; color: #8494a8; }
.app-rv-filelist-link:hover .app-rv-filelist-ico { color: #2c5f92; }
.app-rv-filelist-name {
    flex: 1 1 auto;
    min-width: 0;
    font-size: 13.5px;
    font-weight: 600;
    /* An uploaded filename can be very long and is not worth three lines; the
       full name is the link's text, so the hover and the download both keep it. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
/* A file row that carries a PROVENANCE CAPTION under its name (who attached it,
   when) puts both lines in one block, and the block takes the flex slot the name
   held on its own. app_rv_files() emits this wrapper only when there is a caption,
   so a caller passing name + href alone renders exactly as it did before. */
.app-rv-filelist-main { flex: 1 1 auto; min-width: 0; }
.app-rv-filelist-main .app-rv-filelist-name { flex: 0 1 auto; display: block; }
.app-rv-filelist-meta {
    display: block;
    margin-top: 1px;
    font-size: 11.5px;
    color: var(--text-muted);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.app-rv-filelist-ext {
    flex: 0 0 auto;
    padding: 1px 7px;
    border-radius: 4px;
    background: var(--head-bg);
    color: #7a8798;
    font-size: 10.5px;
    font-weight: 700;
    letter-spacing: .04em;
}
/* The DOCUMENT type the uploader chose, which is a different fact from the file
   extension beside it — "Investor Statement" vs "PDF". Sentence case and a wider
   cap than the extension chip, because it is a chosen label rather than a
   three-letter token, and it truncates rather than pushing the download glyph off
   the row. */
.app-rv-filelist-type {
    flex: 0 1 auto;
    max-width: 40%;
    padding: 1px 8px;
    border: 1px solid var(--hairline);
    border-radius: 999px;
    background: #fff;
    color: #5b6b7c;
    font-size: 11px;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.app-rv-filelist-dl { flex: 0 0 auto; display: inline-flex; color: #8494a8; }
.app-rv-filelist-link:hover .app-rv-filelist-dl { color: #2c5f92; }
/* An attachment row with no downloadable file behind it: the same row, minus the
   affordances that would promise a download it cannot give. Not an <a>, so it
   takes no hover and no focus. */
.app-rv-filelist-link--none {
    background: var(--head-bg);
    border-style: dashed;
    color: var(--text-soft);
    cursor: default;
}
.app-rv-filelist-link--none .app-rv-filelist-name { font-weight: 500; }

/* ---- A lifecycle, as a line of dated steps ----
   Four dates in stored-column order are four facts; the same four in sequence
   are the record's SHAPE, which is what a reader is actually after when they ask
   where something is in its cycle.

   ONE ROW, ALWAYS (2026-09-02, ticket 868m070pm). This was `repeat(auto-fit,
   minmax(170px, 1fr))`, which wraps as soon as the steps outgrow their host: a
   five-step rail needs 850px at that floor, and a `.modal-lg` record body offers
   ~743px once the dialog padding, the panel padding and the scrollbar are taken
   out — so on the wire record's Progress panel the fifth step, Released, sat alone
   on a second row, under a fourth step whose connector ran off into nothing. A
   sequence that breaks is not a sequence. `grid-auto-flow: column` puts every step
   in the same row and `minmax(0, 1fr)` gives each an equal share of whatever width
   the host has; a label and a value are short words and dates, and each wraps
   inside its own column rather than overprinting the next (the `overflow-wrap`
   rules below). Under 700px the rail stacks one step per row instead — see the
   media rule at the end of this section, whose flow reset is load-bearing. */
.app-rv-tl {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    gap: 0;
    position: relative;
}
.app-rv-tl-item {
    position: relative;
    padding: 2px 14px 2px 0;
}
/* The connector: drawn from each dot to the next, and suppressed on the last one
   so the line does not run off the end into nothing. A ::before on the ITEM
   rather than a border on the list, so the last step can drop its own segment
   and the stacked rail under 700px can drop them all. */
.app-rv-tl-item::before {
    content: "";
    position: absolute;
    left: 9px;
    right: 0;
    top: 4px;
    height: 2px;
    background: var(--hairline);
}
.app-rv-tl-item:last-child::before { display: none; }
.app-rv-tl-dot {
    display: block;
    position: relative;
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: #fff;
    border: 2px solid #b9c4d1;
    margin-bottom: 9px;
}
.app-rv-tl-item--done .app-rv-tl-dot { background: var(--badge-green-fg); border-color: var(--badge-green-fg); }
.app-rv-tl-item--now .app-rv-tl-dot  { background: var(--badge-amber-fg); border-color: var(--badge-amber-fg); }
/* `overflow-wrap: anywhere` on the label and the value too (2026-09-02): the
   columns are `minmax(0, 1fr)`, so a step's track can be narrower than a token in
   it, and a token that cannot wrap overprints the next step. It never fires while
   the word fits — a date or "Complete" in a 140px column is untouched. */
.app-rv-tl-label {
    display: block;
    font-size: 10.5px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .05em;
    color: #8494a8;
    overflow-wrap: anywhere;
}
.app-rv-tl-value {
    display: block;
    margin-top: 2px;
    font-size: 14px;
    font-weight: 700;
    color: #22303f;
    font-variant-numeric: tabular-nums;
    font-feature-settings: 'tnum' 1;
    overflow-wrap: anywhere;
}
/* `overflow-wrap: anywhere` (2026-09-01): a note is often "by <login>", and a login
   is one unbreakable token wider than a 170px step — on the wire record's Progress
   timeline two adjacent "by someone@…" notes overprinted each other. Wrapping
   inside the token keeps every step inside its own column. */
.app-rv-tl-note { display: block; margin-top: 1px; font-size: 11.5px; color: var(--text-soft); overflow-wrap: anywhere; }

/* ---- A set of answers that belong together ----
   A multiselect's picks stored as one comma-joined string read as N facts, not
   as a paragraph. The tick is decorative — the list itself says they were
   selected — so it carries no accessible name. */
.app-rv-answers { list-style: none; margin: 0; padding: 0; }
.app-rv-answer {
    display: flex;
    align-items: flex-start;
    gap: 9px;
    padding: 5px 0;
}
.app-rv-answer-tick {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    height: 18px;
    margin-top: 1px;
    border-radius: 50%;
    background: var(--badge-green-bg);
    color: var(--badge-green-fg);
}
.app-rv-answer-body { min-width: 0; }
.app-rv-answer-label { display: block; font-size: 13.5px; color: #22303f; }
.app-rv-answer-note { display: block; font-size: 12px; color: var(--text-soft); margin-top: 1px; }

/* ---- A decision and what follows from it ----
   A questionnaire is mostly these: an answer, then the detail that answer made
   relevant. The decision leads as a pill so the reader can scan the answers
   without reading the consequences, and the detail hangs off it so it is
   obviously subordinate rather than a sibling fact. */
.app-rv-qa + .app-rv-qa {
    margin-top: 12px;
    padding-top: 12px;
    border-top: 1px solid var(--hairline-soft);
}
.app-rv-qa-head {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px 12px;
}
.app-rv-qa-q {
    flex: 1 1 auto;
    min-width: 0;
    font-size: 13.5px;
    font-weight: 600;
    color: #3b4857;
}
.app-rv-qa-detail {
    margin-top: 9px;
    padding-left: 12px;
    border-left: 2px solid var(--hairline);
}
/* The detail is usually a field grid; inside 12px of indent it should not also
   try to be two columns on a dialog-width body. */
.app-rv-qa-detail .app-fields { gap: 10px 20px; }

/* ---- Prose inside a record (a comment, a biography) ----
   `pre-wrap` would keep the author's own wrapping, which on pasted prose is one
   400-character line. The value is escaped and nl2br'd server-side, so paragraph
   breaks survive and nothing else has to. */
.app-rv-prose { font-size: 14px; line-height: 1.6; color: #22303f; }
.app-rv-empty { margin: 0; font-size: 13.5px; color: var(--text-muted); font-style: italic; }

/* ---- The audit stamps, as a footer strip ----
   They were a full `.app-panel` titled "Audit Trail" — the same visual weight as
   the record's own content, for four stamps nobody opened the record to read. */
.app-rv-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px 20px;
    margin-top: 2px;
    padding: 10px 2px 0;
    border-top: 1px solid var(--hairline-soft);
    font-size: 11.5px;
    color: var(--text-muted);
}
.app-rv-meta > .app-rv-ico { margin-right: -8px; color: #a3aebc; }
.app-rv-meta-item { display: inline-flex; align-items: baseline; gap: 6px; }
.app-rv-meta-label { font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.app-rv-meta-value { color: var(--text-soft); }

/* An actionable value (mailto: / tel:) inside a read-only field grid. */
.app-rv-link {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    color: #2c5f92;
    font-weight: 600;
    text-decoration: none;
}
.app-rv-link:hover { text-decoration: underline; }
.app-rv-link .app-rv-ico { margin-right: 0; color: #8494a8; }

/* ---- The record dialog ----
   `AppRecordDialog` (general.js) rides the shared `.modal-overlay` /
   `.modal-dialog` / `.modal-header` / `.modal-body` band and holds a
   `&fragment=1` body. The shared `.modal-body .app-panel` rules above already
   flatten the cards inside it; these two are what the RECORD shape needs. */
.app-recdlg .modal-body {
    /* The dialog card is capped at 90vh by `.modal-lg`; this is what makes the
       BODY the part that scrolls, so the header — and the close button — stay put
       on a record eleven sections long.
       ⚠ IT IS PER TIER. `.modal-xl` is 92vh, so a consumer at that size owes
       itself the matching cap — see `.app-recdlg .modal-xl .modal-body` in
       assets/client_invoicing/client_invoicing.css. */
    max-height: calc(90vh - 62px);
}
/* A FLOOR UNDER THE BODY, for `AppRecordDialog.create({ tallBody: true })`.
   A dialog sizes to its content, which is right until the content is a form
   that REVEALS ITSELF a section at a time: its first state is three lines
   tall, the body is the scroller, and an `.app-select` panel opened in it is
   cut off at the body's edge — so reading the rest of a dropdown means
   scrolling the DIALOG, which is what the operator filed against the Add a
   Transaction wizard (ticket 868m4dkft §1, 2026-09-11).

   480px is derived, not chosen: an `.app-select-panel` is its search box plus
   `.app-select-list`'s own 240px cap (~290px), and a first control sits about
   190px below the top of a body once its section head and caption are drawn.
   Scrolling inside the LIST past that cap is the panel's own behaviour and
   stays — it is the dialog that must not be the thing that scrolls.

   ⚠ `min()` IS LOAD-BEARING. A bare `min-height` BEATS `max-height` in CSS,
   so on a short window a flat 480px would push the dialog past the viewport
   and take the header — and the close button — off screen with it. Capped
   against the same `calc(90vh - 62px)` the rule above uses, the floor simply
   stops applying where there is no room for it. A consumer at `.modal-xl`
   (92vh) owes itself the matching cap, exactly as the rule above does. */
.app-recdlg--tall .modal-body { min-height: min(480px, calc(90vh - 62px)); }
/* A record body opens with its identity strip, which already carries the
   record's name; the top padding above it can be tighter than a page's. */
.app-recdlg .app-rv-rec { margin-bottom: 16px; }

@media (max-width: 700px) {
    .app-rv-addr-grid { grid-template-columns: minmax(0, 1fr); }
    .app-rv-avatar { width: 40px; height: 40px; border-radius: 10px; font-size: 13px; }
    .app-rv-roster-item { align-items: flex-start; }
    .app-rv-roster-note { align-self: center; }
    /* One step per row: the connector would otherwise be a 2px stub between two
       stacked dots, which reads as a rendering fault rather than a sequence.
       `grid-auto-flow: row` IS the stacking — the base rule flows the steps into
       COLUMNS (one row, always), and without this reset they would still all sit
       in one row here, five steps squeezed into a phone's width. */
    .app-rv-tl { grid-auto-flow: row; grid-template-columns: minmax(0, 1fr); gap: 12px; }
    .app-rv-tl-item::before { display: none; }
    .app-rv-tl-dot { margin-bottom: 5px; }
    /* THE AUDIT STAMP KEEPS ITS GLYPH (operator ticket, 2026-09-02, reported on
       app_bank_activity.php?page=view — the fix is here because the strip is
       shared and every record view had it).

       The strip is a WRAPPING flex row and `.app-rv-meta-item` has no shrink
       allowance, so its hypothetical size is its max-content width — wider than a
       phone on the very first stamp ("Added <login> · <date> <time>"). Flex breaks
       lines on the HYPOTHETICAL size, before any growing or shrinking happens, so
       the item went to line two and left the 13px clock alone on line one: an
       orphan glyph floating above the text, which reads as a rendering fault
       rather than as provenance.

       `flex: 1 1 0` on the FIRST item only is what fixes it, and the `0` basis is
       the load-bearing part — `min-width: 0` alone does NOT, because it lowers the
       item's minimum without touching the max-content basis the line-breaking step
       actually reads. With a 0 basis the item always fits beside the glyph, then
       grows into the rest of the line and wraps its own text there. Scoping it to
       `.app-rv-ico + …` keeps a SECOND stamp ("Last updated") on its natural
       behaviour: it still breaks to a line of its own, which is what it should do.

       `align-items: flex-start` then puts the glyph beside the stamp's FIRST line
       instead of centring it against a block now two or three lines tall, and the
       2px is that line box's own half-leading (11.5px text at 1.5 = a 17px line,
       around a 13px icon).

       The stamp then wraps only as far as it has to, which is the other half of
       what was asked. `overflow-wrap` on the VALUE and not `flex-wrap` on the item
       is the difference between two lines and three: wrapping the item drops the
       whole value below "ADDED" whether or not it needed the room, while giving
       the value a break opportunity lets it start on the label's line and run on.
       It is also what stops a long unbroken login overflowing the strip once the
       item is allowed to shrink. */
    .app-rv-meta { align-items: flex-start; }
    .app-rv-meta > .app-rv-ico { margin-top: 2px; }
    .app-rv-meta > .app-rv-ico + .app-rv-meta-item { flex: 1 1 0; min-width: 0; }
    .app-rv-meta-value { overflow-wrap: anywhere; }
}


/* ══════════════════════════════════════════════════════════════════════════
   .app-tablestack — A SMALL BESPOKE TABLE, ONE CARD PER ROW ON A PHONE
   ══════════════════════════════════════════════════════════════════════════
   For the short read-only tables that are deliberately NOT an AppGrid — nothing
   in them sorts, pages, exports or drills, so they are written as a plain
   `<table>`. AppGrid swaps itself for stacked cards at 700px and these had no
   answer at all, which on a four-column table is not a cosmetic problem: measured
   on app_wire_templates.php?page=templateapproval at a 375px viewport, the panel
   offered 289px and the table's own MIN-CONTENT was 403px, so 114px of the Status
   column fell outside an ancestor's clip and could not be reached at any scroll
   position. The reader was being asked to authorise money with a column missing.

   HOW TO USE IT: put the class on the `<table>` and a `data-label` on every `<td>`
   carrying the column's own heading. Below 700px the head is dropped, each row
   becomes a card and each cell becomes a label-over-value block; above it the
   table is untouched, so this costs a desktop layout nothing.

   PROMOTED 2026-09-02 on the SECOND consumer (README §2). bulk_wire's `.bw-table`
   is the first — the same technique, hand-rolled, for the same "one card per wire"
   shape — and is left alone for now under the same rule this file's
   `.app-progress-*` block cites: promote on the second consumer, converge the
   first later. Converging it means deleting five rules from bulk_wire.css and
   adding the class to its editor table; the `data-label`s it needs are already
   there.

   The label's type is `.app-grid-kv-k`'s, restated because a `::before` cannot
   carry a class — which is the one unavoidable duplication in this technique and
   the reason to keep it in ONE place rather than in each feature. Keep the two in
   step: a stacked cell in a bespoke table and a stacked cell in an AppGrid card
   are the same object to a reader, and bulk_wire's copy drifting to 11.5px/700/
   #6b7280 is exactly the drift this file exists to stop.
   ========================================================================== */
@media (max-width: 700px) {
    .app-tablestack thead { display: none; }
    .app-tablestack,
    .app-tablestack tbody,
    .app-tablestack tr,
    .app-tablestack td { display: block; }
    .app-tablestack tbody tr {
        border: 1px solid var(--hairline);
        border-radius: 10px;
        background: #fff;
        margin: 0 0 10px;
        padding: 6px 0;
    }
    .app-tablestack tbody tr:last-child { margin-bottom: 0; }
    /* `tbody td` and not a bare `td`, which is not fussiness: a consumer's own
       `.x-table td { border-bottom: … }` has the SAME 0,1,1 specificity and its
       file loads after this one, so the bare form lost and left the row hairlines
       drawn between the facts INSIDE each card — and only in the cards above the
       last, because the consumer's `tr:last-child td` rule cancelled them there,
       so two cards had dividers and the third did not. One more element in the
       selector makes the swap win over any consumer that styles its own cells,
       which by definition is all of them. */
    .app-tablestack tbody td,
    .app-tablestack tfoot td { border-bottom: 0; padding: 6px 14px; }
    .app-tablestack td[data-label]::before {
        content: attr(data-label);
        display: block;
        font-size: 11px;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.3px;
        color: #8a94a0;
        margin-bottom: 1px;
    }
    /* An empty cell would otherwise render as a label over nothing — a card full
       of headings for facts this row does not have. */
    .app-tablestack td:empty { display: none; }
}


/* ══════════════════════════════════════════════════════════════════════════
   .app-pick-row / .app-addnew — A PICKER AND ITS "＋ New"
   ══════════════════════════════════════════════════════════════════════════
   A dropdown whose value the reader may not have yet, with the button that
   creates one without leaving the form. The operator's ask, twice now:
   "there needs to be a way to add new dropdown values without having to leave
   the page" (Client Onboarding, 2026-08-25) and "the user doesn't have the
   option to create a new wire template within the form … add a button to the
   right of the Bank Wire Template field" (LOA requests, ticket 868kyy2b9).

   THE BUTTON IS DELIBERATELY QUIET — a dashed outline, muted, solid only on
   hover. It sits beside the control the reader came to use and must not compete
   with it, or every form with one reads as if creating a new value were the
   normal path. The row WRAPS, so a narrow column stacks the button under the
   picker instead of crushing it.

   `--block` is the same button as a full-width one, for a picker (a pick list, a
   panel) that has no single control to sit beside; put it in `.app-pick-actions`.

   Promoted from onboarding.css's `.co-*` copies 2026-08-31 by the second
   adopter. Height 36px matches `.form-field`; `.app-select` is given the flex
   basis because it is what the picker resolves to once AppSelect enhances it.
   ══════════════════════════════════════════════════════════════════════════ */
.app-pick-row {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    flex-wrap: wrap;
}
.app-pick-row > .app-select,
.app-pick-row > select.form-field { flex: 1 1 260px; max-width: 520px; }
.app-addnew {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    gap: 4px;
    height: 36px;
    padding: 0 10px;
    background: #fff;
    border: 1px dashed #b9c4d2;
    border-radius: 6px;
    color: var(--text-muted);
    font-size: 12px;
    font-weight: 700;
    cursor: pointer;
    white-space: nowrap;
    transition: border-color .12s, color .12s, background-color .12s;
}
.app-addnew:hover {
    border-style: solid;
    border-color: var(--navy);
    background: var(--subnav-hover);
    color: var(--navy);
}
.app-addnew[disabled] { opacity: var(--control-disabled-opacity); cursor: not-allowed; }
.app-addnew.app-addnew--block { height: auto; border-style: solid; }
.app-pick-actions { margin-bottom: 8px; }

/* ══════════════════════════════════════════════════════════════════════════
   AppEmbedModal — a whole PAGE hosted in a modal IFRAME (general.js)
   ══════════════════════════════════════════════════════════════════════════
   The default surface is the LONG one (a form/wizard); `--fit` is the short
   document the same route can redirect to, sized from its own content by the
   module. Read that module's header before changing either tier — the numbers
   here were measured, not chosen.

   Promoted from wire_requests.css's `.wr-modal*` 2026-08-31, unchanged, when the
   LOA request form became the second host of the wire-template wizard. Only the
   iframe modal moved: `.wr-modal*` had already lost its other consumers (the
   three grid pickers went to AppDrill 2026-07-28), so nothing was left behind.

   The backdrop is display:flex, so it needs its own [hidden] guard (the
   recurring feature-display-vs-[hidden] rule).
   ══════════════════════════════════════════════════════════════════════════ */
.app-embedmodal {
    position: fixed; inset: 0;
    /* ABOVE the 9000 modal band, and that is not cosmetic. The wire-request
       wizard opens this from a PAGE, where the 1000 it used to carry was
       enough; the LOA request form opens it from INSIDE an AppRecordDialog
       (`.modal-overlay`, 9000), and at 1000 the wizard rendered behind the
       form that raised it. Above the tip bubble (9300) and the date panel
       (9200) too, so nothing from the form underneath floats over the frame;
       below the access-denied overlay (9600) and appConfirm (9700), which
       must still reach the reader over this. */
    z-index: 9400;
    background: rgba(16,24,40,.5);
    display: flex; align-items: center; justify-content: center; padding: 20px;
}
.app-embedmodal[hidden] { display: none !important; }
.app-embedmodal-dialog {
    background: #fff; border-radius: 12px;
    box-shadow: 0 16px 48px rgba(16,24,40,.28);
    width: 1100px; height: 88vh;
    max-width: 100%; max-height: 86vh;
    display: flex; flex-direction: column; overflow: hidden;
    animation: appEmbedModalIn .16s ease;
    /* The containing block for the loading veil below — it covers the frame and
       stops at the head, which the module measures rather than assumes. */
    position: relative;
}
@keyframes appEmbedModalIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.app-embedmodal-head {
    display: flex; align-items: center; justify-content: space-between; gap: 10px;
    padding: 14px 18px; border-bottom: 1px solid #e8ecf1;
}
.app-embedmodal-title { margin: 0; font-size: 15.5px; color: var(--navy); }
.app-embedmodal-x { border: 0; background: none; font-size: 24px; line-height: 1; color: #98a2b3; cursor: pointer; padding: 0 4px; }
.app-embedmodal-x:hover { color: #1d2939; }
.app-embedmodal-frame { border: 0; width: 100%; flex: 1 1 auto; background: #fff; }

/* ---- THE LOADING VEIL (ticket 868m6bfnj item 2, 2026-09-17) ----
   Until now this dialog opened onto a blank white iframe and stayed that way for
   the length of the round trip — and the routes framed here are the SLOW ones by
   construction: every one of them is a gate that may redirect to the 2FA
   challenge before it renders anything. A reader who pressed "Configure a New
   Fund" got an empty white box first and had no way to tell it from a broken one.

   Covers the FRAME and not the head — `top` is set inline by the module from the
   head's measured height, so × and the title stay live while a slow route is in
   flight. `inset` is not used for the same reason. The module toggles [hidden],
   and display:flex is an author rule, so the guard below is the recurring
   feature-display-vs-[hidden] one (same as .callout-banner[hidden] above). */
.app-embedmodal-loading {
    position: absolute;
    left: 0; right: 0; bottom: 0;
    top: 0;                      /* replaced inline with the measured head height */
    display: flex; align-items: center; justify-content: center;
    background: #fff;
    border-radius: 0 0 12px 12px;
    z-index: 1;
}
.app-embedmodal-loading[hidden] { display: none !important; }
/* The portal-wide spinner geometry (.ds-view-spinner / .co-spinner / the drill
   loader all draw this same ring); `spin` is declared once in this file. */
.app-embedmodal-spinner {
    width: 30px; height: 30px;
    border: 3px solid #e3e8ef;
    border-top-color: var(--navy);
    border-radius: 50%;
    animation: spin .8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
    .app-embedmodal-spinner { animation-duration: 2.4s; }
}

/* THE SAME MODAL, WHILE IT HOLDS THE SHORT DOCUMENT (QA 2026-08-21).
   The wire-template wizard's route sends an Authenticator-App user through
   app_my_profile.php?page=authentication&embed=1 IN THIS FRAME before the wizard
   renders, so the short challenge inherited a dialog sized for the long form:
   measured at 1600x1000, a 1100x860 dialog around a 287px card left 282px of dead
   white above it and 238px below, and stretched the card to 1056px for a 200px code
   field. 640px is the shared .modal-md token — the width the data-rooms step-up
   already gives this same challenge — and the HEIGHT comes from the framed
   document's own content wrapper (the module's fit()). Both classes are needed to
   outrank the mobile height rule below, whatever the source order. */
.app-embedmodal-dialog.app-embedmodal-dialog--fit { width: 640px; max-width: 94vw; height: auto; }
/* flex: 0 0 auto — the default frame's `flex: 1 1 auto` would stretch this back to
   the dialog's height and silently undo the fit. The height below is a PRE-LOAD
   PLACEHOLDER ONLY (the frame is blank until the short document lands); the real
   number is set inline by the module, so do not "fix" that JS by trusting this
   stylesheet. */
.app-embedmodal-dialog--fit .app-embedmodal-frame {
    flex: 0 0 auto; height: 300px;
    transition: height 120ms ease;
}

@media (max-width: 700px) {
    .app-embedmodal { padding: 8px; }
    .app-embedmodal-dialog { max-height: 94vh; height: 94vh; }
}

/* THE AUTHENTICATOR STEP-UP (AppStepUp, general.js), ONE TIER ABOVE THE BAND.
   A challenge is always raised from on top of something, and sometimes that is
   another `.app-embedmodal`: the Digital Subs Invitation-QR toggle asks for it
   from inside the QR modal. Both would sit in the same 9400 band, where
   document order breaks the tie, and the QR modal is built first, so the
   challenge would land BEHIND the page that asked for it. 9450 clears the band
   and stays below the access-denied (9600) and appConfirm (9700) surfaces,
   which must still reach the reader over everything. AppModal.topBackdrop()
   ranks by computed z-index, so Escape follows the same order. Promoted from
   digital_subs.css's `.ds-stepup-embed` 2026-09-23, when the opener became
   shared. Two classes, so it outranks `.app-embedmodal` whichever stylesheet
   loads second. */
.app-embedmodal.app-stepup-embed { z-index: 9450; }

/* THE FRAMED SIDE of that contract — the chrome-less document the modal loads.
   A step-up / confirmation panel inside somebody else's dialog is ~200px of
   content, so it needs a padded wrapper and, when it has a way out to offer, a
   button row. Promoted from data_rooms' `.dr-embed-wrap` / `.dr-embed-actions`
   2026-09-03 by the second consumer (the wire-template approval's Authenticator
   step-up, ticket 868m0ckxz); views/data_rooms/mfa_stepup_view.php was repointed
   in the same change, so there is one copy and not two.

   NOT here: the `min-width: 0` that releases the 1200px body floor. That is a
   rule on the BODY and each embed body already declares its own
   (`.wt-embed-body`, `.dr-embed-body`, `body.mp-authn-embed`, `body.ep-embedded`)
   — see the fluid-page list at the top of this file. */
.app-embed-wrap { padding: 14px 16px; }
.app-embed-actions {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
    margin-top: 14px;
}

/* ==========================================================================
   AppProgressDialog (assets/general/general.js) — the shared stage-list
   progress dialog for a save that does several things on the server. Rides
   the shared .modal-overlay / .modal-dialog.modal-md chrome; these rules style
   only the stage list, the closing result block and the footer. Second consumer
   of the shape — bulk_wire's local .bw-progress was the first and is left alone
   (README §2: promote on the second consumer, converge the first later).
   States on a step: is-pending, is-active (spinner), is-done, is-skip, is-fail.
   ========================================================================== */
/* The module toggles `hidden` on the close control (a .modal-close, which is
   display:inline-flex), the detail line, the result block and the footer —
   author display rules would otherwise win over the UA's [hidden]. */
.app-progress-overlay [hidden] { display: none !important; }
.app-progress-body { padding: 18px 22px 16px; }
.app-progress-steps {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 11px;
}
.app-progress-step {
    display: flex;
    align-items: flex-start;
    gap: 12px;
}
.app-progress-ico {
    flex: 0 0 auto;
    width: 26px;
    height: 26px;
    border-radius: 50%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--pill-gray-bg);
    border: 1px solid var(--pill-gray-bd);
    color: var(--pill-gray-fg);
    transition: background-color .15s, border-color .15s, color .15s;
}
.app-progress-step.is-active .app-progress-ico { background: #eef3fb; border-color: #c6d9f1; color: var(--navy); }
.app-progress-step.is-done   .app-progress-ico { background: var(--pill-green-bg); border-color: var(--pill-green-bd); color: var(--pill-green-fg); }
.app-progress-step.is-fail   .app-progress-ico { background: var(--pill-red-bg);   border-color: var(--pill-red-bd);   color: var(--pill-red-fg); }
.app-progress-step.is-skip   .app-progress-ico { background: var(--pill-gray-bg);  border-color: var(--pill-gray-bd);  color: var(--pill-gray-fg); }
/* The active step's spinner — the same `spin` keyframes .saving-spinner uses. */
.app-progress-spin {
    display: block;
    width: 12px;
    height: 12px;
    border: 2px solid rgba(15, 52, 96, .22);
    border-top-color: var(--navy);
    border-radius: 50%;
    animation: spin .8s linear infinite;
}
.app-progress-text {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
    padding-top: 4px;
}
.app-progress-label { font-size: 14px; font-weight: 600; color: var(--text); line-height: 1.3; }
.app-progress-step.is-pending .app-progress-label { color: var(--text-muted); font-weight: 500; }
.app-progress-step.is-fail    .app-progress-label { color: var(--pill-red-fg); }
.app-progress-detail { display: block; font-size: 12.5px; line-height: 1.45; color: var(--text-soft); }
.app-progress-step.is-fail .app-progress-detail { color: var(--pill-red-fg); }
/* The state word for screen readers only — the icon carries it visually. */
.app-progress-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}
/* The closing summary — what the server reported, in the caller's words. */
.app-progress-result {
    margin: 18px 0 8px;
    padding: 14px 16px;
    border-radius: 10px;
    background: #f7f9fc;
    border: 1px solid var(--hairline);
    font-size: 13.5px;
    line-height: 1.5;
    color: var(--text);
}
.app-progress--success .app-progress-result { background: var(--pill-green-bg); border-color: var(--pill-green-bd); }
.app-progress--danger  .app-progress-result { background: var(--pill-red-bg);   border-color: var(--pill-red-bd); }
.app-progress-result p { margin: 0 0 6px; }
.app-progress-result p:last-child { margin-bottom: 0; }
.app-progress-result ul { margin: 6px 0 0; padding-left: 18px; }
.app-progress-result li { margin: 3px 0; }
.app-progress-foot {
    display: flex;
    justify-content: flex-end;
    gap: 8px;
    padding: 4px 22px 18px;
}
@media (prefers-reduced-motion: reduce) {
    .app-progress-spin { animation-duration: 1.6s; }
    .app-progress-ico { transition: none; }
}
@media (max-width: 700px) {
    .app-progress-foot .btn { width: 100%; justify-content: center; }
}

/* ══════════════════════════════════════════════════════════════════════════
   THE WORKSPACE — a rail of things to do, and the one being done beside it
   (assets/general/app_workspace.js)
   ══════════════════════════════════════════════════════════════════════════
   The answer to a recurring operator complaint, in the operator's own words
   twice now: "requires a person to open and close a number of tasks … it's
   really hard to discern what is actually needed" (Implementation Tasks,
   2026-08-26) and "the list is very lengthy and confusing and requires a lot of
   clicks for a user to resolve all of their items" (Due Diligence Requests,
   2026-09-08). Both rounds asked for the same shape and named the other screen
   as the model, so it is a shared component from here rather than a second copy:
   a sticky index of every item in one parent record, each carrying its own
   state, and the item being worked on in a pane beside it. Nothing opens and
   nothing closes.

   Promoted 2026-09-08 from implementation_tasks' `.it-work` / `.it-rail*` /
   `.it-pane*`, byte-equivalent, when app_cdd_documents.php became the second
   host — and implementation_tasks was CONVERGED onto it the same day, so there
   is one copy of this component and `.it-work` / `.it-rail*` / `.it-pane*` no
   longer exist. What that sheet still owns is what is genuinely the family's:
   its `.app-rail-mark--<slug>` accents, the "With Opus" divider row it puts
   between the two halves of its rail, and the gap its own fragment identity
   block wants in the pane.

   WHAT IS SHARED AND WHAT IS NOT. The shell is: the two columns, the sticky
   rail, its head and meter slot, the filter chips, the item rows and their
   states, the pane head. The ACCENT COLOUR of an item's mark is NOT — a family's
   status palette is the family's, and `.app-rail-mark--<slug>` is declared per
   feature exactly as `.app-pill` + `.cdd-status--<slug>` already splits. The
   base below only sizes and centres the mark and gives it the neutral tone.

     <div class="app-work">
       <aside class="app-rail"><div class="app-rail-inner">
         <div class="app-rail-head">…title, .app-meter, .app-rail-sub…</div>
         <div class="app-rail-filter">…two .app-rail-chip…</div>
         <div class="app-rail-list">
           <div class="app-rail-group"><div class="app-rail-group-head">…</div>
             <ol class="app-rail-items"><li class="app-rail-item">
               <a class="app-rail-link"><span class="app-rail-mark">…</span>
                 <span class="app-rail-text"><span class="app-rail-name">…</span>
                 <span class="app-rail-state">…</span></span></a></li></ol></div>
         </div>
         <p class="app-rail-empty" hidden>…</p>
       </div></aside>
       <div class="app-work-col">
         <div class="app-pane-head">…pos, .app-pane-full…</div>
         <div class="app-pane">…the record / the form…</div>
       </div>
     </div>

   The rail is a real first column, not an overlay — on a 1100px `.page-narrow`
   page there is room for it, and a floating index over a 460-character request
   would cover the thing it indexes.
   ══════════════════════════════════════════════════════════════════════════ */
.app-work {
    display: grid;
    grid-template-columns: 272px minmax(0, 1fr);
    gap: 0 24px;
    /* HOW FAR THE PANE'S BODY SITS BELOW THE TOP OF ITS COLUMN, declared here
       because two rules thirty lines apart have to agree on it: the head band,
       and the rail's offset under it. It is the column card's own top border (1)
       + the band's `min-height` (30) + the band's two paddings (8 + 8) + the
       band's bottom rule (1). Change any of those and change this, or the rail
       stops starting level with the record title — which is the whole point of
       it, and one pixel out is visible against two card edges. */
    --app-pane-body-top: 48px;
    /* NO `align-items: start`, and that is load-bearing rather than an omission —
       the same trap the onboarding form rail paid for. `start` shrink-wraps the
       rail's grid item to its own content, and a sticky child can only travel
       inside its containing block, so the rail would scroll away partway down a
       fifteen-item parent and leave the left column blank. Default `stretch`
       gives the item the row's height, which is exactly the distance it has to
       stay pinned for. */
}
/* ── THE PANE COLUMN IS A CARD ───────────────────────────────────────────
   Operator, 2026-09-11 (ClickUp 868m452hb, NAV Inputs): *"the entire thing is in
   a container, but there's no visual box that makes it look cohesive"*. The rail
   has been a bordered card since the first host and the column beside it was
   bare, so the half of the screen the reader actually WORKS in read as loose
   parts scattered on the page fill next to a tidy index — and on an upload form,
   whose body is a numbered sequence of steps, the steps did not read as one form.

   It takes the SHARED card box (`.app-card` / `.app-panel`, converged 2026-07-23)
   rather than a look of its own, and `.app-pane-head` below joins that family's
   head-surface rule: a workspace column is a card with a head and a body, not a
   fifth box style in this sheet.

   ⚠ IT MUST NOT CLIP, which is why it is NOT `.app-panel`. That class is
   `overflow: hidden`, and six families already re-assert `overflow: visible` on it
   because an AppSelect menu or a date panel renders inside the card and is cut off
   at the rounded edge. This box holds whole forms, so it never clips — the head
   band rounds its own top corners instead of relying on the parent to clip them. */
.app-work-col {
    /* minmax(0, 1fr) above + this: a wide child (a long unbroken url in a
       request, a file row) must not blow the grid out — the documented
       min-width:auto trap. */
    min-width: 0;
    background: #fff;
    border: 1px solid var(--hairline);
    border-radius: 12px;
    box-shadow: 0 1px 3px rgba(15, 28, 54, .05);
}

/* ── The rail ─────────────────────────────────────────────────────────
   STICKY, and the sticky element is the INNER div, not the <aside>: sticky
   clamps to its containing block, so pinning the grid item itself would pin it
   to the row box, i.e. not at all. `top` clears the 56px fixed topbar. */
.app-rail {
    position: relative;
    /* ⚠ THE RAIL STARTS BELOW THE PANE'S HEAD BAND, not level with it. Operator,
       2026-09-11: the rail *"looks out of place next to the title of the input …
       as the open full page is pushing the title down"*. The band is a toolbar
       for the pane; the thing the rail indexes is the RECORD under it, so the
       rail's card and that record's title begin on the same line.
       STICKY IS UNAFFECTED — padding only moves where the rail STARTS, and
       `.app-rail-inner` still pins at 72px the moment the page scrolls. */
    padding-top: var(--app-pane-body-top);
}
.app-rail-inner {
    position: sticky;
    top: 72px;
    max-height: calc(100vh - 96px);
    overflow-y: auto;
    padding: 16px 14px;
    background: #fff;
    border: 1px solid var(--hairline);
    border-radius: 10px;
    box-shadow: 0 1px 2px rgba(16, 24, 40, .04);
}

.app-rail-head { margin-bottom: 12px; }
.app-rail-title {
    display: block;
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .6px;
    color: var(--text-muted);
}
/* The gaps around the SHARED meter are the host's — .app-meter carries none. */
.app-rail-head .app-meter-num { margin-top: 3px; }
.app-rail-head .app-meter { margin-top: 8px; }
.app-rail-sub { margin: 8px 0 0; font-size: 12.5px; color: var(--text-soft); }

/* ── The rail's two chips (what is left / everything) ────────────────────
   PRESENTATION ONLY — every item is rendered either way, so nothing here
   decides what a reader may see. It exists because a parent that is
   nine-fifteenths done had the six that matter nine rows apart. */
.app-rail-filter { display: flex; gap: 6px; margin-bottom: 10px; }
.app-rail-chip {
    flex: 1 1 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 5px 8px;
    border: 1px solid var(--hairline);
    border-radius: 999px;
    background: #fff;
    font-size: 12px;
    font-weight: 600;
    color: var(--text-soft);
    cursor: pointer;
}
.app-rail-chip:hover { border-color: #2f5fb3; color: #2f5fb3; }
.app-rail-chip.is-on { background: #e8f0fb; border-color: #2f5fb3; color: #2f5fb3; }

/* ── The item list ──────────────────────────────────────────────────── */
.app-rail-group + .app-rail-group { margin-top: 12px; }
.app-rail-group-head {
    padding: 0 8px 4px;
    font-size: 10.5px;
    font-weight: 700;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: var(--text-muted);
}
.app-rail-items { list-style: none; margin: 0; padding: 0; }
.app-rail-link {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    padding: 7px 8px;
    border-radius: 7px;
    border-left: 2px solid transparent;
    text-decoration: none;
    line-height: 1.3;
}
.app-rail-link:hover { background: var(--subnav-hover); }
.app-rail-item.is-active > .app-rail-link {
    background: #e8f0fb;
    border-left-color: #2f5fb3;
}
/* The mark's BOX, and the neutral tone. Its accent is the FEATURE's, declared as
   `.app-rail-mark--<slug>` in the feature sheet, deliberately: a rail item and
   the status pill on the record it opens are the same fact, and two palettes for
   one fact is how a reader stops trusting either. The SHAPE carries the state as
   much as the colour does, which is what keeps the rail readable to a reader who
   cannot separate two accents — so a family supplies a glyph per state, not one
   glyph in six colours. */
.app-rail-mark {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    margin-top: 1px;
    color: var(--badge-gray-fg);
}
.app-rail-mark svg { margin-right: 0; vertical-align: 0; }

.app-rail-text { flex: 1 1 auto; min-width: 0; }
.app-rail-name { display: block; font-size: 12.5px; font-weight: 600; color: var(--text); }
.app-rail-state { display: block; margin-top: 1px; font-size: 11px; color: var(--text-muted); }
/* A finished item stays legible and stops competing: it is context now, not
   work. It is NOT struck through — the reader may still want to read it. */
.app-rail-item.is-done .app-rail-name { font-weight: 500; color: var(--text-soft); }

.app-rail-empty { margin: 10px 8px 0; font-size: 12.5px; color: var(--text-muted); }
/* [hidden] companions: these elements carry an author `display`, which beats the
   UA's `[hidden] { display: none }` — the trap this sheet documents for
   `.app-fields[hidden]`. Without them the rail filter would hide nothing. */
.app-rail-item[hidden],
.app-rail-group[hidden],
.app-rail-empty[hidden] { display: none !important; }

/* ── The pane ───────────────────────────────────────────────────────── */
/* THE HEAD BAND. Its SURFACE is not here — this selector is in the
   `.app-card-head, .app-panel-head` group near the top of the sheet, so the
   gradient, the bottom rule and the top-corner radius have ONE definition for all
   three. What is the pane's own is the row layout and a tighter padding: this
   band holds a position label and one small button, not a card's title block.
   ⚠ ITS HEIGHT IS 47px — `min-height` 30 + padding 8 + 8 + its own bottom rule 1 —
   which is `--app-pane-body-top` ON `.app-work` less the column card's top border.
   The rail is offset by that property, so change the padding or the min-height
   here and change the property with it. */
.app-pane-head {
    display: flex;
    align-items: center;
    gap: 10px;
    min-height: 30px;
    padding: 8px 20px;
}
.app-pane-pos {
    font-size: 11px;
    font-weight: 700;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: var(--text-muted);
}
.app-pane-full { margin-left: auto; }
/* THE CARD'S BODY. The padding is a page's rhythm rather than a modal's, because
   the pane is as wide as a `.page-narrow` column and the bodies in it are the
   same full records and forms their standalone routes show. */
.app-pane { padding: 18px 20px 20px; }
/* A body in the pane opens with its identity strip, which already carries the
   item's name under the parent's — so the space above it is tighter than a
   page's, the same trade `.app-recdlg .app-rv-rec` makes in a dialog. A family
   whose fragment identity is its OWN block (implementation_tasks' `.it-rec`)
   states the same gap in its own sheet. */
.app-pane > .app-rv-rec { margin-bottom: 14px; }
/* A card inside the pane is a section of ONE flow, not a floating panel: the
   column card around it already supplies the elevation, so a second shadow reads
   as a stack of loose sheets. Identical rule and identical reason to
   `.modal-body .app-panel` — and it had better be, because a fragment body is the
   SAME body in the dialog and in the pane. Descendant and not child for the same
   reason that one is: a fragment body is usually wrapped (`.ni-dlg`, or a
   `data-diag-node` div), so `>` would never match. */
.app-pane .app-card,
.app-pane .app-panel { box-shadow: none; }
/* A body made of shared cards ends in an 18px card margin, which lands on top of
   the pane's own bottom padding and reads as a gap the card cannot close. */
.app-pane .app-card:last-child,
.app-pane .app-panel:last-child { margin-bottom: 0; }

.app-work-flash { margin-bottom: 14px; }
.app-work-flash[hidden] { display: none !important; }

@media (max-width: 700px) {
    /* Below 700px this sheet releases the 1200px body floor and the layout goes
       fluid, so the two columns become one and the rail unpins — a sticky index
       above the thing it indexes is just a block that will not scroll away. It
       stays FIRST, because on a phone "what is left to do" is the more useful
       thing to land on than the middle of one item. */
    .app-work { grid-template-columns: minmax(0, 1fr); gap: 16px 0; }
    /* The rail is FIRST here and has no head band above it to clear, so the
       offset that lines it up with the record title on a desktop is nothing but
       a gap at the top of a phone. */
    .app-rail { padding-top: 0; }
    .app-rail-inner { position: static; max-height: none; }
    .app-pane-head { padding: 8px 14px; }
    .app-pane { padding: 14px 14px 16px; }
}

/* ══════════════════════════════════════════════════════════════════════════
   AppDrawer — the shared RIGHT-EDGE SLIDE-IN, for a short form raised from
   somewhere the reader must not lose.

   Introduced 2026-09-10 with "Ask a Question" (views/general/app_ask_question.php
   + assets/general/app_ask_question.js), which the operator asked for as *"a
   slide in drawer"* / *"a slide in modal window"* opened from the NAV approval
   form. THE CHROME IS SHARED AND THE FORM IS NOT: anything that needs the same
   gesture — a two-field form over a record the reader is working on — mounts its
   own body in this shell rather than authoring a second one.

   WHY A DRAWER AND NOT A `.modal-lg` DIALOG. The reader is mid-approval. A
   centred dialog covers the thing they were reading and reads as an
   interruption; a panel that slides in from the edge reads as "this opened
   beside what you were doing", which is what asking a question about a record
   IS. It also composes: a drawer over a dialog is legible, a dialog over a
   dialog is two cards fighting for the middle.

   ── WHAT IT INHERITS, AND WHY THE NAMES MATTER ──────────────────────────────

   `.app-drawer-backdrop` ends in `-backdrop`, so AppModal (general.js) adopts
   it with NO list to maintain: a click on the backdrop is swallowed and the
   panel pulses instead of closing over unsaved work, and Escape over an edited
   form routes through appConfirm first. That is the portal-wide contract — do
   not hand-roll `if (e.target === overlay) close()`; the ~40 that exist no
   longer fire. `[data-modal-close]` on the × is how AppModal finds the close
   control, and `dialogOf()` falls back to the backdrop's first visible child,
   which is the panel — so this shell needs no entry in that module's DIALOG
   list either.

   ⚠ THE PANEL IS NESTED INSIDE THE BACKDROP, AND THAT IS NOT A STYLING CHOICE.
   AppModal marks a modal dirty by walking UP from the edited field to its
   nearest backdrop (`markDirty` -> `closestBackdrop`). The two topbar drawers
   put backdrop and panel side by side, which is fine for them — they are EXEMPT
   from the guard because they hold no unsaved work — but a sibling panel here
   would never be found, so the form would lose the unsaved-work protection and
   Escape would drop a half-written question without asking. Sibling markup is
   the trap; keep the nesting.

   That is the whole difference between this drawer and those two: this one
   holds a form.

   ── Z-INDEX: 9400, THE SAME BAND AS `.app-embedmodal`, FOR THE SAME REASON ──
   It is raised from INSIDE a `.modal-overlay` (the NAV approval dialog, 9000),
   so 9000 would put it behind the form that opened it. Above the tip bubble
   (9300) and the date panel (9200) too, so nothing floating out of the form
   underneath crosses the panel; BELOW the access-denied overlay (9600) and
   appConfirm (9700), both of which must still reach the reader over this. The
   two 9400 surfaces are never open together — one is raised from a wizard, the
   other from a record form.
   ══════════════════════════════════════════════════════════════════════════ */
.app-drawer-backdrop {
    position: fixed;
    top: 0; right: 0; bottom: 0; left: 0;
    z-index: 9400;
    background: rgba(16, 24, 40, .5);
    opacity: 0;
    transition: opacity .2s ease;
}
.app-drawer-backdrop.is-open { opacity: 1; }
/* display:none, not just opacity: a transparent backdrop still eats every click
   on the page under it. The recurring feature-display-vs-[hidden] rule. */
.app-drawer-backdrop[hidden] { display: none !important; }

.app-drawer {
    /* ABSOLUTE inside the fixed backdrop, which resolves against the viewport
       box — so the panel is pinned to the right edge without a second
       `position: fixed` and without the backdrop needing to be a flex box. */
    position: absolute;
    top: 0; right: 0; bottom: 0;
    display: flex;
    flex-direction: column;
    width: 520px;
    /* 48px of the page stays visible at every width, so the reader can always
       see WHERE they are — the same trade `.menu-drawer` makes. */
    max-width: calc(100vw - 48px);
    background: #fff;
    box-shadow: -18px 0 50px rgba(15, 23, 42, .28);
    transform: translateX(102%);
    transition: transform .28s cubic-bezier(.32, .72, .33, 1);
}
/* The class goes on the BACKDROP and both children read it, so one attribute
   and one class drive the whole surface — and `[hidden]` on the backdrop hides
   the panel with it. */
.app-drawer-backdrop.is-open .app-drawer { transform: none; }
.app-drawer[hidden] { display: none !important; }

/* ── the head ─────────────────────────────────────────────────────────────
   The navy band and the 3px rule are the shared dialog vocabulary, so a drawer
   and a modal read as the same system. An eyebrow above the title because the
   title is the ACT ("Ask a Question") and the eyebrow is the OBJECT ("about
   this NAV") — a reader arriving from a button needs both. */
.app-drawer-head {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 15px 20px;
    background: #1d1d1b;
    border-bottom: 3px solid var(--navy);
    flex-shrink: 0;
}
.app-drawer-head-text { flex: 1; min-width: 0; }
.app-drawer-eyebrow {
    display: block;
    font-size: 10px;
    font-weight: 700;
    letter-spacing: .1em;
    text-transform: uppercase;
    color: rgba(255, 255, 255, .55);
}
.app-drawer-title {
    display: block;
    font-size: 16px;
    font-weight: 700;
    color: #fff;
    line-height: 1.3;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.app-drawer-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    flex-shrink: 0;
    background: none;
    border: 1px solid rgba(255, 255, 255, .18);
    border-radius: 4px;
    color: #cfd4da;
    cursor: pointer;
}
.app-drawer-close:hover { background: rgba(255, 255, 255, .1); color: #fff; }

/* ── the body ─────────────────────────────────────────────────────────────
   THE ONLY SCROLLER. The head and the action row are flex-shrink:0, so the
   submit button never scrolls out of reach on a short viewport — the failure
   the `.modal-body`-as-scroller rule exists to prevent, and the same shape. */
.app-drawer-body {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    padding: 18px 20px;
}
/* The form primitives underneath are the app's own (.form-row / .form-label /
   .form-field). A drawer is 520px wide, so nothing here caps a field: the panel
   IS the measure. */
.app-drawer-body .form-field { width: 100%; }

/* The read-only identity of the record being asked about. `.app-fields` would
   be a two-column <dl> and this is 480px of usable width, so the pairs stack —
   inline, quiet, and unmistakably not an input. */
.app-drawer-context {
    display: flex;
    flex-wrap: wrap;
    gap: 4px 18px;
    padding: 10px 12px;
    margin-bottom: 16px;
    background: #f7f9fc;
    border: 1px solid var(--border);
    border-radius: 6px;
    border-left: 3px solid var(--navy);
}
.app-drawer-ctx { display: flex; gap: 6px; align-items: baseline; min-width: 0; }
.app-drawer-ctx-label {
    font-size: 10px;
    font-weight: 700;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--text-muted);
    flex-shrink: 0;
}
.app-drawer-ctx-value {
    font-size: 12.5px;
    font-weight: 600;
    color: var(--text);
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ── the action row ──────────────────────────────────────────────────────
   A FOOTER, so it composes `.form-actions` (the top rule + padding) rather
   than `.detail-modal-actions`, which is a TOP row and would put its border on
   the wrong edge. Pinned outside the scroller. */
.app-drawer-actions {
    display: flex;
    gap: 10px;
    align-items: center;
    flex-shrink: 0;
    padding: 14px 20px;
    margin: 0;
    border-top: 1px solid var(--border);
    background: #fbfcfe;
}
.app-drawer-actions .btn { flex-shrink: 0; }
/* Pushes anything after it (a Cancel) to the right edge. */
.app-drawer-actions .app-drawer-actions-gap { margin-left: auto; }

@media (max-width: 700px) {
    /* Full-bleed on a phone: 48px of a page nobody can read is not orientation,
       it is a dead strip. Square off the shadow side with it. */
    .app-drawer { width: 100%; max-width: 100%; }
}
