/* Orbitron (variable) — self-hosted, per the Vorperian style guide. */
@font-face {
  font-family: 'Orbitron';
  src: url('../fonts/orbitron.woff2') format('woff2');
  font-weight: 300 900; font-display: swap;
}
:root {
  --vc-dark:#1b1b1f;
  /* Brand palette — Vorperian gold on black by default; base.html overrides these
     per-team from the owner's chosen primary/secondary colors. */
  --brand-primary:#ff9900;
  --brand-secondary:#0b0b0d;
}
body { font-family: "Liberation Sans", Arial, Helvetica, sans-serif; }

/* Cyberpunk headings + logo/brand: Orbitron, uppercase (Vorperian style guide). */
h1,h2,h3,h4,h5,h6,.display-1,.display-2,.display-3,.display-4,.display-5,.display-6,
.navbar-brand,.navbar-brand .fw-semibold,.bb-lockup span {
  font-family:'Orbitron', system-ui, -apple-system, sans-serif;
  text-transform:uppercase; letter-spacing:.03em; font-weight:800;
}
.navbar-brand,.bb-lockup span,.display-1,.display-2,.display-3,.display-4,.display-5,.display-6 { font-weight:900; }

/* Vorperian-by-default theming, all keyed off the brand vars so a team's own
   colors flow through when set. */
.navbar[data-bs-theme="dark"]{ background: var(--brand-secondary) !important; }
/* The navbar wears the uniform's collar: three gold rules with dark gaps along
   its bottom edge — the ambient "ours" band (DESIGN.md, two-tier band system).
   Keyed off the brand vars, so a tenant's own colours flow through it. */
.navbar[data-bs-theme="dark"]{
  border-bottom: 7px solid transparent;
  border-image: repeating-linear-gradient(
    to bottom,
    var(--brand-primary) 0 1px,
    var(--brand-secondary) 1px 3px,
    var(--brand-primary) 3px 4px,
    var(--brand-secondary) 4px 6px,
    var(--brand-primary) 6px 7px) 7;
}
/* Buttons stay in the body font (never the Orbitron/uppercase heading treatment,
   even when nested in a heading context). An accordion header IS a control, and
   Bootstrap builds it as a <button> inside an <h2> — so it inherited the display
   face and rendered a whole data line ("all permissions · 0 member(s)") in
   uppercase Orbitron. The Short Shout Rule: two or three words, never a clause. */
.btn, .accordion-button { font-family: "Liberation Sans", Arial, Helvetica, sans-serif; text-transform: none; }
.accordion-button { letter-spacing: 0; font-weight: 600; }
/* Slim the top bar on desktop too (the mobile media query trims it further). */
.navbar { padding-top:.35rem; padding-bottom:.35rem; }
.navbar-brand { line-height:1.05; }
/* rem, not px: in the compensated state (root font scaled, layout wider than the
   hardware) px assets draw at HALF their intended size while everything rem-based
   comes out right — the owner's screenshot showed a 19px-apparent logo beside
   correctly-sized text. 2.375rem is the same 38px on an uncompensated device. */
.navbar-brand img { height:2.375rem; }
.btn-dark{ background:var(--brand-primary); border-color:var(--brand-primary); color:#0b0b0d; }
.btn-dark:hover,.btn-dark:focus{ background:var(--brand-primary); border-color:var(--brand-primary); color:#0b0b0d; filter:brightness(1.08); }
a:not(.btn):not(.bb-btn):not(.nav-link):not(.navbar-brand):not(.dropdown-item){ color:var(--brand-primary); }
a:not(.btn):not(.bb-btn):not(.nav-link):not(.navbar-brand):hover{ filter:brightness(1.12); }
/* LIGHT MODE NEEDS A SHADE OF THE GOLD, NOT THE GOLD. #ff9900 on white measures
   2.1:1 against a 4.5:1 floor — measured across four pages, 10 of 15 links failed,
   worst 2.03:1. This is not a second hue: DESIGN.md's one-hue rule says in as many
   words that "tints and shades of the gold exist only where contrast demands them",
   and it is derived from --brand-primary, so a tenant's own accent darkens with it
   rather than being replaced. 60% of the hue over black gives 5.4:1 on white, which
   keeps headroom for a tenant whose brand is lighter than ours.
   Dark mode keeps the pure gold (≈8:1 on the near-black page), and any container
   that is dark in BOTH themes — the navbar, a section bar, the hazard band — is
   re-asserted below rather than inheriting the shade off the light root. */
[data-bs-theme="light"] a:not(.btn):not(.bb-btn):not(.nav-link):not(.navbar-brand):not(.dropdown-item) {
  color: color-mix(in srgb, var(--brand-primary) 60%, #000);
}
/* The :not() chain is repeated on purpose. The base rule above carries five of them,
   so a shorter override — `.ledger__bar a` — LOSES on specificity and silently does
   nothing; measured, it left "Mark all read" at 3.6:1 on the dark bar in light mode
   while reading like a fix. */
[data-bs-theme="light"] [data-bs-theme="dark"] a:not(.btn):not(.bb-btn):not(.nav-link):not(.navbar-brand):not(.dropdown-item),
[data-bs-theme="light"] .ledger__bar a:not(.btn):not(.bb-btn):not(.nav-link):not(.navbar-brand):not(.dropdown-item),
[data-bs-theme="light"] .hazard--band a:not(.btn):not(.bb-btn):not(.nav-link):not(.navbar-brand):not(.dropdown-item) {
  color: var(--brand-primary);
}
/* The dock panel is a LIFTED surface in dark mode, so the page's 8:1 gold drops to
   4.3:1 on it — under the floor by a hair, which is still under it. Lift the hue
   the same way the surface was lifted. */
[data-bs-theme="dark"] .dockstack a:not(.btn):not(.bb-btn):not(.nav-link):not(.navbar-brand):not(.dropdown-item) {
  color: color-mix(in srgb, var(--brand-primary) 78%, #fff);
}
/* `.btn-link` is a BUTTON that reads as a link, so the rule above skips it by
   design — and it was left on Bootstrap's link-blue, the one non-gold hue on
   several pages. It is a link in every way that matters to the eye. */
.btn-link {
  --bs-btn-color: var(--brand-primary);
  --bs-btn-hover-color: var(--brand-primary);
  --bs-btn-active-color: var(--brand-primary);
}
.form-control:focus,.form-select:focus,.form-check-input:focus{
  border-color:var(--brand-primary);
  box-shadow:0 0 0 .2rem color-mix(in srgb, var(--brand-primary) 22%, transparent);
}
.form-check-input:checked{ background-color:var(--brand-primary); border-color:var(--brand-primary); }
.link-primary,.text-primary{ color:var(--brand-primary) !important; }

/* ===== Mobile responsiveness =====
   Safety net so no page scrolls sideways: clip stray horizontal overflow (clip,
   unlike hidden, doesn't create a scroll container so position:sticky still
   works), keep media within their box, and let wide content scroll inside its
   own table-responsive box rather than pushing the page wide. */
/* --ui-scale compensates for a browser laying the page out at the wrong size for the hardware —
   see static/js/uiscale.js. 1 (the default) is a no-op, so nothing here changes for a device that
   is already correct. Every rem in the app, and Bootstrap's, follows this. */
html {
  -webkit-text-size-adjust: 100%;
  font-size: calc(16px * var(--ui-scale, 1));
}
body { overflow-x: clip; }
img, canvas, iframe, svg, video { max-width: 100%; }
.table-responsive { -webkit-overflow-scrolling: touch; }

/* Long unbreakable values (emails, links, tokens) wrap instead of forcing width. */
a[href^="mailto:"], a[href^="tel:"], .text-wrap-any, code { overflow-wrap: anywhere; }

/* Sideways-scroll hint. scrollhint.js wraps any box that really is scrollable
   (a wide .table-responsive, the Settings tab strip) in .xscroll; we fade its
   right edge so the hidden columns read as "there's more", not as missing. The
   fade sits on the WRAPPER because it must not scroll away with the content, and
   because opaque table cells paint over anything drawn on the box itself. It's a
   veil rather than a fade-to-background so it works whatever colour the card is,
   and it clears once you've scrolled to the end. The veil INVERTS per theme — the
   app is dark by default, and a dark shadow on a dark card is invisible. */
.xscroll { position: relative; }
.xscroll::after {
  content: ""; position: absolute; top: 0; right: 0; bottom: 0; width: 2.25rem;
  background: linear-gradient(to left, rgba(0, 0, 0, .38), rgba(0, 0, 0, 0));
  pointer-events: none; transition: opacity .15s linear;
}
[data-bs-theme="dark"] .xscroll::after {
  background: linear-gradient(to left, rgba(255, 255, 255, .16), rgba(255, 255, 255, 0));
}
.xscroll.xscroll-end::after { opacity: 0; }
/* On a phone the veil alone is easy to miss and the swipe is the only way to
   reach those columns, so add a brand edge rule. On desktop it would just be
   noise — the window can be widened instead — so the veil carries it there. */
@media (max-width: 767.98px) {
  .xscroll::after { border-right: 2px solid var(--brand-primary); }
}

/* E-sign document preview: stacked page images in a native-scroll box. iOS Safari
   won't scroll a multi-page PDF inside an iframe (it sticks on page 1), but a
   plain scroll container of img pages scrolls fine on every browser.
   (Tag names written bare on purpose: in angle brackets the detector reads this
   comment as markup and reports a broken image that does not exist.) */
.doc-preview {
  max-height: 70vh;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  background: #525659;
  padding: 8px;
}
/* A viewer whose page COUNT arrives over the network (data-count-url) can't reserve its space
   from its content: at first paint it holds one line of "Loading preview…", and it only learns
   how tall it will be a round trip later — so everything below it jumped down when the pages
   landed. max-height alone can't fix that (it constrains growth, it doesn't reserve). The pane
   therefore takes a DEFINITE height, the way the template-manager pane already did — which is
   why that page never jumped and this one did. Safari and the installed app show it worst
   because they paint the short box before the scripts at the end of <body> have even run. */
.doc-preview[data-count-url] { height: 70vh; }
/* EVERY page image, not only the ones inside .doc-preview. The public invoice, the e-sign pages
   and the contract field-marker all render .doc-page outside that wrapper, so they reserved no
   space at all and the page jumped as each one arrived — which is exactly what the owner saw. */
.doc-page {
  display: block;
  width: 100%;
  max-width: 760px;
  margin: 0 auto 8px;
  /* Reserve the letter-page footprint BEFORE the image loads, so the page never jumps
     when it pops in. Until it loads, a grey rectangle of exactly this size shows
     "Loading document"; the page image then draws over it. */
  aspect-ratio: 8.5 / 11;
  object-fit: contain;
  background: #d9d9d9 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='30'%3E%3Ctext x='130' y='20' font-family='sans-serif' font-size='15' fill='%23808080' text-anchor='middle'%3ELoading document%3C/text%3E%3C/svg%3E") center no-repeat;
}

/* A page image that isn't inside the dark viewer still needs its footprint reserved; it just
   doesn't want the viewer's drop shadow fighting the page background. */
.doc-preview .doc-page { box-shadow: 0 0 5px rgba(0, 0, 0, .4); }

/* Page/card header action rows: wrap their buttons on narrow screens instead of
   overflowing. Any .d-flex holding .btn children is treated as a toolbar. */
.d-flex.gap-2 > .btn, .d-flex.gap-2 > form { flex: 0 0 auto; }
/* ...except a form that is itself a CLUSTER of controls (the overdue-reminder
   form: button + ordinal + "BCC me" + "Test to me"). Pinned at flex:0 0 auto it
   can't shrink below its max-content width, so on a phone it forced the document
   page to 584px — and since body is overflow-x:clip, "Test to me" was clipped
   away with no way to scroll to it. Marking such a form .flex-wrap opts it into
   shrinking so it wraps its own controls onto extra lines instead. */
.d-flex.gap-2 > form.flex-wrap { flex: 0 1 auto; min-width: 0; }
/* ...and the same trap one class along, for a form marked .flex-grow-1 (the live
   search on Clients and Vendors). Bootstrap's utility sets only flex-GROW, so with
   the rule above it computed to `1 0 auto` — grow freely, never shrink — and at a
   scaled-up text size the search box ran 33px past the screen. A control that is
   allowed to grow must be allowed to shrink. */
.d-flex.gap-2 > form.flex-grow-1 { flex: 1 1 auto; min-width: 0; }

@media (max-width: 575.98px) {
  main.container-fluid { padding-left: .75rem !important; padding-right: .75rem !important; }
  .card-body { padding: .85rem; }
  .ledger > .p-3 { padding: .85rem !important; }   /* converted section bodies keep the tightening */
  /* Tile padding in px. In rem it grew with the user's text scale and ate the room
     the FIGURE needed — at 1.4x it was taking 45px of a 185px tile, and the amount
     got clipped. Chrome should not win space from the number it exists to frame. */
  .stat-card.p-3 { padding: 12px !important; }
  .row.g-3 { --bs-gutter-x: 12px; --bs-gutter-y: 12px; }
  h1.h3 { font-size: 1.3rem; }
  /* Toolbars / button rows wrap by default on phones. */
  .toolbar, .page-actions, .d-flex.gap-2:not(.flex-nowrap) { flex-wrap: wrap; }
  .btn-group.flex-wrap-sm { flex-wrap: wrap; }
}
/* ---- Dashboard "Edit layout" mode ----
   View mode is clean (no widget chrome). Edit mode reveals the Add/Reset controls and a × on
   each tile, outlines tiles as draggable, and makes their inner links/buttons inert so a drag
   or a stray tap never navigates. SortableJS keeps tiles compacted as one is moved. */
.dash-edit-only { display: none; }
.dash-editing .dash-edit-only { display: inline-flex; }
.dash-edit-hint { display: none; }
.dash-editing .dash-edit-hint { display: block; }
.dash-del {
  display: none; border: 0; background: transparent; color: var(--bs-danger);
  font-size: 1.15rem; line-height: 1; padding: 0 .3rem; cursor: pointer;
}
.dash-editing .dash-del { display: inline-flex; }
/* THE GRAB AREA, and `touch-action: none` on it is LOAD-BEARING — not styling.
   A touch that begins on a touch-action:auto surface is claimed by the compositor as soon as the
   finger slides, and from then on the browser DISCARDS programmatic scrolling: measured on an
   iPhone mid-drag, we asked for 11216px of edge auto-scroll and the page moved 4px. Starting the
   drag on a touch-action:none element keeps the gesture on the main thread — the same test then
   moves 1241px of 1750 (620px/s vs 257px/s).
   It is INSET from the tile's edges because touch-action:none also disables NATIVE scrolling
   wherever it applies: the band of tile left around the pad is what you swipe to scroll the page,
   with the browser's own inertia. Percentage insets so a short stat card and a tall table card
   both keep a usable band. */
.dash-editing #dash-grid [data-mod] { cursor: grab; }
.dash-editing #dash-grid [data-mod].sortable-chosen { cursor: grabbing; }
/* Holding a widget must not also start a text selection on its figures (and on iOS raise the
   copy/look-up callout) underneath the drag. Suppressed only while editing — the numbers stay
   selectable in view mode. -webkit-touch-callout is the iOS-only half; Blink ignores it. */
.dash-editing #dash-grid [data-mod] {
  -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
}
.dash-editing #dash-grid [data-mod] .card,
.dash-editing #dash-grid [data-mod] .ledger,
.dash-editing #dash-grid [data-mod] .stat-card {
  outline: 1px dashed var(--brand-primary); outline-offset: -1px;
}
/* Inert content while editing — a drag or a stray tap must never navigate. */
.dash-editing #dash-grid [data-mod] a,
.dash-editing #dash-grid [data-mod] button:not(.dash-del),
.dash-editing #dash-grid [data-mod] input,
.dash-editing #dash-grid [data-mod] form { pointer-events: none; }

.dash-drag-pad { display: none; }
.dash-editing #dash-grid [data-mod] { position: relative; }
.dash-editing #dash-grid [data-mod] .dash-drag-pad {
  display: flex; align-items: center; justify-content: center;
  position: absolute; inset: 15% 10%; z-index: 3; border-radius: .9rem;
  touch-action: none; cursor: grab;
  -webkit-user-select: none; user-select: none;
  /* Frosted glass rather than a coloured box: a neutral veil plus a slight blur reads as a
     panel laid OVER the widget, and keeps the figures legible underneath. */
  background: color-mix(in srgb, var(--bs-body-color) 7%, transparent);
  -webkit-backdrop-filter: blur(1.5px); backdrop-filter: blur(1.5px);
  /* Fade the panel out towards its own edges so it blends into the tile instead of ending in a
     hard rectangle — and so the band around it reads as "not part of the handle". */
  -webkit-mask-image: radial-gradient(ellipse closest-side at 50% 50%, #000 40%, transparent 100%);
  mask-image: radial-gradient(ellipse closest-side at 50% 50%, #000 40%, transparent 100%);
  transition: background .12s ease;
}
/* The colour change means UNDOCKED, not "touched". :active fires the instant a finger lands, so
   tinting on it told you the widget was grabbed while the press-and-hold delay was still running —
   a lie about the state, and the only thing distinguishing a press that will become a drag from a
   swipe that will scroll the page. .sortable-chosen is added at exactly the instant the delay is
   satisfied, so the tint now lands with the lift. The CURSOR still changes on press: that is a
   desktop affordance, not a claim about what happened. */
.dash-editing #dash-grid [data-mod] .dash-drag-pad:active { cursor: grabbing; }
.dash-editing #dash-grid [data-mod].sortable-chosen .dash-drag-pad {
  cursor: grabbing;
  background: color-mix(in srgb, var(--brand-primary) 24%, transparent);
}
.dash-drag-pad-label {
  font-size: .6875rem; font-weight: 600; letter-spacing: .07em; text-transform: uppercase;
  color: var(--bs-body-color); text-align: center; white-space: nowrap;
  padding: .16rem .5rem; border-radius: .5rem;
  /* Its own chip, nearly opaque: the label has to stay readable over a table row or a bar chart,
     and it sits at the centre of the mask where the panel is at full strength. */
  background: color-mix(in srgb, var(--bs-body-bg) 84%, transparent);
  box-shadow: 0 1px 6px rgba(0, 0, 0, .22);
}
/* A short tile keeps the words, just smaller — they must be legible on every widget. */
.dash-drag-pad.is-tight .dash-drag-pad-label { font-size: .56rem; padding: .1rem .38rem; }
/* On a short tile the panel is only ~55px tall, so a fade starting at 40% swallows the label.
   Hold full strength across the middle and fade only at the very edge. */
.dash-drag-pad.is-tight {
  -webkit-mask-image: radial-gradient(ellipse closest-side at 50% 50%, #000 72%, transparent 100%);
  mask-image: radial-gradient(ellipse closest-side at 50% 50%, #000 72%, transparent 100%);
}

.dash-ghost { opacity: .4; }
/* "You've got it" feedback the moment the press-and-hold lands. It has to be VISUAL: iOS plays
   its haptic only for a real finger on a real control, so a programmatic tap can't buzz an
   iPhone at all (probed on iOS 26.5.2 — see plan.xml). Sortable adds .sortable-chosen at exactly
   the instant the delay is satisfied, before the tile has moved, so the lift lands on the beat
   the finger is waiting for. .sortable-drag is the floating clone that follows it. */
.dash-editing #dash-grid [data-mod].sortable-chosen .card,
.dash-editing #dash-grid [data-mod].sortable-chosen .ledger,
.dash-editing #dash-grid [data-mod].sortable-chosen .stat-card,
.sortable-drag .card, .sortable-drag .ledger, .sortable-drag .stat-card {
  transform: scale(1.04);
  box-shadow: 0 .6rem 1.2rem rgba(0, 0, 0, .45);
  transition: transform .1s ease-out, box-shadow .1s ease-out;
}

/* Admin section tiles — a calm, tappable grid instead of a wall of buttons. */
.admin-tile { color: inherit; transition: border-color .12s, transform .06s; }
.admin-tile:hover { border-color: var(--brand-primary); }
.admin-tile:active { transform: translateY(1px); }

.stat-card { border:1px solid var(--bs-border-color); border-radius:0; background:var(--bs-body-bg); }
.stat-card .stat-value { font-size:1.6rem; font-weight:600; line-height:1.1; }
.stat-card .stat-label { font-size:.8125rem; text-transform:uppercase; letter-spacing:.04em; color:var(--bs-secondary-color); }
/* The scope suffix ("· 2026 YTD") is `.small` INSIDE the label, so it compounded to
   11.38px — the last size on the dashboard nobody chose. Pinned to the micro step. */
.stat-card .stat-label .small, .stat-card .stat-label small { font-size: .6875rem; }
/* Dashboard module controls (↑ ↓ ✕) sit beside the title in a flex row. A long
   title used to push them past the card's right border on a phone ("Outstanding
   A/R" overflowed by 3px): the title's longest word sets a min-content width the
   row can't shrink below. Let the title break mid-word and give way; keep the
   button group at its natural size. */
.stat-card .stat-label { min-width: 0; overflow-wrap: anywhere; }
.stat-card .btn-group { flex-shrink: 0; }
/* THE FIGURE MUST NEVER BE CLIPPED. stat-value was a flat rem, so at a scaled-up
   text size "$57,907.46" ran past its own tile and the page's overflow-x:clip cut
   the last digits off — the one number on the screen that must be read exactly.
   vw-bounded, so the tile's width has the final say over the text scale. */
/* NOWRAP, not break-anywhere: "$6,327.00" split across two lines as "$6,327." /
   "00" is not a readable figure. The SIZE adapts to the tile instead — vw-bounded,
   so the tile's width always wins over the user's text scale.
   ONE rule, deliberately not a phone override: the first attempt put the small
   size in the ≤576px block and this larger one after it, so the later rule won at
   equal specificity and the phone kept the big text. A single self-limiting
   min() cannot be defeated by source order. */
.stat-card .stat-value { font-size: min(1.6rem, 5.6vw); white-space: nowrap; }
.stat-card .stat-label { font-size: min(.8125rem, 3.4vw); }
/* The period suffix ("· as of 06 Aug 2026") is not part of the label's shout: in
   uppercase it turned a two-word tile heading into three wrapped lines of caps.
   Own line, reading face, quiet. */
.stat-card .stat-label > span {
  display: block; text-transform: none; letter-spacing: 0;
  font-weight: 400; opacity: .8;
}
.table-sm td, .table-sm th { vertical-align:middle; }
.login-wrap { max-width:380px; margin:8vh auto; }
.login-logo { height:64px; }
.badge-status { text-transform:capitalize; }
.money { font-variant-numeric: tabular-nums; }

/* ---- LIVE WIDGETS MUST NOT CHANGE HEIGHT WHEN THEY TICK -------------------------------------
   The admin dashboard re-renders its watchable widgets every five seconds by replacing the
   card body's markup. Both of them draw a table whose LENGTH is data — the per-container list
   and the per-repo list — so a container starting or a repo dropping out of the window changed
   the card's height, which reflowed everything below it. On a page you are reading, the content
   moving under the cursor every five seconds is worse than the figures being a tick stale.

   A FIXED-HEIGHT SCROLLER RATHER THAN A min-height, deliberately: a minimum stops the card
   growing and does nothing when the list shrinks, so the jump simply happens in the other
   direction. The height is stated once, here, and the tick cannot touch it.

   And tabular figures on the numbers: proportional digits change WIDTH as they count, so a
   memory figure crossing 999→1000 rewraps its own line even when the row count is stable. */
.tile-rows { height: 8.25rem; overflow-y: auto; }
.tile-num  { font-variant-numeric: tabular-nums; }

/* Audit-log actor styling: platform admins (super-admins + global service
   accounts) in Vorperian gold with a crown; agent actions get a muted gear. */
.actor-admin { color: #ff9900; font-weight: 600; }
/* rem, not em: `.85em` inside an already-reduced parent compounded to 11.2px —
   a size no one chose, and the light-theme contrast failure on the dashboard. */
.actor-agent { color: var(--bs-secondary-color); font-size: .8125rem; }

/* Line-item cells that are empty render as "XXX" on the document — grey them
   out in the builder and show an XXX cue so it's clear they're intentionally blank. */
input.li-empty { background: var(--bs-secondary-bg); color: var(--bs-secondary-color); }
input.li-empty::placeholder { color: var(--bs-secondary-color); opacity: 1; }
input.li-total { background: var(--bs-secondary-bg); }

/* No number-input spinner "adjustment arrows" on line-item cells (they show on
   hover/focus otherwise). Fields are still editable by typing. */
input.li-xxx::-webkit-outer-spin-button,
input.li-xxx::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input.li-xxx[type="number"] { -moz-appearance: textfield; appearance: textfield; }

/* Locked XXX cells on catalog line items (field not set for that item). */
input.li-locked { cursor: not-allowed; background: var(--bs-secondary-bg); }

/* ---- Dark-mode readability ----
   Several Bootstrap helpers are hard-coded to the LIGHT palette (light bg +
   black text). In dark mode they render as glaring light bars, and any text we
   theme separately can disappear on them. Re-map them to theme variables so
   they stay legible in both modes. */
[data-bs-theme="dark"] .table-light {
  --bs-table-color: var(--bs-body-color);
  --bs-table-bg: var(--bs-tertiary-bg);
  --bs-table-border-color: var(--bs-border-color);
  --bs-table-striped-color: var(--bs-body-color);
  --bs-table-striped-bg: var(--bs-secondary-bg);
  --bs-table-hover-color: var(--bs-body-color);
  --bs-table-hover-bg: var(--bs-secondary-bg);
  color: var(--bs-body-color);
  border-color: var(--bs-border-color);
}
[data-bs-theme="dark"] .text-bg-light {
  color: var(--bs-body-color) !important;
  background-color: var(--bs-tertiary-bg) !important;
}

/* Inline code tokens — placeholder names, DNS records, header names. Bootstrap
   colours these PINK (#d63384 light, #e685b5 dark), which is a second hue by the
   back door: the settings page alone renders 40 of them, so the loudest colour on
   a tab about email templates was the punctuation of the template. DESIGN.md
   earns the monospace face for "figures, references, identifiers" — the FACE is
   what marks a token, not a hue. A tonal chip separates it instead. */
code {
  color: var(--bs-body-color);
  background: var(--bs-tertiary-bg);
  border: 1px solid color-mix(in srgb, var(--bs-border-color) 55%, transparent);
  border-radius: 0;
  padding: .05rem .3rem;
}
/* On a bar or a hazard band the surface is dark in BOTH themes, so the chip
   must be light-on-dark there (same reasoning as `.ledger__bar .btn`). */
.ledger__bar code, .hazard--band code {
  color: #fff; background: rgba(255, 255, 255, .12);
  border-color: rgba(255, 255, 255, .25);
}

/* Highlighted (confidential) note: yellow background needs dark text in BOTH
   modes, or the theme's light body text vanishes on the yellow. */
.note-highlight { background: #fff3a0; color: #212529; }

/* Dashboard magnitude bars: use the theme's emphasis color so the fill
   contrasts with the card in light AND dark mode (was a fixed near-black). */
.bar-fill { background: var(--bs-emphasis-color); }

/* Segmented filters (All / Quotes / Invoices, period pickers): the active tab
   is MARKED, never filled — Bootstrap's active state filled it with a grey that
   belongs to no system and made "where am I" the loudest thing on the page.
   Gold as structure (like the ledger's rules), not as a demand. */
.btn-group > .btn-outline-dark.active {
  --bs-btn-active-bg: transparent;
  --bs-btn-active-color: var(--bs-emphasis-color);
  --bs-btn-active-border-color: var(--bs-border-color);
  box-shadow: inset 0 -3px 0 var(--brand-primary);
  font-weight: 600;
}

/* Tab strips (Settings): same grammar as the segmented filters above — the
   active tab is MARKED with the gold underline, never filled. Bootstrap's
   nav-pills filled it with link-blue, the only non-gold hue on the whole page
   (measured: #0d6efd fill + 24 elements of #6ea8fe text). One hue, one system. */
.nav-ledger {
  --bs-nav-link-color: var(--bs-body-color);
  --bs-nav-link-hover-color: var(--bs-emphasis-color);
  --bs-nav-link-padding-y: .45rem;
  --bs-nav-link-padding-x: .8rem;
  border-bottom: 1px solid var(--bs-border-color);
}
.nav-ledger .nav-link {
  border-radius: 0;
  border-bottom: 3px solid transparent;   /* reserve the marker's space: no jiggle */
  white-space: nowrap;
}
.nav-ledger .nav-link.active {
  color: var(--bs-emphasis-color);
  background: transparent;
  border-bottom-color: var(--brand-primary);
  font-weight: 600;
}

/* Outline buttons are hard-coded to the light palette: btn-outline-dark is
   black (invisible on a dark page) and btn-outline-secondary is a low-contrast
   grey. Re-map them to theme vars so they stay visible in dark mode — used by
   the Documents Quotes/Invoices toggle, the dashboard Reset, Edit buttons, etc. */
[data-bs-theme="dark"] .btn-outline-dark {
  --bs-btn-color: var(--bs-body-color);
  --bs-btn-border-color: var(--bs-border-color);
  --bs-btn-hover-color: var(--bs-body-bg);
  --bs-btn-hover-bg: var(--bs-body-color);
  --bs-btn-hover-border-color: var(--bs-body-color);
  --bs-btn-active-color: var(--bs-body-bg);
  --bs-btn-active-bg: var(--bs-body-color);
  --bs-btn-active-border-color: var(--bs-body-color);
}
[data-bs-theme="dark"] .btn-outline-secondary {
  --bs-btn-color: var(--bs-body-color);
  --bs-btn-border-color: var(--bs-border-color);
  --bs-btn-hover-color: var(--bs-body-bg);
  --bs-btn-hover-bg: var(--bs-secondary-color);
  --bs-btn-hover-border-color: var(--bs-secondary-color);
  --bs-btn-active-color: var(--bs-body-bg);
  --bs-btn-active-bg: var(--bs-secondary-color);
}
/* Solid dark button (Save, Record payment/expenditure, etc.): a near-black
   button on a dark page has no visible edge. Invert it to a light button in
   dark mode so it reads as the primary action. */
[data-bs-theme="dark"] .btn-dark {
  --bs-btn-bg: var(--bs-emphasis-color);
  --bs-btn-border-color: var(--bs-emphasis-color);
  --bs-btn-color: var(--bs-body-bg);
  --bs-btn-hover-bg: var(--bs-secondary-color);
  --bs-btn-hover-border-color: var(--bs-secondary-color);
  --bs-btn-hover-color: var(--bs-body-bg);
  --bs-btn-active-bg: var(--bs-secondary-color);
  --bs-btn-active-border-color: var(--bs-secondary-color);
  --bs-btn-active-color: var(--bs-body-bg);
}

/* Navbar: always keep the logo (brand) and the hamburger toggler on ONE row on
   mobile. The trimmed logo is wide, so with the brand text + tenant badge the
   toggler used to wrap to a second line. The reason min-width:0 alone wasn't
   enough: flexbox decides whether to WRAP an item using its *hypothetical* width
   (the content width), which min-width doesn't reduce — so the wide brand pushed
   the toggler off before any shrinking happened. Setting flex-basis:0 makes the
   brand's hypothetical width zero, so it can never bump the toggler off the row;
   it then grows to fill the space (content left-aligned + ellipsized) while the
   toggler sits at the far right. Scoped to <992px; the desktop layout and the
   drop-down collapse menu are untouched. */
.navbar-toggler { flex-shrink: 0; }
@media (max-width: 991.98px) {
  /* Slim the bar so a bigger logo doesn't make it tall. */
  .navbar { padding-top: .25rem; padding-bottom: .25rem; }
  .navbar > .container-fluid > .navbar-brand {
    flex: 1 1 0;            /* hypothetical width 0 -> never wraps the toggler */
    min-width: 0;
    overflow: hidden;
    flex-wrap: nowrap;      /* keep logo + name + badge on ONE line (don't stack) */
    white-space: nowrap;    /* also covers the case it's laid out as inline content */
  }
  .navbar-brand img { height: 2.375rem; flex-shrink: 0; }   /* rem — see the base rule */
  .navbar-brand > span { flex: 0 1 auto; }              /* the text/badge give way, not the logo */
  .navbar-brand .fw-semibold,
  .navbar-brand .badge {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
  }
  /* THE NAME SHRINKS BEFORE IT TRUNCATES — but in JS (uiscale.js fitBrand), not here.
     The first fix was a vw clamp, and it shrank EVERY name: font-size cannot depend on
     text length in CSS, so protecting "VORPERIAN CYBERARTS" cost "BATBILL" a quarter of
     its size for nothing (owner's screenshot, 2026-08-06). The JS asks the one question
     CSS cannot — does this text actually overflow? — and steps the size down only then,
     in rem so the compensated state scales it correctly. The ellipsis above remains the
     floor for genuinely absurd lengths. */
}

/* ---------------------------------------------------------------------------------------------
   THE DOCK STACK — bottom-right of the viewport: notifications (permanent, nearest the thumb)
   and the setup checklist (temporary, stacked above it).

   Notifications used to be a bell in the navbar, where a 340px dropdown hanging off a 44px icon
   is fiddly on a phone and invisible on a laptop. Docked, both are one tap from anywhere, and
   both are native <details> so they collapse with NO JavaScript — a panel covering the screen
   with no way to shut it is an obstacle, which is exactly what the first attempt shipped.

   SIZE IS THE WHOLE PROBLEM ON A PHONE: a fixed narrow column, a hard height cap on each panel,
   and only one open at a time (docks.js). The stack hides entirely in dashboard edit mode — it
   sits exactly where a widget gets dragged and would eat both the drop and the edge auto-scroll.
   --------------------------------------------------------------------------------------------- */
.dockstack {
  /* CLEAR OF THE CORNER. A phone screen's corners are ROUNDED, and a round button parked in one
     with a 12px margin is half-hidden behind the curve — which is exactly what shipped. The inset
     is now large enough that a 4rem button sits inside the straight edges, and the safe-area
     insets are added on top for the day the app runs edge-to-edge (they resolve to 0 today, so
     the fallback is what does the work). */
  position: fixed;
  right: calc(1.25rem + env(safe-area-inset-right, 0px));
  bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
  z-index: 1035;                                            /* under modals (1055), over content */
  display: flex; flex-direction: column; align-items: stretch; gap: .5rem;
  width: min(21rem, calc(100vw - 1.5rem));
  pointer-events: none;                       /* the gaps between docks stay click-through */
}
.dockstack > * { pointer-events: auto; }
.dash-editing .dockstack { display: none; }

/* Panel above pill, so the pill stays anchored at the corner and the list grows upward — the
   thumb never has to move to collapse it again. */
.dock { display: flex; flex-direction: column-reverse; align-items: stretch; gap: .4rem; }
/* The same click-through trick as .dockstack, one level down, and it is needed for the same
   reason. .dockstack is pointer-events:none so its gaps stay transparent to clicks — but .dock
   is a direct child and gets `auto` back, while `align-items:stretch` makes it span the full
   21rem column even when it is collapsed to a pill in the corner. Measured at 390px: a 162px
   pill inside a 304px element, i.e. 142px of INVISIBLE hit area lying across the card beneath
   it. Taps on content the user can see plainly did nothing, with nothing on screen to explain
   why. Only the pill and the panel should ever receive a tap. */
.dock { pointer-events: none; }
.dock > * { pointer-events: auto; }

.dock__pill {
  display: flex; align-items: center; gap: .45rem; cursor: pointer; list-style: none;
  margin-left: auto; width: fit-content; max-width: 100%;
  border: 1px solid var(--bs-border-color); border-radius: 999px;
  background: var(--bs-body-bg); color: var(--bs-body-color);
  padding: .45rem .875rem; font-size: .85rem; font-weight: 600;
  box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .18);
}
/* An OVAL, not a circle. A round icon button is a small target however big you draw it, and it
   says nothing; the word pads it out horizontally and makes the whole pill the thing you press.
   3.25rem tall clears the 44px iOS and 48dp Android minimums on the short axis, and the label
   gives it ~11rem on the long one. It is the one control on every page of the app. */
.dock__pill--wide {
  min-height: 3.25rem; padding: 0 1.15rem; gap: .55rem; font-size: .875rem;
  background: var(--brand-primary); border-color: transparent; color: #0b0b0d;
  box-shadow: 0 .4rem 1.2rem rgba(0, 0, 0, .35);
}
.dock__pill--wide:hover { filter: brightness(1.08); }
.dock__pill--wide .dock__spark { font-size: 1.35rem; }
.dock__pill--wide .dock__count {
  min-width: 1.5rem; font-size: .8125rem; line-height: 1.5rem; padding: 0 .35rem; text-align: center;
}
.dock__pill::-webkit-details-marker { display: none; }
.dock__pill::marker { content: ""; }
.dock__pill:hover { border-color: var(--brand-primary); }
.dock__spark { line-height: 1; }
.setup-dock__pill .dock__spark { color: var(--bs-warning); }
.dock__count {
  background: color-mix(in srgb, var(--bs-warning) 22%, transparent);
  border-radius: 999px; padding: .05rem .45rem; font-variant-numeric: tabular-nums;
}
/* Unread notifications are a call to action, not a progress figure. */
#notif-dock .dock__count { background: var(--bs-danger); color: #fff; font-weight: 700; }
#notif-dock .dock__count.d-none { display: none; }
.dock__chev { transition: transform .15s ease; font-size: .6875rem; }
.dock[open] .dock__chev { transform: rotate(180deg); }

.dock__panel {
  /* ELEVATION, not decoration. Measured on dev before this: the panel computed to
     rgb(33,37,41) floating over a bg-body-tertiary page of rgb(43,48,53) — a raised surface
     TEN UNITS DARKER than the thing it covers, held apart by a translucent border and a soft
     shadow. That is why it read as unreadable over app pages: nothing said "this is on top".
     A raised surface in a dark theme gets LIGHTER, because light comes from above; in a light
     theme the page is already near-white, so the shadow does the work instead. Both keyed off
     the theme's own vars so a tenant's colours still flow through. */
  border-color: var(--bs-border-color);
  box-shadow: 0 1.25rem 3rem -.5rem rgba(0, 0, 0, .55),
              0 .25rem .75rem -.25rem rgba(0, 0, 0, .35);
  max-height: min(48vh, 24rem); overflow: auto;
}
[data-bs-theme="dark"] .dock__panel {
  background: color-mix(in srgb, var(--bs-body-bg) 84%, #fff 16%);
}
/* The panel's header is the ledger bar now, which carries its own dark fill —
   no transparent override needed the way the old .card-header demanded. The
   dock floats over content, so every page reserves clearance at the bottom
   (see main padding below) or the last row of a table is unreachable under it. */
/* `!important` is load-bearing, and its absence made this rule a no-op since the day it was
   written. base.html renders `<main class="container-fluid px-3 px-lg-4 py-4">`, and Bootstrap's
   `.py-4` is `padding-bottom: 1.5rem !important` — important beats non-important whatever the
   specificity, so `main` computed 24px, not 88px, on every page of the app. Measured with a
   geometric intersection test: the dock pill covered real table cells, including the money
   column ("$500.00" on /clients/, "$1,010.00" on /finance/). The comment above described a
   behaviour the stylesheet never had. THIRD instance of this exact trap in one day — see the
   landing page's `main{padding:0}` and `body{background}`. */
main.container-fluid, main.container { padding-bottom: 5.5rem !important; }
/* The pill sits on the page too, so it gets the same treatment: a control floating over
   content should never be darker than the content. */
[data-bs-theme="dark"] .dock__pill:not(.dock__pill--wide) {
  background: color-mix(in srgb, var(--bs-body-bg) 84%, #fff 16%);
}
/* `:not(--wide)` is load-bearing. Without it this rule outranks `.dock__pill--wide`
   (attribute + class beats class, and it is later in the file), so it replaced the
   wide pill's GOLD background while leaving its ink `color: #0b0b0d` behind:
   #0b0b0d on the lifted grey measures 2.14:1 against a 4.5:1 floor, on the one
   control that is on every page of the app. The pill had been rendering grey
   instead of gold since the lift was added, in dark mode only — which is the
   default theme. */
/* Notification icons: drawn from the sprite in templates/_icons.html, sized to sit
   on the first line of the title. They were emoji until 2026-08-06 and rendered as
   missing-glyph boxes wherever a colour emoji font was absent. */
.notif-icon { width: 20px; height: 20px; flex: 0 0 20px; margin-top: .12rem;
              color: var(--bs-secondary-color); }
.notif-unread .notif-icon { color: var(--brand-primary); }
/* UNREAD, in the one hue. Was a Bootstrap-blue background wash on both the dock
   feed and the notifications page — a second hue, and a fill where the lineage
   uses a rule. A gold edge marks the row instead. */
.notif-unread { border-left: 2px solid var(--brand-primary); }
.notif-unread .fw-semibold { color: var(--bs-emphasis-color); }

.dock__scroll { max-height: min(42vh, 21rem); overflow-y: auto; }
/* Installed to the home screen, the home indicator owns the bottom strip — lift clear of it. */
html.pwa-standalone .dockstack { bottom: calc(2.4rem + env(safe-area-inset-bottom, 0px)); }

/* INSTALLED APP: clear the status bar and the rounded corners.
   Every safe-area inset in this file was a BOTTOM one — nothing ever reserved the
   TOP, so in the installed app the navbar painted underneath the clock and signal
   icons. It is only visible once the app is on a home screen, which is why months
   of browser testing never showed it. The side insets matter in landscape, where
   the notch eats the left or right edge. */
html.pwa-standalone .navbar { padding-top: calc(.35rem + env(safe-area-inset-top, 0px)); }
html.pwa-standalone body {
  padding-left: env(safe-area-inset-left, 0px);
  padding-right: env(safe-area-inset-right, 0px);
}

/* NO PAGE MAY SCROLL SIDEWAYS. DESIGN.md states it as a rule and the suite pins it
   at one text size, but the installed app runs at whatever scale WebKit's per-site
   zoom left behind — and at 1.25x the dashboard pushed 13px past the edge, at 1.5x
   60px. A table that cannot fit must scroll INSIDE its own container; the page
   itself never does. `clip` rather than `hidden` because `hidden` on a scroll
   container silently kills position:sticky inside it. */
html { overflow-x: clip; }
body { overflow-x: clip; }
/* The setup checklist, PINNED at the top of the feed. Tinted and sticky so it reads as "this
   stays until you deal with it" rather than as the oldest notification in the list. */
.notif-pinned {
  position: sticky; top: 0; z-index: 2;
  background: color-mix(in srgb, var(--bs-warning) 12%, var(--bs-body-bg));
  border-bottom: 1px solid var(--bs-border-color);
}
.notif-pinned .progress { height: 5px; }
.notif-pinned__steps { list-style: none; margin: 0; padding: 0; }
.notif-pinned__steps li { display: flex; align-items: center; gap: .5rem; padding: .3rem 0; }
.notif-pinned__done summary { cursor: pointer; }

@media (max-width: 575.98px) {
  .dockstack {
    right: calc(1.1rem + env(safe-area-inset-right, 0px));
    bottom: calc(1.6rem + env(safe-area-inset-bottom, 0px));
    width: min(19rem, calc(100vw - 2.2rem));
  }
  .dock__panel { max-height: 42vh; }
  .dock__scroll { max-height: 36vh; }
  /* The label stays on a phone: it is what gives the button its width, and width is what makes
     it hittable. (It used to be hidden here, which is how the button ended up as a small circle
     tucked into the screen's rounded corner.) */
  .dock__pill--wide { min-height: 3.25rem; padding: 0 1.1rem; font-size: .875rem; }
}

/* The × on a widget must stay clickable in EDIT MODE, where you actually need it. The drag pad is
   inset only 15%/10% and sits at z-index 3, so on a short stat card it lands directly over the
   button — the one control edit mode exists for was the one the handle swallowed. Raised above
   the pad, and given a real touch target: it is a 1.15rem glyph, which is not a tap area. */
.dash-editing #dash-grid [data-mod] .dash-del {
  position: relative; z-index: 4; pointer-events: auto;
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.9rem; min-height: 1.9rem; border-radius: 50%;
  background: color-mix(in srgb, var(--bs-body-bg) 86%, transparent);
  box-shadow: 0 1px 5px rgba(0, 0, 0, .2);
}
.dash-editing #dash-grid [data-mod] .dash-del:hover {
  background: color-mix(in srgb, var(--bs-danger) 16%, var(--bs-body-bg));
}


/* BACK / UP control — one step toward the dashboard from wherever you are (services/navigation).
   A BREADCRUMB, not a button: the first version was a bordered pill on its own row, which took a
   full line of vertical space on every page and competed with the page heading and its action
   buttons for attention. It is a wayfinding hint, so it reads like one — small, quiet, tucked
   above the title — while keeping a 44px tap target through padding rather than bulk. */
.app-back {
  display: inline-flex; align-items: center; gap: .15rem;
  /* Small to look at, generous to hit: the padding carries the touch target (~40px) and the
     negative margins take that space back out of the layout, so it still reads as a quiet line
     above the title rather than a button. Making it visually smaller shrank the target to 29px,
     which the check caught. */
  margin: -.7rem 0 -.1rem -.45rem; padding: .85rem .6rem .85rem .45rem;
  color: var(--bs-secondary-color); text-decoration: none;
  font-size: .8125rem; font-weight: 600; line-height: 1;
}
.app-back:hover { color: var(--brand-primary); }
.app-back__chev { font-size: 1.05rem; line-height: .8; margin-top: -.08rem; }


/* PAGE HEADER ROWS ON A PHONE. Nearly every page opens with a
   `d-flex justify-content-between` holding the title and its action buttons. With no wrap the
   two fight over ~390px: the title breaks mid-word and the buttons stack into a narrow column
   beside it. Letting the row wrap puts the buttons on their own line, where they are both
   readable and hittable. Scoped to direct children of <main> so it only ever affects page
   headers, not the many flex rows inside cards and tables. */
@media (max-width: 575.98px) {
  main > .d-flex.justify-content-between { flex-wrap: wrap; row-gap: .5rem; }
  main > .d-flex.justify-content-between > .d-flex { flex-wrap: wrap; }
}

/* ---------------------------------------------------------------------------------------------
   LINE ITEMS ON A PHONE — the builder's worst defect, measured before the fix: an 8-column table
   rendered 545px of content inside a 337px container at 390px wide, so Unit Rate (a 35px-wide
   input), Tax, Line Total and Delete all sat behind a NESTED horizontal scroll. The price — the
   entire reason the document exists — was off-screen, and the delete button's right edge was at
   x=567 in a 390px viewport.

   The fix is CSS-only on purpose. The markup stays a real <table>, so every selector in
   builder.js, recomputeRow(), and collect() are untouched — a presentation change cannot
   disturb the tax or discount arithmetic, which mirrors the server's recompute_totals.

   Below lg each row becomes a card: description on its own line, quantities paired, the rate and
   its total paired, and the money right-aligned and bold. Labels come from `data-label`, set in
   addRow(), because the <thead> is hidden here and an unlabelled column is unusable stacked.
   --------------------------------------------------------------------------------------------- */
@media (max-width: 991.98px) {
  /* The wrapper's horizontal scroll is what hid the price. Nothing overflows once stacked. */
  .table-responsive:has(#itemsTable) { overflow-x: visible; }
  #itemsTable, #itemsTable tbody, #itemsTable td { display: block; width: auto; }
  #itemsTable thead { display: none; }

  /* CSS GRID, not inline-block. The first attempt paired fields with inline-block widths and
     they each took their own line: whitespace between inline-blocks plus a margin pushed the
     pair past 100%. Grid has no whitespace and no width arithmetic to get wrong. */
  #itemsTable tbody tr {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: .35rem .6rem;
    align-items: end;
    border: 1px solid rgba(255,255,255,.14);
    border-radius: .6rem;                 /* control radius, per DESIGN.md Shapes */
    padding: .6rem;
    padding-right: 3.2rem;                /* clear of the delete button in the corner */
    margin-bottom: .75rem;
    position: relative;
  }
  [data-bs-theme="light"] #itemsTable tbody tr { border-color: rgba(0,0,0,.14); }

  #itemsTable td { border: 0; padding: 0; }
  /* The label a hidden <thead> can no longer provide. */
  #itemsTable td[data-label]::before {
    content: attr(data-label);
    display: block;
    font-size: .6875rem;
    opacity: .7;
    margin-bottom: .1rem;
  }
  /* Description and the money each own a full row; the four quantity fields pair up. */
  #itemsTable td[data-label="Description"] { grid-column: 1 / -1; }
  #itemsTable td[data-label="Line Total"]  { grid-column: 1 / -1; }
  #itemsTable td[data-label="Line Total"] .li-total { font-weight: 700; font-size: 1.05rem; }
  /* Tax is a toggle, not a value: its label sits beside it rather than above. */
  #itemsTable td.li-taxcell { text-align: left !important; display: flex; align-items: center; gap: .5rem; }
  #itemsTable td.li-taxcell::before { margin-bottom: 0; }
  /* A 16px checkbox was the smallest target on the page. 28px plus the cell's own padding
     gives a thumb something to land on; a literal 44px checkbox looks like a button. */
  #itemsTable td.li-taxcell .li-tax { width: 1.75rem; height: 1.75rem; margin: 0; flex: 0 0 auto; }
  /* Delete was 26x31 next to the fields a thumb was hunting for. Move it to the card's own
     corner and give it a real target. */
  #itemsTable td.li-delcell { position: absolute; top: .5rem; right: .5rem; padding: 0; }
  #itemsTable td.li-delcell .li-del {
    min-width: 44px; min-height: 44px; line-height: 1; font-size: 1.1rem;
  }
  /* THE THUMB RULE: every control a finger lands on clears 44px. */
  #itemsTable .form-control, #itemsTable .form-control-sm { min-height: 44px; font-size: 1rem; }

  /* Recovered-work offer. Restore and Discard are a one-shot, irreversible-feeling pair, and
     the same rule applies to them: a 31px btn-sm for "give me back the invoice I just lost" is
     the wrong place to make someone aim. Full width so the two choices can't be mis-tapped for
     each other, Restore first because it is what the banner is for. */
  #draftBanner .btn { min-height: 44px; flex: 1 1 100%; }

  /* ---- The builder's phone action bar ------------------------------------------------------
     The running total and Save, pinned to the thumb zone. Above the content (1030) but BELOW
     the dock stack (1035), and the dock is lifted clear of it below — a floating pill parked on
     top of the Save button is the obvious way to get this wrong. */
  #builderBar {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 1030;
    display: flex; align-items: center; gap: .75rem;
    padding: .6rem .9rem;
    padding-bottom: calc(.6rem + env(safe-area-inset-bottom, 0px));
    background: var(--bs-body-bg);
    border-top: 1px solid var(--bs-border-color);
    box-shadow: 0 -.4rem 1rem rgba(0, 0, 0, .25);
  }
  #builderBar .bb-total { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
  #builderBar .bb-total__label { font-size: .6875rem; opacity: .7; }
  #builderBar #tTotalBar { font-size: 1.3rem; font-weight: 700; }
  #builderBar .btn { margin-left: auto; min-height: 48px; padding-inline: 1.1rem; flex: 0 0 auto; }
  /* The bar is fixed, so it covers whatever the page ends with unless the page makes room.
     `!important` because <main> carries Bootstrap's `.py-4`, and every Bootstrap spacing
     utility is declared !important — a plain rule here loses however specific it is, which is
     exactly what happened: the padding read 24px and the bar sat across the last card. */
  body.has-builder-bar main { padding-bottom: 6rem !important; }
  /* Lift the floating dock clear of the bar. Equal specificity to the PWA-standalone rule
     above, and this one comes later, so it wins there too — deliberately: 6rem is the larger
     offset, so the dock stays clear either way. */
  body.has-builder-bar .dockstack {
    bottom: calc(6rem + env(safe-area-inset-bottom, 0px));
  }
}

/* Desktop keeps the sidebar, where Totals and Save are already in view. */
@media (min-width: 992px) { #builderBar { display: none; } }

/* ------------------------------------------------------------------------------------------
   .text-secondary is MUTED TEXT here, in all 597 places we use it. Bootstrap 5.3 disagrees,
   and the difference fails WCAG AA across the whole app in dark mode.

   5.3 kept two variables whose names invite exactly this mistake:
     --bs-secondary-rgb    the BRAND grey #6c757d, identical in both themes
     --bs-secondary-color  the MUTED TEXT colour, theme-aware
   `.text-secondary` reads the first. The theme-aware utility is `.text-body-secondary`, which
   5.3 introduced when it renamed `.text-muted` — so a class that had meant "muted text" for
   years quietly became a brand-colour utility, and nothing announced it.

   Measured on a real page before this rule: #6c757d on the dark card surface #212529 is
   3.29:1, against the 4.5:1 AA needs for body text — and it was carrying the client's own rep
   name and contact details. Bootstrap already computes a passing value; nothing was reading it.

   One line rather than rewriting 597 call sites across 81 templates, because "muted text" is
   what every one of them means. If you are tempted to delete this and switch the templates to
   .text-body-secondary, that is the idiomatic fix and it is welcome — but do all 597 at once,
   because half a migration leaves the other half failing contrast with nothing to notice it. */
.text-secondary { color: var(--bs-secondary-color) !important; }

/* THE SAME TRAP, ON THE MONEY. `.text-success` and `.text-danger` read
   --bs-success/--bs-danger, which are fixed brand colours tuned for white:
   #198754 and #dc3545 measure 3.4:1 on the dark ledger surface #212529,
   against the 4.5:1 AA needs for body text.

   That is not a decorative failure. These two classes colour the AMOUNT on
   every row of the finance ledger and the dashboard's transactions widget —
   the +$1,035.00 and the −$200.00 — so the least readable text on a dark
   screen was the figure the product exists to report. 23 of them on /finance/
   alone, measured on the rendered page.

   Bootstrap 5.3 already computes passing, theme-aware values for exactly this
   (--bs-*-text-emphasis: #75b798 / #ea868f in dark = 6.6:1 and 6.1:1); as with
   .text-secondary above, nothing was reading them. Dark only: on the light
   theme the originals already pass, and the emphasis pair would be too pale. */
[data-bs-theme="dark"] .text-success { color: var(--bs-success-text-emphasis) !important; }
[data-bs-theme="dark"] .text-danger  { color: var(--bs-danger-text-emphasis) !important; }

/* ============================================================================
   THE LEDGER — the app's hard-ruled surface, per DESIGN.md's lineage section.
   DNA Lounge -> Vorperian Cyberarts -> BatBill. What we inherit is STRUCTURE:
   a black field, square corners, rules and filled uppercase section bars, and
   density. What we leave behind is the grandparent's uniform 1px harshness and
   its several competing hues — here rules carry hierarchy (a section bar is not
   a row divider) and there is exactly ONE hue, Vorperian gold.

   These are primitives, not page styles: the documents list is the first
   surface converted, and clients/finance follow onto the same classes.
   ========================================================================= */

.ledger {
  border: 1px solid var(--bs-border-color);
  border-radius: 0;                    /* square by rule — the rounded Bootstrap
                                          card is the single biggest reason the
                                          app read as generic SaaS */
  box-shadow: none;
  background: var(--bs-body-bg);
}

/* The filled section bar. Structure is drawn, never floated: a solid band with
   a gold rule on top, which is where the hierarchy lives. Gold is the marker,
   not the fill — a full gold band would spend the one hue on furniture. */
.ledger__bar {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: .75rem; flex-wrap: wrap;
  padding: .5rem .85rem;
  background: var(--brand-secondary);
  color: #fff;
  border-bottom: 1px solid var(--bs-border-color);
  /* The TRIPLE RULE — three gold lines with black gaps, off the technician
     jacket's collar and cuffs (DESIGN.md). This is the ambient band: it says
     "ours". The hazard diagonal below is its rare sibling and says "this can
     cost you". Same family, never confusable. */
  border-top: 7px solid transparent;
  border-image: repeating-linear-gradient(
    to bottom,
    var(--brand-primary) 0 1px,
    var(--brand-secondary) 1px 3px,
    var(--brand-primary) 3px 4px,
    var(--brand-secondary) 4px 6px,
    var(--brand-primary) 6px 7px) 7;
  font-family: 'Orbitron', system-ui, -apple-system, sans-serif;
  font-weight: 800; text-transform: uppercase; letter-spacing: .08em;
  font-size: .8125rem;                 /* 13px — above the 11px functional floor */
  line-height: 1.3;
}
.ledger__bar-note {
  font-family: "Liberation Sans", Arial, Helvetica, sans-serif;
  font-weight: 400; text-transform: none; letter-spacing: 0;
  font-size: .8125rem; color: rgba(255,255,255,.72);
}
.ledger__bar-note strong { color: var(--brand-primary); font-weight: 700; }

/* Controls that sit INSIDE the bar. The bar is dark in BOTH themes, so the
   theme-mapped outline buttons (dark-on-light in light mode) would vanish on
   it. Buttons in a bar are light-on-dark by construction, and stay in the body
   font (The Buttons Are Not Signage Rule — the bar shouts, its controls don't). */
.ledger__bar .btn {
  --bs-btn-color: #fff;
  --bs-btn-border-color: rgba(255, 255, 255, .45);
  --bs-btn-bg: transparent;
  --bs-btn-hover-color: #fff;
  --bs-btn-hover-bg: rgba(255, 255, 255, .12);
  --bs-btn-hover-border-color: rgba(255, 255, 255, .7);
  --bs-btn-active-color: var(--brand-secondary);
  --bs-btn-active-bg: #fff;
  font-family: "Liberation Sans", Arial, Helvetica, sans-serif;
  font-weight: 400; text-transform: none; letter-spacing: 0;
}

/* ---- The hazard band ------------------------------------------------------
   DNA Lounge's actual signature, per the owner's photographs (DESIGN.md,
   "warning signs and stanchions"): diagonal stripes running the full width of a
   sign or strung between stanchions as barrier tape. Ours is gold where theirs
   is hi-vis yellow-green — Vorperian Cyberarts is the gold version.

   RESERVED, never ambient. A warning sign means "something here can hurt you",
   which on this product is overdue money, a live-money confirm, or the alpha
   banner. Stripe every section and the device becomes furniture, leaving
   nothing to say the one thing that matters. */
.hazard {
  background-image: repeating-linear-gradient(
    -45deg,
    var(--brand-primary) 0 10px,
    var(--brand-secondary) 10px 20px);
  height: 6px;
}
.hazard--band {                        /* a full sign: stripes above and below */
  height: auto; background-image: none;
  border-top: 6px solid transparent; border-bottom: 6px solid transparent;
  border-image: repeating-linear-gradient(
    -45deg, var(--brand-primary) 0 10px, var(--brand-secondary) 10px 20px) 6;
  background-color: var(--brand-secondary);
  color: #fff; padding: .55rem .85rem;
  font-family: 'Orbitron', system-ui, -apple-system, sans-serif;
  font-weight: 800; text-transform: uppercase; letter-spacing: .08em;
  font-size: .8125rem;
  display: flex; align-items: baseline; justify-content: space-between;
  gap: .75rem; flex-wrap: wrap;
}
.hazard--band .amt { color: var(--brand-primary); font-variant-numeric: tabular-nums; }
/* PROSE INSIDE THE BAND returns to the reading face. The band shouts in uppercase
   Orbitron, which is right for "3 PROJECTS OWING" and wrong for a sentence: the
   sandbox banner ran 88 characters of uppercase, and DESIGN.md's Short Shout Rule
   says two or three words, "never for prose". Same shout/note split as
   .ledger__bar / .ledger__bar-note, so the two bands stay relatives. */
.hazard__note {
  font-family: "Liberation Sans", Arial, Helvetica, sans-serif;
  font-weight: 400; text-transform: none; letter-spacing: 0;
  font-size: .8125rem; line-height: 1.45;
}
.hazard__note strong { font-weight: 700; }
/* PHONE: keep the WARNING, drop the operator tooling. The mail-redirect field and
   its button stacked into ~250px of banner at the top of EVERY page — the single
   biggest thing making the app feel cluttered on a phone. The alpha/sandbox
   message itself stays (PRODUCT.md: alpha honesty is required, the banner is not
   to be designed away); only the redirect control, which is a deliberate desk
   action, waits for a wider screen. */
@media (max-width: 640px) {
  /* !important because the form carries Bootstrap's .d-flex, which is itself
     `display: flex !important` — a plain `display: none` loses to it silently. */
  .hazard--band form { display: none !important; }
  .hazard--band { padding: .45rem .85rem; }
}
@media (prefers-reduced-motion: no-preference) { .hazard { transition: none; } }

/* Rows. Hairline dividers — deliberately lighter than the frame, so the eye
   reads the block first and the rows second. */
.ledger table { margin: 0; width: 100%; }
.ledger thead th {
  font-weight: 600; font-size: .8125rem; text-transform: uppercase;
  letter-spacing: .06em; color: var(--bs-secondary-color);
  padding: .5rem .85rem; white-space: nowrap;
  border-bottom: 1px solid var(--bs-border-color);
}
.ledger tbody td { padding: .6rem .85rem; vertical-align: middle; }
.ledger tbody tr + tr > td {
  border-top: 1px solid color-mix(in srgb, var(--bs-border-color) 45%, transparent);
}
.ledger tbody tr:hover > td { background: var(--bs-tertiary-bg); }

/* Money reads as a column of figures, so it gets lining tabular numerals and
   right alignment. Everything else is left-aligned — a centred column of
   figures cannot be scanned, which is the grandparent's mistake we skip. */
.ledger .num {
  text-align: right; white-space: nowrap;
  font-variant-numeric: tabular-nums lining-nums;
  font-feature-settings: "tnum" 1, "lnum" 1;
}
.ledger .num--zero { color: var(--bs-secondary-color); }
.ledger .num--owed { font-weight: 700; }

/* ---- Status, in ONE hue ----------------------------------------------------
   The old badges ran six Bootstrap colours (info / primary / success / warning
   / danger / secondary), which is exactly the "multiple competing hues" the
   lineage says to leave behind — and it made every row shout equally.
   Here gold means ONE thing: this row wants something from you. Everything
   settled or in-progress is a hairline chip; a finished document recedes. */
/* Bootstrap's `.badge` is `font-size: .75em` — RELATIVE, so inside a `.btn-sm` it computed
   10.5px, below this app's own 11px functional floor and a size nobody chose. Same
   em-compounding trap as `.actor-agent`. The ledger world replaced badges with `.st`; the
   stragglers are pinned to the micro step rather than left to inherit their way downward. */
.badge { font-size: .6875rem; }

.st {
  display: inline-block; padding: .1rem .45rem;
  border: 1px solid var(--bs-border-color); border-radius: 0;
  font-size: .6875rem; font-weight: 700; text-transform: uppercase;
  letter-spacing: .07em; line-height: 1.5; white-space: nowrap;
  color: var(--bs-body-color);
}
.st--quiet { color: var(--bs-secondary-color); }          /* draft, void, superseded */
.st--done  { color: var(--bs-secondary-color);            /* paid, signed — finished, so it recedes */
             border-color: color-mix(in srgb, var(--bs-border-color) 60%, transparent); }
.st--live  { border-color: var(--brand-primary); color: var(--brand-primary); }  /* overdue, partial */
/* NOT A VERDICT — "we could not run this check at all". The verifier needs a fourth state next to
   verified / within noise / beyond noise, and it is the one that gets collapsed into "failed" if
   nothing distinguishes it: a document sealed under an older canonicalisation is UNCHECKABLE, and
   reporting our own version bump as tampering is the worst answer that page can give.

   DASHED, NOT A NEW COLOUR. The palette is one hue by rule (see DESIGN.md), and this state is
   precisely not an alarm — spending gold on it would rank it beside "read this clause now". The
   dash carries "indeterminate" structurally, which survives greyscale printing and colour-blindness
   in a way a second hue would not. */
.st--unknown { color: var(--bs-secondary-color); border-style: dashed; }

/* THE SAME SHADE, FOR THE CHIP. The link fix above shaded gold for light mode and
   stopped there; `.st--live` — the chip that means THIS ROW WANTS SOMETHING FROM YOU —
   kept the raw hue and measured 2.14:1 on white. The most urgent state in the system
   was its least readable one, on every page that renders a status, not just admin.
   Same derivation as the links (60% of the tenant's own hue over black) so this is a
   shade and not a second colour.

   ORDER MATTERS: this sits BEFORE the bar rules below on purpose. `[data-bs-theme=
   "light"] .st--live` and `.ledger__bar .st--live` are both (0,2,0), so the tie is
   broken by document order — putting this first lets the bar keep the pure gold it
   needs against its dark background. Moving it below would silently un-fix the bars. */
[data-bs-theme="light"] .st--live {
  color: color-mix(in srgb, var(--brand-primary) 60%, #000);
  border-color: color-mix(in srgb, var(--brand-primary) 60%, #000);
}
/* Bootstrap's semantic text utilities are authored for its own palette, not a white
   page: `.text-warning` on white is 1.63:1 and `.text-success` 3.96:1, both under the
   4.5 floor, and both were carrying the headline number on the self-test tiles — the
   count you open that page to read. Darkened for light mode only; dark mode keeps the
   brighter values, which already clear 7:1 on the near-black page. */
[data-bs-theme="light"] .text-warning {
  color: color-mix(in srgb, var(--brand-primary) 60%, #000) !important;
}
[data-bs-theme="light"] .text-success {
  color: color-mix(in srgb, #198754 78%, #000) !important;
}
/* #dc3545 measures 3.96:1 on the light `row--bad` tint — under the floor, and it is the
   FAILED count on the self-test history, i.e. the number that decides whether you keep
   reading. Darkened rather than replaced: red here is not decoration, it is the one
   place the design system tolerates a second hue because "this one broke" has to be
   distinguishable at a glance from "this one is merely urgent" (which is the gold). */
[data-bs-theme="light"] .text-danger {
  color: color-mix(in srgb, #dc3545 82%, #000) !important;
}
/* Bootstrap amber (#ffc107) is a THIRD hue and reads at 1.63:1 on white — it was the
   "Drain" control on the fleet page, an action that takes a node out of service. Mapped
   onto the same shaded gold as everything else that wants attention, so the console does
   not introduce a colour the rest of the product never uses. */
[data-bs-theme="light"] .btn-outline-warning {
  --bs-btn-color: color-mix(in srgb, var(--brand-primary) 60%, #000);
  --bs-btn-border-color: color-mix(in srgb, var(--brand-primary) 60%, #000);
  --bs-btn-hover-bg: color-mix(in srgb, var(--brand-primary) 60%, #000);
  --bs-btn-hover-border-color: color-mix(in srgb, var(--brand-primary) 60%, #000);
  --bs-btn-hover-color: #fff;
}
/* 4.45:1 against a 4.5 floor — the kind of miss that survives every eyeball review and
   fails every audit. Bootstrap's grey, one step darker for light mode only. */
[data-bs-theme="light"] .btn-outline-secondary {
  --bs-btn-color: #5c636a;
  --bs-btn-border-color: #8b9197;
}
/* AND THE BAR TAKES ITS COLOUR BACK. `.ledger__bar .btn` is (0,2,0) and so is
   `[data-bs-theme="light"] .btn-outline-secondary`, so the tie goes to whichever sits
   later — and mine does. Without this, darkening those buttons "for light mode" pushed
   the Reload / Re-settle controls on the fleet page from 4.45:1 to 3.23:1, because they
   live on a bar that is dark in BOTH themes. The fix made the thing worse and looked
   like progress; it was caught by re-measuring rather than by re-reading the rule.
   Three selectors, not one, because the same tie exists for every light-mode button
   override above. */
[data-bs-theme="light"] .ledger__bar .btn-outline-secondary,
[data-bs-theme="light"] .ledger__bar .btn-outline-warning,
[data-bs-theme="light"] .hazard--band .btn-outline-secondary,
[data-bs-theme="light"] .hazard--band .btn-outline-warning {
  --bs-btn-color: #fff;
  --bs-btn-border-color: rgba(255, 255, 255, .45);
  --bs-btn-hover-color: #fff;
  --bs-btn-hover-bg: rgba(255, 255, 255, .12);
  --bs-btn-hover-border-color: rgba(255, 255, 255, .7);
}
[data-bs-theme="dark"] .btn-outline-warning {
  --bs-btn-color: var(--brand-primary);
  --bs-btn-border-color: var(--brand-primary);
  --bs-btn-hover-bg: var(--brand-primary);
  --bs-btn-hover-border-color: var(--brand-primary);
  --bs-btn-hover-color: #12100e;
}

/* Chips that sit INSIDE a section bar. The bar is dark in both themes (same
   reasoning as .ledger__bar .btn above), so the theme-mapped chip colors would
   go invisible in light mode. st--live stays gold — gold reads on the bar. */
.ledger__bar .st, .hazard--band .st, .hostbar .st {
  color: #fff; border-color: rgba(255, 255, 255, .45);
  font-family: "Liberation Sans", Arial, Helvetica, sans-serif; letter-spacing: .06em;
}
.ledger__bar .st--quiet, .ledger__bar .st--done, .ledger__bar .st--unknown,
.hazard--band .st--quiet, .hazard--band .st--done, .hazard--band .st--unknown,
.hostbar .st--quiet, .hostbar .st--done, .hostbar .st--unknown {
  color: rgba(255, 255, 255, .65);
  border-color: rgba(255, 255, 255, .25);
}
/* The host band is a SATURATED violet on dev, not the near-black the other bands use,
   so the .65 alpha that clears AA on #0b0b0d only reaches 3.73:1 here. Same idea, more
   of it — measured 5.8:1 on the violet and better still on prod's near-black band. */
.hostbar .st--quiet, .hostbar .st--done {
  color: rgba(255, 255, 255, .92);
  border-color: rgba(255, 255, 255, .45);
}
.ledger__bar .st--live, .hazard--band .st--live, .hostbar .st--live {
  color: var(--brand-primary); border-color: var(--brand-primary);
}

/* The SAME inversion, for text. `.ledger__bar .st` was fixed when the chips moved
   onto the bar, but muted TEXT there was not: `.text-secondary` and `.small` map to a
   dark grey in light mode, and the bar is dark in BOTH themes, so a timestamp on the
   estate bar rendered at 1.18:1 — present, aligned, and unreadable. Measured on a
   rendered page in light mode; the dark theme it was authored in hid it completely,
   which is the whole reason this rule is written against the bar rather than the theme.
   Scoped to the bar's own descendants so nothing in the panel body is touched. */
.ledger__bar .text-secondary, .hazard--band .text-secondary,
.ledger__bar .text-body-secondary, .hazard--band .text-body-secondary,
.ledger__bar small, .hazard--band small {
  color: rgba(255, 255, 255, .72) !important;
}

/* ---- The taped-off control -------------------------------------------------
   A destructive button wearing the barrier tape, per the two-tier band system:
   the triple gold rule is ambient and means "ours"; the hazard diagonal means
   THIS CAN COST YOU. Bootstrap red said the same thing in a hue this product
   does not otherwise own, and said it identically for "delete a draft" and
   "bill every customer".

   The stripes are the button's TOP AND BOTTOM EDGE, never behind the label —
   diagonal tape under text is unreadable at any contrast, which is why the band
   carries its stripes on borders too. The body stays solid so the text keeps a
   flat surface, and the face stays the reading font: a button is not signage,
   even a dangerous one.

   Deliberately NOT the default for every destructive control. If the tape is on
   everything it stops meaning anything — it is for actions that move money,
   send mail to real people, or destroy data that is not recoverable from the
   thing in front of you. */
.btn-hazard {
  --bs-btn-color: var(--brand-primary);
  --bs-btn-bg: var(--brand-secondary);
  --bs-btn-border-color: transparent;
  --bs-btn-hover-color: #12100e;
  --bs-btn-hover-bg: var(--brand-primary);
  --bs-btn-hover-border-color: transparent;
  --bs-btn-active-color: #12100e;
  --bs-btn-active-bg: var(--brand-primary);
  --bs-btn-disabled-color: rgba(255, 255, 255, .5);
  --bs-btn-disabled-bg: var(--brand-secondary);
  border-radius: 0;
  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
  border-image: repeating-linear-gradient(
    -45deg, var(--brand-primary) 0 10px, var(--brand-secondary) 10px 20px) 5;
  font-family: "Liberation Sans", Arial, Helvetica, sans-serif;
  font-weight: 700;
  min-height: 44px;                    /* the touch floor, on the one control you
                                          least want mis-tapped */
  padding-inline: .9rem;
}
/* The tape is the emphasis; don't let a focus ring double it into noise, but never
   remove the ring — this is the control that most needs to be keyboard-visible. */
.btn-hazard:focus-visible {
  outline: 2px solid var(--brand-primary); outline-offset: 2px; box-shadow: none;
}
/* On a bar the surround is already dark; the tape would fight the bar's own rule,
   so the button keeps the tape and drops to a quieter body. */
.ledger__bar .btn-hazard, .hazard--band .btn-hazard {
  --bs-btn-bg: transparent;
  --bs-btn-color: #fff;
}

/* ---- Notes, in ONE hue -----------------------------------------------------
   The square sibling of Bootstrap's .alert, which ran cyan (info), green
   (success) and blue-grey (secondary) panels — three hues the lineage leaves
   behind, all found on the Settings domain tab and the bank pages. Same
   semantics as the .st chips: gold means "this wants something from you",
   everything settled or merely informational recedes to a hairline box. */
.note {
  border: 1px solid var(--bs-border-color); border-radius: 0;
  background: var(--bs-tertiary-bg);
  padding: .6rem .85rem; font-size: .875rem; line-height: 1.45;
}
.note--live {                            /* needs your attention / action —
                                            gold frame, same grammar as .st--live */
  border-color: var(--brand-primary);
}

/* ---- Row state, dark-native ------------------------------------------------
   Bootstrap's `.table-danger` / `.table-warning` / `.table-active` set a LIGHT
   background and dark cell text. In this app's native dark theme any cell also
   carrying `.text-secondary` or `.text-danger` keeps its dark-theme token, so
   the row renders light-on-light. Measured: the check name on /admin/selftest at
   **1.02:1** and the cause note on /admin/usage at **1.13:1** — effectively
   invisible, and always on THE ROW THAT IS THE PROBLEM. 50 uses across 31 files.

   This is the same defect class as the money-at-3.4:1 bug: a Bootstrap colour
   tuned for white paper, used on a dark surface. The replacement marks a row the
   way the rest of the system marks anything — a rule in the one hue, plus a tonal
   step — and never touches the text colour, so whatever the cell already uses
   stays legible. */
.ledger tbody tr.row--warn > td,
.ledger tbody tr.row--bad  > td,
.ledger tbody tr.row--now  > td { background: color-mix(in srgb, var(--bs-body-color) 6%, transparent); }
.ledger tbody tr.row--warn > td:first-child { box-shadow: inset 3px 0 0 var(--brand-primary); }
.ledger tbody tr.row--bad  > td:first-child { box-shadow: inset 3px 0 0 var(--bs-danger); }
.ledger tbody tr.row--now  > td:first-child { box-shadow: inset 3px 0 0 var(--bs-body-color); }
/* Outside a .ledger (plain tables) the same treatment, without the frame. */
tr.row--warn > td:first-child { box-shadow: inset 3px 0 0 var(--brand-primary); }
tr.row--bad  > td:first-child { box-shadow: inset 3px 0 0 var(--bs-danger); }
tr.row--now  > td:first-child { box-shadow: inset 3px 0 0 var(--bs-body-color); }
/* AND ON A BLOCK, which is how twelve of these are actually written. Every selector above is
   scoped to a table cell, but `.row--warn` has been used on a plain `<div>` since long before
   this rule existed — the SSN notice in Team Settings, the W-9 gaps on a client page, four
   admin banners — and every one of them rendered as unmarked body text. The class was doing
   nothing at all there, which is worse than a wrong colour: the page looks like it is saying
   something ordinary. Same vocabulary as the row, laid on the leading edge. */
:where(div, p, section).row--warn,
:where(div, p, section).row--bad,
:where(div, p, section).row--now { background: color-mix(in srgb, var(--bs-body-color) 6%, transparent); }
:where(div, p, section).row--warn { box-shadow: inset 3px 0 0 var(--brand-primary); }
:where(div, p, section).row--bad  { box-shadow: inset 3px 0 0 var(--bs-danger); }
:where(div, p, section).row--now  { box-shadow: inset 3px 0 0 var(--bs-body-color); }

/* `.btn-outline-danger`'s red is Bootstrap's paper-tuned #dc3545: 3.41:1 on the
   dark page, on every destructive control in the admin console (Restore x10,
   Unlock commit deploys, Revoke all outstanding). The emphasis token passes. */
[data-bs-theme="dark"] .btn-outline-danger {
  --bs-btn-color: var(--bs-danger-text-emphasis);
  --bs-btn-border-color: var(--bs-danger-border-subtle);
  --bs-btn-hover-bg: var(--bs-danger);
  --bs-btn-hover-border-color: var(--bs-danger);
  --bs-btn-hover-color: #fff;
}

/* ---- Row actions (edit / delete on a ledger row) --------------------------
   Real buttons with a visible edge on approach, not 10×23px glyph links: the
   finance ledger had 23 delete controls at that size, unnamed, 8px from the
   edit pencil — over a route that rewrites recognised revenue. */
.row-act {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 28px; min-height: 28px; padding: 0 .3rem;
  background: transparent; border: 1px solid transparent; border-radius: 0;
  color: var(--bs-secondary-color); text-decoration: none; line-height: 1;
}
.row-act:hover, .row-act:focus-visible {
  border-color: var(--bs-border-color); color: var(--bs-body-color);
}
.row-act--danger { color: var(--bs-danger); }
.row-act--danger:hover, .row-act--danger:focus-visible {
  border-color: var(--bs-danger); color: var(--bs-danger);
}
@media (max-width: 640px) {
  .row-act { min-width: 44px; min-height: 44px; }
}

/* ---- Phone: THE TWO-LINE ROW (.ledger--rows) -------------------------------
   The label/value restack below gives every column its own line. On a six-column
   list that is ~250px per row: /documents/ measured **10,507px for 38 projects —
   12.5 phone screens** — with no search and no filter, to answer the one question
   the product exists for. DESIGN.md specifies the alternative and (wrongly)
   describes it as what this list already does: "a constant narrow key column,
   bold primary line, quieter secondary under it, money right-aligned."

     line 1   Project title ............................ $1,000.00
     line 2   CLIENT Acme · STATUS Sent · TOTAL $1,000.00

   ONE MODEL PER TABLE, NOT TWO FIGHTING. The restack below now carries
   `:not(.ledger--rows)`, so nothing here has to out-specify it. The first attempt
   at this layered overrides on top instead and lost four times over — a bare
   `.ledger--rows` losing to `.ledger:not(.ledger--flat)`, then the title cell
   (which carries no data-label) being captured by the controls rule and rendering
   the row name-last. Opting out is what makes the rest of this simple.

   Opt-in per table, because each must nominate its headline number (`.td--lead`).
   On a money product that is almost never the last column, and guessing wrong
   buries the figure being looked for. */
@media (max-width: 640px) {
  .ledger--rows .table-responsive { overflow-x: visible; }
  .ledger--rows table { width: 100%; max-width: 100%; }
  .ledger--rows thead { display: none; }          /* removed from layout, not clipped */
  .ledger--rows tbody tr {
    display: flex; flex-wrap: wrap; align-items: baseline;
    column-gap: .55rem; row-gap: .15rem;
    padding: 11px 13px;
  }
  .ledger--rows tbody tr + tr { border-top: 1px solid var(--bs-border-color); }
  .ledger--rows tbody td { display: inline-flex; align-items: baseline; gap: .3rem;
                           padding: 0; border: 0; overflow-wrap: anywhere; }
  /* LINE 1 — the name, and the number the page exists to report. */
  .ledger--rows tbody td.td--title {
    /* grow:0 — with flex-grow the title absorbed the free space and the lead figure
       sat next to it instead of at the edge, so the money did not form a column. */
    order: 1; flex: 0 1 auto; max-width: 68%; display: block;
    font-size: 1rem; font-weight: 700;
  }
  .ledger--rows tbody td.td--lead {
    order: 2; margin-left: auto; display: block; text-align: right;
    font-size: 1rem; font-weight: 700;
  }
  /* THE LINE BREAK. A flex container's own ::after IS a flex item, so this is a real
     break with no markup change — and it is what makes the auto margin work: without
     it the first secondary cell kept landing on line 1 beside the figure, so the money
     never formed a column (measured right edges scattered 141-364px). Orders are
     spaced to leave room for it: title 1, figure 2, BREAK 3, secondaries 4, controls 5. */
  .ledger--rows tbody tr::after { content: ""; flex: 0 0 100%; order: 3; height: 0; }
  /* LINE 2 — everything else, inline and quiet, each behind a tiny label. */
  .ledger--rows tbody td[data-label]:not(.td--title):not(.td--lead) {
    order: 4; flex: 0 0 auto; font-size: .8125rem; color: var(--bs-secondary-color);
  }
  .ledger--rows tbody td[data-label]:not(.td--title):not(.td--lead)::before {
    content: attr(data-label);
    font-size: .6875rem; font-weight: 600; text-transform: uppercase;
    letter-spacing: .06em; opacity: .85;
  }
  /* Derived detail stays a desk concern. Showing it inline put FOUR money figures
     on a row whose job is telling you one. */
  .ledger--rows tbody td.td--hide-phone { display: none; }
  /* Controls last. `:not(.td--title)` matters — the title carries no data-label
     either, and without it this rule captures the title and wins the tie. */
  /* The select checkbox LEADS the row — order 0, before the title. At order 5 it
     landed alone on a third line under the metadata, reading as an orphan rather
     than as the row's own control. */
  .ledger--rows tbody td:not([data-label]):not(.td--title):not(.text-end) {
    order: 0; align-self: center;
  }
  .ledger--rows tbody td.text-end { order: 6; margin-left: auto; }
  .ledger--rows .num { text-align: right; }
}

/* ---- Phone: the table becomes a list --------------------------------------
   At 390px the old table was 847px wide inside a 364px container, which put
   the money 291px off-screen — on the figure the whole product exists to tell
   you. A phone is a first-class place to run a business from, so the row
   restacks rather than scrolling sideways. */
@media (max-width: 640px) {
  /* .ledger--flat opts OUT of the restack: a 2-3 column widget table already
     fits at 390px, and restacking it doubles its height for nothing. */
  .ledger:not(.ledger--flat):not(.ledger--rows) .table-responsive { overflow-x: visible; }
  /* Genuinely removed from layout, not just clipped: a 1000px-wide absolutely
     positioned thead still measures as horizontal overflow. */
  .ledger:not(.ledger--flat):not(.ledger--rows) thead { display: none; }
  /* Row padding in px, not rem: at a scaled-up text size rem padding alone ate
     54px of a 440px screen before any content was drawn. */
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody tr { display: block; padding: 11px 13px; }
  /* …EXCEPT A COLLAPSED ONE, which must stay collapsed. The restack above is three classes
     deep and so outranks Bootstrap's own `.collapse:not(.show) { display: none }` (two), which
     meant every `<tr class="collapse">` inside a ledger table was permanently OPEN below 640px
     and closed on a desktop — the toggle looked broken on a phone and fine everywhere it was
     tested. On the service-accounts page that is a rename form AND 41 rights checkboxes forced
     open for every account on the list. Bootstrap drops `.collapse` for `.collapsing` mid-
     transition, so this rule stays out of the animation's way and only pins the resting state. */
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody tr.collapse:not(.show) { display: none; }
  .ledger:not(.ledger--flat):not(.ledger--rows) table { width: 100%; max-width: 100%; }
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody tr + tr { border-top: 1px solid var(--bs-border-color); }
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody tr + tr > td { border-top: 0; }
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody td { display: block; padding: 0; }
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody td[data-label] {
    display: flex; justify-content: space-between; align-items: baseline;
    gap: 1rem; padding-top: .2rem;
    /* WRAP, and let both halves shrink. A flex item's default min-width is its
       min-content, so at a scaled-up text size the label and the value together
       set a floor WIDER than the phone — the row then pushed the table past the
       edge even though it had restacked. The label drops onto its own line
       instead. `gap` in ch rather than rem so it does not scale with the text. */
    flex-wrap: wrap; gap: .25rem 2ch;
  }
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody td[data-label] > * { min-width: 0; }
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody td[data-label]::before {
    content: attr(data-label);
    font-size: .6875rem; font-weight: 600; text-transform: uppercase;
    letter-spacing: .06em; color: var(--bs-secondary-color);
  }
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody td.td--title { font-size: 1rem; padding-bottom: .15rem; }
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody td.td--hide-phone { display: none; }
  .ledger:not(.ledger--flat):not(.ledger--rows) .num { text-align: right; }
  /* A restacked row still cannot be narrower than its longest WORD, and this app is
     full of unbreakable ones — invoice numbers, emails, company names. At a scaled-up
     text size those pushed past the edge even restacked. Let them break rather than
     overhang; nothing here reads as prose where a mid-word break would hurt. */
  .ledger:not(.ledger--flat):not(.ledger--rows) tbody td { overflow-wrap: anywhere; }
  /* Fixed-width filter fields (min-width/max-width in rem, several of them set
     INLINE, which beats a class selector — hence !important) grow with the text
     scale and stop fitting. They may shrink; flex-wrap on the toolbar does the rest.
     Scoped to phone width, so the desktop toolbars keep their comfortable sizes. */
  main .form-control, main .form-select,
  main input[type="search"], main input[type="date"] { min-width: 0 !important; }
  /* The classic flex floor: a flex child will not shrink below its min-content
     unless told to. Every full-width search form in the app is one of these. */
  main .flex-grow-1, main form.flex-grow-1 { min-width: 0; }
  /* Expanded project rows: the document list inside a collapsed row is a flex row
     of a badge, a number and a total, and could not shrink either. */
  .proj-detail .list-group-item { flex-wrap: wrap; gap: .25rem; }
  .proj-detail .list-group-item > * { min-width: 0; }
}

/* ---- COMPENSATED VIEWPORT: the library breakpoints we cannot re-scope -------------------
   The generated section below mirrors OUR sub-tablet media blocks so they still apply when
   uiscale.js is compensating. It cannot do the same for Bootstrap: the grid, and every other
   vendor media query, is answering about the layout viewport, which in this state is roughly
   twice the hardware. On the owner's phone that laid the finance tiles four across a 440pt
   screen — 110pt each — and clipped a six-figure amount inside its own tile.

   So when compensation is ACTIVE and the physical screen is phone-sized, the grid stacks. Gated
   on .ui-compensated and not on width, deliberately: a real phone's media queries work, and a
   deliberate two-up grid there must keep its two columns. This only ever fires in the state that
   is already wrong. */
/* ONE RULE, because one rule is what carries it. The obvious companion —
   `[class*="col-"] { flex: 0 0 100% }` — was tried and is neither necessary nor
   sufficient here, and it matches Bootstrap's `.col-form-label` too, which is a
   layout bug waiting on an unrelated page. Verified by removing each in turn:
   without this line the finance tiles clip a six-figure amount; without the other
   nothing changes. */
/* STACK THE GRID, NOT EVERY ROW. This said `.row > *` and meant "the dashboard's
   col-12 col-lg-6 panels go one per line on a compensated phone" — but it is unscoped,
   so it also flattened the three-across stat row INSIDE the Platform widget, and the
   owner's phone showed 6 / 6 / 21 stacked vertically down a whole screen. It does not
   reproduce at a plain 390px viewport: the compensation class only exists when iOS text
   scaling is on, which is why the bug lived on his device and not in my measurements.
   An author who wrote col-4 or col-6 already chose a layout that survives narrow —
   honour it; col-12 (and anything with no explicit narrow column) still stacks. */
html.ui-compensated.ui-max-576 .row > *:not([class*="col-3"]):not([class*="col-4"]):not([class*="col-6"]) {
  width: 100%;
}

/* ==== BEGIN GENERATED: compensated-viewport copies — do not edit by hand ==== */
/* Generated by scripts/gen_compensated_css.py — see that file for why.
   These mirror the sub-tablet @media blocks above so they still apply when
   uiscale.js is compensating and the layout viewport is wider than the screen. */
:where(html.ui-max-768) .xscroll::after { border-right: 2px solid var(--brand-primary); }
:where(html.ui-max-576) main.container-fluid { padding-left: .75rem !important; padding-right: .75rem !important; }
:where(html.ui-max-576) .card-body { padding: .85rem; }
:where(html.ui-max-576) .ledger > .p-3 { padding: .85rem !important; }
:where(html.ui-max-576) .stat-card.p-3 { padding: 12px !important; }
:where(html.ui-max-576) .row.g-3 { --bs-gutter-x: 12px; --bs-gutter-y: 12px; }
:where(html.ui-max-576) h1.h3 { font-size: 1.3rem; }
:where(html.ui-max-576) .toolbar,
:where(html.ui-max-576) .page-actions,
:where(html.ui-max-576) .d-flex.gap-2:not(.flex-nowrap) { flex-wrap: wrap; }
:where(html.ui-max-576) .btn-group.flex-wrap-sm { flex-wrap: wrap; }
:where(html.ui-max-576) .dockstack { right: calc(1.1rem + env(safe-area-inset-right, 0px)); bottom: calc(1.6rem + env(safe-area-inset-bottom, 0px)); width: min(19rem, calc(100vw - 2.2rem)); }
:where(html.ui-max-576) .dock__panel { max-height: 42vh; }
:where(html.ui-max-576) .dock__scroll { max-height: 36vh; }
:where(html.ui-max-576) .dock__pill--wide { min-height: 3.25rem; padding: 0 1.1rem; font-size: .875rem; }
:where(html.ui-max-576) main > .d-flex.justify-content-between { flex-wrap: wrap; row-gap: .5rem; }
:where(html.ui-max-576) main > .d-flex.justify-content-between > .d-flex { flex-wrap: wrap; }
:where(html.ui-max-640) .hazard--band form { display: none !important; }
:where(html.ui-max-640) .hazard--band { padding: .45rem .85rem; }
:where(html.ui-max-640) .row-act { min-width: 44px; min-height: 44px; }
:where(html.ui-max-640) .ledger--rows .table-responsive { overflow-x: visible; }
:where(html.ui-max-640) .ledger--rows table { width: 100%; max-width: 100%; }
:where(html.ui-max-640) .ledger--rows thead { display: none; }
:where(html.ui-max-640) .ledger--rows tbody tr { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: .55rem; row-gap: .15rem; padding: 11px 13px; }
:where(html.ui-max-640) .ledger--rows tbody tr + tr { border-top: 1px solid var(--bs-border-color); }
:where(html.ui-max-640) .ledger--rows tbody td { display: inline-flex; align-items: baseline; gap: .3rem; padding: 0; border: 0; overflow-wrap: anywhere; }
:where(html.ui-max-640) .ledger--rows tbody td.td--title { order: 1; flex: 0 1 auto; max-width: 68%; display: block; font-size: 1rem; font-weight: 700; }
:where(html.ui-max-640) .ledger--rows tbody td.td--lead { order: 2; margin-left: auto; display: block; text-align: right; font-size: 1rem; font-weight: 700; }
:where(html.ui-max-640) .ledger--rows tbody tr::after { content: ""; flex: 0 0 100%; order: 3; height: 0; }
:where(html.ui-max-640) .ledger--rows tbody td[data-label]:not(.td--title):not(.td--lead) { order: 4; flex: 0 0 auto; font-size: .8125rem; color: var(--bs-secondary-color); }
:where(html.ui-max-640) .ledger--rows tbody td[data-label]:not(.td--title):not(.td--lead)::before { content: attr(data-label); font-size: .6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; opacity: .85; }
:where(html.ui-max-640) .ledger--rows tbody td.td--hide-phone { display: none; }
:where(html.ui-max-640) .ledger--rows tbody td:not([data-label]):not(.td--title):not(.text-end) { order: 0; align-self: center; }
:where(html.ui-max-640) .ledger--rows tbody td.text-end { order: 6; margin-left: auto; }
:where(html.ui-max-640) .ledger--rows .num { text-align: right; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) .table-responsive { overflow-x: visible; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) thead { display: none; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody tr { display: block; padding: 11px 13px; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody tr.collapse:not(.show) { display: none; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) table { width: 100%; max-width: 100%; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody tr + tr { border-top: 1px solid var(--bs-border-color); }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody tr + tr > td { border-top: 0; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody td { display: block; padding: 0; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody td[data-label] { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding-top: .2rem; flex-wrap: wrap; gap: .25rem 2ch; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody td[data-label] > * { min-width: 0; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody td[data-label]::before { content: attr(data-label); font-size: .6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--bs-secondary-color); }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody td.td--title { font-size: 1rem; padding-bottom: .15rem; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody td.td--hide-phone { display: none; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) .num { text-align: right; }
:where(html.ui-max-640) .ledger:not(.ledger--flat):not(.ledger--rows) tbody td { overflow-wrap: anywhere; }
:where(html.ui-max-640) main .form-control,
:where(html.ui-max-640) main .form-select,
:where(html.ui-max-640) main input[type="search"],
:where(html.ui-max-640) main input[type="date"] { min-width: 0 !important; }
:where(html.ui-max-640) main .flex-grow-1,
:where(html.ui-max-640) main form.flex-grow-1 { min-width: 0; }
:where(html.ui-max-640) .proj-detail .list-group-item { flex-wrap: wrap; gap: .25rem; }
:where(html.ui-max-640) .proj-detail .list-group-item > * { min-width: 0; }
/* ==== END GENERATED ==== */
