/* App-owned overrides layered on top of the Sneat/Vuexy theme + vendor libs.
   Loaded last in <head> so these win on equal specificity. */

/* ============================================================================
   DESIGN TOKENS
   ----------------------------------------------------------------------------
   The app's shared UI vocabulary. See docs/ui-ux-saas-standard-plan.md.

   Why these exist: before this block, every dimension in the UI was a local
   literal — 639 inline `style="…"` attributes across the templates, with e.g.
   the filter select width re-chosen page by page. Tokens give the component
   partials (layout/partials/_page_header, _list_toolbar, _sort_link,
   _empty_state) one place to read those values from.

   Naming: `--app-*` so these never collide with Bootstrap's own `--bs-*`.

   Seeded from the values ALREADY most common in the codebase, so introducing
   this block changes nothing visually. Change a value here only when the
   intent is to move it everywhere.
   ========================================================================== */
:root {
  /* Filter/toolbar control widths — from the prevailing list-page filter bar
     (status select 140px, search 170px). Date inputs need room for a rendered
     locale date plus the native picker affordance. */
  --app-filter-w-select: 140px;
  /* A picker whose options are NAMES rather than short states. 140px fits "Any
     status"; it truncates "Local Test Number (Default)" to "Local Test Nur", which
     is the one thing a number picker must not do. Opt in with
     `app-filter-select--wide` (the same grammar as `app-filter-search--grow`). */
  --app-filter-w-select-wide: 220px;
  --app-filter-w-search: 170px;
  --app-filter-w-date: 150px;

  /* ---- List-table COLUMN widths -------------------------------------------
     Before these, no column-width mechanism existed anywhere in the app: no
     `table-layout`, no column class, only a handful of banned inline
     `style="width:…"` attributes. Every list column was auto-width, so the
     browser handed each column a roughly equal slice of the card's surplus
     width and the widest column on the employee list ended up being the one
     rendering the word "System" (measured: Name 327px, Phone 252, Status 158,
     Last Modified 267, Modified By 368, Actions 240). Bug-doc items 5.1/10/13.

     FOUR semantic widths, not a pile of pixel classes — a list column is one of
     four things, and naming the ROLE means every screen retunes from one number:

       narrow  — a badge, a short enum, a count, a code, a phone number
       meta    — a rendered date, a date+time, or the two-line actor stack
       name    — the primary identity column the row is clicked through from
       actions — the row-action cluster (<= 4 `btn-sm btn-icon`, or a dropdown)

     Plus ONE special case, `control`, for a cell holding a live form control —
     see .app-col-control below for why it is not one of the four.

     Values are px (like --app-filter-w-* above) so a column token and a filter
     token can be compared without a rem conversion. They are sized from what the
     CELLS hold, never from the header text — which is the whole bug: "Modified
     By" is a wide heading over a narrow value. `actions` is four 30px icon
     buttons plus their gaps and the cell's padding; `meta` is
     "14-Sep-2026 10:45 PM" at the theme's body size; `name` is the avatar plus
     the `truncate-lg` (200px) the identity cells already cap themselves at.

     These are HINTS, not caps — see the .app-col-* rules for why that matters. */
  --app-col-w-narrow: 112px;
  --app-col-w-meta: 168px;
  --app-col-w-name: 280px;
  --app-col-w-actions: 144px;
  --app-col-w-control: 230px;
  /* The free-text column (a message body): a floor so it cannot collapse to one
     character a line. See .app-col-text. */
  --app-col-w-text-min: 256px;

  /* Floor for a list card showing its EMPTY state. A tall card is doing useful
     work when a list has nothing in it (the icon + heading + explanation + CTA
     need the room, and a 40px-high "no rows" card reads as a broken page); it is
     doing damage when the list has one row and two thirds of the screen is white.
     See .card.list-table-card. */
  --app-list-card-empty-min-h: 24rem;

  /* Floor for a list card in SHORT-VIEWPORT conditions only. The card normally
     fills the space the page's flex column leaves it (see .card.list-table-card),
     which needs no number at all; this is the floor for the case where that
     leftover space is tiny -- a laptop in a small window, or a page whose header
     has grown -- so the card never collapses to a sliver. It is deliberately
     SMALLER than the empty-state floor: a short list needs room to not look
     broken, not room for an icon + heading + CTA. */
  --app-list-card-min-h: 12rem;

  /* Gap scale for toolbars, header action clusters, and inline control rows.
     Mirrors the Bootstrap gap utilities the templates already reach for
     (gap-2 = .5rem is the established default for these rows). */
  --app-gap-xs: 0.25rem;
  --app-gap-sm: 0.5rem;
  --app-gap-md: 1rem;
  --app-gap-lg: 1.5rem;

  /* ---- Vertical rhythm / density -------------------------------------------
     The stock theme spends ~8rem of vertical chrome around a list before the
     first row of data appears, and every gap is the SAME 1.5rem — page padding,
     header gap, card padding — so nothing establishes hierarchy and short lists
     read as mostly empty space:

       navbar → page top ............ 1.5rem   (container-p-y)
       header → card ................ 1.5rem   (mb-4)
       table header padding ......... 1.161rem x2 = 2.32rem   <- largest single item
       row cell padding ............. 0.782rem x2 = 1.56rem
       card → pager ................. 1.5rem

     These tokens tighten that budget and, more importantly, DIFFERENTIATE the
     gaps so the page has a rhythm instead of one flat interval. The rule of
     thumb: gaps between *sections* (header→card) stay larger than gaps *within*
     a section (rows), and the table header — pure chrome — gives up the most.

     Data density is the point of a list screen: more rows visible without
     scrolling directly reduces how much the user has to page. Change these to
     retune density globally rather than editing per-page utilities. */
  --app-page-header-mb: 1rem;
  --app-section-gap: 1rem;

  /* Table density. Header padding is chrome and yields the most; row padding
     stays comfortably above the ~44px touch-target floor for the row actions. */
  --app-table-header-py: 0.75rem;
  --app-table-cell-py: 0.625rem;
  /* Horizontal cell padding on laptop screens (below 1600px). The theme's 1.25rem
     per side is a third of a nine-column list's width, and it is what pushed lists
     wider than a 1280/1366 content area -- at which point the pinned actions column
     sits on top of the column before it (bug-doc items 172, 173, 175). */
  --app-table-cell-px-compact: 0.625rem;

  /* Empty state: vertical breathing room for the full (icon + heading + copy
     + CTA) treatment, and the muted icon disc that sits above it. */
  --app-empty-state-py: 3rem;
  --app-empty-state-icon-size: 3.5rem;
}

/* ---- Toolbar control widths -------------------------------------------------
   Utility classes wrapping the width tokens, so a filter control carries a
   class instead of an inline style. Width only — the caller still supplies
   form-select-sm / form-control-sm for sizing per the button-size rule.

   `flex: none` matters: these sit in a `d-flex` toolbar, where a bare width on
   a flex item is still a *hint* — the item can be compressed below it, or (for
   an <input>) stretched. Pinning flex-grow/shrink to 0 makes the token width
   the actual rendered width. */
.app-filter-select,
.app-filter-search,
.app-filter-date {
  flex: none;
}

.app-filter-select {
  width: var(--app-filter-w-select);
}

.app-filter-select--wide {
  width: var(--app-filter-w-select-wide);
}

.app-filter-search {
  width: var(--app-filter-w-search);
}

/* The search box and its magnifier are one input group (BD-225 #219); the group
   takes the button's width on top of the token, so the text box itself keeps it. */
.input-group.app-filter-search {
  width: calc(var(--app-filter-w-search) + 2.5rem);
}

/* The theme's `.input-group:has(button) .form-control` forces the DEFAULT-size
   padding with !important, so a small input group holding a button (the search box
   and its magnifier) rendered ~5px taller than the -sm Filters / Add buttons beside
   it. Restore the -sm padding for small groups. */
.input-group.input-group-sm:has(button) .form-control {
  padding: 0.215rem calc(0.75rem - var(--bs-border-width)) !important;
}

/* The same kind of slip on an invalid field: the theme's `.form-control.is-invalid`
   (and is-valid) swaps in a 2px border with the DEFAULT-size padding, so a small
   field grew ~4.75px when it turned red and pushed the rows below it down on a
   failed submit (New Receipt -> Deposit ledger, 2026-10-10). Keep the -sm padding
   and give the extra border pixel back from it, so the height does not change. */
.was-validated .form-control-sm:invalid,
.was-validated .form-control-sm:valid,
.form-control-sm.is-invalid,
.form-control-sm.is-valid {
  padding: calc(0.215rem - 1px) calc(0.75rem - 1px);
}

.app-filter-date {
  width: var(--app-filter-w-date);
}

/* Select2 replaces the <select> with a generated `.select2-container` sibling
   and initialises it with `width: '100%'` (see base.html) — so the width token
   on the original <select> is NOT inherited by what the user actually sees, and
   the container expands to fill the toolbar, pushing the remaining controls
   onto a second line.

   Previously each page prevented this with a hard-coded wrapper
   (`<div style="width:140px">`). Constraining the generated container here
   removes the need for that wrapper and keeps the width in the token layer. */
.app-filter-select+.select2-container {
  width: var(--app-filter-w-select) !important;
  flex: none;
}

.app-filter-select--wide+.select2-container {
  width: var(--app-filter-w-select-wide) !important;
}

.app-filter-date+.select2-container {
  width: var(--app-filter-w-date) !important;
  flex: none;
}

/* ---- List toolbar: search + Filters drawer + chips (2026-09-27 redesign) ------
   One pattern on every list page (layout/partials/_list_toolbar.html, behaviour in
   js/list_filters.js, research in docs/research/2026-09-list-filters-ux.md).

   Inside a page header the toolbar sits IN the header's row, between the title and
   the page actions (rules below, owner 2026-09-23); a toolbar outside any header
   (a card's own filter bar) keeps the plain full-width row that follows. */
/* Its own full-width row (page_header puts it there). NOT `flex: 1 1 auto`: the
   content container is a column flexbox, where that grows the toolbar DOWNWARDS
   (a 230px blank band above the list on /customers). Inside a card header -- the
   one row context tests/unit/test_list_toolbar_layout_guard.py allows -- it sits
   beside the card's title and takes the free space, so its controls still end at
   the header's right edge. */
.app-list-toolbar {
  width: 100%;
  margin-bottom: 1rem;
}

.card-header .app-list-toolbar {
  width: auto;
  flex: 1 1 auto;
  margin-bottom: 0;
}

/* In the page header's row (owner, 2026-09-23 -- a second row holding only the search
   box and the Filters button left a wide empty band under the title): title on the
   left; the toolbar, then the page's actions, on the right, Add last. The toolbar
   takes the auto margin, so the actions sit right after it and end at the edge. */
/* Basis 22rem, growing to 30rem, never under 18rem: a flex line breaks on the items'
   BASE sizes before anything shrinks, so a 30rem basis wrapped the row (to stacked)
   where a narrower search box would have fitted -- /customers at 1280. */
[data-page-header] > .app-list-toolbar {
  width: auto;
  flex: 1 1 22rem;
  max-width: 30rem;
  min-width: 18rem;
  margin: 0 0 0 auto;
}

/* In the header the search box and the Filters button stay on one line together;
   when the header has no room for them, the whole toolbar stacks (list_filters.js). */
[data-page-header] > .app-list-toolbar > .app-list-toolbar-row {
  flex-wrap: nowrap;
}

[data-page-header].app-page-header--toolbar > .app-page-actions {
  margin-left: 0 !important;
}

/* The title block gives way, so a long subtitle wraps under the title instead of
   taking the whole line and pushing the toolbar down (/fabrication, /wa-marketing/jobs
   stacked even at 1440 -- the Filters button alone on a line of its own). */
[data-page-header].app-page-header--toolbar > :first-child {
  flex: 1 1 12rem;
  min-width: 0;
}

[data-page-header] > .app-list-toolbar .app-filter-search--grow {
  flex: 1 1 12rem;
}

/* A tab row in the title slot (`title_meta` holding a `data-priority-nav` row, marked
   by _page_header.html): the slot takes the line's free width, and may give it back
   below its content on a narrow screen, where priority_nav.js moves tabs into More.
   Sized by its content instead, it shrank to what the script had left in the row --
   /leave-requests at 1440px showed "More (4)" on a 123px row (QA 2026-10-23). Both
   levels: the header's flex item and the title block inside it. `:first-child` puts
   the first rule level with `.app-page-header--toolbar > :first-child` above (whose
   12rem basis it overrides on a page with a toolbar, like the leave queue), and it
   comes later, so it wins. */
[data-page-header] > :first-child.app-page-header-title--nav {
  flex: 1 1 auto;
  min-width: 0;
}

[data-page-header] > :first-child.app-page-header-title--nav > * {
  flex: 1 1 auto;
  min-width: 0;
}

/* The row did not fit (list_filters.js measures it): the toolbar takes its own line
   under the title and actions, full width, the search box filling it, Filters last. */
[data-page-header].app-page-header--stacked > .app-list-toolbar {
  order: 3;
  flex: 1 1 100%;
  max-width: none;
  margin-left: 0;
}

[data-page-header].app-page-header--stacked > .app-page-actions {
  margin-left: auto !important;
}

[data-page-header].app-page-header--stacked > .app-list-toolbar .app-filter-search--grow {
  flex: 1 1 auto;
}

/* Active-filter chips, moved by list_filters.js onto the line under the header. */
[data-page-header] + .app-filter-chips {
  margin: -0.75rem 0 1rem;
}

.app-list-toolbar-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 0.5rem;
}

.app-filter-search--grow {
  flex: 0 1 22rem;
  width: auto;
  min-width: 10rem;
}

/* At phone width the search box takes the row and the Filters button sits beside
   it, still at the right edge. */
@media (max-width: 575.98px) {
  .app-filter-search--grow {
    flex: 1 1 10rem;
  }
}

/* The drawer: Bootstrap's `.offcanvas-end` look, opened by list_filters.js (not
   Bootstrap's own behaviour — its focus trap fights Select2/flatpickr, which render
   on <body>). An overlay with a backdrop: the page under it does not move. Above the
   theme's fixed navbar, so its header is never covered. */
.app-filter-drawer.offcanvas {
  --bs-offcanvas-width: 24rem;
  z-index: 1095;
  max-width: 100vw;
}

.app-filter-backdrop.offcanvas-backdrop {
  z-index: 1090;
}

@media (max-width: 575.98px) {
  .app-filter-drawer.offcanvas {
    --bs-offcanvas-width: 100vw;
  }
}

.app-filter-drawer .offcanvas-header {
  border-bottom: 1px solid var(--bs-border-color);
}

/* The drawer takes focus when it opens (list_filters.js); the dialog itself needs no
   ring -- its controls keep theirs. */
.app-filter-drawer:focus {
  outline: 0;
}

/* Bug-doc #249: the body used to grow to the drawer's full height (Bootstrap's
   flex-grow), so Apply / Clear all sat at the very bottom, a screen away from two
   filter fields. It now takes its content's height and the footer follows the last
   field; a long filter list still shrinks the body, which scrolls, and the footer
   stays in view. */
.app-filter-drawer .offcanvas-body {
  padding-top: 1rem;
  flex: 0 1 auto;
}

.app-filter-drawer-footer {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
  padding: 0.75rem var(--bs-offcanvas-padding-x, 1.5rem);
  border-top: 1px solid var(--bs-border-color);
}

/* Select2 draws its list on <body> at z-index 1031 (below). While the drawer is
   open, a list opened from it must sit above the drawer, or only the part past the
   drawer's edge would show. */
body.app-filter-drawer-open .select2-container--open {
  z-index: 1100;
}

/* Same for the comboTree ledger tree. Its detached panel already sits at 1095 to
   clear this theme's modals -- the SAME z-index as the filter drawer -- so inside
   an open drawer the two tie and DOM order decides, which drew the tree behind the
   drawer it was opened from. Lift it for the duration, as Select2's list is. */
body.app-filter-drawer-open .ct-drop-down-container--detached {
  z-index: 1100;
}

/* A Select2 list opened from a page's controls was drawn UNDER a floating layer
   (BD-188 #192, BD-191 #195): the theme sets .select2-dropdown to z-index 1000.
   One step above the page's own floating layers, for every Select2 on the page. A
   picker inside a dialog attaches to its .modal and stacks inside the modal. */
.light-style .select2-dropdown,
.dark-style .select2-dropdown {
  z-index: 1031;
}

/* In the drawer the fields stack, one per line, each with its label above it. */
.app-filter-panel-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 1rem;
}

.app-filter-field {
  min-width: 0;
}

.app-filter-field-label {
  font-size: 0.8125rem;
  color: var(--bs-secondary-color);
  margin-bottom: 0.25rem;
}

/* In the drawer every control takes its field's full width; the toolbar's fixed
   width tokens are for inline rows only. */
.app-filter-drawer .app-filter-select,
.app-filter-drawer .app-filter-date,
.app-filter-drawer .app-filter-search,
.app-filter-drawer .form-select,
.app-filter-drawer .form-control {
  width: 100%;
  flex: auto;
}

.app-filter-drawer .app-filter-select+.select2-container,
.app-filter-drawer .app-filter-date+.select2-container,
.app-filter-drawer .select2-container {
  width: 100% !important;
  /* A page-level cap written for the old inline row (invoices' customer picker had
     `max-width: 220px`) must not leave one field narrower than its neighbours. */
  max-width: none !important;
  min-width: 0 !important;
}

/* A filter multi-select never wraps to a second row: chips stay on one line and a
   long name truncates (base.html caps the chips and summarises past two). */
.app-filter-select+.select2-container .select2-selection--multiple .select2-selection__rendered {
  display: flex;
  flex-wrap: nowrap;
  overflow: hidden;
}

.app-filter-select+.select2-container .select2-selection--multiple .select2-selection__choice {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A page that sized a control for the old inline row (`style="width:150px"`,
   `min-width`/`max-width` on the control or on a wrapper div) must not shrink it in
   the drawer: every field in the drawer is the same full width. */
.app-filter-drawer .app-filter-field [style*="width"] {
  width: 100% !important;
  max-width: none !important;
  min-width: 0 !important;
}

/* A badge inside a button must not decide the button's height (CA-001).
   `.btn` computes to a flex container in this theme, so the button's height is
   its tallest child plus padding. The theme's `.badge` carries its own
   padding-block (4.77px at the toolbar's 11.27px font), which makes it 20.80px
   against the button's own 18.02px line box — so the Filters button measured
   32.95px beside its 30.16px neighbours and sat 1.40px higher, but ONLY while a
   filter was active and the count badge was shown. That is the state a tester
   screenshots and not the state a verifier loads fresh, which is why five rounds
   of "the toolbar is aligned to 0.4px" were all true and all beside the point.

   Sizing the badge from the button's own line box instead of its own padding
   takes both deltas to exactly 0.00 and leaves the pill 24.21 x 16.91px. Keyed
   on `.btn > .badge` rather than on the filter button, so the next badge put
   inside a button cannot bring the misalignment back. */
.btn>.badge {
  line-height: 1;
  padding-block: 0.25em;
}

/* The active filters, under the toolbar row and right-aligned like it (BD-201 #45,
   owner decision 2026-09-23: filters sit on the right, on every list page). */
.app-filter-chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 0.5rem;
  margin-top: 0.5rem;
}

.app-filter-chips[hidden] {
  display: none;
}

.app-filter-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  font-weight: 500;
}

.app-filter-chip-remove {
  border: 0;
  background: none;
  padding: 0;
  color: inherit;
  line-height: 1;
}

.app-filter-clear-all {
  white-space: nowrap;
}

/* ---- Hand-rolled page headers: the action cluster stays on the right --------
   page_header pins its action cluster right with `ms-auto justify-content-end`
   (bug-doc item 56): `justify-content-between` lays out each wrapped LINE on its
   own, so once the title and the actions no longer fit side by side, the actions
   land alone on line 2 and `between` puts them at its START. 39 pages still
   hand-roll that header row (the customer / vendor / project heads, reports,
   portal pages) without the pin; at 390 px the customer page's Back button ended
   at 226 of 374. The same pin, once, for every page-level header row. On one
   line it changes nothing -- `between` already puts the last item at the end. */
.container-p-y > .d-flex.justify-content-between > .d-flex:last-child:not(:first-child) {
  margin-left: auto;
  justify-content: flex-end;
  flex-wrap: wrap;
}

/* ...and the row itself wraps, as page_header's does. Some hand-rolled rows have no
   `flex-wrap`, so at 390 px their controls stuck out past the screen: a date input
   on /reports/daily (57 px of sideways scroll), "Substitution details" on
   /vouchers/templates (90 px). */
.container-p-y > .d-flex.justify-content-between {
  flex-wrap: wrap;
  row-gap: 0.5rem;
}

/* ---- The sidebar's swipe-in zone stays in the gutter ------------------------
   Below xl the theme lays a fixed, invisible `.drag-target` strip (40 px wide,
   z-index 1036) down the left edge so a swipe from the edge opens the menu
   (main.js). The content starts 16 px in, so the strip swallowed every tap on the
   first 24 px of it: at 390 px the order desk's row checkboxes (x = 26) and many
   first-column links could not be pressed (2026-09-27 audit, elementFromPoint).
   An edge swipe starts at the edge; 1 rem -- the gutter -- is enough to catch it. */
.drag-target {
  width: 1rem !important;
}

/* ---- Empty state ------------------------------------------------------------
   The full empty-state treatment: a muted icon disc, a heading, an explanatory
   line, and an optional primary CTA. Rendered inside a table's full-width
   colspan cell (or any block container) by layout/partials/_empty_state.html.

   Replaces the bare grey `<td colspan>` sentence that ~60 tables used, which
   told the user nothing about what to do next. */
.app-empty-state {
  padding-top: var(--app-empty-state-py);
  padding-bottom: var(--app-empty-state-py);
  text-align: center;
}

.app-empty-state-icon {
  width: var(--app-empty-state-icon-size);
  height: var(--app-empty-state-icon-size);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background-color: var(--bs-secondary-bg, #f5f5f9);
  color: var(--bs-secondary-color, #a5a3ae);
  margin-bottom: var(--app-gap-md);
}

.app-empty-state-title {
  font-weight: 500;
  margin-bottom: var(--app-gap-xs);
}

.app-empty-state-text {
  color: var(--bs-secondary-color, #a5a3ae);
  margin-bottom: 0;
}

.app-empty-state-text+.app-empty-state-cta {
  margin-top: var(--app-gap-md);
}

/* The empty-state cell WRAPS. List wrappers are `.table-responsive.text-nowrap`
   and the full-width colspan cell inherited the nowrap, so an explanatory line
   became one unbroken run that set the whole table's width — Background Jobs'
   "No jobs started from this tenant yet" row made its table 2,687px wide in a
   1,132px card, message running off screen (sweep 5, item 2). Wrapping here drops
   the cell's min-content to its longest word, so the table sizes to its card and
   the message breaks inside it. Keyed off the marker both empty-row macros emit,
   so every list gets it; `anywhere` also breaks a long token (a URL, a file name)
   instead of letting it overflow. */
.app-empty-state-row>td {
  white-space: normal;
  overflow-wrap: anywhere;
}

/* ---- Global page-load progress bar -------------------------------------
   Thin bar fixed to the viewport top, shown while a full-page navigation
   (link click / non-AJAX form submit) is in flight, so a slow page read
   as "loading" instead of "did my click register?" — see main.js for the
   show/hide logic (window.appPageProgress). */
#app-page-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  width: 0;
  background: var(--bs-primary);
  z-index: 2000;
  opacity: 0;
  transition: width .3s ease, opacity .2s ease;
  pointer-events: none;
}

#app-page-progress.is-active {
  opacity: 1;
}

/* ---- Dashboard KPI tiles: align value/icon row across cards -----------------
   Each tile's icon+label+value row sits at a fixed min-height so the h4 value
   lines up across a row of cards even when a label wraps to two lines (e.g.
   "Vendor payables" vs "Present today").

   The trailing <small> (a "View X" link, or nothing at all when a tile has no
   link — e.g. "Payables outstanding" with PARTY_MONEY off) is a reserved-height
   footer slot, not just an optional line pushed down by mt-auto: `.dash-autofit`
   is a CSS grid, and grid cells default to `align-items: stretch`, so a short
   card (no <small>) sharing a row with a taller one (has <small>) gets
   stretched to match — mt-auto then shoves that stretched empty space above an
   absent footer instead of the card just being shorter. Reserving the slot's
   height unconditionally means every tile is the same height on its own
   content, before stretch ever enters into it, so pairing within a row no
   longer matters. */
.kpi-tile .card-body {
  display: flex;
  flex-direction: column;
}

.kpi-tile .card-body>.d-flex.align-items-center {
  min-height: 2.5rem;
}

.kpi-tile .card-body>small {
  display: block;
  min-height: 1.25rem;
  margin-top: auto;
  padding-top: .5rem;
}

/* ---- Dashboard: layout that adapts to what actually rendered ----------------
   The dashboard's cards are permission- and entitlement-gated, so the set that
   renders varies per tenant/role — but a hardcoded Bootstrap column width does
   not. A fixed `col-sm-4` leaves a lone surviving KPI stranded at a third of
   the page; a fixed `col-xl-8`/`col-xl-4` pair collapses to one third-width
   card in a full-bleed row when its partner is gated away.

   These rules let the *rendered* children drive the widths, so no Jinja gate
   has to pre-count its siblings:

   - `.dash-autofit` — a grid whose tracks are sized by `auto-fit`, so N cards
     always divide the row evenly however many survive gating (one card fills
     the row, two go half/half, three go thirds…), capped by a min track width
     so tiles never stretch absurdly wide on a large display.
   - `.dash-split` — the asymmetric 8/4-style pairs. When both children render
     it keeps the intended weighting; when only one survives, `:only-child`
     promotes it to the full row.

   Both replace the `.row`/`.col-*` classes on the wrapper, not on the cards, so
   card markup (and `.kpi-tile`) is untouched. The gap is the 1.5rem Bootstrap's
   `.g-4` applies, so these rows keep the spacing every other row in the app has
   — set literally, since `--bs-gutter-x` is scoped to `.row`, which these
   wrappers deliberately no longer are. */
/* The wrappers keep their Bootstrap `.row`/`.col-*` classes underneath these,
   so a page that gets the theme but not this file still lays out in columns
   instead of collapsing to full-width strips. `display: grid` makes the
   columns' float/width inert, but `.row`'s negative margins and `.col-*`'s
   gutter padding would double up with `gap` — so they are zeroed here and the
   grid owns the spacing whenever these rules apply.

   Each selector carries its own `display: grid` + `gap` + margin reset rather
   than relying on `.dash-autofit-wide`/`.dash-autofit-tile` being combined
   with the base `.dash-autofit` class (`class="dash-autofit dash-autofit-tile"`)
   — templates use them as standalone wrapper classes
   (`class="dash-autofit-tile mb-4"`), so without this a `.dash-autofit-tile`
   alone only set `grid-template-columns` and never actually turned the
   wrapper into a grid: it fell back to normal block layout, one full-width
   `<div>` per row, with `text-center` centering the icon inside each. */
.dash-autofit,
.dash-autofit-wide,
.dash-autofit-tile,
.dash-split {
  display: grid;
  gap: 1.5rem;
  margin-right: 0;
  margin-left: 0;
}

/* Tiles: even tracks, min 15rem, collapsing to a single column on narrow
   screens (matching the sm breakpoint the tiles previously stacked at). */
.dash-autofit {
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
}

/* Wide cards (tables, trend bars, lists) need more room before they wrap. */
.dash-autofit-wide {
  grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
}

/* Compact tiles (`layout/partials/kpi_tiles.html`: a label and a figure, no
   icon) pack tighter than the dashboard's icon tiles, so they take a smaller
   floor — otherwise the shared partial would fit fewer per row than the
   `col-6 col-lg` it replaced. */
.dash-autofit-tile {
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}

/* `auto-fit` collapses tracks that no item lands in, but a *single* rendered
   tile still occupies only its own ~15rem track — so the rest of the row reads
   as one long blank gap beside it (e.g. a fresh tenant where every KPI but
   "Users in your tenant" is gated away). That is the same stranded-lone-survivor
   case the `.dash-split` rule above exists to prevent.

   A KPI tile is an icon, a label and a figure, so unlike the wide table/list
   cards it is NOT promoted to the full row — that would trade the gap for a
   mostly-empty banner. Capping the row at one bounded track is the fix: the
   tile keeps a sensible width and the row stops reserving space for tracks
   that never materialised. Two or more tiles are untouched and keep dividing
   the row evenly. Listed after all three `.dash-autofit*` templates so it wins
   on order as well as specificity. */
.dash-autofit:has(> :only-child),
.dash-autofit-tile:has(> :only-child) {
  grid-template-columns: minmax(10rem, 30rem);
}

/* A lone *wide* card is a table, list or trend strip — it uses the room, so it
   takes the full row instead of the capped block above. `.dash-autofit-wide`
   is used standalone in templates (not combined with `.dash-autofit`), so this
   targets the class on its own; listed after the tile rule above so it wins
   on order when a future template *does* combine the two. */
.dash-autofit-wide:has(> :only-child) {
  grid-template-columns: minmax(0, 1fr);
}

/* Asymmetric pair: the intended weighting when both halves render... */
@media (min-width: 1200px) {
  .dash-split {
    grid-template-columns: 2fr 1fr;
  }
}

/* ...and the full row when gating left only one. Deliberately OUTSIDE the
   min-width query above: below 1200px `.dash-split` has no explicit template,
   so a lone survivor lands in a single implicit column sized to its content —
   which strands e.g. "This month" at part-width when the attendance card next
   to it is gated away. Promoting at every width is the whole point of the rule.
   `1 / -1` is a harmless no-op on the single-column fallback and the correct
   full-row span once the 2fr/1fr template applies. */
.dash-split>:only-child {
  grid-column: 1 / -1;
}

/* Grid items default to `min-width: auto`, which refuses to shrink below the
   content's intrinsic width — a long material name or an unbroken figure would
   push the track wider than its share and overflow the row. Pinning it to 0
   lets the track hold its 1fr and the inner `.text-truncate` do its job.
   (Equal heights come free: items stretch to the row's tallest by default,
   which is what the cards' `h-100` measures against.) */
.dash-autofit>*,
.dash-autofit-wide>*,
.dash-autofit-tile>*,
.dash-split>* {
  min-width: 0;
  padding-right: 0;
  padding-left: 0;
}

/* When every card in a row is gated away the wrapper still renders, and its
   `mb-4` would leave a phantom gap between the rows that survived. Collapse it
   in CSS — declarative, so it cannot drift out of step with the gates the way a
   Jinja "did any of these render?" pre-count would.

   `:has(> *)` is the load-bearing test: Jinja leaves the comments/whitespace
   between the stripped-out `{% if %}` blocks, so the wrapper is rarely `:empty`
   in the strict sense even when no card rendered. `:empty` is kept alongside it
   only for the whitespace-free case. */
.dash-autofit:empty,
.dash-autofit-wide:empty,
.dash-autofit-tile:empty,
.dash-split:empty,
.dash-autofit:not(:has(> *)),
.dash-autofit-wide:not(:has(> *)),
.dash-autofit-tile:not(:has(> *)),
.dash-split:not(:has(> *)) {
  display: none;
}

/* ---- Slim, rounded, always-visible scrollbar (Google Docs style) ------------
   Applied to Bootstrap's .table-responsive wrappers (voucher grids, report
   tables, etc.) in place of the bulky default browser scrollbar.
   (`.nav-tabs-scroll` used to share this token for the detail-page tab bars
   too -- removed with the last two templates that carried it, superseded by
   `data-priority-nav`/priority_nav.js below, which removes the tab-row
   scroll entirely instead of just styling it -- bug-doc items 26, 59, 173.) */
.table-responsive {
  /* A positioning context, so absolutely-positioned descendants are clipped by
     this scroller instead of escaping it. Select2 hides each original <select>
     as a 1px `position: absolute` box at its static position; without this its
     containing block was the enclosing .card, so when a wide table scrolled
     (financial period openings, long ledger names) the hidden "Side" selects
     sat past the viewport edge and gave the whole PAGE a horizontal scrollbar.
     Row ⋮ menus are unaffected: main.js gives them Popper's `fixed` strategy. */
  position: relative;
  scrollbar-width: thin;
  scrollbar-color: var(--bs-border-color, #d1d0d4) transparent;
}

.table-responsive::-webkit-scrollbar {
  height: 6px;
}

.table-responsive::-webkit-scrollbar-track {
  background: transparent;
}

.table-responsive::-webkit-scrollbar-thumb {
  background-color: var(--bs-border-color, #d1d0d4);
  border-radius: 999px;
}

.table-responsive::-webkit-scrollbar-thumb:hover {
  background-color: var(--bs-secondary-color, #a5a3ae);
}

/* ---- Priority navigation: "More" overflow for tab/chip rows ----------------
   QA bug-doc items 26, 59, 173 ("tabs / filter chips too crowded"). Owner-
   picked prototype "Tabs A -- More menu" (js/priority_nav.js): a row shows as
   many items as fit on one line; the rest collapse into a trailing "More (N)"
   dropdown. A row opts in with [data-priority-nav].

   Before the script runs -- and if it never does -- the row is a plain
   wrapping flex row: no clipped content, no scrollbar, every item still a
   real link/button/input. This has to out-rank the vendor rule that forces
   `overflow-x: auto !important` onto every bare .nav-tabs
   (`div:not(.nav-align-left):not(.nav-align-right) > .nav-tabs` in
   vendor/css/core*.css), so it repeats that selector shape at !important —
   app.css loads last, but this makes the win independent of load order too. */
[data-priority-nav],
div:not(.nav-align-left):not(.nav-align-right) > .nav-tabs[data-priority-nav] {
  flex-wrap: wrap !important;
  overflow-x: visible !important;
  width: 100%;
}

/* Once priority_nav.js has measured the row and decided what fits, it adds
   this class: strictly one line, nothing wraps, nothing scrolls -- overflow
   items live in the More dropdown instead. */
[data-priority-nav].priority-nav-active,
div:not(.nav-align-left):not(.nav-align-right) > .nav-tabs[data-priority-nav].priority-nav-active {
  flex-wrap: nowrap !important;
  overflow-x: hidden !important;
}

/* Natural width while measuring/laid out -- items never get squeezed by flex
   shrink, so what fits on the line is exactly what the user sees fit. */
[data-priority-nav].priority-nav-active > * {
  flex: 0 0 auto;
}

/* ...except while the row stacks vertically (flex-column on phones): then every
   item is shown and nothing is clipped. */
[data-priority-nav].priority-nav-active.priority-nav-vertical {
  overflow-x: visible !important;
}

.priority-nav-more-toggle {
  white-space: nowrap;
}

/* Bug-doc #257: at 1280 CSS px (a 1440 screen at 110-125% zoom) Shop Orders' seven
   status tabs needed ~1000px and got 956, so two fell into More -- with the theme's
   ~25px of padding either side of every tab doing most of that. A tab strip that
   opts into priority-nav is a status/section switcher, often with a count badge per
   tab; 1rem either side keeps it readable and lets it fit. One rule, every strip. */
.nav-tabs[data-priority-nav] > .nav-item > .nav-link {
  padding-inline: 1rem;
}

/* ---- Detail-page navigation: two tab levels + in-card view filter ------------
   `grouped_detail_tabs` / `view_filter` in layout/partials/_detail_tabs.html.
   Both tab levels sit in ONE card ("R2 · One navigation card"): L1 underline tabs
   with icons on a hairline, L2 a row of soft chips beneath it, and the old third row
   of pills is a segmented control in the content card's header. Each level looks
   different, the higher one heavier. Design review:
   docs/designs/web/detail-tab-hierarchy.html (levels) and
   docs/designs/web/tab-bar-refinements.html (this card treatment). A full-width grey
   strip for L2 was tried first and read as a disabled toolbar. */
/* The wrapper is a theme `.card` (background, radius, shadow, dark mode). */
.detail-nav {
  margin-bottom: 1.5rem;
}

.detail-nav-primary {
  gap: 0.25rem;
  padding-inline: 0.5rem;
  border-bottom: 1px solid var(--bs-border-color, #e6e6e8);
}

/* With no page row beneath, the card holds one row: no hairline under it. */
.detail-nav:not(.detail-nav-has-sub) > .detail-nav-primary {
  border-bottom: 0;
}

.detail-nav-primary > .nav-item > .nav-link {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.8rem 0.9rem;
  border: 0;
  border-bottom: 2px solid transparent;
  border-radius: 0;
  background: transparent;
  color: var(--bs-secondary-color, #6d6b77);
  font-weight: 500;
  white-space: nowrap;
}

.detail-nav-primary .priority-nav-more-toggle,
.detail-nav-secondary .priority-nav-more-toggle {
  border: 0;
  background: transparent;
  color: var(--bs-secondary-color, #6d6b77);
}

.detail-nav-primary .priority-nav-more-toggle {
  padding: 0.65rem 0.9rem;
  font-weight: 500;
}

.detail-nav-secondary .priority-nav-more-toggle {
  padding: 0.25rem 0.7rem;
  font-size: 0.8125rem;
}

.detail-nav-primary > .nav-item > .nav-link > .ti {
  font-size: 1.15rem;
}

.detail-nav-primary > .nav-item > .nav-link:hover {
  color: var(--bs-heading-color, #444050);
}

.detail-nav-primary > .nav-item > .nav-link.active {
  color: var(--bs-primary, #7367f0);
  border-bottom-color: var(--bs-primary, #7367f0);
  font-weight: 600;
}

.detail-nav-secondary {
  gap: 0.375rem;
  padding: 0.6rem 0.9rem;
}

.detail-nav-secondary > .nav-item > .nav-link {
  padding: 0.25rem 0.75rem;
  border-radius: 0.375rem;
  font-size: 0.8125rem;
  color: var(--bs-secondary-color, #6d6b77);
  white-space: nowrap;
}

.detail-nav-secondary > .nav-item > .nav-link:hover {
  color: var(--bs-heading-color, #444050);
  background: rgba(var(--bs-secondary-rgb, 128, 131, 144), 0.08);
}

.detail-nav-secondary > .nav-item > .nav-link.active {
  color: var(--bs-primary, #7367f0);
  background: rgba(var(--bs-primary-rgb, 115, 103, 240), 0.12);
  font-weight: 600;
}

.view-filter {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 0.125rem;
  padding: 0.2rem;
  border-radius: 0.5rem;
  background: var(--bs-tertiary-bg, #f5f5f9);
}

.view-filter > .nav-link {
  display: flex;
  align-items: center;
  gap: 0.375rem;
  padding: 0.25rem 0.75rem;
  border: 0;
  border-radius: 0.375rem;
  background: transparent;
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--bs-secondary-color, #6d6b77);
  white-space: nowrap;
}

.view-filter > .nav-link.active {
  background: var(--bs-paper-bg, var(--bs-body-bg, #fff));
  color: var(--bs-heading-color, #444050);
  font-weight: 600;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
}

.view-filter-count {
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
  opacity: 0.7;
}

.view-filter > .nav-link.active .view-filter-count {
  color: var(--bs-primary, #7367f0);
  opacity: 1;
}

/* The card header that carries a view filter: title left, filter and actions right,
   wrapping under the title on a phone where the filter takes the full width. */
.card-header.view-filter-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 0.75rem;
}

.view-filter-header > .view-filter-title {
  margin-right: auto;
  min-width: 0;
}

@media (max-width: 575.98px) {
  .view-filter-header > .view-filter {
    order: 3;
    width: 100%;
  }

  .view-filter-header > .view-filter > .nav-link {
    flex: 1 1 auto;
    justify-content: center;
  }
}

.priority-nav-more-menu {
  min-width: 12rem;
  max-height: 60vh;
  overflow-y: auto;
}

.priority-nav-more-menu > li {
  list-style: none;
}

/* Items keep their own markup (.nav-link / a chip's label.btn) when moved
   into the dropdown -- flattened here to read as ordinary dropdown rows
   instead of a tab underline or a pill button. */
.priority-nav-more-menu .nav-link,
.priority-nav-more-menu label.btn {
  display: block;
  width: 100%;
  margin: 0 !important;
  padding: 0.375rem 1rem;
  border: 0 !important;
  border-radius: 0 !important;
  text-align: left;
  background: transparent !important;
  color: var(--bs-body-color, #4b4b5a) !important;
}

.priority-nav-more-menu .nav-link:hover,
.priority-nav-more-menu .nav-link:focus,
.priority-nav-more-menu label.btn:hover {
  background: var(--bs-tertiary-bg, #f5f5f9) !important;
}

.priority-nav-more-menu .nav-link.active,
.priority-nav-more-menu .nav-link[aria-current],
.priority-nav-more-menu label.btn:has(input:checked) {
  color: var(--bs-primary, #7367f0) !important;
  font-weight: 600;
}

.priority-nav-more-toggle.active {
  color: var(--bs-primary, #7367f0) !important;
}

/* ---- Select2 ↔ Bootstrap control sizing -------------------------------------
   The plain Select2 theme renders its single-select box at its own height, so on
   filter bars (where the neighbours are form-*-sm inputs/buttons) and inside
   modals the picker doesn't line up with the other controls. Match Select2's box
   to the Bootstrap control it replaces — default size, with a -sm variant when
   the origin <select> carries form-select-sm / form-control-sm (the filter rows). */
.select2-container--default .select2-selection--single {
  height: calc(1.5em + 0.75rem + 2px);
  /* = .form-control height */
  display: flex;
  align-items: center;
  border: 1px solid var(--bs-border-color, #d1d0d4);
  border-radius: var(--bs-border-radius, 0.375rem);
}

.select2-container--default .select2-selection--single .select2-selection__rendered {
  line-height: 1.5;
  padding-left: 0.6rem;
  padding-right: 1.5rem;
  color: var(--bs-body-color, #6f6b7d);
}

/* The theme's own rules (.light-style/.dark-style) set __rendered's line-height and
   __arrow's height from a fixed box height baked in for the *unthemed* Select2 size —
   different from the shorter .form-control height applied above. Left alone, that
   mismatch pushes the clear "×" and the dropdown arrow out of vertical alignment with
   the selected text. Reset all three to plain flex children of the (now flex) parent
   so they always centre on whatever height the control ends up at, small or default. */
.select2-container--default .select2-selection--single .select2-selection__rendered {
  line-height: normal !important;
  display: flex;
  align-items: center;
  flex: 1 1 auto;
  min-width: 0;
}

.select2-container--default .select2-selection--single .select2-selection__arrow {
  height: auto !important;
  top: 0;
  bottom: 0;
  right: 0.25rem;
  display: flex;
  align-items: center;
  position: static;
  flex: 0 0 auto;
  order: 3;
}

.select2-container--default .select2-selection--single .select2-selection__arrow b {
  position: static !important;
  top: auto !important;
}

/* Select2's DOM order is rendered → clear → arrow; the clear "×" comes before the
   arrow in the markup, so give it an explicit order between them (arrow always
   pinned last) rather than relying on source order once both are flex items. */
.select2-container--default .select2-selection--single .select2-selection__clear {
  line-height: normal;
  display: flex;
  align-items: center;
  position: static;
  float: none;
  order: 2;
  margin-left: auto;
  padding-right: 0.25rem;
}

/* Owner bug #11a (2026-09-24, the customer-import review grid's Category
   picker, but every select2-ajax/-local single-select in the app shares this
   rule): the vendor theme colours the clear "×" #acaab1 (light) / #76778e
   (dark) -- a near-white grey on a white control, the "nearly invisible"
   affordance the owner measured. Darkened to the body-text colour (still
   visibly secondary to the selection text, which stays full-strength) and
   sized up so the glyph itself is easier to hit and to see, with a muted
   hover state as the one visible cue this element does something. */
.select2-container--default .select2-selection--single .select2-selection__clear {
  font-size: 1.125rem;
  font-weight: 700;
}

.light-style .select2-container--default .select2-selection--single .select2-selection__clear {
  color: var(--bs-body-color, #6f6b7d);
}

.dark-style .select2-container--default .select2-selection--single .select2-selection__clear {
  color: var(--bs-body-color, #acabc1);
}

.select2-container--default .select2-selection--single .select2-selection__clear:hover {
  color: var(--bs-danger, #ea5455);
}

/* Small variant — line up with the -sm controls used in list-page filter bars. */
select.form-select-sm+.select2-container--default .select2-selection--single,
select.form-control-sm+.select2-container--default .select2-selection--single {
  height: calc(1.5em + 0.5rem + 2px);
  /* = .form-control-sm height */
  font-size: 0.8125rem;
}

/* The popup list sizes to its OWN content, not to the closed control.

   Select2 writes an inline `width` on .select2-dropdown equal to the container it
   was opened from, and our filter selects are a fixed 140px
   (--app-filter-w-select). A longer option than that had nowhere to go and broke
   across two lines: "Invalid number" on the Targets status filter rendered as
   "Invalid" / "number" (BD-208 #208).

   So the list is three-way clamped: never narrower than 180px or the control it
   opened from, whichever is wider (select2_modal_position.js sets `--s2-control-w`
   to the control's width on every resize), as wide as its widest option, and no
   wider than 300px -- unless the control itself is wider, which wins. Past the cap
   a long option is cut with an ellipsis on ONE line (rule below), and its full
   text shows on hover. The earlier `min-width: max-content` could not be capped
   at all -- min-width wins over max-width -- so one long name (a 60-character
   material) stretched the popup to ~650px and off the page.

   Shared, so it holds for every Select2 in the app -- the filter toolbars, the
   Filters panel and the pickers inside dialogs -- not just the one screen it was
   reported on. */
.select2-dropdown {
  width: max-content !important;  /* beats Select2's inline width = the control's */
  min-width: max(180px, var(--s2-control-w, 0px));
  max-width: min(90vw, 300px);
}

/* Owner bug #7 (2026-09-24, /attendance Manual punch -> Employee, "seen
   elsewhere too"): opening a Select2 dropdown showed a needless vertical
   scrollbar for a SHORT list, and sometimes a horizontal one too.

   Two separate causes, both in the vendor sheet's
   `.select2-container--default .select2-results > .select2-results__options`
   rule (select2.css) -- overridden here, same selector, loaded after it:

   1. `max-height: 15rem` (240px) is narrower than six short result rows
      already are (~248px measured: 38.7px/row + 0.5rem margin-block top and
      bottom) -- so a list the owner called "few options" (six employees, all
      fully visible in the screenshot) still overflowed by a few px and drew
      a scrollbar. Widened to 18rem, which fits ~7 rows before scrolling
      starts; a genuinely long list still scrolls, once, inside this box.
   2. The vendor rule sets `overflow-y: auto` but no `overflow-x`, so it falls
      back to the initial `visible`. `.select2-dropdown`'s own width above is
      computed by `min-width: max-content` from the widest OPTION TEXT (BD-208)
      -- a computation a classic (non-overlay) scrollbar system does not always
      settle in the same pass as reserving this box's own vertical-scrollbar
      gutter, which can leave the results list a few px narrower than its
      widest row and show a sideways scrollbar nobody asked for.
      `overflow-x: hidden` makes that impossible outright (a result label
      wraps instead), and `scrollbar-gutter: stable` reserves the vertical
      scrollbar's width up front so classic and overlay scrollbar systems
      agree on the available width in the first place.

   Shared, so it holds for every Select2 in the app -- Attendance's manual
   punch, every Filters drawer, and every party/appointment picker -- not
   just the one screen it was reported on. */
.select2-container--default .select2-results > .select2-results__options {
  max-height: 18rem;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-gutter: stable;
}

/* One line per option: a label longer than the capped popup (.select2-dropdown
   above) ends in an ellipsis instead of wrapping onto a second line, and
   select2_modal_position.js puts the full text in a hover title. Leaf options
   only -- an optgroup (role=group) holds its options as nested blocks, and the
   multi-column picker lays out its own cells. */
.select2-dropdown:not(.select2-columns-dropdown) .select2-results__option:not([role="group"]),
.select2-dropdown:not(.select2-columns-dropdown) .select2-results__group {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Dropdown popup text — Select2's default is smaller than the app's normal body
   copy, which makes long lists (e.g. the audit log's user filter) hard to read.
   Bump the search box + result rows to match .form-control's font-size, even
   when the closed control is the -sm variant (the popup itself never shrinks). */
.select2-dropdown .select2-search__field,
.select2-results__option {
  font-size: 0.9375rem;
}

/* Focus/open state matches Bootstrap's focused control border. */
.select2-container--default.select2-container--focus .select2-selection--single,
.select2-container--default.select2-container--open .select2-selection--single {
  border-color: var(--bs-primary, #4285f4);
}

/* Field-level validation state on a Select2-enhanced <select>: Select2 hides the
   native element and renders its own widget, so Bootstrap's .is-invalid border
   (which targets the hidden <select>) is invisible on its own. main.js adds
   is-invalid to the native select on both client- and server-side validation
   failures (see applyFieldErrors / data-validate-errors); paint the *widget* to
   match instead of the hidden element underneath it. */
select.is-invalid+.select2-container--default .select2-selection--single {
  border-color: var(--bs-danger, #ea5455);
}

/* Two eye icons on one password field in Microsoft Edge (bug-doc item 181, BD-175).
   password_toggle.js appends our OWN show/hide button to every password input, but
   Edge separately draws its native reveal/clear icons INSIDE the input once it has
   text — there is no DOM node for those, so CSS is the only way to reach them.
   Suppressed everywhere (not just the account page's Change-password fields) so the
   login field and every other password input get the same single toggle. */
input[type=password]::-ms-reveal,
input[type=password]::-ms-clear {
  display: none;
}

/* Field-level validation state on an input that a script wraps in a Bootstrap
   .input-group AFTER the template renders — e.g. password_toggle.js (every
   type="password" field gets an appended show/hide button) and phone_input.js
   (every phone field gets a prepended country-code select). The wrap wraps
   only the <input>, so an .invalid-feedback div authored as the *next sibling
   of the input* in the template ends up a sibling of the .input-group instead
   once the wrap runs — Bootstrap's ".is-invalid ~ .invalid-feedback" rule
   requires the is-invalid element and the feedback div to be siblings under
   the SAME parent, so it stops matching. Reach through the wrapper: show a
   .invalid-feedback that immediately follows an .input-group containing an
   is-invalid control. */
.input-group:has(.is-invalid)+.invalid-feedback {
  display: block;
}

/* The same reach-through for a picker with a "+" beside it
   (layout/partials/_select2_with_add.html, _ledger_tree_field.html): the
   control sits in `d-flex > .flex-grow-1` and its message is authored after
   the whole row, which is also where main.js's appFeedbackAnchor puts it. */
.d-flex:has(> .flex-grow-1 .is-invalid)+.invalid-feedback {
  display: block;
}

/* ---- Non-reflowing invalid-feedback (ported from the TaxCoop Angular app) -------
   Bootstrap's default .invalid-feedback sits in normal flow: when it appears it
   pushes every field below it down, which in a modal reflows the whole dialog (and
   can visibly jump/resize it) on every failed submit. Opt a field's feedback div
   into `invalid-feedback-fixed` to take it OUT of flow — it overlays just under the
   field instead of displacing anything. The field's wrapper (.col-.mb-3/etc.) needs
   `position: relative` for the absolute child to anchor correctly; add
   `invalid-feedback-fixed-wrap` to that wrapper (most of this app's field wrappers
   are Bootstrap grid .col-* divs, which are already position:static by default, so
   this opt-in is required, not automatic like it is for a plain block).
   Use for any field inside a modal, or any field with siblings below it that would
   visibly jump; a full-page form's last field in a row can keep the default
   in-flow .invalid-feedback if there's nothing below it to displace. */

.invalid-feedback-fixed-wrap {
  position: relative;
}

.invalid-feedback-fixed {
  /* NOTE: this is `unset`, so the whole class is a NO-OP -- `unset` on `position`
     resolves to `static` and the `top`/`left`/`z-index` below do nothing. It has
     been that way since #58 introduced it, while the comment above describes an
     absolutely-positioned child, so all 366 opted-in fields still displace their
     neighbours. Changing it to `absolute` does make the class work, but it was
     measured (sweep 8, BD-209 #209) to overlay a checkbox and its label in the Add
     Broadcast modal -- an absolute box covers whatever sits under the field. Making
     that safe app-wide needs a pass over the opted-in forms (and probably
     `pointer-events: none`), which is an owner decision, not a QA-sweep change. Left
     as-is deliberately; see docs/qa/runs/2026-09-21-sweep-8.md. */
  position: unset;
  top: 100%;
  left: 0;
  width: max-content;
  max-width: 100%;
  white-space: normal;
  word-break: break-word;
  z-index: 2;
}

/* ---- Reserved-slot invalid-feedback: the error line never reflows the form ----
   The Add Material modal (modal-dialog-centered) visibly jumped on an empty
   submit: three in-flow error lines appeared at once, the dialog grew ~60px
   and re-centred. Rather than overlay the message (the BD-209 risk above), opt a
   field's feedback div into `invalid-feedback-reserve`: it always occupies its
   one-line slot, invisible until the field is invalid, so showing it changes no
   geometry. Pair it with a smaller bottom margin on the field wrapper (mb-1
   instead of mb-3) so the reserved line replaces the gap rather than adding to it.
   The "shown" selectors mirror every way this app reveals feedback: Bootstrap's
   sibling rule, main.js's d-block (client + server errors), .was-validated, and
   the input-group reach-through above. */
.invalid-feedback.invalid-feedback-reserve {
  display: block;
  visibility: hidden;
  min-height: 1.5em;
}

.is-invalid ~ .invalid-feedback.invalid-feedback-reserve,
.input-group:has(.is-invalid) + .invalid-feedback.invalid-feedback-reserve,
.was-validated :invalid ~ .invalid-feedback.invalid-feedback-reserve,
.invalid-feedback.invalid-feedback-reserve.d-block {
  visibility: visible;
}

/* ---- Every modal reserves its error lines (2026-10-05) -----------------------
   The opt-in above was applied to one modal at a time, and 61 modal templates
   (~340 feedback divs, two thirds of them carrying the no-op
   invalid-feedback-fixed) still grew and re-centred on a failed submit -- the
   Bank Account modal visibly jumped. So inside a .modal every .invalid-feedback
   gets the reserved slot by default. A field with NO authored feedback div
   still grows the modal once, when main.js creates one on submit -- give such
   a field an authored `.invalid-feedback` to reserve its line.
   - :where() keeps the base rule at the specificity of Bootstrap's own
     `.invalid-feedback { display: none }` (app.css loads after it, so it wins),
     which lets each reveal selector below override it without !important.
   - Table cells are left alone: a reserved line under every cell would make
     every line-item row taller.
   - A `.d-none` feedback (shown later by script) still has display:none
     !important, so it reserves nothing, as before.
   - The reveal list mirrors every way the app shows feedback: Bootstrap's
     sibling rules, main.js's d-block, the input-group reach-through, and
     birthday_age_validation.js's inline `display: block`.
   - Where the feedback ends a field whose wrapper already has a 1rem gap
     below it (mb-3, a g-3/gy-3 row), the slot gives back half of that gap so
     the modal grows by ~1rem per row instead of ~1.5rem. It is only safe on
     :last-child -- anything after the feedback would be pulled into it.
   - A full-page form opts into the same behaviour with `form-reserve-feedback`
     on its <form> (2026-10-10, New Receipt: a blank submit pushed every row
     below the header down a line per error). Page forms are opt-in, not
     global, because a reserved line under every field changes the spacing of
     every page that has one -- a form takes it on once its fields are checked:
     every validated field authors its own `.invalid-feedback` (one per field),
     and no row relies on `align-items-end`, whose columns would then differ in
     height by the slots they carry. */
:where(.modal, .form-reserve-feedback) .invalid-feedback:not(:where(.table *)) {
  display: block;
  visibility: hidden;
  min-height: 1.5em;
}

:is(.modal, .form-reserve-feedback) .is-invalid ~ .invalid-feedback,
:is(.modal, .form-reserve-feedback) .input-group:has(.is-invalid) + .invalid-feedback,
:is(.modal, .form-reserve-feedback) .d-flex:has(> .flex-grow-1 .is-invalid) + .invalid-feedback,
:is(.modal, .form-reserve-feedback) .was-validated :invalid ~ .invalid-feedback,
:is(.modal, .form-reserve-feedback) .invalid-feedback.d-block,
:is(.modal, .form-reserve-feedback) .invalid-feedback[style*="display: block"] {
  visibility: visible;
}

:is(.modal, .form-reserve-feedback) .mb-3 > .invalid-feedback:last-child,
:is(.modal, .form-reserve-feedback) :is(.g-3, .gy-3) > [class*="col"] > .invalid-feedback:last-child {
  margin-bottom: -0.5rem;
}

/* ---- align-items-end rows: a field error must not shift the sibling columns ----
   Bug-doc #270 (2026-09-24, Material/Service item -> Brochures tab): the upload
   row is `.row.align-items-end` (Title | File | Upload button) specifically so
   the labelless Upload button lines up with the BOTTOM of the Title/File
   inputs, not their tops. Submitting with an empty Title appends an
   in-flow `.invalid-feedback` line under the Title column -- which makes
   THAT column taller, and align-items-end re-aligns every column to the
   row's new tallest bottom, so File's input (and the button) drop out of
   line with Title's.

   This is the same displacement `.invalid-feedback-fixed` (above) exists to
   stop, but that class is deliberately left a no-op app-wide (BD-209: making
   it absolute overlaid a checkbox+label in the Add Broadcast modal, and
   fixing that for all 366 already-opted-in fields is "an owner decision, not
   a QA-sweep change" -- see the comment on invalid-feedback-fixed). This
   rule is narrower and does not touch that switch: scoped to ONLY
   .row.align-items-end, where a field growing taller is a real layout bug
   (defeats the whole reason that class was chosen) rather than a style
   preference, so the risk BD-209 found is much smaller. pointer-events: none
   keeps it from blocking clicks on whatever a short row places underneath.

   Descendant, not child (2026-10-10, New Receipt -> Deposit ledger): a picker
   with a "+" beside it sits in `d-flex > .flex-grow-1`, and main.js inserts
   the message right after the picker -- INSIDE .flex-grow-1 -- so a child-only
   selector missed it, the column grew one line, and its label/input rode up
   above Customer and Bank account. Anchored to the column (the nearest
   positioned ancestor), it lands in the same place as a direct child would.
   Table cells keep their in-flow messages. */
.row.align-items-end > [class*="col-"] {
  position: relative;
}

.row.align-items-end > [class*="col-"] .invalid-feedback:not(:where(.table *)) {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  width: auto;
  pointer-events: none;
  z-index: 1;
}

/* ---- Field errors inside table cells must wrap, not spill sideways ----
   Line-entry tables sit in `.table-responsive.text-nowrap`, so a client-side
   `.invalid-feedback` ("Value must be less than or equal to 8900") inherited
   nowrap and ran across the neighbouring Tax column (vendor settlement goods
   rows, 2026-09-28). Wrap it to the cell's width instead. */
.table td .invalid-feedback {
  white-space: normal;
  word-break: break-word;
}

/* ---- Line tables that reserve their error line (opt in: .line-feedback-reserve) ----
   A cell's message ("Enter the quantity.") is in flow, so it grew its row and pushed
   the picker/tax rows out of line. Here every line row keeps a fixed one-line slot at
   the bottom of its cells and the message is absolutely placed inside it, so showing
   or clearing an error changes no geometry. */
.line-feedback-reserve tbody > tr:not(.js-tax-row, [data-line-desc-row]) > td {
  position: relative;
  padding-bottom: 1.5rem;
}

.line-feedback-reserve tbody > tr:not(.js-tax-row, [data-line-desc-row]) > td .invalid-feedback {
  position: absolute;
  left: 0.5rem;
  bottom: 0.2rem;
  width: auto;
  margin: 0;
  font-size: 0.75rem;
  line-height: 1.2;
  white-space: nowrap;
  pointer-events: none;
  z-index: 1;
}

/* Compact date inputs in search/filter forms (GET forms) — they were as wide as
   text inputs; shrink to just fit "YYYY-MM-DD" + the native picker. */
form[method="get"] input[type="date"] {
  width: 8.5rem;
  max-width: 8.5rem;
}

/* ---- Sticky navbar: keep anchor targets out from under it -----------------------
   The back-office shell runs the theme's own `layout-navbar-fixed` mode (turned on in
   layout/vertical.html — see the comment there). The one thing that mode does NOT bring
   with it is scroll compensation: a `#fragment` link, a `scrollIntoView()`, and the
   browser's own "restore scroll position" all align the target with the top of the
   viewport, which is now 4.5rem of navbar. `scroll-padding-top` on the scroll container
   shifts every one of those landings down by the bar's height, once, for the whole app —
   the alternative is a `scroll-margin-top` on every anchorable element, which is the same
   per-screen duplication the token block at the top of this file exists to avoid.
   Scoped to the class so the layouts that do NOT fix their navbar are unaffected. */
.layout-navbar-fixed {
  scroll-padding-top: 5rem;
}

/* ---- Left navigation density -------------------------------------------------
   The Sneat vertical menu leaves a lot of vertical whitespace between items and
   section headers, so the nav reads as very sparse. Tighten it. The left menu
   lives in a single partial (layout/partials/menu.html, id="layout-menu") that
   every menu-bearing layout includes, and this stylesheet loads on all of them,
   so the override applies across layouts. The #layout-menu id raises specificity
   enough to win over the theme without !important. */
#layout-menu .menu-item {
  margin-top: 0;
  margin-bottom: 0.125rem;
}

#layout-menu .menu-link {
  padding-top: 0.5rem;
  padding-bottom: 0.5rem;
}

#layout-menu .menu-sub .menu-link {
  padding-top: 0.375rem;
  padding-bottom: 0.375rem;
}

/* Wishes submenu carries a meaningful icon on each item (marked with
   .menu-sub-icons on the <ul>). The icons use `tf-icons ti ti-*` WITHOUT the
   theme's `.menu-icon` class — `.menu-icon` in a submenu made the glyph render
   as the notdef circle. We then hide the theme's default circle bullet
   (.menu-link::before) and drop the icon into that gutter, keeping the labels
   aligned with the icon-less submenus. */
#layout-menu .menu-sub.menu-sub-icons .menu-link::before {
  content: none !important;
}

#layout-menu .menu-sub.menu-sub-icons .menu-link {
  position: relative;
  padding-left: 2.75rem;
}

#layout-menu .menu-sub.menu-sub-icons .menu-link i.ti {
  position: absolute;
  left: 1.1rem;
  margin: 0;
  font-size: 1.05rem;
  line-height: 1;
}

#layout-menu .menu-header {
  margin-top: 0.75rem;
  padding-top: 0.3rem;
  padding-bottom: 0.3rem;
  line-height: 1.25;
}

/* .menu-header reused INSIDE a submenu (menu-sub) — same theme class and
   markup (<li class="menu-header small text-uppercase"><span
   class="menu-header-text">) used for the top-level Workforce/Finance/etc.
   dividers, so a grouped-but-not-nested-toggle submenu (e.g. WhatsApp
   Marketing) looks like the rest of the app's sidebar rather than a
   one-off. Only the indent changes, to sit under the submenu's icon gutter. */
#layout-menu .menu-sub .menu-header {
  margin-top: 0.5rem;
  padding-left: 2.75rem;
}

#layout-menu .menu-sub .menu-header:first-child {
  margin-top: 0;
}

/* Trim the brand/header block at the very top of the menu. Keep it stacked
   above the scrolling menu items (with the menu's own background) so the
   brand mark is never painted over when the list scrolls. */
#layout-menu .app-brand.demo {
  margin-top: 0.6rem;
  margin-bottom: 0.6rem;
  position: relative;
  z-index: 100;
  background-color: inherit;
}

/* Long unbroken names (e.g. a name typed with no spaces) inside a flex row: a flex
   child's default min-width is `auto`, which lets its content refuse to shrink and
   overflow the row instead of wrapping — hits detail-page header titles
   (employees/customers/vendors/projects _head.html, all `.d-flex > div > h4`) and
   Select2's dropdown result rows the same way. Force them to wrap within their box.
   Any heading in that position, not only `mb-1`: the wish detail page's `h4.mb-0`
   title (a 160-character occasion name) ran off the page behind a horizontal
   scrollbar (bug doc #329). */
.d-flex>div>h4,
.select2-results__option,
dl.row dd {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* A long selected value (e.g. a customer name typed with no spaces) in a Select2
   SINGLE-select's closed box: unlike the dropdown rows above, the closed control has
   no room to wrap, so truncate with an ellipsis instead of letting it stretch the
   control past its column (hit the invoice/receipt/payment "Customer" picker with a
   long name). Applies app-wide to every .select2-ajax / .select2-local single-select,
   not just one page. */
.select2-container--default .select2-selection--single .select2-selection__rendered {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Select2 sizing: never let the control grow wider than the column it sits in.
   Select2 stamps an inline `style="width:<N>px"` on its generated .select2-container
   at init time, measured from the origin <select>. Inside a responsive column or a
   `d-flex > .flex-grow-1` wrapper (the shared _select2_with_add.html layout), that
   measurement is taken before/independently of the final flex layout, so the
   container keeps a stale pixel width and spills over its neighbour — the "Customer"
   picker on the receipt/payment/invoice forms visibly overlapped the field beside it.
   `width: 100%` re-ties the box to its wrapper and `max-width: 100%` caps it even
   when the inline width wins, so the box always tracks its column at every breakpoint.
   Both must stay: the inline style is more specific than a plain `width` declaration,
   hence the `!important`. Long selected text is then ellipsised by the
   .select2-selection__rendered rule above.
   For multi-selects the same cap makes selected tags wrap onto new lines; when there
   are many, the box caps its height and scrolls internally instead of overflowing.
   Scoped to `.select2` — the in-place control. The open dropdown's wrapper that
   Select2 appends to <body> also carries `.select2-container` (but not `.select2`);
   at `width: 100%` it became body-wide from the control's left edge and pushed a
   horizontal scrollbar onto the page whenever a picker was open. */
.select2.select2-container {
  width: 100% !important;
  max-width: 100%;
}

/* The `width: 100% !important` above ties the Select2 box to its immediate
   wrapper, but that wrapper is itself a flex item (`.select2-with-add-field`,
   the `.flex-grow-1` column in _select2_with_add.html's `d-flex`) — and a flex
   item's default `min-width` is `auto`, which floors it at its CONTENT's
   intrinsic width, not the space flex-grow actually gives it. Select2's stamped
   inline pixel width (see the comment above) counts as that content, so once a
   long value is selected the flex item refuses to shrink below it and pushes
   past the grid column into whatever sits beside the picker (e.g. the Qty
   field on the maintenance "Add item" row, once an item is selected). Only
   `.select2-with-add-field` needs this: the `.select2.select2-container`
   width rule above already handles other placements (single-column forms,
   table cells) that don't sit in a `d-flex` row with a sibling "+" button. */
.select2-with-add-field,
.d-flex > .flex-grow-1:has(> select.select2-ajax),
.d-flex > .flex-grow-1:has(> select.select2-local),
.d-flex > .flex-grow-1:has(> .select2-container) {
  /* Hand-written `d-flex > .flex-grow-1` wrappers (e.g. the Add Material dialog's
     HSN/SAC + "+" row) don't carry `.select2-with-add-field`; without this a long
     selected value kept the wrapper wide and it overlapped the neighbouring field. */
  min-width: 0;
}

/* Same `min-width: auto` floor, one level up. A Bootstrap grid `.col-*` is itself
   a flex item of its `.row`, so it too is floored at its CONTENT's intrinsic
   width -- and Select2's stamped inline pixel width is that content. Capping
   `.select2-with-add-field` above stops the picker overlapping its "+" button,
   but the column still refuses to shrink below the stamped width, widens the
   `.row` past its container and hands the modal body a horizontal scrollbar
   (hit on the "Edit Contract" dialog with a long customer name). Scoped to the
   columns that actually hold a Select2 -- `:has()` so no markup change is needed
   at the ~366 opted-in field wrappers, and so a column with ordinary content
   keeps Bootstrap's default sizing. */
/* Voucher line-grid item picker (invoice, delivery challan). A native <select>
   is as wide as its LONGEST option, and the grid is `text-nowrap`, so one long
   item name in the catalog stretched the Material/Service column across the
   page. Fix the width and ellipsise the selected label; the open list still
   shows full names. */
.voucher-line-item-picker {
  width: 260px;
  max-width: 260px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The same column once Select2 has replaced the <select>. The line grids are
   auto-layout `text-nowrap` tables and `.select2.select2-container` (above) is
   `width: 100%`, so the column sized itself to the SELECTED label: picking a
   longer item ("Sample Service (SER-0001) — HSN: 9999") widened the
   Material/Service column and shoved Qty/Price right. Pin the picker to one
   width in every voucher line grid (invoice, expense, delivery challan,
   room-scope template) -- put `voucher-line-item-cell` on the item's <td>.
   Select2 already ellipsises a label that doesn't fit. Higher specificity than
   the `width: 100% !important` rule above, so this one wins. */
.voucher-line-item-cell .select2.select2-container {
  width: 280px !important;
  min-width: 280px;
  max-width: 280px;
}

/* ...and since the picker is a fixed width, its `.flex-grow-1` wrapper must not
   grow either: when the auto-layout table hands the column spare width, a growing
   wrapper stretches past the 280px picker and parks the "+" button at the far edge
   of the cell, leaving an empty gap between the two. `!important` because the
   wrapper also carries Bootstrap's `.flex-grow-1`, which is itself
   `flex-grow: 1 !important` and otherwise wins over a plain `flex` shorthand. */
.voucher-line-item-cell .select2-with-add-field {
  flex: 0 0 auto !important;
}

/* Same cell class, comboTree ledger picker (expense ledger lines: asset/expense
   category + ledger). comboTree wraps the text input in `.ct-wrapper`, which in an
   auto-layout `text-nowrap` grid shrinks to the input's default size and clips the
   picked name ("Fixed A▾"). Give it one fixed width and ellipsise the label. */
.voucher-line-item-cell .ct-wrapper {
  width: 220px;
  min-width: 220px;
  max-width: 220px;
}

.voucher-line-item-cell .ct-wrapper .ct-input-box {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Every comboTree ledger picker, not only the ones in a voucher grid cell (ADO #1697,
   #2422): the stock stylesheet reserves 3rem on the right for the arrow and the "x" --
   but a picker input is also a Bootstrap `.form-control`, whose own padding shorthand
   wins on order and let the placeholder / picked name run underneath the caret ("Search
   ledger▾"). Restore the reserved column and ellipsise what does not fit. */
.ct-wrapper .ct-input-box.form-control {
  padding-right: 3rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.row > [class*="col-"]:has(.select2-container),
.row > [class*="col-"]:has(select.select2-ajax),
.row > [class*="col-"]:has(select.select2-local) {
  min-width: 0;
}

.select2-container--default .select2-selection--multiple {
  max-height: 8.5rem;
  overflow-y: auto;
  /* Reserve the dropdown caret's column (drawn below) so a tag, the inline search
     field or the "x" clear never runs underneath it. Same 2.25rem the single
     select gives its `.select2-selection__arrow`.

     `!important` earns its place here. The Sneat skin sets this box's padding with
     a SHORTHAND, at higher specificity than this rule and once more for the
     focus/open state, in each of the light and dark skins
     (`.light-style .select2-container--default .select2-selection--multiple`,
     `…--focus …`, and the two dark twins) — four rules that would each reset the
     right padding back to the theme's. Matching all four selector-for-selector
     would hard-code the skin's structure into this file; one important longhand
     states the rule once and survives the theme being re-generated. */
  padding-right: 2.25rem !important;
}

.select2-container--default .select2-selection--multiple .select2-selection__rendered {
  display: flex;
  flex-wrap: wrap;
  /* The Sneat skin gives only the LAST tag (`:nth-last-child(2)`) a bottom margin,
     written for Select2's float layout. Under flex's default `stretch` that taller
     margin box stretches every other tag on the line, so tags of one row render at
     different heights. `flex-start` keeps each tag its own natural height. */
  align-items: flex-start;
}

.select2-container--default .select2-selection--multiple .select2-selection__choice {
  max-width: 100%;
  white-space: normal;
  overflow-wrap: anywhere;
}

/* ---- Multi-select dropdown caret (bug-doc rev2 item 137) --------------------
   Select2 builds `.select2-selection__arrow` for a SINGLE select only. A MULTI
   select is the same control opening the same dropdown, but it drew nothing at
   its right edge -- so it did not read as a dropdown at all. The Add Employee
   modal is where that is impossible to miss: "Category" (single) shows a chevron
   and "Entities" (multiple), directly beside it, showed none.

   Select2 gives the multi-select no arrow element to style, so the caret is drawn
   as a pseudo-element -- on the CONTAINER, not on `.select2-selection--multiple`,
   because that box is a `max-height` + `overflow-y: auto` scroller (see above) and
   anything positioned inside it scrolls away with the tags. `.select2-container`
   is `position: relative` in the vendor sheet, so it is the stable anchor.

   The glyph, its box and its open-state rotation are the theme's own single-select
   values, copied so the two controls cannot look like two different widgets; the
   height matches `.select2-selection__arrow`'s, which keeps the caret level with
   the FIRST row of tags rather than drifting down as the box grows.

   One rule, so every Select2 multi-select in the app gains it at once -- 26
   `select2_with_add(..., multiple=True)` call sites plus 6 hand-written
   `<select multiple class="select2-*">` controls -- not just the one form. */
.select2-container--default:has(.select2-selection--multiple)::after {
  content: "";
  position: absolute;
  top: 0;
  right: 0.5rem;
  /* = .select2-selection__arrow's height in the Sneat select2 skin. */
  height: calc(1.625em + 0.852rem + 2px);
  width: 20px;
  pointer-events: none;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 20px 19px;
  background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 22" fill="none"><path d="M10.9999 12.0743L15.5374 7.53676L16.8336 8.83292L10.9999 14.6666L5.16626 8.83292L6.46243 7.53676L10.9999 12.0743Z" fill="%232f2b3d" fill-opacity="0.9"/></svg>');
  transform-origin: center;
  transition: transform 0.3s ease;
}

.dark-style .select2-container--default:has(.select2-selection--multiple)::after {
  background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 22" fill="none"><path d="M10.9999 12.0743L15.5374 7.53676L16.8336 8.83292L10.9999 14.6666L5.16626 8.83292L6.46243 7.53676L10.9999 12.0743Z" fill="%23e1def5" fill-opacity="0.9"/></svg>');
}

/* Open -> point up, exactly as the single-select arrow does. */
.select2-container--default.select2-container--open:has(.select2-selection--multiple)::after {
  transform: rotate(180deg);
}

/* Disabled -> the muted glyph, matching `.select2-container--disabled`'s arrow. */
.select2-container--default.select2-container--disabled:has(.select2-selection--multiple)::after {
  background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 22" fill="none"><path d="M10.9999 12.0743L15.5374 7.53676L16.8336 8.83292L10.9999 14.6666L5.16626 8.83292L6.46243 7.53676L10.9999 12.0743Z" fill="%23acaab1" fill-opacity="0.9"/></svg>');
}

.dark-style .select2-container--default.select2-container--disabled:has(.select2-selection--multiple)::after {
  background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 22" fill="none"><path d="M10.9999 12.0743L15.5374 7.53676L16.8336 8.83292L10.9999 14.6666L5.16626 8.83292L6.46243 7.53676L10.9999 12.0743Z" fill="%2376778e" fill-opacity="0.9"/></svg>');
}

/* Small variant -- the filter-bar pickers (the finance report's Projects select,
   /reports/departments) are `form-select-sm`, so the caret's box has to shrink
   with them or the glyph centres below the control's midline. Mirrors the -sm
   rule the single select already carries above. */
select.form-select-sm+.select2-container--default:has(.select2-selection--multiple)::after,
select.form-control-sm+.select2-container--default:has(.select2-selection--multiple)::after {
  height: calc(1.5em + 0.5rem + 2px);
}

/* Count-collapsed multi-select (e.g. employee Departments): when more than one
   item is chosen the individual tags overflow, so JS adds .select2-count-collapsed
   to the container and injects a single .js-dept-count summary chip. Hide every
   real tag but keep the summary chip visible. One selection collapses nothing. */
.select2-count-collapsed .select2-selection__rendered>.select2-selection__choice:not(.js-dept-count) {
  display: none;
}

/* Multi-column AJAX picker (opt-in via data-columns on .select2-ajax, see
   base.html's buildColumnsTemplate): each dropdown option — and the header row
   injected on select2:open — renders as an evenly-spaced row of columns instead
   of Select2's default single-line label. Lets a picker like the receipt form's
   invoice selector show Invoice # / Date / Outstanding side by side, so an
   operator can tell candidates apart without a separate reference table. */
.select2-columns-row {
  display: flex;
  gap: 0.75rem;
  width: 100%;
}

.select2-columns-col {
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.select2-columns-header {
  padding: 0.375rem 0.75rem;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--bs-secondary-color, #6c757d);
  text-transform: uppercase;
  border-bottom: 1px solid var(--bs-border-color, #dee2e6);
  background: var(--bs-tertiary-bg, #f8f9fa);
}

.select2-columns-header .select2-columns-col {
  overflow: visible;
  white-space: normal;
}

/* Select2 sizes the dropdown to match the trigger control's width by default
   (dropdownAutoWidth is off) — fine for a plain single-line label, but a
   narrow trigger (e.g. a 230px inline picker) squeezes multi-column rows down
   to unreadable "INV-0…" fragments. Widen the dropdown itself well past the
   trigger; Select2 keeps it left-aligned to the control, so this only ever
   grows the popup rightward into open page space. */
.select2-container--open .select2-dropdown.select2-columns-dropdown {
  min-width: max(420px, var(--s2-control-w, 0px));
}

/* comboTree ledger-tree picker: the dropdown is re-parented to <body> and
   position:fixed by base.html's appInitLedgerTree (see the JS comment there for
   why) so it escapes a scrollable ancestor's clipping (e.g. the amount-lines
   grid's .table-responsive) — same problem Select2's dropdownParent already
   solves for every other picker. The panel lives on <body>, so it must out-rank
   the modal itself, not just the backdrop: this theme raises Bootstrap's stock
   modal z-index to 1090 (--bs-modal-zindex in vendor/css/core-*.css), so 1080
   put the dropdown BEHIND an open modal. 1095 clears it. A comfortable min-width
   keeps ledger names from being cramped into the trigger's own narrow width. */
.ct-drop-down-container--detached {
  z-index: 1095;
  min-width: 260px;
  /* Fallback only — reposition() in base.html overrides max-height per open with
     the space actually available on the side it chose (and flips the panel above
     the trigger when there isn't room below), so a picker near the bottom of a
     modal doesn't render its list past the viewport fold. */
  max-height: 280px;
  overflow-y: auto;
}

/* comboTree's stock +/- text glyphs (see .ct-parent-plus in comboTreeStyle.css)
   swapped for Tabler's chevron-right/chevron-down, matching the icon font this
   app already uses everywhere else (e.g. the Chart of Accounts page's own
   expand/collapse toggle, ledgers/list.html's .coa-toggle). Codepoints read
   directly from vendor/fonts/tabler-icons.css: \ea61 = ti-chevron-right,
   \ea5f = ti-chevron-down. Only a parent item (one with a sibling <ul>) gets an
   icon at all — a leaf's .ct-parent-plus span doesn't exist in the DOM. */
/* Wrap long ledger names inside the panel instead of overflowing it. A name
   with no spaces (e.g. "aaaa…aaa") has no natural break point, so the browser
   pushed it onto its own line under the icon and ran it past the panel's
   right edge behind a horizontal scrollbar. `anywhere` lets it break mid-word
   and still counts toward min-content sizing, so the icon stays beside it. */
.ct-drop-down-container {
  overflow-x: hidden;
}

.ct-drop-down-container span.ct-list-item-title {
  overflow-wrap: anywhere;
  word-break: break-word;
}

.ct-drop-down-container .ct-parent-plus {
  font-family: "tabler-icons" !important;
  font-style: normal;
  font-weight: normal !important;
  font-size: 1rem !important;
  line-height: 1 !important;
  text-indent: -9999px;
  overflow: hidden;
}

.ct-drop-down-container .ct-parent-plus::before {
  content: "\ea61";
  text-indent: 0;
  position: absolute;
  left: 0;
  right: 0;
  text-align: center;
}

.ct-drop-down-container .ct-item-parent:has(> ul:not([style*="display: none"]))>.ct-parent-plus::before {
  content: "\ea5f";
}

.select2-selection__choice.js-dept-count {
  cursor: default;
}

/* ---- Required-field indicator ---------------------------------------------------
   Any label whose field (a following sibling inside the same field wrapper, possibly
   nested in an input-group / Select2 / "+ add" flex wrapper) carries `required` gets
   a red asterisk. Purely attribute-driven, so JS that toggles `required` at runtime
   (e.g. Email when "create user account" is ticked) updates the star automatically.
   Select2 keeps the original <select>'s required attribute, so pickers match too.
   Labels that already hand-render a star (<span class="text-danger">*</span>, the
   older per-template convention) are excluded so they never show a double star. */
.form-label:not(:has(.text-danger)):has(~ input:required)::after,
.form-label:not(:has(.text-danger)):has(~ select:required)::after,
.form-label:not(:has(.text-danger)):has(~ textarea:required)::after,
.form-label:not(:has(.text-danger)):has(~ div input:required)::after,
.form-label:not(:has(.text-danger)):has(~ div select:required)::after,
.form-label:not(:has(.text-danger)):has(~ div textarea:required)::after {
  content: " *";
  color: var(--bs-danger, #ff3e1d);
}

/* Line-item tables: a column whose cells are required on any line in use
   (`data-required-in-row` on the cells, see main.js) marks its <th> with
   `data-required-col`. Static — the column is required whenever a line is used,
   and the empty starter row is never blocked. */
th[data-required-col]::after {
  content: " *";
  color: var(--bs-danger, #ff3e1d);
}

/* ---- List pages: card height ----------------------------------------------------
   Every list page renders `card > table-responsive > table` followed by the pager.

   ORIGINALLY this block made the card STRETCH — `flex: 1 1 auto` inside a flex-column
   page container, plus a 24rem floor — so that an empty, a short and a full list all
   occupied the same height and the pager did not jump between views. That was the
   documented intent, and for the empty state it is still right.

   That stretch was then REMOVED (UX review C2), because a one-row list rendered a card
   several hundred px tall with the row at the top and nothing under it — on Occasion
   wishes with one occasion, roughly two thirds of the screen was empty white, which
   reads as "something failed to load" rather than "there is one item". The card was
   made to shrink to its rows instead.

   That over-corrected, and this block is the third state. Shrinking to rows left the
   footer riding up into the middle of the page on every short list — a one-row
   Customers list and a seven-row Users list each ended mid-screen with the
   copyright line under them, and no two list pages agreed on where anything sat.

   The diagnosis both earlier passes missed is that "card height" is really TWO
   decisions. C2's white space came from the TABLE stretching, not from the card being
   tall: the row was marooned at the top of a tall empty box. So the two are now split
   — the card stretches (stable footer, consistent pages) while the table stays the
   height of its rows (no marooned row; the slack is card background beneath the rows,
   which reads as a panel). The EMPTY state keeps its own taller floor, where the icon
   + heading + explanation + CTA genuinely need the room.

   The empty case is detected from the markup the shared macro already emits:
   layout/partials/_empty_state.html tags its row `.app-empty-state-row` (both the full
   treatment and the compact one-liner), so no list template needs to know about this.
   A list that still hand-rolls a bare `<td colspan>` empty row carries no marker and
   gets the short card — the fix for those is to use the macro, not to widen this
   selector. :has() is already load-bearing in this file (see the page-container rule
   directly below), so it is not a new dependency. */
.content-wrapper>.container-p-y:has(> .card.list-table-card) {
  display: flex;
  flex-direction: column;
}

.card.list-table-card {
  /* 1 1 auto: the card fills the height the page's flex column leaves it, so the
     footer sits at the bottom of the viewport on every list page whatever the row
     count. No calc(100vh - Npx) is needed and none should be added: the theme
     already builds the chain (html min-height:100vh -> .layout-wrapper ->
     .layout-page -> .content-wrapper, each flex:1 1 auto, with .layout-navbar and
     .content-footer as flex:0 0 auto), so the browser subtracts the navbar, page
     header, pager and footer itself -- and stays right when any of them changes
     height. A hardcoded offset would not.

     This does NOT bring back the C2 complaint (a one-row list showing two thirds
     of a screen of white). What was wrong then was that the ROWS were stretched,
     leaving a single row marooned at the top of a tall empty box. Here the card
     stretches and the rows do not: .table-responsive below fills the card, but a
     <table> does not grow to fill a taller block parent, so the rows keep their
     natural height and the slack is card background beneath them. That reads as a
     panel rather than as a list that failed to load. */
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  min-height: var(--app-list-card-min-h);
}

/* The empty state keeps the TALLER floor. The base rule above already stretches the
   card; what is specific to the empty state is that its floor must fit the icon +
   heading + explanation + CTA, which 12rem does not. */
.card.list-table-card:has(.app-empty-state-row) {
  min-height: var(--app-list-card-empty-min-h);
}

/* The wrapper STRETCHES to the bottom of the card, but its table does not.

   Why the wrapper and not the rows: this element is the one that scrolls
   horizontally (`.table-responsive`), and a scrollbar is painted at the bottom
   edge of the box that owns it. When this was `flex: 0 0 auto` the box ended
   directly under the last row, so on a one-row list the horizontal scrollbar sat
   stranded in the middle of the card with empty card below it.

   `flex: 1 1 auto` puts that bottom edge at the bottom of the card, so the
   scrollbar lands where a scrollbar belongs. The rows stay their natural height
   regardless, because a `<table>` does not stretch to fill a taller block
   parent -- which is the property the C2 dead-space fix actually needed. The
   slack is card background BELOW the rows, and the scrollbar rides its bottom.

   `overflow-y: auto` so a list longer than the card scrolls within it instead of
   pushing the pager off the bottom of the page. */
.card.list-table-card>.table-responsive {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

/* A page whose list cards are the panes of a top-level `.tab-content` (the
   project Debit tab: Paid/Voided · Outstanding · Advances share one URL, so they
   are panes, not pages). The stretch above only reaches a card that is a DIRECT
   child of the page container; here two wrappers sit in between, and without
   them joining the flex column the card shrank to its rows -- the short card
   with the footer riding up that the rule above exists to prevent. Only the
   ACTIVE pane becomes flex: Bootstrap hides the others with display:none, and
   overriding that would show all three at once. */
.content-wrapper>.container-p-y:has(> .tab-content > .tab-pane > .card.list-table-card) {
  display: flex;
  flex-direction: column;
}

.container-p-y>.tab-content:has(> .tab-pane > .card.list-table-card) {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
}

.container-p-y>.tab-content>.tab-pane.active:has(> .card.list-table-card) {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
}

/* ---- List tables: column widths -------------------------------------------------
   Opt-in classes for the `<th>` of a list column, reading the --app-col-w-* tokens.
   One definition, every list screen; a per-screen inline `style="width:…"` is banned
   by the UI standard (docs/ui-ux-saas-standard-plan.md) precisely because it puts the
   same decision in 186 places.

   WHY A WIDTH ON `<th>` IS SAFE HERE, AND WHY IT IS A HINT RATHER THAN A CAP.
   These tables are `table-layout: auto` (the browser default — nothing in this app
   sets `fixed`, and it must stay that way). Under auto layout a specified column width
   is clamped UP to the column's min-content width: a value SMALLER than the content
   is simply ignored, so a token can never clip or ellipsis a cell, and it can never
   be the thing that makes `.table-responsive text-nowrap` scroll sideways. What it
   does do is take the column out of the queue for the table's SURPLUS width: Chromium
   and Gecko hand the extra space to `width:auto` columns first and only then share it
   between fixed columns in proportion to their widths. That is the whole mechanism —
   the numbers below are a statement of RELATIVE intent (a name column is 2.5x a badge
   column), not of final rendered pixels.

   Two ways to use it, and the choice is deliberate per table:
     - a table with a FREE-TEXT column (a message body, an evidence note) tokenises
       every OTHER column and leaves the free-text one `auto`, so the surplus lands in
       the one column that can spend it;
     - a table with no free-text column tokenises EVERY column, so the surplus scales
       the intended proportions instead of inflating whichever heading is longest.

   `table-layout: fixed` would give exact control and is deliberately NOT used: with
   the `text-nowrap` these tables carry, a fixed column narrower than its content
   overflows into its neighbour instead of growing, which trades a cosmetic defect for
   a data-loss one. */
.app-col-narrow {
  width: var(--app-col-w-narrow);
}

.app-col-meta {
  width: var(--app-col-w-meta);
}

.app-col-name {
  width: var(--app-col-w-name);
}

.app-col-actions {
  width: var(--app-col-w-actions);
}

/* A column whose CELLS hold a live form control rather than text — today the
   "Set stage" select2 + submit on /wa-marketing-status. Not one of the four: the
   others are sized from the text they render and are free to be ignored when the
   content is wider, whereas this one is sized from a WIDGET whose own width is
   computed by JS from the cell it is initialised in (select2 collapses the native
   <select> to 1px and sizes its generated container to 100% of the cell). The 230px
   is carried over verbatim from the inline width it replaces, measured as correct in
   docs/qa/walkthrough-findings.md section N — same computed width, one fewer inline
   style. */
.app-col-control {
  width: var(--app-col-w-control);
}

/* The FREE-TEXT column — a message body, a note — on its <th> and its <td>s. It is
   the column the tokens above leave `auto` so it absorbs the surplus, which also
   made it the one the browser squeezed first when there was NO surplus: with
   `overflow-wrap: anywhere` its min-content is a single character, so the
   WhatsApp-templates Body column measured 76px wide, rows 360px tall, hidden behind
   the pinned Action column (sweep 4, item 77). A floor keeps it readable — the
   table scrolls inside its card below that instead. No cap: `max-width` on a table
   cell is ignored under `table-layout: auto` (measured — the inline 280px these
   cells carried never applied), and none is needed, because the text WRAPS (opting
   out of the shell's `text-nowrap`, breaking a long unbroken token rather than
   overflowing), so it can only take surplus width, never force a scroll. */
.app-col-text {
  min-width: var(--app-col-w-text-min);
  white-space: normal;
  overflow-wrap: anywhere;
}

/* A column whose value can be ONE LONG TOKEN — an email, a snake_case table
   name, a place name, a campaign name — on its <th> and its <td>s. Under the
   shell's `text-nowrap` a single such value set the whole column's width and
   pushed the table past its card at 1280: /audit 978 in 972
   (`whatsapp_channel_settings`), /leads 985 in 972 (an email under Person), the
   Lead Report's campaign table (sweep 5, item 4). It wraps, breaking inside a
   token when it must. Only under pressure, though: with `table-layout: auto` a
   column keeps its one-line width whenever the table fits, and falls back towards
   its min-content only when it would not. The floor is the NARROW token, not
   .app-col-text's prose floor — 256px would itself widen these tables — and it is
   what stops the column collapsing to a character a line (item 77). */
.app-col-wrap {
  min-width: var(--app-col-w-narrow);
  white-space: normal;
  overflow-wrap: anywhere;
}

/* The shared confirm dialog (#appDialogModal in layout/base.html, behind every
   `data-confirm`). Its message usually names a record, and a record's name can be one
   long token -- a 160-character occasion title ran straight out of the dialog
   (bug-doc #305). Break inside the token rather than overflow. */
#appDialogBody {
  overflow-wrap: anywhere;
}

/* The AUDIT column — "Last Modified" / "Modified": the updated_at date stacked over
   who changed the row (the `modified_cell` byline) — on its <th> AND every <td>
   under it. One class, every list; a template adding an audit column adds this class
   to its header and its cells, never a per-screen media query.

   RE-DECIDED 2026-09-21 (ADR 0052). This used to be `display: none` below 1600px of
   viewport, which was the item-119 fix: the audit columns were then a PAIR (a bare
   date column plus the date-over-actor stack) costing ~330px, and they were what
   pushed nine- and ten-column lists past their card, where the pinned Actions column
   sat on top of them (/material-or-services 1529 in a 1132 card at 1440).

   Hiding below 1600 turned out to hide them almost always, because 1600 is CSS
   pixels and browser zoom spends them: a 1920 screen at 125% is 1536, a 1536 laptop
   at 100% is 1536, a 1366 laptop at 100% is 1366. Every ordinary laptop was under the
   line, so in practice nobody ever saw "Last Modified" — which is not what the owner
   asked for in item 119, and is what they asked to undo.

   So the columns are shown at every width, and the width they cost was removed
   instead of the columns:

     1. The PAIR was one column's worth of information. `modified_cell`'s first line
        IS the updated_at timestamp, so the bare-date column beside it printed the
        same fact twice. Seventeen list templates carried both; they now carry one,
        headed by the sortable "Last Modified" link that used to sit over the bare
        date. Measured at 1229px (a 1536 laptop at 125%): /material-or-services 1138
        -> 1031, /employees 1078 -> 971, /receipts 1048 -> 941.
     2. The surviving cell WRAPS (below), which lets the date and the time fall onto
        separate lines when the table is under pressure, taking its floor to the width
        of "17-Sep-2026" rather than of the whole stamp (`modified_cell` in
        layout/partials/byline.html keeps each half in its own nowrap span so the
        break lands between them).

   Measured afterwards across /customers, /material-or-services, /walk-ins,
   /appointments, /employees and /receipts at all fifteen CSS widths that 1366/1536/
   1920 produce at 80/90/100/110/125% zoom — 1093, 1229, 1242, 1366, 1396, 1518, 1536,
   1707, 1708, 1745, 1920, 2133, 2400. The column is VISIBLE in all ninety, where
   before it was hidden in seven of every fifteen. Eighty-six of the ninety also fit
   their card. The four that do not are /material-or-services (+66px, +53px) and
   /receipts (+45px, +32px) at 1228 and 1242 CSS px — a 1536 laptop at 125% and a 1366
   at 110% — the band where the sidebar is still expanded but the viewport is small,
   leaving a 921-934px card. Both scroll slightly inside their card there rather than
   losing the column, and the pinned Actions cell rides the scroll as it always has.
   Widening the card (the sidebar's collapse threshold, which drops at ~1200 and is
   why the narrower 1093 fits where 1229 does not) is the remaining lever, and it is a
   navigation decision rather than this one. */
.app-col-audit {
  /* Cells opt out of the list shell's `text-nowrap`. With `table-layout: auto` the
     stamp still renders on ONE line whenever the table has the room — the browser
     only falls back towards a column's min-content when the max-content layout would
     not fit — so this changes nothing on a table that already fits, and on one that
     does not it costs a second line here instead of a sideways scroll with the pinned
     Actions column over the last data column. Breaks at the space only (no
     `overflow-wrap`): a date split mid-token is unreadable. */
  white-space: normal;
}

/* ---- List density ----------------------------------------------------------
   Tightens the vertical chrome around list data. Scoped through the
   .list-table-card marker so DETAIL pages, modals, and dashboard cards keep the
   theme's roomier default — density is what a list screen needs, not what every
   screen needs.

   The theme spends ~8rem above the first data row, with every gap set to the
   same 1.5rem. Differentiating those gaps (section gaps larger than in-section
   gaps) is what gives the page a rhythm; see the token block at the top. */

/* Page top: the gap under the navbar does not need to equal the gap between
   the header and the card. */
.content-wrapper>.container-p-y:has(> .card.list-table-card) {
  padding-top: var(--app-section-gap) !important;
}

/* Header → card. Overrides the mb-4 the page-header partial emits, keeping the
   utility class in the markup as the single source of that spacing. */
.container-p-y:has(.card.list-table-card)>.d-flex:first-child {
  margin-bottom: var(--app-page-header-mb) !important;
}

/* Table header: pure chrome, so it gives up the most (1.161rem -> 0.75rem). */
.card.list-table-card .table thead tr th {
  padding-block: var(--app-table-header-py);
}

/* Row cells: still >= the ~44px touch-target floor once the row's -sm icon
   buttons (~30px) and the two-line name/code cell are accounted for. */
.card.list-table-card .table> :not(caption)>*>* {
  padding-top: var(--app-table-cell-py);
  padding-bottom: var(--app-table-cell-py);
}

/* Laptop widths: tighter horizontal padding so a list fits its card instead of
   scrolling sideways. The actions column is pinned (see "pinned actions column"
   below), so whenever a table is wider than its card the pinned cell covers the
   column before it -- the lone "₹" of a hidden Total. Fitting the table is the
   only cure for that; the pin itself stays for phones, where scrolling is expected.
   1440 and 1536 laptops are "laptop" too -- a twelve-column /receipts was still
   1174px in a 1132px card at 1440 even with the audit columns gone (item 119).
   1600px and up keep the theme's roomier padding.

   This KEEPS the 1600 line although .app-col-audit no longer has one (ADR 0052).
   The two were only ever the same number, never the same rule: this one is about
   how much air a cell can afford, and 1600 is still where a list stops being
   cramped. Moving it down with the audit change would have taken padding back off
   the very laptops the audit change is for, and the 2026-09-21 measurements were
   taken WITH this squeeze in force -- they are the numbers recorded above. */
@media (max-width: 1599.98px) {
  .card.list-table-card .table> :not(caption)>*>* {
    padding-left: var(--app-table-cell-px-compact);
    padding-right: var(--app-table-cell-px-compact);
  }
}

/* The same laptop squeeze for the two other things that made tables overflow.

   1. A `.table-sm` in a scrolling wrapper outside a list card — a settings grid of
      inputs, a detail-page sub-table. The theme gives `.table-sm` the SAME 1.25rem
      side padding as a full table (only its vertical padding is small), which on the
      Appointment Settings working-hours grid was 280px of its 957px (item 119).
      `table-sm` is the author asking for a compact table, so it gets the compact
      token wherever a list card's cells do.

   2. List HEADERS may wrap. "Modified By", "Net amount", "Customer/Vendor" are wide
      headings over narrow values, and under `text-nowrap` the heading alone sets the
      column's width. With `table-layout: auto` a wrappable heading still renders on
      one line whenever the table has the room — the browser only falls back towards
      a column's min-content when the max-content layout would not fit — so this
      changes nothing on a table that already fits and turns a sideways scroll (with
      the pinned Actions column over the last data column) into a two-line heading
      on one that does not. Cells keep `text-nowrap`: a wrapped VALUE is harder to
      scan than a wrapped heading. */
@media (max-width: 1599.98px) {
  .table-responsive>.table-sm> :not(caption)>*>* {
    padding-left: var(--app-table-cell-px-compact);
    padding-right: var(--app-table-cell-px-compact);
  }

  .card.list-table-card .table-responsive>.table>thead>tr>th {
    white-space: normal;
  }
}

/* Card → pager. The pager partial emits mt-3; keep it tighter and symmetric
   with the header gap so the card sits evenly between the two. */
.container-p-y:has(.card.list-table-card)>nav,
.container-p-y>.tab-content>.tab-pane:has(> .card.list-table-card)>nav {
  margin-top: var(--app-section-gap) !important;
}

/* List-page titles: the theme's h4 (~1.5rem) reads oversized next to the -sm
   filter controls on the same row — render at h5 scale instead. Scoped through
   the list-table-card marker so detail pages and modals keep the theme size. */
.container-p-y:has(.card.list-table-card)>.d-flex h4 {
  font-size: 1.125rem;
}

.container-p-y:has(.card.list-table-card)>.d-flex h4 .badge {
  vertical-align: middle;
}

/* The pager always renders OUTSIDE the list card, directly below it (the shared
   pager partial after the card close) — one placement everywhere, so pagination
   sits at the same spot on every list screen. Don't re-introduce in-card pager
   rows: Bootstrap's .card-body defaults to flex:1 1 auto, so inside a stretched
   card a pager row would split the spare space with the list and float mid-card. */

/* ---- List tables: pinned actions column -----------------------------------------
   List tables scroll horizontally inside .table-responsive on narrow screens, and
   the actions column (by convention the LAST column on every list page) scrolls
   out of reach with them. Pin the last column to the right edge so row actions
   stay visible while the data scrolls underneath. Pure CSS (sticky + right:0) on
   the shared list-table structure — no per-template markup. The cell needs an
   opaque background so scrolled data doesn't show through; Bootstrap's hover /
   striping paint via an inset box-shadow on the cell, so they still render on
   top of it. A table whose last column is NOT actions opts out with
   .no-sticky-actions on the <table>. */
.card.list-table-card .table-responsive>.table:not(.no-sticky-actions)>thead>tr>th:last-child,
.card.list-table-card .table-responsive>.table:not(.no-sticky-actions)>tbody>tr>td:last-child {
  position: sticky;
  right: 0;
  z-index: 2;
  background-color: var(--bs-card-bg, #fff);
  /* left-edge shadow as the "there is data underneath" scroll cue */
  box-shadow: -6px 0 8px -6px rgba(47, 43, 61, 0.14);
}

/* Bootstrap tables default cells to vertical-align:top, so the action icons sit
   flush with the row's top edge instead of centering against a taller sibling
   cell (e.g. a two-line "Modified By" date+name stack) — center them instead. */
.card.list-table-card .table-responsive>.table:not(.no-sticky-actions)>tbody>tr>td:last-child {
  vertical-align: middle;
}

.card.list-table-card .table-responsive>.table:not(.no-sticky-actions)>thead>tr>th:last-child {
  z-index: 3;
  /* header corner wins over body cells */
}

/* A dropdown opened from a pinned cell: sticky cells stack above their row, so
   the menu would render UNDER the pinned cells of the rows below. Raise the
   open row (and its own pinned cell) above its siblings while the menu is open. */
.card.list-table-card .table-responsive>.table>tbody>tr:has(.dropdown-menu.show) {
  position: relative;
  z-index: 5;
}

.card.list-table-card .table-responsive>.table>tbody>tr:has(.dropdown-menu.show)>td:last-child {
  z-index: 6;
}

/* ---- List tables: pinned leading column(s) ---------------------------------------
   The mirror of the pinned actions column: the row's identity (its number / name)
   stays on screen while the data scrolls sideways. Pins the FIRST column, plus the
   second when the first is the bulk-select box (`.app-col-select`, from
   layout/partials/_bulk_select.html) -- a lone checkbox says nothing about which
   row it is. Applies to every list table; one whose first column is NOT the row's
   identity opts out with .no-sticky-lead on the <table>.

   The select column gets a FIXED width (--app-col-select-w) because the second
   column's `left` offset has to equal it exactly, and pure CSS cannot measure it.
   The scroll-cue shadow goes on the right edge of the LAST pinned leading cell,
   layered after Bootstrap's inset hover/striping shadow so overriding box-shadow
   doesn't drop the hover tint on the pinned cells. Spanning cells (the empty-state
   row) are left alone. */
.card.list-table-card .table-responsive>.table:not(.no-sticky-lead) {
  --app-col-select-w: 3rem;
}

.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>*>tr>:first-child:not([colspan]),
.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>*>tr>.app-col-select:first-child+* {
  position: sticky;
  left: 0;
  z-index: 2;
  background-color: var(--bs-card-bg, #fff);
}

.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>*>tr>.app-col-select:first-child {
  width: var(--app-col-select-w) !important;
  min-width: var(--app-col-select-w);
  max-width: var(--app-col-select-w);
  padding-left: 1rem;
  padding-right: 0.5rem;
}

.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>*>tr>.app-col-select:first-child+* {
  left: var(--app-col-select-w);
}

.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>*>tr>:first-child:not(.app-col-select):not([colspan]),
.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>*>tr>.app-col-select:first-child+* {
  box-shadow:
    inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg))),
    6px 0 8px -6px rgba(47, 43, 61, 0.14);
}

/* header corner wins over body cells, as for the actions column */
.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>thead>tr>:first-child,
.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>thead>tr>.app-col-select:first-child+* {
  z-index: 3;
}

/* an open row dropdown must still cover the pinned leading cells of rows below */
.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>tbody>tr:has(.dropdown-menu.show)>:first-child,
.card.list-table-card .table-responsive>.table:not(.no-sticky-lead)>tbody>tr:has(.dropdown-menu.show)>.app-col-select:first-child+* {
  z-index: 6;
}

/* ---- List tables: row actions (row_actions macro) -------------------------------
   The one shape of a row's actions (layout/partials/_row_actions.html): Details and
   Edit as icons, everything else in the ⋮ -- and, when there is no room, EVERYTHING
   in the ⋮. "No room" is the `lg` breakpoint: below 992px (phones, portrait
   tablets) every list table scrolls sideways and the pinned actions column would
   spend ~130px of a 358px phone table on icons. The macro renders each inline
   action twice -- the icon (.app-row-action) and its menu twin
   (.app-row-action-twin) -- and these rules show exactly one of the pair, so the
   collapse is pure CSS: no per-page JS, no measuring.

   The wrapper is inline-flex so a cell's own text-align (some lists right-align
   their actions) still places it, and nowrap so the icons never stack into a
   taller row. */
.app-row-actions {
  display: inline-flex;
  align-items: center;
  flex-wrap: nowrap;
  white-space: nowrap;
  vertical-align: middle;
}

@media (max-width: 991.98px) {
  .app-row-actions>.app-row-action {
    display: none !important;
  }
}

/* Why a menu item is disabled, printed under its caption (bug-doc #320): a disabled
   .dropdown-item has pointer-events:none, so its title tooltip never shows. Indented
   past the item's icon, wrapping inside the menu instead of widening it. */
.dropdown-item:has(> .app-menu-reason) {
  flex-wrap: wrap;   /* the theme's items are flex rows: put the reason on its own line */
}

.dropdown-item .app-menu-reason {
  display: block;
  flex-basis: 100%;
  max-width: 16rem;
  padding-left: 1.625rem;
  white-space: normal;
  font-size: 0.75rem;
  line-height: 1.3;
}

@media (min-width: 992px) {
  .app-row-actions .app-row-action-twin {
    display: none !important;
  }

  /* A ⋮ that only holds twins has nothing to show at this width... */
  .app-row-actions>.app-row-menu-narrow {
    display: none !important;
  }

  /* ...unless the list keeps a ⋮-sized slot so rows with and without a menu line
     up (row_actions(menu_slot=true), the same box row_menu_slot() emits). */
  .app-row-actions>.app-row-menu-narrow.app-row-menu-slot {
    display: inline-block !important;
    visibility: hidden;
  }
}

/* ---- List tables: sortable column headers ---------------------------------------
   A header rendered via the `sort_link` macro (see list templates) carries
   .sortable-col so it's visually distinguishable from a plain, non-sortable header
   at a glance — a muted ↕ icon when it isn't the active sort, replaced by a solid
   ↑/↓ once clicked. Without this, every <th> looks identical and a user has no way
   to tell which columns respond to a click. */
.card.list-table-card th.sortable-col a {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
}

.card.list-table-card th.sortable-col a .ti-arrows-sort {
  opacity: 0.35;
  /* muted: sortable, not the active sort */
}

.card.list-table-card th.sortable-col a:hover .ti-arrows-sort {
  opacity: 0.7;
}

.card.list-table-card th.sortable-col a .ti-arrow-up,
.card.list-table-card th.sortable-col a .ti-arrow-down {
  opacity: 1;
  /* solid: this is the active sort */
}

/* ---- Scrollable modals with a wrapping <form> ---------------------------------
   .modal-dialog-scrollable relies on .modal-content being a flex column so the
   .modal-body shrinks and scrolls while header/footer stay pinned. Our form modals
   wrap header/body/footer in a <form> (so the footer submit belongs to the form),
   which is a plain block — the flex chain breaks, the body never gets a height
   constraint, and on short viewports the footer is clipped by .modal-content's
   overflow:hidden with no way to scroll to it. Make the form carry the same flex
   contract so the body scrolls and the footer stays reachable. */
.modal-dialog-scrollable .modal-content>form {
  display: flex;
  flex-direction: column;
  min-height: 0;
  max-height: 100%;
  overflow: hidden;
}

/* ---- PD detail: narrow numeric / measure fields --------------------------------
   Numeric controls (rates, sizes, areas, plot counts, market/guideline values,
   advances, extents, etc.) and the two width text fields (road_width, frontage)
   are rendered by pd_field_spec.js with .pd-field-narrow instead of col-md-4.
   At ≥768 px they are ~1/3 the width of a col-md-4 (≈11.5 % of the 12-column
   row) so they pack tighter; on small screens they expand to full width. */
@media (min-width: 768px) {
  .pd-field-narrow {
    flex: 0 0 auto;
    width: 16%;
    min-width: 130px;
  }
}

/* ---- Map picker search results (geo_map_picker.js + pd_map_picker.js) ----------
   The results dropdown floats directly over the Leaflet map, so a plain/translucent
   .list-group-item blends into the map tiles behind it. Scope solid, high-contrast
   row backgrounds to just these two result lists (by their data-*-results host). */
[data-geo-map-results] .list-group-item,
[data-pd-map-results] .list-group-item {
  background: #f1f1f1d1;
}

[data-geo-map-results] .list-group-item:hover,
[data-pd-map-results] .list-group-item:hover {
  background: #ffffffd1;
}

.truncate {
  display: inline-block;
  max-width: 110px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin: 0;
}

.truncate-md {
  display: inline-block;
  max-width: 160px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin: 0;
  cursor: pointer;
}

.truncate-lg {
  display: inline-block;
  max-width: 200px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin: 0;
}

.truncate-xl {
  display: inline-block;
  max-width: 400px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin: 0;
  cursor: pointer;
}

.word-break-two-line {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: normal;
  max-width: 300px;
  line-height: 1.4em;
}

.word-break-two-line-without-width {
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 1.4em;
}

.fc-scroller {
  overflow: hidden !important;
}

/* An appointment that will not happen (cancelled, no-show, rescheduled): it keeps its
   category's colour, which the calendar's toggles match, so it is muted and struck
   through instead -- it looked scheduled (F-0359). Set by CalendarService. */
.fc .app-cal-off {
  opacity: 0.55;
}
.fc .app-cal-off .fc-event-title,
.fc .app-cal-off .fc-list-event-title {
  text-decoration: line-through;
}

#pd-detail-container label {
  white-space: nowrap !important;
}

/* ---------------------------------------------------------------------------
   Rate-card cost grid (#243)

   Three cell states that must stay visibly distinct *without hover*: a priced
   cell, an unpriced one (dashed, empty, still editable) and an inapplicable one
   (shaded, a dot, no field at all). The distinction also carries in the DOM as
   different accessible names -- see the template; a tooltip would survive
   neither a screen reader in browse mode nor a printed screenshot.
   --------------------------------------------------------------------------- */
.rcg-table {
  font-variant-numeric: tabular-nums;
}

.rcg-item-col {
  position: sticky;
  left: 0;
  z-index: 2;
  min-width: 12rem;
}

/* The sticky column's opaque backing.
   `.rcg-item-col` alone (0,1,0) loses to Bootstrap's own
   `.table > :not(caption) > * > *` (0,1,1), which paints every cell
   `var(--bs-table-bg)` — usually transparent. The column then scrolled *over*
   the rate cells with nothing behind it, and the item name and the first
   column of rates rendered on top of each other. Matching Bootstrap's own
   selector shape wins on specificity without an `!important`, and the box
   shadow overrides the accent shadow Bootstrap paints in the same rule
   (striping/hover), which would otherwise show through the background. */
.rcg-table> :not(caption)>*>.rcg-item-col {
  background-color: var(--bs-card-bg, #fff);
  box-shadow: none;
}

/* The header cell of that column is the corner of two sticky axes, so it has to
   sit above the sticky column body beside it as well as above the rates. */
.rcg-table>thead>tr>.rcg-item-col {
  z-index: 3;
}

/* The theme uppercases every table `th`, which is right for a column heading and
   wrong for content: the body cells of this column carry item names a studio
   typed, and "Kitchen (L-shaped)" shouted back as "KITCHEN (L-SHAPED)" loses the
   casing somebody chose. Scoped to `tbody` so the "Item" heading above keeps the
   treatment every other heading has, and specific enough to beat the theme's own
   `.table th`. The category band below stays uppercase -- that one is a heading. */
.rcg-table tbody th.rcg-item-col {
  text-transform: none;
  letter-spacing: normal;
}

/* Wide enough for a lakh-scale figure in the money format ("₹1,25,000.00") -- at
   6rem every price was cut to "₹1250.0C" (the owner's screenshot of 12 October). */
.rcg-cell {
  min-width: 8.5rem;
}

/* The category band inside a grid. A grid is one axis subset and can hold items
   from several categories, so this is where the reader is told which is which.
   Sticky-left with the item column, because the band is only useful if it is
   still on screen when the grid is scrolled sideways. */
.rcg-catrow>td {
  position: sticky;
  left: 0;
  padding-top: 0.35rem;
  padding-bottom: 0.35rem;
  background: var(--bs-tertiary-bg, #f8f9fa);
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--bs-secondary-color, #6c757d);
}

.rcg-input {
  border: 0;
  border-radius: 0;
  text-align: right;
  background: transparent;
}

.rcg-input:focus {
  box-shadow: inset 0 0 0 2px var(--bs-primary);
}

/* Unpriced: an empty field with a dashed rule, so the eye reads "nothing here
   yet" rather than "zero". */
.rcg-input.rcg-unpriced {
  border-bottom: 1px dashed var(--bs-warning);
}

/* Inapplicable: shaded, marked with a dot, and never editable -- this rate does
   not exist for this item, which is a different fact from nobody having typed it. */
.rcg-na {
  background-image: repeating-linear-gradient(45deg, transparent, transparent 4px,
      rgba(var(--bs-secondary-rgb, 108, 117, 125), 0.12) 4px,
      rgba(var(--bs-secondary-rgb, 108, 117, 125), 0.12) 8px);
  color: var(--bs-secondary-color, #6c757d);
}

/* The paste/selection rectangle. */
.rcg-cell.rcg-selected {
  outline: 2px solid var(--bs-primary);
  outline-offset: -2px;
}

/* ---------------------------------------------------------------------------
   Rate-card readiness checklist (the right rail).

   Replaces the stack of warning cards an incomplete card used to print. A step
   is a thing that becomes *true*, so it carries a mark rather than a colour: a
   tick when it is done, a hollow ring for the one that is next, a faint ring for
   the ones after it. State is never carried by colour alone -- the mark differs,
   and the text carries it too (see the partial's visually-hidden state).
   --------------------------------------------------------------------------- */
.rc-checklist .rc-check {
  flex: 0 0 auto;
  width: 1.25rem;
  height: 1.25rem;
  margin-top: 0.1rem;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 0.75rem;
  line-height: 1;
  border: 1.5px solid var(--bs-border-color, #d9dee3);
}

.rc-checklist .rc-check-done {
  background: var(--bs-success, #71dd37);
  border-color: var(--bs-success, #71dd37);
  color: #fff;
}

/* The step that is next. A ring rather than a fill: it is not done, and a filled
   mark beside an undone step is the thing that made the old rail read as noise. */
.rc-checklist .rc-check-current {
  border-color: var(--bs-primary, #696cff);
  border-width: 2px;
  box-shadow: 0 0 0 3px rgba(105, 108, 255, 0.12);
}

.rc-checklist .rc-check-todo {
  border-style: dashed;
}

/* ---------------------------------------------------------------------------
   Design Assets (build plan phase 3a)
   --------------------------------------------------------------------------- */

/* The upload control. `00-overview`'s card section E names a bare "Choose File"
   button as one of four tells that a phase is not done -- and here the reason is
   a product one rather than a cosmetic one: this is the one control on the page
   whose *constraints* the studio has to read before they use it. The real input
   is still inside and still the thing that submits, so this works with no
   JavaScript and with a keyboard; main.js only adds drag/drop and the chosen
   file's name. */
.asset-drop {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  width: 100%;
  padding: 1.15rem 1.25rem;
  border: 1.5px dashed var(--bs-border-color, #d9dee3);
  border-radius: 0.5rem;
  background: var(--bs-body-bg, #fff);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease;
}

.asset-drop:hover,
.asset-drop:focus-within {
  border-color: var(--bs-primary, #696cff);
  background: rgba(105, 108, 255, 0.04);
}

.asset-drop.is-dragging {
  border-color: var(--bs-primary, #696cff);
  border-style: solid;
  background: rgba(105, 108, 255, 0.08);
}

/* A disabled input (a read-only form) leaves the zone inert: no pointer, no hover. */
.asset-drop:has(.asset-drop__input:disabled) {
  cursor: not-allowed;
  opacity: 0.6;
  border-color: var(--bs-border-color, #d9dee3);
  background: var(--bs-body-bg, #fff);
}

.asset-drop.is-filled {
  border-style: solid;
  border-color: var(--bs-success, #71dd37);
}

/* Visually hidden, not display:none -- it must stay focusable and stay in the
   form, because it is what actually carries the bytes. */
.asset-drop__input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.asset-drop__icon {
  font-size: 1.5rem;
  line-height: 1;
  color: var(--bs-primary, #696cff);
  flex: 0 0 auto;
}

.asset-drop--sm {
  padding: 0.7rem 0.85rem;
  gap: 0.6rem;
}

.asset-drop--sm .asset-drop__icon {
  font-size: 1.1rem;
}

.asset-drop__main {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.asset-drop__title {
  font-weight: 500;
  color: var(--bs-heading-color, #384551);
}

.asset-drop__hint {
  font-size: 0.8125rem;
  color: var(--bs-secondary-color, #a5a3ae);
}

/* The gallery card. A thumbnail where there is one, and a stated size where
   there is not -- never an invented picture, which is the same rule the editor's
   missing-model placeholder follows one layer down. */
.asset-card__thumb {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  background: var(--bs-tertiary-bg, #f5f5f9);
  border-radius: 0.5rem 0.5rem 0 0;
}

.asset-card__thumb img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.asset-card__ghost {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  color: var(--bs-secondary-color, #a5a3ae);
  font-size: 0.8125rem;
}

.asset-card__ghost i {
  font-size: 1.75rem;
}

.min-width-200px {
  min-width: 200px !important;
}

.min-width-150px {
  min-width: 150px !important;
}

.height-110px {
  height: 110px !important;
}

.select2-selection--single {
  .select2-selection__rendered {
    max-width: calc(100% - 40px) !important;
  }
}

/* ============================================================================
   SHELL BRAND MARK — layout/partials/_workspace_brand.html
   ----------------------------------------------------------------------------
   The tenant's uploaded workspace image, in the sidebar brand row (every page)
   and the subscription-gate topbar. Uploads are arbitrary pictures, not square
   logos: the QA tenant's is a 375x250 landscape PHOTOGRAPH, and a real logo is
   usually a wide wordmark.

   What the old inline style did, measured: `height:34px;width:34px;
   object-fit:cover` on a 375x250 image scales it to 51x34 to fill the square and
   then CROPS 8.5px off each side. It never pushed the row around — the box was
   fixed — but it silently ate the ends of anything wider than it is tall, which
   is precisely what a wordmark is. `contain` keeps the whole mark, letterboxed.

   The box stays SQUARE rather than growing sideways because the sidebar has a
   collapsed state (`.app-brand { width: 4.375rem }` = 70px, vendor core-blue.css)
   that this same element has to sit inside; a slot wide enough to flatter a wide
   logo when expanded would be clipped by that rail when collapsed. Fixed height +
   fixed width + `contain` therefore means: never taller than the row, never wider
   than the rail, never squashed, never cropped.

   `--app-brand-img-size` is set per call site (34px sidebar, 28px gate).
   ========================================================================== */
.app-brand-tenant-img {
  display: block;
  width: var(--app-brand-img-size, 34px);
  height: var(--app-brand-img-size, 34px);
  object-fit: contain;
  object-position: center;
}

/* Q19: a studio with no logo yet is marked by its initials -- the same source as the
   name beside it. Sized like the logo it stands in for. */
.app-brand-initials {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--app-brand-img-size, 34px);
  height: var(--app-brand-img-size, 34px);
  border-radius: 0.375rem;
  background: var(--bs-primary);
  color: #fff;
  font-weight: 700;
  font-size: calc(var(--app-brand-img-size, 34px) * 0.42);
  letter-spacing: 0.02em;
}

/* ============================================================================
   SHELL BRAND ROW — layout/partials/menu_brand.html
   ----------------------------------------------------------------------------
   The sidebar's collapse toggle must stay inside the brand row, whatever the
   workspace is called. Reported as "the hamburger is missing from the sidebar"
   on QA; it was not missing and it was not a regression from a deploy — it was
   CLIPPED, and had been for as long as the workspace name was long enough.

   `.app-brand` (vendor core-blue.css) is 260px wide with `overflow: hidden`,
   and neither of its children would give way: `.app-brand-link` is
   `flex: 0 1 auto` with the flex default `min-width: auto`, and
   `.app-brand-text` is `flex-shrink: 0`. So a long name kept the link at its
   full content width, the toggle's `ms-auto` pushed it past 260px, and
   `overflow: hidden` ate it. Measured at 1920 with only the NAME changed (the
   mark is a fixed 34x34 square in both branches of _workspace_brand.html, so
   it is not a variable):

     "Landess Connect"        link 200.64px   toggle 226 -> 244   inside, hittable
     "WhatsApp Marketing Co"  link 264.28px   toggle 286 -> 304   16.3px outside

   — the second pair being QA's own figures to the pixel. The bug is therefore
   DATA-dependent: it appears for any tenant whose branding overflows the row
   and not for the others, which is why no test and no local check had caught
   it. Hence the shared classes here rather than anything tenant-specific.

   The name is the part that gives way: it is the only child that can (the mark
   is fixed, the toggle is the control being protected) and it already carries
   `text-truncate` in _workspace_brand.html — it was simply never allowed to
   act. The toggle is pinned so that, once the row can shrink at all, it is not
   the thing squeezed.

   Not `.app-brand-link` on its own: the subscription gate
   (layout/subscription_gate.html) uses that class OUTSIDE any `.app-brand`,
   with no `text-truncate` on its name, where there is no 260px box to escape.
   Scoping to `.app-brand` keeps this to the row that actually clips.

   Guard: tests/unit/test_app_css_brand_row_toggle.py. `getBoundingClientRect()`
   and computed `visibility` BOTH report the clipped toggle "visible" — the
   checks that tell the truth are `toggle.right <= brand.right` and
   `document.elementFromPoint()` returning the toggle.
   ========================================================================== */
.app-brand > .app-brand-link {
  min-width: 0;
}

.app-brand .app-brand-text {
  flex-shrink: 1;
  min-width: 0;
}

.app-brand .layout-menu-toggle {
  flex-shrink: 0;
}

/* ============================================================================
   WHATSAPP CHAT — wa_chat/_panel.html, wa_chat/_bubble.html, wa_chat/inbox.html
   ----------------------------------------------------------------------------
   The in-app WhatsApp conversation reads like WhatsApp in the app's own palette:
   a tinted thread, received bubbles on the left on the card surface, sent bubbles
   on the right in the theme primary, each with its "tail" corner squared off. It
   is the treatment Vuexy's own chat app uses (vendor/css/pages/app-chat.css),
   minus that page's fixed full-viewport scaffold, so ONE panel fits a detail tab
   and the Chats inbox alike.
   ========================================================================== */
.wa-chat-thread {
  height: min(62vh, 560px);
  overflow-y: auto;
  padding: 1rem 1.25rem;
  background-color: var(--bs-body-bg);
  /* Scrolling back prepends earlier messages and the panel script keeps the reader's
     place itself; the browser's own scroll anchoring would shift it a second time. */
  overflow-anchor: none;
}

.wa-chat-bubble {
  max-width: 75%;
  box-shadow: 0 0.0625rem 0.375rem 0 rgba(47, 43, 61, 0.1);
}

.wa-chat-row-in .wa-chat-bubble {
  background-color: var(--bs-card-bg, #fff);
  border-top-left-radius: 0 !important;
}

.wa-chat-row-out .wa-chat-bubble {
  border-top-right-radius: 0 !important;
}

.wa-chat-bubble img {
  max-width: 100%;
  max-height: 320px;
}

.wa-chat-text {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.wa-chat-meta {
  margin-top: 0.25rem;
  font-size: 0.75rem;
  text-align: end;
}

/* The tick on a sent bubble (wa_chat/_macros.html::delivery_tick) — WhatsApp's own
   marks. Read is WhatsApp's blue; "Not delivered" is a badge rather than a bare red
   icon, so it still reads on the primary-coloured bubble. */
.wa-chat-tick-read {
  color: #53bdeb;
}

.wa-chat-meta .wa-chat-tick-failed {
  font-size: 0.6875rem;
  vertical-align: text-bottom;
}

/* ---- Party import: the step-2 review grid (bug-doc rev2 item 138) -----------
   `#party-import-grid` (app/templates/party_import/step2_review.html, rebuilt as
   an editable grid by app/static/js/party_import_grid.js) is 13 columns of form
   controls inside one `.table-responsive` card. It was reported as an "alignment
   issue"; measured at 1920px the four rows of a four-row file were 94 / 95 / 133
   / 166 px tall -- a 72px spread -- and the screenshot showed values cut off
   mid-word ("vimal Tr", a year as "2(").

   Both symptoms are ONE cause. Bootstrap's `.table` is `width: 100%`, so an
   auto-layout table squeezes all 13 columns into the card however narrow the
   card is, and the two kinds of cell then fail in opposite directions:

     * a bare `.form-control` is `width: 100%`, which contributes NO minimum
       width in auto table layout, so the cell collapses towards its padding and
       the input scrolls its own value out of sight -- the clipping;
     * a control group (the phone cell's dial-code select + number, a date cell's
       field + its clear button) is a flex container whose items DO carry a
       minimum, so `.input-group`'s `flex-wrap: wrap` breaks it onto a second
       line instead -- the row grows a line taller than its neighbours, and which
       rows do that depends on which optional fields they happen to have filled.

   So the table is allowed to be wider than its wrapper, and each data cell gets
   a real floor. The wrapper is already `.table-responsive`, so the overflow
   scrolls horizontally, which is what the rest of the app's wide tables do. */
#party-import-grid {
  width: auto;
  min-width: 100%;
}

/* Bootstrap bottom-aligns `thead` and top-aligns the body. A two-line header
   ("CAN SEND MARKETING") therefore pushes every single-line header down to its
   own baseline -- "NAME *" sitting lower than the select-all box beside it is
   the offset in the report. One shared middle for both halves. */
#party-import-grid > thead,
#party-import-grid > tbody {
  vertical-align: middle;
}

/* "NAME" and the required-marker "*" are one label; without this the star wraps
   onto a second line of its own and the header row grows for nothing. */
#party-import-grid > thead > tr > th {
  white-space: nowrap;
}

/* A floor per data column, so the table cannot collapse a column to nothing. */
#party-import-grid > tbody > tr > td[data-column] {
  min-width: 11rem;
}

/* The cell floor alone did not stop values being cut off. An input shares its
   cell with the dial-code select (phone) or the clear button (dates), and a bare
   .form-control shrinks to whatever is left: measured on QA after the cell floor
   shipped, the phone number showed 28 of its 100px -- three digits of ten -- and
   the dates 77 of 105px. A floor on the INPUT makes the column grow instead; the
   grid already scrolls sideways inside its card. 13rem fits the phone, the dates
   and a typical name; e-mail addresses run longer. */
#party-import-grid > tbody > tr > td[data-column] .form-control {
  min-width: 13rem;
}

#party-import-grid > tbody > tr > td[data-column] .form-control[type="email"] {
  min-width: 15rem;
}

/* Owner bug #11a (2026-09-24): the Category cell's Select2 replaces its
   <select> -- `form-select`, never `form-control` -- with a generated
   `.select2-container`, so the `.form-control` floor two rules up never
   touched it. Every sibling cell in this row got wide enough to read in
   full (the floor above); Category alone stayed at whatever `width: 100%`
   of its flex-grow-1 wrapper happened to compute to, which read as "too
   small" next to them. Same floor as the other cells. */
#party-import-grid > tbody > tr > td[data-column] .select2-container {
  min-width: 13rem;
}

/* The dial-code select stops stacking above the number, and a date field stops
   stacking above its clear button -- the ragged row heights. */
#party-import-grid .input-group {
  flex-wrap: nowrap;
}

/* The status cell carries a badge plus a wrapping note, so it keeps a floor of
   its own rather than being the column the others squeeze. */
#party-import-grid .js-row-status {
  min-width: 12rem;
}

/* Item 163: the review grid's .table-responsive container must be position: relative
   so positioned descendants (such as .input-group) stay inside the scroll container
   instead of escaping and triggering a page-wide horizontal scrollbar. */
.table-responsive:has(> #party-import-grid) {
  position: relative;
}

/* Chats inbox: the conversation list beside the open conversation. */
.wa-inbox {
  height: calc(100vh - 16rem);
  min-height: 32rem;
}

.wa-inbox-sidebar,
.wa-inbox-conversation {
  height: 100%;
  min-height: 0;
}

.wa-inbox-list {
  min-height: 0;
  overflow-y: auto;
}

.wa-inbox-thread {
  margin: 0.125rem 0.5rem;
  border-radius: 0.375rem;
  color: inherit;
}

.wa-inbox-thread:hover {
  background-color: rgba(var(--bs-primary-rgb), 0.06);
  color: inherit;
}

.wa-inbox-thread.active {
  background-color: rgba(var(--bs-primary-rgb), 0.12);
}

.wa-inbox .wa-chat-card {
  display: flex;
  flex-direction: column;
  height: 100%;
  border-radius: 0;
  box-shadow: none;
}

.wa-inbox .wa-chat-thread {
  flex: 1 1 auto;
  height: auto;
  min-height: 0;
}

/* Which app a conversation is on (ADR 0093): the small mark on a Chats avatar's
   corner, and the per-app tone it and the filter chips use (approved prototype D,
   docs/designs/unified-inbox). */
.chat-app-whatsapp { --chat-app-color: #25d366; }
.chat-app-instagram { --chat-app-color: #d6249f; }
.chat-app-messenger { --chat-app-color: #0a7cff; }
.chat-app-telegram { --chat-app-color: #229ed9; }
.chat-app-wechat { --chat-app-color: #07c160; }
.chat-app-mark { color: var(--chat-app-color); }
.chat-app-avatar { position: relative; }
.chat-app-avatar .chat-app-badge {
  position: absolute;
  right: -4px;
  bottom: -4px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 2px solid var(--bs-paper-bg, #fff);
  background: var(--chat-app-color);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
}
.chat-app-avatar .chat-app-badge .ti { font-size: 11px; }
.chat-app-stack { display: inline-flex; }
.chat-app-stack .chat-app-dot {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--chat-app-color);
  color: #fff;
  font-size: 11px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 2px solid var(--bs-paper-bg, #fff);
  margin-left: -5px;
}
.chat-app-stack .chat-app-dot:first-child { margin-left: 0; }
.chat-app-stack .chat-app-dot.is-off { filter: grayscale(1); opacity: 0.55; }

@media (max-width: 991.98px) {
  .wa-inbox,
  .wa-inbox-sidebar,
  .wa-inbox-conversation {
    height: auto;
  }

  .wa-inbox-list {
    max-height: 70vh;
  }

  .wa-inbox .wa-chat-thread {
    height: 60vh;
  }
}

/* ============================================================================
   EXPLAINED TERM — layout/partials/_glossary.html
   ----------------------------------------------------------------------------
   The mark that says "this word has a meaning you can ask for": a dotted underline
   and the help cursor. One sentence arrives on hover and, because the span is
   focusable, on keyboard focus too.

   This is NOT a new visual language — it is the one the theme already ships.
   Bootstrap's Reboot, vendored in vendor/css/core.css, styles `abbr[title]` with
   exactly these three declarations, so a reader who has met a defined term anywhere
   on the web has already met this one. It is not written AS an `<abbr>` because
   these are business terms, not abbreviations, and a screen reader announces
   `<abbr>` as one.

   Deliberately not the `?` icon: that mark belongs to help_hint.html's
   click-to-open popover, and the two affordances stay told apart by sight — one
   says "there is a word here to define", the other "there is a paragraph here to
   read".

   The focus ring is explicit rather than left to the UA default: a `<span>` is not
   natively focusable, so nothing in the theme has ever had to style its focus, and
   a keyboard user needs to see where the tooltip is about to come from.
   ========================================================================== */
.app-term {
  text-decoration: underline dotted;
  text-decoration-skip-ink: none;
  cursor: help;
}

.app-term:focus-visible {
  outline: 2px solid var(--bs-primary);
  outline-offset: 2px;
  border-radius: 0.125rem;
}
/* ── Menu-builder presentation preview (ADR 0051) ──────────────────────────────
   The chat viewer's own bubbles (.wa-chat-*, above) drawn inside the builder, so a
   preview looks like the conversation it predicts. Only what the chat has no rule
   for is added here: the flat "buttons" and list rows WhatsApp draws under a bubble,
   and the frame standing in for a product photo. */
.wa-style-preview-inner {
  background-color: var(--bs-tertiary-bg, #f5f5f9);
  /* The bubbles scroll; the message-count line above them does not, so the cost of
     the screen stays on screen however long the conversation it predicts. */
  max-height: 22rem;
  overflow-y: auto;
}

.wa-style-preview-inner .wa-chat-bubble {
  max-width: 100%;
  width: 100%;
}

.wa-style-preview-button {
  color: var(--bs-primary);
  border-color: var(--bs-border-color) !important;
}

.wa-style-preview-photo {
  height: 4.5rem;
  background-color: var(--bs-secondary-bg, #eceef1);
}

.wa-style-preview-rows .border-bottom:last-child {
  border-bottom: 0 !important;
}

/* A preview that is being refreshed SAYS SO. The only busy signal this panel used to
   carry was `aria-busy`, which paints nothing, so a refresh that was merely slow was
   indistinguishable from a control that was not wired up — and on QA six racing
   requests made one modal open take 4.7s (see wa_presentation_preview.js). The old
   drawing dims, because it is a picture of a DIFFERENT style than the one now
   selected, and a chip names what is happening. */
.wa-style-preview {
  position: relative;
}

.wa-style-preview-busy>* {
  opacity: 0.4;
}

.wa-style-preview-busy::after {
  content: "Refreshing…";
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  z-index: 1;
  padding: 0.125rem 0.5rem;
  border-radius: 999px;
  font-size: 0.75rem;
  color: var(--bs-secondary-color, #6f6b7d);
  background-color: var(--bs-tertiary-bg, #f5f5f9);
  border: 1px solid var(--bs-border-color, #d1d0d4);
}

/* ============================================================================
   FILE THUMBNAIL — a small square that opens a file in the shared preview dialog
   (partials/_file_preview_modal.html). First used for a customer's UPI payment
   screenshots on the storefront order page (owner, 2026-09-27); use it wherever a
   handful of attached files should read as pictures rather than as a table.
   An image fills the square (cropped to fit -- the dialog shows it whole); any
   other file shows its icon. `--pending` is a file still on its way.
   ========================================================================== */
.app-file-thumb {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 4.5rem;
  height: 4.5rem;
  padding: 0;
  overflow: hidden;
  border: 1px solid var(--bs-border-color);
  border-radius: var(--bs-border-radius);
  background: var(--bs-light-bg-subtle, var(--bs-body-bg));
  color: var(--bs-secondary-color);
  font-size: 1.75rem;
  cursor: pointer;
}
.app-file-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.app-file-thumb:hover,
.app-file-thumb:focus-visible {
  border-color: var(--bs-primary);
}
.app-file-thumb--pending {
  cursor: default;
  opacity: 0.6;
}

/* ==========================================================================
   .app-yearpick — the YEAR-GRID picker (app/static/js/year_picker.js).

   A page of 16 years (4x4) with < > arrows paging between pages, replacing the
   flatpickr-with-its-calendar-hidden approach this block used to style. That
   one hid flatpickr's month box to leave only the year stepper, but the stepper
   lives INSIDE that box (.flatpickr-current-month is position:absolute against
   it) and the < > arrows are absolutely positioned against .flatpickr-months —
   so hiding it removed the stepper and collapsed the arrows onto the row below,
   rendering as a bare "< Done >" strip. flatpickr has no year-grid view at all,
   so the grid is hand-rolled and this is its own styling.

   Used by financial_periods/_modal.html (financial-year start year).
   ========================================================================== */
.app-yearpick {
  position: relative;
}
/* The field keeps its normal form-row width — the wrapper must not shrink it
   (flatpickr's own `static:true` wrapper was display:inline-block, which is
   what knocked this field out of alignment with the rest of the form). */
.app-yearpick > .form-control {
  width: 100%;
  cursor: pointer;
  background-color: var(--bs-body-bg);
}
.app-yearpick__panel {
  position: absolute;
  z-index: 1056;  /* above .modal (1055) — the picker opens inside one */
  top: calc(100% + 0.25rem);
  left: 0;
  width: 17rem;
  max-width: 100%;
  padding: 0.5rem;
  border: 1px solid var(--bs-border-color);
  border-radius: var(--bs-border-radius);
  background-color: var(--bs-body-bg);
  box-shadow: var(--bs-box-shadow);
}
.app-yearpick__panel[hidden] {
  display: none;
}
.app-yearpick__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
  padding: 0.375rem 0.5rem;
  border-radius: var(--bs-border-radius);
  background-color: var(--bs-primary);
  color: #fff;
}
.app-yearpick__range {
  font-weight: 600;
}
.app-yearpick__nav {
  border: 0;
  padding: 0 0.5rem;
  line-height: 1;
  font-size: 1.25rem;
  background: transparent;
  color: inherit;
}
.app-yearpick__nav:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
.app-yearpick__grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.25rem;
}
.app-yearpick__year {
  padding: 0.5rem 0.25rem;
  border: 0;
  border-radius: var(--bs-border-radius);
  background: transparent;
  color: var(--bs-body-color);
}
.app-yearpick__year:hover:not(:disabled) {
  background-color: var(--bs-secondary-bg);
}
/* The chosen year. Uses the theme primary so it matches every other selected
   state in the app rather than introducing a second accent. */
.app-yearpick__year--on,
.app-yearpick__year--on:hover:not(:disabled) {
  background-color: var(--bs-primary);
  color: #fff;
}
/* Out of the min/max bounds: shown (so the grid keeps its shape) but dead. */
.app-yearpick__year--out {
  opacity: 0.35;
  cursor: not-allowed;
}


/* The interior studio's home (BD-027): the path's five tiles read as one road. */
.studio-path__step { min-width: 10rem; transition: box-shadow .15s ease, transform .15s ease; }
.studio-path__step:hover { transform: translateY(-2px); box-shadow: 0 .25rem 1rem rgba(47, 43, 61, .12); }
.studio-path__step--empty h3 { color: var(--bs-secondary-color); }
/* ---------------------------------------------------------------------------
 * Message standard (docs/designs/web/message-standard.html)
 * One visual language for everything the app tells a person:
 *   - #appDialogModal (appConfirm / appAlert / appError, and the form error list
 *     from partials/_error_modal.html) -- round severity icon, plain title, detail;
 *   - .app-toasts / .app-toast -- success/info/warning flashes, never errors.
 * The icon carries the severity, so the header is never a solid colour bar.
 * ------------------------------------------------------------------------- */
.app-msg .modal-content { border: 0; border-radius: 0.875rem; overflow: hidden; }
.app-msg .modal-body { padding: 1.5rem 1.5rem 0.5rem; }
.app-msg .modal-footer { border-top: 0; padding: 1rem 1.5rem 1.25rem; gap: 0.5rem; }
.app-msg .modal-footer > * { margin: 0; }
.app-msg__top { display: flex; gap: 0.875rem; align-items: flex-start; }
.app-msg__icon {
  --app-msg-rgb: var(--bs-primary-rgb);
  flex: none; width: 2.75rem; height: 2.75rem; border-radius: 50%;
  display: grid; place-items: center; font-size: 1.375rem;
  color: rgb(var(--app-msg-rgb));
  background-color: rgba(var(--app-msg-rgb), 0.14);
}
.app-msg__icon--danger  { --app-msg-rgb: var(--bs-danger-rgb); }
.app-msg__icon--warning { --app-msg-rgb: var(--bs-warning-rgb); }
.app-msg__icon--success { --app-msg-rgb: var(--bs-success-rgb); }
.app-msg__icon--info    { --app-msg-rgb: var(--bs-info-rgb); }
.app-msg__text { flex: 1 1 auto; min-width: 0; padding-top: 0.125rem; }
.app-msg__title { font-size: 1.0625rem; font-weight: 600; margin: 0 0 0.25rem; color: var(--bs-heading-color, #384551); }
.app-msg__body { color: var(--bs-secondary-color); white-space: pre-line; overflow-wrap: anywhere; }
.app-msg__close { margin-left: auto; flex: none; }
/* Vuexy floats a modal's .btn-close off the card's corner (translate + white chip),
   which suits a header bar; this one sits in the body row beside the title. */
.app-msg .app-msg__close,
.app-msg .app-msg__close:hover,
.app-msg .app-msg__close:focus {
  transform: none; box-shadow: none; background-color: transparent; padding: 0.25rem;
}
/* Beats Vuexy's `.btn { display: inline-flex !important }` (see main.js showDialog). */
.app-msg .modal-footer .btn.app-msg__hidden { display: none !important; }
/* The list of problems (validation, or several error flashes at once). */
.app-msg__list {
  list-style: none; margin: 1rem 0 0; padding: 0;
  border: 1px solid var(--bs-border-color); border-radius: 0.625rem; overflow: hidden;
}
.app-msg__list li + li { border-top: 1px solid var(--bs-border-color); }
.app-msg__item {
  display: flex; gap: 0.625rem; align-items: flex-start; width: 100%;
  padding: 0.6875rem 0.875rem; background: none; border: 0; text-align: left;
  color: var(--bs-body-color); font: inherit;
}
button.app-msg__item { cursor: pointer; }
button.app-msg__item:hover { background-color: var(--bs-secondary-bg, rgba(0,0,0,.03)); }
button.app-msg__item:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: -2px; }
.app-msg__chip {
  flex: none; font-size: 0.75rem; font-weight: 600; line-height: 1.4; border-radius: 0.375rem;
  padding: 0.125rem 0.5rem; margin-top: 0.0625rem;
  color: var(--bs-danger); background-color: rgba(var(--bs-danger-rgb), 0.12);
}
.app-msg__go { margin-left: auto; flex: none; color: var(--bs-secondary-color); opacity: 0.6; }
.app-msg__bullet { flex: none; color: var(--bs-danger); margin-top: 0.1875rem; font-size: 0.75rem; }

/* Toasts: top-right, stacked, auto-dismiss (main.js appToast). Server-rendered
   flashes land here too, so without JS they still show (just without the timer).
   main.js sets --app-toast-top to just below the sticky navbar so a toast never
   covers its controls; pages without a navbar keep the 1rem corner. */
.app-toasts {
  position: fixed; z-index: 1095; right: 1rem;
  top: var(--app-toast-top, calc(1rem + env(safe-area-inset-top, 0px)));
  width: min(22.5rem, calc(100vw - 2rem));
  display: grid; gap: 0.625rem; pointer-events: none;
}
.app-toast {
  --app-msg-rgb: var(--bs-success-rgb);
  pointer-events: auto; position: relative; overflow: hidden;
  display: flex; gap: 0.75rem; align-items: flex-start;
  padding: 0.75rem 0.875rem 0.875rem;
  background-color: var(--bs-paper-bg, var(--bs-body-bg));
  border: 1px solid var(--bs-border-color); border-radius: 0.625rem;
  box-shadow: 0 0.5rem 1.5rem rgba(34, 41, 47, 0.14);
  color: var(--bs-body-color); overflow-wrap: anywhere;
  animation: app-toast-in 0.2s ease;
}
.app-toast--info    { --app-msg-rgb: var(--bs-info-rgb); }
.app-toast--warning { --app-msg-rgb: var(--bs-warning-rgb); }
.app-toast--danger  { --app-msg-rgb: var(--bs-danger-rgb); }
.app-toast__icon {
  flex: none; width: 1.75rem; height: 1.75rem; border-radius: 50%;
  display: grid; place-items: center; font-size: 1rem;
  color: rgb(var(--app-msg-rgb)); background-color: rgba(var(--app-msg-rgb), 0.14);
}
.app-toast__msg { flex: 1 1 auto; min-width: 0; padding-top: 0.1875rem; }
.app-toast .btn-close { flex: none; font-size: 0.625rem; margin-top: 0.375rem; }
.app-toast__bar {
  position: absolute; left: 0; bottom: 0; height: 3px; width: 100%;
  transform-origin: left; background-color: rgb(var(--app-msg-rgb));
  animation: app-toast-drain linear forwards; animation-duration: var(--app-toast-ms, 4000ms);
}
.app-toast:hover .app-toast__bar { animation-play-state: paused; }
@keyframes app-toast-in { from { opacity: 0; transform: translateX(1.25rem); } }
@keyframes app-toast-drain { to { transform: scaleX(0); } }
@media (prefers-reduced-motion: reduce) {
  .app-toast { animation: none; }
}

/* The in-dialog error box of an AJAX (js-modal-form) dialog: the person is already
   inside a modal, so its error stays there -- in the same soft-danger language. */
.js-modal-error.alert-danger {
  border: 1px solid rgba(var(--bs-danger-rgb), 0.25);
  background-color: rgba(var(--bs-danger-rgb), 0.08);
  color: var(--bs-body-color);
  border-radius: 0.625rem;
}

/* Section error (message standard: "one part of the page failed, the rest works").
   layout/partials/_section_error.html server-side, window.appSectionError in JS.
   Sits IN PLACE of the part that failed -- never a dialog, never a raw alert-danger. */
.app-section-error {
  display: flex; gap: 0.75rem; align-items: flex-start;
  padding: 0.875rem 1rem; border-radius: 0.625rem;
  border: 1px solid rgba(var(--bs-danger-rgb), 0.22);
  background-color: rgba(var(--bs-danger-rgb), 0.06);
  color: var(--bs-body-color); overflow-wrap: anywhere;
}
.app-section-error--warning {
  border-color: rgba(var(--bs-warning-rgb), 0.3);
  background-color: rgba(var(--bs-warning-rgb), 0.08);
}
.app-section-error .app-msg__icon { width: 2.25rem; height: 2.25rem; font-size: 1.125rem; }
.app-section-error__text { flex: 1 1 14rem; min-width: 0; }
.app-section-error__title { font-weight: 600; margin: 0; }
.app-section-error__msg { color: var(--bs-secondary-color); margin: 0.125rem 0 0; }
.app-section-error__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-self: center; }
.app-section-error--compact { padding: 0.5rem 0.75rem; font-size: 0.875rem; }
.app-section-error--compact .app-msg__icon { width: 1.75rem; height: 1.75rem; font-size: 1rem; }

/* Full-page error (errors/_error_page.html): code, icon, title, what to do. */
.app-error-page { max-width: 30rem; margin: 0 auto; text-align: center; display: grid; justify-items: center; gap: 0.75rem; }
.app-error-page .app-msg__icon { width: 4rem; height: 4rem; font-size: 2rem; }
.app-error-page__code { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.08em; color: var(--bs-secondary-color); text-transform: uppercase; }
.app-error-page__title { font-size: 1.5rem; font-weight: 600; margin: 0; text-wrap: balance; }
.app-error-page__msg { color: var(--bs-secondary-color); margin: 0; }
.app-error-page__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; margin-top: 0.5rem; }
.app-error-page__ref { font-size: 0.875rem; color: var(--bs-secondary-color); }

/* Action-failed dialog extras (appError opts.facts / opts.steps / opts.actions). */
.app-msg__facts {
  display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; margin: 1rem 0 0;
  padding: 0.75rem 0.875rem; border-radius: 0.625rem; background-color: var(--bs-secondary-bg, rgba(0,0,0,.03));
  font-size: 0.9375rem;
}
.app-msg__facts dt { color: var(--bs-secondary-color); font-weight: 400; }
.app-msg__facts dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; }
.app-msg__steps-title { font-size: 0.75rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--bs-secondary-color); font-weight: 600; margin: 1rem 0 0.375rem; }
.app-msg__steps { margin: 0; padding-left: 1.125rem; display: grid; gap: 0.25rem; }
.app-msg .modal-footer .app-msg__extra { margin-right: auto; }

/* ---- Voucher line grids: every control on one line ----------------------------
   A line's cells were vertically CENTRED, and the item cell is the tallest (the
   picker with its "+", then the Exempt tick under it) -- so the picker sat higher
   than Qty / Unit price / the delete icon beside it. Align the cells to the TOP so
   the first row of controls shares one line; a cell holding only text (the line
   number, "—", computed amounts) or only a checkbox is padded down to the middle
   of that line (control ~30px high: 0.594rem cell padding + ~0.3rem).
   Shared by every [data-lines] grid (vouchers/_lines_js.html): invoices, delivery
   challans, expenses, receipts, payments, journals, contras, room scope templates. */
[data-lines]>[data-line-row]>td {
  vertical-align: top;
}

[data-lines]>[data-line-row]>td:not(:has(input:not([type="hidden"]):not([type="checkbox"]), select, textarea, button)) {
  padding-top: 0.9rem;
}

/* ---- Busy submit buttons keep their colour ----
   appSetSubmitBusy (main.js) disables a submit button for the duration of its
   request and prepends a spinner. Bootstrap paints every :disabled button at
   opacity .65, so a Save in flight read as a washed-out, unavailable button.
   The spinner already says "working"; keep the button at full strength. */
.btn.is-busy:disabled,
.btn.is-busy.disabled {
  opacity: 1;
}

/* ---- Page locked while a full-page POST is in flight ----
   main.js adds .app-page-locked to <html> on a POST form submit and blocks
   further clicks in the page content (and every submit) until the page
   navigates. The busy button keeps its colour and spinner — it says which
   action is running; every other action on the record greys out, so a control
   scrolled into view mid-request reads as unavailable instead of live. The
   sidebar and navbar stay usable (main.js does not block them). */
html.app-page-locked .content-wrapper,
html.app-page-locked .content-wrapper * {
  cursor: progress !important;
}
html.app-page-locked .content-wrapper :is(a[href], .btn, button, input[type="submit"],
  input[type="button"], [role="button"], [role="menuitem"], [data-bs-toggle]):not(.is-busy),
html.app-page-locked .modal :is(.btn, button, a[href]):not(.is-busy) {
  opacity: .45;
  cursor: not-allowed !important;
  transition: opacity .15s ease-in-out;
}

/* A page framed into a dialog (layout/frame.html, e.g. Manage Roles) takes the
   dialog's surface colour, not the app's grey page background, so the iframe
   body matches the modal header/footer around it like every other modal. */
body.app-frame {
  background-color: var(--bs-paper-bg, var(--bs-body-bg));
}

/* The framed page has no sidebar layout, so it lacks the theme's full-height
   chain (.layout-wrapper -> .layout-page -> .content-wrapper) that lets a list
   card stretch (see .card.list-table-card). Rebuild it here: the wrapper fills the
   iframe (100vh inside an iframe is the frame's height) and its flex-grow-1
   container hands the slack to the list card, so a short framed list keeps the
   same card height as every other list page. */
body.app-frame .content-wrapper {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

/* Money field with its currency symbol inside the box (static/js/money_input.js).
   The symbol is an overlay over the field's left padding; the input keeps its plain
   numeric value. Clicks pass through to the input. */
.money-input {
  position: relative;
}
.money-input-symbol {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  pointer-events: none;
  color: var(--bs-body-color);
  line-height: 1;
}
.money-input:has(input:disabled) .money-input-symbol,
.money-input:has(input[readonly]) .money-input-symbol {
  color: var(--bs-secondary-color);
}
/* A server-rendered message that sits after the box rather than beside the input. */
.money-input:has(.is-invalid) ~ .invalid-feedback {
  display: block;
}

/* ---- Feature prerequisites (app.core.feature_prerequisites) ------------------
   A sidebar entry whose feature is held but whose prerequisite is not (Orders
   without Accounting) is shown LOCKED rather than hidden: dimmed, with a lock at the
   trailing edge, and it opens the page that explains what is missing. Hover still
   reads as a link -- it is one. */
.app-menu-locked > .menu-link {
  opacity: 0.6;
}
.app-menu-locked > .menu-link:hover,
.app-menu-locked > .menu-link:focus-visible {
  opacity: 0.85;
}
.app-menu-locked__icon {
  font-size: 0.95rem;
}

/* The platform's tenant Entitlements tab (option B, owner 2026-10-07): a flat list
   grouped by product area on the left, the selected capability's details panel on
   the right; stacked on narrow screens. Nothing is nested -- what a capability needs
   is shown as data on its row and in its panel. */
.app-ent-split {
  display: grid;
  grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
  border-top: 1px solid var(--bs-border-color);
}
@media (max-width: 991.98px) {
  .app-ent-split { grid-template-columns: minmax(0, 1fr); }
}
.app-ent-list {
  padding-block: 0.25rem;
  border-right: 1px solid var(--bs-border-color);
  min-width: 0;
}
@media (max-width: 991.98px) {
  .app-ent-list { border-right: 0; border-bottom: 1px solid var(--bs-border-color); }
}
.app-ent-area__label {
  padding: 0.75rem 1.25rem 0.25rem;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--bs-secondary-color);
}
.app-ent-item {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto auto;
  align-items: center;
  gap: 0.75rem;
  width: 100%;
  padding: 0.5rem 1.25rem;
  border: 0;
  border-left: 3px solid transparent;
  background: transparent;
  color: var(--bs-body-color);
  text-align: left;
}
.app-ent-item:hover { background: var(--bs-tertiary-bg, rgba(67, 89, 113, 0.05)); }
.app-ent-item:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: -2px; }
.app-ent-item[aria-current="true"] {
  border-left-color: var(--bs-primary);
  background: rgba(var(--bs-primary-rgb), 0.08);
}
.app-ent-item__name { min-width: 0; overflow-wrap: anywhere; }
/* Phone: name, source and state on one line; the "needs" line under them. */
@media (max-width: 575.98px) {
  .app-ent-item {
    grid-template-columns: minmax(0, 1fr) auto auto;
    grid-template-areas: "name src state" "needs needs needs";
    row-gap: 0;
  }
  .app-ent-item__name { grid-area: name; }
  .app-ent-item__src { grid-area: src; }
  .app-ent-item__state { grid-area: state; }
  .app-ent-item__needs { grid-area: needs; }
}
.app-ent-dot {
  display: inline-block;
  width: 0.45rem;
  height: 0.45rem;
  margin: 0 0.25rem 0.05rem 0.15rem;
  border-radius: 50%;
  background: var(--bs-secondary-color);
}
.app-ent-dot.is-on { background: var(--bs-success); }
.app-ent-dot.is-blocking { background: var(--bs-danger); }
.app-ent-panels { min-width: 0; }
.app-ent-panel {
  position: sticky;
  top: 5rem;
  display: grid;
  gap: 1.25rem;
  padding: 1.25rem;
}
.app-ent-panel__label {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--bs-secondary-color);
}
.app-ent-link {
  padding: 0.375rem 0.625rem;
  border: 1px solid var(--bs-border-color);
  border-radius: var(--bs-border-radius);
}

/* The panel's Granted / Denied switch: the chosen side is filled, so which one is in
   force reads at a glance. The theme paints a checked outline button with a pale tint
   at higher priority (measured: rgb(242,247,254) on the checked label), hence the
   !important -- scoped to this one control. */
.app-ent-panel .btn-check:checked + .btn-outline-primary {
  background-color: var(--bs-primary) !important;
  border-color: var(--bs-primary) !important;
  color: var(--bs-white, #fff) !important;
}

/* Voucher Files tab (accounting/_attachments.html). It is a list table inside a
   DETAIL page, so it takes its height floor and row density from the
   same --app-list-* / --app-table-* tokens as `.card.list-table-card` -- but not that
   marker itself, whose `.container-p-y:has(...)` rules restyle the page header and
   would shrink the detail page's title. The search box keeps a list-toolbar width;
   a long file name truncates with an ellipsis (full name in its title). */
/* One floor with or without files (owner, 2026-10-09): the tab keeps the same
   height whether it holds the empty state or a few rows, instead of collapsing to
   its rows. The slack sits below the rows as card background (the table does not
   stretch), the same as a list card. */
.card.app-attachments-card {
  display: flex;
  flex-direction: column;
  min-height: var(--app-list-card-empty-min-h);
}
.card.app-attachments-card>.table-responsive {
  flex: 1 1 auto;
}
.card.app-attachments-card .table thead tr th {
  padding-block: var(--app-table-header-py);
}
.card.app-attachments-card .table> :not(caption)>*>* {
  padding-top: var(--app-table-cell-py);
  padding-bottom: var(--app-table-cell-py);
}
.app-attachments-search { width: 16rem; max-width: 100%; }
.app-attachments-name { max-width: 32rem; }

/* Footer row (Cancel / submit) of a form fragment loaded into the shared
   #form-modal (layout/partials/form_modal.html). That dialog has no
   .modal-footer of its own — the whole fragment lives in the scrolling
   .modal-body — so pin the row to the bottom of the scroll area, the way the
   static modals' .modal-footer stays visible. On the full page it is a plain row.
   It bleeds over the body's padding (left, right and below) so no card edge
   or shadow shows around it. */
#form-modal .modal-body .form-modal-footer {
  --form-modal-pad: var(--bs-modal-padding, 1.5rem);
  position: sticky;
  bottom: calc(-1 * var(--form-modal-pad));
  z-index: 3;
  margin-left: calc(-1 * var(--form-modal-pad));
  margin-right: calc(-1 * var(--form-modal-pad));
  margin-bottom: calc(-1 * var(--form-modal-pad));
  padding: 1rem var(--form-modal-pad);
  background: var(--bs-modal-bg, var(--bs-body-bg));
  border-top: 1px solid var(--bs-border-color);
}

/* ---------------------------------------------------------------------------
   Rate card page, the UX pass of 12 October.
   --------------------------------------------------------------------------- */

/* The grid is as wide as its columns, not the page: a one-rate grid stretched a
   single column across 600px with the symbol at one end and the figure at the
   other. Its own scroll box carries both sticky axes -- the band rows at the top,
   the item column at the left -- so a wide, long grid keeps its headings. */
.rcg-scroll {
  max-height: min(72vh, 52rem);
  overflow: auto;
}

.rcg-table.table {
  width: auto;
  --rcg-band-h: 2.5rem;
}

.rcg-table > thead > tr > th {
  position: sticky;
  top: calc(var(--rcg-band, 0) * var(--rcg-band-h));
  z-index: 2;
  height: var(--rcg-band-h);
  background-color: var(--bs-card-bg, #fff);
  box-shadow: inset 0 -1px 0 var(--bs-border-color, #dbdade);
}

.rcg-table > thead > tr > th.rcg-item-col {
  z-index: 4;
}

.rcg-input {
  font-variant-numeric: tabular-nums;
  min-width: 8rem;
}

/* The grid's own toolbar: the vendor and coverage on the left, its acts on the right. */
.rc-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem 1rem;
}

.rc-toolbar__vendor {
  /* Its natural width: shrinking it wrapped the label, the picker and the coverage
     onto three lines. When the row is full, the actions wrap under it instead. */
  flex: 0 0 auto;
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 0.5rem 0.75rem;
}

.rc-toolbar__vendor select {
  flex: 0 0 auto;
  width: auto;
  min-width: 16rem;
}

.rc-toolbar__vendor .select2-container {
  min-width: 16rem;
}

.rcg-band__label {
  position: sticky;
  left: 13rem;
  padding: 0 0.5rem;
}

.rc-toolbar__coverage {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding-left: 0.75rem;
  border-left: 1px solid var(--bs-border-color, #dbdade);
}

.rc-toolbar__coverage .progress {
  width: 6rem;
  height: 0.375rem;
}

.rc-toolbar__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 0.5rem;
  margin-left: auto;
}

.rc-toolbar__hint {
  padding: 0 1.5rem 0.75rem;
  color: var(--bs-secondary-color, #6c757d);
  font-size: 0.8125rem;
}

/* The right rail: one card, its parts as quiet sections -- not three cards
   competing in uppercase. */
.rc-rail__section {
  padding: 1.25rem 1.5rem;
}

.rc-rail__section + .rc-rail__section {
  border-top: 1px solid var(--bs-border-color, #dbdade);
}

.rc-rail__title {
  margin: 0 0 0.5rem;
  font-size: 0.9375rem;
  font-weight: 600;
  color: var(--bs-heading-color, inherit);
}

/* On the Items tab the rail sits under the table, its sections side by side. */
.rc-rail--wide {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
}

.rc-rail--wide .rc-rail__section + .rc-rail__section {
  border-top: 0;
  border-left: 1px solid var(--bs-border-color, #dbdade);
}

/* ---------------------------------------------------------------------------
   The quotation editor fallback, in direction A (the owner's pick, 12 October):
   the levers behind one sentence, the ledger as one sheet with ruled section
   headings, and the totals closing it.
   --------------------------------------------------------------------------- */
.qe-money > summary {
  cursor: pointer;
  list-style: none;
}

.qe-money > summary::-webkit-details-marker {
  display: none;
}

.qe-money[open] .qe-money__toggle {
  display: none;
}

.qe-sheet {
  padding: 0.5rem 1.5rem 1.5rem;
}

.qe-sheet #ledger > .card {
  border: 0;
  box-shadow: none;
  margin-bottom: 1.5rem !important;
}

.qe-sheet #ledger > .card > .card-header {
  padding-left: 0;
  padding-right: 0;
  border-bottom: 2px solid var(--bs-heading-color, #444050);
  background: transparent;
}

.qe-sheet #ledger > .card > .card-header h6 {
  font-size: 1.125rem;
}

.qe-sheet__totals {
  display: flex;
  justify-content: flex-end;
}

.qe-sheet__totals > div {
  width: min(28rem, 100%);
  padding-top: 0.75rem;
}

.qe-sheet__totals hr {
  border-top: 2px solid var(--bs-heading-color, #444050);
  opacity: 1;
}

/* TaxCoop credit/debit grid (vouchers/_dr_cr.html): the side a line is NOT on is
   locked read-only by _dr_cr_js.html -- grey it, as TaxCoop disables it. */
input[data-dr-cr-part][readonly] {
  background-color: var(--bs-secondary-bg, #f1f1f2);
  cursor: not-allowed;
}

/* Calendar (Dental): the part of a day outside the filtered doctor's working hours. */
.fc .fc-bg-event.doctor-off-hours {
  background: repeating-linear-gradient(135deg, rgba(var(--bs-secondary-rgb, 130, 134, 139), .18) 0 6px,
                                        rgba(var(--bs-secondary-rgb, 130, 134, 139), .08) 6px 12px);
  opacity: 1;
}

/* ==========================================================================
   Dental odontogram (patient-v2 spec 5.4): the shared SVG tooth chart and its
   option toolbar. Colours come from the theme's own tokens, so dark mode and
   the tenant accent follow without a literal here.
   ========================================================================== */
/* The palette is shared by the chart and its legends (the symbol key draws with it). */
.odo-wrap, .odo-legend {
  --odo-bad: var(--bs-danger);
  --odo-plan: var(--bs-warning);
  --odo-prog: var(--bs-primary);
  --odo-done: var(--bs-success);
  --odo-line: var(--bs-border-color);
  --odo-tooth: var(--bs-body-bg);
  --odo-root: rgba(var(--bs-body-color-rgb), 0.08);
}
.odo-wrap {
  overflow-x: auto;
  border: 1px solid var(--bs-border-color);
  border-radius: var(--bs-border-radius);
}
.odo { display: block; width: 100%; min-width: 600px; height: auto; user-select: none; }
.odo .jaw { font-size: 9px; font-weight: 600; letter-spacing: .12em; fill: var(--bs-secondary-color); text-anchor: middle; }
.odo .midline { stroke: var(--bs-secondary-color); stroke-dasharray: 3 3; stroke-width: 1; }
.odo .hit { fill: transparent; stroke: none; }
.odo .tooth { outline: none; text-decoration: none; }
.odo.odo-live .tooth { cursor: pointer; }
.odo.odo-live .tooth:hover .hit { fill: rgba(var(--bs-body-color-rgb), 0.06); }
.odo .tooth:focus-visible .hit { stroke: var(--bs-primary); stroke-width: 1.5; stroke-dasharray: 3 2; }
.odo .tooth.sel .hit, .odo .tooth.sel:hover .hit { fill: rgba(var(--bs-primary-rgb), 0.14); stroke: var(--bs-primary); stroke-width: 1.5; }
.odo .crown { fill: var(--odo-tooth); stroke: var(--odo-line); stroke-width: 1; }
.odo .root { fill: var(--odo-root); stroke: var(--odo-line); stroke-width: 1; }
.odo .f-bad .crown { fill: rgba(var(--bs-danger-rgb), 0.16); stroke: var(--odo-bad); }
.odo .f-plan .crown { fill: rgba(var(--bs-warning-rgb), 0.18); stroke: var(--odo-plan); }
.odo .f-prog .crown { fill: rgba(var(--bs-primary-rgb), 0.16); stroke: var(--odo-prog); }
.odo .f-done .crown { fill: rgba(var(--bs-success-rgb), 0.16); stroke: var(--odo-done); }
.odo .missing .crown, .odo .missing .root { opacity: .3; }
.odo .xm { stroke: var(--odo-bad); stroke-width: 2; stroke-linecap: round; }
.odo .caries { fill: var(--odo-bad); }
.odo .tnum { font-size: 10px; font-weight: 600; fill: var(--bs-secondary-color); text-anchor: middle; }
.odo .tooth.sel .tnum { fill: var(--bs-primary); }
.odo .tmark { font-size: 7.5px; font-weight: 600; text-anchor: middle; fill: var(--odo-bad); }
.odo .tmark.p { fill: var(--odo-plan); }
.odo .tmark.g { fill: var(--odo-prog); }
.odo .tmark.d { fill: var(--odo-done); }
.odo-legend { display: flex; flex-wrap: wrap; gap: .75rem; font-size: .75rem; color: var(--bs-secondary-color); margin-top: .5rem; }
.odo-legend i { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px; vertical-align: -2px; border: 1px solid; }
.odo-legend .lg-bad { background: rgba(var(--bs-danger-rgb), 0.16); border-color: var(--bs-danger); }
.odo-legend .lg-plan { background: rgba(var(--bs-warning-rgb), 0.18); border-color: var(--bs-warning); }
.odo-legend .lg-prog { background: rgba(var(--bs-primary-rgb), 0.16); border-color: var(--bs-primary); }
.odo-legend .lg-done { background: rgba(var(--bs-success-rgb), 0.16); border-color: var(--bs-success); }
/* The symbols each finding / treatment draws on its tooth (OdontogramTooth.glyphs). A
   recorded finding is ink (--odo-ink); a treatment takes its plan tone. */
.odo-wrap, .odo-legend { --odo-ink: rgba(var(--bs-body-color-rgb), 0.78); }
.odo .gl-x { --gl: var(--odo-ink); }
.odo .gl-p { --gl: var(--odo-plan); }
.odo .gl-g { --gl: var(--odo-prog); }
.odo .gl-d { --gl: var(--odo-done); }
.odo .gl-rct line { stroke: var(--gl); stroke-width: 2.2; stroke-linecap: round; }
.odo .gl-fill { fill: var(--gl); stroke: none; }
.odo .gl-crown { fill: none; stroke: var(--gl); stroke-width: 2.4; stroke-linejoin: round; }
.odo .gl-bridge { fill: rgba(var(--bs-body-color-rgb), 0.06); stroke: var(--gl); stroke-width: 2.4; stroke-linejoin: round; }
.odo .gl-implant rect { fill: rgba(var(--bs-body-color-rgb), 0.06); stroke: var(--gl); stroke-width: 1.4; }
.odo .gl-implant line { stroke: var(--gl); stroke-width: 1.2; }
.odo .gl-ortho { fill: var(--odo-tooth); stroke: var(--gl); stroke-width: 1.6; }
.odo .gl-impacted .crown, .odo .gl-impacted .root { stroke: var(--gl); stroke-dasharray: 2.5 1.5; }
.odo .gl-span { stroke: var(--gl); stroke-linecap: round; }
.odo .gl-bridge-bar { stroke-width: 4; opacity: .85; }
.odo .gl-wire { stroke-width: 1.4; }
/* A missing tooth a bridge (pontic) or implant replaces shows the replacement, rootless. */
.odo .replaced .root, .odo .implant .root { display: none; }
.odo-symbols { margin-top: .25rem; }
.odo.odo-key { display: inline-block; width: 18px; min-width: 0; height: 18px; margin-right: 4px; vertical-align: -4px; }
.odo-toolbar .btn.active, .odo-toolbar .btn.on { background: var(--bs-primary); border-color: var(--bs-primary); color: var(--bs-white); }
.odo-toolbar .dropdown-item.on { font-weight: 600; }

/* Case-sheet editor (patient-v2 spec 5.4): the save indicator and the whole-mouth chips'
   active state. Theme tokens only. */
.cs-save { font-size: .8125rem; font-weight: 500; color: var(--bs-success); white-space: nowrap; }
.cs-save:disabled { opacity: 1; }
.cs-save.cs-save--saving { color: var(--bs-secondary-color); }
.cs-save.cs-save--fail { color: var(--bs-danger); text-decoration: underline; cursor: pointer; }
[data-odo-whole-mouth] .btn.on { background: var(--bs-danger); border-color: var(--bs-danger); color: var(--bs-white); }

/* ==========================================================================
   Patient pages V3 / V4 (patient-v3-v4 spec 2, 10.6): shared building blocks of
   templates/patients_v34/_ui.html. No pills -- status is a dot plus a word and
   alerts are one line. Colours are the theme's tokens.
   ========================================================================== */
.pv-dot { display: inline-flex; align-items: center; gap: .4rem; font-size: .8125rem; white-space: nowrap; }
.pv-dot::before { content: ""; width: .5rem; height: .5rem; border-radius: 50%; background: var(--pv-dot, var(--bs-secondary-color)); flex: none; }
.pv-dot-ok { --pv-dot: var(--bs-success); }
.pv-dot-warn { --pv-dot: var(--bs-warning); }
.pv-dot-accent { --pv-dot: var(--bs-primary); }
.pv-dot-bad { --pv-dot: var(--bs-danger); }
.pv-dot-muted { --pv-dot: var(--bs-secondary-color); }
.pv-alerts { font-size: .78rem; color: var(--bs-heading-color); border-left: 3px solid var(--bs-warning); background: rgba(var(--bs-warning-rgb), .12); border-radius: var(--bs-border-radius-sm); padding: .375rem .625rem; }
.pv-kv { display: grid; grid-template-columns: 7.5rem minmax(0, 1fr); gap: .5rem .75rem; align-items: center; font-size: .8rem; }
.pv-kv dt { font-weight: 400; color: var(--bs-secondary-color); }
.pv-kv dd { margin: 0; color: var(--bs-heading-color); }
.pv-split { display: grid; grid-template-columns: 1fr; gap: .875rem; }
.pv-split > div { display: grid; gap: .875rem; align-content: start; min-width: 0; }
@media (min-width: 768px) { .pv-split { grid-template-columns: minmax(13.75rem, 5fr) 7fr; } }
.pv-split-detail:focus { outline: none; }
/* V3 / V4 scale the whole mouth to its card (V4: the detail pane) instead of the 600px
   minimum that makes V2's chart scroll sideways, so 18-28 / 48-38 are all visible -- on a
   phone too. */
.pv-split .odo, .pv-chart .odo { min-width: 0; }
[data-ld-item] { cursor: pointer; }
[data-ld-item].active { background: rgba(var(--bs-primary-rgb), .1); }

/* The case sheet's record on the Document tab (dental/record/_full_record_body.html),
   shared by patients_v2 / v3 / v4 sheet_document.html. scroll-margin-top keeps a section
   clear of the header when V4's section list jumps to it (no effect elsewhere). */
.sheet-document section { margin-bottom: 1.25rem; scroll-margin-top: 5rem; }
.sheet-document h6 { text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid var(--bs-border-color); padding-bottom: 2px; }
.sheet-document .chip { display: inline-block; border: 1px solid var(--bs-border-color); border-radius: 4px; padding: 0 5px; font-size: .75rem; margin-right: 3px; }
/* V3 (spec 2 goal 2, no chips): a record chip reads as plain comma-separated text. */
.pv-doc .chip { display: inline; border: 0; border-radius: 0; padding: 0; font-size: inherit; margin: 0; }
.pv-doc .chip + .chip::before { content: ", "; }

/* Menu-less gate screens (layout/partials/_gate_topbar.html): the subscription gate and
   the first-run workspace set-up. */
.subgate-topbar { border-bottom: 1px solid var(--bs-border-color); }
.subgate-main { padding-top: 2.5rem; padding-bottom: 2.5rem; }

/* A pager that is followed by another block on the page (a detail page stacks several
   paged cards) keeps the page's normal 1.5rem gap before it, instead of touching the
   next card (ADO #2424). A pager that ends the page or its container is unchanged. */
.app-pager:not(:last-child) {
  margin-bottom: 1.5rem;
}
