/* ============================================================================
   kit.css - the UI Template Kit stylesheet

   This is DCM Industries ERP's app.css with every colour literal retired into a
   var() role. Structurally it is the same file, section for section, so a fix
   found in one is portable to the other by hand.

   IT DEFINES NO COLOURS. Load a theme ahead of it:

       <link rel="stylesheet" href="assets/css/tokens.css">
       <link rel="stylesheet" href="assets/css/themes/indigo.css">
       <link rel="stylesheet" href="assets/css/kit.css">

   The only literals left in here are #fff and #000, which are not theme colours
   but the ends of the range - and the rgba() shadows and scrims, which are
   deliberately colourless.

   Light mode only, as the source is. The token layer is built dark-ready
   (see README) but no dark theme ships.

   ⚠ HAND-PATCHED SINCE GENERATION — 2026-09-08, 2026-09-09 and 2026-09-10. A regeneration
     DISCARDS these unless they are re-applied. Each is marked with
     `HAND-PATCH <date>` at the rule itself.

   2026-09-09 — Shree Radhe Creation:
       §15g .cell-age / .act-pick — the days-in-stock badge that rides on the
            date column, and the row checkbox that feeds a bulk price change.
       §15h .sb-totals / .sb-total-row / .sb-cam — the till's totals block,
            sticky to the bottom on a phone, and the QR camera preview.
       §15i .seg-tabs / .seg-tab — the Sale screen's All / Open / Completed /
            Returns strip, scrolling sideways inside itself at 375px.
       §15j .os-* / .hb-* / .dash-donut / .dk-* — the dashboard's Old Stock
            card, the horizontal category bars and the stock donut.
       §15k .bottom-nav / .bnav-item — the phone's primary navigation (D6),
            and .table-app.as-cards, which turns a wide grid into one card per
            row below 768px using the column labels the server already sends.
       §5  .empty-in — the empty / error / loading message inside a grid was
            centred across a colspan wider than a phone, so it rendered
            OFF-SCREEN. kit.js's three renderTable* helpers now wrap their text
            in it. Also a kit.js hand-patch of the same date.

   2026-09-10 — Shree Radhe Creation:
       §15l .cfg-bands / .cfg-band / .cfg-band-sep / .cfg-bands-lbl — the
            Configuration screen's four old-stock band boxes, one stored value
            entered as four inputs. The only place in the kit where two inputs
            deliberately share a row at 375px.
       §15m .modal-warn — one amber strip for a rule a dialog must state before
            its primary button is pressed. First use: the return window.
       §10 .opt-list-page — the curated list used as a screen rather than
            inside a dialog, so it is not capped at 300px.
       §15g .cell-money / .cell-money-cr — a right-aligned, tabular money
            column, and the "Cr" suffix that carries a negative balance's
            meaning without colouring it. Driven off the registry's `money`
            type, so every later screen with an amount in it gets this free.

   2026-09-08:

       §12  .auth-brand-inner / .auth-logo — the sign-in identity block is
            CENTRED. It was left-aligned inside a centred box, which read as
            off-centre. .auth-points stays left.
       §9   .card-header-custom h6 i — the gap that was missing when a card
            header carried an icon. Card headers are iconless as a rule; the
            reference DASHBOARD is the one documented exception, so this rule
            is live and load-bearing again.
       §9   .modal-hd-title — accepts an <h6> as well as a <span>, so views and
            confirmDialog() emit the same element.
       §7   .fl-hint.fl-hint-note — the icon + emphasis hint variant.
       §12a .pf-* — the My Profile Account card: avatar, identity, stat pair.
       §5   .muted-note — the small grey "(you)" beside a name in a list.
       §10  .role-row — the selectable row in Role Management's master-detail
            pair, now that choosing a role loads the matrix beside it.
       §10  .perm-mod-hd — every matrix row carries its own checkbox on the
            left, module rows included, so a whole row is granted in one click.
       §2   .user-avatar img — the sidebar pill may carry a photo, not only an
            initial.
       §17  .kpi-8 — an eighth KPI tone, off the new --series-8 token.
       §16  .dc-pct — the per-bar percentage now drops below 992px, not 768px.
       §16  .dc-plot.dc-nopct — the same rule measured per COLUMN, for the many-
            columns-on-a-wide-screen case no viewport query can see.
       §15g .cell-actions — the actions column had NO WIDTH; it is now derived
            from --act-n, the button count the markup declares.
       §15g .supplier-bar / .tag-* / .fl-switch / .imp-plan — the Supplier master.
            At 768 a month of days gives each column 19.7px and a "126%" needs
            24: sixteen captions in twenty-six were clipped to a stump.
       §16  .dash-chart.dc-thin .dc-lbl — the thinned bar chart's day labels
            may overflow their own 9px column, or every two-digit day is cut in
            half at 375px beside a blank neighbour.
       §0   [hidden] { display: none !important } — the ONE rule that makes
            `el.hidden = true` work on an element carrying any author display.
            It retires a trap this file had already patched locally six times.

   Breakpoints:  <576 phone . 576-767 large phone . 768-991 tablet
                 992-1199 small laptop . >=1200 desktop
   ========================================================================== */

/* The :root block that lived here is now assets/css/tokens.css.
   Every role name it declared survives, so the rules below are unchanged. */

/* ==========================================================================
   0. Base
   ========================================================================== */
body {
  font-family: 'Segoe UI', system-ui, sans-serif;
  background: var(--page-bg);
  color: var(--body);
  margin: 0;
  overflow-x: hidden;                 /* the page body never scrolls sideways */
}

/* ⚠ DO NOT PUT `overflow-x: hidden` OR `clip` ON `html`. It was tried on
   2026-08-25 to stop a phone panning sideways, and it BROKE VERTICAL SCROLLING
   ON EVERY PAGE - measured: a 1520px document in a 667px viewport with
   `window.scrollY` stuck at 0 and `scrollingElement.scrollTop` refusing to move.

   The mechanism: `body` already carries `overflow-x: hidden`, which is what the
   viewport's scrolling is propagated FROM while the root stays fully `visible`.
   Give the root an overflow of its own and that propagation stops - the root
   becomes the scroll container, and Chrome resolves its `overflow-y: visible`
   against the clipped x axis as clipped too. The page then scrolls in neither
   direction, on every screen in the app, and nothing looks broken enough to
   investigate.

   The horizontal pan is dealt with where it actually starts - see
   `overscroll-behavior-x` on .table-wrap. */

html { -webkit-text-size-adjust: 100%; }
button, .btn, [onclick] { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
.min-w-0 { min-width: 0; }

/* HAND-PATCH 2026-09-08 — [hidden] ACTUALLY HIDES, EVERYWHERE.

   The browser's own rule for [hidden] is `display: none` in the USER-AGENT
   sheet, and ANY author declaration of `display` outranks it. So the instant an
   element carries `.d-flex`, `.btn`, `.form-check` or any other Bootstrap class
   that sets a display, `el.hidden = true` becomes a no-op — the attribute is
   set, the element stays on screen, and nothing anywhere reports a fault.

   This file had already paid for that SIX separate times, each patched locally:
   .f-count · .opt-ref · .perm-matrix tr · .calc-nav-empty · .calc-tab ·
   .calc-panel · .calc-msg · .calc-stack · .rp-fcount · .rp-chips. It was about
   to be paid for a seventh, eighth, ninth and tenth — #pmTools (.d-flex),
   #uStatusWrap and #rStatusWrap (.form-check), and the profile's Remove button
   (.btn) are all toggled with el.hidden and all carry an author display.

   One rule ends the whole class of bug. `!important` is what makes it beat an
   author display; the local rules above and below are left in place, harmless
   and now redundant.

   ⚠ THE COROLLARY: toggle visibility with `el.hidden`, never with
     `el.style.display` — an inline style loses to this rule and the element
     stays hidden. modalError() is the one place that uses style.display, and it
     never sets the hidden attribute, so the two do not meet. */
[hidden] { display: none !important; }

/* ==========================================================================
   1. Sidebar - overlays the page at EVERY width; never reserves space
   ========================================================================== */
.sidebar {
  width: var(--sidebar-w);
  height: 100vh;
  height: 100dvh;                     /* accounts for mobile browser chrome */
  position: fixed; top: 0; left: 0; z-index: 300;
  background: linear-gradient(180deg, var(--ink-900) 0%, var(--ink-700) 60%, var(--ink-600) 100%);
  display: flex; flex-direction: column;
  transform: translateX(-100%); transition: transform .25s ease;
  -webkit-overflow-scrolling: touch;
}
.sidebar.open { transform: translateX(0); }

.sidebar-overlay {
  display: none; position: fixed; inset: 0; z-index: 299;
  background: rgba(0,0,0,.35); cursor: pointer;
}
.sidebar-overlay.show { display: block; }

.sidebar-brand {
  display: flex; align-items: center; gap: .6rem;
  padding: 1.5rem 1.25rem 1rem;
  border-bottom: 1px solid rgba(255,255,255,.08);
}
.logo-box {
  width: 40px; height: 40px;
  border-radius: 5px; padding: 5px;
  background: #fff; object-fit: cover; flex-shrink: 0;
  box-shadow: 0 0 24px var(--accent-glow);
}
.brand-name { color: #fff; font-weight: 700; font-size: .9rem; margin: 0; line-height: 1.2; }
.brand-sub  { color: rgba(255,255,255,.45); font-size: .72rem; }

.nav-section {
  padding: 1rem 0 0; flex: 1; overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(255,255,255,.22) transparent;
  -webkit-overflow-scrolling: touch;
}
.nav-section:hover { scrollbar-color: rgba(255,255,255,.35) transparent; }
.nav-section::-webkit-scrollbar { width: 5px; }
.nav-section::-webkit-scrollbar-track { background: transparent; }
.nav-section::-webkit-scrollbar-thumb { background: rgba(255,255,255,.22); border-radius: 3px; }
.nav-section::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.35); }

.nav-label {
  font-size: .65rem; letter-spacing: 1.2px;
  color: rgba(255,255,255,.35); font-weight: 600;
  padding: .5rem 1.25rem .25rem; text-transform: uppercase;
}
.nav-item {
  display: flex; align-items: center; gap: .6rem;
  padding: .55rem 1.25rem;
  color: rgba(255,255,255,.65); text-decoration: none;
  font-size: .875rem; font-weight: 500;
  border-left: 3px solid transparent; transition: all .15s;
}
.nav-item:hover { color: #fff; background: rgba(255,255,255,.07); }
.nav-item.active { color: #fff; background: rgba(255,255,255,.12); border-left-color: var(--accent-soft); }
.nav-item i { font-size: 1rem; width: 20px; text-align: center; flex-shrink: 0; }
.nav-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Collapsible sub-menu */
.nav-group-hd {
  display: flex; align-items: center; gap: .6rem;
  padding: .55rem 1.25rem;
  color: rgba(255,255,255,.65); font-size: .875rem; font-weight: 500;
  border-left: 3px solid transparent; cursor: pointer;
  transition: all .15s; user-select: none;
}
.nav-group-hd:hover { color: #fff; background: rgba(255,255,255,.07); }
.nav-group-hd i:first-child { font-size: 1rem; width: 20px; text-align: center; }
.nav-group-arr { margin-left: auto; font-size: .7rem; transition: transform .2s; opacity: .5; }
.nav-group.open .nav-group-arr { transform: rotate(180deg); opacity: 1; }
.nav-group-body { display: none; background: rgba(0,0,0,.15); }
.nav-group.open .nav-group-body { display: block; }
.nav-child a {
  display: flex; align-items: center; gap: .6rem;
  padding: .48rem 1.25rem .48rem 2.35rem;
  color: rgba(255,255,255,.55); text-decoration: none;
  font-size: .82rem; font-weight: 500;
  border-left: 3px solid transparent; transition: all .15s;
}
.nav-child a:hover { color: #fff; background: rgba(255,255,255,.06); }
.nav-child a.active { color: #fff; background: rgba(255,255,255,.10); border-left-color: var(--accent-soft); }
.nav-child a i { font-size: .9rem; width: 18px; text-align: center; }

.sidebar-footer { padding: 1rem 1.25rem; border-top: 1px solid rgba(255,255,255,.08); }
.user-pill {
  display: flex; align-items: center; gap: .6rem;
  padding: .5rem; background: rgba(255,255,255,.06); border-radius: 10px;
}
.user-avatar {
  width: 32px; height: 32px;
  background: linear-gradient(135deg, var(--accent), var(--purple));
  border-radius: 8px;
  display: flex; align-items: center; justify-content: center;
  color: #fff; font-size: .75rem; font-weight: 700; flex-shrink: 0;
}
/* HAND-PATCH 2026-09-08 — the pill's avatar may be a photo, not only an initial.
   ⚠ overflow: hidden on the tile plus object-fit: cover on the image. Without
     the cover a portrait photo letterboxes inside a square and the gradient
     shows through two edges of it, which looks like a rendering fault rather
     than a photograph. */
.user-avatar { overflow: hidden; }
.user-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.user-pill .u-name { color: #fff; font-size: .8rem; font-weight: 600; line-height: 1.25; }
.user-pill .u-role { color: rgba(255,255,255,.45); font-size: .7rem; }
.signout-btn {
  display: block; width: 100%; margin-top: .5rem; text-align: center;
  background: rgba(255,255,255,.1); color: rgba(255,255,255,.8);
  border: 1px solid rgba(255,255,255,.15); border-radius: 8px;
  font-size: .82rem; font-weight: 600; padding: .28rem .5rem; cursor: pointer;
  text-decoration: none;
}
.signout-btn:hover { background: rgba(255,255,255,.16); color: #fff; }

/* ==========================================================================
   2. Layout, topbar & page body
   ========================================================================== */
/* Overlay at EVERY width - the sidebar slides over the page and never reserves
   space, so the content width is identical whether the menu is open or shut.
   Pinning it on desktop would reflow every table and chart on each toggle. */
.main { margin-left: 0; min-height: 100vh; }
/* The sticky offset for a column that has to sit clear of the sticky topbar -
   the calculator rail is the only user (app.css §15d). Measured off the shipped
   shell: the topbar is 65px from 1200px up. It is a token rather than a literal
   so the two cannot drift apart in different files. */
:root { --calc-rail-top: calc(var(--seed-topbar-h) + 1rem); }

.topbar {
  background: #fff; border-bottom: 1px solid var(--card-border);
  padding: .75rem 1.5rem;
  display: flex; align-items: center; justify-content: space-between; gap: .6rem;
  position: sticky; top: 0; z-index: 50;
}
.topbar h5, .page-title {
  margin: 0; font-size: 1.05rem; font-weight: 700; color: var(--ink-900); line-height: 1.25;
}
.page-sub { margin: 0; font-size: .75rem; color: var(--muted); }
.page-body { padding: 1rem; }

/* ⚠ THE MASTER PICKER IS GONE (2026-08-31). `.master-pick`, `.master-pick-btn`,
   the `.mo-tick` rules and the whole `.topbar:has(.master-pick)` media block
   went with it - the master is chosen from a rail on the screen itself now
   (`.mnav-*`, section 15f), because the masters are grouped and a dropdown
   cannot show a heading.

   ⚠ THAT MEDIA BLOCK IS WHY MASTERS WAS THE ONE SCREEN WITH A TWO-ROW TOPBAR ON
   A PHONE: it gave the right-hand cluster `width: 100%`, which wrapped the row
   whatever the left half was doing. With it gone that bar is one row like every
   other screen's. Do not reintroduce a `:has()` topbar rule for a control that
   is no longer in the topbar.

   Bulk Upload is all that is left up there. It acts on whichever master the rail
   has selected, which is exactly why it must not sit inside the control that
   selects one. Its label drops below 768px so the icon still fits beside the
   page title. */
.master-menu { min-width: 210px; padding: .3rem; }
.master-menu .dropdown-item {
  display: flex; align-items: center; gap: .55rem;
  border-radius: 8px; font-size: .85rem; padding: .45rem .55rem;
}
.master-menu .dropdown-item i:first-child { font-size: .95rem; color: var(--muted); width: 1.1em; }
.master-menu .mo-label { flex: 1 1 auto; }
.master-bulk-btn { display: inline-flex; align-items: center; gap: .4rem; white-space: nowrap; }
.master-bulk-btn .mp-caret { font-size: .7rem; opacity: .75; }
.master-bulk-btn[aria-expanded="true"] .mp-caret { transform: rotate(180deg); }

/* ==========================================================================
   3. Cards
   ========================================================================== */
.section-card {
  background: var(--card-bg); border-radius: var(--radius);
  border: 1px solid var(--card-border); box-shadow: var(--shadow-card);
}
.section-card + .section-card { /* margin-top: 1rem; */ }
.card-header-custom {
  padding: 1rem 1.25rem; border-bottom: 1px solid var(--line);
  display: flex; align-items: center; justify-content: space-between;
  gap: .6rem; flex-wrap: wrap;
}
.card-header-custom h6 { margin: 0; font-weight: 700; color: var(--ink-900); font-size: .9rem; }

/* HAND-PATCH 2026-09-08 — the gap a card-header icon never had.
   ⚠ CARD HEADERS ARE ICONLESS AS A RULE (2026-09-08, the user's instruction: a
     section glyph has to be invented for every new card, most business sections
     have no obvious one, and a wrong-but-present icon is worse than none). The
     rule fixes a real defect: `<h6><i …></i>Change Password</h6>` rendered as
     `⚭Change Password`, flush, with no space at all.

   ⚠ AND IT IS LIVE AGAIN — the reference dashboard is the ONE documented
     exception (2026-09-08, the user's call), so seven card headers now depend on
     this rule. Do not delete it as dead.

   ⚠ margin-right, NOT a flex `gap`, and that is deliberate. `.dash-head` makes
     the `h6` ITSELF `display: flex`, which blockifies the <i> into a flex item —
     a margin still applies there, and it also survives the ordinary non-flex
     header. A `gap` would work in one context and not the other. (The opposite
     call to T10, where the label can vanish and a margin would be left behind.) */
.card-header-custom h6 i { margin-right: .45rem; color: var(--accent); }
.card-header-custom .head-sub { font-size: .72rem; color: var(--muted); font-weight: 400; }

/* ⚠ THE KPI TILES WERE REMOVED ON 2026-08-30, ON THE USER'S INSTRUCTION.
   `.kpi-card` / `-label` / `-value` / `-sub` / `-link` / `.kpi-row-7` are all
   gone with them, and nothing outside the dashboard ever used them.

   Why, so it is not rebuilt by reflex: three of the seven tiles were figures the
   "This Period Vs Previous" table already carried as rows (Efficiency, Entries)
   or now does (Working Days, moved there as the first row), and the other four
   were the ACTIVE column of Master Data Usage - literally the same
   `COUNT(*) WHERE status='active'` query, printed twice on one screen.

   ⚠ DO NOT WRITE MARKUP AGAINST THEM. If a headline strip is ever wanted again,
   the thing to check first is whether the figure is already a row somewhere on
   this screen - that is what made these duplicates rather than a summary. */

/* ==========================================================================
   4. Buttons & badges
   IMS applied these inline on every page; same values, named once.
   ========================================================================== */
.btn-soft {                                   /* topbar / secondary primary action */
  background: var(--accent-tint); color: var(--violet-ink); border: none;
  font-weight: 600; border-radius: var(--radius-field);
}
.btn-soft:hover, .btn-soft:focus { background: var(--purple-200); color: var(--violet-ink); }
.btn-save {                                   /* dialog confirm */
  background: var(--violet-ink); color: #fff; border: none;
  border-radius: 8px; padding: .35rem 1.2rem; font-weight: 600;
}
.btn-save:hover, .btn-save:focus { background: var(--purple-900); color: #fff; }
.btn-del {
  background: var(--danger); color: #fff; border: none; border-radius: 8px;
  padding: .375rem .75rem; font-size: .875rem; cursor: pointer;
  display: inline-flex; align-items: center; gap: .3rem;
}
.btn-del:hover { background: var(--danger-ink); color: #fff; }

/* ⚠ EVERY EXPORT / DOWNLOAD CONTROL IN THE APP, AND THE ONLY GREEN BUTTON.
   One look, one icon, one word: `bi-download` + the label `Export`, never a
   format name. "Export Excel" named the library that wrote the file, which is
   the one thing nobody opening it needs to know, and it made Export the widest
   control in a card header on a phone.

   ⚠ THE WASH, NOT A SOLID FILL. Solid --ok (var(--ok)) under white text measures
   3.30:1 and fails AA at this size; --ok-bg under --ok-ink measures 4.57:1 and
   passes, and the hover pair 7.52:1. It is also the shape .btn-soft already
   uses - a tint behind its own dark ink - so Export reads as a sibling of Add
   rather than as a foreign control. This green is the app's only one, already
   shipping as .st-active and .act-ok.

   ⚠ INLINE-FLEX + gap, AND THE ICON CARRIES NO me-1. Below 576 the label span
   is display:none, and a gap collapses with it where a margin does not - with
   me-1 the glyph sat 4px left of its own centre on every phone.

   ⚠ THE LABEL IS HIDDEN BELOW 576, SO title + aria-label ARE MANDATORY. The
   icon is then the whole affordance and nothing else announces the button -
   the rule .action-btn carries directly below, which export buttons never got.

   ⚠ NO `display` IS SET ON A NARROWER SELECTOR. masters.js hides and shows its
   button with an inline style.display, and an inline none must keep beating
   this rule; clearing it back to '' is what falls through to inline-flex. */
.btn-export {
  background: var(--ok-bg); color: var(--ok-ink); border: none;
  font-weight: 600; border-radius: var(--radius-field);
  display: inline-flex; align-items: center; gap: .35rem;
}
.btn-export:hover, .btn-export:focus { background: var(--ok-200); color: var(--ok-900); }

/* Icon-only, fixed square. Row actions carry no label: one icon + one tint per
   action, identical on every screen, so the Actions column stays narrow and the
   row height is set by the text, not by the buttons. Every one must still carry
   title + aria-label - the icon is the whole affordance. */
.action-btn {
  width: 28px; height: 28px; padding: 0; flex-shrink: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border: none; border-radius: 8px;
  font-size: .8rem; cursor: pointer; line-height: 1;
}
.action-btn:disabled { opacity: .45; cursor: not-allowed; }
.act-edit { background: var(--info-bg); color: var(--info-700); }
.act-edit:hover { background: var(--info-200); }
.act-del  { background: var(--danger-bg); color: var(--danger); }
.act-del:hover  { background: var(--danger-100); }
/* Print opens a controlled document, not an edit - grey rather than a colour, so
   the two coloured buttons beside it stay the ones that change something. */
.act-print { background: var(--n-100); color: var(--n-600); }
.act-print:hover { background: var(--n-250); }
.act-perm { background: var(--info-bg); color: var(--info-ink); }
.act-perm:hover { background: var(--info-200); }
.act-ok   { background: var(--ok-bg); color: var(--ok-ink); }
.act-warn { background: var(--warn-bg); color: var(--warn-ink); }

.status-badge {
  border-radius: 20px; padding: 2px 10px;
  font-size: .72rem; font-weight: 600; display: inline-block; white-space: nowrap;
}
/* IMS lifecycle set - kept for the stock report and future operational screens */
.st-store        { background: var(--accent-tint); color: var(--violet-ink); }
.st-floor        { background: var(--info-bg);     color: var(--info-ink); }
.st-issued       { background: var(--ok-bg);       color: var(--ok-ink); }
.st-scrapped     { background: var(--purple-50); color: var(--purple-700); }
.st-direct-scrap { background: var(--amber-100); color: var(--amber-700); }
.st-lost         { background: var(--danger-bg); color: var(--danger-ink); }
.st-direct-lost  { background: var(--series-pink-bg); color: var(--series-pink-ink); }
.st-broken       { background: var(--warn-bg); color: var(--warn-ink); }
/* Account/role set - the ERP admin screens */
.st-active   { background: var(--ok-bg); color: var(--ok-ink); }
.st-inactive { background: var(--n-100); color: var(--muted); }
.st-role     { background: var(--accent-tint); color: var(--violet-ink); }
.st-info     { background: var(--info-bg); color: var(--info-ink); }
.st-warn     { background: var(--warn-bg); color: var(--warn-ink); }
.st-danger   { background: var(--danger-bg); color: var(--danger-ink); }

/* HAND-PATCH 2026-09-08 — the "(you)" beside your own name in a list.
   ⚠ NOT a .status-badge, deliberately. Beside a role badge a second pill reads
     as a second STATUS — something about the account — when all it says is
     which row is yours. Plain small grey text says that and nothing more. */
.muted-note { font-size: .72rem; color: var(--faint); font-weight: 400; }

.perm-key {
  font-family: Consolas, 'Courier New', monospace; font-size: .7rem;
  background: var(--n-100); border: 1px solid var(--card-border);
  color: var(--n-600); border-radius: 6px; padding: 1px 6px; white-space: nowrap;
}
.avatar-sm {
  width: 36px; height: 36px; border-radius: 50%; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: .72rem; font-weight: 700; color: #fff;
}
.group-label {
  font-size: .68rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .8px;
  margin-bottom: .4rem; display: flex; align-items: center; gap: .3rem;
}

/* ==========================================================================
   5. Tables - IMS look; cards below 768 so nothing scrolls sideways
   ========================================================================== */
.table-app { font-size: .83rem; margin-bottom: 0; width: 100%; }
.table-app thead th {
  background: var(--n-50); color: var(--muted); font-weight: 600; font-size: .78rem;
  border-bottom: 1px solid var(--card-border); white-space: nowrap;
  padding: .75rem 1rem;
}
/* Sortable header. The icon is always present, so the row never reflows on
   click and a sortable column is discoverable before you hover it. */
.table-app thead th.th-sort { cursor: pointer; user-select: none; }
.table-app thead th.th-sort:hover { background: var(--n-150); color: var(--body); }   /* was var(--text) - undefined in app.css */
/* --accent, not --primary: there has never been a --primary in :root, so both
   of these rules resolved to nothing and a sorted column looked exactly like an
   unsorted one apart from its arrow. Found 2026-08-04 building the Production
   grid, which sorts on every column. */
.table-app thead th.th-sort:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.table-app thead th.th-sort i { opacity: .35; font-size: .7rem; }
.table-app thead th.th-sort:hover i { opacity: .7; }
.table-app thead th.th-sort-on { color: var(--accent); }
.table-app thead th.th-sort-on i { opacity: 1; }

.table-app tbody td { vertical-align: middle; padding: .3rem 1rem; border-bottom: 1px solid var(--line); }
.table-app tbody tr:last-child td { border-bottom: none; }
.table-app tbody tr:hover { background: var(--n-25); }
/* The grid is the one place in this app where a horizontal scrollbar is normal
   rather than a fault, so it is made to look deliberate instead of borrowed:
   thin, tinted to the field border, and matching the sidebar's treatment. */
.table-wrap {
  overflow-x: auto; -webkit-overflow-scrolling: touch;
  scrollbar-width: thin; scrollbar-color: var(--field-border) transparent;

  /* ⚠ THE GESTURE STOPS AT THE TABLE - it does not chain on to the page
     (2026-08-25, the user's report: "header is able to scroll slightly
     horizontal where as it must be fix only table should scroll").

     Without this, a sideways swipe that reaches the end of the table carries on
     into the viewport, and on a touch device the whole page pans a little way -
     taking the sticky topbar and the filter bar with it, which is exactly what
     the user photographed. It is a SCROLL-CHAINING effect, not an overflowing
     element: nothing on the page is too wide (measured, docScrollWidth === 375
     at 375), which is why it never showed on a desktop where the wheel does not
     chain.

     ⚠ `-x` ONLY, AND `contain` RATHER THAN `none`. Vertical chaining is what
     lets a swipe that starts over the grid go on to scroll the PAGE, which is
     the ordinary way anybody reads a long report on a phone - taking that away
     would trap the finger inside the table. `contain` stops the chain without
     disabling the grid's own overscroll effects.

     ⚠ AND NOT `overflow-x: hidden/clip` ON `html`, which was the first attempt.
     See the note beside the `html` rule at the top of this file: it stops the
     pan and breaks vertical scrolling on every page in the app. */
  overscroll-behavior-x: contain;
}
.table-wrap::-webkit-scrollbar { height: 8px; }
.table-wrap::-webkit-scrollbar-track { background: transparent; }
.table-wrap::-webkit-scrollbar-thumb { background: var(--field-border); border-radius: 4px; }
.table-wrap::-webkit-scrollbar-thumb:hover { background: var(--faint); }

.row-count { font-size: .78rem; color: var(--muted); }
/* HAND-PATCH 2026-09-09 — LEFT, not centre, and the message sticks.
   ⚠ IT WAS CENTRED, AND THAT PUT IT OFF-SCREEN. The cell spans every column,
     so on a 10-column grid it is ~890px wide while a phone is 375. Centring
     placed "No customers yet" at x≈445 — inside .table-wrap's horizontal
     scroll but outside its visible area — so the screen showed an empty white
     box and a pager reading "Showing 0-0 Of 0", with the one sentence that
     explains it hidden until you scrolled sideways.
   ⚠ STICKY ALONE DOES NOT FIX IT, which is the trap worth remembering:
     position:sticky stops an element scrolling OUT to the left, it never pulls
     one back from the right. Measured after trying it — the span stayed at
     x=402. Left-aligning is what puts it at x=0; the sticky then KEEPS it there
     once the grid is scrolled sideways.
   ⚠ Centring is given up deliberately. A message aligned with the first
     column reads perfectly well on a wide screen, and being visible on a narrow
     one is not a trade. */
.empty-row {
  text-align: left; color: var(--muted);
  padding: 2rem 1rem !important; font-size: .83rem;
}
.empty-in { position: sticky; left: 1rem; display: inline-block; max-width: 100%; }

/* Row actions. A hook on five screens since 2026-07-30 with no rule behind it -
   the buttons only ever sat on one line because the cell happened to be wide
   enough. Below, .table-pin makes that cell the fixed anchor the whole grid
   scrolls under, and a fixed anchor that can reflow is not fixed. */
/* The width now comes from the count of buttons — see §15g. */

/* ── Grid modifiers - both opt-in ─────────────────────────────────────────────
   .table-nowrap  every cell on one line at EVERY width, not just below 768.
                  A wrapped cell makes its row two or three lines tall while its
                  neighbours stay one, and the eye loses the row it was reading;
                  the table grows past the card instead and .table-wrap scrolls
                  it. Opt-in, because a column of prose - the Activity Log's
                  Description - would push the table several screens wide and
                  turn its own scrollbar into a thing nobody can aim.

   .table-pin     from 768px up, holds the LAST cell of every row (the Actions
                  cell) against the right edge of .table-wrap while the data
                  scrolls underneath it. Ten columns of real machine and part
                  names do not fit a laptop, and Edit and Delete are what the
                  register is opened to reach - scrolling right to find them,
                  then left to read which row you are on, is the failure this
                  prevents.

                  BELOW 768 the pin is switched off and the whole table moves as
                  one piece, on the user's instruction (2026-08-05). It is the
                  right call: 125px of pinned column out of a 375px screen leaves
                  the data 250px, and a machine name needs more than that on its
                  own. A phone scrolls the table; a desktop scrolls the data.

   Both are set on the <table>, so a screen opts in by class and the engine
   behind it never has to know. */
.table-nowrap thead th,
.table-nowrap tbody td { white-space: nowrap; }

@media (min-width: 768px) {
  /* .cell-actions, never :last-child. The empty state is one td with a colspan
     and it IS the last child - pinning it right would park "No production
     entries match these filters" against the edge of a table it is supposed to
     be centred across. */
  .table-pin .cell-actions {
    position: sticky; right: 0; z-index: 2;
    background: var(--card-bg);
  }
  .table-pin thead .cell-actions { background: var(--n-50); }
  /* The row highlight has to reach the pinned cell too: the cell paints its own
     opaque background over tr:hover, and a row that lights up everywhere except
     the column your cursor is heading for looks broken. */
  .table-pin tbody tr:hover .cell-actions { background: var(--n-25); }

  /* Hairline first, so the column reads as a column even on a table that fits
     and never scrolls. */
  .table-pin .cell-actions::before {
    content: ''; position: absolute; top: 0; bottom: 0; left: 0;
    width: 1px; background: var(--card-border);
  }
  /* The shadow is the only part JS owns: CSS cannot know whether anything is
     currently hidden underneath. .is-pinned is set by syncPin() in app.js while
     there is more table to the left of the pin, and removed at the far right, so
     the depth appears exactly when there is depth. */
  .table-wrap.is-pinned .table-pin .cell-actions {
    box-shadow: -9px 0 12px -8px var(--shadow-ink);
  }
}

/* Every table stays a TABLE at every width and scrolls sideways inside
   .table-wrap. The phone card layout (each row a stacked label/value list) was
   removed on 2026-08-02 at the user's instruction: a record is far easier to
   read as one line you scan across than as a tall list you scroll down, and the
   two layouts meant every screen had to be checked twice.

   Cells must NOT wrap - a wrapped cell makes the row two or three lines tall and
   the grid stops reading as a grid.

   The PAGE still never scrolls sideways (R9): the overflow belongs to
   .table-wrap, which is the only element allowed to scroll horizontally. */
@media (max-width: 767px) {
  .table-app { font-size: .78rem; }
  .table-app tbody td,
  .table-app thead th { white-space: nowrap; }
  .table-app tbody td { padding: .45rem .6rem; }
  .table-app thead th { padding: .55rem .6rem; }
}

/* ==========================================================================
   6. Filter bar & pagination
   ========================================================================== */
.filter-bar { padding: 1rem; display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
.filter-bar .form-control, .filter-bar .form-select { font-size: .82rem; }

/* Opt-in modifier: below 576 every control in the bar takes a full row.
   Two 170px selects DO fit side by side on a 375px screen, but only by leaving
   each too narrow to read "Notification Settings" in - and a filter whose
   options you cannot read is not a filter. The date trigger stretches with them
   so the bar reads as a stack rather than one wide control and two ragged ones.

   A modifier rather than a rule on .filter-bar itself: the masters and users
   bars mix fields with buttons, and a full-width button is not an improvement.
   Applied on the Activity Log, whose bar is three fields and nothing else. */
@media (max-width: 575px) {
  .filter-stack > * { flex: 1 1 100%; max-width: 100% !important; }
  .filter-stack .dr-wrap { display: block; }
  .filter-stack .dr-trigger { width: 100%; justify-content: space-between; }

  /* Not part of .filter-stack: any bar holding a date trigger wants this,
     including the ones that mix fields with buttons and so must not stack
     wholesale. The rule above stays for .filter-stack's own use; this one
     applies to every bar and is harmless where both match. */
  .filter-bar .dr-wrap { flex: 1 1 100%; }
  .filter-bar .dr-trigger { width: 100%; justify-content: space-between; }
}

/* ── Filter side panel ────────────────────────────────────────────────────────
   A screen with eight filters cannot put all eight in the bar: the bar wraps to
   three rows and the four that matter stop being findable. The ones used on
   every visit stay on screen; the rest live here, one click away, behind a
   trigger that CARRIES A COUNT - a filter you cannot see is a filter that makes
   the grid look empty for no reason.

   Bootstrap's offcanvas, which is already in the vendored bundle, plus the
   .modal-hd / .modal-footer this app already uses - both of which are
   standalone and do not require a .modal-content around them. Building a
   drawer from scratch would have been a third dialog idiom to maintain.

   Nothing inside commits until Apply, same as the date range picker. */
.filter-panel { width: 340px; }
.filter-panel-body {
  display: flex; flex-direction: column; gap: 1rem;
  padding: 1.15rem; background: #fff;
}
/* .fl-field carries a bottom margin for form rows; in a flex column with its own
   gap that reads as uneven spacing. */
.filter-panel-body .fl-field { margin-bottom: 0; }
.filter-panel-note {
  margin: 0; font-size: .74rem; color: var(--faint); line-height: 1.5;
  padding-top: .35rem; border-top: 1px dashed var(--line);
}
/* Reset sits hard left, Apply hard right: they are opposite intentions and
   should not read as a pair of adjacent buttons you might hit either of. */
.filter-panel-ft { flex-shrink: 0; }

/* [hidden] MUST come with the display rule. The browser hides [hidden] with
   `display:none` from its own stylesheet, and ANY author `display` beats it -
   so without this line the badge would sit there reading "0" forever. */
.f-count[hidden] { display: none; }
.f-count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 17px; height: 17px; margin-left: .4rem; padding: 0 .3rem;
  border-radius: 9px; background: var(--accent); color: #fff;
  font-size: .66rem; font-weight: 700; line-height: 1;
}
.btn-soft .f-count { background: var(--violet-ink); }

@media (max-width: 575px) {
  /* Full width on a phone: 340px of a 375px screen leaves a 35px strip of page
     behind it, which looks like a rendering fault rather than a backdrop. */
  .filter-panel { width: 100%; }
}

.pager {
  display: flex; align-items: center; justify-content: space-between;
  gap: .6rem; flex-wrap: wrap; padding: .75rem 1rem; border-top: 1px solid var(--line);
}
.pager-btns { display: flex; gap: .25rem; flex-wrap: wrap; }
.page-btn {
  border: 1px solid var(--card-border); background: #fff; border-radius: 7px;
  padding: 3px 10px; font-size: .78rem; cursor: pointer; color: var(--n-600);
}
.page-btn:hover:not(:disabled) { background: var(--accent-wash); border-color: var(--accent-soft); color: var(--accent); }
.page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.page-btn:disabled { opacity: .4; cursor: default; }
.page-size { display: inline-flex; align-items: center; gap: .4rem; font-size: .78rem; color: var(--muted); }
.page-size select { width: auto; padding: .15rem 1.6rem .15rem .5rem; font-size: .78rem; }

/* ==========================================================================
   7. (was: Tabs - master data)
   Removed 2026-08-02 with the tab bar. `.tab-bar`, `.tab-btn` and `.tab-panel`
   had no remaining markup anywhere. The master selector became a topbar dropdown
   then, and on 2026-08-31 it became the left rail in section 15f - so the tab
   bar this section is a tombstone for has now been replaced twice. See
   docs/DESIGN_SYSTEM.md.
   ========================================================================== */

/* ==========================================================================
   8. Forms - stock Bootstrap, lightly tuned
   ========================================================================== */
.form-label.small { font-size: .8rem; margin-bottom: .25rem; color: var(--n-700); }
.form-control:focus, .form-select:focus {
  border-color: var(--accent-soft);
  box-shadow: 0 0 0 .2rem var(--accent-ring-soft);
}
.is-invalid, .form-control.is-invalid, .form-select.is-invalid {
  border-color: var(--danger) !important;
  box-shadow: 0 0 0 .2rem var(--danger-ring) !important;
}

/* ── Floating-label field - THE standard text input ───────────────────────────
   Empty + unfocused: the label sits inside the box like a placeholder.
   Focused:           the border turns accent and the label rises onto it.
   Filled:            the label stays up, muted.
   The label carries a white background and side padding so it notches the
   border cleanly rather than overlapping it.

   Markup:
     <div class="fl-field">
       <input id="x" placeholder=" ">                 <- placeholder=" " is required
       <label for="x">Tag Name <span class="req">*</span></label>
     </div>
   Textareas add .fl-textarea so the label sits at the top, not centred.        */
.fl-field {
  position: relative; background: #fff;
  border: 1.5px solid var(--field-border); border-radius: var(--radius-field);
  transition: border-color .15s;
}
.fl-field input, .fl-field textarea, .fl-field select {
  display: block; width: 100%; border: none; outline: none; background: transparent;
  padding: 6px 10px; font-size: .9rem; color: var(--body); line-height: 1.4;
  border-radius: var(--radius-field);
}
.fl-field textarea { resize: none; padding-top: 6px; }
.fl-field select { appearance: none; cursor: pointer; padding-right: 34px; }
.fl-field.fl-select::after {
  content: ''; position: absolute; right: 14px; top: 50%; pointer-events: none;
  width: 7px; height: 7px; margin-top: -5px;
  border-right: 1.5px solid var(--faint); border-bottom: 1.5px solid var(--faint);
  transform: rotate(45deg);
}
.fl-field label {
  position: absolute; left: 12px; top: 47%; transform: translateY(-50%);
  font-size: .9rem; color: var(--faint); pointer-events: none;
  transition: all .15s ease; background: #fff; padding: 0 4px; line-height: 1.2;
  white-space: nowrap; max-width: calc(100% - 24px); overflow: hidden; text-overflow: ellipsis;
}
.fl-field label .req { color: var(--danger); }
.fl-field.fl-textarea label { top: 17px; transform: none; }

.fl-field:focus-within { border-color: var(--accent); }
.fl-field:focus-within label,
.fl-field.has-value:not(:focus-within) label,
.fl-field.fl-select label {                    /* a select always shows a value */
  top: 0; transform: translateY(-50%);
  font-size: .72rem; font-weight: 600;
}
.fl-field:focus-within label { color: var(--accent); }
.fl-field.has-value:not(:focus-within) label,
.fl-field.fl-select:not(:focus-within) label { color: var(--muted); }

/* .fl-date - a native date input paints "dd-mm-yyyy" whether it holds a value
   or not, so the label can never sit down over it without landing on top of the
   format hint. Exactly the problem a select has, and the same fix. */
.fl-field.fl-date label {
  top: 0; transform: translateY(-50%);
  font-size: .72rem; font-weight: 600;
}
.fl-field.fl-date:not(:focus-within) label { color: var(--muted); }
.fl-field.fl-date input { cursor: pointer; }

/* .fl-ro - filled in by something other than this field. Read-only and NOT
   disabled: a disabled input is skipped by tab and by the screen reader, and
   this is a value the operator is meant to read. The label carries its own white
   background to notch the border, so it has to be re-tinted or it sits in a
   white slot cut out of a grey field. */
.fl-field.fl-ro { background: var(--n-50); border-color: var(--card-border); }
.fl-field.fl-ro label { background: var(--n-50); }
.fl-field.fl-ro input { color: var(--muted); cursor: not-allowed; }

/* .fl-suffix - the unit, inside the field. Set beside the input instead, it
   wraps under it at 375px and reads as a caption for the NEXT field. */
.fl-field .fl-suffix {
  position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
  font-size: .72rem; color: var(--faint);
  pointer-events: none; white-space: nowrap;
}
.fl-field:has(.fl-suffix) input { padding-right: 68px; }
/* The number spinner would sit underneath the unit. Nothing on this form is
   stepped one at a time anyway - a cycle time is read off a gauge and typed. */
.fl-field:has(.fl-suffix) input[type=number] { -moz-appearance: textfield; appearance: textfield; }
.fl-field:has(.fl-suffix) input[type=number]::-webkit-outer-spin-button,
.fl-field:has(.fl-suffix) input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* Padding for a card that is a form rather than a grid. .card-header-custom
   sets the header's; this sets the body's, and matches it. */
.form-pad { padding: 1.25rem; }
@media (max-width: 575px) { .form-pad { padding: .9rem; } }

.fl-field.fl-invalid { border-color: var(--danger) !important; border-width: 2px !important; }
.fl-field.fl-invalid label { color: var(--danger) !important; }
.fl-hint { font-size: .7rem; color: var(--faint); margin-top: .25rem; }

/* HAND-PATCH 2026-09-08 — a hint that WARNS rather than merely explains.
   An ⓘ icon in the accent, and <strong> inside it is allowed to carry weight.
   ⚠ SPEC'D ONCE, ON PURPOSE. The first use was the Add Role dialog's "a new
     role starts with zero permissions" warning; without a named variant the
     next screen needing an emphasised hint invents a second one, and then there
     are two field-hint styles nobody chose between.
   ⚠ `align-items: flex-start` and the icon's own line-height: a two-line hint
     must not centre its icon against the block — it belongs beside the FIRST
     line, where the reading starts. */
.fl-hint-note { display: flex; align-items: flex-start; gap: .35rem; }
.fl-hint-note > i { color: var(--accent); flex: 0 0 auto; line-height: 1.45; }
.fl-hint-note strong { color: var(--muted); font-weight: 700; }

/* A boolean field. Boxed to the same height as a text field so a row mixing the
   two does not look ragged. */
.sw-field {
  display: flex; align-items: center; min-height: 44px;
  padding: .35rem .75rem;
  border: 1px solid var(--field-border); border-radius: var(--radius-field);
  background: #fff;
}
.sw-field .form-check { padding-left: 2.4em; }
.sw-field .form-check-input { margin-left: -2.4em; cursor: pointer; }
.sw-field .form-check-input:checked { background-color: var(--accent); border-color: var(--accent); }
.sw-field .form-check-label { font-size: .85rem; color: var(--body); cursor: pointer; user-select: none; }

/* Multi-select: a filterable checkbox list. A native <select multiple> needs
   ctrl-click to deselect and is effectively unusable on a touch screen. */
.ms-box {
  border: 1px solid var(--field-border); border-radius: var(--radius-field);
  background: #fff; overflow: hidden;
}
.ms-hd {
  display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  padding: .4rem .65rem; background: var(--n-50); border-bottom: 1px solid var(--line);
}
.ms-label { font-size: .78rem; font-weight: 600; color: var(--muted); }
.ms-count { font-size: .7rem; color: var(--accent); font-weight: 600; white-space: nowrap; }
.ms-search {
  width: 100%; border: 0; border-bottom: 1px solid var(--line);
  padding: .45rem .65rem; font-size: .82rem; outline: none;
}
.ms-search:focus { border-bottom-color: var(--accent); }
.ms-list { max-height: 168px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
.ms-item {
  display: flex; align-items: center; gap: .55rem;
  padding: .4rem .65rem; font-size: .83rem; cursor: pointer; margin: 0;
  border-bottom: 1px solid var(--line);
}
.ms-item:last-child { border-bottom: none; }
.ms-item:hover { background: var(--accent-wash); }
.ms-item input { width: 15px; height: 15px; cursor: pointer; flex-shrink: 0; accent-color: var(--accent); }
.ms-empty { padding: .8rem .65rem; font-size: .78rem; color: var(--faint); text-align: center; }
.ms-box.fl-invalid { border-color: var(--danger); }

/* ── Multi-select extensions, added for the bulk breakdown screen ────────────
   A select-all and a per-row tag. Kept here with the component rather than in
   the bulk block below, because nothing about them is specific to that screen -
   the next multi-select that needs "tick everything" gets them free.

   The foot, not the header: select-all acts on the LIST, and a control sitting
   above the search box reads as if it acted on the search. */
.ms-item-lbl { flex: 1 1 auto; min-width: 0; }
.ms-foot {
  display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  padding: .4rem .65rem; background: var(--n-50); border-top: 1px solid var(--line);
}
.ms-all {
  display: flex; align-items: center; gap: .45rem; margin: 0;
  font-size: .78rem; font-weight: 600; color: var(--muted); cursor: pointer; user-select: none;
}
.ms-all input { width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); }
.ms-foot-note { font-size: .7rem; color: var(--amber-700); font-weight: 600; text-align: right; }

/* A row that is selectable but will be skipped. Tinted rather than disabled:
   the machine is a legitimate choice - somebody may want it in the batch to see
   it named in the dialog - and greying it out would say it cannot be picked. */
.ms-item-booked { background: var(--amber-50); }
.ms-item-booked:hover { background: var(--amber-100); }
.ms-tag {
  flex-shrink: 0; font-size: .64rem; font-weight: 700; text-transform: uppercase;
  letter-spacing: .3px; padding: .1rem .35rem; border-radius: 4px;
  background: var(--amber-200); color: var(--amber-900);
}

/* ══ Bulk breakdown entry ════════════════════════════════════════════════════
   The twelve-hour picker and its summary dialog. Built from the same borders,
   tints and type scale as .ms-box and .hour-row so the screen reads as part of
   the production module rather than as a visitor to it. */

/* Said before anything is typed. Informational, not a warning - creating an
   incomplete entry is the correct use of this screen, not a risk being run. */
.bk-note {
  display: flex; align-items: flex-start; gap: .5rem;
  padding: .6rem .75rem; margin-bottom: 1rem;
  background: var(--accent-wash); border: 1px solid var(--card-border);
  border-radius: var(--radius-field);
  font-size: .78rem; line-height: 1.45; color: var(--body);
}
.bk-note .bi { color: var(--accent); flex-shrink: 0; margin-top: .1rem; }

/* ⚠ THE ROW'S FOUR TRACKS, DECLARED ONCE. The heading row, every hour row and
   both breakpoints below read this one custom property, so the header can never
   sit out of line with the columns it names. Label · reason · minutes · approver
   - the same four facts in the same order as .hour-rej on step 2. */
.bk-hours {
  --bk-cols: minmax(140px, 1fr) minmax(130px, 1.3fr) 58px minmax(120px, 1.1fr);
  border: 1px solid var(--field-border); border-radius: var(--radius-field);
  background: #fff; overflow: hidden;
}
.bk-hd, .bk-foot {
  display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  padding: .4rem .65rem; background: var(--n-50);
}
/* ⚠ THE SAME FOUR TRACKS AS .bk-row, REPEATED RATHER THAN GUESSED AT - the same
   arrangement .hour-head keeps over .hour-row on step 2. A heading row laid out
   any other way drifts out of line with its own columns the first time one of
   them is resized. */
.bk-hd {
  display: grid; grid-template-columns: var(--bk-cols);
  border-bottom: 1px solid var(--line);
}
/* Wraps: it now names every reason in play with its minutes, which on a mixed
   batch is longer than one line at any width this panel is used at. */
.bk-foot {
  border-top: 1px solid var(--line); font-size: .74rem; color: var(--muted);
  flex-wrap: wrap; line-height: 1.45;
}
.bk-hd-col {
  font-size: .7rem; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .3px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bk-hd-min { text-align: center; }
.bk-all {
  display: flex; align-items: center; gap: .45rem; margin: 0;
  font-size: .78rem; font-weight: 600; color: var(--muted); cursor: pointer; user-select: none;
}
.bk-all input { width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); }

/* ⚠ NO max-height AND NO INNER SCROLL, unlike .ms-list above. All twelve hours
   are visible at once on purpose: the question this half of the screen asks is
   "which hours", and an answer that needs scrolling to read back is one people
   get wrong. Twelve rows is ~400px, which is affordable beside a machine list
   that is shorter, and fine stacked on a phone. */
.bk-row {
  display: grid; grid-template-columns: var(--bk-cols);
  align-items: center; gap: .5rem;
  padding: .32rem .65rem; border-bottom: 1px solid var(--line);
}
.bk-row:last-child { border-bottom: none; }
.bk-row.is-on { background: var(--accent-wash); }
.bk-chk {
  min-width: 0; margin: 0;
  display: flex; align-items: center; gap: .55rem;
  cursor: pointer; user-select: none;
}
.bk-chk input { width: 15px; height: 15px; cursor: pointer; flex-shrink: 0; accent-color: var(--accent); }
.bk-lbl { font-size: .83rem; color: var(--body); white-space: nowrap; }
.bk-min {
  width: 100%; min-width: 0; text-align: center;
  border: 1px solid var(--field-border); border-radius: 6px;
  padding: .2rem .3rem; font-size: .82rem; color: var(--body); background: #fff;
}
.bk-min:focus { outline: none; border-color: var(--accent); }
.bk-min:disabled { background: var(--n-50); color: var(--faint); }

/* ⚠ THE ROW SELECTS ARE .hour-sel - step 2's control, not a second one. The two
   screens record the same three facts about an hour, and a breakdown reason that
   looks like one thing on the bulk screen and another on the entry form is two
   components to keep in step forever. .bk-sel only adds what the denser row
   needs: a smaller line so twelve rows still fit beside a machine list. */
.bk-sel { font-size: .78rem; padding: 3px 6px; }
.bk-sel:disabled { background: var(--n-50); color: var(--faint); cursor: not-allowed; }
.bk-sum-warn { color: var(--amber-700); font-weight: 600; }

/* The summary dialog. Rendered into confirmDialog's body, so it inherits that
   modal's width - hence the wrapping chip list rather than a table. */
.bk-sum-row {
  display: flex; align-items: baseline; justify-content: space-between; gap: .75rem;
  padding: .2rem 0; font-size: .78rem; color: var(--muted);
}
.bk-sum-row b { color: var(--body); text-align: right; }
/* The minutes beside a reason on a mixed batch - the figure qualifies the name,
   so it must not read as loud as it. */
.bk-sum-min { font-weight: 500; color: var(--muted); }
.bk-sum-list { display: flex; flex-wrap: wrap; gap: .3rem; margin: .55rem 0 0; }
.bk-sum-chip {
  font-size: .72rem; padding: .15rem .4rem; border-radius: 5px;
  background: var(--accent-wash); color: var(--accent); font-weight: 600;
}
.bk-sum-skip {
  margin-top: .5rem; font-size: .72rem; line-height: 1.4;
  color: var(--amber-900); background: var(--amber-50); border: 1px solid var(--amber-200);
  border-radius: 6px; padding: .35rem .5rem;
}
.bk-sum-total {
  margin-top: .65rem; padding-top: .55rem; border-top: 1px solid var(--line);
  font-size: .82rem; color: var(--body);
}

/* ⚠ ON A SMALL SCREEN THE ROW SCROLLS SIDEWAYS - IT DOES NOT STACK (the user's
   instruction, 2026-08-24). The first cut broke each row into three lines below
   576px, which is what .hour-rej does on step 2. That is right for a form being
   filled in one hour at a time and wrong here: this screen's whole job is to
   answer twelve identical rows at once, and thirty-six stacked lines is a
   panel nobody can read down. Kept as one table, it scrolls - which is the same
   call the masters grid made with .table-keep.

   ⚠ THE SCROLLER WRAPS THE HEADING ROW AND THE LIST, NEVER THE WHOLE PANEL.
   The heading has to travel with the columns it names, and the foot - which is
   prose, and wraps - has to stay where it is. R9 is untouched: the panel
   scrolls, the page body never does. */
.bk-scroll { overflow-x: auto; }

/* ⚠ FIXED TRACKS AND min-width: max-content BELOW 768. Fractional tracks would
   just squeeze to fit and there would be nothing to scroll; without the
   min-content floor the grid overflows its own row, so the tint and the hair
   line stop at the viewport edge and only the controls carry on. */
@media (max-width: 767.98px) {
  .bk-hours { --bk-cols: 132px 148px 52px 148px; }
  .bk-hd, .bk-row { min-width: max-content; }
  .bk-sel { font-size: .76rem; }
}

/* A taller touch target rather than a denser one - this is a screen used with
   gloves on. */
@media (max-width: 575.98px) {
  .bk-row { padding: .45rem .65rem; }
  .bk-min { padding: .3rem .3rem; }
}

/* Manage-an-option-list dialog: add on one line, then the reorderable list. */
.opt-add { display: flex; align-items: stretch; gap: .5rem; }
.opt-add .btn { flex-shrink: 0; white-space: nowrap; }
.opt-hint {
  font-size: .72rem; color: var(--faint);
  margin: .6rem 0 .35rem; display: flex; align-items: center; gap: .25rem;
}
.opt-list {
  border: 1px solid var(--field-border); border-radius: var(--radius-field);
  background: #fff; overflow: hidden;
  max-height: 300px; overflow-y: auto; -webkit-overflow-scrolling: touch;
}
.opt-row {
  display: flex; align-items: center; gap: .5rem;
  padding: .35rem .5rem .35rem .25rem;
  border-bottom: 1px solid var(--line); background: #fff;
}
.opt-row:last-child { border-bottom: none; }
.opt-val { flex: 1 1 auto; font-size: .85rem; color: var(--body); word-break: break-word; }
.opt-grip {
  flex-shrink: 0; display: flex; align-items: center; justify-content: center;
  width: 30px; min-height: 30px; color: var(--faint); cursor: grab;
  /* Required, not cosmetic: without it a touch drag scrolls the dialog instead
     of moving the row, and pointermove stops firing once the browser takes over. */
  touch-action: none; user-select: none;
}
.opt-grip:active { cursor: grabbing; }
.opt-row:hover .opt-grip { color: var(--muted); }
.opt-row.opt-dragging {
  background: var(--accent-wash);
  box-shadow: 0 2px 8px rgb(0 0 0 / .10); border-radius: 6px;
  position: relative; z-index: 2;
}
.opt-empty { padding: .9rem .65rem; font-size: .78rem; color: var(--faint); text-align: center; }

/* ── reflist: a value that also names a row elsewhere ─────────────────────────
   Live on Breakdown Reasons, where each reason carries the supervisor who
   normally approves it (2026-08-19). The row select reuses .hour-sel - the app
   already has a compact select and a second one that looked almost the same
   would be the drift this stylesheet exists to avoid.

   ⚠ [hidden] NEEDS ITS OWN display:none. .fl-field sets display:flex, and an
   author display beats the UA rule for [hidden] - the same trap already noted
   at .f-count[hidden]. Without it the add row's reference
   picker shows on every plain list too. */
/* 160px, measured rather than chosen: the dialog is 500px at every width from
   576 up (Bootstrap caps .modal-dialog), which leaves 468px for the name field,
   this select and the Save button. At the 210px this started as, the SECONDARY
   control came out wider than the name it qualifies - 189 against 173 - which
   reads as though the approver were the point of the row. At 160 the name takes
   ~202 and leads, and the select still shows ~18 characters before .hour-sel's
   ellipsis, with the full name visible the moment it opens. */
.opt-ref { flex: 0 1 160px; min-width: 0; }
.opt-ref[hidden] { display: none; }
/* display:flex, not the default inline: as an inline box the select sits on a
   text baseline and the row carries the leading underneath it - 20px of it,
   measured, which is most of a row's height again once the select wraps below
   576. */
.opt-row-ref { flex: 0 0 175px; min-width: 0; display: flex; }
.opt-row-ref .hour-sel { padding: 3px 6px; }

/* The 44px touch target the pointer drag needs on a phone, where there is no
   hover to reveal the grip in the first place. */
@media (max-width: 575.98px) {
  .opt-add { flex-wrap: wrap; }
  .opt-add .btn { width: 100%; }
  .opt-grip { width: 38px; min-height: 38px; color: var(--muted); }
  .opt-row { padding: .45rem .5rem .45rem .25rem; }

  /* The reference takes its own line rather than squeezing a 175px select in
     beside a wrapping name. `order` puts it BELOW the delete button while the
     grip, the name and the delete stay on the first line; the indent lines it
     up with the name rather than the grip. */
  .opt-ref { flex: 1 1 100%; }
  .opt-row { flex-wrap: wrap; }
  /* 46px = the 38px grip plus the row's .5rem gap, so the select starts at the
     same x as the name it belongs to rather than 8px to its left. No margin-top:
     `gap` is a ROW gap as well as a column one once the row wraps, so it has
     already put 8px there and a margin only doubles it. */
  .opt-row-ref { flex: 1 1 100%; order: 3; margin-left: 46px; }
}

/* ── Bulk upload dialog ──────────────────────────────────────────────────────
   Three states in one modal - pick a file, review the plan, or read why the
   file was refused. Only one is ever visible. */

/* A <label> wrapping a hidden file input: the whole box is the click target and
   the drop zone at once, with no JS needed to open the picker. */
.imp-drop {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: .3rem; text-align: center; cursor: pointer;
  padding: 1.6rem 1rem;
  border: 2px dashed var(--field-border); border-radius: var(--radius-field);
  background: var(--page-bg); color: var(--muted);
  transition: border-color .15s, background .15s;
}
.imp-drop:hover, .imp-drop-on { border-color: var(--accent); background: var(--accent-wash); }
.imp-drop > i { font-size: 1.6rem; color: var(--accent); }
.imp-drop-main { font-size: .88rem; font-weight: 600; color: var(--ink-900); }
.imp-drop-sub  { font-size: .74rem; color: var(--faint); }

.imp-tip { font-size: .74rem; color: var(--faint); margin-top: .7rem; line-height: 1.5; }
.btn-link-inline {
  background: none; border: 0; padding: 0;
  color: var(--accent); font: inherit; text-decoration: underline; cursor: pointer;
}

/* The chosen file and its remove button on one line. Removing the file is an
   action on the file - it belongs here, not competing with Cancel and Import in
   the footer. */
.imp-file {
  display: flex; align-items: center; gap: .45rem;
  padding: .45rem .4rem .45rem .55rem; margin-bottom: .7rem;
  border: 1px solid var(--line); border-radius: var(--radius-field); background: var(--page-bg);
}
.imp-file > i:first-child { color: var(--accent); flex-shrink: 0; }
.imp-file-name {
  flex: 1 1 auto; min-width: 0;
  font-size: .82rem; font-weight: 600; color: var(--ink-900); word-break: break-all;
}
.imp-file-x {
  flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; padding: 0;
  border: 0; border-radius: 6px; background: transparent;
  color: var(--muted); font-size: .8rem; cursor: pointer;
  transition: background .15s, color .15s;
}
.imp-file-x:hover, .imp-file-x:focus-visible { background: var(--danger-bg); color: var(--danger); }
/* A real touch target where there is no hover to hint at it, same as .opt-grip. */
@media (max-width: 575.98px) {
  .imp-file-x { width: 38px; height: 38px; font-size: .9rem; }
}

/* Three counts, side by side at every width - they are short, and stacking them
   would push the record list below the fold on a phone. */
.imp-stats { display: flex; gap: .5rem; }
.imp-stat {
  flex: 1 1 0; min-width: 0; text-align: center;
  padding: .55rem .3rem; border-radius: var(--radius-field);
  border: 1px solid var(--line); background: var(--page-bg);
}
.imp-stat-n { display: block; font-size: 1.15rem; font-weight: 700; color: var(--ink-900); line-height: 1.2; }
.imp-stat-l { display: block; font-size: .7rem; color: var(--muted); }
/* The updates are the ones worth noticing - they overwrite something. */
.imp-stat-warn { border-color: var(--warn-bg); background: var(--warn-bg); }
.imp-stat-warn .imp-stat-n, .imp-stat-warn .imp-stat-l { color: var(--warn-ink); }

.imp-updating {
  margin-top: .7rem; padding: .6rem .65rem;
  border: 1px solid var(--warn-200); border-radius: var(--radius-field); background: var(--warn-bg);
}
/* The badges sit ON the warn panel, so they cannot also be warn-tinted - they
   would disappear into it. White chips, warm border. */
.imp-updating-list .status-badge {
  background: #fff; color: var(--warn-ink); border: 1px solid var(--warn-200);
}
.imp-updating-hd {
  display: flex; align-items: flex-start; gap: .4rem;
  font-size: .76rem; font-weight: 600; color: var(--warn-ink); margin-bottom: .5rem;
}
.imp-updating-list {
  display: flex; flex-wrap: wrap; gap: .3rem;
  max-height: 160px; overflow-y: auto; -webkit-overflow-scrolling: touch;
}
.imp-more { font-size: .74rem; color: var(--muted); align-self: center; }

/* The per-row error list. Scrolls inside itself so the footer buttons stay put
   however many rows failed. */
.imp-rows {
  margin-top: .6rem; max-height: 240px; overflow-y: auto; -webkit-overflow-scrolling: touch;
  border: 1px solid var(--line); border-radius: var(--radius-field); background: #fff;
}
.imp-row-err {
  display: flex; align-items: flex-start; gap: .5rem;
  padding: .4rem .55rem; border-bottom: 1px solid var(--line);
  font-size: .78rem; color: var(--body);
}
.imp-row-err:last-child { border-bottom: none; }
.imp-row-n {
  flex-shrink: 0; font-weight: 600; color: var(--danger-ink);
  background: var(--danger-bg); border-radius: 5px; padding: 0 .35rem;
}
.imp-rows .imp-more { display: block; padding: .4rem .55rem; }

/* A password eye or clear button sitting inside the field. The type selector is
   the reliable path; :has() then covers any other field that carries a toggle. */
.fl-field .pw-toggle { right: 8px; }
.fl-field input[type="password"], .fl-field input[type="text"].has-toggle { padding-right: 42px; }
.fl-field:has(.pw-toggle) input { padding-right: 42px; }

/* Compact variant for filter bars and other dense rows. Same language, less height. */
.fl-field.fl-sm input, .fl-field.fl-sm select, .fl-field.fl-sm textarea {
  padding: 6px 10px; font-size: .82rem;
}
.fl-field.fl-sm label { left: 10px; font-size: .82rem; }
.fl-field.fl-sm:focus-within label,
.fl-field.fl-sm.has-value:not(:focus-within) label,
.fl-field.fl-sm.fl-select label { font-size: .68rem; }
.fl-field.fl-sm.fl-select select { padding-right: 30px; }
.form-check-input:checked { background-color: var(--accent); border-color: var(--accent); }
.form-check-input:focus { box-shadow: 0 0 0 .2rem var(--accent-ring); border-color: var(--accent-500); }
.form-switch .form-check-input { cursor: pointer; }

.upload-zone {
  border: 2px dashed var(--field-border); border-radius: 12px;
  padding: 2rem; text-align: center; cursor: pointer;
  transition: all .2s; background: var(--n-50);
}
.upload-zone:hover, .upload-zone.drag-over { border-color: var(--accent); background: var(--purple-50); }
.upload-zone i { font-size: 2.5rem; color: var(--faint); display: block; margin-bottom: .5rem; }

.pw-toggle {
  position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
  background: none; border: none; color: var(--faint); cursor: pointer;
  padding: .35rem .5rem; border-radius: 6px; z-index: 4; line-height: 1;
}
.pw-toggle:hover { color: var(--n-600); background: var(--n-100); }


/* ==========================================================================
   9. Dialogs
   Header and footer are TINTED SECTIONS with their own rules; the body is white
   between them. The three parts must read as separate bands, not one surface.
   ========================================================================== */
.modal-content { border-radius: var(--radius); border: none; overflow: hidden; }
.modal-top .modal-dialog { margin-top: 5vh; margin-bottom: 1rem; }

/* ── Header band ─────────────────────────────────────────────────────────── */
.modal-hd {
  display: flex; align-items: center; gap: .75rem; flex-shrink: 0;
  padding: .6rem 1.15rem;
  background: var(--n-50); border-bottom: 1px solid var(--n-200);
}
.modal-hd-icon {
  width: 30px; height: 30px; border-radius: 9px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center; font-size: .95rem;
}
.mhi-primary { background: var(--accent-tint); color: var(--violet-ink); }
.mhi-warning { background: var(--amber-100); color: var(--amber-700); }
.mhi-danger  { background: var(--danger-bg); color: var(--danger); }
/* HAND-PATCH 2026-09-08 — one dialog header shape, everywhere.
   ⚠ The views wrote a <span> here and confirmDialog() wrote an <h6>, so the two
     inherited different default weights and margins and no two dialogs in the
     application quite matched. Every dialog is an <h6> now; the explicit
     margin and font-size below are what make that harmless. */
.modal-hd-title { flex: 1; margin: 0; font-weight: 700; color: var(--ink-900); font-size: .95rem; }
.modal-hd-close {
  background: none; border: none; color: var(--faint); cursor: pointer;
  padding: .3rem; font-size: .9rem; border-radius: 6px;
  display: flex; align-items: center; line-height: 1;
}
.modal-hd-close:hover { color: var(--n-600); background: var(--n-150); }

/* ── Body ────────────────────────────────────────────────────────────────── */
.modal-body { padding: 1.15rem; background: #fff; }

/* ── Footer band - status switch left, actions right ─────────────────────── */
.modal-footer {
  padding: .3rem 1.15rem; margin: 0;
  background: var(--n-50); border-top: 1px solid var(--n-200);
  display: flex; align-items: center; justify-content: space-between; gap: .6rem;
}
.modal-footer .footer-actions { display: flex; gap: .5rem; margin-left: auto; }
.status-toggle { cursor: pointer; width: 2.4em; height: 1.25em; margin-top: 0; }
.status-toggle:checked { background-color: var(--accent); border-color: var(--accent); }
.status-toggle-lbl {
  font-size: .84rem; font-weight: 600; color: var(--muted);
  cursor: pointer; user-select: none; margin-left: .1rem;
}

/* Destructive and cautionary dialogs tint the header band. */
.modal-hd.modal-hd-danger { background: var(--danger-bg); border-bottom-color: var(--danger-200); }
.modal-hd.modal-hd-danger .modal-hd-title { color: var(--danger-800); }
.modal-hd.modal-hd-warn { background: var(--amber-50); border-bottom-color: var(--amber-200); }
.modal-hd.modal-hd-warn .modal-hd-title { color: var(--amber-800); }

/* ==========================================================================
   10. Permission matrix
   ========================================================================== */

/* HAND-PATCH 2026-09-08 — a role row is a control now.
   The permission matrix moved out of a modal and into a card beside the list,
   so the ROW is what opens it. Three things follow from that and all three are
   here rather than inline:
     · a pointer, because nothing else says the row is clickable;
     · a hover, for the same reason;
     · a persistent SELECTED state, because with the matrix on screen beside it
       the row is the only thing saying which role those ticks belong to.
   ⚠ The left border is on the FIRST CELL, not on the row. A border-left on a
     <tr> is ignored under `border-collapse: collapse`, which .table-app uses. */
.role-row { cursor: pointer; }
.role-row:hover > td { background: var(--n-50); }
.role-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.role-row.is-selected > td { background: var(--accent-wash); }
.role-row.is-selected > td:first-child { box-shadow: inset 3px 0 0 var(--accent); }

.perm-matrix { font-size: .8rem; }
.perm-matrix th { color: var(--muted); font-weight: 600; text-align: center; font-size: .72rem; padding: .4rem .3rem; }
.perm-matrix th:first-child, .perm-matrix td:first-child { text-align: left; }
.perm-matrix td { text-align: center; vertical-align: middle; padding: .4rem .3rem; }
.perm-matrix .form-check-input { cursor: pointer; margin: 0; }
.module-name { font-weight: 600; color: var(--ink-900); font-size: .8rem; }
.module-info { color: var(--faint); font-size: .8rem; cursor: pointer; margin-left: .35rem; }
.module-info:hover { color: var(--accent); }

/* Three levels, each stepped in from the one above (2026-08-03):

     AREA            the same areas as the sidebar - a module name alone does not
                     say which part of the app it belongs to
       Module        its five action columns
         Extra       a permission of that module with no column to sit in

   All in ONE table, never a table per area: the five action columns have to stay
   in a single alignment down the whole dialog.

   The indents are the only thing saying what belongs to what, so they are set
   here as one scale rather than per row. */
.perm-matrix .perm-group td {
  background: var(--accent-wash); border-top: 1px solid var(--card-border);
  padding: .4rem .55rem; font-size: .72rem; font-weight: 700;
  letter-spacing: .4px; text-transform: uppercase; color: var(--ink-700);
}
.perm-matrix tr.perm-group:first-child td { border-top: none; }

/* The area row is an ACCORDION HEADER (2026-08-17).
   Collapsing hides the rows in place - `hidden` on the <tr>, never a removal:
   saving is full-replace, so an unmounted row is a revoked permission. The UA
   rule for [hidden] is display:none, but the same author-declaration trap that
   bit .f-count[hidden] applies to anything that later declares a display on
   these rows, so it is stated here rather than relied on. */
.perm-matrix tr[hidden] { display: none; }

.perm-area-hd { display: flex; align-items: center; gap: .5rem; }

/* A button does NOT inherit type - the area row's uppercase/weight/tracking all
   have to be handed to it or the header changes font mid-row. */
.perm-area-toggle {
  flex: 1 1 auto; display: flex; align-items: center; gap: .5rem;
  background: none; border: 0; padding: 0; text-align: left;
  font: inherit; color: inherit;
  letter-spacing: inherit; text-transform: inherit;
}
.perm-area-toggle:hover { color: var(--accent); }
.perm-area-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.perm-caret { font-size: .7rem; transition: transform .15s ease; }
.perm-area-toggle[aria-expanded="true"] .perm-caret { transform: rotate(90deg); }

/* The count is the only thing a collapsed area shows about its contents, so it
   sits BESIDE THE NAME and not against the right edge: the area row spans all
   six columns, and below ~700px that table scrolls sideways inside .table-wrap -
   a right-aligned badge is then off-screen on exactly the device that most needs
   the areas shut. */
.perm-area-count {
  margin-left: .15rem; text-transform: none; letter-spacing: 0;
  font-size: .7rem; font-weight: 600; color: var(--accent);
  background: #fff; border: 1px solid var(--card-border);
  border-radius: 999px; padding: .05rem .45rem;
}
.perm-area-count.is-none { color: var(--faint); }
.perm-area-count.is-all  { color: var(--ok-800); border-color: var(--ok-200); background: var(--ok-50); }

/* Level 2. The guide line is what makes the nesting readable at a glance rather
   than inferred from whitespace. */
.perm-matrix .perm-module > td:first-child { padding-left: 1.35rem; }

/* HAND-PATCH 2026-09-08 — EVERY ROW CARRIES ITS OWN CHECKBOX ON THE LEFT, the
   module rows included, so a whole row can be granted in one click instead of
   five. This is .perm-area-hd's layout one level in; both exist because the box
   and the name must sit on one baseline whatever the name wraps to. */
.perm-mod-hd { display: flex; align-items: center; gap: .5rem; }
.perm-mod-hd > span { min-width: 0; }

/* ⚠ The extras row puts its box inside the same flex wrapper, so its own
   padding-left must not double up on the guide line's inset. */
.perm-matrix .perm-extra .perm-mod-hd { gap: .45rem; }

/* Level 3, hung off its OWN module's row - not collected at the foot of the
   area, which is what put "Backup - Download" under Notification Settings. */
.perm-matrix .perm-extra td {
  padding: .3rem .55rem .3rem 2.9rem; font-size: .78rem; color: var(--body);
  position: relative;
}
.perm-matrix .perm-extra td::before {
  content: ''; position: absolute; left: 1.95rem; top: 0; bottom: 50%;
  border-left: 1px solid var(--field-border);
  border-bottom: 1px solid var(--field-border);
  width: .55rem; border-bottom-left-radius: 4px;
}
.perm-matrix .perm-extra .perm-key { margin-left: .1rem; }

@media (max-width: 575px) {
  .perm-matrix .perm-module > td:first-child { padding-left: .9rem; }
  .perm-matrix .perm-extra td { padding-left: 2.1rem; }
  .perm-matrix .perm-extra td::before { left: 1.3rem; }
}

/* ==========================================================================
   11. (removed) Activity feed
   ==========================================================================
   The dashboard's Recent Activity feed was removed on 2026-08-29, and
   .activity-item / -ico / -txt / -time went with it - that feed was their only
   user, the Activity Log screen being a table.

   ⚠ THE SECTION NUMBER IS KEPT AND THE GAP IS DELIBERATE. CLAUDE.md and
   DESIGN_SYSTEM.md cite §15b-2, §15c and §15d by number; renumbering twelve
   headings to close one hole would break every one of those references.
   ========================================================================== */

/* ==========================================================================
   12. Auth screens
   ========================================================================== */
.auth-body { background: var(--page-bg); }
.auth-wrap { display: flex; min-height: 100dvh; }
.auth-brand {
  flex: 1 1 46%; max-width: 560px;
  background: linear-gradient(150deg, var(--ink-900) 0%, var(--ink-700) 50%, var(--accent) 100%);
  color: #fff; align-items: center; justify-content: center; padding: 3rem 2.5rem;
}
/* HAND-PATCH 2026-09-08 — the identity block is CENTRED.
   ⚠ .auth-brand is already `justify-content: center`, so this box was always
     horizontally centred AS A BOX. But a flex item sizes to its content, and
     the widest content here is a points line (~240px) — so the box was 240px
     wide with a 120px logo sitting flush LEFT inside it above left-aligned
     text, which is exactly what reads as "not centred". Centring the box was
     never the missing half; centring its CONTENTS was.
   ⚠ .auth-points below re-declares `text-align: left` and MUST keep it: a
     ticked checklist centred loses the column its ticks form, and the ticks
     forming a column is the whole shape of a checklist. */
.auth-brand-inner { max-width: 380px; text-align: center; }
.auth-logo {
  width: 120px; border-radius: 12px; margin: 0 auto 1.4rem; padding: 8px;
  display: block;                     /* an inline img cannot be margin-centred */
  background: #fff; box-shadow: 0 0 32px var(--accent-glow-strong); object-fit: cover;
}
.auth-brand h2 { font-size: 1.5rem; font-weight: 800; letter-spacing: .3px; margin: 0; }
.auth-tagline { font-size: .78rem; color: var(--accent-soft); margin-top: .4rem; letter-spacing: .4px; }
.auth-brand-divider { height: 1px; background: rgba(255,255,255,.15); margin: 1.6rem 0 1.2rem; }
.auth-brand-sub { font-size: .82rem; color: rgba(255,255,255,.7); font-weight: 600; }
/* HAND-PATCH 2026-09-08 — left, deliberately, inside a centred parent. */
.auth-points { list-style: none; padding: 0; margin: 1.1rem 0 0; text-align: left; }
.auth-points li { font-size: .82rem; color: rgba(255,255,255,.78); padding: .35rem 0; display: flex; gap: .6rem; align-items: center; }
.auth-points i { color: var(--accent-soft); }

.auth-pane {
  flex: 1 1 54%; display: flex; flex-direction: column;
  align-items: center; justify-content: center; padding: 1.5rem 1rem;
}
.auth-card {
  width: 100%; max-width: 400px; background: #fff;
  border: 1px solid var(--card-border); border-radius: var(--radius);
  box-shadow: 0 10px 40px var(--shadow-deep); padding: 1.75rem 1.6rem;
}
.auth-card-brand { align-items: center; gap: .7rem; margin-bottom: 1.4rem; }
.auth-logo-sm {
  width: 80px; border-radius: 8px; flex: 0 0 auto; padding: 4px;
  margin-bottom:24px;background: #fff; border: 1px solid var(--card-border); object-fit: cover;
}
.acb-name { text-align:center;font-weight: 800; color: var(--ink-900); font-size: 1.3rem; line-height: 1.2; }
.acb-sub  { text-align:center;font-size: .8rem; color: var(--muted); letter-spacing: .3px; }
.auth-title { font-size: 1.18rem; font-weight: 800; color: var(--ink-900); margin: 0 0 .2rem; }
.auth-sub { font-size: .81rem; color: var(--muted); margin: 0 0 1.3rem; }
.auth-foot { font-size: .72rem; color: var(--faint); margin: 1.2rem 0 0; text-align: center; }
.auth-link { font-size: .8rem; color: var(--accent); text-decoration: none; font-weight: 600; }
.auth-link:hover { text-decoration: underline; }

/* ==========================================================================
   12a. My Profile - the Account card
   HAND-PATCH 2026-09-08. Added with the profile rebuild; nothing else uses it.
   ========================================================================== */

.pf-identity { display: flex; align-items: center; gap: .85rem; }

/* The avatar tile. ⚠ flex: 0 0 auto AND an explicit width — inside a flex row a
   sized box still shrinks when the text beside it is long, and a squashed
   circle reads as a broken image rather than as a tight layout. */
.pf-avatar {
  flex: 0 0 auto; width: 64px; height: 64px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center; overflow: hidden;
  background: linear-gradient(135deg, var(--accent), var(--purple));
  color: #fff; font-size: 1.5rem; font-weight: 700; line-height: 1;
  user-select: none;
}
/* object-fit: cover, or a portrait letterboxes and the gradient shows through
   two edges of the photograph. */
.pf-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }

.pf-name  { font-weight: 700; color: var(--ink-900); font-size: 1rem; line-height: 1.3; }
.pf-email { font-size: .78rem; color: var(--muted); }

.pf-avatar-actions { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .9rem; }

/* ⚠ VISUALLY HIDDEN, NOT display:none AND NOT removed. A raw file input cannot
   be styled to match anything in this kit and every browser draws it
   differently — but it must remain a real, focusable input, or keyboard users
   lose the control altogether. */
.pf-file {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* Two figures side by side. ⚠ 1fr 1fr and never `auto auto`: an auto track
   sizes to its own content, so the divider between them would sit at a
   different place depending on whether the count is 9 or 126. */
.pf-stats {
  display: grid; grid-template-columns: 1fr 1fr; gap: .75rem;
  margin-top: 1.1rem; padding-top: .9rem; border-top: 1px solid var(--line);
}
.pf-stat-k { font-size: .7rem; color: var(--faint); font-weight: 600; }
.pf-stat-v { font-size: .95rem; color: var(--ink-900); font-weight: 700; margin-top: .1rem; }

@media (max-width: 575.98px) {
  .pf-avatar { width: 56px; height: 56px; font-size: 1.3rem; }
}

/* ==========================================================================
   13. Toast - IMS alert styling; stacked so two toasts never overlap
   ========================================================================== */
.toast-stack {
  position: fixed; top: .75rem; right: .75rem; z-index: 9999;
  display: flex; flex-direction: column; gap: .5rem;
  max-width: min(360px, calc(100vw - 1.5rem));
}
.toast-stack .alert {
  margin: 0; border-radius: 10px; box-shadow: var(--shadow-pop); font-size: .83rem;
}

/* ==========================================================================
   14. Date range picker - shared component, every screen that filters by date
   ========================================================================== */
.dr-wrap { position: relative; display: inline-block; }
.dr-trigger {
  display: inline-flex; align-items: center; gap: .45rem;
  background: #fff; border: 1px solid var(--card-border); border-radius: var(--radius-field);
  padding: .35rem .7rem; font-size: .8rem; color: var(--n-600); cursor: pointer; white-space: nowrap;
}
.dr-trigger:hover { border-color: var(--accent-soft); color: var(--accent); }

/* position:fixed and NO left/right/top of its own - DateRangePicker._place()
   sets both, in viewport coordinates, every time the panel opens and on any
   scroll or resize while it is open. The old `right: 0` anchoring is what put
   the panel off the left of the screen: the trigger sits at the left of the
   filter bar, so hanging the panel from its right edge sent it out of view.
   Fixed also means no ancestor's overflow can clip it. */
.dr-panel {
  display: none; position: fixed; z-index: 1300;
  background: #fff; border: 1px solid var(--card-border); border-radius: var(--radius);
  box-shadow: var(--shadow-pop); overflow: hidden;
  max-height: calc(100vh - 16px); overflow-y: auto;
}
.dr-panel.show { display: block; }

.dr-body { display: flex; align-items: stretch; }

/* Presets: a column beside the calendars on desktop, full-width rows stacked
   above them on a phone - the user's reference for the narrow layout. */
.dr-presets {
  display: flex; flex-direction: column; gap: 2px;
  padding: .6rem; border-right: 1px solid var(--line); min-width: 148px;
}
.dr-preset {
  text-align: left; background: none; border: none; border-radius: 7px;
  padding: .4rem .6rem; font-size: .8rem; color: var(--n-600); cursor: pointer; white-space: nowrap;
}
.dr-preset:hover { background: var(--accent-wash); color: var(--accent); font-weight: 600; }

.dr-cals { display: flex; gap: 1.1rem; padding: .7rem .8rem; }
.dr-cal { min-width: 232px; }

.dr-cal-hd { display: flex; align-items: center; justify-content: space-between; margin-bottom: .5rem; }
.dr-cal-title { font-size: .82rem; font-weight: 700; color: var(--ink-900); }
.dr-nav {
  background: none; border: none; color: var(--muted); cursor: pointer;
  padding: .1rem .35rem; border-radius: 6px; line-height: 1; font-size: .8rem;
}
.dr-nav:hover { background: var(--accent-wash); color: var(--accent); }
/* Two months move as a pair: the left one cannot go forward, the right one
   cannot go back, so they can never cross or show the same month twice.
   visibility, not display, so the title stays centred. */
.dr-cal:first-child .dr-next,
.dr-cal:last-child  .dr-prev { visibility: hidden; }

.dr-dow, .dr-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.dr-dow { margin-bottom: .2rem; }
.dr-dow span {
  text-align: center; font-size: .68rem; font-weight: 600;
  color: var(--faint); padding: .2rem 0;
}
.dr-grid { gap: 1px; }
.dr-d {
  background: none; border: none; cursor: pointer; color: var(--body);
  font-size: .78rem; padding: .34rem 0; border-radius: 6px; text-align: center;
}
.dr-d:hover { background: var(--accent-wash); color: var(--accent); }
.dr-pad, .dr-pad:hover { cursor: default; background: none; }
.dr-d.is-today { font-weight: 800; color: var(--ink-900); }
.dr-d.in-range { background: var(--accent-wash); color: var(--accent); border-radius: 0; }
.dr-d.is-start, .dr-d.is-end {
  background: var(--accent); color: #fff; font-weight: 700;
}
.dr-d.is-start { border-radius: 6px 0 0 6px; }
.dr-d.is-end   { border-radius: 0 6px 6px 0; }
.dr-d.is-start.is-end { border-radius: 6px; }

.dr-foot {
  display: flex; align-items: center; justify-content: space-between; gap: .6rem;
  border-top: 1px solid var(--line); padding: .6rem .8rem; background: var(--n-25);
}
.dr-sum { font-size: .78rem; color: var(--muted); font-weight: 600; }
.dr-acts { display: flex; gap: .4rem; flex-shrink: 0; }

/* One month below 992 - two will not fit beside a preset column. */
@media (max-width: 991px) {
  .dr-cal:last-child { display: none; }
  .dr-cal:first-child .dr-next { visibility: visible; }
}

/* Phone: presets become full-width rows ABOVE a single month and the panel
   spans the viewport less its margin. _place() still clamps it, so it cannot
   leave the screen on either axis. */
@media (max-width: 575px) {
  .dr-panel { width: calc(100vw - 16px); }
  .dr-body { flex-direction: column; }
  .dr-presets {
    border-right: none; border-bottom: 1px solid var(--line);
    min-width: 0; padding: .5rem;
  }
  .dr-preset {
    border: 1px solid var(--card-border); border-radius: var(--radius-field);
    padding: .5rem .7rem; margin-bottom: 1px;
  }
  .dr-cals { padding: .6rem .5rem; }
  .dr-cal { min-width: 0; width: 100%; }
  .dr-d { padding: .5rem 0; }
  .dr-foot { padding: .6rem .5rem; }
  .dr-sum { font-size: .74rem; }
}

/* ==========================================================================
   15. Searchable select - the dropdown every <select> opens

   The native element is still there and still holds the value; only its own
   dropdown is suppressed. So there is nothing to style for the closed state -
   .fl-select already draws the field and its chevron. This is the panel alone.

   Appended to <body> and positioned with position:fixed by csPlace(), for the
   same reason as the date panel: inside .fl-field it would be clipped by the
   dialog's overflow, and anchored with CSS offsets it would open off-screen.
   ========================================================================== */
.cs-pop {
  position: fixed; z-index: 1400; background: #fff;
  border: 1px solid var(--card-border); border-radius: var(--radius-sm);
  box-shadow: var(--shadow-pop); overflow: hidden;
  max-width: calc(100vw - 16px);
}
/* Above the date panel (1300) and Bootstrap's modal (1055): a select inside a
   dialog must not open behind the dialog it belongs to. */

.cs-search { display: flex; align-items: center; gap: .4rem; padding: .45rem .6rem; border-bottom: 1px solid var(--line); }
.cs-search i { color: var(--faint); font-size: .78rem; }
.cs-search input {
  border: none; outline: none; width: 100%; font-size: .82rem;
  color: var(--body); background: none; padding: .1rem 0;
}
.cs-search input::placeholder { color: var(--faint); }

/* Short lists keep the input - typing still filters - but collapse it out of
   sight. display:none would make it unfocusable and the keyboard path would die
   with it, so it is squashed to nothing instead. */
.cs-pop.cs-quiet .cs-search {
  height: 0; padding: 0; border-bottom: none; overflow: hidden; opacity: 0;
}

.cs-list { max-height: 260px; overflow-y: auto; padding: .25rem; }
.cs-opt {
  padding: .4rem .6rem; font-size: .82rem; color: var(--body);
  border-radius: 6px; cursor: pointer; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
}
/* One highlight, driven by the keyboard. Hover deliberately does NOT paint its
   own: with both, the mouse resting anywhere over the list makes it impossible
   to see which option Enter would take. */
.cs-opt.is-active { background: var(--accent-wash); color: var(--accent); font-weight: 600; }
.cs-opt.is-sel::after { content: '\F26E'; font-family: 'bootstrap-icons'; float: right; opacity: .7; font-size: .72rem; }
.cs-empty { padding: .6rem; font-size: .78rem; color: var(--faint); text-align: center; }

@media (max-width: 575px) {
  .cs-list { max-height: 45vh; }
  .cs-opt { padding: .55rem .6rem; }     /* touch target */
}

/* ==========================================================================
   15b. Step rail - the multi-step entry form
   ==========================================================================
   A RAIL, NOT A TAB BAR. Tabs say "these panes are peers, go anywhere"; the
   production entry's three steps are not peers - the hourly counts mean nothing
   before the machine and cycle time that give them a target, and Review has
   nothing to review until both are in. So the steps are numbered, ordered, and
   locked until earned. (`.tab-bar` / `.tab-btn` / `.tab-panel` were deleted in
   session 6 and are not coming back; there was nothing to reuse either way.)

   ONE set of markup, three shapes:
     <768   a single "Step 1 of 3 · Basic Details" line and a progress bar.
            Three items with hints is 200px of a 640px phone viewport spent on
            navigation, on a screen you opened to type into.
     768-991 a horizontal strip of three, hints dropped.
     ≥992   the vertical rail, 220px, sticky so it stays with you down a long
            form. Affordable on a laptop; a third of a 375px screen, which is
            why it is not there.                                              */
.step-wrap { display: flex; flex-direction: column; gap: 1rem; }
/* ⚠ THE LEFT COLUMN IS .step-side, NOT .step-rail. The rail is the nav alone;
   the side is the nav plus anything that belongs beside the form - today the
   bulk-entry block on a new entry. It exists because .step-wrap is a flex ROW
   from 992px, so a second block added as a child of it becomes a third column
   and takes its width out of the form. The 220px basis and the sticky both live
   here for the same reason: they describe the COLUMN, and the rail is only its
   first occupant. */
.step-side { display: flex; flex-direction: column; gap: 1rem; width: 100%; min-width: 0; }
.step-rail { width: 100%; }
/* min-width:0 or a wide select inside a flex child refuses to shrink and pushes
   the whole page sideways - the one thing R9 forbids outright. */
.step-body { width: 100%; min-width: 0; }

.step-item {
  display: none;                       /* the phone shows .step-mini instead */
  width: 100%; align-items: center; gap: .65rem;
  padding: .55rem .7rem; border: 1px solid transparent;
  border-radius: var(--radius-sm); background: transparent;
  text-align: left; cursor: pointer; font-size: .82rem;
}
.step-num {
  flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: .76rem; font-weight: 700; line-height: 1;
  background: var(--n-250); color: var(--muted); transition: background .15s, color .15s;
}
.step-txt { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.step-name {
  font-weight: 600; color: var(--body);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.step-hint { display: none; font-size: .69rem; color: var(--faint); }

.step-item.is-current { background: var(--accent-wash); border-color: var(--accent-soft); }
.step-item.is-current .step-num { background: var(--accent); color: #fff; }
.step-item.is-current .step-name { color: var(--ink-700); }

/* Done keeps its NUMBER and only changes colour. A tick would say more, but it
   would also mean hard-coding a Bootstrap Icons codepoint into a ::before, and a
   codepoint guessed wrong renders as an empty box on every completed step. */
.step-item.is-done .step-num { background: var(--ok-bg); color: var(--ok-ink); }

.step-item.is-locked { cursor: not-allowed; opacity: .5; }
.step-item.is-locked .step-name { color: var(--muted); }
.step-item:not(.is-locked):not(.is-current):hover { background: var(--n-50); }

/* The phone's whole rail. Its bar is the only thing left saying how far in you
   are once the numbered list is gone. */
.step-mini {
  background: var(--card-bg); border: 1px solid var(--card-border);
  border-radius: var(--radius-sm); box-shadow: var(--shadow-card);
  padding: .6rem .8rem;
}
.step-mini-row { display: flex; align-items: baseline; gap: .5rem; }
.step-mini-count {
  font-size: .66rem; font-weight: 700; letter-spacing: .6px;
  text-transform: uppercase; color: var(--faint); flex: 0 0 auto;
}
.step-mini-name {
  font-size: .86rem; font-weight: 600; color: var(--ink-700);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.step-mini-bar {
  display: block; height: 4px; margin-top: .45rem;
  border-radius: 2px; background: var(--n-250); overflow: hidden;
}
.step-mini-bar i { display: block; height: 100%; background: var(--accent); transition: width .2s ease; }

.step-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-top: 1rem; }

/* ⚠ DOCUMENT ORDER IS SAVE, PREVIOUS, CANCEL; `order` paints them the other way
   round. Tab from the form's last field has to reach Save & Continue rather than
   Cancel (the user's call, 2026-08-06), and document order is the honest way to
   set tab order - scattering tabindex values to fix a sequence is how a form
   ends up with a tab order nobody can predict.

   Visually: Previous and Cancel hard left, Save hard right. Opposite intentions
   must not read as a pair of adjacent buttons you might hit either of - the same
   call the filter panel's footer makes. */
.step-actions .sa-prev        { order: 1; }
.step-actions .sa-cancel      { order: 2; }
.step-actions .footer-actions { order: 3; margin-left: auto; }

@media (max-width: 575px) {
  /* ⚠ ONE SHARED ROW AT EVERY WIDTH (the user's call, 2026-08-17).
     This block used to give Save its own full-width row, on the stated grounds
     that "at 375px three buttons on one line leaves each too narrow to carry
     its label". Measured, that is not true - it assumed the three would split
     the row equally. Their natural widths at 375px are Previous 88, Cancel 59
     and Save 152; with two 8px gaps that is 315px inside a 359px row.

     What it cost was worse than the space it saved: with Previous hidden on
     step 1, the row above held nothing but a 59px Cancel pinned left, so the
     rare action that loses your work sat alone ABOVE the one taken every time.

     Save keeps `order: 3` and stays hard right, and GROWS into whatever is
     left - 292px on step 1, 196px with Previous showing - so the primary is
     always the widest target on the row rather than leaving a gap beside it.
     flex-wrap on the parent is the safety net below 375px: Save drops to its
     own line rather than crushing its label. */
  .step-actions .footer-actions { flex: 1 1 auto; }
  .step-actions .footer-actions .btn { width: 100%; }

  /* ⚠ .sa-plain OPTS OUT OF THE STRETCH ABOVE, and exists because the rule is
     right for the entry form and wrong everywhere else. There, Save is the
     repeated action across three steps and being the widest target on the row
     is a help. On a screen whose primary button is a ONE-SHOT - bulk entry
     books every machine you ticked and consumes a production number for each -
     a 292px slab beside a 59px Cancel is not emphasis, it is a button that
     looks like the only thing on the row. Natural width, hard right. */
  .step-actions.sa-plain .footer-actions { flex: 0 0 auto; }
  .step-actions.sa-plain .footer-actions .btn { width: auto; }
}

@media (min-width: 768px) {
  .step-mini { display: none; }
  .step-item { display: flex; flex: 1 1 0; min-width: 0; }
  .step-rail {
    display: flex; gap: .35rem; padding: .5rem;
    background: var(--card-bg); border: 1px solid var(--card-border);
    border-radius: var(--radius); box-shadow: var(--shadow-card);
  }
}

@media (min-width: 992px) {
  .step-wrap { flex-direction: row; gap: 1.25rem; align-items: flex-start; }
  /* The column's width and its sticky behaviour - see the note on .step-side.
     The rail keeps only what describes the rail itself. */
  .step-side { flex: 0 0 220px; width: 220px; position: sticky; top: 1rem; }
  .step-rail { flex-direction: column; padding: .6rem; }
  .step-item { flex: 0 0 auto; }
  .step-hint { display: block; }
}

/* ── The bulk breakdown entry block ──────────────────────────────────────────
   Sits under the step rail on a NEW entry only. Styled as a quiet card rather
   than a button: it is an alternative route through the module, not an action
   on the form beside it, and a filled button here would compete with Save. */
.bk-cta {
  display: flex; align-items: center; gap: .65rem;
  padding: .65rem .7rem; text-decoration: none;
  background: var(--card-bg); border: 1px solid var(--card-border);
  border-radius: var(--radius); box-shadow: var(--shadow-card);
  transition: border-color .15s, background .15s;
}
.bk-cta:hover { background: var(--accent-wash); border-color: var(--accent); }
.bk-cta-icon {
  flex-shrink: 0; display: flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; border-radius: 50%;
  background: var(--accent-wash); color: var(--accent); font-size: .9rem;
}
.bk-cta-txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: .1rem; }
.bk-cta-name { font-size: .82rem; font-weight: 600; color: var(--body); line-height: 1.2; }
.bk-cta-hint { font-size: .7rem; color: var(--muted); line-height: 1.3; }
.bk-cta-go { flex-shrink: 0; color: var(--faint); font-size: .75rem; }
.bk-cta:hover .bk-cta-go { color: var(--accent); }

/* ── Hourly count grid - step 2 of the production entry ───────────────────────
   ONE LINE PER HOUR, and the line has TWO HALVES.

   Left, what the hour PRODUCED: the label, whether it was worked, the quantity,
   and the running total that quantity produces. The reference screen splits
   those last two into a left column of twelve inputs and a right column of
   twelve read-only boxes, which puts hour 7's output and hour 7's total 700px
   apart with nothing joining them. Reading across one row is the whole point.

   Right, tinted, what the hour LOST (2026-08-06): one reason, the minutes, and
   the supervisor who approved it. The tint is the whole reason the two halves
   can share a line without being read as one list of seven equal fields - it
   says "different kind of fact" before anything is read.

   ⚠ THE TINT IS AMBER, NOT RED. Lost minutes are a normal, expected, recorded
   part of a shift; red is for something being wrong with the form.

   ⚠ ONE TABLE, NOT TWELVE CARDS (2026-08-06, the user's call). Each hour was a
   separately bordered, rounded, gapped card, and twelve of them read as twelve
   unrelated forms rather than one register - which is what a shift sheet is. So:
   one bordered container, a shaded header, and rows separated by a single hair
   line. The gap is ZERO on purpose - it is what lets the tinted rejection cells
   meet and form a continuous column band down the table.

   THREE SHAPES. Above 1200 the whole line fits. From 576 to 1199 the tinted half
   drops to a second line of its own, full width, still tinted, so it stays
   attached to its hour. Below 576 everything folds to one control per line -
   seven columns at 375px is what R9 forbids, and shrinking them to fit gives
   inputs too narrow to show four digits.                                       */
.hour-grid {
  display: flex; flex-direction: column; gap: 0;
  background: #fff;
  border: 1px solid var(--card-border); border-radius: var(--radius-field);
  overflow: hidden;                    /* the band and the rows meet the corners */
}

.hour-head, .hour-row, .hour-foot {
  display: grid;
  /* Qty and Min are three or four digits and no more, so they are narrow on
     purpose - a wide box invites a number that does not belong in it. */
  grid-template-columns: minmax(110px, 1fr) 58px 66px 86px minmax(300px, 1.7fr);
  gap: .45rem; align-items: center;
}
.hour-head {
  padding: .4rem .55rem;
  background: var(--n-50); border-bottom: 1px solid var(--card-border);
  font-size: .66rem; font-weight: 700; letter-spacing: .5px;
  text-transform: uppercase; color: var(--faint);
  /* Every heading centred, on the user's instruction (2026-08-06). The cells
     below follow their own heading rather than the column's data type - a
     centred heading over a right-aligned column reads as a mistake. */
  text-align: center;
}
.hour-head > span:first-child { text-align: left; }

/* The header's three sub-columns must line up with the controls beneath them, so
   it repeats .hour-rej's track sizes rather than guessing at them - and carries
   the same tint, so the band starts at the header rather than under it. The
   the same tint, so the band starts at the header rather than under it. */
.hour-head-rej {
  display: grid;
  grid-template-columns: minmax(130px, 1.4fr) 58px minmax(120px, 1.2fr);
  gap: .4rem;
  align-self: stretch; align-items: center;
  /* Right margin cancels the header's own padding so the band runs clean to the
     container edge instead of stopping 9px short of it. */
  margin: -.4rem -.55rem -.4rem 0; padding: .4rem .55rem .4rem .5rem;
  background: var(--warn-bg); border-left: 1px solid var(--warn-200);
}

.hour-row {
  padding: .3rem .55rem;
  border-bottom: 1px solid var(--line);
}
.hour-row:last-child { border-bottom: none; }
/* No border to recolour any more, so the whole row washes instead. */
.hour-row:focus-within { background: var(--accent-wash); }
.hour-row.is-off { background: var(--n-50); }

/* The label cell carries the time AND the row's cycle marker - the flag that
   ends the cycle here, or the production number that already owns this hour.
   They share a cell rather than taking a seventh grid column: a column costs
   width in all three shapes of this row, and below 576 the label already spans
   the full width so the marker rides along with no layout of its own. */
.hour-lbl {
  display: flex; align-items: center; justify-content: space-between; gap: .4rem;
  font-size: .8rem; font-weight: 600; color: var(--body); min-width: 0;
}
.hl-time { white-space: nowrap; }
.hour-row.is-off .hour-lbl { color: var(--muted); }

/* ── Where the cycle ends ─────────────────────────────────────────────────────
   A machine can change job mid-shift, so one entry covers 08:00-12:00 and the
   next covers 12:00-20:00. The flag states where this one stops; the tag names
   the entry that owns an hour this one cannot use. */

/* ⚠ ALWAYS VISIBLE, NEVER HOVER-REVEALED - the same call the calculator's
   favourite stars settled: :hover does not fire on the touch screens this is
   used on, so a control that appears on hover is a control that does not exist.
   Faint until it means something, full strength once it does. */
.hl-end {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; padding: 0;
  border: none; background: none; border-radius: var(--radius-field);
  font-size: .8rem; line-height: 1; color: var(--faint); opacity: .45;
  cursor: pointer; transition: opacity .12s, color .12s, background-color .12s;
}
.hl-end:hover, .hl-end:focus-visible { opacity: 1; color: var(--accent); background: var(--accent-wash); }
.hour-row.is-end .hl-end { opacity: 1; color: var(--accent); }

/* The production number that owns this hour. Not a link: the way to take an
   hour back is to shorten the cycle holding it, on that entry - and a link out
   of a half-filled form is an invitation to lose what is in it. */
.hl-tag {
  flex: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
  font-size: .62rem; font-weight: 700; letter-spacing: .3px; white-space: nowrap;
  padding: .1rem .3rem; border-radius: 4px;
  background: var(--n-250); color: var(--ink-700);
}

/* ⚠ THE CUT LINE IS WHAT MAKES THE SPAN READABLE AT A GLANCE. Twelve rows with
   eight of them greyed says "eight hours idle" just as loudly as twelve blank
   boxes did; the rule under the last hour of the cycle is what says "and this is
   where it stopped". */
.hour-row.is-end { border-bottom: 2px solid var(--accent); }

/* An hour outside this cycle. Both states wash out, and both keep their time
   legible - the row still has to be readable, because reading it is how somebody
   works out that the shift was split. */
.hour-row.is-claimed,
.hour-row.is-after-end { background: var(--n-100); }
.hour-row.is-claimed .hl-time,
.hour-row.is-after-end .hl-time { color: var(--faint); font-weight: 500; }

/* ⚠ THE REJECTION HALF DIMS HERE AND ONLY HERE. .is-off deliberately leaves it
   at full contrast - an unworked hour is exactly where "No Operator, 60 minutes"
   belongs - but an hour belonging to another production number is not this
   entry's to explain at all. */
.hour-row.is-claimed .hour-rej,
.hour-row.is-after-end .hour-rej { opacity: .5; }

/* The register's split-shift marker, beside the production number. Deliberately
   quiet - it is a "there is more to this day" note, not a warning. */
.cycle-chip {
  display: inline-flex; align-items: center; gap: .1rem;
  font-size: .62rem; font-weight: 700; vertical-align: middle;
  padding: .05rem .28rem; border-radius: 4px;
  background: var(--accent-100); color: var(--ink-600);
}
.cycle-chip i { font-size: .72rem; }

/* The word "Worked" is gone from every row (the user's call, 2026-08-06) - the
   column heading says it once, twelve times was noise, and it cost 30px of a row
   that has seven things in it. The input keeps its aria-label, and below 576px
   where the heading is hidden the ::before below puts a word back. */
.hour-chk {
  display: inline-flex; align-items: center; justify-content: center;
  font-size: .74rem; color: var(--muted); cursor: pointer; user-select: none;
}
.hour-chk input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; margin: 0; }

.hour-in {
  width: 100%; text-align: center;
  border: 1.5px solid var(--field-border); border-radius: var(--radius-field);
  padding: 4px 5px; font-size: .85rem; color: var(--body); background: #fff;
  font-variant-numeric: tabular-nums;
}
.hour-in:focus { outline: none; border-color: var(--accent); }
.hour-in:disabled { background: var(--n-100); color: var(--faint); cursor: not-allowed; }
/* No spinner. Twelve of them down the column is twelve controls nobody presses,
   and each one steals 16px from a field that has to hold six digits. */
.hour-in[type=number] { -moz-appearance: textfield; appearance: textfield; }
.hour-in::-webkit-outer-spin-button,
.hour-in::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* tabular-nums so the running total forms a straight edge down the column - it
   is read as a sequence, and proportional digits make it ripple. Centred rather
   than right-aligned since the headings were centred (2026-08-06): tabular
   figures in a fixed 86px column still line up, and a centred heading over a
   right-aligned column reads as a mistake. */
.hour-cum {
  text-align: center; font-size: .85rem; font-weight: 600;
  color: var(--ink-700); font-variant-numeric: tabular-nums;
}
.hour-row.is-off .hour-cum { color: var(--faint); }
.hour-cum::before { content: none; }

/* ── The rejection half ───────────────────────────────────────────────────────
   ⚠ NOT dimmed by .is-off. An hour that was not worked is exactly where "No
   Operator - 60 minutes" belongs, so only the quantity greys out; this block
   stays live at full contrast. That is the user's decision of 2026-08-06 and it
   is the one thing about this component not to "tidy up". */
/* ⚠ THE TOTALS ROW TAKES THIS RULE, NOT .hour-head-rej's. The two differ by the
   0.1rem of left padding each needs to cancel its own parent's, and the header
   therefore sits 1px off the inputs below it. A heading may; a SUM may not - it
   has to line up with the twelve figures it adds together, which is the whole
   argument for putting it at the foot of the column. Measured: 1px out against
   the header's padding, exact against the row's.                              */
.hour-rej, .hour-foot-rej {
  display: grid;
  grid-template-columns: minmax(130px, 1.4fr) 58px minmax(120px, 1.2fr);
  gap: .4rem; align-items: center;
  /* Stretched to the full row height with the row's own padding cancelled, so
     twelve of these MEET and read as one continuous column band rather than
     twelve floating chips. That is the whole reason .hour-grid has gap: 0. */
  align-self: stretch;
  margin: -.3rem -.55rem -.3rem 0; padding: .3rem .55rem .3rem .4rem;
  background: var(--warn-bg);
  border-left: 1px solid var(--warn-200);
}
/* Filled in, so it earns a little more presence than an empty one. */
.hour-rej.has-rej { background: var(--warn-100); }

.hour-sel {
  width: 100%; min-width: 0;
  border: 1.5px solid var(--field-border); border-radius: var(--radius-field);
  padding: 4px 6px; font-size: .8rem; color: var(--body); background: #fff;
  /* Truncated rather than allowed to widen the column: "Machine Cleaning" and a
     six-word supervisor name must not decide the row height. */
  text-overflow: ellipsis;
}
.hour-sel:focus { outline: none; border-color: var(--accent); }
.hour-sel:disabled { background: var(--n-50); color: var(--faint); cursor: not-allowed; }

/* ── The two totals, at the foot of the columns that make them ─────────────
   The user's instruction, 2026-08-24. Total produced sits under Qty., total
   breakdown minutes under Min. - each directly below the twelve figures it is
   the sum of. Both were tiles in the card underneath the grid until then, and a
   tile states a figure without saying where it came from.

   ⚠ IT IS NOT ITS OWN GRID. It is named beside .hour-row in every rule that
   sizes those tracks, at every breakpoint, so a retuned column can never leave a
   total sitting out of line with the numbers above it.

   No border-top: the last hour row keeps its own hair line once something
   follows it, and two rules stacked read as one 2px rule.                      */
.hour-foot {
  /* Matches .hour-row's padding exactly - .hour-foot-rej's negative margins are
     cut to cancel that figure, and a different one here breaks the amber band's
     right edge as well as the alignment. */
  padding: .3rem .55rem;
  background: var(--n-100);
}
/* Right-aligned so it butts against the figure it names, and set as a column
   heading rather than as a sentence - it is a label. Spans the Hour and Worked
   tracks: neither has a total, and a caption confined to the first one sits a
   column away from its own number. */
.hour-foot > .hf-cap { grid-column: span 2; }
.hf-cap {
  text-align: right; min-width: 0;
  font-size: .66rem; font-weight: 700; letter-spacing: .5px;
  text-transform: uppercase; color: var(--faint);
}
/* Centred on the column above it, tabular so the digits sit under the digits. */
.hf-val {
  text-align: center; font-size: .95rem; font-weight: 700;
  color: var(--ink-700); font-variant-numeric: tabular-nums;
}
/* Amber only once the shift has lost something - a zero in a warning colour is
   a colour people stop reading. */
.hf-val.is-on { color: var(--warn-ink); }
/* The tracks with no total under them. Present so the row's five columns and the
   band's three still resolve; gone below 576 where the row becomes label/value */
.hf-gap { display: block; }

@media (max-width: 1199px) {
  .hour-head, .hour-row, .hour-foot {
    grid-template-columns: minmax(120px, 1.2fr) 66px 74px minmax(80px, .8fr);
  }
  /* On its own line the band has nothing to meet, so it becomes a block again -
     inset, rounded and fully bordered, which is what an isolated tinted area
     should look like. */
  .hour-head-rej, .hour-rej, .hour-foot-rej { grid-column: 1 / -1; }
  .hour-head-rej, .hour-rej, .hour-foot-rej {
    align-self: auto; margin: .25rem 0 0;
    border: 1px solid var(--warn-200); border-radius: var(--radius-field);
  }
  .hour-head-rej { text-align: center; }
}

@media (max-width: 575px) {
  .hour-head { display: none; }
  .hour-row { grid-template-columns: auto 1fr; gap: .3rem .5rem; padding: .5rem .6rem; }
  .hour-lbl { grid-column: 1 / -1; }
  .hour-cum {
    grid-column: 1 / -1;
    display: flex; align-items: baseline; justify-content: space-between;
  }
  /* The column header is gone, so the number carries its own label again. */
  .hour-cum::before {
    content: 'Total after'; font-size: .66rem; font-weight: 700;
    letter-spacing: .5px; text-transform: uppercase; color: var(--faint);
  }
  /* Same reason: with the heading hidden, a bare checkbox says nothing about
     what ticking it means. This is the word "Worked" earning its place in the
     one place it still has to. */
  .hour-chk { justify-content: flex-start; gap: .35rem; }
  .hour-chk::after {
    content: 'Worked'; font-size: .7rem; font-weight: 600;
    letter-spacing: .3px; color: var(--muted);
  }
  /* One control per line. The three carry their own placeholder text - Reason,
     Min, Approver - so nothing here needs the header that has just been hidden. */
  .hour-rej { grid-template-columns: 1fr; gap: .3rem; }
  .hour-min { text-align: left; }

  /* The columns are gone, so the two totals stop being column feet and become
     what every other figure on a phone is: a label with its value beside it. */
  .hour-foot { grid-template-columns: 1fr auto; gap: .3rem .5rem; padding: .5rem .6rem; }
  .hour-foot > .hf-cap { grid-column: 1; }
  .hour-foot-rej { grid-template-columns: 1fr auto; }
  .hf-cap { text-align: left; }
  .hf-val { text-align: right; }
  .hf-gap { display: none; }
}

/* An hour over the 60-minute ceiling is marked on the keystroke. .hour-in is not
   a .form-control, so Bootstrap's .is-invalid does not reach it. */
.hour-in.is-invalid { border-color: var(--danger); background: var(--danger-bg); }

/* ── Rejection quantity - .rej-qty ────────────────────────────────────────────
   PIECES rejected across the entry, under the hourly grid. A separate card from
   the grid because it is a separate fact: the hour rows say why minutes were
   lost, this says how many pieces did not pass, and only this one changes a
   quantity.

   Amber like the hourly block, so "rejection" looks like one idea across the
   screen even though the two hold different things.                            */
.rej-qty {
  margin-top: .9rem; padding: .7rem .75rem;
  background: var(--warn-bg); border: 1px solid var(--warn-200);
  border-radius: var(--radius-field);
}
.rej-qty-hd {
  display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap;
  margin-bottom: .6rem;
}
.rej-qty-hd h6 {
  margin: 0; font-size: .8rem; font-weight: 700;
  color: var(--warn-ink); text-transform: uppercase; letter-spacing: .5px;
}

/* The outcome of the six fields above it, so it is separated by a rule and
   weighted like a total rather than like another field. */
.rej-qty-out {
  display: flex; align-items: center; justify-content: left;
  gap: .5rem; flex-wrap: wrap;
  margin-top: .7rem; padding-top: .6rem;
  border-top: 1px solid var(--warn-200);
}
.rq-lbl { font-size: .78rem; font-weight: 600; color: var(--warn-ink); }
.rq-val {
  font-size: 1.15rem; font-weight: 700; color: var(--ink-700);
  font-variant-numeric: tabular-nums;
}
/* More rejected than produced. Refused on save; said in colour before then. */
.rej-qty.over { border-color: var(--danger); }
.rej-qty.over .rq-val { color: var(--danger); }

/* ── Auto-advance toggle - .hour-auto ─────────────────────────────────────────
   A button that reports a STATE, so it has to look different when it is on -
   `aria-pressed` is the state, and styling from it means the two can never
   disagree. It replaced Fill down, which was an action and needed none of this. */
.hour-auto { display: inline-flex; align-items: center; gap: .4rem; }
.hour-auto[aria-pressed="true"] {
  background: var(--accent-tint); border-color: var(--accent-soft); color: var(--ink-700);
}
.ha-state {
  padding: 0 .4rem; border-radius: 999px;
  background: var(--n-250); color: var(--muted);
  font-size: .64rem; font-weight: 700; letter-spacing: .5px; text-transform: uppercase;
}
.hour-auto[aria-pressed="true"] .ha-state { background: var(--accent); color: #fff; }

/* ── Total breakdown minutes, reason by reason ─────────────────────────
   EVERY configured reason, in the Configuration list's own order, whether this
   entry used it or not (the user's instruction, 2026-08-24). It replaced a chip
   row carrying the reasons in use, heaviest first - a correct ranking and a
   useless place to look, because a reason changed position on every entry and
   was absent altogether on the entries where it cost nothing. A fixed slot is
   what the printed form has always had, so the two now read the same way.

   ⚠ THE CARD USED TO HOLD SIX FIGURE TILES AS WELL - produced, rejected, after
   rejection, rework, hours worked, ending count. All six are gone (the user's
   instruction, 2026-08-24): the two totals worth having moved to the foot of
   their own columns inside the grid (.hour-foot), and the rest were each already
   stated somewhere the eye was going anyway. What is left is one band, so the
   card is a block rather than a three-column grid.

   ⚠ AND THE TOTAL IS NOT PRINTED IN THE HEADING ANY MORE, for the reason it was
   put there in the first place: one figure, in one place. The heading names what
   the band is instead.                                                         */
.hour-sum {
  margin-top: .9rem; padding: .7rem .75rem;
  background: var(--accent-wash); border: 1px solid var(--card-border);
  border-radius: var(--radius-field);
}
.hs-lbl {
  font-size: .64rem; font-weight: 700; letter-spacing: .5px;
  text-transform: uppercase; color: var(--faint);
}

.hs-brk { display: flex; flex-direction: column; gap: .4rem; }
.hs-brk-hd { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }

/* 140px is what puts TWO slots on a 375px phone - the longest seeded reason
   (Machine Cleaning) measures 100px beside a three-digit figure, and the track
   is 148px there. auto-fill, not auto-fit: the tracks must keep their width when
   a plant configures three reasons rather than twelve, or three slots stretch
   across the whole band and read as three cells of their own rather than as a
   list of twelve that happens to be short. */
.hs-brk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .3rem .4rem; }
.hs-brk-item {
  display: flex; align-items: baseline; justify-content: space-between; gap: .4rem;
  min-width: 0; padding: 3px 8px; border-radius: var(--radius-field);
  background: var(--card-bg); border: 1px solid var(--card-border);
}
/* Wraps rather than ellipsises: a reason is added from the Configuration screen
   and nothing caps its length, and half a name is worse than two lines. */
.hs-brk-name {
  min-width: 0; font-size: .72rem; font-weight: 600; color: var(--muted);
  overflow-wrap: anywhere;
}
.hs-brk-min {
  font-size: .82rem; font-weight: 700; color: var(--faint);
  font-variant-numeric: tabular-nums;
}
/* Only a slot that cost the shift something is coloured - twelve amber boxes
   are twelve things to read, one amber box is the answer. */
.hs-brk-item.is-on { background: var(--warn-bg); border-color: var(--warn-200); }
.hs-brk-item.is-on .hs-brk-name,
.hs-brk-item.is-on .hs-brk-min { color: var(--warn-ink); }
/* The empty state is a sentence, so it takes the whole band - left in a 152px
   track it would set six words over five lines. */
.hs-brk-none { grid-column: 1 / -1; font-size: .78rem; }


/* ==========================================================================
   15b-2. Production entry, step 3 - the review - .rv-*
   ==========================================================================
   Read-only, and rendered from the entry AS STORED. Its blocks are the printed
   daily report's blocks in the same order - job details, hour by hour, downtime,
   rejection, then the derived figures - so what somebody checks on screen is
   what they will later sign on paper. It is drawn in this application's own
   language rather than as a copy of that form: print-report.css reproduces a
   controlled document and was explicitly not a precedent for screens, and the
   form's fixed 47-row grid does not survive 375px.

   Nothing here is a card of its own. The step panel is already a .section-card,
   and five nested cards inside it is the boxes-in-boxes look R9 exists to avoid;
   the blocks are separated by a rule instead.                                  */

.rv-block + .rv-block,
.rv-block + .rv-pair,
.rv-pair  + .rv-block {
  margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--card-border);
}

/* -- Breakdown beside Rejection - .rv-pair ------------------------------------
   The user's instruction, 2026-08-24: the two halves of what a shift lost -
   minutes on one side, pieces on the other - share the screen 50/50. Read
   together, and neither fills a 1366px row on its own: the band is two rows of
   slots and the rejection block is a list plus four sums, so stacked they left a
   column of white space each, which is what R9 forbids.

   ⚠ THE PAIR IS THE BLOCK-LEVEL UNIT, which is why the rule above names it on
   both sides. Get that wrong and the horizontal rule that separates blocks lands
   INSIDE the pair, one column starting a rule lower than the other.

   Above 992 the divider is VERTICAL and belongs to the second column - the pair
   is left on the default `stretch`, deliberately, so that rule runs the full
   height of the taller half whichever half that is. Below 992 the pair collapses
   to one column and the ordinary horizontal rule comes back, which is the whole
   of what the media query undoes. 992 is the breakpoint .rv-calc already uses:
   two 500px halves are fine, two 180px halves are not.

   `min-width: 0` because a grid item's automatic minimum is its content, and the
   rejection block's sums do not wrap.                                          */
.rv-pair { display: grid; grid-template-columns: 1fr; }
.rv-pair > .rv-block { min-width: 0; }
@media (min-width: 992px) {
  .rv-pair { grid-template-columns: 1fr 1fr; column-gap: 1.75rem; }
  .rv-pair > .rv-block + .rv-block {
    margin-top: 0; padding-top: 0; border-top: none;
    padding-left: 1.75rem; border-left: 1px solid var(--card-border);
  }
}
.rv-hd {
  display: flex; align-items: center; justify-content: space-between;
  gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem;
}
.rv-hd h6 {
  margin: 0; font-size: .78rem; font-weight: 700;
  color: var(--ink-700); text-transform: uppercase; letter-spacing: .5px;
}
.rv-sub {
  font-size: .7rem; font-weight: 400; color: var(--muted);
  text-transform: none; letter-spacing: 0;
}

/* ── Job details - every value is a way back to the field that holds it ───────
   A button, not a link with an href: it moves between steps of a form that is
   already open. The pencil appears on hover and on keyboard focus only - twelve
   permanent pencils read as twelve things to do, when the answer is usually
   "these are all correct".                                                     */
.rv-grid {
  display: grid; gap: .25rem;
  grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
}
.rv-item {
  position: relative;
  display: flex; flex-direction: column; gap: .1rem;
  width: 100%; min-width: 0; text-align: left;
  padding: .35rem .5rem;
  background: transparent; border: 1px solid transparent;
  border-radius: var(--radius-field);
  font: inherit; color: inherit;
}
.rv-item:not(.is-static):hover {
  background: var(--accent-wash); border-color: var(--card-border); cursor: pointer;
}
.rv-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.rv-item .bi-pencil {
  position: absolute; top: .4rem; right: .45rem;
  font-size: .7rem; color: var(--accent); opacity: 0; transition: opacity .12s ease;
}
.rv-item:hover .bi-pencil,
.rv-item:focus-visible .bi-pencil { opacity: 1; }

.rv-lbl {
  font-size: .64rem; font-weight: 700; letter-spacing: .5px;
  text-transform: uppercase; color: var(--faint);
}
.rv-val {
  font-size: .88rem; font-weight: 600; color: var(--ink-700);
  font-variant-numeric: tabular-nums; overflow-wrap: anywhere;
}
.rv-val small { font-size: .7rem; font-weight: 600; color: var(--muted); }
/* An em dash reads as "answered, with nothing" where a blank reads as unfinished. */
.rv-dash { color: var(--faint); font-weight: 500; }

/* ── Hour by hour ─────────────────────────────────────────────────────────────
   Inside .table-wrap like every other table here, so the seven columns scroll
   sideways within the card and never widen the page (R9). An hour that was not
   worked is greyed and shows no counter reading - repeating the previous one
   would suggest a reading was taken.                                           */
.rv-table tbody td, .rv-table thead th { vertical-align: middle; }
.rv-n { text-align: right; font-variant-numeric: tabular-nums; }
.rv-table tr.rv-off td { background: var(--n-50); }
/* ⚠ ONLY THE HOUR'S OWN THREE CELLS GREY OUT. The rejection half of an unworked
   hour must read at full strength - "No Operator, 60 minutes" on an empty hour is
   the most valuable line on the row, and dimming it hides exactly the thing the
   row exists to say. The same rule .hour-row.is-off follows on step 2, where
   .hour-rej is deliberately left alone. */
.rv-table tr.rv-off td:nth-child(-n+3) { color: var(--muted); }
.rv-table .rv-act { text-align: right; padding-top: .15rem; padding-bottom: .15rem; }
.rv-table .rv-act .btn { padding: .1rem .4rem; line-height: 1.2; }
.rv-table tfoot .rv-tot td {
  background: var(--accent-wash); font-weight: 700; color: var(--ink-700);
  border-top: 1px solid var(--card-border);
}

/* ── Downtime and rejection lists ─────────────────────────────────────────── */
.rv-rows { display: flex; flex-direction: column; }
.rv-row {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: .75rem; padding: .3rem .1rem; border-bottom: 1px solid var(--line);
  font-size: .83rem;
}
.rv-row:last-child { border-bottom: none; }
.rv-row-lbl { color: var(--body); min-width: 0; }
.rv-row-lbl small { color: var(--muted); font-size: .7rem; }
.rv-row-val {
  font-weight: 700; color: var(--ink-700); font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.rv-row-val small { font-size: .7rem; font-weight: 600; color: var(--muted); }
.rv-row.is-total {
  margin-top: .2rem; padding-top: .45rem;
  border-top: 1px solid var(--card-border); border-bottom: none;
}
.rv-row.is-total .rv-row-lbl { font-weight: 700; color: var(--ink-700); }
/* Produced, less rejected, equals after rejection - set apart so the three read
   as one piece of arithmetic rather than as three more list rows. */
.rv-sums {
  margin-top: .7rem; padding: .5rem .7rem;
  background: var(--accent-wash); border: 1px solid var(--card-border);
  border-radius: var(--radius-field);
}
.rv-tag {
  display: inline-block; margin-left: .3rem; padding: 0 .4rem;
  border-radius: 999px; background: var(--n-250); color: var(--muted);
  font-size: .62rem; font-weight: 700; letter-spacing: .3px; text-transform: uppercase;
}
.rv-empty { margin: 0; padding: .35rem .1rem; font-size: .8rem; color: var(--muted); }

/* ── The calculations ─────────────────────────────────────────────────────────
   Label above its working, result held at the right. ONE layout at every width:
   the working is a sentence and wraps, so nothing here needs a breakpoint - and
   a three-column table of formulas is the first thing to break at 375px.

   The result column carries the tone, and only on the loss/increase line. A
   shift that beat its target and a shift that missed it are the same fact said
   twice if the efficiency is coloured as well, and two signals for one fact is
   how people stop reading both.                                                */
/* ⚠ TWO COLUMNS OF ROWS FROM 992px, AND THE FIGURE SITS BESIDE ITS OWN LABEL
   (the user's instruction, 2026-08-24: "figures need to be near by so you can
   create two columns"). Before this the block was one full-width column with
   `.rv-res` thrown to the far right by `justify-self: end` across a 1000px row -
   so a label and the number it names could be half a screen apart, and fourteen
   of them ran off the bottom of the card.

   The row is a 2x2 grid: label | value on the first line, the working | its hour
   restatement on the second. `.rv-res` no longer spans both rows - being level
   with the label is the whole point.

   One column below 992: two 500px halves are fine, two 180px halves are not, and
   the working line is prose-length. */
.rv-calc { display: grid; grid-template-columns: 1fr; column-gap: 1.75rem; }
@media (min-width: 992px) { .rv-calc { grid-template-columns: 1fr 1fr; } }
.rv-calc-row {
  display: grid; grid-template-columns: minmax(0, 1fr) auto;
  align-items: baseline; gap: .1rem .75rem;
  padding: .45rem .1rem; border-bottom: 1px solid var(--line);
}
.rv-calc-row:last-child { border-bottom: none; }
/* The block always renders an EVEN number of rows - twelve since 2026-08-24 - so
   at two columns the last TWO close the grid and both drop their rule. Add or
   remove a row in pairs, or one column ends a rule lower than the other. */
@media (min-width: 992px) {
  .rv-calc-row:nth-last-child(-n+2) { border-bottom: none; }
}
.rv-calc-lbl {
  grid-column: 1; grid-row: 1; font-size: .83rem; font-weight: 600; color: var(--body);
}
.rv-work {
  grid-column: 1; grid-row: 2; font-size: .72rem; color: var(--muted);
  font-variant-numeric: tabular-nums; overflow-wrap: anywhere;
}
.rv-res {
  grid-column: 2; grid-row: 1; justify-self: end;
  font-size: 1.05rem; font-weight: 700; color: var(--ink-700);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.rv-res small { font-size: .7rem; font-weight: 600; color: var(--muted); }
/* Minutes said again in hours, under the figure it restates - the blue column the
   legacy review carried. It is a restatement, never a second measurement, so it
   is quieter than the value above it. */
.rv-note {
  grid-column: 2; grid-row: 2; justify-self: end;
  font-size: .72rem; font-weight: 600; color: var(--info-ink);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.rv-note small { font-size: .65rem; font-weight: 600; opacity: .85; }
/* Attendance is a notation, not a quantity - tabular figures would space "P-2"
   like a number. */
.rv-att { font-variant-numeric: normal; letter-spacing: .02em; }
.rv-calc-row.is-good .rv-res, .rv-calc-row.is-good .rv-note { color: var(--ok-ink); }
.rv-calc-row.is-bad  .rv-res, .rv-calc-row.is-bad  .rv-note { color: var(--danger-ink); }

/* Downtime exceeding the staffed time - a data-entry fault, and the same thing
   the printed form says in its Remarks block rather than showing a bare 0. */
.rv-warn {
  margin: 0 0 .6rem; padding: .5rem .65rem;
  background: var(--warn-bg); border: 1px solid var(--warn-200);
  border-radius: var(--radius-field);
  color: var(--warn-ink); font-size: .78rem; font-weight: 600;
}

@media (max-width: 575px) {
  .rv-grid { grid-template-columns: 1fr; }
  .rv-item { padding: .3rem .4rem; }
}

/* ==========================================================================
   15c. Dashboard - .dash-* / .dc-* / .dash-tbl / .mu-*
   ==========================================================================
   Charts are hand-built from HTML boxes and one inline SVG arc. There is no
   chart library and there must not be one: R8 forbids a CDN, and Chart.js was
   deleted on 2026-08-02 as 845K of vendored code with zero references. A bar
   chart is a flex row of divs; buying a dependency for that is how the 845K
   happened the first time.

   ⚠ The bar chart is HTML, NOT SVG, on purpose. An SVG scaled with
   preserveAspectRatio="none" stretches its own axis labels into illegible
   ovals, and doing it properly means recomputing text transforms on resize.
   Boxes in a flex row reflow for free and keep text crisp at 375px.
   ========================================================================== */

/* ── Card head - icon left, action pill right ────────────────────────────────
   ⚠ SCOPED WITH .dash-head, AND THAT MODIFIER IS THE WHOLE POINT. A bare
   `.card-header-custom h6 { display: flex }` reaches all eleven screens, and two
   of them put inline content inside that heading - production-form step 3 is
   `Review <span class="fs-72 muted">Check Every Figure…</span>`, which turns
   into two flex items the moment the parent stops being a block box, losing the
   word space between them. The dashboard opted in; nothing else did.

   The icon exists because this screen shows SEVEN cards at once and a reader
   scanning for downtime should not have to read seven headings to find it. */
.card-header-custom.dash-head h6 { display: flex; align-items: center; gap: .5rem; }
.card-header-custom.dash-head h6 i { color: var(--accent); font-size: 1rem; flex: 0 0 auto; }

/* The action opposite the heading. A pill rather than a bare text link: the same
   words, but a 30px-tall target instead of a 15px line of text, which is what a
   finger on a shop-floor tablet is actually aiming at.

   ⚠ .dash-acts KEEPS THE CAPTION AND THE PILL TOGETHER ON THE RIGHT. Every one
   of these headings already carried a .head-sub, and .card-header-custom spaces
   its children apart - so a pill added as a third child lands with the caption
   stranded in the middle of the row. */
.dash-act {
  display: inline-flex; align-items: center; gap: .3rem;
  background: var(--accent-tint); color: var(--violet-ink);
  border: none; border-radius: 999px; padding: .28rem .7rem;
  font-size: .72rem; font-weight: 600; text-decoration: none; white-space: nowrap;
}
.dash-act:hover, .dash-act:focus { background: var(--purple-200); color: var(--violet-ink); }
.dash-acts { display: inline-flex; align-items: center; gap: .6rem; min-width: 0; }
.dash-acts .head-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ── Period bar ───────────────────────────────────────────────────────────── */
.dash-bar { gap: .6rem; }
.dash-range-note { font-size: .75rem; color: var(--muted); }

/* ⚠ `.dash-sample-chip` WAS REMOVED ON 2026-08-30 WITH THE FLAG THAT DROVE IT.
   Phase 2 pointed every measure at the real register, DASHBOARD_SAMPLE_DATA had
   been a constant `false` ever since, and the browser hid this chip on every
   load - a constant, a payload key, a line of JS and this block, all agreeing
   about nothing. */

/* ── Bar chart ────────────────────────────────────────────────────────────── */
.dash-chart { padding: 1rem 1.25rem 1.1rem; }
.dc-plot {
  display: flex; align-items: flex-end; gap: 3px;
  height: 176px; padding-bottom: .35rem;

  /* Quarter gridlines behind the bars - the reference chart's grid, drawn as a
     repeating background rather than four more elements per chart.

     ⚠ THE DAY LABEL IS INSIDE THE COLUMN, SO "THE BOTTOM OF THIS BOX" IS NOT
     THE BARS' ZERO. Anchoring the grid on `left bottom` put every line 19.67px
     below the baseline the bars actually stand on - measured, and invisible to
     anybody checking that the lines merely exist. The label's height is
     subtracted from both the anchor and the step, so the five rules land on the
     bar area's 0 / 25 / 50 / 75 / 100.

     ⚠ WHICH IS WHY --dc-lbl-h ALSO SIZES .dc-lbl ITSELF. A grid that assumed a
     height the label was free to change is a grid that silently slides off the
     baseline the next time somebody retunes that font. One number, both users.

     ⚠ AND --dc-cap-h IS THE SAME BARGAIN AT THE OTHER END. The per-bar
     percentage sits ABOVE the bars, so it takes height out of the stack: the
     anchor is untouched (the baseline is still the baseline) but the STEP must
     lose it too, or a full-height bar overshoots the top gridline by exactly the
     caption. It sizes .dc-pct itself for the same reason the label does. */
  --dc-lbl-h: 20px;
  /* Zero below 992, where .dc-pct is display:none - the grid must not reserve
     height for a caption that is not there. */
  --dc-cap-h: 15px;
  background-image: linear-gradient(to top, var(--line) 1px, transparent 1px);
  background-size: 100% calc((100% - var(--dc-lbl-h) - var(--dc-cap-h)) / 4);
  background-position: left calc(100% - var(--dc-lbl-h));
  background-origin: content-box;
  background-clip: content-box;
}
.dc-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; height: 100%; }
.dc-stack { position: relative; flex: 1 1 auto; display: flex; align-items: flex-end; }
/* ⚠ --purple, not --accent. The reference chart is violet-forward and the
   indigo bar sat a shade too close to the grid it stands on. The tone bars
   (is-over / is-under) are unchanged - they carry meaning, not theme. */
.dc-bar {
  width: 100%; background: var(--purple); border-radius: 4px 4px 0 0;
  min-height: 2px; opacity: .85; transition: opacity .15s;
}
.dc-col:hover .dc-bar { opacity: 1; }
/* ⚠ TWO TONES ONLY: green at or above target, ORANGE below it. There used to be
   a third, unmarked band between 85% and target that rendered in the neutral
   violet - on real data most days landed in it, so a missed target looked
   exactly like a met one. The plain .dc-bar violet now means only "no target was
   set", which cannot be over or under one. */
.dc-bar.is-over { background: var(--ok); }
.dc-bar.is-under { background: var(--warn); }
.dc-bar.is-zero { background: var(--card-border); }
/* The per-bar percentage. Height is --dc-cap-h so the gridlines can subtract it
   - see .dc-plot. Tabular figures, so a column of them does not jitter. */
.dc-pct {
  height: var(--dc-cap-h); flex: 0 0 auto;
  font-size: .58rem; font-weight: 700; text-align: center; line-height: var(--dc-cap-h);
  font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden;
}
.dc-pct.is-over { color: var(--ok-ink); }
.dc-pct.is-under { color: var(--warn-ink); }
/* ⚠ EVERY COLUMN KEEPS ITS PERCENTAGE (the user's instruction, 2026-08-30) - it
   is NOT thinned with the day labels. The two are read differently: the labels
   are a scale, where every second one still tells you where you are, while the
   percentage IS the figure the chart exists to show and a column without one
   reads as a day with no target. They are legible down to ~26px per column, so
   below that the captions are dropped ALL AT ONCE rather than clipped to a
   stump.

   ⚠ HAND-PATCH 2026-09-08 — THE BREAKPOINT WAS 767px AND THAT WAS ONE STEP TOO
     LOW. The rule "~26px per column" was right; the width it was pinned to was
     not, because the chart sits in a `col-xl-8` track and is nowhere near the
     viewport's width. Measured on a 26-column month, plot column width:

         768px → 19.7px   16 of 26 captions CLIPPED
         992px → 26.9px   none clipped
        1024px → 28.0px   1200px → 34.1px   1366px → 39.7px

     992 is where the stated rule actually starts holding, so that is where the
     captions now start. `--dc-cap-h` moves with it, or the grid reserves height
     for a caption that is not drawn and every full-height bar overshoots the top
     gridline. One number, both users — the same bargain `--dc-lbl-h` makes. */
@media (max-width: 991.98px) {
  .dc-pct { display: none; }
  .dc-plot { --dc-cap-h: 0px; }     /* the grid must not reserve height for it */
}

/* ⚠ HAND-PATCH 2026-09-08 (round 2) — AND THE MEDIA QUERY ABOVE STILL CANNOT
     SEE THE REAL RULE. "~26px per column" is a fact about the COLUMN, and a
     column is the plot's width divided by the number of columns — which is
     DATA, not a viewport. 992 is the right breakpoint for the 26-column month
     it was measured on and the wrong one for a 62-day window, where the column
     is 11.5px at 992 and does not reach 26px until 1920.

     So `dc-nopct` is set by fitChartCaptions() in dashboard-render.js from the
     measured column width, and covers the case the media query cannot: many
     columns on a WIDE screen. The media query keeps its own job — captions off
     on small screens — and the two are deliberately independent.

     ⚠ It must zero --dc-cap-h as well as hide the caption, or the grid reserves
     height for something it is not drawing and every full-height bar overshoots
     the top gridline by exactly the caption. One number, both users. */
.dc-plot.dc-nopct .dc-pct { display: none; }
.dc-plot.dc-nopct { --dc-cap-h: 0px; }
/* The target sits ON the column as a dashed rule - a second bar beside it would
   double the width of a twelve-hour chart and halve the readable one. */
.dc-tgt { position: absolute; left: 0; right: 0; border-top: 2px dashed var(--danger); opacity: .55; }
.dc-lbl {
  font-size: .62rem; color: var(--faint); text-align: center;
  padding-top: .3rem; white-space: nowrap; overflow: hidden;
  /* ⚠ Height declared, not left to the text - .dc-plot's gridlines subtract
     exactly this to find the bars' baseline. Change one and change both. */
  height: var(--dc-lbl-h); flex: 0 0 auto;
}
/* Past ~16 columns the labels collide; show every second one. */
.dash-chart.dc-thin .dc-col:nth-child(even) .dc-lbl { visibility: hidden; }

/* HAND-PATCH 2026-09-08 — hiding the NEIGHBOUR is only half the fix.
   `.dc-lbl` is `overflow: hidden` inside a column that flexes to `1fr`, so at
   375px a 30-day chart gives each label a 9.16px box — and a two-digit day
   needs 11px. Measured: every label from 10 to 30 was cut down the middle while
   the alternate one beside it sat blank. Thinning made room on the ROW and none
   in the BOX.

   The label is centre-aligned, so letting it overflow spills ~1px each side into
   a column whose own label is `visibility: hidden` — space that is already
   reserved and already empty. Scoped to `.dc-thin`, so an unthinned chart keeps
   the clip that stops a long label bleeding into a live neighbour. */
.dash-chart.dc-thin .dc-lbl { overflow: visible; }

.dash-legend { display: inline-flex; align-items: center; gap: .3rem; font-size: .7rem; color: var(--muted); }
.dash-key { width: 10px; height: 10px; border-radius: 2px; display: inline-block; margin-left: .5rem; }
.dash-key-over  { background: var(--ok); }          /* tracks .dc-bar.is-over  */
.dash-key-under { background: var(--warn); }        /* tracks .dc-bar.is-under */
.dash-key-tgt { background: transparent; border-top: 2px dashed var(--danger); height: 0; width: 12px; border-radius: 0; }

/* ── Dashboard card tables - .dash-tbl ────────────────────────────────────────
   Breakdown By Reason, Rejection By Type, Machines and Operator Achievement.

   ⚠ THESE WERE FOUR LISTS OF PER-ROW CSS GRIDS AND THE COLUMNS DID NOT LINE UP.
   Each `.rk-row` was its own grid, so an `auto` track sized against ITS OWN row:
   measured live, one label track came out 143px and the next 157px. A table
   shares one set of widths across every row by construction - which is why this
   is markup, not a CSS fix (2026-08-30, the user's instruction).

   ⚠ `table-layout: fixed` + <col> widths from dashTable(): the numeric columns
   hold their width whatever is in them, and the name column takes the slack and
   ellipsises rather than pushing the figures out of a 400px card.

   ⚠ TIGHTER PADDING THAN .table-app's OWN. These sit in a col-xl-4 card, where
   1rem either side of five columns is 160px of the ~376px available. */
.dash-tbl { padding: .1rem .9rem .8rem; }
.dash-tbl table { margin-bottom: 0; table-layout: fixed; width: 100%; }
/* ⚠ `thead th` / `tbody td`, NOT the bare `th, td`. Below 768 there is a
   `.table-app thead th { padding: .55rem .6rem }` rule (§5, the grid's own
   responsive block) at specificity (0,1,2); `.dash-tbl th` is only (0,1,1) and
   loses to it however late it is declared, so the tighter padding these narrow
   cards need silently stopped applying on exactly the widths that need it most.
   Matching its specificity lets source order decide, and this file is later. */
.dash-tbl thead th, .dash-tbl tbody td { padding: .34rem .4rem; }
/* ⚠ `.dash-tbl table thead th`, at (0,1,3), and NOT because it needs the extra
   element. `.table-app thead th` is (0,1,2) and sets `white-space: nowrap` in
   two places, one of them inside the ≤767 block; matching its specificity leaves
   the winner to source order, which is a coin-toss to rely on across two
   sections of one stylesheet - and it was losing, so "Produced Qty" was clipped
   at 375 while every other rule in this block applied.
   ⚠ HEADINGS WRAP; VALUES NEVER DO (see .dash-tbl .num). A two-word caption over
   a fixed-width numeric column has nowhere to go but a second line. */
.dash-tbl table thead th {
  font-size: .64rem; letter-spacing: .3px; text-transform: uppercase;
  color: var(--faint); font-weight: 700; white-space: normal;
}
.dash-tbl tbody td { font-size: .78rem; color: var(--body); }
.dash-tbl .num { text-align: right; font-variant-numeric: tabular-nums; }
/* ⚠ THE NOWRAP IS SCOPED TO tbody, AND THAT IS THE POINT. `.dash-tbl .num` also
   matches the HEADER cell of a numeric column - at (0,2,0) it outranked the
   heading rule above and pinned "Produced Qty" to one line inside an 84px
   column, where it was clipped. A figure must never break; its caption must be
   free to. */
.dash-tbl tbody .num { white-space: nowrap; font-weight: 700; color: var(--muted); font-size: .74rem; }
/* The name column: one line, ellipsised, with the full text on its title. */
.dash-tbl .nm span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The swatch / band-dot column - no padding to steal from the name beside it. */
.dash-tbl .key { padding-left: 0; padding-right: 0; }
/* Operator Achievement's Highest / Lowest divider, a row of its own so both
   halves share the table's columns. */
.dash-tbl tr.split td {
  font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
  color: var(--faint); background: var(--n-50);  /* was var(--soft) - undefined in app.css */ padding-top: .3rem; padding-bottom: .3rem;
}

/* A rank row's name as a link into its own report. It inherits the row's colour
   - a blue underlined name in a dense list of ten reads as ten warnings. */
.rk-link { color: inherit; text-decoration: none; }
.rk-link:hover, .rk-link:focus { color: var(--accent); text-decoration: underline; }

/* The donut's legend key, and the machine band dot. Both live in their own
   narrow column so a long name can never ellipsise them away. */
.rk-key { display: block; width: 10px; height: 10px; border-radius: 3px; }
.mc-dot { display: block; width: 9px; height: 9px; border-radius: 50%; background: var(--card-border); }
.mc-dot.is-ok { background: var(--ok); }
.mc-dot.is-warn { background: var(--warn); }
.mc-dot.is-bad { background: var(--danger); }
.dash-tbl tbody .mc-oee { font-weight: 800; }
.mc-oee.is-ok { color: var(--ok); }
.mc-oee.is-warn { color: var(--warn-ink); }
.mc-oee.is-bad { color: var(--danger); }

/* ── Master Data Usage - master · active records · unused-since count ──────────
   One row per master, the whole row a link into that master (Unused Since
   oldest-first). The chevron column is always present so the counts line up
   whether or not a given row is a link. */
/* A block being re-fetched by its own control - Master Data Usage's day gap and
   the comparison card's date range. */
.dash-mu.is-busy, #compareBox.is-busy, #operatorBox.is-busy,
#outputBox.is-busy { opacity: .5; pointer-events: none; }
/* The card's own table already carries the padding - see .dash-tbl. */
.dash-mu .dash-tbl { padding-left: 1.25rem; padding-right: 1.25rem; }

/* ⚠ THE LINK IS PER CELL, NOT PER ROW - a <tr> cannot be an <a>. Each cell's
   anchor fills the cell, so the whole row is clickable and the row still behaves
   as a table row for alignment. */
.mu-cell { display: block; color: inherit; text-decoration: none; }
.dash-mu tbody tr:hover .mu-cell { color: var(--accent); }
.mu-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.dash-mu tbody td.num .mu-cell { font-weight: 700; }
.mu-unused { color: var(--muted); }
.mu-unused.is-on { color: var(--danger); }
.mu-unused.mu-none { color: var(--faint); }
.mu-go { color: var(--faint); font-size: .8rem; }
.dash-mu tbody tr:hover .mu-go { color: var(--accent); }
/* The small dropdowns that live in a dashboard card header - Master Data Usage's
   day gap and the machine card's OEE threshold. */
.dash-sel { width: auto; }

/* ── Period comparison ─────────────────────────────────────────────────────── */
.dash-cmp .table-app tbody td { padding-top: .28rem; padding-bottom: .28rem; }
.cmp-n, .cmp-c { text-align: right; }
.cmp-n { font-variant-numeric: tabular-nums; }
.cmp-prev { color: var(--muted); }
.cmp-u { font-size: .72em; color: var(--faint); }
/* ⚠ The change column is TONED, and the tone comes from the SERVER:
   dashboard_delta() knows that breakdown falling is good news, so a down arrow
   here is green on the rows where down is the right direction. A metric with no
   good direction - a target, an entry count - is left grey deliberately. */
.cmp-chg { font-size: .74rem; font-weight: 700; white-space: nowrap; color: var(--muted); }
.dash-note {
  display: flex; align-items: flex-start; gap: .4rem;
  margin: .75rem 1.25rem 0; padding: .45rem .6rem;
  background: var(--warn-bg); color: var(--warn-ink);
  border-radius: var(--radius-sm); font-size: .7rem; line-height: 1.4;
}
.cmp-chg.is-ok  { color: var(--ok); }
.cmp-chg.is-bad { color: var(--danger); }

/* ── Breakdown donut ───────────────────────────────────────────────────────── */
.bk-top {
  display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
  padding: 1rem 1.25rem .25rem;
}
.dash-donut { position: relative; width: 132px; flex: 0 0 auto; }
.dash-donut svg { width: 132px; height: 132px; display: block; }
/* The centre is a positioned overlay, not SVG <text>: the viewBox scales with
   the ring, and scaled text is exactly the illegibility the bar chart above is
   built from HTML boxes to avoid. */
.dn-mid {
  position: absolute; inset: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; text-align: center;
  pointer-events: none;
}
.dn-val { font-size: 1.3rem; font-weight: 800; color: var(--ink-900); line-height: 1.1; }
.dn-cap {
  font-size: .58rem; font-weight: 700; letter-spacing: .4px;
  text-transform: uppercase; color: var(--faint);
}
.bk-facts { flex: 1 1 150px; min-width: 0; display: grid; gap: .5rem; }
.bk-fact { background: var(--page-bg); border-radius: var(--radius-sm); padding: .45rem .6rem; }
.bk-fact b { display: block; font-size: 1rem; font-weight: 800; color: var(--ink-900); line-height: 1.2; }
.bk-fact span {
  font-size: .62rem; color: var(--muted);
  text-transform: uppercase; letter-spacing: .4px;
}
/* ── Breakdown trend chart ─────────────────────────────────────────────────
   ⚠ THE POINTS ARE HTML AND THE LINES ARE SVG, and that split is deliberate.
   The plot has to stretch horizontally to fill its column, and an SVG stretched
   with preserveAspectRatio="none" turns a circle into an oval and text into a
   smear - which is why the Output chart above is built from HTML boxes. Here the
   polylines DO live in the stretched SVG, because a stretched line is still the
   right line; `vector-effect: non-scaling-stroke` is what keeps the stroke 2px
   at any width. Every dot, label and tick is a positioned HTML element instead
   and cannot distort at all. */
.bk-split { margin: 0; }
.bk-left  { border-right: 1px solid var(--card-border); }

.bk-chart { padding: 1rem 1.25rem .25rem; }
/* The shared .dash-note, pulled back inside the chart's own padding - it belongs
   to this chart rather than to the card, and its default margin is written for a
   note sitting under a full-width block. */
.bk-note { margin: 0 0 .6rem; }
.bk-cap {
  font-size: .68rem; color: var(--muted); line-height: 1.45; margin-bottom: .6rem;
}
.bk-hidden {
  display: inline-block; background: var(--accent-wash); color: var(--accent);
  border-radius: 999px; padding: .05rem .45rem; font-weight: 700; white-space: nowrap;
}

/* ⚠ ONE HEIGHT, THREE USERS - the y-axis column, the plot and the gridlines all
   size from it, so the ticks can never sit a few pixels off the lines they
   label. Change it and all three move together. */
.bk-chart { --bk-h: 230px; }

.bk-plotwrap { display: flex; gap: .5rem; }

/* The y captions sit OUTSIDE the plot, so the plot's 0-100% coordinate space is
   the data's alone - the same bargain --dc-lbl-h strikes on the bar chart. */
.bk-yaxis {
  position: relative; width: 34px; flex: 0 0 34px; height: var(--bk-h);
  /* ⚠ THE SAME 1px THE PLOT SPENDS ON ITS AXIS RULE, so the two boxes have the
     same INNER height and a tick anchored at 0% lands where the plot's 0% is.
     Without it every tick sat 1px below its gridline - measured, and invisible
     to any check that only asserts the two boxes are the same height, which they
     already were. */
  border-bottom: 1px solid transparent;
}
.bk-ytick {
  position: absolute; right: 0; transform: translateY(50%);
  font-size: .6rem; color: var(--faint); font-weight: 700; white-space: nowrap;
}

.bk-plot {
  position: relative; flex: 1 1 auto; min-width: 0; height: var(--bk-h);
  border-left: 1px solid var(--card-border); border-bottom: 1px solid var(--card-border);
}
.bk-grid {
  position: absolute; left: 0; right: 0; height: 0;
  /* ⚠ border-BOTTOM, NOT border-top. On a height:0 element anchored by `bottom`,
     a top border is drawn one pixel ABOVE the anchor and a bottom border is
     drawn exactly ON it - so with border-top every gridline sat 1px off the
     value it represents, all the way up the chart. */
  border-bottom: 1px dashed var(--line); pointer-events: none;
}
.bk-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }

.bk-dot {
  position: absolute; width: 7px; height: 7px; border-radius: 50%;
  transform: translate(-50%, 50%); border: 1.5px solid #fff; cursor: help;
}
.bk-pt {
  position: absolute; transform: translate(-50%, 50%);
  margin-bottom: 11px; font-size: .58rem; font-weight: 700;
  color: var(--ink-900); background: rgba(255, 255, 255, .82);
  border-radius: 3px; padding: 0 2px; white-space: nowrap; pointer-events: none;
}

.bk-xaxis { position: relative; height: 16px; margin-left: 34px; }
.bk-xlab {
  position: absolute; transform: translateX(-50%); top: 3px;
  font-size: .6rem; color: var(--faint); white-space: nowrap;
}

/* A row that switches a line on and off. The dim is the whole statement - the
   swatch, the name and the figures all fade together, so a hidden reason reads
   as hidden rather than as a row that failed to load. */
.rk-tog { cursor: pointer; }
/* A reason the chart does not draw. It keeps its row, its colour and its share -
   it is still in the total everything else is measured against - so the only
   thing it gives up is the pointer, because there is no line to toggle. */
.rk-noline { cursor: help; }
.rk-tog:hover td { background: var(--accent-wash); }
.rk-tog.is-off td { opacity: .38; }
.rk-tog:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ── Responsive ───────────────────────────────────────────────────────────── */
@media (max-width: 1199px) {
  /* Below xl the two halves stack, so the divider between them stops being a
     vertical rule and becomes the horizontal one that separates stacked blocks -
     the same rule .rv-pair follows on the production review. */
  .bk-left { border-right: 0; border-bottom: 1px solid var(--card-border); padding-bottom: .5rem; }
  .bk-chart { padding-top: .35rem; }
}
@media (max-width: 991px) {
  .dc-plot { height: 150px; }
  .bk-chart { --bk-h: 190px; }
}
@media (max-width: 767px) {
  .dash-chart, .dash-tbl, .dash-mu .dash-tbl { padding-left: .9rem; padding-right: .9rem; }
  .bk-top { padding-left: .9rem; padding-right: .9rem; }
  .bk-chart { padding-left: .9rem; padding-right: .9rem; --bk-h: 165px; }
  /* The y captions give up their column - at this width every pixel of plot is
     worth more than a second decimal on a gridline. */
  .bk-yaxis { width: 26px; flex-basis: 26px; }
  .bk-xaxis { margin-left: 26px; }
  .bk-ytick, .bk-xlab { font-size: .55rem; }
  .dc-plot { height: 132px; gap: 2px; }
  /* ⚠ THE COMPARISON CARD'S HEADER NOW HOLDS A DATE RANGE PICKER, and at 375 the
     picker + caption + report pill come to 484px in a 328px box. Left nowrap it
     spilled 17px past the card and the PAGE scrolled sideways (R9). It wraps to
     two lines instead - the card is taller, nothing is cut off, and the body
     still never pans. */
  .dash-acts { flex-wrap: wrap; }
}
@media (max-width: 575px) {
  .dash-bar { gap: .45rem; }
  .dash-range-note { width: 100%; order: 3; }
  .dc-plot { height: 116px; }
  .dash-tbl tbody td, .mu-name { font-size: .74rem; }
  .dash-tbl thead th, .dash-tbl tbody td { padding-left: .3rem; padding-right: .3rem; }
}
@media (max-width: 414px) {
  .dash-chart, .dash-tbl, .dash-mu .dash-tbl { padding-left: .75rem; padding-right: .75rem; }
  .bk-top { padding-left: .75rem; padding-right: .75rem; }
  .dc-plot { height: 104px; }
}

/* ==========================================================================
   15d. Calculator - .calc-*
   ==========================================================================
   ⚠ THE MASTER DATA SCREEN CARRIES A COUSIN OF THIS RAIL - section 15f,
   `.mnav-*`, added 2026-08-31. It is a SEPARATE COPY, chosen over sharing one
   block, so the Bootstrap overrides, the sticky/max-height arrangement and the
   active row's language exist twice. A FIX FOUND HERE IS WORTH CARRYING THERE
   BY HAND; nothing will tell you.

   A shop-floor utility, and it is judged as one: it has to be usable with one
   thumb, standing at a machine, on a 375px phone that may be running as an
   installed app with no browser chrome at all.

   Four decisions follow from that and should not be undone:

     • THE ANSWER IS A BOX INSIDE THE FIELD GRID (.fl-result), never a card
       below it. It sits immediately after the last input, so on a phone it is
       on screen while the fields are being typed. A separate result section put
       it under a 190px drawing and every edit became scroll-down-scroll-up.
       This replaced a sticky-to-the-bottom result card, which solved the same
       problem by covering the form.

     • FIELDS ARE TWO PER ROW AT EVERY WIDTH. A dimension is five or six digits
       (see the suffix rule below, which is what makes them fit at 375px).

     • THE TAB STRIP SCROLLS SIDEWAYS AND THE PAGE NEVER DOES (R9). Five tabs do
       not fit across 375px, and wrapping them to two rows spends 90px of a 640px
       viewport on navigation.

     • Every control is at least 42px tall. These are gloved hands.

   No new colours: the result box is the accent wash and the accent border that
   .section-card and the KPI tiles already use.                                */

/* ── The menu: a VERTICAL rail that becomes a drawer ──────────────────────────
   Replaced the horizontal pill strip on 2026-08-17. The strip was right for
   five calculators and wrong for seventeen: a horizontal scroller puts most of
   the catalogue behind a gesture with no affordance, and the pills either shrink
   past legibility or run off the edge.

   ⚠ ONE LIST, TWO BEHAVIOURS. The markup is a Bootstrap `.offcanvas-xl`: a
   static block from 1200px, an off-canvas drawer below. There is no second copy
   for mobile - see the warning in the view, calcTabKeys() reads the whole
   document and duplicates would break tab switching outright.

   ⚠ THE BREAKPOINT IS 1200, NOT 992, AND THAT IS FORCED. .calc-split already
   claims 992px to put the drawing beside the form; a rail there would make three
   columns inside 990px and leave the form under 480px - narrower than the two
   fields per row this screen is built on. At 1200 the form still gets ~690px. */
.calc-shell { display: block; }
@media (min-width: 1200px) {
  /* ⚠ 264px, AND EVERY ONE OF THOSE PIXELS WAS ARGUED FOR.
     216 → 248 on 2026-08-22, 248 → 264 on 2026-08-24 when the list gained a
     permanently reserved scrollbar gutter (see .calc-nav-list below). The
     original note stands: The rename made the names sentences rather
     than words - "Find A Slip Gauge For Sine Table" measures 192px in the row
     font, against the 165px of text the 216px rail left after its padding and
     the favourite star. Three of the five ellipsised, so the rail no longer
     told you which calculator you were on, which is the one job it has.

     The three alternatives all cost more than 32px does: wrapping to two lines
     breaks the rhythm of the list (and .calc-tab-btn span says so), a smaller
     font is the wrong trade on a shop-floor screen, and shortening the names
     again is the change the user just asked for, backwards. Measured after: the
     form still lays out beside its drawing at 1200. */
  .calc-shell {
    display: grid; align-items: start; gap: 1.25rem;
    grid-template-columns: 264px minmax(0, 1fr);
  }
  /* ⚠ THE RAIL STICKS AND SCROLLS ITSELF (2026-08-24, the user's instruction:
     "give calculator menu scrollable not entire page unless calculator page is
     bigger than screen").

     It used to follow the page, on the stated ground that two scrollbars fight
     for one wheel. That reasoning held at five rows and stopped holding at
     seventeen: the rail is ~700px tall, so on a 768px-high screen it - not the
     calculator - was what made the page scroll, and reaching the bottom of the
     menu scrolled the panel you were reading off the top. The two scrollbars
     never actually compete, because the rail only takes the wheel while the
     pointer is over the rail.

     THE RESULT IS THE RULE THE USER ASKED FOR: the page scrolls when, and only
     when, the CALCULATOR is taller than the screen. Most of the seventeen are
     not, so most of them now sit still.

     ⚠ --calc-rail-top IS THE STICKY TOP, AND IT IS THE TOPBAR'S HEIGHT PLUS THE
     PAGE PADDING. .topbar is `position: sticky; top: 0`, so a rail stuck at 0
     slides UNDER it. Measured against the shipped shell: 65px of topbar at
     1200px and up, plus .page-body's 1rem at that width. ⚠ BOTH HALVES MATTER: the
     rail's resting top there IS 81px, and a sticky top any larger makes the card
     jump down by the difference the moment the page scrolls. max-height leaves
     the same 1rem at the bottom, so the card never runs into the viewport edge. */
  .calc-nav {
    position: sticky; top: var(--calc-rail-top);
    max-height: calc(100vh - var(--calc-rail-top) - 1rem);
    /* ⚠ THE CARD ITSELF HAS TO BE A COLUMN FLEX BOX, and this is the third
       Bootstrap default that had to be undone here. Above the breakpoint it
       turns the responsive offcanvas into a plain `display: block`, and a block
       parent does NOT hand its max-height down: .offcanvas-body sized to its
       content (710px) inside a 503px card and simply overflowed, clipped by the
       card's own `overflow: hidden`. Nothing scrolled, and the bottom four rows
       of the menu were unreachable. Flex is what makes the cap propagate. */
    display: flex !important; flex-direction: column;
  }
  /* ⚠ TWO BOOTSTRAP RULES HAVE TO BE UNDONE HERE, AND BOTH BIT.
     Above the breakpoint Bootstrap turns a responsive offcanvas into a plain
     block, and in doing so it sets:

       .offcanvas-xl              { background-color: transparent !important }
       .offcanvas-xl .offcanvas-body { display: flex }

     The first is why the rail had no white card behind it - and it carries
     !important, so ours has to as well; there is no way to out-specify it.
     The second is worse and less obvious: it makes .calc-nav-body a ROW flex
     container, so the list inside became a flex ITEM and shrank to its content.
     That is why the active row's tint stopped two thirds of the way across
     instead of filling the menu. Restoring `display: block` is the whole fix. */
  .calc-nav {
    background: var(--card-bg) !important;
    border: 1px solid var(--card-border);
    border-radius: var(--radius); box-shadow: var(--shadow-card);
    overflow: hidden;                 /* so a row's tint stops at the radius */
  }
  /* No padding around the list: rows run edge to edge, which is what lets the
     active row's left bar sit on the card's own edge. The search box gets its
     own inset instead. */
  /* ⚠ THE SELECTOR HAS TO OUT-SPECIFY BOOTSTRAP'S, NOT JUST FOLLOW IT.
     Theirs is `.offcanvas-xl .offcanvas-body` (0,2,0). A bare `.calc-nav-body`
     is (0,1,0) and loses however late it is loaded - which is exactly what
     happened: the menu kept Bootstrap's `display:flex`, the list stayed a row
     flex item, and every row shrank to its text instead of filling the menu. */
  /* 10px top and bottom, ZERO left and right - the user's own value, found by
     switching Bootstrap's display:flex off in DevTools. The vertical padding
     lifts the first and last rows off the card's rounded corners; horizontal
     padding is what would stop a row's tint reaching the edge, so there is
     none, and the search box carries its own inset instead. */
  /* ⚠ A COLUMN FLEX BOX NOW, NOT display:block - and the width guarantee the
     block fix bought is unchanged. Bootstrap's own `display: flex` made this a
     ROW container, which is what shrank every row to its text; a COLUMN
     container stretches its children across, same as block did. It is a flex
     box because the search box has to stay put while the list under it scrolls,
     which needs the list to be the only growing child.

     ⚠ min-height: 0 IS LOAD-BEARING ON BOTH. A flex item's default min-height
     is `auto`, so without it the body refuses to shrink below its content and
     the max-height above does nothing at all - the rail grows and the page
     scrolls again, which is the bug this whole block exists to fix. */
  .calc-nav.offcanvas-xl .offcanvas-body {
    display: flex; flex-direction: column; align-items: stretch;
    padding: 10px 0; min-height: 0; overflow: hidden;
  }
  .calc-nav.offcanvas-xl .offcanvas-body .calc-nav-search { flex: 0 0 auto; margin: 0 .5rem .5rem; }
  /* THE LIST is what scrolls. `overscroll-behavior: contain` stops a flick that
     reaches the end of the menu from carrying on into the page - on a trackpad
     that reads as the whole screen jumping. */
  .calc-nav.offcanvas-xl .offcanvas-body .calc-nav-list {
    flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
    /* ⚠ scrollbar-gutter: stable IS WHAT KEEPS THIS MEASURABLE. Without it the
       bar appears only on short viewports, so a row is 197px of text at 768px
       high and 182px at 600px - and four of the seventeen names ellipsised on
       the second one only. A width bug that shows on some screens and not
       others is one nobody can reproduce. The gutter is always reserved, so one
       measurement covers every viewport height, and the rail was widened
       248 → 264px to pay for it. */
    scrollbar-gutter: stable;
  }
}
/* Below 1200 Bootstrap owns the offcanvas geometry; only the width is ours. */
@media (max-width: 1199.98px) {
  .calc-nav { max-width: 300px; width: 86vw; }
  /* No horizontal padding here either, for the same reason as the rail: a row's
     tint and its active bar have to reach the drawer's edge, or the selected
     calculator reads as a floating chip rather than the chosen row. */
  /* ⚠ THE DRAWER SCROLLS INSIDE ITSELF TOO, and at seventeen rows it has to:
     the list is ~700px against a 667px phone. Bootstrap's .offcanvas-body is
     already `overflow-y: auto`, but that is inherited by luck - stating it here
     means a future padding or display change cannot take it away silently.
     Same column-flex shape as the rail, so the search box stays put while the
     list moves under it, and `overscroll-behavior` stops a flick at the end of
     the list from scrolling the page behind the drawer. */
  .calc-nav-body {
    display: flex; flex-direction: column; align-items: stretch;
    padding: .25rem 0 1rem; min-height: 0; overflow: hidden;
  }
  .calc-nav-body .calc-nav-search { flex: 0 0 auto; margin: .25rem .75rem .5rem; }
  .calc-nav-body .calc-nav-list {
    flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  }
}
.calc-content { min-width: 0; }   /* a grid child must be told it may shrink */

/* The trigger names where you are, not just what it opens - "Calculators" alone
   would make the current calculator unreadable once the drawer is shut. */
.calc-nav-trigger {
  display: flex; align-items: center; gap: .55rem; width: 100%;
  min-height: 44px; padding: .5rem .85rem; margin-bottom: 1rem;
  background: var(--card-bg); border: 1px solid var(--card-border);
  border-radius: var(--radius-sm); box-shadow: var(--shadow-card);
  color: var(--ink-600); font-size: .85rem; font-weight: 600; cursor: pointer;
}
.calc-nav-trigger:hover { border-color: var(--accent-soft); background: var(--accent-wash); }
.calc-nav-trigger i { font-size: 1rem; opacity: .8; }
.calc-nav-trigger-lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* No gap: the rows are a continuous list separated by a hairline, not a stack of
   floating chips. Groups were built and removed on 2026-08-17 ("make it easy"),
   so the list is flat - the filter is what keeps it findable past eight. */
.calc-nav-list { display: flex; flex-direction: column; }

.calc-nav-search { position: relative; margin-bottom: .5rem; }
.calc-nav-search i {
  position: absolute; left: .6rem; top: 50%; transform: translateY(-50%);
  font-size: .8rem; color: var(--faint); pointer-events: none;
}
.calc-nav-search input {
  width: 100%; padding: .45rem .6rem .45rem 1.9rem;
  border: 1px solid var(--field-border); border-radius: var(--radius-field);
  font-size: .82rem; outline: none; background: #fff;
}
.calc-nav-search input:focus { border-color: var(--accent); }
/* The UA's own clear affordance, so there is not a second one beside it. */
.calc-nav-search input::-webkit-search-cancel-button { cursor: pointer; }

/* A narrow scrollbar on the menu list, both engines. Every pixel it does not
   take is a pixel of calculator name - and this list is scrolled with a thumb
   or a wheel, never dragged by its bar. */
.calc-nav-list { scrollbar-width: thin; scrollbar-color: var(--field-border) transparent; }
.calc-nav-list::-webkit-scrollbar { width: 6px; }
.calc-nav-list::-webkit-scrollbar-track { background: transparent; }
.calc-nav-list::-webkit-scrollbar-thumb { background: var(--field-border); border-radius: 3px; }
.calc-nav-list::-webkit-scrollbar-thumb:hover { background: var(--muted); }

.calc-nav-empty {
  margin: .5rem 0 0; padding: .75rem .6rem; text-align: center;
  font-size: .76rem; color: var(--faint);
}
.calc-nav-empty[hidden] { display: none; }   /* author display beats the UA rule */

/* ── One row in the menu ──────────────────────────────────────────────────────
   The row is the WRAPPER; the calculator and its star are two buttons inside it
   (a button inside a button is invalid markup - see the note in the view).

   ⚠ IT BORROWS THE SIDEBAR'S ACTIVE LANGUAGE, ON PURPOSE: a tinted background
   plus a 3px accent bar down the left edge, exactly like .nav-item.active. The
   first cut gave every row its own border and radius, which put bordered pills
   inside a bordered card - two nested boxes per row, and at seventeen rows that
   reads as clutter rather than a list. A list needs one edge to run the eye
   down, not seventeen.

   Left-aligned and full width now that it is vertical: centred labels have no
   common left edge to scan. */
.calc-tab {
  display: flex; align-items: stretch; align-self: stretch; width: 100%;
  min-height: 38px;
  border: none; border-left: 3px solid transparent; background: transparent;
  color: var(--muted); transition: background .15s, color .15s, border-color .15s;
}
.calc-tab + .calc-tab { border-top: 1px solid var(--line); }
.calc-tab-btn {
  flex: 1 1 auto; min-width: 0;
  display: inline-flex; align-items: center;
  /* .8rem of lead-in, not .55: with the icon gone the label would otherwise sit
     almost on the active row's left bar. */
  padding: .45rem .2rem .45rem .8rem; border: none; background: transparent;
  cursor: pointer; text-align: left;
  font-size: .82rem; font-weight: 600; color: inherit;
}
/* The label may ellipsis rather than wrap - a two-line row breaks the rhythm of
   the list. There is no icon: they were removed on the user's instruction
   (2026-08-17), and with seventeen shop-floor calculators an icon per row is
   seventeen glyphs that all mean "calculator" and none of which distinguish. */
.calc-tab-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.calc-tab:hover { background: var(--accent-wash); color: var(--ink-600); }
.calc-tab.active {
  background: var(--accent-tint); border-left-color: var(--accent); color: var(--violet-ink);
}
.calc-tab[hidden] { display: none; }        /* the filter hides rows outright */

/* One favourite per user - it decides which tab this screen opens on. Amber
   when set, and never coloured on hover alone: a star that lights up under the
   pointer reads as already chosen. */
/* Every row shows its star, on the user's instruction (2026-08-17): a hollow
   outline when it is not the favourite, solid amber when it is. Hiding them
   until hover was tried the same day and rejected - the star is then invisible
   on a touch screen, which is where this screen is used, and a control you
   cannot see is a control nobody knows exists. */
.calc-fav {
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
  width: 30px; padding: 0; border: none; background: transparent;
  color: var(--faint); font-size: .78rem; cursor: pointer; line-height: 1;
  transition: color .15s;
}
.calc-fav:hover { color: var(--warn); }
.calc-fav[aria-pressed="true"] { color: var(--amber); }

.calc-panel[hidden] { display: none; }

/* ── Two fields per row on a phone ────────────────────────────────────────────
   The view uses col-6 at every width (the user's instruction, 2026-08-15): a
   dimension is five or six digits, so a full-width box on a 375px screen is
   nine tenths white space and doubles the scrolling to reach the answer.

   ⚠ THIS RULE IS WHAT MAKES THAT FIT, and it is not cosmetic. The shared
   .fl-field rule reserves 68px of right padding for the unit - correct for
   "Qty./Hr." on the production form, fatal in a 155px box, where it leaves 67px
   for the number. Here the unit is "mm", "kg" or one symbol, so 34px is ample
   and the digits get the rest. Scoped to .calc-panel so the settings drawer's
   "g/cm³" keeps the full reservation.                                          */
.calc-panel .fl-suffix { right: 10px; font-size: .68rem; }
.calc-panel .fl-field:has(.fl-suffix) input { padding-right: 34px; }

/* ── The × inside a field ─────────────────────────────────────────────────────
   Shown only while the box holds something, so an untouched form is not a row
   of crosses, and the extra right padding is only reserved then - an empty
   field keeps every pixel for the digits about to be typed into it.           */
.fl-clear {
  position: absolute; right: 32px; top: 50%; transform: translateY(-50%);
  display: none; align-items: center; justify-content: center;
  width: 22px; height: 22px; padding: 0;
  border: none; border-radius: 50%; background: var(--accent-50); color: var(--muted);
  font-size: .6rem; line-height: 1; cursor: pointer;
}
.fl-clear:hover { background: var(--accent-tint); color: var(--violet-ink); }
.fl-field.has-value .fl-clear { display: inline-flex; }
/* A field the mode has turned into an output (.fl-ro on the Sin panel) cannot
   be cleared - the value is not the operator's to remove, and the × would be an
   affordance for nothing. */
.fl-field.fl-ro .fl-clear { display: none; }
.calc-panel .fl-field.has-value:has(.fl-clear) input { padding-right: 60px; }

/* ── The answer, as the last box of the grid ──────────────────────────────────
   Same 44px box as the fields beside it so the row does not go ragged, but
   filled with the figure at 1.5rem in the brand ink - the one thing on this
   screen that is read from across a bench. .fl-label rather than a <label>:
   there is no control to be labelled, and a real <label> would move focus to
   nothing when tapped.                                                        */
.fl-field.fl-result {
  background: var(--accent-wash); border-color: var(--accent-soft);
  display: flex; align-items: center;
}
.fl-field.fl-result .fl-label {
  position: absolute; left: 12px; top: 0; transform: translateY(-50%);
  padding: 0 4px; background: var(--accent-wash);
  font-size: .72rem; font-weight: 600; color: var(--ink-600);
}
/* ⚠ THE SAME BOX AS THE FIELDS BESIDE IT - the user's instruction, 2026-08-15:
   "as per normal text box, just keep colour difference". A .fl-field input is
   6px of padding around a 1.4 line-height at .9rem, so the figure is sized to
   land on the same outside height; only the tint, the weight and the ink say
   this one is the answer. Do NOT give it a min-height or a larger line-height:
   a result box one row taller than its neighbours makes the whole grid ragged,
   which is what the first cut did.                                            */
.calc-out-val {
  flex: 1 1 auto; min-width: 0; padding: 6px 34px 6px 10px;
  font-size: 1.15rem; font-weight: 800; line-height: 1.1;
  color: var(--accent-dark); font-variant-numeric: tabular-nums;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fl-field.fl-result.is-empty .calc-out-val { color: var(--faint); }
/* A word that belongs to a figure - the piece count on the Blocks tab, which
   rides on the stack's heading. */
.calc-badge {
  flex-shrink: 0;
  font-size: .68rem; font-weight: 600; color: var(--ink-600); white-space: nowrap;
}
.fl-field.fl-result .fl-copy { display: inline-flex; right: 8px; background: transparent; font-size: .72rem; }
.fl-field.fl-result .fl-copy:hover { background: var(--accent-tint); }

/* ── Cross-section drawing ───────────────────────────────────────────────────
   ⚠ CAPPED IN WIDTH, NOT IN HEIGHT. These are dimensioned drawings with a
   viewBox, so preserveAspectRatio letterboxes them inside whatever box they are
   given: a max-height on a 1366px screen leaves the drawing small and marooned
   in white space, while removing the cap entirely grows it to 450px and pushes
   the answer off the fold. A max-width does neither - the figure is the same
   size at every width above 420px, and shrinks to fit below it.               */
.calc-fig {
  margin: 1rem 0 0; padding: .75rem;
  background: var(--n-50); border: 1px solid var(--card-border);
  border-radius: var(--radius-sm);
}
.calc-fig svg { display: block; width: 100%; max-width: 420px; height: auto; margin: 0 auto; }

/* ── Form beside the drawing ──────────────────────────────────────────────────
   Stacked by default - which is the phone, and the order the app it replaces
   uses: fields, answer, then the drawing under them. From 992px the two sit
   side by side, the drawing in a fixed column so it stops growing while the
   form takes whatever is left. align-items:start keeps the drawing at the top
   of its column instead of centring it against a taller form.                 */
.calc-split > .calc-form { min-width: 0; }
@media (min-width: 992px) {
  .calc-split {
    display: grid; align-items: start; gap: 1.25rem;
    grid-template-columns: minmax(0, 1fr) minmax(260px, 400px);
  }
  .calc-split > .calc-fig { margin-top: 0; }
}

/* ── Segmented control - sin mode, block set ─────────────────────────────── */
.calc-seg-lbl { display: block; font-size: .72rem; font-weight: 600; color: var(--muted); margin-bottom: .3rem; }

/* ⚠ CAPPED IN WIDTH. Left to stretch, two buttons reading "Calculate Height"
   and "Calculate Angle" become 500px each on a 1366px screen - a pair of slabs
   that look like the page's primary action rather than a mode switch. Full
   width is still right below 576, where a thumb needs the target.            */
/* ⚠ 460px, AND THE BUTTONS WRAP (2026-08-22). This was 380px with
   `white-space: nowrap` on the buttons, which is a clip waiting to happen:
   `overflow: hidden` here plus nowrap there means a caption longer than its
   share is CUT OFF BY THE PARENT, silently. It survived at "Calculate Height" /
   "Calculate Angle" and broke the moment the Sin modes were renamed for their
   direction - "Calculate Slip Gauge To Angle" needs ~214px with its padding, so
   the pair needed 428 against a 380 cap and the second one read
   "Calculate Slip Gauge To Ar…".

   ⚠ THE CAP ALONE IS NOT THE FIX, AND MUST NOT BE TREATED AS ONE. Below about
   460px the control is narrower than the cap anyway - at 375 the form is 328px -
   so two nowrap captions can never both fit and raising the number just moves
   the breakage off the desktop. Wrapping is what makes it correct at EVERY
   width; the cap only keeps them on one line where there is room. */
.calc-seg {
  display: flex; width: 100%; max-width: 460px; overflow: hidden; background: #fff;
  border: 1.5px solid var(--field-border); border-radius: var(--radius-field);
}
.calc-seg-btn {
  flex: 1 1 0;
  display: flex; align-items: center; justify-content: center; text-align: center;
  min-height: 42px; padding: .5rem .9rem;
  border: none; background: transparent; cursor: pointer;
  font-size: .82rem; font-weight: 600; color: var(--muted); white-space: nowrap;
}

/* ⚠ THE WRAP IS A MODIFIER, NOT THE DEFAULT - and making it the default broke
   the other control the same day. `.calc-seg` has two users: the Sin mode pair,
   whose captions are sentences, and the block set (83 / 87 Pieces), whose two
   words sit in a `col-sm-auto` column. An AUTO column is sized by its content's
   min-content width, and `white-space: nowrap` is what makes that the width of
   the caption - so lifting nowrap globally let that column collapse and "83
   Pieces" wrapped to two lines, growing a 44px control to 51px for no reason.

   min-width: 0 is the half of this that `flex: 1 1 0` does NOT give you: without
   it a flex item floors at its longest unbreakable run and overflows the parent
   whatever the basis says. With nowrap gone, the run is one word. */
.calc-seg-wrap .calc-seg-btn { white-space: normal; min-width: 0; line-height: 1.25; }
.calc-seg-btn + .calc-seg-btn { border-left: 1px solid var(--field-border); }
.calc-seg-btn[aria-pressed="true"] { background: var(--accent-tint); color: var(--violet-ink); }

/* ── The one inline message on the screen ─────────────────────────────────────
   The block solver's, and only the block solver's. Every other fault is a toast
   plus a red box (calcShowErrors) - see the note in the view for why this one
   is different: it explains the TARGET, not a field, and it changes with every
   digit typed, so as a toast it would fire three times while 55.273 is keyed. */
.calc-msg {
  display: block; margin-top: .85rem; padding: .55rem .75rem;
  background: var(--warn-bg); border: 1px solid var(--warn-200);
  border-radius: var(--radius-sm); color: var(--warn-ink);
  font-size: .8rem; line-height: 1.45;
}
.calc-msg[hidden] { display: none; }

/* ── The block stack - the order the blocks come out of the cupboard ─────── */
.calc-stack { margin-top: .85rem; }
.calc-stack[hidden] { display: none; }
.calc-stack-hd {
  display: flex; align-items: center; gap: .5rem; margin-bottom: .35rem;
  font-size: .72rem; font-weight: 600; color: var(--muted);
}
.calc-stack-hd .calc-badge { margin-left: auto; }
/* The copy button is a .fl-clear outside a field, so it has no .has-value to
   reveal it - shown here unconditionally, and static rather than absolute. */
.calc-stack-hd .fl-copy { position: static; display: inline-flex; transform: none; }
.calc-stack-list {
  list-style: none; counter-reset: blk; margin: 0; padding: 0;
  background: #fff; border: 1px solid var(--card-border);
  border-radius: var(--radius-sm); overflow: hidden;
}
.calc-stack-list li {
  counter-increment: blk;
  display: flex; align-items: center; gap: .6rem;
  padding: .45rem .75rem; border-bottom: 1px solid var(--line); font-size: .88rem;
}
.calc-stack-list li:last-child { border-bottom: none; }
.calc-stack-list li::before {
  content: counter(blk); flex-shrink: 0; width: 18px;
  font-size: .7rem; font-weight: 700; color: var(--faint);
}
.cs-blk { font-weight: 700; color: var(--ink-900); font-variant-numeric: tabular-nums; }
.cs-run { margin-left: auto; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.calc-stack-ft {
  margin-top: .4rem; font-size: .72rem; color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 575.98px) {
  /* Rows stay 44px on a phone even as the type shrinks - a menu row is a thumb
     target, and this list is used with gloves on. */
  .calc-tab { min-height: 44px; }
  .calc-tab-btn { padding: .5rem .3rem .5rem .6rem; font-size: .8rem; }
  .calc-nav-trigger { margin-bottom: .75rem; font-size: .82rem; }
  .calc-fav { width: 34px; }
  .calc-fig { padding: .5rem; }
  /* 155px of field at 375px: every pixel not spent on the unit is a digit. */
  .calc-panel .fl-suffix { right: 7px; font-size: .64rem; }
  .calc-panel .fl-field:has(.fl-suffix) input { padding-right: 27px; font-size: .95rem; }
  .calc-panel .fl-field.has-value:has(.fl-clear) input { padding-right: 50px; }
  .fl-clear { right: 26px; width: 20px; height: 20px; }
  .calc-out-val { font-size: 1.3rem; padding-right: 30px; }
  .calc-panel .fl-hint { font-size: .66rem; line-height: 1.3; }
  /* ⚠ .8rem, NOT .85 (2026-08-22). The Ø prefix cost about 13px and pushed the
     two longest labels over: "Ø Raceway Large I.D." measured 133px against the
     130px a 156px field leaves at 375, so it ellipsised to "Ø Raceway Large
     I.…" - losing the half that says WHICH raceway. .8rem measures it at 125
     and clears with 5px to spare.

     ⚠ THE FIX IS THE FONT, NOT THE INSET, and that was not the first attempt.
     Widening the label box means either moving its left inset off the input's
     12px text origin - so the label and the value it labels no longer line up -
     or extending it right, under the MM suffix. Both are visible at rest on
     every field; a label half a point smaller is visible on none. */
  .calc-panel .fl-field label { font-size: .8rem; }
  .calc-panel .fl-field:focus-within label,
  .calc-panel .fl-field.has-value:not(:focus-within) label { font-size: .68rem; }
}
@media (max-width: 374.98px) {
  .calc-tab-btn span { font-size: .74rem; }
}

/* ==========================================================================
   15e. Operator Report - .rp-*
   ==========================================================================
   One summed grid: a row per operator per month. Almost nothing here is new -
   the grid is .table-app + .table-nowrap and the card is .section-card.

   ⚠ THE PIVOT AND ITS VENDOR OVERRIDES WERE DELETED ON 2026-08-24 with the
   WebDataRocks library itself (the user's instruction). Gone: .rp-seg,
   .rp-view[hidden], .rp-pivot, .rp-pivot-note and every #rpPivot / #wdr-*
   rule that scaled the vendor toolbar. Do not write markup against them.
   ========================================================================== */

/* The count beside the filters. Not a badge: it states a fact about the screen
   rather than marking a status, and a coloured pill among the filter controls
   reads as another control. */
.rp-count { font-size: .76rem; color: var(--muted); font-weight: 600; white-space: nowrap; }
.rp-count strong { color: var(--ink-900); font-weight: 700; }

/* A month with no target prints a dash, never 0% (CALCULATIONS §3.7) - this is
   what makes the dash read as "nothing was expected" rather than as a value that
   failed to load. */
.rp-null { color: var(--faint); }

/* ⚠ THE HEADER WRAPS; THE CELLS DO NOT. `.table-nowrap` keeps every DATA row one
   line tall, which is what it is for, and it sets nowrap on `thead th` too -
   this line is what takes that back. Four of the Operator Report's captions are
   three words ("Pre-Machining Rejection Qty.") and holding those on one line
   costs over 200px of table width; wrapped, that grid fits with no horizontal
   scroll from 1280 up. The captions are the user's own wording and are not to be
   shortened to buy the width back. */
#rpTable thead th { white-space: normal; }

/* ⚠ AND THE PRODUCTION REPORT OPTS BACK OUT OF IT (2026-08-25, the user's
   instruction). Wrapping a caption is what pushes its sort icon onto a line of
   its own - the icon is inline content, so when the last line is full it is the
   thing that drops - and with fifteen columns sharing the width that happened on
   almost all of them, including "Shift". The header stood 112px tall with the
   icons orphaned beneath their captions.

   Restoring nowrap puts every caption and its icon on ONE line and takes the
   header to a single row. It also means the COLUMN WIDTHS LOOK AFTER THEMSELVES:
   each column becomes as wide as the wider of its caption and its longest value,
   which is why that bundle declares almost no widths.

   ⚠ IT IS A MODIFIER AND NOT A CHANGE TO THE RULE ABOVE, because the Operator
   Report needs the opposite. Specificity (1,1,2) beats (1,0,2), so the class
   wins wherever it is applied and nothing else moves.

   The cost is table width - this grid scrolls further inside .table-wrap - and
   that is the trade the user chose. The page body still never scrolls (R9). */
#rpTable.rp-head-nowrap thead th { white-space: nowrap; }

/* ── The date range INSIDE the card header - .rp-hdr-range ──────────────────
   The Machine Production Report has no filter card (2026-08-25, the user's
   instruction): it held one control and a count strip, cost about 100px above a
   report that is fifteen rows tall, and the point of removing it was to see
   every machine at once.

   ⚠ ALMOST NOTHING IS NEEDED, because .card-header-custom is already
   `display:flex; justify-content:space-between; flex-wrap:wrap` - the picker
   sits hard right beside the title and drops onto its own line on a phone with
   no rule of its own. What IS needed is the vertical padding back: a header
   holding a control is taller than one holding two lines of text, and 1rem top
   and bottom around a 34px trigger reads as a gap rather than a header.

   The trigger keeps its own width; it is not stretched to the header, because a
   date range that spans a 1900px card looks like a search box. */
.rp-hdr-range { padding-top: .6rem; padding-bottom: .6rem; }

/* ── The MoM toggle on the Production Report card header - .rp-head-tools / .rp-mom
   ──────────────────────────────────────────────────────────────────────────────
   2026-08-29, the user's instruction: a button that regroups the entry grid by
   Month-Year. .card-header-custom is `justify-content: space-between` with two
   children today (the title, the caption); a third would strand the caption in
   the middle, so the caption and the button share one flex box, hard right.

   The button borrows .rp-fbtn's washed-indigo look rather than adding a fourth
   button style to the app, and takes a SOLID fill when it is on so the current
   view is legible from more than the label alone. */
.rp-head-tools {
  display: inline-flex; align-items: center; gap: .55rem;
  flex-wrap: wrap; justify-content: flex-end;
}
.rp-head-tools .rp-mom {
  display: inline-flex; align-items: center; white-space: nowrap;
  background: var(--accent-wash); border: 1px solid var(--accent-200); color: var(--accent);
  font-weight: 600;
}
.rp-head-tools .rp-mom:hover, .rp-head-tools .rp-mom:focus {
  background: var(--accent-tint); color: var(--accent-dark);
}
.rp-head-tools .rp-mom.is-on {
  background: var(--accent); border-color: var(--accent); color: #fff;
}
.rp-head-tools .rp-mom.is-on:hover, .rp-head-tools .rp-mom.is-on:focus {
  background: var(--accent-dark); border-color: var(--accent-dark); color: #fff;
}

/* ── The filter bar, the chips and the drawer - .rp-fbar / .rp-chips / .rp-drawer
   ──────────────────────────────────────────────────────────────────────────────
   Built 2026-08-25 on the user's instruction, from their reference screenshot.
   The Production Report had eight controls in one bar: a wall of dropdowns on a
   desktop, and eight stacked rows on a phone before a single figure was visible.
   Four stay - SEARCH, the DATE RANGE, FILTERS and CLEAR - and the other seven
   moved behind the button.

   ⚠ THE DRAWER IS A BOOTSTRAP OFFCANVAS. The bundle is on every page and the
   calculator's rail already uses one, so the backdrop, the focus trap, ESC and
   the scroll lock are Bootstrap's rather than three more rules here. */

/* The search box leads and takes the slack; everything after it is its own size.
   Below 576 it takes the whole row, because a 140px search box beside a date
   range is a box nobody can read what they typed into. */
.rp-search { flex: 1 1 220px; min-width: 180px; max-width: 340px; }

@media (max-width: 575.98px) {
  .rp-search { flex: 1 1 100%; max-width: none; }
}

/* ── The tone of the bar ─────────────────────────────────────────────────────
   ⚠ LIGHT, NOT DARK - corrected 2026-08-25 on the user's instruction ("current
   is more darker"), against their reference screenshot.

   ⚠ THE CAUSE WAS A TOKEN THAT DOES NOT MEAN WHAT IT READS LIKE. The chip was
   written `background: var(--accent-soft)` on the assumption that "soft" meant a
   wash. It does not: `--accent-soft` is var(--accent-soft), a saturated indigo used for the
   sidebar's active bar, and the fallback beside it could never apply because the
   token exists. The pale ones are `--accent-wash` (var(--accent-wash)) and `--accent-tint`
   (var(--accent-tint)). Check the value, not the name.

   The bar reads in two tones, which is what the reference does:
     ACCENT-WASHED - the date range, and Filters. Controls that are on, or that
                     always carry a value.
     NEUTRAL       - Clear. It undoes rather than filters, and a tinted Clear
                     among tinted filters is a fifth thing that looks active. */

/* The date trigger is white everywhere else in the app; inside this bar it is
   washed, because it always holds a value and the reference shows it that way.
   ⚠ SCOPED TO .rp-fbar so the Activity Log, the dashboard and the other reports
   keep the plain white trigger they already have. */
.rp-fbar .dr-trigger {
  background: var(--accent-wash); border-color: var(--accent-200); color: var(--accent); font-weight: 600;
}
.rp-fbar .dr-trigger:hover { background: var(--accent-tint); }

/* ⚠ OVERRIDES .btn-soft's var(--accent-tint), WHICH READ AS THE DARKER OF THE TWO against a
   white card. Washed fill, a light indigo hairline, indigo text. */
.rp-fbar .rp-fbtn {
  display: inline-flex; align-items: center; white-space: nowrap;
  background: var(--accent-wash); border: 1px solid var(--accent-200); color: var(--accent);
  font-weight: 600;
}
.rp-fbar .rp-fbtn:hover, .rp-fbar .rp-fbtn:focus { background: var(--accent-tint); color: var(--accent-dark); }

/* ⚠ THE COUNT IS A SOLID BADGE AND IS HIDDEN AT ZERO. Solid because it is the
   one thing on the bar that has to be read at a glance - the reference makes it
   the only saturated element there - and hidden because a "0" beside Filters is
   a figure somebody has to read before learning it means "none". */
.rp-fcount {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 18px; height: 18px; margin-left: .4rem; padding: 0 5px;
  border-radius: 999px; background: var(--accent); color: #fff;
  font-size: .66rem; font-weight: 800; line-height: 1;
}
/* [hidden] loses to any author display - the trap already recorded at
   .f-count[hidden] and .hs-cell[hidden]. .rp-fcount HAS an author display, so
   without this the badge shows an empty pill at zero. */
.rp-fcount[hidden] { display: none !important; }

/* Neutral, because it undoes. */
/* ⚠ SCOPED `.rp-fbar .rp-clear` (0,2,0) RATHER THAN `.rp-clear` (0,1,0), and
   that is not belt and braces. Bootstrap's `.btn` sets `color: var(--bs-btn-color)`
   at the same (0,1,0) specificity, and at equal specificity the winner is
   whichever the cascade saw last - which is not something to leave to the order
   two stylesheets happen to load in. Measured before the fix: the button
   rendered var(--n-800) on a white fill with a var(--n-800) border, which is the darkest
   thing on a bar whose whole complaint was being too dark. */
.rp-fbar .rp-clear {
  white-space: nowrap; background: #fff; border: 1px solid var(--card-border);
  color: var(--muted); font-weight: 600;
}
.rp-fbar .rp-clear:hover:not(:disabled) { background: var(--n-50); color: var(--body); border-color: var(--field-border); }
.rp-fbar .rp-clear:disabled { opacity: .5; cursor: default; background: #fff; color: var(--faint); }

/* ── The chips ───────────────────────────────────────────────────────────────
   One per filter that is on. Each names its FIELD as well as its value, because
   "2nd" alone leaves the reader guessing which of seven dropdowns it came from.

   ⚠ THE LIGHTEST THING ON THE SCREEN. There can be seven of them at once, and
   seven saturated pills under a filter bar is a second toolbar competing with the
   grid they are describing. Washed fill, hairline border, indigo text. */
.rp-chips {
  display: flex; flex-wrap: wrap; gap: .4rem;
  padding: .6rem 1rem .75rem;
  border-top: 1px solid var(--line);
}
.rp-chips[hidden] { display: none !important; }

.rp-chip {
  display: inline-flex; align-items: center; gap: .25rem;
  padding: .2rem .3rem .2rem .55rem;
  border-radius: 999px; border: 1px solid var(--accent-100);
  background: var(--accent-wash);
  font-size: .72rem; font-weight: 600; color: var(--accent);
  max-width: 100%;
}
/* The field name is the quieter half - the VALUE is what is being read. */
.rp-chip-k { color: var(--accent-400); font-weight: 700; }

/* A button, not a span with a click handler: for a keyboard user it is the only
   way to take one filter off without opening the drawer. */
.rp-chip-x {
  display: inline-flex; align-items: center; justify-content: center;
  width: 18px; height: 18px; padding: 0; margin-left: .1rem;
  border: none; border-radius: 50%; background: transparent;
  color: var(--accent-400); font-size: .95rem; line-height: 1; cursor: pointer;
}
.rp-chip-x:hover { background: var(--accent-100); color: var(--accent-dark); }
.rp-chip-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* ── The drawer ──────────────────────────────────────────────────────────────
   ⚠ 380px, NOT Bootstrap's 400 - measured: seven fields plus their labels need
   340px of content, and 380 leaves the padding without stranding the panel's
   own scrollbar. Below 576 it takes the width it is given. */
.rp-drawer { width: 380px; max-width: 100%; }

.rp-drawer .offcanvas-header { border-bottom: 1px solid var(--line); padding: .85rem 1.15rem; }
.rp-drawer .offcanvas-title  { font-size: .85rem; font-weight: 700; color: var(--ink-900); }

/* ⚠ THE BODY SCROLLS, THE FOOT DOES NOT. Bootstrap's body is already
   `overflow-y: auto`; what it does not do is keep a footer out of that scroll,
   so without `flex-shrink: 0` the two buttons scroll away with the seventh field
   and Apply is unreachable on a short screen - which is the one control the
   whole panel exists to reach. */
.rp-drawer .offcanvas-body { padding: 1rem 1.15rem; display: flex; flex-direction: column; gap: .85rem; }
.rp-dw-field { width: 100%; }

.rp-drawer-foot {
  flex-shrink: 0;
  display: flex; gap: .6rem; padding: .85rem 1.15rem;
  border-top: 1px solid var(--line); background: var(--bg-soft, var(--n-50));
}
/* Apply is the primary and takes the room; Reset All is the one you press by
   accident, so it stays the size of its own label. */
.rp-drawer-foot .btn-save { flex: 1 1 auto; }

/* Below 576 the header wraps to two lines and the picker takes the full width -
   a 240px control floated right under a heading reads as detached from it. */
@media (max-width: 575.98px) {
  .rp-hdr-range { gap: .5rem; }
  .rp-hdr-range > div:last-child,
  .rp-hdr-range .dr-trigger { width: 100%; }
}

/* The totals row. Sticky to the bottom of the scrolling body would need a fixed
   table height, which this grid deliberately does not have; it sits at the foot
   of the page's rows instead.

   ⚠ ITS HORIZONTAL PADDING MUST BE THE BODY'S, AND THE DEFAULT IS NOT (fixed
   2026-08-25, the user's report: "footer data and grid data are slightly
   misaligned for every column").

   `.table-app tbody td` sets `padding: .3rem 1rem`, but there is no matching rule
   for `tfoot`, so those cells fell through to Bootstrap's
   `.table > :not(caption) > * > *`, which is `.5rem .5rem`. THE CELL BOXES LINE
   UP PERFECTLY - measured, every column 0px out - and the FIGURES INSIDE THEM do
   not, because 8px of padding against 16px moves right-aligned text by exactly
   8px. It reads as a whole column being off by a character, and it is invisible
   to any check that measures the cells rather than what is drawn in them.

   The vertical padding is deliberately left larger than the body's: a totals row
   is worth a little more air than the rows it totals. */
#rpFoot td {
  background: var(--n-50); border-top: 2px solid var(--line);
  font-weight: 700; color: var(--ink-900);
  padding: .5rem 1rem;
}
#rpFoot .rp-foot-lbl { font-weight: 600; color: var(--muted); }

@media (max-width: 575.98px) {
  .rp-count { width: 100%; text-align: right; }
}

/* ==========================================================================
   15f. Master Data rail - .mnav-*
   ==========================================================================
   The master is chosen from a vertical rail: a static card from 1200px up, a
   Bootstrap off-canvas drawer below it. It replaced the topbar dropdown on
   2026-08-31 so the masters could carry GROUP HEADINGS, which a dropdown
   cannot show.

   ⚠ THIS IS A SEPARATE COPY OF THE CALCULATOR'S RAIL (section 15d, .calc-*),
   AND THE USER CHOSE THE COPY OVER SHARING ONE BLOCK. The two are cousins: the
   Bootstrap overrides below, the sticky/max-height arrangement and the active
   row's language are all the same argument, most of it found the hard way in
   15d. A FIX FOUND IN EITHER BLOCK IS WORTH CARRYING TO THE OTHER BY HAND -
   nothing will tell you. Both block headers name each other, so the second copy
   is at least findable from the first.

   What is deliberately NOT copied from 15d:
     - no filter box - six rows do not need finding, and the calculator's box
       only earns its place past eight;
     - no favourite star - the tab the user last used already answers "which
       master does this open on", and the dashboard's own deep links override it
       anyway. That also lets a row be a plain button element, where 15d needs a
       wrapper div (a button inside a button is invalid markup);
     - icons ARE kept - the calculator dropped them because seventeen glyphs all
       meaning "calculator" distinguish nothing, whereas a building, a person, a
       machine and a thermometer are six different things.

   ⚠ THE BREAKPOINT IS 1200 AND IT WAS CHOSEN, NOT INHERITED. The masters grid
   is 8-11 columns and never wraps a cell, so at 1024 it needs every pixel; a
   rail there would spend ~200 of them on a control used a few times a session.
   1200 is also 15d's breakpoint, so the application has ONE answer to "where
   does a side rail appear".
   ========================================================================== */
.mnav-shell {
  display: block;
  /* ⚠ MEASURED, NOT GUESSED - the lesson 15d paid for three times (216 → 248 →
     264 as its names grew). Measured against the shipped stylesheet, 2026-08-31:

       widest label ("Customers")        63.16px of text
       row chrome (3px active bar + 12.8 lead-in + 16.72 icon
                   + 8.8 gap + 8 right + the reserved scrollbar gutter)
                                         59.31px
       the card's own border              2.00px
       ------------------------------------------------
       the narrowest rail that fits it  124.47px

     208 therefore leaves 83.5px of headroom - a label of up to 146.69px before
     anything ellipsises, which is roughly "Breakdown Reasons". That is the
     seventh master's allowance, and it is the whole argument for the slack.

     ⚠ AND THE SLACK IS NOT FREE. Every pixel here is a pixel the grid does not
     get. Measured at 1366 with all six masters loaded: five of them fit their
     column exactly, and OPERATORS - 11 columns, 1181px - scrolls 92px inside
     .table-wrap where before the rail it fitted. The page itself never scrolls
     sideways at any width (R9). Do not raise this without re-measuring both
     halves; do not lower it without measuring the longest label first. */
  --mnav-w: 208px;
}
@media (min-width: 1200px) {
  .mnav-shell {
    display: grid; align-items: start; gap: 1.25rem;
    grid-template-columns: var(--mnav-w) minmax(0, 1fr);
  }
  /* ⚠ THE RAIL STICKS AND SCROLLS ITSELF, so the PAGE scrolls only when the
     grid is taller than the screen. --calc-rail-top is the topbar's height plus
     the page padding - one number with one meaning, and re-typing
     calc(var(--seed-topbar-h) + 1rem) here is the one piece of duplication that would actually
     break something: 15d asserts the rail's resting 81px against it. The
     --calc- prefix is the name it already had; it is not calculator-only. */
  .mnav {
    position: sticky; top: var(--calc-rail-top);
    max-height: calc(100vh - var(--calc-rail-top) - 1rem);
    /* ⚠ THE CARD HAS TO BE A COLUMN FLEX BOX. Above the breakpoint Bootstrap
       turns a responsive offcanvas into a plain display:block, and a block
       parent does NOT hand its max-height down - the body sizes to its content
       and overflows, clipped by the card's own overflow:hidden, so nothing
       scrolls and the bottom rows are unreachable. Flex propagates the cap. */
    display: flex !important; flex-direction: column;
    /* ⚠ THE BACKGROUND NEEDS !important. Bootstrap sets
       .offcanvas-xl { background-color: transparent !important } above the
       breakpoint; there is no way to out-specify it. */
    background: var(--card-bg) !important;
    border: 1px solid var(--card-border);
    border-radius: var(--radius); box-shadow: var(--shadow-card);
    overflow: hidden;                 /* so a row's tint stops at the radius */
  }
  /* ⚠ AND THE SELECTOR HAS TO OUT-SPECIFY BOOTSTRAP'S, NOT MERELY FOLLOW IT.
     Theirs is .offcanvas-xl .offcanvas-body { display: flex } at (0,2,0); a
     bare .mnav-body is (0,1,0) and loses however late it loads. Left as a ROW
     flex container it makes the list a flex ITEM, which shrinks every row to
     its own text - that is what makes an active row's tint stop two thirds of
     the way across instead of filling the card.

     ⚠ min-height: 0 IS LOAD-BEARING. A flex item's default is auto, so without
     it the body refuses to shrink below its content and the max-height above
     does nothing at all.

     10px top and bottom, ZERO left and right: the vertical padding lifts the
     first and last rows off the rounded corners, and horizontal padding is what
     would stop a row's tint and its 3px active bar reaching the card's edge. */
  .mnav.offcanvas-xl .offcanvas-body {
    display: flex; flex-direction: column; align-items: stretch;
    padding: 10px 0; min-height: 0; overflow: hidden;
  }
  .mnav.offcanvas-xl .offcanvas-body .mnav-list {
    flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
    /* Reserved always, not only when it appears: a bar that shows up on a short
       viewport and not a tall one makes the row width unmeasurable, which is
       how 15d ended up with four names ellipsising at 600px high and none at
       768px. Six masters will not scroll today; a seventh might. */
    scrollbar-gutter: stable;
  }
}
/* Below 1200 Bootstrap owns the offcanvas geometry; only the width is ours. */
@media (max-width: 1199.98px) {
  .mnav { max-width: 300px; width: 86vw; }
  /* Same column-flex shape as the rail so the list scrolls inside the drawer
     rather than the drawer growing, and overscroll-behavior stops a flick at
     the end of the list from scrolling the page behind it. No horizontal
     padding, for the same reason as the rail. */
  .mnav-body {
    display: flex; flex-direction: column; align-items: stretch;
    padding: .25rem 0 1rem; min-height: 0; overflow: hidden;
  }
  .mnav-body .mnav-list {
    flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  }
}
.mnav-content { min-width: 0; }   /* a grid child must be told it may shrink */

/* The trigger names WHERE YOU ARE, not just what it opens - "Master Data" alone
   would make the current master unreadable the moment the drawer is shut. */
.mnav-trigger {
  display: flex; align-items: center; gap: .55rem; width: 100%;
  min-height: 44px; padding: .5rem .85rem; margin-bottom: 1rem;
  background: var(--card-bg); border: 1px solid var(--card-border);
  border-radius: var(--radius-sm); box-shadow: var(--shadow-card);
  color: var(--ink-600); font-size: .85rem; font-weight: 600; cursor: pointer;
}
.mnav-trigger:hover { border-color: var(--accent-soft); background: var(--accent-wash); }
.mnav-trigger i { font-size: 1rem; opacity: .8; }
.mnav-trigger-lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* No gap: a continuous list separated by a hairline, not a stack of chips. */
.mnav-list { display: flex; flex-direction: column; }
.mnav-list { scrollbar-width: thin; scrollbar-color: var(--field-border) transparent; }
.mnav-list::-webkit-scrollbar { width: 6px; }
.mnav-list::-webkit-scrollbar-track { background: transparent; }
.mnav-list::-webkit-scrollbar-thumb { background: var(--field-border); border-radius: 3px; }
.mnav-list::-webkit-scrollbar-thumb:hover { background: var(--muted); }

/* ── The group heading ────────────────────────────────────────────────────────
   PLAIN AND ALWAYS OPEN (the user's call, 2026-08-31): no caret, no accordion,
   nothing remembered per user. With one group, a control that collapses it is a
   control that does nothing - and a rail whose only heading can be hidden is a
   rail that can be left showing nothing at all.

   ⚠ IF THEY EVER DO COLLAPSE, THEY FOLLOW ROLE MANAGEMENT'S RULE: hide the rows
   IN PLACE, never unmount them. Nothing here reads the DOM to decide what is
   saved, so unmounting would not lose data the way it does there - but the boot
   lookup and switchTab() both query every .mnav-item in the document, and a
   master inside a collapsed group has to stay findable by them.

   It carries the same horizontal lead-in as a row's label, so the heading and
   the names under it share one left edge. The first row after a heading drops
   its hairline: the heading is already the separator, and two rules 8px apart
   read as a mistake. */
.mnav-group {
  padding: .55rem .8rem .3rem;
  font-size: .68rem; font-weight: 700; letter-spacing: .04em;
  text-transform: uppercase; color: var(--faint);
}
.mnav-group + .mnav-item { border-top: none; }
/* Not the FIRST heading in the list - that one is already lifted by the body's
   own 10px, and a second gap above it makes the card look mis-padded. */
.mnav-item + .mnav-group {
  margin-top: .35rem; padding-top: .55rem; border-top: 1px solid var(--line);
}

/* ── One row ──────────────────────────────────────────────────────────────────
   ⚠ IT BORROWS THE SIDEBAR'S ACTIVE LANGUAGE, exactly as 15d does: a tinted
   background and a 3px accent bar down the left edge, like .nav-item.active.
   Bordered pills inside a bordered card are two nested boxes per row, and a
   list needs one edge to run the eye down, not six. */
.mnav-item {
  display: flex; align-items: center; gap: .55rem;
  align-self: stretch; width: 100%; min-height: 38px;
  padding: .45rem .5rem .45rem .8rem;
  border: none; border-left: 3px solid transparent; background: transparent;
  color: var(--muted); cursor: pointer; text-align: left;
  font-size: .82rem; font-weight: 600;
  transition: background .15s, color .15s, border-color .15s;
}
.mnav-item + .mnav-item { border-top: 1px solid var(--line); }
.mnav-item > i { flex: 0 0 auto; width: 1.1em; font-size: .95rem; opacity: .85; }
/* The label may ellipsis rather than wrap - a two-line row breaks the rhythm of
   the list, and the rail is sized so it should never come to that. */
.mnav-lbl {
  flex: 1 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mnav-item:hover { background: var(--accent-wash); color: var(--ink-600); }
.mnav-item.active {
  background: var(--accent-tint); border-left-color: var(--accent); color: var(--violet-ink);
}
.mnav-item.active > i { opacity: 1; }

@media (max-width: 767.98px) {
  /* 44px is the touch target the drawer is used with. */
  .mnav-item { min-height: 44px; }
  .mnav-trigger { margin-bottom: .75rem; font-size: .82rem; }
}

/* ==========================================================================
   16. Utilities
   ========================================================================== */
.text-title { color: var(--ink-900); }
/* Scoped to .fl-field label since 2026-07-30. A required marker is worth the
   same red wherever it appears - a legend, a card header, a section title. */
.req { color: var(--danger); }
.nowrap { white-space: nowrap; }
.fs-72 { font-size: .72rem; }
.fs-78 { font-size: .78rem; }
.muted { color: var(--muted); }
.faint { color: var(--faint); }
.skeleton {
  background: linear-gradient(90deg, var(--n-100) 25%, var(--n-250) 37%, var(--n-100) 63%);
  background-size: 400% 100%; animation: sk 1.2s ease infinite; border-radius: 6px; color: transparent;
}
@keyframes sk { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
.empty-state { text-align: center; padding: 2.5rem 1rem; color: var(--faint); }
.empty-state i { font-size: 2.2rem; display: block; margin-bottom: .6rem; opacity: .5; }
.empty-state p { font-size: .85rem; margin: 0; }

@media (max-width: 1199px) { .page-body { padding: 1.25rem; } }
@media (max-width: 991px) {
  .page-body { padding: 1.25rem 1rem; }
  .topbar { padding: .7rem 1rem; }
}
@media (max-width: 767px) {
  .page-body { padding: 1rem .75rem; }
  .topbar { padding: .6rem .75rem; flex-wrap: wrap; row-gap: .4rem; }
  /* ⚠ BASIS 0, NOT auto - THE TOPBAR TRUNCATES ITS CAPTION RATHER THAN GROWING
     A SECOND ROW. This block is the one place .topbar wraps, and with wrap on,
     a flex line breaks on each item's BASE size before any item is allowed to
     shrink - so with `auto` the heading block's natural width (its .page-sub,
     which is the longer of the two lines) decided whether the right-hand
     cluster kept its seat. Measured on Production: at 414 the row was at
     exactly 100% of capacity - 278.45 + 9.6 + 106.77 = 394.8 - so the home
     button's 46px tipped it, and at 375 it had already tipped on its own,
     costing 37px of a sticky bar on the narrowest screen there is (R9). At
     basis 0 the block never forces the break and truncates instead, which is
     what .page-title and .page-sub carry text-truncate FOR.

     ⚠ MASTERS USED TO BE THE EXCEPTION AND NO LONGER IS (2026-08-31). Its
     `:has(.master-pick)` rules gave the right-hand cluster width:100%, which
     wrapped that topbar to two rows whatever the left half was based on -
     measured 92.39px at 375 and 414. The picker moved to a rail on the screen
     (section 15f) and those rules went with it, so Masters is now one row like
     everything else and the rule above governs it too. */
  .topbar > div:first-child { flex: 1 1 0; min-width: 0; }
  .topbar h5, .page-title { font-size: .95rem; }
  .page-sub { font-size: .7rem; }
  .filter-bar { padding: .75rem; }
}
@media (max-width: 575px) {
  .page-body { padding: .75rem .6rem; }
  .section-card { border-radius: 12px; }
  .topbar { padding: .5rem .6rem; }
  .topbar h5, .page-title { font-size: .9rem; }
  .card-header-custom { padding: .75rem .9rem; }
  .modal-dialog { margin: .5rem; max-width: calc(100vw - 1rem); }
  .modal-top .modal-dialog { margin-top: 2vh; }
  .modal-hd { padding: .7rem .9rem; }
  .modal-body { padding: .9rem; }
  .modal-footer { padding: .6rem .9rem; }
  /* Keep the switch and the buttons on one line; wrap only if truly out of room. */
  .modal-footer { flex-wrap: wrap; row-gap: .5rem; }
  .pager { padding: .6rem .8rem; }
}
/* 414 and 375: reclaim every pixel - nothing may overflow (R9) */
@media (max-width: 414px) {
  .page-body { padding: .6rem .5rem; }
  .filter-bar { padding: .6rem; gap: .4rem; }
  .filter-bar .form-control, .filter-bar .form-select { max-width: 100% !important; width: 100%; }
  .sidebar { width: 86vw; max-width: 300px; }
}
@media (max-width: 375px) {
  .topbar h5, .page-title { font-size: .85rem; }
  .table-app { font-size: .8rem; }
  .action-btn { width: 26px; height: 26px; font-size: .75rem; }
  .btn-save { padding: .35rem .9rem; }
}

@media print {
  .sidebar, .sidebar-overlay, .topbar, .pager, .filter-bar, .action-btn, .btn { display: none !important; }
  .main { margin-left: 0 !important; }
  .page-body { padding: 0 !important; }
  .section-card { border: none; box-shadow: none; }
}

/* ==========================================================================
   17. KPI cards - REBUILT FOR THE KIT

   ⚠ THIS APP DELETED ITS KPI STRIP (2026-08-30) AND app.css SECTION 3 CARRIES A
   TOMBSTONE. It is back here deliberately, and the distinction matters:

     The tiles were removed there because ON THAT PARTICULAR SCREEN all seven
     figures were already rows elsewhere on the page - four duplicated the Master
     Data Usage table and two were rows of the comparison table. That is a fault
     in that dashboard, not in the component.

   THE TEST TO APPLY BEFORE USING THEM: is this figure already a row on this
   screen? If yes, do not add a tile.

   Every measurement below was paid for once already - see the warnings inline.
   ========================================================================== */

/* col-6 col-md-4 col-lg-2 gives six across. ⚠ A BARE col-xl IS WHAT FITS SEVEN:
   Bootstrap's grid is 12 columns and 7 does not divide it, so a numbered class
   cannot do it - a breakpoint class with no number means "share the row equally". */
.kpi-card {
  position: relative;
  overflow: hidden;                 /* ⚠ the disc below extends past the corner;
                                       without this the page pans sideways (R9) */
  border-radius: var(--radius);
  padding: .85rem .95rem;
  min-height: 92px;
  /* ⚠ height:100%, not just min-height. Only SOME tiles carry a change chip, and
     without this the ones that do stand taller than the ones that do not and the
     strip stops lining up - which is most of what a strip is for. The Bootstrap
     column already stretches; the card has to fill it. */
  height: 100%;
  display: flex; flex-direction: column; justify-content: center;
  background: linear-gradient(135deg,
              color-mix(in oklch, var(--kpi-tone, var(--series-1)) 88%, #000),
              color-mix(in oklch, var(--kpi-tone, var(--series-1)) 62%, #000));
  box-shadow: 0 2px 10px color-mix(in srgb, var(--kpi-tone, var(--series-1)) 34%, transparent);

  /* ⚠ THE TILE MUST SET ITS OWN COLOUR. `a .kpi-card` is (0,1,1) and beats a bare
     `.kpi-card` at (0,1,0), so a tile wrapped in a link inherits the body slate
     instead - which is how the change chip once rendered dark slate on a dark
     pill and could not be read at all. */
  color: #fff;
}
a.kpi-card, a .kpi-card { color: #fff; text-decoration: none; }

/* ⚠ A POSITIONED ::after PAINTS ABOVE IN-FLOW TEXT however early it is declared,
   so the value's corner washes out without this. */
.kpi-card > * { position: relative; z-index: 1; }

.kpi-card::after {
  content: ''; position: absolute; top: -34px; right: -34px;
  width: 104px; height: 104px; border-radius: 50%;
  background: rgba(255,255,255,.13);
  pointer-events: none;             /* the whole tile is usually a link */
}

/* ⚠ DO NOT REBUILD THE HIERARCHY OUT OF OPACITY. Size and weight already do it,
   and opacity costs most on exactly the tiles with the least contrast to spare. */
.kpi-label { font-size: .72rem; font-weight: 600; letter-spacing: .3px; color: #fff; }
.kpi-value { font-size: 1.5rem; font-weight: 700; line-height: 1.15; color: #fff; }
.kpi-sub   { font-size: .68rem; font-weight: 500; color: #fff; }

/* ⚠ THE CHANGE CHIP IS A DARK WASH, NEVER A WHITE ONE. A white wash LIGHTENS the
   tile: measured 2.64:1 on emerald, worse than the bare tile behind it. The tone
   lives in the arrow, not in the pill. */
.kpi-delta {
  display: inline-flex; align-items: center; gap: .2rem;
  align-self: flex-start; margin-top: .25rem;
  padding: .08rem .38rem; border-radius: 999px;
  font-size: .66rem; font-weight: 700;
  background: rgba(0,0,0,.24); color: #fff;
}
.kpi-delta.is-strong { background: rgba(0,0,0,.40); }

/* Seven tones, each a series token - so a retheme recolours the whole strip and
   nobody edits a gradient stop. */
.kpi-1 { --kpi-tone: var(--series-1); }
.kpi-2 { --kpi-tone: var(--series-2); }
.kpi-3 { --kpi-tone: var(--series-3); }
.kpi-4 { --kpi-tone: var(--series-4); }
.kpi-5 { --kpi-tone: var(--series-5); }
.kpi-6 { --kpi-tone: var(--series-6); }
.kpi-7 { --kpi-tone: var(--series-7); }
/* HAND-PATCH 2026-09-08 — an eighth tone, for an eight-tile KPI strip. */
.kpi-8 { --kpi-tone: var(--series-8); }

/* Between 992 and 1399 the type steps down. Measured, not guessed: at 992 a tile
   is 143px, and at 1.5rem "9,84,120 PCS" needs 124px inside a 116px content box
   and WRAPS - which makes the strip taller than the two-row version it replaced. */
@media (min-width: 992px) and (max-width: 1399.98px) {
  .kpi-value { font-size: 1.15rem; }
  .kpi-card  { padding: .7rem .75rem; min-height: 84px; }
}
@media (max-width: 575.98px) {
  .kpi-value { font-size: 1.3rem; }
  .kpi-card  { min-height: 84px; }
}


/* ==========================================================================
   18. Output chart - the day axis on a phone            [KIT FIX, see below]

   .dc-thin hides every SECOND label, which is right at ~16-24 columns and not
   enough at 30 on a phone. MEASURED at 375: the column is 9.2px and a two-digit
   day needs 11px, so 11 of the 15 surviving labels were cut to one character and
   the axis read "4 6 8 1 1 1".

   ⚠ HIDING MORE NEIGHBOURS DOES NOT FIX IT - the label box IS the column, so a
   lone label is still confined to 9.2px. The fix is to let the survivors SPILL
   into the space the hidden ones leave: they are centred, their neighbours are
   invisible, and nothing else occupies that row.

   Every fifth, not every second, so a spilled two-digit label has two empty
   columns either side and cannot collide with the next one.
   ========================================================================== */
@media (max-width: 575.98px) {
  .dash-chart.dc-thin .dc-lbl { visibility: hidden; }
  .dash-chart.dc-thin .dc-col:nth-child(5n+1) .dc-lbl {
    visibility: visible;
    overflow: visible;          /* the whole point - spill past the 9.2px column */
  }
}


/* ==========================================================================
   19. Button states and metrics                        [KIT FIX, see below]

   Two faults, both reported from real use, both present in the source app.

   ── (a) A CUSTOM BUTTON LOST ITS FILL THE MOMENT YOU PRESSED IT ────────────
   Bootstrap paints the pressed and keyboard-focused states through variables:

       .btn:first-child:active, :not(.btn-check)+.btn:active {
         color: var(--bs-btn-active-color);
         background-color: var(--bs-btn-active-bg); ... }        (0,3,0)
       .btn:focus-visible { ... var(--bs-btn-hover-bg) ... }     (0,2,0)

   Every custom button class here is (0,1,0) and declared NONE of those
   variables, so Bootstrap won on mousedown and painted an EMPTY var() -
   which is invalid at computed-value time and resolves to `transparent`
   over slate text. MEASURED: a pressed .btn-del computes
   `background-color: rgba(0,0,0,0)` and `color: oklch(0.2827 …)` - the red
   Delete button turns white with dark text while the mouse is down, and the
   same happens on Tab-focus.

   ⚠ THE FIX IS TO DEFINE THE VARIABLES, NOT TO OUT-SPECIFY THE RULE. Setting
   --bs-btn-* lets Bootstrap's own :hover / :active / :focus-visible paint
   correctly, needs no !important, and cannot be broken by a later Bootstrap
   patch reshuffling selector order. Fighting it with `.btn.btn-del:active`
   works today and ties on specificity, which is a coin-toss on source order.

   ── (b) FOUR SIBLING BUTTONS, FOUR DIFFERENT HEIGHTS ──────────────────────
   MEASURED at .btn-sm: .btn-soft 29px · .btn-export 29px · .btn-save 32.2px ·
   .btn-del 33px. Each had grown its own padding and font-size over time, so a
   dialog footer or a card header showing two of them side by side had them
   visibly out of line. They now share one metric block and one min-height, so
   they match whatever is next to them.

   ⚠ THE COMBINED SELECTOR IS (0,1,0) ON PURPOSE. masters.js hides and shows
   the export button with an inline style.display, and an inline declaration
   must keep beating this - which it does. Do not raise the specificity here
   and do not add !important.
   ========================================================================== */

.btn-soft, .btn-save, .btn-del, .btn-export {
  /* One metric for all four, so they line up with each other and with .btn-sm. */
  padding: .375rem .85rem;
  font-size: .82rem;
  line-height: 1.4;
  min-height: 32px;
  font-weight: 600;
  border: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .35rem;                  /* gap, never me-1 - it collapses with a hidden label */
  --bs-btn-border-color: transparent;
  --bs-btn-active-border-color: transparent;
  --bs-btn-border-width: 0;
}

.btn-soft {
  --bs-btn-bg:           var(--accent-tint);
  --bs-btn-color:        var(--violet-ink);
  --bs-btn-hover-bg:     var(--purple-200);
  --bs-btn-hover-color:  var(--violet-ink);
  --bs-btn-active-bg:    var(--purple-200);
  --bs-btn-active-color: var(--violet-ink);
  --bs-btn-focus-box-shadow: 0 0 0 3px var(--accent-ring-soft);
}
.btn-save {
  --bs-btn-bg:           var(--violet-ink);
  --bs-btn-color:        #fff;
  --bs-btn-hover-bg:     var(--purple-900);
  --bs-btn-hover-color:  #fff;
  --bs-btn-active-bg:    var(--purple-900);
  --bs-btn-active-color: #fff;
  --bs-btn-focus-box-shadow: 0 0 0 3px var(--accent-ring);
}
.btn-del {
  --bs-btn-bg:           var(--danger);
  --bs-btn-color:        #fff;
  --bs-btn-hover-bg:     var(--danger-ink);
  --bs-btn-hover-color:  #fff;
  --bs-btn-active-bg:    var(--danger-800);
  --bs-btn-active-color: #fff;
  --bs-btn-focus-box-shadow: 0 0 0 3px var(--danger-ring);
}
.btn-export {
  --bs-btn-bg:           var(--ok-bg);
  --bs-btn-color:        var(--ok-ink);
  --bs-btn-hover-bg:     var(--ok-200);
  --bs-btn-hover-color:  var(--ok-900);
  --bs-btn-active-bg:    var(--ok-200);
  --bs-btn-active-color: var(--ok-900);
  --bs-btn-focus-box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent);
}

/* Bootstrap's .btn-light is used beside these (Cancel, Back). Give it the same
   metric so a Save / Cancel pair matches, and a real pressed state so it does
   not flash white either. */
.btn.btn-sm.btn-light {
  min-height: 32px;
  font-size: .82rem;
  line-height: 1.4;
  --bs-btn-active-bg:    var(--n-200);
  --bs-btn-active-color: var(--body);
  --bs-btn-focus-box-shadow: 0 0 0 3px var(--accent-ring-soft);
}

/* The icon-only row action is a fixed 28px square and is NOT part of the block
   above - it has no label, so a shared min-height would stretch it. It needs the
   pressed state for the same reason, though. */
.action-btn {
  --bs-btn-border-color: transparent;
  --bs-btn-active-border-color: transparent;
}
.action-btn:active { filter: brightness(.92); }

/* ── The topbar is the third alignment case ───────────────────────────────
   It mixes icon-only squares - the hamburger and Home, 38x38 because that is a
   deliberate touch target - with the export button at 32px. MEASURED on the
   master list: 38 / 38 / 32, all centred on 28.6, so they line up but the third
   control is visibly 6px shorter than the two beside it.

   One height inside the topbar. ⚠ Scoped to .topbar on purpose: .btn-export
   also sits in a card header, where 32px is right and matching a hamburger
   that is not there would make it the tallest thing in the row. */
.topbar .btn { min-height: 38px; }

/* ==========================================================================
   15g. The Supplier master — .supplier-bar, .tag-*, and the actions-column width
   ========================================================================== */

/* ⚠ HAND-PATCH 2026-09-08 — THE ACTIONS COLUMN HAD NO WIDTH AT ALL. It carried
   `white-space: nowrap` and nothing else, so an auto table layout handed it
   whatever was left over and the three 28px buttons sat in a column two or
   three times their width. Reported against pages/master-list.html.

   ⚠ THE WIDTH IS DERIVED FROM THE BUTTON COUNT, not typed as a pixel figure.
     The markup declares `style="--act-n:3"` on both the th and the td, so a
     master with two row actions or four gets the right column by changing one
     number — and, more to the point, so does this one when a fourth action is
     added and nobody remembers a magic 96px was tuned for three.

   ⚠ AND --act-w MOVES WITH THE BUTTON AT 575px, where .action-btn drops from
     28px to 26px (§10). A hardcoded width would be 6px too wide on every phone.
     One number, two users — the bargain --dc-lbl-h already makes for the chart.

   The cell's own left/right padding is added by box-sizing, so the arithmetic
   is buttons + gaps only. */
.cell-actions {
  --act-n: 3;
  --act-w: 28px;
  --act-gap: 6px;
  white-space: nowrap;
  width: calc(var(--act-n) * var(--act-w) + (var(--act-n) - 1) * var(--act-gap));
}
.cell-actions > .action-btn + .action-btn { margin-left: var(--act-gap); }

@media (max-width: 575.98px) {
  .cell-actions { --act-w: 26px; }
}

/* ⚠ THE RAIL'S ROWS ARE ANCHORS HERE, NOT BUTTONS. .mnav-item is written
   element-agnostically, but nothing in this stylesheet resets a link — an
   unclassed <a> takes the browser's blue underline, and the rail would be the
   one navigation in the application that looked like body copy. */
.mnav-item { text-decoration: none; }

/* ── The filter bar ────────────────────────────────────────────────────────
   ⚠ TWO GROUPS, space-between: filters left, actions hard right (the user's
     screenshots, 2026-09-08). A THIRD direct child would strand the middle one
     — the same trap .card-header-custom carries, for the same reason.
   ⚠ Each group wraps INTERNALLY. Below ~900px the actions drop under the
     filters as a block rather than interleaving with them, which is what keeps
     Add Supplier findable instead of stranded between two dropdowns. */
.supplier-bar { justify-content: space-between; align-items: flex-end; }
.supplier-bar .fb-filters,
.supplier-bar .fb-actions { display: flex; flex-wrap: wrap; gap: .5rem; align-items: flex-end; }
.supplier-bar .fb-filters { flex: 1 1 auto; min-width: 0; }
.supplier-bar .fb-actions { flex: 0 0 auto; }

/* ── Tag chips ─────────────────────────────────────────────────────────────
   ⚠ background AND color ARE SET INLINE, from the row. This is the one place
     in the application where a colour is data rather than a token (R11 governs
     the stylesheet, and kit.css still declares only #fff and #000). The ink is
     computed from the colour's luminance so a yellow tag is readable — see
     tag_ink() in modules/tags/shared.php, and its mirror in suppliers.js. */
.tag-chips { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
.tag-chip {
  display: inline-flex; align-items: center;
  padding: .1rem .45rem; border-radius: 999px;
  font-size: .7rem; font-weight: 700; line-height: 1.5;
  white-space: nowrap; border: none;
}

/* The picker inside the supplier dialog: every tag, as a toggle.
   ⚠ AN UNSELECTED CHIP IS DIMMED, NOT GREYED. Greying it out would hide the
     colour that is the whole point of the tag, so "not chosen" is carried by
     opacity and the ring, and the identity stays legible either way. */
.tag-pick { display: flex; flex-wrap: wrap; gap: .35rem; }
.tag-opt { cursor: pointer; opacity: .45; transition: opacity .15s, box-shadow .15s; }
.tag-opt:hover { opacity: .75; }
.tag-opt.is-on { opacity: 1; box-shadow: 0 0 0 2px var(--card-bg), 0 0 0 4px var(--accent-ring); }

/* A caption above a control that is not a text field — the tag picker and the
   switch have no floating label to float. */
.fl-stack { display: flex; flex-direction: column; gap: .35rem; }
.fl-stack-lbl { font-size: .72rem; font-weight: 600; color: var(--muted); }
.fl-switch { display: flex; align-items: center; gap: .5rem; min-height: 38px; padding-left: 2.4rem; }
.fl-switch .form-check-label { font-size: .82rem; font-weight: 600; color: var(--ink-700); }

/* A neutral badge, for a "No" that is not a failure. .st-inactive is grey with
   a meaning attached; this one carries none. */
.st-muted { background: var(--n-100); color: var(--muted); }

/* ── The Manage Tags dialog ─────────────────────────────────────────────── */
.tag-form { display: flex; gap: .5rem; align-items: flex-end; margin-bottom: .75rem; }
.tag-color-lbl { font-size: .7rem; font-weight: 700; letter-spacing: .04em;
                 text-transform: uppercase; color: var(--muted); margin-bottom: .4rem; }
.tag-swatches { display: flex; flex-wrap: wrap; gap: .35rem; }
.tag-sw {
  width: 26px; height: 26px; padding: 0; border: none; border-radius: 50%;
  cursor: pointer; color: #fff; font-size: .75rem; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
  box-shadow: inset 0 0 0 1px var(--shadow-ink);
}
.tag-sw.is-on { box-shadow: 0 0 0 2px var(--card-bg), 0 0 0 4px var(--ink-900); }
.tag-custom { display: flex; align-items: center; gap: .5rem; margin-top: .6rem; }
.tag-custom label { font-size: .72rem; font-weight: 600; color: var(--muted); }
.tag-custom input[type="color"] {
  width: 34px; height: 26px; padding: 0; border: 1px solid var(--field-border);
  border-radius: var(--radius-sm); background: none; cursor: pointer;
}
.tag-preview { padding: .1rem .5rem; border-radius: 999px; font-size: .7rem; font-weight: 700; }

/* The reorderable list. ⚠ touch-action: none ON THE GRIP is what makes a drag
   work on a touch screen at all — without it the browser claims the gesture for
   scrolling and the pointer events never arrive. */
.tag-list { margin-top: .5rem; border: 1px solid var(--card-border); border-radius: var(--radius-sm); }
.tag-row {
  display: flex; align-items: center; gap: .5rem;
  padding: .4rem .5rem; background: var(--card-bg);
}
.tag-row + .tag-row { border-top: 1px solid var(--line); }
.tag-row.is-dragging { background: var(--accent-wash); }
.tag-grip { cursor: grab; color: var(--faint); touch-action: none; padding: .25rem; line-height: 1; }
.tag-grip:active { cursor: grabbing; }
.tag-row-sp { flex: 1 1 auto; }

/* ── The upload dialog's plan and error states ───────────────────────────── */
.imp-plan {
  margin-top: .75rem; padding: .5rem .65rem; border-radius: var(--radius-sm);
  background: var(--accent-wash); color: var(--ink-700); font-size: .8rem;
}
/* ⚠ THE FAILURE HEADLINE SAYS "nothing was saved" AND MUST KEEP SAYING IT. The
   import is all-or-nothing, and somebody reading a list of row errors will
   otherwise assume the good rows went in. */
.imp-fail {
  margin-top: .75rem; padding: .5rem .65rem; border-radius: var(--radius-sm);
  background: var(--danger-bg); color: var(--danger-ink);
  font-size: .8rem; font-weight: 600;
}
.imp-err {
  display: flex; gap: .5rem; align-items: baseline;
  margin-top: .35rem; padding: .35rem .5rem;
  border: 1px solid var(--danger-100); border-radius: var(--radius-sm);
  font-size: .78rem; color: var(--ink-700);
}
.imp-row {
  flex: 0 0 auto; font-weight: 700; color: var(--danger-ink);
  font-variant-numeric: tabular-nums;
}

/* ── Money cells ─────────────────────────────────────── HAND-PATCH 2026-09-09
   ⚠ THE CLASS IS PUT ON THE td AND THE th BOTH, from the registry's declared
     `money` type — a right-aligned column whose heading is left-aligned reads
     as a mistake, and the two must be decided in one place.
   ⚠ tabular-nums IS THE POINT, not decoration: it is what makes a column of
     amounts form a straight edge on the decimal. Proportional digits leave
     ₹1,199 and ₹650 visibly misaligned in a five-row list.
   ⚠ NO COLOUR ON THE NEGATIVE CASE. Credit from a supplier is money in her
     favour; the danger token would say the opposite. The suffix carries it. */
.cell-money { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.cell-money-cr {
  margin-left: .2rem; font-size: .72em; font-weight: 700;
  color: var(--muted); letter-spacing: .02em;
}

/* ── A curated list used as a SCREEN ─────────────────── HAND-PATCH 2026-09-09
   ⚠ .opt-list CAPS ITSELF AT 300px BECAUSE IT WAS BUILT FOR A DIALOG, where
     an inner scroll is right — the dialog cannot grow. On a full screen that
     cap clips the list at seven rows of ten and puts a second scrollbar inside
     a page that already scrolls, which on a phone is the "two scrollbars" trap
     where the wrong one moves under your thumb.
   ⚠ THE MODIFIER GOES ON THE ELEMENT, NOT A MEDIA QUERY. Whether the list is
     a screen or a dialog is a fact about the markup, not about the viewport —
     the same list at 1920px inside a dialog still wants its cap. */
.opt-list-page { max-height: none; overflow-y: visible; }

/* ── Stock grid extras ───────────────────────────────── HAND-PATCH 2026-09-09
   ⚠ THE AGE RIDES ON THE DATE, not in a column of its own. "12/03/2026"
     answers nothing by itself; "12/03/2026 181d" is the number the Old Stock
     card is about, on the row she is already reading — and it costs no width
     on a phone, where a seventh column would push the grid wider still.
   ⚠ .is-old IS A WEIGHT CHANGE, NOT A COLOUR ALONE. Colour on its own fails
     in shop-window daylight on a cheap screen, which is where this is read. */
.cell-age {
  margin-left: .35rem; font-size: .72em; font-weight: 600;
  color: var(--faint); font-variant-numeric: tabular-nums;
}
.cell-age.is-old { color: var(--amber-800); font-weight: 800; }

/* The row checkbox that feeds a bulk price change. Sized to the same 28px box
   as .action-btn so the actions column stays one straight edge. */
.act-pick {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; cursor: pointer;
}
.act-pick input { width: 16px; height: 16px; cursor: pointer; }
@media (max-width: 575.98px) {
  .act-pick { width: 26px; height: 26px; }
}

/* ── The till ────────────────────────────────────────── HAND-PATCH 2026-09-10
   ⚠ THE TOTALS BLOCK IS THE ONE THING SHE READS OUT TO A CUSTOMER, so it is
     bigger than body text and the final total is bigger again. Three lines in
     the order she says them: gross, discount, total.
   ⚠ ON A PHONE IT STICKS TO THE BOTTOM. With a queue watching she must not
     have to scroll to see what to charge — position:sticky keeps it in view
     while the lines above it scroll. */
.sb-totals {
  border: 1px solid var(--card-border); border-radius: var(--radius);
  padding: .5rem .75rem; background: var(--card-bg);
}
.sb-total-row {
  display: flex; justify-content: space-between; align-items: baseline;
  padding: .2rem 0; font-size: .9rem; color: var(--ink-700);
  font-variant-numeric: tabular-nums;
}
.sb-total-row.is-total {
  border-top: 1px solid var(--card-border); margin-top: .25rem; padding-top: .45rem;
  font-size: 1.15rem; font-weight: 800; color: var(--ink-900);
}
/* ⚠ A WEIGHT AND A LABEL CHANGE, NOT COLOUR ALONE — this is read in shop
   daylight on a cheap screen (BUSINESS_RULES §1.2). */
.sb-total-row.is-due { font-weight: 800; color: var(--danger-800); }

@media (max-width: 767.98px) {
  .sb-totals {
    position: sticky; bottom: 0; z-index: 3;
    background: var(--surface); box-shadow: 0 -4px 12px rgba(0,0,0,.08);
  }
}

/* The scanner preview. Capped so it never eats the whole screen on a phone. */
.sb-cam {
  width: 100%; max-height: 46vh; border-radius: var(--radius);
  background: #000; object-fit: cover;
}

/* ── Segmented tabs ──────────────────────────────────── HAND-PATCH 2026-09-10
   Used by the Sale screen to switch All / Open / Completed / Returns.
   ⚠ BUTTONS, NOT LINKS. Three of the four filter the page that is already
     loaded; navigating would throw away her filters and her scroll position.
   ⚠ THE ACTIVE TAB CARRIES WEIGHT AND A BORDER, not colour alone — same
     reason as every other state in this application (§1.2 of BUSINESS_RULES):
     it is read in shop daylight on a cheap screen.
   ⚠ IT SCROLLS SIDEWAYS INSIDE ITSELF at 375px rather than wrapping to two
     rows, which would push the grid down the page on the one screen where
     vertical space is scarcest. R9 is satisfied because the PAGE still does
     not scroll — the strip does, inside its own box. */
.seg-tabs {
  display: flex; gap: .25rem; overflow-x: auto; scrollbar-width: none;
  border-bottom: 1px solid var(--card-border); margin-bottom: .5rem;
}
.seg-tabs::-webkit-scrollbar { display: none; }

.seg-tab {
  appearance: none; background: none; border: 0;
  border-bottom: 2px solid transparent;
  padding: .45rem .8rem; white-space: nowrap;
  font-size: .85rem; font-weight: 600; color: var(--muted);
  cursor: pointer; border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.seg-tab:hover { color: var(--ink-900); background: var(--card-bg); }
.seg-tab.is-on {
  color: var(--accent-dark); border-bottom-color: var(--accent); font-weight: 800;
}
.seg-tab:focus-visible { outline: 2px solid var(--accent-500); outline-offset: -2px; }

/* ── The dashboard's Old Stock card ──────────────────── HAND-PATCH 2026-09-10
   ⚠ THE HEADLINE IS DELIBERATELY LARGE. "Money Stuck In Old Stock" is the
     question the whole application exists to answer (BUSINESS_RULES §1.3), and
     it competes on the page with five KPI tiles that are already loud.
   ⚠ THE BANDS RUN COOL TO WARM — the oldest stock is the most urgent, so the
     ramp carries meaning rather than decoration. Each band also has a LABEL and
     a number in the table, so the colour is never the only signal (§1.2). */
.os-headline { text-align: center; padding: .5rem 0 .75rem; }
.os-amount {
  font-size: 2.1rem; font-weight: 800; line-height: 1.1; color: var(--ink-900);
  font-variant-numeric: tabular-nums;
}
.os-caption {
  font-size: .78rem; font-weight: 700; letter-spacing: .3px;
  text-transform: uppercase; color: var(--muted); margin-top: .15rem;
}
.os-note { font-size: .8rem; color: var(--ink-700); margin-top: .35rem; }
.os-none { font-size: .9rem; color: var(--ok-800); padding: .5rem 0; }

@media (max-width: 575.98px) { .os-amount { font-size: 1.7rem; } }

/* The stacked proportion bar. No legend — the table below is the legend. */
.os-bar {
  display: flex; height: 12px; border-radius: 999px; overflow: hidden;
  background: var(--card-bg); margin: .25rem 0 .5rem;
}
.os-seg { display: block; height: 100%; }
.os-seg-1 { background: var(--series-2); }
.os-seg-2 { background: var(--series-4); }
.os-seg-3 { background: var(--amber); }
.os-seg-4 { background: var(--danger); }

.os-dot {
  display: inline-block; width: 9px; height: 9px; border-radius: 50%;
  margin-right: .45rem; vertical-align: baseline;
}
/* ⚠ The whole row is the target, so the cursor has to say so. */
.os-row { cursor: pointer; }
.os-row:hover { background: var(--card-bg); }

/* ── Horizontal bars — "What Is Selling" ─────────────────────────────────────
   ⚠ HORIZONTAL, BECAUSE CATEGORY NAMES ARE WORDS. A vertical bar chart clips
     them at any width a phone has (KNOWLEDGE_BASE T17 is exactly this lesson).
   ⚠ A GRID, NOT FLEX: one shared set of column widths across every row, so the
     figures line up whatever the longest name happens to be. */
.hb-row {
  display: grid; grid-template-columns: minmax(0,6.5rem) 1fr auto auto;
  align-items: center; gap: .5rem; padding: .3rem 0;
  font-size: .82rem;
}
.hb-lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-900); }
.hb-track { background: var(--card-bg); border-radius: 999px; height: 10px; overflow: hidden; }
.hb-fill  { display: block; height: 100%; border-radius: 999px; }
.hb-1 { background: var(--series-1); } .hb-2 { background: var(--series-2); }
.hb-3 { background: var(--series-3); } .hb-4 { background: var(--series-4); }
.hb-5 { background: var(--series-5); } .hb-6 { background: var(--series-6); }
.hb-7 { background: var(--series-7); }
.hb-val { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--ink-900); }
.hb-sub {
  font-size: .72rem; color: var(--muted); font-variant-numeric: tabular-nums;
  min-width: 1.6rem; text-align: right;
}

/* ── The one donut ───────────────────────────────────────────────────────────
   Three slices is the ceiling (§9). One inline SVG arc per slice; no library. */
.dash-donut-wrap { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.dash-donut { position: relative; }
.donut-mid {
  position: absolute; inset: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; pointer-events: none;
}
.donut-mid b { font-size: 1.25rem; font-weight: 800; color: var(--ink-900); line-height: 1; }
.donut-mid span { font-size: .68rem; color: var(--muted); }
.ds-1 { stroke: var(--series-1); }
.ds-2 { stroke: var(--amber); }
.ds-3 { stroke: var(--card-border); }

.donut-key { display: flex; flex-direction: column; gap: .3rem; font-size: .82rem; }
.dk-row { display: flex; align-items: center; gap: .45rem; color: var(--ink-700); }
.dk-row b { margin-left: auto; color: var(--ink-900); font-variant-numeric: tabular-nums; }
.dk-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
/* ⚠ The key's dots are FILLED where the arcs are STROKED, so each needs its
   own declaration — `stroke` does nothing to a div. */
.dk-dot.ds-1 { background: var(--series-1); }
.dk-dot.ds-2 { background: var(--amber); }
.dk-dot.ds-3 { background: var(--card-border); }

/* ── The phone's bottom bar ───────────────────────────── HAND-PATCH 2026-09-10
   ⚠ THE PRIMARY NAVIGATION ON THE DEVICE THIS SHOP USES (D6). Hidden from
     992px up, where the sidebar takes over.
   ⚠ SAFE-AREA INSET, because an iPhone's home indicator sits exactly where the
     last row of a fixed bar goes. The viewport meta already carries
     viewport-fit=cover, which is what makes env() resolve to anything.
   ⚠ THE PAGE GETS BOTTOM PADDING TO MATCH, or the last row of every list ends
     up underneath the bar — unreachable, and invisible enough that nobody
     reports it. */
.bottom-nav { display: none; }

@media (max-width: 991.98px) {
  .bottom-nav {
    display: flex;
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 1040;
    background: var(--surface);
    border-top: 1px solid var(--card-border);
    box-shadow: 0 -2px 10px rgba(0,0,0,.06);
    padding-bottom: env(safe-area-inset-bottom, 0);
  }

  .bnav-item {
    flex: 1 1 0; min-width: 0;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: .1rem; padding: .4rem .2rem .35rem;
    background: none; border: 0;
    color: var(--muted); text-decoration: none;
    font-size: .66rem; font-weight: 600; line-height: 1.1;
    /* ⚠ 48px of target, which is the floor for a thumb (BUSINESS_RULES §4). */
    min-height: 48px;
  }
  .bnav-item i { font-size: 1.15rem; }
  .bnav-item span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  /* ⚠ WEIGHT AND A BAR, NOT COLOUR ALONE — read in shop daylight (§1.2). */
  .bnav-item.active { color: var(--accent-dark); font-weight: 800; }
  .bnav-item.active::before {
    content: ''; position: absolute; top: 0; width: 34px; height: 3px;
    border-radius: 0 0 3px 3px; background: var(--accent);
  }
  .bnav-item { position: relative; }

  /* Clear the bar, plus the inset, so the last row is always reachable. */
  .page-body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)); }

  /* ⚠ A sticky element must clear it too, or the till's totals block sits
     underneath the navigation. */
  .sb-totals { bottom: calc(56px + env(safe-area-inset-bottom, 0)); }
}

/* ── Lists become cards on a phone ────────────────────── HAND-PATCH 2026-09-10
   ⚠ A TWELVE-COLUMN GRID CANNOT BE READ AT 375px. It scrolled sideways inside
     its own .table-wrap, which satisfies R9 and is still miserable: she has to
     scroll right to find the price and back left to know which garment it
     belonged to.

   ⚠ EACH ROW BECOMES A CARD, AND EACH CELL CARRIES ITS OWN LABEL, taken from
     `data-label` — which master-grid.js writes from the column metadata the
     server already sends. No second list of column names.

   ⚠ A CELL WITH NO LABEL IS THE ACTIONS CELL and keeps its own row. */
@media (max-width: 767.98px) {
  .table-app.as-cards,
  .table-app.as-cards tbody,
  .table-app.as-cards tr,
  .table-app.as-cards td { display: block; width: auto; }

  .table-app.as-cards thead { display: none; }

  .table-app.as-cards tr {
    border: 1px solid var(--card-border); border-radius: var(--radius);
    padding: .5rem .65rem; margin-bottom: .5rem; background: var(--surface);
  }

  .table-app.as-cards td {
    display: flex; align-items: baseline; justify-content: space-between; gap: .75rem;
    border: 0; padding: .18rem 0; text-align: right;
  }

  .table-app.as-cards td[data-label]::before {
    content: attr(data-label);
    flex: 0 0 auto; text-align: left;
    font-size: .7rem; font-weight: 700; letter-spacing: .2px;
    text-transform: uppercase; color: var(--muted);
  }

  /* ⚠ The actions row spans the card and sits at the end, so the buttons are
     where the thumb already is rather than scattered mid-card. */
  .table-app.as-cards td.cell-actions {
    justify-content: flex-end; gap: .35rem;
    border-top: 1px solid var(--card-border); margin-top: .3rem; padding-top: .4rem;
  }

  /* An empty state or an error keeps its single centred cell. */
  .table-app.as-cards td[colspan] { justify-content: center; text-align: center; }
  .table-app.as-cards td[colspan]::before { content: none; }

  /* Nothing to scroll any more, so the wrap must not pretend there is. */
  .table-wrap:has(.table-app.as-cards) { overflow-x: visible; }
}

/* ==========================================================================
   §15l  Configuration — the four old-stock band boxes    HAND-PATCH 2026-09-10

   ⚠ ONE SETTING, FOUR INPUTS. `old_stock_bands` is stored as "30,60,90,180"
     because its consumer reads one key; it is ENTERED as four small boxes
     because a comma-separated list typed on a phone keypad is a refusal she
     cannot explain. config.js joins them back up.

   ⚠ THIS IS THE ONE PLACE TWO INPUTS MAY SIT SIDE BY SIDE AT 375px (R9's usual
     answer is "never"). These four are one value, they are three characters
     wide, and stacking them reads as four unrelated settings. They shrink with
     the viewport rather than wrapping, and the row still fits at 320px.

   ⚠ NO .is-invalid RULE HERE — § above already styles it globally with
     --danger + --danger-ring and !important. A second one would be a second
     place to change the colour of a refusal.
   ========================================================================== */
.cfg-bands-lbl {
  display: block;
  font-size: .78rem; font-weight: 600; color: var(--muted);
  margin-bottom: .3rem;
}

.cfg-bands {
  display: flex; align-items: center; gap: .35rem;
  flex-wrap: nowrap;
}

.cfg-band {
  flex: 1 1 0; min-width: 0; max-width: 6rem;
  padding: 6px 8px; font-size: .9rem; line-height: 1.4;
  text-align: center;
  font-weight: 600; font-variant-numeric: tabular-nums;
  color: var(--body); background: var(--surface);
  border: 1px solid var(--field-border); border-radius: var(--radius-field);
}

.cfg-band:focus {
  outline: none;
  border-color: var(--accent-soft);
  box-shadow: 0 0 0 .2rem var(--accent-ring-soft);
}

/* ⚠ The spinner arrows are hidden: at this width they eat a third of the box,
   and a day count is typed rather than nudged. -webkit- and -moz- both, or
   Firefox keeps them and the four boxes stop lining up. */
.cfg-band::-webkit-outer-spin-button,
.cfg-band::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.cfg-band { -moz-appearance: textfield; appearance: textfield; }

.cfg-band-sep {
  flex: 0 0 auto;
  color: var(--faint); font-size: .85rem; line-height: 1;
  user-select: none;
}

/* ── A warning inside a dialog ────────────────────────── HAND-PATCH 2026-09-10
   §15m  One amber strip, for a rule the dialog must state BEFORE the primary
   button is pressed rather than after. First use: the return window on the Sale
   screen's Take Garments Back dialog.

   ⚠ NOT .imp-fail, which is red and belongs to a failed import. A rule she can
     still satisfy — by picking the other reason, or by changing a setting — is
     not an error, and colouring it as one teaches her to read red as "try
     again" instead of "this went wrong". */
.modal-warn {
  margin: 0 0 .75rem; padding: .55rem .7rem;
  border: 1px solid var(--warn-200); border-radius: var(--radius-field);
  background: var(--warn-bg); color: var(--warn-ink);
  font-size: .8rem; line-height: 1.45;
}
