/*
 * Vantage app UI primitives — opt-in migration layer.
 *
 * Scope every rule to body.v-ui so marketing pages and unmigrated app pages
 * keep their intentional designs. vantage-brand.js loads this file across the
 * app; a page opts into the wider primitives by adding class="v-ui" to <body>
 * and deleting its competing local primitive rules. Tenant colors continue to
 * come from vantage-brand.js.
 */

/* ── The Vantage platform mark accent (Nate ruling, 2026-07-31) ─────────────
 * The diamond in the Vantage V is BRAND FURNITURE, not a themed UI color: it
 * is the platform's own mark (shown only for platform accounts — a tenant sees
 * their logo or initial instead), so it holds its amber through light and dark
 * exactly like a logo does. The V strokes around it DO follow the theme, via
 * currentColor — that is the whole bug this token was cut for: the mark's V was
 * hardcoded #f8f8f8 and went invisible on the light-theme header.
 *
 * Unscoped on purpose, for the same reason .vdt-* and .v-fab are: the mark is
 * injected by vantage-brand.js / vantage-sidebar.js / vantage-client-nav.js
 * onto pages that have not opted into body.v-ui. Every consumer still writes
 * the fallback — `var(--v-brand-mark, #C9A464)` — because login.html links
 * vantage-theme.css WITHOUT this sheet.
 *
 * Consumers (the complete list; a new one belongs here, not on a fifth copy):
 * vantage-brand.js [data-brand-logo-mark], vantage-sidebar.js mobile-bar FAB,
 * vantage-client-nav.js V_GLYPH, login.html static nav mark. NOT consumers, and
 * correctly hardcoded: admin-settings.html's favicon previews (they DEPICT the
 * asset on a light vs dark tab) and images/vantage-favicon.svg (paints its own
 * #1c2627 plate).
 */
:root { --v-brand-mark: #C9A464; }

/* THE page gutter. Every full-width band's inner content box is
   `max-width: var(--v-content-max)` + `padding: 0 var(--v-gutter)`, so the hero
   title, the tab strip, the list toolbar, the context bar and the table all begin
   on one vertical line at every viewport width.

   28px is the app-shell standard — what `body.v-ui.v-app-shell .main` has always
   used (it now consumes this token). Pages that rolled their own wrapper at 40px
   were the outliers; admin-proposals and admin-change-orders were each 12px off
   their own hero until 2026-08-03.

   UNSCOPED, and that is load-bearing — NOT in the body.v-ui block. Page
   stylesheets consume this token, and a page whose <body> never gets `v-ui`
   (assigned-*, setup-*, admin-vmeet, contractor, dashboard — the sidebar adds
   only `v-app-shell` there) would resolve it to nothing. An unresolved var makes
   the whole `padding` shorthand invalid, which computes to 0 and slams the page
   content flush against the viewport edge. That is exactly what happened when
   this token first shipped inside body.v-ui.

   Measure before changing the value: the first attempt moved the hero to 40px,
   which fixed two pages and broke every page that uses `.main`. */
:root {
  --v-gutter: 28px;
  --v-gutter-sm: 16px;
}

/* Shared date picker
 * Loaded app-wide by vantage-brand.js. This component is intentionally not
 * scoped to body.v-ui because native date fields are enhanced globally while
 * the broader page migration remains opt-in.
 */
.vdt-input { cursor: pointer; }

.vdt-dropdown {
  display: none;
  position: fixed;
  z-index: 100000;
  width: min(262px, calc(100vw - 16px));
  padding: 10px;
  border: 1px solid color-mix(in srgb, var(--gold) 30%, transparent);
  border-radius: 10px;
  background: var(--bg-mid);
  box-shadow: 0 10px 30px color-mix(in srgb, var(--bg) 55%, transparent);
  color: var(--gold-light);
  font-family: var(--font);
  user-select: none;
}

.vdt-dropdown.is-open { display: block; }

.vdt-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
}

.vdt-year-header { margin-bottom: 10px; }

.vdt-nav-btn,
.vdt-period-btn,
.vdt-link-btn,
.vdt-day,
.vdt-month,
.vtp-link-btn,
.vtp-slot {
  border: 0;
  background: transparent;
  color: var(--gold-light);
  font-family: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.vdt-nav-btn {
  padding: 2px 8px;
  color: var(--gold);
  font-size: 18px;
  line-height: 1;
}

.vdt-period-btn {
  padding: 2px 6px;
  border-radius: 5px;
  color: var(--gold);
  font-size: 13px;
  
  letter-spacing: 0.5px;
}

.vdt-year {
  color: var(--gold);
  font-size: 13px;
  
  letter-spacing: 0.5px;
}

.vdt-nav-btn:hover,
.vdt-period-btn:hover,
.vdt-link-btn:hover,
.vtp-link-btn:hover {
  background: color-mix(in srgb, var(--gold) 8%, transparent);
  color: var(--gold-light);
}

.vdt-nav-btn:focus-visible,
.vdt-period-btn:focus-visible,
.vdt-link-btn:focus-visible,
.vdt-day:focus-visible,
.vdt-month:focus-visible,
.vtp-link-btn:focus-visible,
.vtp-slot:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 1px;
}

.vdt-dow-grid,
.vdt-day-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 2px;
}

.vdt-dow-grid { margin-bottom: 4px; }

.vdt-dow {
  padding: 3px 0;
  color: var(--muted);
  font-size: 10px;
  text-align: center;
}

.vdt-day {
  min-width: 0;
  padding: 6px 0;
  border-radius: 6px;
  font-size: 12px;
  text-align: center;
  transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}

.vdt-day:hover,
.vdt-month:hover { background: color-mix(in srgb, var(--gold) 10%, transparent); }

.vdt-day.is-outside {
  color: var(--muted);
  opacity: 0.55;
}

.vdt-day.is-today,
.vdt-month.is-today { box-shadow: inset 0 0 0 1px var(--gold); }

.vdt-day.is-selected,
.vdt-month.is-selected {
  background: var(--gold);
  color: var(--bg);
  
}

.vdt-month-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 6px;
}

.vdt-month {
  padding: 9px 0;
  border-radius: 6px;
  font-size: 12px;
  transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}

.vdt-footer {
  display: flex;
  justify-content: flex-end;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid color-mix(in srgb, var(--gold) 15%, transparent);
}

.vdt-footer-split { justify-content: space-between; }

.vdt-link-btn,
.vtp-link-btn {
  padding: 2px 4px;
  border-radius: 4px;
  color: var(--gold);
  font-size: 11px;
}

/* Shared time picker
 * Sibling of the date picker above, and unscoped for the same reason: native
 * time fields are enhanced app-wide by vantage-time-picker.js while the wider
 * body.v-ui page migration stays opt-in. Everything the two components share —
 * the footer link button, the focus ring, the button reset — is expressed by
 * extending the selector lists above rather than restating the declarations.
 */
.vtp-input { cursor: text; }

.vtp-dropdown {
  display: none;
  position: fixed;
  z-index: 100000;
  width: min(200px, calc(100vw - 16px));
  padding: 8px;
  border: 1px solid color-mix(in srgb, var(--gold) 30%, transparent);
  border-radius: 10px;
  background: var(--bg-mid);
  box-shadow: 0 10px 30px color-mix(in srgb, var(--bg) 55%, transparent);
  color: var(--gold-light);
  font-family: var(--font);
  user-select: none;
}

.vtp-dropdown.is-open { display: block; }

/* position:relative is load-bearing — revealSelected() centres the highlighted
   slot using offsetTop, which must be measured against this scroller and not
   whatever the nearest positioned ancestor happens to be. */
.vtp-list {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 224px;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: color-mix(in srgb, var(--gold) 35%, transparent) transparent;
}

.vtp-slot {
  flex: 0 0 auto;
  padding: 6px 8px;
  border-radius: 6px;
  font-size: 12px;
  text-align: left;
  transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}

.vtp-slot:hover { background: color-mix(in srgb, var(--gold) 10%, transparent); }

.vtp-slot.is-now { box-shadow: inset 0 0 0 1px var(--gold); }

.vtp-slot.is-selected {
  background: var(--gold);
  color: var(--bg);
}

.vtp-footer {
  display: flex;
  justify-content: space-between;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid color-mix(in srgb, var(--gold) 15%, transparent);
}

/* Canonical application dialogs. These are intentionally app-wide rather than
 * body.v-ui-only: vantage-brand.js loads them only on Vantage application
 * surfaces, and legacy pages need a themed replacement while they migrate. */
/* ── The shared confirm/alert/prompt dialog ──────────────────────────────────
   IT HAS NO FRAME CSS. vantage-dialogs.js builds the canonical modal anatomy
   (.modal-overlay > .modal.v-service-modal > .modal-header/.modal-body/
   .v-modal-foot), so it is styled by the shared modal rules like every other
   modal in the app. Opt into the stylesheet; never re-implement it.

   A private `.v-dialog` / `.v-dialog__*` frame lived here until 2026-08-03 with
   its values hand-copied from the canon. It had already drifted — 16px vs the
   canon's 12px radius, its own header padding and flat background instead of the
   64px gradient band, a RED title on destructive dialogs, and a `&times;` text
   glyph instead of the icon close. That is what a duplicate presentation path
   costs. All of it is deleted; do not add frame CSS back here.

   The one declaration that remains is content, not chrome: dialog messages are
   passed with real newlines, so the paragraph has to honour them. */
#v-dialog-message { margin: 0; white-space: pre-line; }
#v-dialog-input { width: 100%; margin-top: 16px; }

/* Shell semantics are available to every authenticated shell consumer, even
   while its page body remains on the legacy component system. */
:is(body.v-ui, body.v-app-shell) {
  --v-page-bg: var(--v-theme-canvas, var(--bg));
  --v-surface: var(--v-theme-surface, var(--bg-mid));
  --v-surface-raised: var(--v-theme-surface-raised, var(--bg-light));
  --v-surface-inset: color-mix(in srgb, var(--v-page-bg) 72%, var(--v-surface));
  --v-text: var(--v-theme-text, var(--v-brand-text, var(--gold-light)));
  --v-text-muted: var(--v-theme-text-muted, var(--v-brand-muted, var(--muted)));
  --v-text-subtle: var(--v-theme-text-subtle, color-mix(in srgb, var(--v-text-muted) 72%, var(--v-surface)));
  --v-border: var(--v-theme-border, color-mix(in srgb, var(--v-text) 18%, transparent));
  --v-border-strong: color-mix(in srgb, var(--v-text) 34%, transparent);
  --v-drawer-header-height: 56px;
  --v-drawer-border: var(--v-border);
  --v-drawer-control-size: 32px;
  /* Drawer corner radius (Nate ruling 2026-08-11). Every drawer is flush to the
     right viewport edge and slides in from it, so the radius applies to the LEFT
     corners only — the two edges that actually sit over the page. 16px matches
     the facelift modal frame (vantage-facelift.css) so a drawer and a modal read
     as the same class of surface. Both canonical primitives (.v-drawer and
     .v-detail-drawer) consume this token; nothing else may restate the value.
     Zeroed at the mobile breakpoints below, where a drawer goes full-bleed and a
     rounded left edge against a square right edge reads as a mistake. */
  --v-drawer-radius: 16px;
  --v-accent: var(--v-theme-accent, var(--gold));
  --v-on-accent: var(--v-theme-on-accent, var(--bg));
  --v-font-ui: var(--font, "Lato", Arial, sans-serif);
  --v-font-display: var(--v-font-ui);
  /* The one permitted non-tenant typeface — identifiers/codes/amounts only.
     Pages must consume this token, never restate a mono stack. */
  --v-font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}

/* The alert-badge tint lives at :root, not on the shell block above, because
   .v-alert-badge is consumed by the sidebar on 26 pages whose <body> has not
   yet opted into v-ui/v-app-shell (admin-mobile-*, admin-gc-*, setup-*, the
   demo pages). Scoping the token to the shell left those pages resolving the
   background to an invalid value, i.e. an invisible badge. One home only. */
:root {
  --v-alert-badge: var(--red, #c0392b);
  --v-alert-badge: hsl(from var(--red, #c0392b) calc(h - 18) calc(s * .55) calc(l * .85));

  /* VSign field ink (2026-08-05). One palette for the annotation boxes drawn on
     a PDF page — the placer (vsign-field-placer.css) and the signer's own view
     (sign.css) both consume it, so an admin previews exactly what the customer
     will see. Before this they carried two different palettes.

     These are deliberately NOT tenant tokens. The boxes sit on white paper: a
     tenant whose accent resolves near-white (the default brand's body ink is
     ~#ebebeb) rendered them invisible — the reported bug. Paper ink has to stay
     legible on paper regardless of the tenant's colors. Every hue is outside
     the C27b banned 150–215° band.

     At :root, not on body.v-ui, for the same reason as --v-alert-badge above:
     the ONE page where a customer actually draws on these fields — sign.html —
     is a standalone signing surface whose <body> has not opted into v-ui. Under
     the shell scope every token resolved to nothing there, so the overlays lost
     their per-type colour and the popover pad fell back to plain black ink,
     while the admin placer showed the full palette. One home only (2026-08-17). */
  --vsign-ink-signature:  #1e8449;   /* 145° green  */
  --vsign-ink-initials:   #7d3c98;   /* 282° purple */
  --vsign-ink-date:       #b9770e;   /* 36° amber   */
  --vsign-ink-print-name: #b03a5b;   /* 343° rose   */
  --vsign-ink-text:       #a04000;   /* 24° rust    */
  --vsign-ink-prefill:    #3949ab;   /* 232° indigo */
}

body.v-ui {
  /* Warning accent for .v-banner-warning (no page may hardcode it) */
  --v-warn: #e5a54b;
  /* Informational accent — scheduled//in-progress status text.
     Was the loose literal #5dade2 on 15+ pages; no page may hardcode it. */
  --v-info: #5dade2;
  /* Affirmative accent (Nate, approved 2026-07-27) — the ONE green in the app.
     Exists because an irreversible client-facing YES ("Accept Proposal") must
     read as affirmative, which gold cannot carry when gold is also every neutral
     primary. Consumed ONLY by the .v-affirm modifier in vantage-buttons.css and
     the .v-na-* next-action list below (2026-07-30 — the affirmative "done /
     live next step" dot states, previously a legacy teal literal held page-
     locally in admin-projects); no page may hardcode a green. Hue 145 deg,
     deliberately clear of the C27b teal/cyan window (150-215). */
  --v-ok: #2ecc71;
  /* Danger accent — overdue, failed, cancelled, offline. Added 2026-08-12 to
     close a hole: the facelift already had a danger ROLE (--vf-danger), but it
     was `var(--red, var(--vf-accent))` and --red is set by no tenant, so danger
     fell back to the ACCENT. On a tenant whose accent equals its text colour
     that resolved to #ebebeb — measured — which made every danger surface
     indistinguishable from ordinary text. Its three siblings above all carry a
     real default; this is the missing fourth. #e07070 is not a new colour: it is
     the value .v-banner-danger already used. No page may hardcode a red. */
  --v-danger: #e07070;
  /* Meeting families (Nate, approved 2026-08-20). A meeting on the calendar or
     in the VMeet hub is coloured by what it is: onsite ticket work blue, onsite
     project work green, anything virtual purple. Project phases keep gold and
     are not meetings. Consumed ONLY through the .vmk-* classes below, which
     vantage-meeting-kinds.js resolves - no page may hardcode a meeting colour.

     --v-meet-service is a true blue at hue 219, deliberately NOT the azure
     --v-info (#5dade2, hue 203): the calendar already paints scheduled TASKS in
     that azure, and two adjacent 10px chips a few degrees apart read as one
     colour. It also sits clear of the C27b teal/cyan window (150-215).

     --v-meet-project is var(--v-ok) on purpose rather than a second green.
     --v-ok is documented above as the ONE green in the app; a meeting is not
     "affirmative", but the palette does not gain a near-duplicate green to say
     so. One green, two consumers.

     --v-meet-virtual (hue 258) is the only new hue this adds. Nothing else in
     the product is purple, which is exactly why virtual reads instantly. */
  --v-meet-service: #4d82e3;
  --v-meet-project: var(--v-ok);
  --v-meet-virtual: #9b7ede;
  --v-space-1: 4px;
  --v-space-2: 8px;
  --v-space-3: 12px;
  --v-space-4: 16px;
  --v-space-5: 20px;
  --v-space-6: 24px;
  --v-space-8: 32px;
  --v-space-10: 40px;
  --v-radius-sm: 4px;
  --v-radius-md: 6px;
  --v-radius-lg: 10px;
  /* Form controls have three intentional density tiers:
     standard desktop (32px), touch/mobile (42px below), and compact (28px).
     32px standard approved 2026-07-25 (was 36px) so controls sit comfortably
     inline in modals. Keep this token canonical; legacy field rules consume
     it as their first choice. */
  --v-control-height: 32px;
  --v-control-height-compact: 28px;
  --v-control-pad-y: 7px;
  --v-control-pad-x: 10px;

  /* One height for every panel header, so the divider lands on the same line
     across a row of cards whether or not a given header carries a control
     (Nate 2026-08-11: "make one header type for all of these"). The value is
     the compact control plus the header's 12px padding top and bottom plus its
     1px divider — box-sizing is border-box, so a header carrying a button
     measures exactly this and a title-only header is held to it by min-height.
     Written as a calc rather than 53px so it tracks the control token. A
     title-only header used to be 45px and one with a button 57px — side by side
     in a grid row that read as three different cards. */
  --v-panel-header-h: calc(var(--v-control-height-compact) + 24px + 1px);

  --v-content-max: 1400px;

  /* C31 — table row density. TWO tokens and they are the COMPLETE set:
     --v-row-pad for every .table-wrap table, --v-row-pad-ledger for the
     accounting ledgers (.vm-ledger-tbl + .v-finance-table) ONLY. Page/module
     CSS may not declare td/th padding at all; a table wanting a third density
     belongs in row detail instead (the Staged & Installed ruling). Header
     padding/size is separately owned (single-line ledger header ruling). */
  --v-row-pad: 13px 16px;
  --v-row-pad-ledger: 8px 12px;

  /* Row HEIGHT, which density alone does not fix. With --v-row-pad only, a row
     is as tall as its tallest cell: a text-only row makes 46px (13 + a 13px
     line box + 13), a row whose action cell holds a 28px compact control makes
     54px. Two tables side by side, or two rows in ONE table where only some
     rows can be acted on, therefore rule at different heights for no reason a
     reader can see. This is the floor that makes them one grid — derived from
     the two tokens above, never a fresh number, so it cannot drift from them.
     It is a floor, not a cap: `height` on a <tr> is a minimum, so a row with
     genuinely taller content still grows.

     Consumed page-side today (admin-contracts-shell.css). Promoting it to
     `body.v-ui .table-wrap > table > tbody > tr` here would put every list page
     on one row height — a site-wide look change, so it is Nate's call, not a
     side effect of this file. */
  --v-row-h: calc(13px * 2 + var(--v-control-height-compact));

  --font-body: var(--v-font-ui);
  background: var(--v-page-bg);
  color: var(--v-text);
  font-family: var(--v-font-ui);
}

/* The affirmative accent has TWO appearances (Nate, 2026-08-12: "they look
   quite neon in light mode"). #2ecc71 is tuned for a dark canvas; on the pale
   light workspace it carries ~1.9:1 against the canvas — a glare, not a
   status. Light mode drops to the same deep green the light-mode success toast
   already uses (vantage-theme.css), which clears 5.4:1 on white and keeps the
   check glyph inside a filled dot (painted var(--bg)) readable.

   Hue 143 deg — same family as the dark value, still clear of the C27b
   teal/cyan window (150-215). --v-warn and --v-info are NOT flipped here: both
   are darker to begin with and neither was raised. One token, two appearances;
   no consumer branches on the theme. */
html[data-theme="light"] body.v-ui { --v-ok: #1e7a42; }

/* ── Meeting family marks ─────────────────────────────────────────────────────
   The colour half of vantage-meeting-kinds.js. A consumer asks the module for a
   class (vmk-service / vmk-project / vmk-virtual) and puts it on the element;
   what that class MEANS in pixels is decided once, here.

   Two shapes, because two surfaces need it:
     .vmk-chip  a filter or legend pill that names the family
     .vmk-mark  the colour applied to something the page already draws (a
                calendar event, a hub row), as a left rule plus a tinted ground

   Both read --vmk-color, which the family classes set. That indirection is what
   lets a new family arrive as three lines rather than a sweep. */
body.v-ui .vmk-service { --vmk-color: var(--v-meet-service); }
body.v-ui .vmk-project { --vmk-color: var(--v-meet-project); }
body.v-ui .vmk-virtual { --vmk-color: var(--v-meet-virtual); }

body.v-ui .vmk-mark {
  border-left: 2px solid var(--vmk-color);
  background: color-mix(in srgb, var(--vmk-color) 14%, transparent);
  color: var(--vmk-color);
}

body.v-ui .vmk-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 10px;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--vmk-color) 55%, transparent);
  background: color-mix(in srgb, var(--vmk-color) 12%, transparent);
  color: var(--vmk-color);
  font-size: 11px;
  line-height: 1.6;
  white-space: nowrap;
}

/* The dot carries the family colour when the chip itself stays neutral, which
   is what a legend row and a pressed filter pill both need. */
body.v-ui .vmk-dot {
  width: 8px;
  height: 8px;
  flex: 0 0 auto;
  border-radius: 50%;
  background: var(--vmk-color);
}

/* Shared application shell. The sidebar owns global navigation and the appbar
   owns utilities; pages contribute exactly one content heading and optional
   section navigation. Existing workflow handlers remain page-owned. */
body.v-app-shell > .nav,
body.v-app-shell > .mobile-menu { display: none !important; }

/* The shared utility bar is shell chrome, so every sidebar consumer reserves
   its height. body.v-ui remains the separate page-body component opt-in. */
body.v-app-shell { padding-top: 56px !important; }

body.v-app-shell { --v-appbar-h: 56px; }  /* token: pages stacking their own workflow bar below the appbar consume this */
body.v-app-shell #vsb-appbar {
  position: fixed;
  top: 0;
  right: 0;
  left: var(--sb-w);
  z-index: 1990;
  display: flex;
  height: var(--v-appbar-h, 56px);
  align-items: center;
  justify-content: space-between;
  padding: 0 18px;
  border-bottom: 1px solid var(--v-border);
  background: var(--v-theme-chrome, var(--v-surface));
  backdrop-filter: blur(16px);
  transition: left var(--sb-dur) var(--sb-ease);
}
body.sb-off.v-app-shell #vsb-appbar { left: var(--sb-cw); }
body.v-app-shell #vsb-appbar-context { min-width: 0; }
body.v-app-shell #vsb-appbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }

body.v-ui.v-app-shell .page-hero {
  position: relative;
  z-index: 1;
  min-height: 0;
  /* No horizontal padding on the BAND — the gutter belongs to the inner content
     box (see --v-gutter). Same 28px as before at any width up to --v-content-max;
     the band-vs-inner split is what makes it keep matching `.main` ABOVE that
     width, where a band-level gutter stops tracking the centred content box. */
  padding: 34px 0 16px !important;
  border: 0;
  background: transparent;
  box-shadow: none;
  backdrop-filter: none;
}

body.v-ui.v-app-shell .page-hero-inner {
  min-height: 0;
  padding: 0 var(--v-gutter);
  align-items: flex-end;
  flex-wrap: wrap;
}

/* The shell hero is the .v-page-title role — it adds nothing but the shell's
   own color; size, weight and tracking come from the one scale step. */
body.v-ui.v-app-shell .hero-title {
  color: var(--v-text);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-page);
  font-weight: 500;
  letter-spacing: -0.02em;
}
body.v-ui.v-app-shell .hero-sub { max-width: 760px; margin-top: 5px; color: var(--v-text-muted); font-size: var(--v-fs-page-sub); }
/* An entity identifier under the page title (project code and friends). It is a
   chip, not an eyebrow — it names the record rather than categorising the page,
   and it sits BELOW the title. Layout only; the chip owns its own chrome. */
body.v-ui.v-app-shell .hero-code { margin-top: 7px; }

body.v-ui.v-app-shell .hero-actions { margin-left: auto; padding-right: 0; }

/* The hero BACK LINK — a quiet "← Back" above a detail page's title. Promoted
   into the canon 2026-08-03 from a page-local copy in admin-schedule-detail.css.
   It is a link, not a button: a hero carries its real actions on the right.

   WHICH ONE DO I USE? This and .v-breadcrumbs both sit above the title and both
   answer "how do I get out of here", so the choice is not taste:

     .v-breadcrumbs — the page KNOWS its parent. Name the trail. It says where
       you are and what you are inside of, which a back link cannot.
     .hero-back     — the page's parent is wherever you came FROM. vmeet-detail
       is the case: goBack() is history.back() with a home fallback
       (vmeet-detail.js:74), so there is no fixed hierarchy to name.

   Reach for .v-breadcrumbs first; it is right far more often. Phase 5 of
   docs/briefs/active-project-scope-standardization.md moved admin-schedule-detail
   and admin-vmeet across, leaving vmeet-detail as the one honest consumer. */
body.v-ui .hero-back {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 10px;
  color: var(--v-text-muted, var(--muted));
  font-size: 12px;
  text-decoration: none;
  transition: color 0.15s;
}
body.v-ui .hero-back:hover { color: var(--v-accent, var(--gold)); }
body.v-ui.v-app-shell .tabs-wrap {
  position: relative;
  top: auto;
  z-index: 1;
  border-bottom-color: var(--v-border);
  background: transparent;
  backdrop-filter: none;
}
body.v-ui.v-app-shell .tabs { padding: 0 28px; gap: 6px; }
body.v-ui.v-app-shell .tab-btn {
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  min-height: 42px;
  /* Tokenized so .tabs-wrap can cancel exactly this much and put the FIRST TAB'S
     LABEL on the page gutter — see the optical-alignment note on .tabs-wrap. */
  padding: 0 var(--v-tab-pad-x, 12px);
  border: 0 !important;
  border-bottom: 1px solid transparent !important;  /* thinned (Nate 2026-07-26: 2px active underline "glaring") */
  border-radius: 0 !important;
  color: var(--v-text-muted);
  /* A control label rides the BODY step, not the label step: 13px is what
     buttons and tabs have always shipped at (the facelift set 13px here and
     beat the canon's 12px, so the canon's value was dead). --v-fs-label is
     the FIELD label — the thing that names an input — and stays 12px. */
  font-size: var(--v-fs-body);
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
}
body.v-ui.v-app-shell .tab-btn:hover { color: var(--v-text); background: color-mix(in srgb, var(--v-text) 5%, transparent); }
body.v-ui.v-app-shell .tab-btn.active { color: var(--v-text); border-bottom-color: var(--v-accent) !important; background: transparent; }
body.v-ui.v-app-shell .main { padding: 32px var(--v-gutter) 80px; }

/* Client portal content width.
   `.main` and `.page-hero-inner` are already centred boxes capped at
   --v-content-max, but 1400px never engages once the 240px rail is subtracted
   from a normal laptop, so portal pages read as full-bleed. A customer's pages
   carry a fraction of an admin page's density, so the portal narrows the token
   rather than adding a second layout: every centred surface follows at once and
   no component rule changes. */
body.v-ui.v-app-shell.vsb-client { --v-content-max: 1080px; }
body.v-ui.v-app-shell .v-page-duplicate-intro { display: none !important; }
body.v-ui.v-app-shell .v-toolbar-only { justify-content: flex-end; }
/* The .comms-detail-*/.comms-message-* names that used to be listed here went
   with the message drawer into vantage-comms-viewer.js, which carries these ink
   tokens itself — it also runs on pages that never load this stylesheet. */
body.v-ui.v-app-shell :is(td, .section-sub) { color: var(--v-text); }
body.v-ui.v-app-shell :is(.td-empty, .tab-search-count, .comms-log-summary, .drop-hint) { color: var(--v-text-muted); }

/* The shell adds colour to the section role and nothing else — weight and
   tracking belong to the one rule that owns the role (search "THE section
   heading"). This block used to restate both, and its -0.02em beat the role's
   own tracking on every shell page. */
body.v-ui.v-app-shell .section-title {
  color: var(--v-text);
  font-family: var(--v-font-display);
}
body.v-ui.v-app-shell .section-title-meta {
  color: var(--v-text-muted);
  font-family: var(--v-font-ui);
  font-size: 0.6em;
  font-weight: 400;
  letter-spacing: 0;
}

/* The sidebar moves its existing profile control into the appbar. */
body.v-app-shell #vsb-user {
  display: flex !important;
  width: auto !important;
  height: 40px;
  align-items: center;
  gap: 9px;
  padding: 4px 8px;
  border-radius: 9px;
  background: transparent;
  color: inherit;
  text-decoration: none;
}
body.v-app-shell #vsb-user:hover { background: color-mix(in srgb, var(--v-text) 7%, transparent); }
body.v-app-shell #vsb-user:focus-visible { outline: 2px solid var(--v-accent); outline-offset: 2px; }
body.v-app-shell #vsb-user #vsb-avatar { width: 32px; height: 32px; }
body.v-app-shell #vsb-user #vsb-uname { color: var(--v-text) !important; }
body.v-app-shell #vsb-user #vsb-urole { color: var(--v-text-muted) !important; }
body.sb-off.v-app-shell #vsb-appbar #vsb-uinfo { max-width: 160px; opacity: 1; pointer-events: auto; }
body.sb-off.v-app-shell #vsb-appbar #vsb-user { justify-content: flex-start; margin-bottom: 0; }
body.v-app-shell #vnc-bell {
  position: relative;
  top: auto;
  right: auto;
  width: 32px;
  height: 32px;
  color: var(--v-text-muted);
}
/* ── [hidden] outranks every component's display ────────────────────────────
   The HTML `hidden` attribute is styled by the UA as `[hidden] { display: none }`
   — specificity (0,1,0). Every canonical component that sets its own display
   beats that: `body.v-ui.v-face .v-chip { display: inline-flex }` is (0,3,1), so
   a chip carrying `hidden` still painted. Measured: the project-code chip on the
   Projects picker rendered as an empty 22x8 pill under the page title, because
   the picker state hides it and the chip rule won.

   This is the whole class of bug, not one chip — any component with a display
   rule ignores `hidden` the same way, and `hidden` is the obvious thing to reach
   for when JS shows and hides a node. One line ends it. Same reasoning as the
   normalize.css rule of long standing. */
body.v-ui [hidden] { display: none !important; }

/* ── Notification badge ────────────────────────────────────────────────────
   THE canonical unread/alert badge, promoted from the utility-bar bell on
   Nate's ruling 2026-08-11. Every count or dot that rides a nav icon consumes
   this one class: the bell (#vnc-bell-badge), the VNOC alert count
   (#vsb-net-badge), the mobile VChat unread dot (#vsb-mob-chat-badge).

   It is NOT .v-chip. A chip is the identifier/state pill in
   vantage-facelift.css, and co-classing the two is exactly what broke both
   sidebar badges: body.v-ui.v-face .v-chip (0,3,1) outranked their own
   .vsb-badge{display:none} (0,1,0), so they rendered as permanently visible,
   permanently empty grey capsules. Deliberately unscoped by body class so the
   26 sidebar pages that have not opted into v-ui/v-app-shell still get it.

   Hidden unless .is-on. Visibility is the badge's own contract, not something
   a caller expresses with an inline style, so an empty badge cannot show. */
.v-alert-badge {
  box-sizing: border-box;
  display: none;
  align-items: center;
  justify-content: center;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border: 0;
  border-radius: 999px;
  background: var(--v-alert-badge);
  color: var(--v-surface, var(--bg-mid, #1a2e2e));
  font-family: var(--v-font-ui, var(--font, "Lato", Arial, sans-serif));
  font-size: 9px;
  font-weight: 500;
  letter-spacing: 0;
  line-height: 1;
  flex-shrink: 0;
  pointer-events: none;
}
.v-alert-badge.is-on { display: flex; }
/* Dot variant — same badge where the source knows only that something is
   unread, not how much. VANCE exposes unread as a boolean, never a count. */
.v-alert-badge.v-alert-dot { min-width: 9px; width: 9px; height: 9px; padding: 0; }
/* Pinned variant — overlays the top-right of the icon it belongs to. The host
   element must be position:relative. Pages that need a different anchor set
   only position/top/left locally; they never restate the badge's own look. */
.v-alert-badge.v-alert-pin { position: absolute; top: 0; right: 0; }

/* ── Tag — THE squared classification / state marker ───────────────────────
   A chip (.v-chip, vantage-facelift.css) is a 20px capsule and answers "what
   entity / what lifecycle status is this row in". A TAG is the smaller squared
   marker that qualifies a line INSIDE a row: what kind of thing it is, or what
   has already happened to it. Different question, different geometry, so it is
   deliberately not a .v-chip variant.

   Adopted from the proposal builder's .item-type-tag (the CFE / Labor / Fee
   family), which is the shape Nate named as canonical on 2026-08-24. That page
   still carries its local copy; migrating .item-type-tag onto this class is a
   separate change and is noted in the handoff rather than done here.

   Tint comes from ONE variable so a new kind of tag is a hue, not a new rule:
     .v-tag                 neutral   — a classification with no urgency
     .v-tag.is-accent       brand     — the tenant accent
     .v-tag.is-locked       #bb8fce   — immutable, the books already have it
   The locked hue is semantic, not brand-derived, for the same reason the
   status chart in vantage-facelift.css is: "locked" means the same thing
   whatever a tenant rebrands to. */
.v-tag {
  --v-tag-hue: var(--vf-text-muted, var(--muted, #8a9a9a));
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 1px 7px;
  border: 1px solid color-mix(in srgb, var(--v-tag-hue) 34%, transparent);
  border-radius: 3px;
  background: color-mix(in srgb, var(--v-tag-hue) 13%, transparent);
  color: var(--v-tag-hue);
  font-family: var(--font, "Lato", Arial, sans-serif);
  font-size: 10px;
  font-weight: 400;
  line-height: 1.6;
  letter-spacing: 0.02em;
  text-transform: none;
  white-space: nowrap;
  vertical-align: middle;
}
.v-tag.is-accent { --v-tag-hue: var(--vf-accent, var(--gold, #c9a464)); }
.v-tag.is-locked { --v-tag-hue: #bb8fce; }
/* Wrapping variant — a long classification (an overhead account name, a cost
   type) needs to fold rather than blow the column out. */
.v-tag.is-wrap { white-space: normal; }

/* CoastPix-style profile/workspace menu. Identity comes from the existing
   employee profile loader; timezone and weather reuse their canonical Vantage
   controllers instead of maintaining page-local copies. */
body.v-app-shell #vsb-profile-menu {
  position: fixed;
  top: 64px;
  right: 16px;
  z-index: 2050;
  display: none;
  width: min(390px, calc(100vw - 32px));
  max-height: calc(100dvh - 86px);
  overflow-y: auto;
  border: 1px solid var(--v-border);
  border-radius: 18px;
  background: var(--v-surface);
  color: var(--v-text);
  box-shadow: 0 24px 70px rgba(0,0,0,.42);
  font-family: var(--v-font-ui);
}
body.v-app-shell #vsb-profile-menu.open { display: block; }
body.v-app-shell .vsb-pm-head { display: flex; gap: 13px; align-items: center; padding: 18px; border-bottom: 1px solid var(--v-border); }
body.v-app-shell .vsb-pm-avatar { width: 52px; height: 52px; flex: 0 0 52px; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px solid var(--v-border-strong); border-radius: 50%; background: var(--v-surface-raised); color: var(--v-accent); }
body.v-app-shell .vsb-pm-avatar img { width: 100% !important; height: 100% !important; object-fit: cover; }
body.v-app-shell .vsb-pm-identity { min-width: 0; display: flex; flex-direction: column; }
body.v-app-shell .vsb-pm-identity strong { overflow: hidden; color: var(--v-text); font-family: var(--v-font-display); font-size: 19px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
body.v-app-shell .vsb-pm-identity span { margin-top: 2px; color: var(--v-text-muted); font-size: 13px; }
body.v-app-shell .vsb-pm-identity a,
/* Client portal opens Account Settings as a modal rather than navigating to a
   settings route, so the same identity affordance is a button there. Shares one
   declaration with the anchor so the two can never drift apart. */
body.v-app-shell .vsb-pm-identity-action { width: fit-content; margin-top: 5px; color: var(--v-accent); font-size: 11px; font-weight: 500; text-decoration: none; }
body.v-app-shell .vsb-pm-identity-action { padding: 0; border: 0; background: none; font-family: inherit; cursor: pointer; }
body.v-app-shell .vsb-pm-info { display: grid; gap: 12px; padding: 16px 18px; }
/* padding:0 is load-bearing — the Time Zone row is a <button> and every other row
   is a <div>, so the UA's default 1px 6px button padding indented that one row's
   label and value 6px further than its siblings. */
body.v-app-shell .vsb-pm-row { display: grid; width: 100%; grid-template-columns: 20px 82px minmax(0,1fr); gap: 8px; align-items: start; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; }
body.v-app-shell button.vsb-pm-row { cursor: pointer; }
body.v-app-shell .vsb-pm-row > span:not(.vsb-pm-icon) { color: var(--v-text-muted); font-size: 12px; }
body.v-app-shell .vsb-pm-row strong { min-width: 0; overflow-wrap: anywhere; color: var(--v-text); font-size: 12px; font-weight: 500; }
body.v-app-shell .vsb-pm-icon { display: flex; color: var(--v-accent); }
body.v-app-shell .vsb-pm-tz-choices { display: grid; grid-template-columns: repeat(3,1fr); gap: 6px; margin: -4px 0 2px 28px; }
body.v-app-shell .vsb-pm-tz-choices[hidden] { display: none; }
body.v-app-shell .vsb-pm-tz-choices button { min-width: 0; padding: 7px 4px; border: 1px solid var(--v-border); border-radius: 8px; background: var(--v-surface-raised); color: var(--v-text-muted); font: 500 10px/1.2 var(--v-font-ui); cursor: pointer; }
body.v-app-shell .vsb-pm-tz-choices button small { display: block; margin-top: 3px; color: var(--v-text-subtle); font-size: 8px; }
body.v-app-shell .vsb-pm-tz-choices button[data-current="true"] { border-color: var(--v-accent); color: var(--v-text); }
body.v-app-shell .vsb-pm-tz-choices button:disabled { cursor: not-allowed; opacity: .42; }
body.v-app-shell .vsb-pm-weather { padding: 16px 18px; border-top: 1px solid var(--v-border); background: var(--v-surface-inset); }
body.v-app-shell .vsb-pm-section-title { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--v-accent); }
body.v-app-shell .vsb-pm-section-title strong { color: var(--v-text); font-size: 13px; }
body.v-app-shell .vsb-pm-loading { color: var(--v-text-muted); font-size: 12px; line-height: 1.5; }
body.v-app-shell .vsb-pm-weather-now { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
body.v-app-shell .vsb-pm-weather-now div { display: flex; flex-direction: column; gap: 3px; }
body.v-app-shell .vsb-pm-weather-now div strong { color: var(--v-text); font-size: 15px; }
body.v-app-shell .vsb-pm-weather-now div span { color: var(--v-text-muted); font-size: 11px; }
body.v-app-shell .vsb-pm-weather-now > b { color: var(--v-text); font-size: 30px; line-height: 1; }
body.v-app-shell .vsb-pm-weather-metrics { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin-top: 12px; }
body.v-app-shell .vsb-pm-weather-metrics > span { padding: 8px 4px; border-radius: 9px; background: var(--v-surface); text-align: center; }
body.v-app-shell .vsb-pm-weather-metrics small, body.v-app-shell .vsb-pm-weather-metrics strong { display: block; }
body.v-app-shell .vsb-pm-weather-metrics small { color: var(--v-text-muted); font-size: 9px; }
body.v-app-shell .vsb-pm-weather-metrics strong { margin-top: 3px; color: var(--v-text); font-size: 11px; }
body.v-app-shell .vsb-pm-hourly-label { display: block; margin: 14px 0 7px; color: var(--v-text-muted); font-size: 10px; }
body.v-app-shell .vsb-pm-hourly { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 3px; }
body.v-app-shell .vsb-pm-hourly > span { min-width: 52px; padding: 7px 4px; border: 1px solid var(--v-border); border-radius: 8px; background: var(--v-surface); text-align: center; }
body.v-app-shell .vsb-pm-hourly :is(small,b,strong,em) { display: block; font-style: normal; }
body.v-app-shell .vsb-pm-hourly small { color: var(--v-text-muted); font-size: 8px; }
/* Holds the canonical condition SVG (vIcon), not a text glyph — center it and
   let the icon inherit currentColor. */
body.v-app-shell .vsb-pm-hourly b { display: flex; justify-content: center; margin: 3px 0; color: var(--v-text); }
body.v-app-shell .vsb-pm-hourly strong { color: var(--v-text); font-size: 10px; }
body.v-app-shell .vsb-pm-hourly em { margin-top: 2px; color: var(--v-accent); font-size: 8px; }
body.v-app-shell .vsb-pm-action, body.v-app-shell .vsb-pm-customize summary { display: flex; width: 100%; align-items: center; gap: 9px; padding: 13px 18px; border: 0; border-top: 1px solid var(--v-border); background: transparent; color: var(--v-text); font: inherit; text-decoration: none; cursor: pointer; }
body.v-app-shell .vsb-pm-action > span, body.v-app-shell .vsb-pm-customize summary > span:first-child { display: flex; color: var(--v-accent); }
body.v-app-shell .vsb-pm-action strong, body.v-app-shell .vsb-pm-customize strong { font-size: 12px; }
body.v-app-shell .vsb-pm-action small { margin-left: auto; color: var(--v-text-muted); font-size: 10px; line-height: 1.2; font-family: var(--v-font-mono); }
body.v-app-shell .vsb-pm-customize summary { list-style: none; }
body.v-app-shell .vsb-pm-customize summary::-webkit-details-marker { display: none; }
body.v-app-shell .vsb-pm-chevron { margin-left: auto; color: var(--v-text-muted) !important; }
body.v-app-shell .vsb-pm-customize-body { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px 18px 16px; border-top: 1px solid var(--v-border); background: var(--v-surface-inset); }
body.v-app-shell .vsb-pm-customize-body button:not(.vsb-pm-theme-option),
body.v-app-shell .vsb-pm-customize-body a { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 38px; padding: 8px; border: 1px solid var(--v-border); border-radius: 9px; background: var(--v-surface); color: var(--v-text); font: 500 10px var(--v-font-ui); text-decoration: none; cursor: pointer; }

/* Backdrop picker. The two-column grid above is for paired actions, so the
   label, the swatch grid and the motion switch each span the full width. */
body.v-app-shell .vsb-pm-bd-label { grid-column: 1 / -1; margin: 4px 0 -2px; color: var(--v-text-muted); font: 500 10px var(--v-font-ui); letter-spacing: .06em; text-transform: uppercase; }
body.v-app-shell .vsb-pm-bd-grid { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
body.v-app-shell .vsb-pm-customize-body .vsb-pm-bd { flex-direction: column; gap: 4px; min-height: 0; padding: 5px 4px 6px; overflow: hidden; }
body.v-app-shell .vsb-pm-bd canvas { display: block; width: 100%; height: 30px; border-radius: 5px; background: var(--v-surface-inset); }
body.v-app-shell .vsb-pm-bd span { font-size: 9px; line-height: 1; color: var(--v-text-muted); }
body.v-app-shell .vsb-pm-bd:hover { border-color: var(--v-accent); }
body.v-app-shell .vsb-pm-bd.is-on { border-color: var(--v-accent); box-shadow: inset 0 0 0 1px var(--v-accent); }
body.v-app-shell .vsb-pm-bd.is-on span { color: var(--v-text); }
body.v-app-shell .vsb-pm-customize-body .vsb-pm-motion { grid-column: 1 / -1; justify-content: flex-start; }
body.v-app-shell .vsb-pm-motion em { margin-left: auto; font-style: normal; color: var(--v-accent); }
/* The menu owns only placement and the icon/text row. `.v-seg` exclusively
   owns the joined frame, segment sizing, dividers and active presentation. */
body.v-app-shell .vsb-pm-theme-seg { grid-column: 1 / -1; justify-self: start; }
body.v-app-shell .vsb-pm-theme-option { display: inline-flex; align-items: center; gap: 7px; }
body.v-app-shell .vsb-pm-palette-summary { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 3px 1px; color: var(--v-text-muted); font-size: 10px; }
body.v-app-shell .vsb-pm-palette-summary strong { color: var(--v-text); font-size: 10px; font-weight: 500; }
body.v-app-shell .vsb-pm-customize-body .vsb-pm-branding-link { grid-column: 1 / -1; }
@media (max-width: 760px) {
  body.v-app-shell { padding-top: 56px !important; }
  body.v-app-shell #vsb-appbar { left: var(--sb-cw); height: 56px; padding: 0 12px; }
  body.v-ui.v-app-shell .page-hero { padding: 26px 16px 12px !important; }
  body.v-ui.v-app-shell .tabs { padding: 0 10px; }
  body.v-ui.v-app-shell .tabs-wrap { --v-tab-pad-x: 10px; }
  body.v-ui.v-app-shell .tab-btn { min-height: 40px; }
  body.v-ui.v-app-shell .main { padding: 24px var(--v-gutter-sm) 64px; }
  body.v-app-shell #vsb-user #vsb-uinfo { display: none; }
  body.v-app-shell .hero-actions .btn-submit { min-height: 36px; padding: 8px 12px; }
}

@media (max-width: 600px) {
  /* The canonical sidebar becomes a bottom navigation at this breakpoint, so
     the shell utility bar must stop reserving the collapsed desktop rail. */
  body.v-app-shell { --v-appbar-h: 54px; padding-top: 54px !important; }
  :is(body.v-ui, body.v-app-shell) { --v-drawer-header-height: 54px; }
  /* Must carry `.sb-off` too. Mobile always sets that class (vantage-sidebar.js
     applyCollapsed), and `body.sb-off.v-app-shell #vsb-appbar` above is (0,2,1) —
     a plain `body.v-app-shell` rule here loses to it no matter the breakpoint, so
     the bar kept the rail inset while the rail itself was display:none. */
  body.v-app-shell #vsb-appbar,
  body.sb-off.v-app-shell #vsb-appbar { left: 0; height: 54px; }
  body.v-app-shell #vnc-backdrop { inset-block-start: 54px; }
  body.v-app-shell #vnc-panel { top: 54px; width: 100vw; }
  body.v-app-shell #vsb-profile-menu { top: 60px; right: 8px; width: calc(100vw - 16px); max-height: calc(100dvh - 68px); border-radius: 14px; }
}

/* Page shell */
body.v-ui .page-hero {
  padding: 100px 40px 32px;
  background: var(--bg-mid);
  border-bottom: 1px solid var(--v-border, var(--gold-dim));
}

body.v-ui .page-hero-inner {
  /* width:100% PLUS horizontal padding only adds up under border-box. Most page
     stylesheets happen to ship a `* { box-sizing: border-box }` reset, so this
     component silently inherited the right box model from whichever page was
     hosting it. The pages whose CSS was rewritten without that reset overflowed
     the hero band by exactly 2 × --v-gutter and pushed .hero-actions off-screen.
     A canonical component owns its own box model rather than borrowing one. */
  box-sizing: border-box;
  width: 100%;
  max-width: var(--v-content-max);
  margin: 0 auto;
  padding: 0 40px;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--v-space-5);
}

body.v-ui .hero-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
}

body.v-ui .v-breadcrumbs {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0 0 8px;
  color: var(--v-text-muted);
  font-size: 12px;
  line-height: 1.4;
}
body.v-ui .v-breadcrumbs a {
  color: inherit;
  text-decoration: none;
}
body.v-ui .v-breadcrumbs a:hover { color: var(--v-text); }
body.v-ui .v-breadcrumbs [aria-current="page"] { color: var(--v-text); }
body.v-ui .v-breadcrumb-separator { color: var(--v-text-subtle); }

body.v-ui .page-hero.v-context-hero {
  padding-top: 48px;
  padding-bottom: 20px;
}

body.v-ui .v-context-hero .hero-actions > button {
  box-sizing: border-box;
  height: var(--v-control-height);
  min-height: var(--v-control-height);
  padding-top: 0 !important;
  padding-bottom: 0 !important;
}

body.v-ui .v-context-meta {
  margin-top: 6px;
  color: var(--muted);
  font-size: 12px;
  line-height: 1.4;
}

body.v-ui .v-context-meta .v-context-status {
  color: var(--gold-dim-text, var(--gold-dim));
}

body.v-ui .v-page-actions,
body.v-ui .v-ledger-section-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--v-space-3);
}

body.v-ui .v-page-actions { justify-content: flex-end; }

/* Canonical collapsed-search trigger. Keep icon-only search actions square and
   on the same control-height rhythm as adjacent filters and segmented controls. */
body.v-ui .v-search-toggle {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 var(--v-control-height);
  width: var(--v-control-height);
  height: var(--v-control-height);
  min-width: var(--v-control-height);
  min-height: var(--v-control-height);
  padding: 0 !important;
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-md);
  background: transparent;
  color: var(--gold);
  font-family: var(--font);
}

body.v-ui .v-search-toggle:hover,
body.v-ui .v-search-toggle:focus-visible {
  border-color: var(--gold);
  outline: none;
  box-shadow: 0 0 0 3px rgba(var(--gold-rgb), 0.12);
}

/* Canonical search disclosure. vantage-ui.js automatically upgrades standard
   .tab-search-wrap controls; data-v-search-disclosure remains available for
   non-standard wrappers. */
body.v-ui .v-search-disclosure {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--v-space-2);
}
body.v-ui .v-search-disclosure .v-search-field {
  position: relative;
  width: min(560px, calc(100% - var(--v-control-height) - var(--v-space-2)));
  max-width: 0;
  overflow: hidden;
  visibility: hidden;
  opacity: 0;
  transform: translateX(6px);
  transition: max-width .2s ease, opacity .16s ease, transform .2s ease, visibility 0s linear .2s;
}
body.v-ui .v-search-disclosure.is-open .v-search-field {
  max-width: 560px;
  overflow: visible;
  visibility: visible;
  opacity: 1;
  transform: none;
  transition-delay: 0s;
}
body.v-ui .v-search-disclosure .v-search-field input { padding-left: var(--v-space-4); }
/* Canonical active-state dot for icon-only triggers. An icon alone cannot say
   "this control is narrowing the list", so the trigger carries a dot. Consumed
   by the search disclosure (has-query) and by any icon trigger marked
   .v-toggle-dot.is-active — e.g. a collapsed filter button. */
body.v-ui .v-search-disclosure .v-search-toggle,
body.v-ui .v-toggle-dot { position: relative; }
body.v-ui .v-search-disclosure.has-query .v-search-toggle::after,
body.v-ui .v-toggle-dot.is-active::after {
  content: '';
  position: absolute;
  top: 5px;
  right: 5px;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--v-accent);
}

body.v-ui .v-table > thead > tr > th {
  padding-block: 10px;
  min-height: 0;
}

/* A long page still needs a vertical scrollbar. In strict light mode keep the
   affordance neutral and quiet rather than exposing the browser's white rail. */
html[data-theme="light"] { scrollbar-color: color-mix(in srgb, var(--muted) 46%, transparent) transparent; }
html[data-theme="light"] ::-webkit-scrollbar { width: 10px; height: 10px; }
html[data-theme="light"] ::-webkit-scrollbar-track { background: transparent; }
html[data-theme="light"] ::-webkit-scrollbar-thumb {
  border: 3px solid transparent;
  border-radius: 999px;
  background: color-mix(in srgb, var(--muted) 46%, transparent);
  background-clip: content-box;
}
body.v-ui .v-ledger-section-header { margin-bottom: 14px; }

body.v-ui .v-action-menu {
  position: fixed;
  z-index: 320;
  display: none;
  width: min(280px, calc(100vw - 24px));
  padding: 8px;
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-md);
  background: var(--bg-mid);
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.42);
}

body.v-ui .v-action-menu.is-open { display: block; }

body.v-ui .v-action-menu-label {
  padding: 6px 8px 8px;
  color: var(--muted);
  font-size: 9px;
  }

body.v-ui .v-action-menu button {
  display: flex;
  width: 100%;
  align-items: center;
  gap: 9px;
  padding: 9px 10px;
  border: 0;
  border-radius: 5px;
  background: transparent;
  color: var(--gold-light);
  font: 12px/1.35 var(--font);
  text-align: left;
  cursor: pointer;
}

body.v-ui .v-action-menu button:hover,
body.v-ui .v-action-menu button:focus-visible {
  background: rgba(var(--gold-rgb), 0.08);
  color: var(--gold);
  outline: none;
}

/* Identifiers, codes, and amounts only — never body copy. */
body.v-ui .v-mono {
  font-family: var(--v-font-mono);
  font-size: 0.92em;
  letter-spacing: 0.01em;
}

body.v-ui .main {
  box-sizing: border-box;
  width: 100%;
  max-width: var(--v-content-max);
  margin: 0 auto;
  padding: 48px 40px 80px;
}

body.v-ui .tab-panel { display: none; }
body.v-ui .tab-panel.active { display: block; }

/* Tabs */
body.v-ui .tabs-wrap {
  position: sticky;
  top: 0;
  z-index: 50;
  overflow-x: auto;
  background: var(--bg-mid);
  border-bottom: 1px solid var(--v-border, var(--gold-dim));
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

/* The tab strip sits on the SAME gutter as the hero and .main, so tabs line up
   with the page title above them and the content below.

   This lived in admin-contracts-shell.css — a page-specific sheet — until
   2026-08-03, which meant admin-contracts was the only page whose tabs were on
   the gutter. Every other consumer rendered them flush to the viewport edge
   (Nate, on the VMeet pages: *"both pages have the tabs off to the left instead
   of aligned with the hero"*). It is a property of the canonical tab strip, not
   of one page, so it belongs here. */
body.v-ui .tabs-wrap {
  box-sizing: border-box;
  width: 100%;
  max-width: var(--v-content-max);
  margin: 0 auto;
  padding: 0 var(--v-gutter);
}
@media (max-width: 768px) {
  body.v-ui .tabs-wrap { padding: 0 var(--v-gutter-sm); }
}

/* THE page row. Any band that sits between the hero title and `.main` - a
   view switcher, a filter strip, a toolbar - is a full-bleed band whose INNER
   content box must be this, or it does not line up with the page.

   The contract itself is old (see the --v-gutter note at the top): centred box,
   capped at --v-content-max, gutter inside. What was missing was a NAME for it,
   so `.page-hero-inner`, `.tabs-wrap`, `.main` and `.tabs` each hand-rolled the
   same five declarations and anything new got only the gutter. A gutter alone
   is not alignment: above --v-content-max the centred boxes move inward by
   (content - 1400) / 2 and the gutter-only row stays put. On a 1760px window
   that is 152px of daylight - the VMeet hub's Calendar|List and filter rows sat
   exactly that far left of their own page title for three weeks
   (Nate, 2026-08-23, having pointed at it five times).

   Use this class. Do not re-derive it, and never "fix" a misaligned row by
   giving it padding. */
body.v-ui .v-page-row {
  box-sizing: border-box;
  width: 100%;
  max-width: var(--v-content-max);
  margin: 0 auto;
  padding: 0 var(--v-gutter);
}
@media (max-width: 768px) {
  body.v-ui .v-page-row { padding: 0 var(--v-gutter-sm); }
}

/* OPTICAL alignment, not box alignment. A tab is a button with its own side
   padding, so putting the STRIP on the gutter still left the first tab's LABEL
   indented by that padding — 10px out from the hero title on admin-contracts,
   12px on the VMeet pages (Nate, 2026-08-04: *"the tabs off to the left instead
   of aligned with the hero"* — he was seeing the same defect from the other
   side). Cancel exactly one tab's padding so the first label lands on the
   gutter, and the strip still scrolls from the true edge. */
body.v-ui.v-app-shell .tabs-wrap {
  padding-left: calc(var(--v-gutter) - var(--v-tab-pad-x, 12px));
}
@media (max-width: 768px) {
  body.v-ui.v-app-shell .tabs-wrap {
    padding-left: calc(var(--v-gutter-sm) - var(--v-tab-pad-x, 10px));
  }
}

body.v-ui .tabs-wrap.v-can-scroll-left {
  box-shadow: inset 22px 0 18px -20px rgba(var(--gold-rgb), 0.8);
}

body.v-ui .tabs-wrap.v-can-scroll-right {
  box-shadow: inset -22px 0 18px -20px rgba(var(--gold-rgb), 0.8);
}

body.v-ui .tabs-wrap.v-can-scroll-left.v-can-scroll-right {
  box-shadow: inset 22px 0 18px -20px rgba(var(--gold-rgb), 0.8), inset -22px 0 18px -20px rgba(var(--gold-rgb), 0.8);
}

body.v-ui .tabs-wrap::-webkit-scrollbar { display: none; }

body.v-ui .tabs {
  display: flex;
  /* border-box is REQUIRED, not cosmetic. With width:100% and horizontal
     padding this box is otherwise (wrap + padding) wide and can never fit its
     .tabs-wrap — so vantage-ui.js measures scrollWidth > clientWidth forever,
     pins .v-has-overflow / .v-can-scroll-right, and the tab bar sits in a
     permanent sideways-scroll state with the fade cue always showing. It was
     off by exactly the padding (56px in the app shell, 80px elsewhere).
     Pages that ship a `* { box-sizing: border-box }` reset masked it, which is
     why the tab bar looked different from page to page. A canonical component
     owns its own box model and never borrows one from the host page. */
  box-sizing: border-box;
  width: 100%;
  max-width: var(--v-content-max);
  margin: 0 auto;
  padding: 0 40px;
}

body.v-ui .tab-btn {
  margin-bottom: -1px;
  padding: 16px 24px;
  border: 0;
  border-bottom: 1px solid transparent;
  background: transparent;
  color: var(--gold-light);
  font-family: var(--font);
  font-size: 13px;
  /* Title Case, not caps — §2 (facelift already renders tabs Title Case on
     v-face pages; the base tier converged under the C28 canon census) */
  white-space: nowrap;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s, background 0.2s;
}

body.v-ui .tab-btn:hover { color: var(--gold); }
body.v-ui .tab-btn.active {
  color: var(--gold);
  border-bottom-color: var(--gold);
}

body.v-ui .tab-btn:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: -3px;
}

/* Dashboard summary strip */
/* .summary-strip / .summary-inner / .summary-stat DELETED 2026-08-03.
   They were a third stat family in this file, alongside .v-kpi-* and
   .stat-label/.stat-val — which is why page-local strips kept reappearing in
   new spellings: the canon was never one source of truth for a KPI. Their only
   consumer was admin-finance, which had them double-classed onto .v-kpi-*
   already, so every declaration here was inert (this block sits above the KPI
   family, and on a specificity tie the later rule wins). The one thing the
   family really provided was a 24px gutter, now .finance-kpi-wrap in
   admin-finance.css. Do not reintroduce a second KPI family. */
/* KPI label — letterspaced but NOT uppercase (caps live only in th; ruling
   2026-07-25 rev: this rule was a canon-level eyebrow the page scans never saw) */
body.v-ui .stat-label {
  margin-bottom: var(--v-space-1);
  color: var(--muted);
  font-size: 10px;
  letter-spacing: 2px;
}
body.v-ui .stat-val { color: var(--gold); font-size: var(--v-fs-figure); font-weight: 400; }
body.v-ui .stat-val.green { color: var(--green); }
body.v-ui .stat-val.red { color: var(--red); }
body.v-ui .stat-sub { margin-top: 2px; color: var(--muted); font-size: 10px; }

body.v-ui .panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--v-space-3);
  margin-bottom: var(--v-space-6);
}

body.v-ui .btn-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--v-space-2);
}

/* Surfaces and forms */
body.v-ui .form-card,
body.v-ui .upload-card {
  max-width: 700px;
  padding: 36px 40px;
  background: var(--bg-mid);
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-lg);
}

body.v-ui .form-card { max-width: 600px; }

body.v-ui .form-row {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--v-space-5);
}

body.v-ui .form-group { margin-bottom: var(--v-space-5); }


/* The canonical required-field marker is `.v-req` — <span class="v-req">*</span>.
   It is defined in vantage-theme.css, NOT here, because this sheet reaches only
   70 of 127 pages and a required marker has to work on every form the platform
   renders. Do not add a second definition here. */

body.v-ui input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="range"]),
body.v-ui select,
body.v-ui textarea {
  width: 100%;
  min-height: var(--v-control-height);
  padding: var(--v-control-pad-y) var(--v-control-pad-x);
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-md);
  outline: none;
  /* background-COLOR, never the shorthand, in any select-matching rule — the
     shorthand wipes the canonical caret (background-image) below. C21. */
  background-color: var(--bg-light);
  color: var(--gold-light);
  font-family: var(--font);
  /* A typed value is the same fact a table cell shows, so it takes the same
     step (Nate ruling 2026-08-27). This was 14px, the last off-scale size in
     the canon's own field chrome, and it disagreed with the 13px that pages
     like Settings were already setting on their own inputs. */
  font-size: var(--v-fs-body);
  line-height: 1.4;
  transition: border-color 0.16s, box-shadow 0.16s, background 0.16s;
}

/* Keep native select popups in the active application color scheme. Without
   this, Chromium can paint the selected menu option with a light OS default
   even though the closed control and its options are themed.
   The same applies to the native pickers on date/time/number inputs (the calendar
   popup and the spinner arrows) — without color-scheme they paint light-on-light
   inside a dark field. Pages used to work around this with an inline
   `style="color-scheme:dark"`, which hardcodes dark and breaks light mode; this
   rule is the root fix, so no field needs that inline hack.

   date and time no longer OPEN a native popup — VantageDate and VantageTime
   replace both app-wide. Keep them listed anyway: those modules load async, so
   the native control is what paints for the first few hundred milliseconds, and
   this is the only thing standing between a dark page and a white flash in that
   window (or permanently, if a picker script ever fails to load). The entries
   are a first-paint fallback now, not the popup fix they were written for.
   month, week, datetime-local and number have no picker and are still fully
   native. */
body.v-ui select,
body.v-ui input[type="date"],
body.v-ui input[type="datetime-local"],
body.v-ui input[type="month"],
body.v-ui input[type="week"],
body.v-ui input[type="time"],
body.v-ui input[type="number"] { color-scheme: dark; }
html[data-theme="light"] body.v-ui select,
html[data-theme="light"] body.v-ui input[type="date"],
html[data-theme="light"] body.v-ui input[type="datetime-local"],
html[data-theme="light"] body.v-ui input[type="month"],
html[data-theme="light"] body.v-ui input[type="week"],
html[data-theme="light"] body.v-ui input[type="time"],
html[data-theme="light"] body.v-ui input[type="number"] { color-scheme: light; }

body.v-ui textarea {
  min-height: 96px;
  padding: 10px 12px;
  resize: vertical;
}

/* The canonical LAUNCHER field — a control that measures and skins exactly like
   the select above but opens a modal picker instead of a native popup — is
   `.v-pick`, and it is defined in vantage-theme.css, NOT here, for the same
   reason `.v-req` is: this sheet reaches 70 of 127 pages, and the modules that
   render a launcher (vantage-place-order.js, po-add-items.js,
   vantage-reallocate.js) inject themselves into legacy pages that load only the
   theme sheet. Do not add a second definition here. */

/* A field holding CODE or an identifier opts into the canonical mono role
   (§2 permits monospace for identifiers and code). `.v-mono` alone loses to the
   field font declaration above on source order, so the field tier of the same
   canonical role is stated here — the ONE place field typography lives. First
   consumer: the raw-HTML email body editor on admin-templates (2026-07-26). */
body.v-ui :is(input, textarea, select).v-mono {
  font-family: var(--v-font-mono);
  font-size: 12px;
  line-height: 1.55;
}

/* Dense accounting grids, table filters, and inline editors opt in explicitly.
   Compact controls stay compact on touch devices; their surrounding row supplies
   the larger tap target when one is needed. */
body.v-ui input.v-control-compact:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]),
body.v-ui select.v-control-compact {
  height: var(--v-control-height-compact) !important;
  min-height: var(--v-control-height-compact) !important;
  padding: 4px 8px !important;
  border-radius: var(--v-radius-sm);
  font-size: 12px;
  line-height: 1.2;
  box-sizing: border-box;
}

body.v-ui input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="range"]):hover,
body.v-ui select:hover,
body.v-ui textarea:hover { border-color: var(--gold); }

body.v-ui input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="range"]):focus,
body.v-ui select:focus,
body.v-ui textarea:focus {
  border-color: var(--gold);
  box-shadow: 0 0 0 3px rgba(var(--gold-rgb), 0.12);
}

body.v-ui input::placeholder,
body.v-ui textarea::placeholder { color: var(--muted); }

body.v-ui select option {
  background: var(--bg-mid);
  color: var(--gold-light);
}

body.v-ui select option:checked {
  background: var(--gold);
  color: var(--bg);
}

/* Dialog-title LAYOUT (C23). Typography is owned by the .modal-title rule
   further down this file — this one sets none, so there is a single owner.

   BOTH header bands, not just the modal: they are centred flex rows, so any
   margin on the title pushes it off-centre inside the band. The drawer was
   missing from this rule, and a title written as a heading tag kept the
   browser's default 0.83em top margin on top of the role's 8px margin — 6.8px
   low in a 64px band, which is what Nate saw on the Notifications drawer
   (2026-08-27). Two drawers had already patched around it with an inline
   `margin:0`; those one-offs are deleted now that the band owns it. */
body.v-ui .modal-header .modal-title,
body.v-ui .v-drawer-header .modal-title {
  margin: 0;
}

/* A HEADER BAND OWNS ITS TITLE'S MARGIN — every band, every title role.
   A band is a centred flex row, so a margin on the title both pushes it
   off-centre and inflates the band. The section role carries `0 0 14px`
   because it is usually a standalone heading with content under it; inside a
   band that margin is simply wrong.

   This generalises what the two rules above already do for the dialog title.
   `.v-panel-header :is(h2, h3, .title)` further down covers only heading TAGS
   in one band — a title written as a div, or any band that is not
   .v-panel-header, fell through. That is what put "Profile Photo" 15px high in
   its card head and made the band 75px instead of 60px (Nate, 2026-08-27).

   `.card-head` and `.panel-header` are legacy band names pending conversion to
   .v-panel-header; they are listed so the fix reaches them today rather than
   after that conversion. Remove them with their last consumer. */
body.v-ui :is(.modal-header, .v-drawer-header, .v-panel-header, .panel-header, .card-head)
  :is(.v-section-title, .section-title, .card-title, .modal-title, .v-page-title) {
  margin: 0;
}

/* NO BOLD — app-wide weight ceiling (Nate ruling 2026-07-25: "kill every bold
   in this app"). The heaviest weight anywhere is 500 (the approved
   customer-editor label weight); hierarchy comes from size and color, never
   weight. This resets every browser-default-bold element; design-contract C22
   bans authored font-weight 600+/bold. */
body.v-ui :is(h1, h2, h3, h4, h5, h6, th, b, strong, dt, legend, label) {
  font-weight: 500;
}

/* Canonical inline GHOST edit (Nate ruling 2026-07-26, proposal-builder area/
   option names): an in-place editable value whose box appears ONLY on hover/
   focus — reads as text, edits like a field. Selector deliberately out-weighs
   the field contract above (same :not() count + extra class). Typography
   inherits from the surrounding text role. */
body.v-ui input.v-ghost-input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]) {
  width: auto;
  min-height: 0 !important;
  height: auto !important;
  padding: 2px 6px !important;
  /* !important: the ghost is a deliberate exception to the field contract —
     facelift's later field rule ties on specificity and was repainting the
     box at rest (Nate, 2026-07-26) */
  background-color: transparent !important;
  border: 1px solid transparent !important;
  border-radius: var(--v-radius-sm) !important;
  font-family: inherit;
  font-size: inherit;
  color: inherit;
  line-height: inherit;
  box-shadow: none !important;
  transition: border-color 0.15s, background-color 0.15s;
}
body.v-ui input.v-ghost-input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):hover {
  border-color: var(--v-border, var(--gold-dim)) !important;
  background-color: rgba(var(--gold-rgb), 0.05) !important;
}
body.v-ui input.v-ghost-input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):focus {
  border-color: var(--gold) !important;
  background-color: var(--bg-light) !important;
}

/* Canonical dropdown chrome (ruling 2026-07-25): never the native chevron.
   The caret is drawn with two gradient tiles so its color is var(--muted) and
   cascades with tenant themes — a data-URI SVG could not use a token. Any
   sheet overriding select surfaces must set background-COLOR, never the
   background shorthand, or it wipes this caret. */
body.v-ui select:not([multiple]) {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 32px;
  background-image:
    linear-gradient(45deg, transparent 50%, var(--muted) 50%),
    linear-gradient(135deg, var(--muted) 50%, transparent 50%);
  background-position: calc(100% - 17px) calc(50% - 2px), calc(100% - 12px) calc(50% - 2px);
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}

body.v-ui select.v-control-compact:not([multiple]) {
  padding-right: 26px !important;
  background-position: calc(100% - 15px) calc(50% - 2px), calc(100% - 10px) calc(50% - 2px);
}

body.v-ui input:disabled,
body.v-ui select:disabled,
body.v-ui textarea:disabled {
  cursor: not-allowed;
  opacity: 0.58;
}

/* Native choice controls are part of the app-wide brand foundation, not only
   the pages that have opted into the broader body.v-ui form migration. Pages
   loading vantage-brand.js load this sheet; marketing pages intentionally do
   not, so the tenant accent remains safely scoped to application surfaces. */
body input[type="checkbox"],
body input[type="radio"] {
  accent-color: var(--gold);
}

/* THE canonical checkbox/radio (Nate ruling 2026-07-25, from the catalog
   filter sidebar: "make this canonical" — the drawn outline box is far easier
   to see than the native control). Custom-drawn, token-colored (the catalog
   original hardcoded green — canon uses the brand accent so tenant rebrands
   cascade). Canon OWNS choice-control chrome (C24): pages/modules never
   restyle it. Escape hatch: .v-native keeps the accent-color native control. */
body.v-ui input[type="checkbox"]:not(.v-native),
body.v-ui input[type="radio"]:not(.v-native) {
  appearance: none;
  -webkit-appearance: none;
  box-sizing: border-box;
  /* !important: legacy inline width:auto (a counter to the old input
     width:100%) collapses an appearance:none box to a sliver — the drawn
     control's size is non-negotiable */
  width: 16px !important;
  height: 16px !important;
  min-height: 16px !important;
  padding: 0 !important;
  flex-shrink: 0;
  margin: 0;
  position: relative;
  border: 1.5px solid var(--gold-dim);
  border-radius: 4px;
  background: transparent;
  cursor: pointer;
  vertical-align: middle;
  transition: background 0.12s, border-color 0.12s;
}
body.v-ui input[type="radio"]:not(.v-native) { border-radius: 50%; }
body.v-ui input[type="checkbox"]:not(.v-native):hover,
body.v-ui input[type="radio"]:not(.v-native):hover { border-color: var(--gold); }
body.v-ui input[type="checkbox"]:not(.v-native):focus-visible,
body.v-ui input[type="radio"]:not(.v-native):focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px rgba(var(--gold-rgb), 0.18);
}
body.v-ui input[type="checkbox"]:not(.v-native):checked,
body.v-ui input[type="radio"]:not(.v-native):checked {
  background: var(--gold);
  border-color: var(--gold);
}
body.v-ui input[type="checkbox"]:not(.v-native):checked::after {
  content: '';
  position: absolute;
  top: 1.5px;
  left: 4.5px;
  width: 4px;
  height: 8px;
  border: 1.5px solid var(--bg);
  border-top: none;
  border-left: none;
  transform: rotate(45deg);
}
body.v-ui input[type="radio"]:not(.v-native):checked::after {
  content: '';
  position: absolute;
  inset: 3.5px;
  border-radius: 50%;
  background: var(--bg);
}
body.v-ui input[type="checkbox"]:not(.v-native):disabled,
body.v-ui input[type="radio"]:not(.v-native):disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

/* THE canonical ON/OFF SWITCH (2026-07-26, admin-templates conversion). A
   boolean that takes effect IMMEDIATELY (module on/off, template active) reads
   as a switch, not a checkbox — but every page carrying one had its own copy of
   the geometry (admin-settings, admin-network, admin-templates, the proposal
   family). This is that geometry promoted once, unchanged from the
   admin-settings reference so no consumer reflows: 44×24 track, 18px thumb,
   gold when on. Structure (the hidden input is the state, never the chrome):

     <label class="toggle-switch">
       <input type="checkbox" aria-label="…">
       <span class="toggle-track"><span class="toggle-thumb"></span></span>
     </label>

   Pages consume it and delete their local copies. The input is display:none, so
   the C24 drawn-checkbox chrome above never applies to it. */
body.v-ui .toggle-switch { flex-shrink: 0; cursor: pointer; }
body.v-ui .toggle-switch input { display: none; }
body.v-ui .toggle-track {
  position: relative;
  display: inline-flex;
  align-items: center;
  width: 44px;
  height: 24px;
  border: 1px solid rgba(var(--gold-rgb), 0.25);
  border-radius: 12px;
  background: var(--bg);
  transition: background 0.2s, border-color 0.2s;
}
body.v-ui .toggle-thumb {
  position: absolute;
  left: 2px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--muted);
  transition: background 0.2s, transform 0.2s;
}
body.v-ui .toggle-switch input:checked + .toggle-track {
  background: rgba(var(--gold-rgb), 0.18);
  border-color: var(--gold);
}
body.v-ui .toggle-switch input:checked + .toggle-track .toggle-thumb {
  background: var(--gold);
  transform: translateX(20px);
}
body.v-ui .toggle-switch input:focus-visible + .toggle-track {
  outline: none;
  box-shadow: 0 0 0 3px rgba(var(--gold-rgb), 0.18);
}

/* THE labelled choice row — one choice control (canonical checkbox, radio, or
   .toggle-switch) beside the text that names it, with an optional .v-choice-hint
   under that text. Promoted 2026-08-05 from the byte-identical private copies in
   admin-recurring.css and admin-vendors.css (both `.check-row`), which are
   deleted; those two pages are the verified consumers.

     <label class="v-choice-row"><input type="checkbox"> Active</label>

     <div class="v-choice-row">
       <label class="toggle-switch">…</label>
       <div class="v-choice-copy">
         <div class="v-choice-label">Active</div>
         <div class="v-choice-hint">Available for new subscriptions.</div>
       </div>
     </div>

   Control chrome still comes from C24 / .toggle-switch — this owns the ROW only
   (alignment, gap, tap height). Known un-migrated relative: admin-settings'
   `.module-row`, which adds a settings-list divider treatment on top of the same
   idea; folding it in is a separate, page-wide change. */
body.v-ui .v-choice-row {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: var(--v-control-height);
  font-size: 13px;
  line-height: 1.45;
  color: var(--gold-light);
  cursor: pointer;
}
body.v-ui .v-choice-row + .v-choice-row { margin-top: 4px; }
/* A row carrying a hint is two lines tall, so the control aligns to the label
   rather than to the block's centre. */
body.v-ui .v-choice-row:has(.v-choice-hint) { align-items: flex-start; }
body.v-ui .v-choice-row:has(.v-choice-hint) > :is(label, input) { margin-top: 3px; }
body.v-ui .v-choice-copy { min-width: 0; }
body.v-ui .v-choice-label { color: var(--gold-light); }
body.v-ui .v-choice-hint { font-size: 11.5px; color: var(--muted); margin-top: 2px; }

/* Card modifier for the choice row — the whole bordered block is the hit target
   and the selected one is filled. For a small set of mutually exclusive answers
   presented as a stack ("what should happen to the attachments?"), where the row
   alone reads as too weak a target. Compose it onto the row, never alone:

     <label class="v-choice-row v-choice-card">
       <input type="radio" name="…" value="…">
       <div class="v-choice-copy">
         <div class="v-choice-label">Keep existing attachments</div>
         <div class="v-choice-hint">Replace only the parent item.</div>
       </div>
     </label>

   Promoted 2026-08-18 from admin-proposal-builder.css's private `.swap-opt-row`
   (deleted) when the design page's Swap Item needed the same three-answer stack.
   Verified consumers: admin-proposal-builder (Swap Item), admin-designs (Swap
   Item). Radio chrome stays with C24 — this owns the card only. */
body.v-ui .v-choice-card {
  padding: 12px 14px;
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: 8px;
  transition: border-color 0.15s, background 0.15s;
}
body.v-ui .v-choice-card:hover { border-color: var(--gold); background: rgba(var(--gold-rgb), 0.04); }
body.v-ui .v-choice-card:has(input:checked) { border-color: var(--gold); background: rgba(var(--gold-rgb), 0.08); }
body.v-ui .v-choice-card + .v-choice-card { margin-top: 10px; }

body.v-ui input[type="number"]::-webkit-outer-spin-button,
body.v-ui input[type="number"]::-webkit-inner-spin-button {
  margin: 0;
  -webkit-appearance: none;
}

/* Financial grids intentionally use smaller numeric editors than full forms. */
body.v-ui input.qty-input {
  width: 72px;
  min-height: 30px;
  padding: 5px 8px;
  text-align: center;
}

body.v-ui input.lc-edit-input {
  width: 38px;
  min-height: 26px;
  padding: 2px 4px;
  text-align: center;
}

body.v-ui input.bom-qty-input {
  width: 58px;
  min-height: 30px;
  padding: 4px 6px;
  text-align: center;
}

/* Canonical summary KPI family. Consumers own metric content and behavior;
   this shared contract owns grid rhythm, surface geometry, and type roles. */
/* Density retightened 2026-08-02 (Nate ruling). The complaint was "massive
   cards"; the answer is these numbers, NOT a second compact stat family. The
   `.stat-pills` strip that had grown page-locally in admin-change-orders and
   admin-proposals was retired in the same change — two families for one concept
   is exactly the drift G-1 exists to stop.

   Left-justified is deliberate and was ruled on with both options rendered:
   the first card lands on the same content edge as the hero title, the list
   toolbar and the table. Do NOT add justify-content:center — pages carry 3 to
   6 KPIs, so centering makes the row's start position depend on the card count
   and the page appears to shift as you move between modules. It also centers
   each wrapped row independently, so nothing lines up vertically either. */
body.v-ui .v-kpi-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 170px));
  gap: 10px;
  max-width: 100%;
  margin-bottom: var(--v-space-6);
}
@media (max-width: 600px) {
  body.v-ui .v-kpi-grid { grid-template-columns: minmax(0, 1fr); }
}
body.v-ui .v-kpi-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* ONE gap owns the whole internal rhythm — see .v-kpi-value below. */
  gap: var(--v-space-1);
  min-height: 72px;
  padding: 12px 14px;
  border: 1px solid var(--v-border);
  border-radius: var(--v-radius-lg);
  background: transparent;
  text-align: center;
}
body.v-ui :is(a, button).v-kpi-card {
  color: inherit;
  font: inherit;
  text-decoration: none;
  cursor: pointer;
  transition: border-color 0.2s, background 0.2s, box-shadow 0.2s, transform 0.2s;
}
body.v-ui :is(a, button).v-kpi-card:hover {
  border-color: var(--v-accent);
  background: color-mix(in srgb, var(--v-accent) 7%, transparent);
  box-shadow: 0 2px 12px rgba(var(--gold-rgb), 0.18);
  transform: translateY(-1px);
}
body.v-ui button.v-kpi-card {
  color: inherit;
  font: inherit;
  cursor: pointer;
  transition: border-color 0.2s, background 0.2s;
}
body.v-ui button.v-kpi-card:hover {
  border-color: var(--gold);
  background: var(--bg-light);
}
/* NO MARGINS in this family. The card's `gap` is the single number that owns
   the internal rhythm, and it must stay that way.

   Why (Nate, 2026-08-02): the value used to carry `margin: 2px 0 4px` and the
   sub `margin-top: 4px` — three independent numbers nobody had reconciled. Add
   half-leading (the 16px label's line-height left 2px under its glyphs, the
   12px sub 2.4px over its own) and the measured optical gaps were 4.0px above
   the number and 10.4px below it: the number sat 2.6x nearer its own title than
   its caption, on every KPI on every page. One gap makes that impossible to
   reintroduce, and it also balances a card that has no sub line — the old
   margins left those hanging.

   If a card needs more air, change the card's `gap`. Never add a margin here. */
body.v-ui .v-kpi-value {
  margin: 0;
  color: var(--gold);
  font-size: var(--v-fs-figure);
  font-weight: 400;
  line-height: 1;
}
/* The KPI card's top line is that card's heading, so it carries the same
   typography as .v-section-title rather than a letterspaced eyebrow — eyebrow
   roles were retired 2026-07-25 and the 2px tracking was the last of them in
   this family (Nate 2026-07-28: KPI titles must match the panel titles below). */
body.v-ui .v-kpi-label {
  color: var(--gold-light);
  font-family: var(--font);
  font-size: var(--v-fs-section);
  font-weight: 500;
  line-height: 1.25;
  letter-spacing: 0;
  text-transform: none;
}
body.v-ui .v-kpi-sub {
  margin: 0;
  color: var(--muted);
  font-size: var(--v-fs-micro);
  font-weight: 400;
  line-height: 1.4;
  overflow-wrap: break-word;
}
/* A KPI card is a fixed-width grid cell (140-170px) whose children are flex
   items in a centered column, so each child's base width is its max-content
   width. Nothing clamped that, so any line longer than the card - a milestone
   name, a long sub caption - laid itself out straight through the border and
   rendered outside the card (Nate, 2026-08-28: admin-projects "Next Milestone").
   The card owns the clamp for every consumer; a page must not solve this again
   locally, and must not "fix" an overflow with white-space:nowrap. */
body.v-ui .v-kpi-card > * {
  max-width: 100%;
  min-width: 0;
}
/* Compact tier — a KPI row that sits BELOW a page's primary summary strip
   (e.g. the PO row on a project's finance page, under the project KPIs).
   Sized to the invoice-tab stat cards Nate ruled read best for a sub-row:
   full-width fluid cells, smaller heading, 18px figure. Everything not set
   here inherits from the family above — colors, states, the child clamp. */
body.v-ui .v-kpi-grid.is-compact {
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 12px;
}
body.v-ui .v-kpi-grid.is-compact .v-kpi-card {
  min-height: 0;
  padding: 14px 18px;
  gap: 4px;
}
body.v-ui .v-kpi-grid.is-compact .v-kpi-label {
  font-size: var(--v-fs-micro);
  font-weight: 400;
  color: var(--muted);
}
body.v-ui .v-kpi-grid.is-compact .v-kpi-value {
  font-size: 18px;
  font-weight: 300;
}
body.v-ui .v-kpi-grid.is-compact .v-kpi-sub {
  font-size: 10px;
}

/* .v-context-bar / -inner / -label were DELETED 2026-08-16. The band said "this
   list is scoped to one project" and, being full-bleed, had to be spliced into
   each page's own stack at whatever point that page chose — which is why no two
   pages ever agreed on where it went. The active scope is now shown by
   .v-scope-chip (vantage-facelift.css, rendered by vantage-scope.js), anchored
   in the hero, where there is exactly one place it can sit. admin-contracts was
   its only consumer and moved in the same change.
   See docs/briefs/active-project-scope-standardization.md. */

/* ── Canonical list toolbar ─────────────────────────────────────────────────
   The row of facets / search / filters that sits directly above a list or
   table. Introduced 2026-08-02 (Nate ruling) to end the free-for-all: seven
   page-local classes were doing this job with no canonical owner —
   .toolbar-wrap/.toolbar-row/.toolbar-sep and .stat-pills (admin-change-orders,
   admin-proposals — duplicated byte-for-byte between the two), .filter-row
   (admin-invoices, admin-recurring, admin-schedule), .filter-bar/.filter-group
   (admin-procurement, admin-expenses), .filters (admin-activity),
   .ledger-filters (admin-reconciliation), and .filters-drawer (admin-catalog,
   the only page that had solved this on a phone).

   THIS IS A LAYOUT WRAPPER, exactly like .v-kpi-grid. It owns row rhythm and
   the responsive collapse and NOTHING else. It declares no border, no surface,
   no radius, no control chrome and no type role, and a page must not give it
   any. Every child is a primitive that already exists:

       status facets   .v-seg + .v-seg-count   (vantage-facelift.css)
       search          .tab-search-wrap        (upgraded by vantage-ui.js)
       extra filters   canonical select/field  (the v-ui field contract)
       actions         .v-btn-* (+ .v-btn-compact)

   Counts belong in the facet pill, not in KPI cards: a row count is a filter,
   so it must be clickable, and a page that shows "Draft 4" next to a status
   <select> is asking the same question twice. KPI cards are for money and
   rollups. A page whose only numbers are counts gets no KPI region at all.

   Anatomy:
     <div class="v-list-toolbar">
       <div class="v-seg">…facet buttons…</div>
       <span class="v-list-toolbar-gap"></span>
       <div class="tab-search-wrap" data-search-label="Search x">…</div>
       <div class="v-list-toolbar-filters">…selects…</div>
     </div>
   vantage-ui.js injects the collapse trigger; do not hand-write one. */
body.v-ui .v-list-toolbar {
  display: flex;
  align-items: center;
  gap: var(--v-space-2);
  flex-wrap: wrap;
  margin-bottom: var(--v-space-4);
}
body.v-ui .v-list-toolbar > .v-seg { flex: 0 1 auto; min-width: 0; }
body.v-ui .v-list-toolbar-gap { flex: 1 1 var(--v-space-2); min-width: var(--v-space-2); }
/* Controls in a toolbar size to their content. The canonical field contract
   gives select/input `width: 100%`, which is right in a form and wrong in a
   row — measured at 414px (admin-expenses) and 538px (admin-activity) before
   this rule covered the filters slot. The `> ` child combinator alone was NOT
   enough: most pages put their dropdowns inside .v-list-toolbar-filters, which
   is a wrapper, so nothing matched and every one of them blew out.
   Checkbox/radio are excluded — they are intrinsically sized, and the multi-check
   dropdowns some pages still keep in the slot are full of them. */
body.v-ui .v-list-toolbar > :is(select, input),
body.v-ui .v-list-toolbar .v-list-toolbar-filters :is(select, input) {
  flex: 0 0 auto;
  width: auto;
}
body.v-ui .v-list-toolbar :is(input[type="checkbox"], input[type="radio"]) { width: initial; }
/* `width: auto` is enough for a <select> (it shrink-wraps its longest option —
   measured 97-137px across the migrated pages) but NOT for a text-ish <input>:
   search and date inputs resolved to their whole container instead, 414px on
   admin-expenses and 560px on admin-activity. They need an explicit size, so
   every filter field in a toolbar lands on one width rather than each page
   inventing a min-width. */
body.v-ui .v-list-toolbar .v-list-toolbar-filters > input[type="search"],
body.v-ui .v-list-toolbar .v-list-toolbar-filters > input[type="text"],
body.v-ui .v-list-toolbar .v-list-toolbar-filters > input[type="date"],
body.v-ui .v-list-toolbar .v-list-toolbar-filters > input[type="number"] {
  flex: 0 0 180px;
  width: 180px;
  max-width: 100%;
}
body.v-ui .v-list-toolbar .tab-search-wrap { flex: 0 1 auto; }
/* …but the search field itself still fills its disclosure. */
body.v-ui .v-list-toolbar .tab-search-wrap input { width: 100%; }
body.v-ui .v-list-toolbar .v-list-toolbar-filters {
  display: flex;
  align-items: center;
  gap: var(--v-space-2);
  flex-wrap: wrap;
}
/* Hidden on desktop. The `button` type selector is load-bearing, not decoration:
   vantage-buttons.css sets display:inline-flex on `body.v-ui .v-btn-secondary`
   (0,3,1) and LOADS AFTER this file, so an equal-specificity rule here loses the
   cascade and the trigger shows at every width — measured, not assumed. The type
   selector takes this to 0,3,2 and wins. Keep it on both rules below. */
body.v-ui .v-list-toolbar button.v-list-toolbar-more { display: none; }

/* Below the tablet break: the facet pill takes the remaining width and scrolls
   inside it (already .v-seg's own behavior, added for admin-selections'
   7-segment control), so the icon triggers stay on the same row instead of
   claiming one of their own. Secondary filters collapse behind the canonical
   .v-toggle-dot trigger — the same active-dot affordance the search disclosure
   uses, which is what that modifier was cut for. */
@media (max-width: 768px) {
  body.v-ui .v-list-toolbar .v-list-toolbar-filters { display: none; }
  body.v-ui .v-list-toolbar .v-list-toolbar-filters.is-open {
    display: flex;
    order: 9;
    width: 100%;
  }
  body.v-ui .v-list-toolbar button.v-list-toolbar-more { display: inline-flex; }
  /* Grow to share the row with the icon triggers — but never past the facets
     themselves. Both halves are load-bearing and were each wrong alone:
       • `flex: 0 1 auto` wraps the triggers onto a second row, because a flex
         line breaks on the item's CONTENT width before shrink is considered.
       • `flex: 1 1 0` alone stretches the pill to fill, so a pill whose facets
         fit renders a long empty strip inside its own border (Nate, 2026-08-03 —
         the Contracts pill at a 669px panel was a 621px box around 400px).
     `max-width: max-content` caps the growth at the facets, so the pill fills
     only what it needs, the triggers stay on the row, and `.v-seg`'s own
     `overflow-x: auto` scrolls when the facets genuinely overflow. */
  body.v-ui .v-list-toolbar > .v-seg { flex: 1 1 0; max-width: max-content; }
  body.v-ui .v-list-toolbar-gap { display: none; }
}

/* Canonical general panel. Domain classes may add workflow layout, never
   another border, radius, surface, header divider, or heading treatment. */
body.v-ui .v-panel {
  overflow: hidden;
  border: 1px solid var(--v-border);
  border-radius: var(--v-radius-lg);
  background: var(--bg-mid);
  box-shadow: none;
}
body.v-ui .v-panel-header {
  /* THE band must own its box model. --v-panel-header-h is authored as
     `control-height + 24px padding + 1px border` — a TOTAL height. Without
     border-box that total becomes the CONTENT height and the padding and
     border are added on top again, so a 53px band renders at 78px. It only
     looked right on pages that happened to ship a global `* { box-sizing }`
     reset; admin-accounting ships none, which is why Nate saw its panel header
     as "WAY too tall" (2026-08-27). Pre-dates the typography lane. */
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  min-height: var(--v-panel-header-h);
  padding: 12px 18px;
  border-bottom: 1px solid var(--v-border);
}
/* A control in a panel header sits at the compact tier. This is what keeps the
   header — and therefore the divider — one height: a standard 32px button in
   one header and a bare title in the next is exactly how a row of cards drifts
   out of alignment. Applies to the header only; the panel body is unaffected.

   `.v-panel .v-panel-header` rather than `.v-panel-header` alone is deliberate
   and load-bearing: vantage-facelift.css sizes `body.v-ui.v-face .v-btn-*` at
   (0,3,1) and loads after this file, so the shorter selector ties on
   specificity and silently loses. The extra class takes this to (0,4,1). */
body.v-ui .v-panel .v-panel-header :is(.v-btn-primary, .v-btn-secondary, .v-btn-danger) {
  height: var(--v-control-height-compact);
  min-height: var(--v-control-height-compact);
  padding-top: 0;
  padding-bottom: 0;
}
/* A panel header opens with its title, at the .v-section-title tier — the 11px
   / 2px-tracked treatment here was the last surviving eyebrow after the role was
   retired 2026-07-25 (Nate 2026-07-28: panel titles must read as titles). */
body.v-ui .v-panel-header :is(h2, h3, .title) {
  margin: 0;
  /* One line, always. A title that wraps makes its header taller than the
     card beside it, which is the same misalignment --v-panel-header-h exists
     to prevent — a long title in a narrow grid column would otherwise undo it.
     min-width:0 is what lets the ellipsis engage inside the flex header. */
  min-width: 0;
  overflow: hidden;
  color: var(--gold-light);
  font-size: 16px;
  font-weight: 500;
  line-height: 1.25;
  letter-spacing: 0.01em;
  text-transform: none;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Canonical anchored popover surface. Consumers own placement and workflow
   content; this shared primitive owns all visible chrome and type geometry. */
body.v-ui .v-popover {
  padding: 14px 16px;
  border: 1px solid var(--v-border);
  border-radius: var(--v-radius-lg);
  background: var(--v-surface);
  box-shadow: 0 14px 36px rgba(var(--bg-rgb), 0.42);
  color: var(--v-text);
  font-family: var(--v-font-ui);
  font-size: 12px;
  line-height: 1.45;
}

/* The hover card built by VantageUI.showHoverCard. It adds only what a hover
   surface needs on top of the shared popover chrome: page-coordinate anchoring,
   a stacking slot under the modal band (9500), and pointer transparency so the
   card can never eat the mouseout that should dismiss it or block the control
   underneath. Everything visible comes from .v-popover above. */
body.v-ui .v-hovercard {
  position: absolute;
  z-index: 9400;
  pointer-events: none;
  padding: 10px 12px;
}

/* ── THE […] MORE BUTTON ──────────────────────────────────────────────────
   The one row-level overflow trigger. Say "the […] more button" and this is
   what you mean: a canonical secondary icon button carrying `.v-more`, whose
   click opens VantageUI.rowMenu with the row's conditional actions.

   Build it with VantageUI.moreBtn() rather than hand-writing the markup — the
   markup below is what that helper emits, recorded here so the class has a
   documented home:

     <button class="v-btn-secondary v-btn-icon v-btn-compact v-more"
             aria-label="More actions" aria-haspopup="menu">
       <span data-vi-icon=more-horizontal data-vi-size=14></span>
     </button>

   `.v-more` adds no chrome of its own — the three canonical button classes own
   every visible property. It exists so an actions cell can shrink to one
   control without the button stretching to fill the flex column, and so the
   pattern has a name that is greppable across the product. */
body.v-ui .v-more {
  flex: 0 0 auto;
  align-self: flex-start;
}

/* The dropdown that .v-more opens (VantageUI.rowMenu). Placement is the only
   thing the JS still owns — it is measured from the anchor at click time. Every
   static property lives here, on top of the shared .v-popover chrome. The
   9400 slot sits under the modal band (9500) so a menu opened behind a modal
   can never cover it. */
body.v-ui .v-rowmenu {
  position: absolute;
  z-index: 9400;
  /* A stretch column, NOT `width:100%` items. An absolutely-positioned box
     shrink-to-fits, and percentage-width children give it no content width to
     fit to — the circular dependency resolved to "all the space left of the
     viewport edge", so a menu of four short labels rendered ~300px wide.
     Stretching flex items fills the row with no percentage involved, which
     lets `max-content` size the menu to its widest label. */
  display: flex;
  flex-direction: column;
  align-items: stretch;
  width: max-content;
  min-width: 170px;
  max-width: 260px;
  padding: 6px;
}
/* `.v-popover` is carried for SPECIFICITY, not decoration: the canonical button
   roles are centered by `body.v-ui.v-face .v-btn-secondary` (0,3,1) in
   vantage-facelift.css, which loads last. `body.v-ui .v-rowmenu > button` is
   (0,2,1) and silently lost both declarations below — menu items rendered
   centered and auto-width. Doubling up the two classes the element already
   carries makes this (0,3,2) and settles it in both tiers. Keep it. */
body.v-ui .v-popover.v-rowmenu > button {
  justify-content: flex-start;
  /* Flat rows, not button pills (Nate 2026-08-30): a menu item sheds the
     canonical button chrome — no border, fill, or shadow of its own — and
     reads as a text row with a hover tint inside the popover frame. The role
     classes stay for their TEXT color (secondary vs danger) and disabled
     handling; only the chrome is stripped. */
  border: none;
  background: none;
  box-shadow: none;
  border-radius: 6px;
  padding: 8px 10px;
  height: auto;
  min-height: 0;
  font-size: 12px;
}
body.v-ui .v-popover.v-rowmenu > button:hover,
body.v-ui .v-popover.v-rowmenu > button:focus-visible {
  border: none;
  background: rgba(var(--gold-rgb, 201, 164, 100), 0.08);
  box-shadow: none;
}
body.v-ui .v-popover.v-rowmenu > button + button { margin-top: 4px; }
body.v-ui .v-rowmenu .v-rowmenu-note {
  padding: 4px 6px 2px;
  color: var(--muted);
  font-size: 10px;
}
/* A menu item that carries a second line — what the action does, or why it is
   unavailable. The canonical button is a fixed-height nowrap row, so an item
   with a `sub` opts into an auto-height two-line box instead. Everything else
   about it (role colours, hover, disabled) still comes from the button. */
body.v-ui .v-rowmenu .v-rowmenu-text {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  min-width: 0;
}
body.v-ui .v-rowmenu .v-rowmenu-sub {
  color: var(--v-text-muted);
  font-size: 10px;
  font-weight: 400;
  line-height: 1.3;
  white-space: normal;
}
body.v-ui .v-popover.v-rowmenu > button.has-sub {
  height: auto;
  min-height: var(--v-control-height-compact, 28px);
  align-items: flex-start;
  padding-top: 7px;
  padding-bottom: 7px;
  white-space: normal;
  text-align: left;
}
body.v-ui .v-popover.v-rowmenu > button.has-sub > [data-vi-icon] { margin-top: 2px; }
body.v-ui .v-hovercard .v-hovercard-title {
  margin-bottom: 4px;
  color: var(--gold-light);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.2px;
}
body.v-ui .v-hovercard .v-hovercard-row {
  display: flex;
  align-items: baseline;
  gap: 6px;
  padding: 2px 0;
}
body.v-ui .v-hovercard .v-hovercard-label {
  flex: 0 0 auto;
  color: var(--muted);
  font-size: 10px;
}
/* THE floating action button (Nate ruling 2026-07-26, selections lane).
   A FAB is its own primitive, NOT a `.v-btn-*` modifier: the canonical button
   roles are a 32px pill and a FAB is a large round surface, so a modifier
   would have to override height, width, padding and radius — exactly what the
   C29 `.v-ai` ruling says a modifier must never do. It sits with the other
   free-floating surfaces (.v-popover, .v-drawer) instead.

   This primitive owns CHROME, SIZE and icon centring. Consumers own PLACEMENT
   (which edge, which offsets, z-index) — the same split as .v-popover. Set
   `--v-fab-size` to resize. Placement must respect the client bottom nav via
   `bottom: var(--vcn-h, 0px)`; the token is 0px wherever that bar is absent.

   DELIBERATELY UNSCOPED (no `body.v-ui`), for the same reason .v-btn-* is:
   vantage-brand.js injects this sheet on every app page, so an unscoped rule
   renders correctly on pages that have not opted into `body.v-ui` yet — e.g.
   the client selections.html. Scoping it would leave those pages silently
   falling back to browser-default chrome (measured: 22px square with a 2px
   border), which is precisely the drift this primitive exists to end. */
.v-fab {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--v-fab-size, 46px);
  height: var(--v-fab-size, 46px);
  padding: 0;
  /* --v-border / --v-surface are only defined under body.v-ui; an undefined
     var() invalidates the whole declaration, which on an unopted page silently
     dropped the border and background (measured). Fall back to the base tokens
     every app page defines. */
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: 50%;
  background: var(--v-surface, var(--bg-mid));
  color: var(--gold);
  cursor: pointer;
  box-shadow: 0 4px 18px rgba(var(--bg-rgb), 0.45);
  transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.2s;
}
.v-fab:hover {
  background: var(--bg-light, var(--bg-mid));
  border-color: var(--gold);
  box-shadow: 0 6px 22px rgba(var(--bg-rgb), 0.55);
}
.v-fab:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 2px;
}
.v-fab[disabled] { opacity: 0.45; cursor: default; box-shadow: none; }

/* .v-fab-extended — THE EXTENDED (labelled) FAB (Nate, approved 2026-07-27).
   A FAB that carries a text label beside its icon. Reference consumer: the CFS
   "Submit" action on client-vshot-ticket / client-vshot-project, which shipped
   as a page-local gold pill on both pages.

   It is a MODIFIER, and it obeys the C29 .v-ai rule about what a modifier may
   contribute: GEOMETRY ONLY. Width, radius, horizontal padding and the
   icon/label gap are the only things "extended" actually means. Border,
   background, colour, shadow, hover, focus ring and the disabled state all
   inherit from .v-fab — so an extended FAB cannot drift into being a second
   FAB family, which is exactly how .vg-cfs-fab happened.

   HEIGHT DELIBERATELY INHERITS `--v-fab-size`: a round FAB and an extended FAB
   on the same screen must sit on the same baseline and read as one control at
   two widths. Do not set height here or on a consumer.

   Consumers still own PLACEMENT only (edge, offsets, z-index), same as .v-fab,
   and must clear the client bottom nav with `bottom: calc(<x> + var(--vcn-h, 0px))`.

   Unscoped for the same measured reason .v-fab is — see the note above. */
.v-fab-extended {
  width: auto;
  min-width: var(--v-fab-size, 46px);
  padding: 0 var(--v-space-4, 18px);
  border-radius: 999px;
  gap: 8px;
}

body.v-ui .v-panel-body {
  padding: 16px 18px;
}

/* ── Canonical "Next Action" list ────────────────────────────────────────────
   ONE concept: an entity dashboard's outstanding-work list. Each row is a
   state dot + label/sub-status + that row's own actions, so independent
   actions never share a line and a multi-button single action (a site visit:
   complete / reschedule / unschedule) keeps its buttons together on one.

   Consumers: the project dashboard bidding checklist (admin-projects.html)
   and the service ticket next-action card (admin-service-finance.html). This
   replaced the page-local `.dash-na-row` / `.dash-na-cl-dot` family that lived
   only in admin-projects — do not restate any of it in page CSS. Domain pages
   own row CONTENT (labels, status text, which actions); the geometry, dot
   states and type tiers are owned here.

   State colors come from the shared status tokens, never literals: --v-ok
   affirmative (done / the one live next step), --v-warn attention (in
   progress, submitted, awaiting someone). */
body.v-ui .v-na-status {
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 4px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--v-border);
}
body.v-ui .v-na-status-state {
  flex-shrink: 0;
  padding: 3px 10px;
  border: 1px solid color-mix(in srgb, var(--v-accent) 40%, transparent);
  border-radius: 10px;
  background: color-mix(in srgb, var(--v-accent) 8%, transparent);
  color: var(--v-accent);
  font-size: 11px;
  line-height: 1.4;
  white-space: nowrap;
}
body.v-ui .v-na-status-note {
  flex: 1;
  min-width: 180px;
  color: var(--muted);
  font-size: 12px;
  line-height: 1.55;
}

/* The list is the query container for its rows. A next-action card is a GRID
   COLUMN — 1-of-3 on the ticket and project dashboards — so a row is routinely
   ~300px wide at a 1400px viewport. Sizing its rows off the viewport therefore
   answered the wrong question, which is what crushed a visit label to one word
   per line beside three full-width buttons (Nate, 2026-08-15). rowMenu appends
   its menu to <body>, so the containment here cannot trap a popover. */
body.v-ui .v-na-list {
  display: flex;
  flex-direction: column;
  gap: 0;
  /* A vertical list never wraps into columns. Stated explicitly because the
     canon is routinely added to an element that already carries a page's own
     wrapper class: on the project dashboard the checklist reuses
     #dash-na-actions, whose `.dash-na-actions { flex-wrap: wrap }` this
     declaration had no counterpart for and therefore survived. A wrapping
     column container stretches its line, which inflated every bidding row from
     62px to 121px the moment .v-na-row itself gained flex-wrap (Nate,
     2026-08-15). Column lists stay nowrap — do not delete this. */
  flex-wrap: nowrap;
  container-type: inline-size;
  container-name: v-na;
}
body.v-ui .v-na-row {
  display: flex;
  align-items: center;
  gap: 14px;
  /* The actions block drops to its own line rather than squeezing the label.
     This is also the fallback for the container query below: without wrap, an
     actions cell that refuses to shrink simply eats the label's width. */
  flex-wrap: wrap;
  padding: 12px 0;
  border-bottom: 1px solid var(--v-border);
}
body.v-ui .v-na-row:last-child { border-bottom: none; }

/* The state dot. Default is an empty outline (not started). */
body.v-ui .v-na-dot {
  position: relative;
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  border: 1px solid var(--v-border-strong);
  border-radius: 50%;
  background: var(--bg);
  transition: transform 0.12s, border-color 0.12s;
}
/* Only a dot that actually toggles state reads as interactive. */
body.v-ui .v-na-dot.is-clickable { cursor: pointer; }
body.v-ui .v-na-dot.is-clickable:hover {
  transform: scale(1.15);
  border-color: var(--v-accent);
}
body.v-ui .v-na-dot.is-progress {
  border-color: var(--v-warn);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--v-warn) 18%, transparent);
}
body.v-ui .v-na-dot.is-done {
  border-color: var(--v-ok);
  background: var(--v-ok);
}
body.v-ui .v-na-dot.is-done::after {
  content: '✓';
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--bg);
  font-size: 11px;
}
body.v-ui .v-na-dot.is-submitted {
  border-color: var(--v-warn);
  background: var(--v-warn);
}
body.v-ui .v-na-dot.is-submitted::after {
  content: '';
  position: absolute;
  inset: 0;
  background-color: var(--bg);
  -webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='black'%20stroke-width='2.4'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cline%20x1='22'%20y1='2'%20x2='11'%20y2='13'/%3E%3Cpolygon%20points='22%202%2015%2022%2011%2013%202%209%2022%202'/%3E%3C/svg%3E") center / 12px 12px no-repeat;
          mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='black'%20stroke-width='2.4'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cline%20x1='22'%20y1='2'%20x2='11'%20y2='13'/%3E%3Cpolygon%20points='22%202%2015%2022%2011%2013%202%209%2022%202'/%3E%3C/svg%3E") center / 12px 12px no-repeat;
}
/* The one live next step — a glowing status marker, never a toggle. */
body.v-ui .v-na-dot.is-next {
  border-color: var(--v-ok);
  background: var(--v-ok);
  cursor: default;
  box-shadow: 0 0 12px color-mix(in srgb, var(--v-ok) 60%, transparent);
  animation: vNaPulse 1.8s ease-in-out infinite;
}
body.v-ui .v-na-dot.is-next:hover { transform: none; border-color: var(--v-ok); }
@keyframes vNaPulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.4; }
}
@media (prefers-reduced-motion: reduce) {
  body.v-ui .v-na-dot.is-next { animation: none; }
}

/* A real basis, not a bare `flex: 1`. Flex breaks lines on the hypothetical
   size, so the basis is what makes the actions wrap instead of the label
   collapsing; `flex: 1` with `min-width: 0` let it shrink to nothing first.
   176px is deliberately modest. The target layout is text-left / buttons-right
   on ONE line, so the basis only has to be big enough that a label wrapping to
   two or three lines still beats collapsing to a word per line. Raising it
   pushes the actions back under the text in a dashboard column, which is the
   layout this replaced — pair any increase with a narrower actions cell. */
body.v-ui .v-na-row-body { flex: 1 1 176px; min-width: 0; }
body.v-ui .v-na-row-label {
  margin-bottom: 2px;
  color: var(--gold-light);
  font-size: 14px;
  line-height: 1.35;
}
body.v-ui .v-na-row-label.is-done      { color: var(--v-ok); }
body.v-ui .v-na-row-label.is-submitted { color: var(--v-warn); }
body.v-ui .v-na-row-status {
  color: var(--muted);
  font-size: 11px;
  line-height: 1.45;
}
body.v-ui .v-na-row-status.is-progress,
body.v-ui .v-na-row-status.is-submitted { color: var(--v-warn); }
body.v-ui .v-na-row-status.is-done      { color: var(--v-ok); }

/* A row's own actions. Buttons stay canonical roles — this only lays them out.
   `flex-shrink: 0` used to sit here and was the whole defect: it sized the cell
   to the widest single line its buttons could form, so the cell's own
   `flex-wrap` never had any pressure to engage and the label absorbed every
   pixel of the shortfall. It shrinks now; the row's wrap decides the line. */
body.v-ui .v-na-row-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 1 auto;
  min-width: 0;
  flex-wrap: wrap;
  justify-content: flex-end;
}
body.v-ui .v-na-row-link {
  flex-shrink: 0;
  color: var(--v-accent);
  font-size: 11px;
  text-decoration: none;
  cursor: pointer;
}
body.v-ui .v-na-row-link:hover { color: var(--gold-light); }

/* Narrow ROW: actions drop under the label rather than crushing it, indented to
   the label's edge so the wrapped line reads as part of the same row. Keyed to
   the list's own width — the viewport media query this replaces never fired for
   the case that actually breaks, a narrow card on a wide screen.

   380px, not 560: a dashboard column is ~410px, and stacking there is what Nate
   rejected. This is the floor where a compact primary plus […] genuinely cannot
   share the line, i.e. phone width, not "narrow-ish". A row whose actions cell
   is wider than a primary + […] will wrap on its own above this — that is the
   row's flex-wrap doing its job, not a missing breakpoint. */
@container v-na (max-width: 380px) {
  body.v-ui .v-na-row { align-items: flex-start; }
  body.v-ui .v-na-row-actions {
    width: 100%;
    padding-left: 32px;
    justify-content: flex-start;
  }
}

/* Header marker for a live "Next Action" panel title. */
body.v-ui .v-na-pulse {
  display: inline-block;
  width: 7px;
  height: 7px;
  margin-right: 7px;
  border-radius: 50%;
  background: var(--v-ok);
  box-shadow: 0 0 10px color-mix(in srgb, var(--v-ok) 70%, transparent);
  vertical-align: middle;
  animation: vNaPulse 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  body.v-ui .v-na-pulse { animation: none; }
}

/* Canonical entity dashboard header. Entity identity and actions share one
   open, hairline-divided composition across Projects and Service Finance. */
body.v-ui .v-entity-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;
  padding: 32px 0 24px;
  margin-bottom: 22px;
  border-bottom: 1px solid var(--v-border);
}
@media (max-width: 640px) {
  body.v-ui .v-entity-header {
    padding: 20px 0;
  }
}

/* Canonical application drawer. Drawers preserve page context, begin below
   the shared utility bar, and own their geometry here—not in feature modules. */
body.v-ui .v-drawer-backdrop {
  position: fixed;
  inset: var(--v-utility-height, 56px) 0 0;
  z-index: 9200;
  display: flex;
  justify-content: flex-end;
  background: rgba(var(--bg-rgb), 0.24);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
body.v-ui .v-drawer-backdrop.open {
  opacity: 1;
  pointer-events: auto;
}
/* Drawer launched from INSIDE a modal — must layer above .modal-overlay (9500)
   while staying below the date-picker popover (10020), session-guard (10050),
   and toasts. Base drawers keep 9200 (below modals) by design. */
/* A base drawer insets below the utility bar because the bar is still on screen
   beside it. A drawer opened from inside a MODAL has no utility bar to clear —
   the modal overlay already covers it — so the same inset left a dead strip
   above the drawer and the panel read as floating loose on the page. */
body.v-ui .v-drawer-backdrop.v-drawer-over-modal { z-index: 9700; inset: 0; }
body.v-ui .v-drawer {
  box-sizing: border-box;
  width: min(720px, 97vw);
  height: 100%;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  border: 0;
  border-left: 1px solid var(--v-border);
  border-radius: var(--v-drawer-radius, 16px) 0 0 var(--v-drawer-radius, 16px);
  background: var(--v-surface, var(--bg-mid));
  box-shadow: -18px 0 52px rgba(var(--bg-rgb), 0.52);
  color: var(--gold-light);
  transform: translateX(100%);
  transition: transform 0.24s cubic-bezier(.22,.61,.36,1);
}
body.v-ui .v-drawer-backdrop.open .v-drawer { transform: translateX(0); }
body.v-ui .v-drawer-header,
body.v-ui .v-drawer-footer {
  flex: 0 0 auto;
  padding: 16px 20px;
  border-color: var(--v-border);
}
/* THE canonical drawer header (Nate 2026-08-26: "one canonical header, and it
   should match the modal") — same band geometry as the v-service-modal header
   (64px, 0 24px). Titles ride .modal-title (19px, facelift-owned typography);
   the close is the same converged 28px hairline icon button (the facelift
   [aria-label="Close"] rule covers .v-drawer-header too). Drawers add no local
   header padding, title sizing, or close chrome of their own. */
body.v-ui .v-drawer-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  box-sizing: border-box;
  min-height: 64px;
  padding: 0 24px;
  border-bottom: 1px solid var(--v-border);
  background: transparent;
}
body.v-ui .v-drawer-footer { border-top: 1px solid var(--v-border); }
body.v-ui .v-drawer-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
body.v-ui .v-drawer-content {
  box-sizing: border-box;
  width: 100%;
  padding: 20px;
}

@media (max-width: 600px) {
  body.v-ui .v-drawer-backdrop { inset-block-start: var(--v-utility-height-mobile, 54px); }
  /* Same reason as the desktop rule: over a modal there is no bar to clear. */
  body.v-ui .v-drawer-backdrop.v-drawer-over-modal { inset-block-start: 0; }
  body.v-ui .v-drawer { width: 100%; max-width: none; border-radius: 0; }
}

/* Tables */
body.v-ui .table-wrap {
  width: 100%;
  min-width: 0;
  max-width: 100%;
  overflow-x: auto;
  overflow-y: hidden;
  background: var(--bg-mid);
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-lg);
  -webkit-overflow-scrolling: touch;
}

body.v-ui .table-wrap > table {
  width: 100%;
  min-width: 560px;
  border-collapse: collapse;
}

/* Shared Job Cost ledger geometry (Materials, Fees, and Expenses).
   The fixed money columns consume 868px before Lifecycle. Preserve enough room
   for its four 62px stages and arrows; narrower surfaces scroll inside the
   table wrapper instead of crushing stages into Unit Cost. */
body.v-ui .table-wrap > table.vm-ledger-tbl {
  min-width: 1280px;
}

/* One canonical single-line header density for Materials, Fees, and Direct Job
   Expenses. Money headings use Unit / Ext. (extended total) terminology. */
body.v-ui .table-wrap > table.vm-ledger-tbl th {
  padding: 8px !important;
  letter-spacing: 1px !important;
  white-space: nowrap;
}

/* C31 — the ledger row tier. Item cells in the Job Cost ledgers run at
   --v-row-pad-ledger with 13px cell type; header size stays with the
   single-line header ruling above. Cells' own inline layout styles (numeric
   alignment, chip wells) continue to out-specify this baseline. */
/* `tbody td` (not bare `td`) so this tier OUT-SPECIFIES the face row tier
   (facelift's `.table-wrap > table td` ties a bare-`td` selector and would
   win on load order — measured during the C31 landing). */
body.v-ui .table-wrap > table.vm-ledger-tbl tbody td {
  padding: var(--v-row-pad-ledger, 8px 12px);
  font-size: 13px;
}

/* Ledger section dividers label origin, not data. Keep them materially denser
   than item rows; this intentionally overrides the general 16px table-cell
   padding below for Proposal Items, accepted CO groups, and Not on Proposal. */
body.v-ui .table-wrap > table.vm-ledger-tbl .vm-sec-hdr > td {
  height: 26px;
  padding: 4px 16px;
  font-size: 10px;
  line-height: 1.2;
}

/* Dense accounting override inputs belong to the compact control tier. The
   general form-control rule is intentionally taller and made Unit Sell expand
   the entire ledger row. */
body.v-ui .vm-ledger-tbl :is(.vm-sell, .vf-sell, .vd-sell) {
  height: var(--v-control-height-compact) !important;
  min-height: var(--v-control-height-compact) !important;
  padding: 3px 6px !important;
}

/* Customer billing state is deliberately distinct from procurement lifecycle:
   a line can be Costed/Paid yet still not be billable. */
.v-billing-state {
  display: inline-flex;
  align-items: center;
  min-height: 18px;
  margin-top: 4px;
  padding: 1px 7px;
  border: 1px solid rgba(var(--gold-rgb), 0.32);
  border-radius: 999px;
  color: var(--gold-light);
  background: rgba(var(--gold-rgb), 0.08);
  font-size: 9px;
  
  line-height: 1.2;
  white-space: nowrap;
}
.v-billing-state.is-ready,
.v-billing-state.is-contracted {
  color: var(--green, var(--gold-light));
  border-color: color-mix(in srgb, var(--green, var(--gold)) 42%, transparent);
  background: color-mix(in srgb, var(--green, var(--gold)) 10%, transparent);
}
/* Scope that arrived by amendment rather than in the original deal. Blue is the
   only hue here that is not already spoken for, and --v-info is defined rather
   than reusing a page's --blue because no page defines one: the theme ships
   --green and --red only. The literal is a true blue (hue 213), deliberately
   clear of the banned teal band (hue ~168, design-contract C27b). */
.v-billing-state.is-change-order {
  color: var(--v-info, #6ea8e8);
  border-color: color-mix(in srgb, var(--v-info, #6ea8e8) 46%, transparent);
  background: color-mix(in srgb, var(--v-info, #6ea8e8) 12%, transparent);
}
.v-billing-state.is-on-invoice {
  color: var(--gold);
  border-color: rgba(var(--gold-rgb), 0.58);
  background: rgba(var(--gold-rgb), 0.14);
}
.v-billing-state.is-blocked {
  color: var(--red, var(--gold-light));
  border-color: color-mix(in srgb, var(--red, var(--gold)) 46%, transparent);
  background: color-mix(in srgb, var(--red, var(--gold)) 10%, transparent);
}
.v-billing-state.is-absorbed { color: var(--muted); }

/* A billing badge is a hover target now, not just a label. The cursor is the
   only affordance that says so; without it the card looks like it opens by
   accident. */
.v-billing-state[data-v-hover] { cursor: help; }
.v-billing-state[data-v-hover]:hover,
.v-billing-state[data-v-hover]:focus-visible {
  border-color: currentColor;
  outline: none;
}

/* Glyph form of the billing state, for dense tables. No chrome at all: a chip
   border around a 13px icon reads as a button, and this is a status, not an
   action. Tone is carried by color alone, which is why the hover card and the
   aria-label both spell the state out — color is never the only channel. */
.v-billing-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: 4px;
  cursor: help;
  opacity: 0.85;
}
.v-billing-icon:hover,
.v-billing-icon:focus-visible { opacity: 1; outline: none; background: rgba(var(--gold-rgb), 0.1); }
.v-billing-icon.is-ok   { color: var(--green, #2ecc71); }
.v-billing-icon.is-bad  { color: var(--red, #c0392b); }
.v-billing-icon.is-done { color: var(--gold); }
.v-billing-icon.is-mute { color: var(--muted); }

body.v-ui .table-wrap > table > thead { background: var(--v-theme-table-header, var(--bg-light)); }

body.v-ui .table-wrap > table th {
  padding: 14px 20px;
  color: var(--gold);
  font-size: var(--v-fs-th);
  font-weight: 400;
  /* The facelift's value, adopted as THE value (Nate ruling 2026-08-27). Canon
     said 2px and the facelift said 0.12em, so a converted page and an
     unconverted one tracked their headers differently. In em so it stays
     proportional if the step is ever retuned. */
  letter-spacing: 0.12em;
  text-align: left;
  text-transform: uppercase;
  /* Header label + sort arrow stay on ONE line — the wrap belongs to the
     table's horizontal overflow, never inside a header cell (Nate 2026-07-25). */
  white-space: nowrap;
}

body.v-ui .table-wrap > table td {
  padding: var(--v-row-pad, 13px 16px);
  color: var(--gold-light);
  border-top: 1px solid var(--v-border, var(--gold-dim));
  font-size: var(--v-fs-body);
}

body.v-ui .table-wrap > table :is(th, td).v-table-center { text-align: center; }
body.v-ui .table-wrap > table :is(th, td).v-table-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
body.v-ui .table-wrap > table :is(th, td).v-table-actions { text-align: right; }

body.v-ui .table-wrap > table tr:hover > td {
  background: var(--v-theme-table-hover, var(--v-theme-chrome-active, var(--bg-light)));
}

/* Explicit canonical table opt-in for application pages that have not yet
   migrated their entire body to .v-ui. This lets one section consume the
   shared geometry without changing unrelated legacy tabs on the same page. */
.table-wrap.v-table-canonical {
  width: 100%;
  min-width: 0;
  max-width: 100%;
  overflow-x: auto;
  overflow-y: hidden;
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-lg, 10px);
  background: var(--bg-mid);
  -webkit-overflow-scrolling: touch;
}

.table-wrap.v-table-canonical > table {
  width: 100%;
  border-collapse: collapse;
}

.table-wrap.v-table-canonical > table > thead { background: var(--v-theme-table-header, var(--bg-light)); }

.table-wrap.v-table-canonical > table th {
  padding: 12px 16px;
  color: var(--gold);
  font-size: 10px;
  font-weight: 400;
  letter-spacing: 1.6px;
  text-align: left;
  text-transform: uppercase;
  white-space: nowrap;
}

.table-wrap.v-table-canonical > table td {
  padding: var(--v-row-pad, 13px 16px);
  color: var(--gold-light);
  border-top: 1px solid var(--v-border, var(--gold-dim));
  font-size: 13px;
  vertical-align: top;
}

.table-wrap.v-table-canonical > table tbody tr { cursor: pointer; }
.table-wrap.v-table-canonical > table tbody tr:hover > td,
.table-wrap.v-table-canonical > table tbody tr:focus-visible > td {
  background: var(--v-theme-table-hover, var(--v-theme-chrome-active, var(--bg-light)));
}

/* Canonical read-only detail drawer. Domain pages supply the content while
   this primitive owns placement, responsive width, and themed framing. */
.v-detail-backdrop {
  position: fixed;
  inset: 0;
  z-index: 10020;
  background: transparent;
  backdrop-filter: none;
  opacity: 0;
  pointer-events: none;
  transition: opacity 160ms ease;
}

.v-detail-drawer {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 10021;
  display: flex;
  width: min(620px, 94vw);
  flex-direction: column;
  overflow: hidden;
  border-left: 1px solid var(--v-drawer-border, var(--v-border));
  border-radius: var(--v-drawer-radius, 16px) 0 0 var(--v-drawer-radius, 16px);
  background: var(--v-surface, var(--bg-mid));
  box-shadow: -12px 0 36px rgba(var(--bg-rgb), 0.55);
  transform: translateX(102%);
  transition: transform 180ms ease;
}

.v-detail-backdrop.open { opacity: 1; pointer-events: auto; }
.v-detail-drawer.open { transform: translateX(0); }

.v-detail-drawer__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  box-sizing: border-box;
  min-height: var(--v-drawer-header-height, 56px);
  height: var(--v-drawer-header-height, 56px);
  flex: 0 0 var(--v-drawer-header-height, 56px);
  gap: 18px;
  padding: 0 18px;
  border-bottom: 1px solid var(--v-drawer-border, var(--v-border));
}

.v-detail-drawer__body {
  flex: 1;
  overflow-y: auto;
  padding: 24px;
}

.v-detail-drawer__close {
  display: inline-grid;
  width: var(--v-drawer-control-size, 32px);
  height: var(--v-drawer-control-size, 32px);
  flex: 0 0 auto;
  place-items: center;
  border: 0;
  border-radius: var(--v-radius-md, 6px);
  background: transparent;
  color: var(--v-text-muted, var(--gold-light));
  cursor: pointer;
}

.v-detail-drawer__close:hover { background: color-mix(in srgb, var(--v-text) 7%, transparent); color: var(--v-text); }

/* ── CO change summary ─────────────────────────────────────────────────────
   The drawer carries NO presentation architecture of its own. Each area is a
   canonical .v-doc-group head over one .v-finance-table, the action reads as a
   canonical .v-chip capsule, and the money closes in the canonical .v-totals
   block — the same anatomy as the vendor-bill drawer (Nate 2026-08-28). What
   remains here is the one thing this surface alone means: a signed delta, and
   a bundle child indented under its parent. The old area-card frame, action
   text colours, private totals box, and the unused __note / __description /
   __qty / __before-after rules are deleted with the markup that used them. */
.v-change-summary__amount { white-space: nowrap; }
/* --success was never defined anywhere in the product, so an added delta had
   been rendering in the inherited text colour, not green (found 2026-08-28).
   --green is the token every other positive mark in this sheet reads. */
.v-change-summary__amount--added { color: var(--green, #2ecc71); }
.v-change-summary__amount--removed { color: var(--red, #c0392b); }
/* A bundle child steps in under its parent and sits on a whisper tint — the
   same read as the bill drawer's expanded rolled-group members. */
.v-change-summary__child td {
  color: var(--gold-light);
  background: rgba(var(--gold-rgb), 0.03);
  border-top-color: rgba(var(--gold-rgb), 0.04);
}
.v-change-summary__child-item { padding-left: 32px; }
.v-change-summary__empty { color: var(--v-text-muted, var(--gold-light)); }
/* A blocker band: lines the change order left under a removed parent. Reads as a
   warning without inventing a component — the sheet's own attention hue on the
   panel tint the drawer already uses. */
.v-change-summary__blocker {
  margin: 0 0 10px;
  padding: 10px 12px;
  border: 1px solid rgba(var(--gold-rgb), 0.28);
  border-left: 3px solid var(--gold);
  border-radius: 8px;
  background: rgba(var(--gold-rgb), 0.06);
  color: var(--v-text, var(--gold-light));
  font-size: 12px;
  line-height: 1.5;
}

@media (max-width: 600px) {
  .v-detail-drawer { width: 100vw; border-radius: 0; }
  .v-detail-drawer__header { padding: 0 18px; }
  .v-detail-drawer__body { padding: 18px; }
}

body.v-ui .v-scroll-hint {
  display: none;
  margin: 0 2px 6px;
  color: var(--muted);
  font-size: 9px;
  text-align: right;
  }

body.v-ui .v-investment-summary {
  width: 100%;
  max-width: 420px;
  margin-left: auto;
  padding: 16px 20px;
  border: 1px solid rgba(var(--gold-rgb), 0.2);
  border-radius: 12px;
  background: var(--bg-mid);
}

/* The workspace backdrop canvas. vantage-ui.js also sets these inline, because
   the backdrop now runs on client and mobile pages that never load this file —
   the rule stays so the canonical surface is styleable from the canon. */
.v-backdrop-canvas {
  position: fixed;
  inset: 0;
  z-index: -1;
  width: 100vw;
  height: 100vh;
  pointer-events: none;
}

/* Canonical compact financial overview table. Revenue and cost stay in one
   structure so row heights, dividers, and responsive overflow are identical
   across ticket and project finance surfaces. */
body.v-ui .table-wrap > table.v-finance-table {
  min-width: 680px;
  table-layout: fixed;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}

body.v-ui .table-wrap > table.v-finance-table th {
  box-sizing: border-box;
  height: 30px;
  padding: 5px 10px;
  border-top-color: rgba(var(--gold-rgb), 0.12);
  line-height: 1.1;
  vertical-align: middle;
}

/* `tbody td` for the same specificity reason as the vm-ledger tier above. */
body.v-ui .table-wrap > table.v-finance-table tbody td {
  height: 36px;
  /* C31 ledger tier; table-layout:fixed keeps column edges unmoved */
  padding: var(--v-row-pad-ledger, 8px 12px);
  font-size: 13px;
  border-top-color: rgba(var(--gold-rgb), 0.12);
  line-height: 1.2;
  vertical-align: middle;
}

body.v-ui .table-wrap > table.v-finance-table th.vft-cat,
body.v-ui .table-wrap > table.v-finance-table td.vft-cat { text-align: left; }
body.v-ui .table-wrap > table.v-finance-table th.vft-center,
body.v-ui .table-wrap > table.v-finance-table td.vft-center { text-align: center; }
body.v-ui .table-wrap > table.v-finance-table th.vft-num,
body.v-ui .table-wrap > table.v-finance-table td.vft-num { text-align: right; }

/* Compact header tier for a dense lane table (.vft-th-sm).

   It is the canonical caps header, SHRUNK: same uppercase, same color, at the
   9px scale with light tracking and 4px of side padding instead of 10px. It
   exists because the default header is 10px ALL CAPS with 2px tracking, which
   needs 93px for "RECEIVED" and 77px for "ACTION", so a table with a run of
   five quantity lanes either abbreviates its headers ("RCVD") or grows 240px
   wider than its content needs.

   This tier flipped twice in two days, so the history is worth keeping:
   - 2026-08-13 it was born title-case and muted, to fit "Received" in 56px.
   - 2026-08-27 it was deleted when Nate ruled every header reads the canonical
     caps tier the PO drawer uses.
   - Same day, seeing the 1194px table that produced: "now this is way wrong. we
     need to get them back smaller ... i just wanna make sure they stay all
     caps. and there doesnt need to be as much space in the Actions column."
   So: caps, small, tight. All three at once.

   Apply it to EVERY header on a table that uses it, never to a subset. A
   caps/small mix inside one header row is what the 2026-08-26 ruling banned,
   and it is the reason this is a table-wide tier rather than a per-column one.
   Reference consumers: the PO card's lifecycle table and the Receive Shipment
   modal's line table (po-manager.js).

   Specificity is deliberate. The rule it must beat is
   `body.v-ui .table-wrap > table.v-finance-table th` at (0,3,3); this is (0,4,3)
   so it wins on merit rather than on stylesheet order. text-transform and color
   are deliberately NOT restated: both are inherited from the canonical header
   above, so this tier cannot drift out of caps or off-palette on its own. */
body.v-ui .table-wrap > table.v-finance-table th.vft-th-sm {
  padding: 5px 4px;
  font-size: 9px;
  letter-spacing: 0.6px;
}
/* A header must start on the same edge as the column it names. The 4px above is
   what lets a narrow lane column hold "RECEIVED" whole, but on a LEFT- or
   RIGHT-aligned header it puts the label 8px outside its own data, which is
   what Nate saw across both PO tables (2026-08-28: "what the fuck is the deal
   with all the headers being negatively outdented from the lines").
   Those headers take the cells' 12px instead. A CENTRED header does not care:
   symmetric padding centres identically at any size, so the lanes keep their
   4px and keep their words. */
body.v-ui .table-wrap > table.v-finance-table th.vft-th-sm:not(.vft-center) {
  padding-inline: 12px;
}
/* ── Document group head — THE bare header line above one finance table ─────
   "Owner (5 Lines)", "Upstairs Audio (4 Changes)". Origin: the bill drawer's
   owner block (Nate 2026-08-26) — a title line, no card frame, the canonical
   table directly beneath it. Promoted here because three surfaces had grown
   their own inline-styled copy of it (bill drawer, PO editor, CO change
   summary) and a shared anatomy cannot live in three private stylesheets
   (Rule G-1). A caller supplies the words and any right-hand control; the
   geometry and colour belong to this primitive. */
body.v-ui .v-doc-group { margin-bottom: 18px; }
body.v-ui .v-doc-group-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 6px;
}
body.v-ui .v-doc-group-head__main {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  min-width: 0;
}
body.v-ui .v-doc-group-title { color: var(--gold); font-size: 14px; }
body.v-ui .v-doc-group-sub { color: var(--muted); }

/* ── Finance-table item cell — THE two-line line-item identity ──────────────
   Two text lines, never more (Nate 2026-08-26): the quiet identity line on top
   (manufacturer / brand) and the line's carrying name beneath it (a SKU on a
   vendor bill, the item description on a change order). Chips ride line 1,
   passive tags ride line 2, and actions live in the row's [...] menu — nothing
   else may grow a third line. Same G-1 origin as .v-doc-group above: the bill
   drawer, PO editor, and change summary each carried an identical private copy
   (.vex-mfr / .poe-line-brand / ...). */
body.v-ui .vft-l1,
body.v-ui .vft-l2 {
  display: flex;
  align-items: center;
  gap: 7px;
  flex-wrap: wrap;
  min-width: 0;
}
body.v-ui .vft-mfr { color: var(--muted); font-size: 10px; line-height: 1.3; }
body.v-ui .vft-name {
  color: var(--gold-light);
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.3;
  word-break: break-word;
}
/* Canonical document totals block (Nate 2026-08-26). Origin: the bill drawer's
   totals box (Subtotal / Tax / Grand Total). One primitive for every document
   surface — bill drawer, PO editor, and any future document drawer. Owner-group
   subtotal blocks use the same anatomy with an "Owner Total" grand row. */
body.v-ui .v-totals { border: 1px solid rgba(var(--gold-rgb), 0.2); border-radius: 10px; padding: 10px 14px; margin-bottom: 14px; }
body.v-ui .v-totrow { display: flex; justify-content: space-between; gap: 14px; font-size: 12px; padding: 4px 0; color: var(--gold-light); }
body.v-ui .v-totrow .lbl { color: var(--muted); }
body.v-ui .v-totrow.v-totgrand { border-top: 1px solid rgba(var(--gold-rgb), 0.25); margin-top: 6px; padding-top: 8px; font-size: 15px; color: var(--gold); }
body.v-ui .v-totrow.v-totgrand .lbl { color: var(--gold-dim-text, var(--gold-dim)); font-size: 11px; }
/* The grand rule separates the figures being summed from the sum. Alone in the
   box there is nothing above it to separate, and it reads as a stray hairline
   across an empty band — which is exactly what a change order with no
   below-the-line tax shows: one Net Change row and nothing else (Nate,
   2026-08-28). It keeps its rule the moment the box grows a row above it. */
body.v-ui .v-totrow.v-totgrand:first-child {
  margin-top: 0;
  padding-top: 4px;
  border-top: 0;
}
/* Attached form (Nate 2026-08-26, rev 2): attached totals render INSIDE the
   .table-wrap, after the table — one box, one border, one radius, no seam to
   align. The child rule flattens the standalone chrome; only the row divider
   separates the last line from Subtotal. */
body.v-ui .table-wrap > .v-totals {
  margin: 0;
  border: 0;
  border-top: 1px solid rgba(var(--gold-rgb), 0.12);
  border-radius: 0;
  background: transparent;
}

body.v-ui .v-finance-table .vft-dash { color: var(--muted); }
body.v-ui .v-finance-table .vft-gold { color: var(--gold);  }
body.v-ui .v-finance-table .vft-muted { color: var(--muted); }
/* The Cost/Revenue split is a 3%-wide gutter cell (width from the consumer's
   colgroup) carrying a faint 1px line down its CENTER (Nate 2026-08-26: the
   separators must exist, but the old border-left rules were the darkest marks in
   the table and sat flush against the previous column instead of centered in the
   gap). The line is a background gradient, not a border, so it stays centered
   whatever the gutter width. 0.14 sits between the 0.08 row hairlines and the
   0.22 header rule. Specificity (0,4,2) beats facelift's blanket td rule and the
   hover background shorthand, so the line survives row hover. */
body.v-ui .table-wrap > table.v-finance-table .vft-divider {
  padding: 0;
  border-top: 0;
  border-left: 0;
  background: linear-gradient(rgba(var(--gold-rgb), 0.14), rgba(var(--gold-rgb), 0.14)) center / 1px 100% no-repeat;
}
/* Header selectors carry .table-wrap for the same facelift reason as the Totals
   rule below: facelift's `body.v-ui.v-face .table-wrap > table th` (0,3,2) paints
   a --vf-line border-bottom on every th, so these must be (0,4,2) to own it. */
body.v-ui .table-wrap > table.v-finance-table .vft-group-head th {
  height: 32px;
  padding-top: 9px;
  padding-bottom: 7px;
  color: var(--gold-dim-text, var(--gold-dim));
  letter-spacing: 1.5px;
  text-align: center;
  /* Faint full-width rule under the group labels (Nate 2026-08-26), one tier
     below the 0.22 column-header rule so the two header lines don't compete. */
  border-bottom: 1px solid rgba(var(--gold-rgb), 0.14);
}
body.v-ui .v-finance-table .vft-group-head .vft-cat { text-align: left; }
/* Line hierarchy (three weights, each meaning something): one medium rule under
   the column headers, whisper hairlines between peer body rows, one strong rule
   above Totals (below). Anything darker than the Totals rule is a defect. */
body.v-ui .table-wrap > table.v-finance-table .vft-col-head th {
  color: var(--muted);
  letter-spacing: 1px;
  border-bottom: 1px solid rgba(var(--gold-rgb), 0.22);
}
body.v-ui .table-wrap > table.v-finance-table tbody td {
  border-top: 1px solid rgba(var(--gold-rgb), 0.08);
}
body.v-ui .table-wrap > table.v-finance-table tbody tr:first-child td { border-top: 0; }
body.v-ui .v-finance-table .vft-muted-row td { color: var(--muted); }
/* The rule that separates the figures being summed from the sum itself. A 30%
   gold tint all but vanished on the light canvas, so a contract-math table read
   as one undifferentiated list of numbers. --v-border-strong is the canonical
   emphasized divider and holds up in both modes. */
/* Specificity is deliberate: facelift's `body.v-ui.v-face .table-wrap > table td`
   is (0,3,2) and was silently repainting this border with --vf-line-soft; the
   extra .table-wrap class makes this (0,4,2) so the canon wins on merit. */
body.v-ui .table-wrap > table.v-finance-table .vft-total-row td {
  /* Must be the strongest line in the table in BOTH modes. --v-border-strong
     computes darker than the 0.22 header rule on the dark canvas, which made the
     sum's rule the faintest one; 45% gold reads as the top of the hierarchy on
     dark and still holds on the light canvas (30% was the tint that vanished). */
  border-top: 1px solid rgba(var(--gold-rgb), 0.45);
  font-weight: 500;
}
body.v-ui .v-finance-note {
  margin-bottom: 12px;
  color: var(--muted);
  font-size: 10px;
  font-style: italic;
  line-height: 1.55;
}
body.v-ui .v-finance-warning {
  display: block;
  margin-top: 4px;
  color: var(--red);
  font-style: normal;
}

body.v-ui .td-empty {
  padding: 48px;
  color: var(--muted);
  font-family: var(--font-body);
  font-size: 14px;
  text-align: center;
}

body.v-ui .td-muted { color: var(--muted); }
body.v-ui .td-gold { color: var(--gold); }

body.v-ui .empty-state {
  padding: 48px 20px;
  color: var(--muted);
  text-align: center;
}

body.v-ui .empty-state p { margin-top: var(--v-space-2); font-size: 14px; }

/* Dialogs.
   z contract (2026-07-25): modals sit ABOVE the appbar (1990), profile menu
   (2050), and drawers (9200) — a modal opened from a drawer must cover it —
   and BELOW the date-picker popover (10020, date inputs live inside modals),
   session-guard (10050), and toasts (100000). The old 200 put every modal
   underneath the utility bar, unreachable; pages compensated with inline
   z hacks, which are now unnecessary and should be removed on conversion. */
body.v-ui .modal-overlay {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 9500;
  align-items: flex-start;
  justify-content: center;
  overflow-y: auto;
  padding: 32px 16px;
  /* ONE backdrop treatment for every modal-family overlay. The confirm dialog
     builds a real .modal-overlay now, so there is no second backdrop class to
     keep in step. Ruling 2026-07-25: identical everywhere. */
  background: rgba(var(--bg-rgb), 0.85);
  backdrop-filter: blur(6px);
}

body.v-ui .modal-overlay.show,
body.v-ui .modal-overlay.open { display: flex; }

body.v-ui .modal {
  position: relative;
  width: 100%;
  max-width: 480px;
  margin: 0 auto;
  padding: 40px;
  background: var(--v-surface, var(--bg-mid));
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-lg);
  box-shadow: 0 20px 60px rgba(var(--bg-rgb), 0.55);
}

/* A dialog panel carries tabindex="-1" so it can be focused on open (the
   picker in vantage-sidebar.js does this, so Escape reaches the overlay's own
   listener). It is a focus TARGET, never a control the user navigates to, so
   it must not paint a focus ring. Without this the browser's default ring
   appeared on any open that followed a keyboard interaction — Escape-closing
   the picker, or typing in its search field, armed :focus-visible for the next
   open, which is why the glow looked intermittent and cleared on page load. */
body.v-ui .modal[tabindex="-1"]:focus,
body.v-ui .modal[tabindex="-1"]:focus-visible {
  outline: none;
}

body.v-ui .modal.modal-wide { max-width: 760px; }

body.v-ui .modal.modal-layout {
  max-width: 960px;
  padding: 0;
  overflow-x: hidden;
  overflow-y: auto;
}

body.v-ui .modal-sub {
  margin-bottom: 28px;
  color: var(--v-text-muted, var(--gold-light));
  font-family: var(--font-body);
  font-size: var(--v-fs-body);
  font-weight: 400;
  line-height: 1.6;
}

body.v-ui .modal-close {
  position: absolute;
  top: 14px;
  right: 14px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid transparent;
  border-radius: var(--v-radius-md);
  background: transparent;
  color: var(--muted);
  cursor: pointer;
  transition: color 0.16s, border-color 0.16s, background 0.16s;
}

/* Inside the canonical .modal-header the close is a FLEX CHILD — the legacy
   corner-pinning above fights the header row and overlaps its neighbors
   (Refresh button, 2026-07-26). Corner-pinning remains only for legacy
   modals that have no header anatomy yet. */
body.v-ui .modal-header .modal-close {
  position: static;
  top: auto;
  right: auto;
  flex-shrink: 0;
}

body.v-ui .modal-close:hover,
body.v-ui .modal-close:focus-visible {
  border-color: var(--gold-dim);
  background: rgba(var(--gold-rgb), 0.06);
  color: var(--gold);
  outline: none;
}

body.v-ui .modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 24px 32px;
  border-bottom: 1px solid var(--v-border, var(--gold-dim));
  background: var(--v-surface-raised, var(--bg-light));
}

body.v-ui .modal-body {
  display: block;
  min-width: 0;
  padding: 24px 32px;
}

body.v-ui .modal.modal-layout .modal-body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 300px;
  padding: 0;
}

body.v-ui .modal-left {
  min-width: 0;
  padding: 28px 32px;
  border-right: 1px solid var(--v-border, var(--gold-dim));
}

body.v-ui .modal-right {
  min-width: 0;
  padding: 24px;
  background: color-mix(in srgb, var(--bg) 45%, transparent);
}

body.v-ui .modal-footer {
  display: flex;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: var(--v-space-3);
  padding: 20px 32px;
  border-top: 1px solid var(--v-border, var(--gold-dim));
  background: var(--v-surface-raised, var(--bg-light));
}

/* ── Sectioned modal — a rail of sections down the left, one pane at a time ──
   For a modal that holds several unrelated groups of settings, where scrolling
   one long column loses the reader. The section rail is the table of contents
   AND the control: click a section, that pane is the body.

   Anatomy — the canonical modal, with the body switched to a desk:

     .modal-overlay > .modal.v-service-modal
       .modal-header
       .modal-body.v-modal-desk
         nav.v-modal-nav       > .v-modal-nav-item[data-pane="<pane id>"]
         div.v-modal-panes     > .v-modal-pane[id="<pane id>"]
       .v-modal-foot

   Behaviour is VantageUI.modalNav(root) in vantage-ui.js — one listener, no
   per-page switcher. `.is-active` marks the open section and pane;
   `.is-dim` marks a section that does not apply to the current record.

   This was the .tm-* family inside admin-schedule-detail.css, written for the
   task editor. The client portal's Account Settings needed the same thing, so
   rather than copy the rules across - the duplication that guarantees the two
   drift - it moved here under the canonical prefix and both pages consume it. */
/* THE FRAME. A desk only works inside a modal that is a flex column with a
   real height: that is what makes the rail run full height, pins the footer to
   the bottom, and leaves the pane as the one thing that scrolls. Without it the
   modal collapses to content height, the rail stops where its last item does,
   the footer floats up under the content, and a long section is simply cut off
   with no way to reach the rest. So the frame belongs to the component, not to
   each page that uses it. A consumer may override the width and height; it must
   never have to restate the layout. */
body.v-ui .modal.v-modal-sectioned {
  display: flex;
  flex-direction: column;
  width: min(960px, 96vw);
  max-width: min(960px, 96vw);
  height: min(720px, 88vh);
  padding: 0;
}

body.v-ui .modal.v-modal-sectioned > .modal-header,
body.v-ui .modal.v-modal-sectioned > .v-modal-foot,
body.v-ui .modal.v-modal-sectioned > .modal-footer { flex-shrink: 0; }

body.v-ui .modal-body.v-modal-desk {
  display: flex;
  flex: 1 1 auto;
  min-height: 0;
  padding: 0;
  overflow: hidden;
}

body.v-ui .v-modal-nav {
  flex-shrink: 0;
  width: 168px;
  padding: 12px 0;
  border-right: 1px solid var(--v-border, var(--gold-dim));
  background: var(--v-surface, var(--bg));
  overflow-y: auto;
}

body.v-ui .v-modal-nav-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 9px 16px;
  border: 0;
  border-left: 2px solid transparent;
  background: none;
  color: var(--v-text-muted, var(--gold-dim-text, var(--gold-dim)));
  font-family: inherit;
  font-size: 13px;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: color 0.15s, background 0.15s, border-color 0.15s;
}

body.v-ui .v-modal-nav-item:hover {
  background: rgba(var(--gold-rgb), 0.04);
  color: var(--v-text, var(--gold-light));
}

body.v-ui .v-modal-nav-item.is-active {
  border-left-color: var(--gold);
  background: rgba(var(--gold-rgb), 0.07);
  color: var(--gold);
}

body.v-ui .v-modal-nav-item:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: -2px;
}

/* A section that does not apply to this record. Still readable, still
   clickable — dimming says "not relevant here", not "forbidden". */
body.v-ui .v-modal-nav-item.is-dim { opacity: 0.4; }

body.v-ui .v-modal-panes {
  flex: 1 1 auto;
  min-width: 0;
  overflow-y: auto;
}

body.v-ui .v-modal-pane { display: none; padding: 22px 24px; }
body.v-ui .v-modal-pane.is-active { display: block; }

body.v-ui .v-modal-pane-title {
  margin-bottom: 14px;
  color: var(--gold);
  font-size: 15px;
}

/* Narrow: the rail becomes a scrolling strip above the pane, because 168px of
   fixed chrome on a phone leaves nothing for the settings themselves. */
@media (max-width: 640px) {
  /* A phone gets the whole viewport rather than a 720px box floating in it. */
  body.v-ui .modal.v-modal-sectioned {
    width: 100%;
    max-width: 100%;
    height: min(92vh, 100%);
  }
  body.v-ui .modal-body.v-modal-desk { flex-direction: column; }
  body.v-ui .v-modal-nav {
    display: flex;
    width: auto;
    padding: 0;
    border-right: 0;
    border-bottom: 1px solid var(--v-border, var(--gold-dim));
    overflow-x: auto;
  }
  body.v-ui .v-modal-nav-item {
    border-bottom: 2px solid transparent;
    border-left: 0;
    padding: 12px 14px;
    white-space: nowrap;
  }
  body.v-ui .v-modal-nav-item.is-active {
    border-bottom-color: var(--gold);
    border-left-color: transparent;
  }
  body.v-ui .v-modal-pane { padding: 20px; }
}

@media (pointer: coarse) {
  body.v-ui {
    --v-control-height: 42px;
    --v-control-pad-y: 10px;
    --v-control-pad-x: 14px;
  }
}

@media (max-width: 640px) {
  body.v-ui {
    --v-control-height: 42px;
    --v-control-pad-y: 10px;
    --v-control-pad-x: 14px;
  }
  body.v-ui .page-hero { padding: 88px 20px 24px; }
  body.v-ui .page-hero.v-context-hero { padding: 16px 16px 14px; }
  body.v-ui .v-context-hero .page-hero-inner { gap: 10px; }
  body.v-ui .v-context-hero .hero-title { font-size: 28px; }
  body.v-ui .v-context-hero .hero-actions {
    display: grid;
    width: 100%;
    grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
    gap: 6px;
  }
  body.v-ui .v-context-hero .hero-actions > button {
    width: 100%;
    padding-right: 6px !important;
    padding-left: 6px !important;
  }
  body.v-ui .page-hero-inner {
    flex-direction: column;
    align-items: flex-start;
    padding: 0;
  }
  body.v-ui .hero-title { font-size: 30px; }
  body.v-ui .tabs { padding: 0 12px; }
  body.v-ui .tab-btn { padding: 14px 16px; font-size: 11px; letter-spacing: 1px; }
  body.v-ui .main { padding: 32px 20px 60px; }
  body.v-ui .panel-header {
    flex-direction: column;
    align-items: stretch;
  }
  body.v-ui .v-page-actions { justify-content: flex-start; }
  body.v-ui .v-scroll-hint.is-visible { display: block; }
  body.v-ui .v-investment-summary { max-width: none; margin-left: 0; }
  body.v-ui .form-row { grid-template-columns: 1fr; gap: 0; }
  body.v-ui .form-card,
  body.v-ui .upload-card { padding: 24px 20px; }
  body.v-ui .modal-overlay { padding: 16px; }
  body.v-ui .modal { margin: 0 auto; padding: 32px 20px 24px; }
  body.v-ui .modal.modal-layout { padding: 0; }
  body.v-ui .modal.modal-layout .modal-body { grid-template-columns: 1fr; }
  body.v-ui .modal-left { padding: 24px 20px; border-right: 0; border-bottom: 1px solid var(--v-border, var(--gold-dim)); }
  body.v-ui .modal-right { padding: 20px; }
  body.v-ui .modal-header,
  body.v-ui .modal-footer { padding: 18px 20px; }
  body.v-ui .summary-stat {
    min-width: 50%;
    border-right: 0;
    border-bottom: 1px solid color-mix(in srgb, var(--gold) 15%, transparent);
  }
}

/* ── One control height (Nate ruling 2026-07-26): single-line fields render
   EXACTLY --v-control-height, same as every button — no more 36px inputs next
   to 32px buttons in a toolbar. Vertical centering comes from the UA (inputs)
   and the caret layout (selects); textareas stay multi-line.
   padding-top/bottom MUST be !important: dozens of hand-authored modals set
   their own inline `style="padding:10px 14px"` on fields, and inline styles
   beat a non-!important stylesheet rule regardless of specificity. Left
   unneutralized, that inline vertical padding eats into this fixed
   border-box height (forced !important by vantage-sidebar.js's field-height
   rule) and clips the glyph tops of the field's text — the "cut off halfway
   up the word" bug (found in admin-schedule-detail's Schedule Appointment
   modal, 2026-08-02; systemic to any field with inline vertical padding). ── */
body.v-ui input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"]):not([type="color"]),
body.v-ui select {
  height: var(--v-control-height);
  padding-top: 0 !important;
  padding-bottom: 0 !important;
}

/* ── Banners — the ONE inline notification band (Nate ruling 2026-07-26).
   Charcoal/neutral by default for standard notices; -success / -warning /
   -danger add a gentle tinted border + glow. Pages must not hand-roll
   notice bands: adopt .v-banner and delete the local styling. Structure:

     <div class="v-banner [v-banner-success|v-banner-warning|v-banner-danger]">
       <span data-vi-icon=info data-vi-size=15></span>
       <div class="v-banner-body">…text…</div>
       (optional right-aligned actions as trailing children)
     </div>                                                                  */
body.v-ui .v-banner {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  background: color-mix(in srgb, var(--gold-light) 4%, transparent);
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-lg, 10px);
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--gold-light);
}
body.v-ui .v-banner > [data-vi-icon] { flex: 0 0 auto; color: var(--muted); }
body.v-ui .v-banner .v-banner-body { flex: 1 1 auto; min-width: 0; }
body.v-ui .v-banner a { color: var(--gold); }
body.v-ui .v-banner-success {
  border-color: color-mix(in srgb, var(--green, #2ecc71) 40%, transparent);
  background: color-mix(in srgb, var(--green, #2ecc71) 6%, transparent);
  box-shadow: 0 0 22px color-mix(in srgb, var(--green, #2ecc71) 9%, transparent);
}
body.v-ui .v-banner-success > [data-vi-icon] { color: var(--green, #2ecc71); }
body.v-ui .v-banner-warning {
  border-color: color-mix(in srgb, var(--v-warn, #e5a54b) 45%, transparent);
  background: color-mix(in srgb, var(--v-warn, #e5a54b) 6%, transparent);
  box-shadow: 0 0 22px color-mix(in srgb, var(--v-warn, #e5a54b) 9%, transparent);
}
body.v-ui .v-banner-warning > [data-vi-icon] { color: var(--v-warn, #e5a54b); }
body.v-ui .v-banner-danger {
  border-color: color-mix(in srgb, var(--red, #c0392b) 45%, transparent);
  background: color-mix(in srgb, var(--red, #c0392b) 7%, transparent);
  box-shadow: 0 0 22px color-mix(in srgb, var(--red, #c0392b) 10%, transparent);
}
body.v-ui .v-banner-danger > [data-vi-icon] { color: #e07070; }


/* ── Canonical collapsible table section ─────────────────────────────────────
   Ruling 2026-07-26 (Nate, admin-reconciliation): the reconciliation
   "dropdown with a table under it" is THE canonical method for grouped
   tables. Native <details>:

     <details class="v-table-disclosure" open>
       <summary><h2 class="v-section-title">Title</h2> <span class="v-chip">…</span></summary>
       <div class="v-td-body"> …table-wrap/table… </div>
     </details>

   The chevron is supplied by the primitive (right-aligned ::after). Pages own
   only the summary CONTENT layout — never the frame, padding, or chevron. */
body.v-ui details.v-table-disclosure {
  background: var(--bg-mid);
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-lg);
  margin-bottom: 16px;
  overflow: hidden;
}
body.v-ui details.v-table-disclosure > summary {
  display: flex; align-items: center; gap: 12px;
  padding: 14px 20px; cursor: pointer; user-select: none; list-style: none;
}
body.v-ui details.v-table-disclosure > summary::-webkit-details-marker { display: none; }
body.v-ui details.v-table-disclosure > summary::after {
  content: '›'; margin-left: auto; flex: 0 0 auto;
  color: var(--muted, var(--gold-dim)); font-size: 13px;
  transition: transform 0.2s;
}
body.v-ui details.v-table-disclosure[open] > summary::after { transform: rotate(90deg); }
body.v-ui details.v-table-disclosure > summary .v-section-title { margin: 0; }
body.v-ui details.v-table-disclosure > summary:hover .v-section-title { color: var(--gold-light); }
body.v-ui details.v-table-disclosure > .v-td-body {
  padding: 14px 20px 18px;
  border-top: 1px solid rgba(var(--gold-rgb), 0.12);
}
/* Tables inside a disclosure use the reference (reconciliation) header
   density — the full-page header is too tall for grouped sections. */
body.v-ui details.v-table-disclosure .table-wrap > table th,
body.v-ui details.v-table-disclosure table th {
  padding-top: 8px !important;
  padding-bottom: 8px !important;
}

/* ── Invoice send summary + share link ─────────────────────────────────────
   Canonical chrome for the "sending a document to a client" surface. Promoted
   out of admin-invoices' page CSS so vantage-invoice-project.js (and any future
   send surface) renders identically on every host page. */
body.v-ui .v-send-info-block {
  background: var(--bg-light, var(--bg-mid));
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-md);
  padding: var(--v-space-4) var(--v-space-5);
  margin-bottom: var(--v-space-6);
}
/* A fixed label column, not flex + min-width. Under flex, a label wider than the
   minimum pushed its own value right, so "Payment Methods" and "Customer View"
   started their values at different x positions. The grid pins every value to
   one edge no matter how long the label is. */
body.v-ui .v-send-info-row {
  display: grid;
  grid-template-columns: 104px 1fr;
  gap: var(--v-space-3);
  align-items: baseline;
  margin-bottom: var(--v-space-2);
  font-size: 13px;
}
body.v-ui .v-send-info-row:last-child { margin-bottom: 0; }
body.v-ui .v-send-info-label { color: var(--muted); font-size: 11px; }
body.v-ui .v-send-info-val { color: var(--gold-light); }
/* Several independent facts stated as a list rather than a run-on joined by
   separators, which reads as one sentence and hides where each choice ends. */
body.v-ui .v-send-info-list {
  list-style: none;
  margin: 0;
  padding: 0;
  color: var(--gold-light);
}
/* Hanging bullets: the dot sits back in the row's gutter so the list TEXT lines
   up with every other value in the block. Indenting the text instead would put
   this one value 16px right of the rest and undo the alignment above. */
body.v-ui .v-send-info-list li {
  position: relative;
  margin-bottom: 3px;
  line-height: 1.5;
}
body.v-ui .v-send-info-list li:last-child { margin-bottom: 0; }
body.v-ui .v-send-info-list li::before {
  content: '';
  position: absolute;
  left: -9px;
  top: 0.6em;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--gold-light);
  opacity: 0.55;
}

body.v-ui .v-share-url-box {
  display: flex;
  align-items: center;
  gap: var(--v-space-3);
  margin-top: var(--v-space-4);
  padding: var(--v-space-3) var(--v-space-4);
  background: var(--bg);
  border: 1px solid var(--v-border, var(--gold-dim));
  border-radius: var(--v-radius-md);
}
body.v-ui .v-share-url-text {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--v-font-mono, monospace);
  font-size: 12px;
  color: var(--gold-light);
}


/* ── THE canonical loading indicator — the Vantage level meter ───────────────
   ONE loading mark for the whole platform. Before this, "loading" was rendered
   ~300 different ways: bare "Loading…" text in a dozen hardcoded colors, an
   hourglass emoji (⏳, which C15b flags), a clipboard emoji, and a couple of
   hand-rolled CSS spins. Every one of those is replaced by this.

   THE MARK is a five-bar audio level meter — the thing every AV, automation,
   and low-voltage contractor already reads as "signal is live". It is drawn as
   five background layers on a SINGLE element (no child spans), so a page swaps
   a "Loading…" string for one span and is done.

   UNSCOPED, on purpose — same reason as .vdt-* and .v-fab above: loading states
   render on client, assigned, and setup pages that have not opted into
   body.v-ui, and a loader that only works on enrolled pages is not canonical.
   Every color therefore carries a literal fallback, because --v-accent and
   --v-text-muted are only defined on body.v-ui / body.v-app-shell.

   USAGE — three forms, nothing else:

     Block (a panel, table body, or section that is still loading):
       <div class="v-load v-load-block" role="status">
         <span class="v-load-mark" aria-hidden="true"></span>
         <span class="v-load-lbl">Loading…</span>
       </div>

     Inline (a row, a list header, a small card):
       <span class="v-load" role="status">
         <span class="v-load-mark" aria-hidden="true"></span>
         <span class="v-load-lbl">Loading…</span>
       </span>

     Bare mark (inside a button, next to the button's own label — inherits the
     button's text color instead of the accent, which is why it takes no
     .v-load wrapper):
       <span class="v-load-mark v-load-sm" aria-hidden="true"></span> Saving…

     Cover (the surface the user just acted on is busy — see .v-load-cover at
     the bottom of this block):
       <div class="v-load-cover" role="status" aria-live="polite">
         <div class="v-load v-load-block">…</div>
       </div>

   Size comes from --v-load-h (default 15px); .v-load-sm and .v-load-lg set it.
   Do not restyle .v-load-mark on a page — change it here or not at all. */
.v-load {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  color: var(--v-accent, var(--gold, #C9A464));
  font-family: var(--v-font-ui, var(--font, 'Lato', Arial, sans-serif));
  font-size: 13px;
  line-height: 1.4;
}

.v-load-lbl {
  color: var(--v-text-muted, var(--muted, #4a7070));
  letter-spacing: 0.2px;
}

/* The meter. Five bars = five background layers, bottom-anchored, each with an
   independently animated height (background-size is a comma list, so one
   keyframe rule drives all five). Square caps are deliberate — that is what a
   real level meter looks like. */
.v-load-mark {
  --v-load-h: 15px;   /* full-scale height of a bar */
  --v-load-bw: 3px;   /* bar width */
  --v-load-bg: 3px;   /* gap between bars */
  display: inline-block;
  flex: 0 0 auto;
  width: calc(5 * var(--v-load-bw) + 4 * var(--v-load-bg));
  height: var(--v-load-h);
  background-image:
    linear-gradient(currentColor, currentColor),
    linear-gradient(currentColor, currentColor),
    linear-gradient(currentColor, currentColor),
    linear-gradient(currentColor, currentColor),
    linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position:
    calc(0 * (var(--v-load-bw) + var(--v-load-bg))) 100%,
    calc(1 * (var(--v-load-bw) + var(--v-load-bg))) 100%,
    calc(2 * (var(--v-load-bw) + var(--v-load-bg))) 100%,
    calc(3 * (var(--v-load-bw) + var(--v-load-bg))) 100%,
    calc(4 * (var(--v-load-bw) + var(--v-load-bg))) 100%;
  animation: v-load-meter 1.15s linear infinite;
  vertical-align: -0.18em;
}

.v-load-mark.v-load-sm { --v-load-h: 11px; --v-load-bw: 2.5px; --v-load-bg: 2.5px; }
.v-load-mark.v-load-lg { --v-load-h: 26px; --v-load-bw: 4px;   --v-load-bg: 5px;   }

@keyframes v-load-meter {
  0%, 100% { background-size: var(--v-load-bw) 26%, var(--v-load-bw) 62%, var(--v-load-bw) 100%, var(--v-load-bw) 44%, var(--v-load-bw) 74%; }
  20%      { background-size: var(--v-load-bw) 70%, var(--v-load-bw) 100%, var(--v-load-bw) 40%, var(--v-load-bw) 84%, var(--v-load-bw) 30%; }
  40%      { background-size: var(--v-load-bw) 100%, var(--v-load-bw) 36%, var(--v-load-bw) 78%, var(--v-load-bw) 26%, var(--v-load-bw) 96%; }
  60%      { background-size: var(--v-load-bw) 48%, var(--v-load-bw) 88%, var(--v-load-bw) 30%, var(--v-load-bw) 100%, var(--v-load-bw) 54%; }
  80%      { background-size: var(--v-load-bw) 86%, var(--v-load-bw) 30%, var(--v-load-bw) 92%, var(--v-load-bw) 40%, var(--v-load-bw) 68%; }
}

/* Block form — the panel / table-body / section state. */
.v-load.v-load-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  width: 100%;
  padding: 38px 20px;
  text-align: center;
}
.v-load.v-load-block .v-load-mark { --v-load-h: 26px; --v-load-bw: 4px; --v-load-bg: 5px; }

/* A block loader dropped into a table cell must not add its own padding on top
   of the cell's, and must center across the full colspan. */
td > .v-load.v-load-block,
th > .v-load.v-load-block { padding: 26px 12px; }

/* Cover — the fourth form (Nate 2026-08-13). A surface the user just acted on
   is BUSY: the work is running, it is not instant, and clicking anything else
   would be wrong. Put it over the surface, not beside it.

     <div class="v-load-cover" role="status" aria-live="polite">
       <div class="v-load v-load-block">
         <span class="v-load-mark" aria-hidden="true"></span>
         <span class="v-load-lbl">Matching the analyzed document to this PO…</span>
       </div>
     </div>

   The parent must be a positioning context (position:relative). It fills that
   parent, dims it, and takes the pointer so the surface underneath cannot be
   clicked while the work runs — that is the point, and it is why a status line
   in a footer is not a substitute: a message you have to notice and read is not
   feedback, it is a footnote.

   z-index is deliberately LOW (1). This covers its own parent surface, never
   the page — the modal/drawer stacking band is owned by .modal-overlay
   (§ z contract). Do not raise it to escape a parent; give the parent
   position:relative instead. */
.v-load-cover {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: color-mix(in srgb, var(--v-surface, var(--bg-mid, #1c2627)) 78%, transparent);
  backdrop-filter: blur(1.5px);
  -webkit-backdrop-filter: blur(1.5px);
  border-radius: inherit;
  cursor: progress;
}
/* Older engines without color-mix still get an opaque-enough scrim. */
@supports not (background: color-mix(in srgb, red 50%, transparent)) {
  .v-load-cover { background: rgba(var(--bg-rgb, 28, 38, 39), 0.78); }
}
.v-load-cover > .v-load.v-load-block { padding: 0; }

/* Motion is decoration here — the label already says "Loading". Freeze the
   meter at a readable staggered rest pose rather than leaving five flat bars. */
@media (prefers-reduced-motion: reduce) {
  .v-load-mark {
    animation: none;
    background-size:
      var(--v-load-bw) 34%, var(--v-load-bw) 66%, var(--v-load-bw) 100%,
      var(--v-load-bw) 66%, var(--v-load-bw) 34%;
  }
}


/* ── The in-flight BUTTON state — .is-busy ───────────────────────────────────
   A button that swaps its own label mid-action ("Save Changes" → "Saving…")
   RESIZES ITSELF, and a modal footer is right-aligned, so the neighbouring
   Cancel slides under a pointer that has not moved. Measured on the shipping
   button scale: Delete → Deleting… moves Cancel 23px; add an inline mark ahead
   of the label and it becomes 52.5px. OK → Processing… is 91.5px. That is a
   misclick on a destructive action, and on a wide enough pair it can overrun
   the footer.

   So the label is NEVER swapped and NEVER replaced. .is-busy leaves the
   button's content box byte-for-byte identical — the label just goes
   transparent, any icon child goes visibility:hidden (which keeps its box),
   and the mark is absolutely centered on top. The shift is therefore 0 by
   CONSTRUCTION, not by measurement: there is no width to compute, nothing to
   pin, and nothing that can overflow.

   Drive it with VantageUI.busy(btn) / VantageUI.idle(btn) — never by hand.
   The helper owns the disabled flag, aria-busy, and the mark element, so no
   call site does label bookkeeping and no restore path can put back the wrong
   text.

   The label stays in the accessibility tree (transparent text still reads),
   so a screen reader announces the button normally and aria-busy marks it
   in flight.

   UNSCOPED, like .v-load above and for a sharper reason: not every in-flight
   button carries a canonical role class. sign.html's is .vsig-sign-btn. Had
   this been scoped to :is(.v-btn-primary, …) the state would have silently
   no-opped there — label still visible, mark absolutely positioned against
   the page instead of the button. */
.is-busy {
  position: relative;
  color: transparent !important;
  cursor: progress;
}
/* A disabled button dims to 45%, which would take the meter with it. The
   transparent label plus a live meter already reads as "not clickable", and
   the mark has to stay legible. */
.is-busy:disabled { opacity: 1; }
/* Icon children keep their box (so nothing reflows) but must not show. */
.is-busy > *:not(.v-btn-load) { visibility: hidden; }
.v-btn-load {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
/* The mark's color is stamped inline by VantageUI.busy(), read off the button
   BEFORE the label goes transparent — so it matches whatever that button's
   foreground was, on any role and on pages with no role class at all. This is
   the fallback for a mark inserted some other way. */
.v-btn-load { color: var(--v-accent, var(--gold, #C9A464)); }

/* ── Multiple-select ──────────────────────────────────────────────────────────
   The field contract above sizes EVERY select to the single-line control
   height, with !important vertical padding. A `multiple` select is sized by its
   `size` attribute instead, so the canon clipped it to one visible row — the
   remaining options were only reachable by scrolling a 42px box.

   Fixed here in the shared system rather than in the one page that hit it, so
   any future multi-select is right by default (AGENTS.md governing objective:
   create the missing primitive once, centrally). Specificity 0,2,1 to clear the
   0,1,1 base rule. */
body.v-ui select[multiple] {
  height: auto;
  min-height: 0;
  padding-top: var(--v-control-pad-y) !important;
  padding-bottom: var(--v-control-pad-y) !important;
}
body.v-ui select[multiple] option {
  padding: 4px 6px;
  border-radius: 4px;
}

/* ═══════════════════════════════════════════════════════════════════════════
   ANNOUNCEMENTS — platform announcement placements
   ═══════════════════════════════════════════════════════════════════════════
   Rendered by vantage-announcement.js on every admin page. FOUR placements are
   selectable per campaign; only three need components here:

     center     → the canonical .modal-overlay / .modal anatomy, unchanged.
                  Nothing below styles it; it must stay identical to every other
                  Vantage modal so a design change reaches it for free.
     top_banner → .v-announce-banner
     corner     → .v-announce-corner
     fullscreen → .v-announce-takeover

   z-index, against the contract documented above .modal-overlay:
     banner    90    — the same strip band as trial-banner.js, so the two stack
                       rather than fight. The banner is ambient, not blocking.
     corner    9300  — above drawers (9200), below modals (9500): a card can
                       float over the page but must never cover a real modal.
     takeover  9500  — modal-equivalent, so it shares the modal band.
   Do NOT raise these to "make it show"; that is how the utility bar ended up
   over modals before the 2026-07-25 contract.

   Colours are theme tokens only. Note the light-mode inversion in
   vantage-brand.js: --gold-dim becomes a PALE border colour, so every text role
   here uses var(--gold-dim-text, var(--gold-dim)) instead. */

body.v-ui .v-announce-banner {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  z-index: 90;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  flex-wrap: wrap;
  padding: 10px 18px;
  background: var(--v-theme-surface-raised, var(--bg-light, var(--bg-mid)));
  border-bottom: 1px solid var(--v-theme-border, var(--gold-dim));
  color: var(--v-theme-text, var(--gold-light));
  font-size: 13px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
}
/* trial-banner.js owns the very top strip and adds this class. Sit under it
   rather than overlapping — both are fixed at the same band. */
body.v-ui.has-trial-banner .v-announce-banner { top: 30px; }

body.v-ui .v-announce-corner {
  position: fixed;
  right: 20px;
  bottom: calc(20px + var(--vcn-h, 0px));
  z-index: 9300;
  width: min(380px, calc(100vw - 40px));
  max-height: min(70vh, 620px);
  overflow-y: auto;
  padding: 20px;
  background: var(--v-theme-surface, var(--bg-mid));
  border: 1px solid var(--v-theme-border, var(--gold-dim));
  border-radius: 12px;
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.4);
}

body.v-ui .v-announce-takeover {
  position: fixed;
  inset: 0;
  z-index: 9500;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: var(--v-theme-canvas, var(--bg));
  overflow-y: auto;
}
body.v-ui .v-announce-takeover-inner {
  width: 100%;
  max-width: 640px;
  margin: auto;
}

/* ── Shared inner parts ──────────────────────────────────────────────────── */

body.v-ui .v-announce-title {
  margin: 0 0 10px;
  font-size: 19px;
  font-weight: 500;
  color: var(--v-theme-text, var(--gold-light));
}
body.v-ui .v-announce-takeover .v-announce-title { font-size: 26px; margin-bottom: 14px; }
body.v-ui .v-announce-banner .v-announce-title { font-size: 13px; margin: 0; }

/* The authored body. Stored as portable semantic HTML — the SAME string the
   email sends — so it carries no styling of its own and is themed here.
   (The email path stamps its own inline styles via inlineRichBody(); these two
   renderers are deliberately separate because their colour requirements are
   opposite.) */
body.v-ui .v-announce-body {
  font-size: 14px;
  line-height: 1.65;
  color: var(--v-theme-text-muted, var(--muted));
}
body.v-ui .v-announce-body > *:first-child { margin-top: 0; }
body.v-ui .v-announce-body > *:last-child  { margin-bottom: 0; }
body.v-ui .v-announce-body h1 { font-size: 20px; margin: 18px 0 8px; color: var(--v-theme-text, var(--gold-light)); font-weight: 500; }
body.v-ui .v-announce-body h2 { font-size: 17px; margin: 16px 0 8px; color: var(--v-theme-text, var(--gold-light)); font-weight: 500; }
body.v-ui .v-announce-body h3 { font-size: 15px; margin: 14px 0 6px; color: var(--v-theme-text, var(--gold-light)); font-weight: 500; }
body.v-ui .v-announce-body p  { margin: 0 0 10px; }
body.v-ui .v-announce-body ul,
body.v-ui .v-announce-body ol { margin: 0 0 10px; padding-left: 22px; }
body.v-ui .v-announce-body li { margin: 0 0 4px; }
body.v-ui .v-announce-body a  { color: var(--v-theme-accent, var(--gold)); text-decoration: underline; }
body.v-ui .v-announce-body img { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; }
body.v-ui .v-announce-body hr {
  border: none;
  border-top: 1px solid var(--v-theme-border, var(--gold-dim));
  margin: 14px 0;
}
body.v-ui .v-announce-banner .v-announce-body p { display: inline; margin: 0; }

body.v-ui .v-announce-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: 18px;
}
body.v-ui .v-announce-banner .v-announce-actions { margin-top: 0; }

/* ── Survey ──────────────────────────────────────────────────────────────── */

body.v-ui .v-announce-survey {
  margin-top: 18px;
  padding-top: 16px;
  border-top: 1px solid var(--v-theme-border, var(--gold-dim));
}
body.v-ui .v-announce-q { margin-bottom: 16px; }
body.v-ui .v-announce-q:last-child { margin-bottom: 0; }
body.v-ui .v-announce-q-prompt {
  display: block;
  margin-bottom: 8px;
  font-size: 14px;
  color: var(--v-theme-text, var(--gold-light));
}
body.v-ui .v-announce-opt {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 7px 10px;
  margin-bottom: 5px;
  border: 1px solid var(--v-theme-border, var(--gold-dim));
  border-radius: 7px;
  font-size: 13px;
  color: var(--v-theme-text-muted, var(--muted));
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease;
}
body.v-ui .v-announce-opt:hover,
body.v-ui .v-announce-opt.is-checked {
  border-color: var(--v-theme-accent, var(--gold));
  background: rgba(var(--gold-rgb, 201, 164, 100), 0.10);
  color: var(--v-theme-text, var(--gold-light));
}
body.v-ui .v-announce-opt input { accent-color: var(--v-theme-accent, var(--gold)); margin: 0; }

/* Rating scale — buttons rather than a range input, which cannot be themed
   consistently across browsers. */
body.v-ui .v-announce-scale { display: flex; gap: 6px; flex-wrap: wrap; }
body.v-ui .v-announce-scale-btn {
  min-width: 38px;
  padding: 7px 0;
  border: 1px solid var(--v-theme-border, var(--gold-dim));
  border-radius: 7px;
  background: transparent;
  color: var(--gold-dim-text, var(--gold-dim));
  font-family: inherit;
  font-size: 13px;
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
body.v-ui .v-announce-scale-btn:hover { border-color: var(--v-theme-accent, var(--gold)); }
body.v-ui .v-announce-scale-btn.is-selected {
  border-color: var(--v-theme-accent, var(--gold));
  background: rgba(var(--gold-rgb, 201, 164, 100), 0.18);
  color: var(--v-theme-text, var(--gold-light));
}

@media (max-width: 620px) {
  body.v-ui .v-announce-corner {
    right: 12px;
    left: 12px;
    width: auto;
    bottom: calc(12px + var(--vcn-h, 0px));
  }
  body.v-ui .v-announce-banner { font-size: 12px; padding: 9px 12px; gap: 10px; }
  body.v-ui .v-announce-actions { justify-content: stretch; }
}

/* ── Expandable table group ────────────────────────────────────────────────────
   A parent row that opens to reveal history rows BELONGING TO THE SAME TABLE.

   Promoted from admin-contracts.css (2026-08-10), where it was built for
   contract revisions and then wanted verbatim by the invoice list for credit
   memos. The structural half lives here; domain semantics (revision chips,
   contract states, delta colouring) stay with the page that means them.

   THE LOAD-BEARING RULE, carried over from the original: child rows are REAL
   ROWS OF THE SAME TABLE, never a nested grid inside a `<td colspan>`. One set
   of column widths is resolved for every row in the tbody, so a child's figures
   sit directly under the parent header that names them. A nested div grid cannot
   align with auto-sized columns at all — that was the shape this replaced, and
   it drifted the moment any cell changed width.

   Specificity: `body.v-ui .table-wrap > table td` owns td padding, colour and
   border-top. A bare `.v-exp-child > td` is (0,2,1) and loses silently, so
   anything touching a td property the canon also sets keeps the prefix. */
body.v-ui .v-exp-cell { padding-right: 0; }
body.v-ui .v-exp-toggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--v-control-height-compact); height: var(--v-control-height-compact);
  padding: 0; border: 0; border-radius: var(--v-radius-sm);
  background: transparent; color: var(--muted); cursor: pointer;
  transition: color 0.15s, background 0.15s;
}
body.v-ui .v-exp-toggle:hover { color: var(--gold); background: rgba(var(--gold-rgb), 0.08); }
body.v-ui tr.v-exp-row.is-open > td { background: var(--v-theme-table-hover, var(--v-theme-chrome-active, var(--bg-light))); }

body.v-ui .table-wrap > table tr.v-exp-child > td { background: var(--bg); }

/* The group's own header row. It IS a header, so it uses the canonical header
   tier (10px / 2px tracking / uppercase / --gold on --bg-light) rather than
   --muted, which reads as disabled data instead of column labels. */
/* The subhead's cells are `th`, not `td`, so they INHERIT the canonical header
   tier from `body.v-ui .table-wrap > table th` — padding, size, tracking, caps,
   colour, all of it. Nothing is re-declared here.
   It previously restated padding/font-size/letter-spacing at slightly different
   values, which is exactly why it read as a chunkier, unrelated band rather than
   as the same header one level down. A sub-header should BE the header, not a
   near-copy of it; only the background and the group indent differ. */
body.v-ui .table-wrap > table tr.v-exp-subhead > th {
  background: var(--v-theme-table-header, var(--bg-light));
  border-top-color: rgba(var(--gold-rgb), 0.22);
}
body.v-ui .table-wrap > table tr.v-exp-subhead > th.v-table-num { text-align: right; }
body.v-ui .table-wrap > table tr.v-exp-subhead > th.v-exp-title,
body.v-ui .table-wrap > table tr.v-exp-child > td.v-exp-label { padding-left: 38px; }

/* The rail ties the group to the row it hangs off. An inset shadow, not a
   border, so it costs the column no width and cannot disturb the grid. */
body.v-ui .table-wrap > table tr.v-exp-subhead > th.v-exp-rail,
body.v-ui .table-wrap > table tr.v-exp-child > td.v-exp-rail {
  padding: 0; box-shadow: inset 3px 0 0 rgba(var(--gold-rgb), 0.55);
}

/* Separators inside the group are quiet; the boundary back out is not. The
   adjacent-sibling selector finds it with no JS marker. */
body.v-ui .table-wrap > table tr.v-exp-child + tr.v-exp-child > td { border-top-color: rgba(var(--gold-dim-rgb), 0.15); }
body.v-ui .table-wrap > table tr.v-exp-child + tr.v-exp-row > td { border-top-color: rgba(var(--gold-rgb), 0.22); }

body.v-ui .v-exp-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
body.v-ui .v-exp-actions { text-align: right; white-space: nowrap; }
body.v-ui .v-exp-actions > * + * { margin-left: 6px; }

/* THE […] SLOT IS ALWAYS RESERVED (Nate, 2026-08-17: "can we align the View
   buttons whether there's a ... button or not? i dont like the shift").
   An actions cell is right-aligned, so a row whose overflow menu is empty —
   a cancelled contract has nothing to resend, a revision is a frozen document
   with no actions — used to let its View button slide into the trigger's
   place, and the primary control zig-zagged down the column.
   The spacer is the trigger's exact footprint (a compact icon button plus the
   6px sibling gap), added only to a cell that HAS a control but no `.v-more`,
   so a genuinely empty cell stays empty. No caller has to remember anything:
   emitting VantageUI.moreBtn() is what turns the spacer off.
   Scoped to `.v-exp-actions` — the expandable document/invoice lists, the only
   place the menu is CONDITIONAL. `.v-table-actions` pages (proposals, change
   orders, inventory, super-admin) render their action set on every row, so
   reserving a slot there would only indent whole tables that never shift. */
body.v-ui .v-exp-actions:has(button):not(:has(.v-more))::after {
  content: "";
  display: inline-block;
  width: var(--v-control-height-compact, 28px);
  margin-left: 6px;
  vertical-align: middle;
}
