/* ═══════════════════════════════════════════════════════════
   BARRO INDUSTRIES — Operations System v3
   Premium Dark Design System
   Palette: Black · Pink · Blue · Gold
   Apple-inspired · Glass · 3D icons · Rich animations
═══════════════════════════════════════════════════════════ */

@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,300;0,14..32,400;0,14..32,500;0,14..32,600;0,14..32,700;0,14..32,800;0,14..32,900&display=swap');

/* ══════════════════════════════
   DESIGN TOKENS
   Moved to css/tokens.css (Batch 2 / spec 2a) — linked before this file.
══════════════════════════════ */

/* ══════════════════════════════
   ANIMATIONS
   v14 de-structuring pass: meshDrift, float, floatB, gradRun, pulseGlow,
   goldShine, and ptr-bounce were removed here — each one only ever powered
   an Astral-exclusive decorative/glow effect that has now been deleted
   throughout this file, so they were fully orphaned (grep confirmed zero
   remaining `animation:` consumers, in this file or in js/*.js).
══════════════════════════════ */
@keyframes shimmer {
  0%   { transform: translateX(-100%) skewX(-12deg); }
  100% { transform: translateX(250%)  skewX(-12deg); }
}
@keyframes fadeUp {
  from { opacity: 0; transform: translateY(20px) scale(0.97); }
  to   { opacity: 1; transform: translateY(0)    scale(1);    }
}
@keyframes ptr-spin {
  to { transform: rotate(360deg); }
}
@keyframes slideIn {
  from { opacity: 0; transform: translateX(-12px); }
  to   { opacity: 1; transform: translateX(0); }
}
@keyframes popIn {
  from { opacity: 0; transform: scale(0.88) translateY(14px); }
  to   { opacity: 1; transform: scale(1)    translateY(0);    }
}
@keyframes dropDown {
  from { opacity: 0; transform: translateY(-10px) scale(0.96); }
  to   { opacity: 1; transform: translateY(0)     scale(1);    }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin3d {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(360deg); }
}
@keyframes statePulse {
  0%, 100% { opacity: .45; }
  50%      { opacity: 1; }
}
@keyframes iconPop {
  0%   { transform: scale(1); }
  40%  { transform: scale(1.18); }
  100% { transform: scale(1); }
}
@keyframes borderGlow {
  0%,100% { box-shadow: 0 0 0 0 var(--pink-glow); }
  50%      { box-shadow: 0 0 0 4px var(--pink-glow); }
}

/* ══════════════════════════════
   RESET
══════════════════════════════ */
*,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
  scroll-behavior: smooth;
  -webkit-tap-highlight-color: transparent;
  /* Match the active theme's canvas so overscroll never flashes a mismatched color */
  background: var(--bg);
  overscroll-behavior: none;
}
/* Universal keyboard-focus ring — WS42 Phase 5. (The actual live rule is the
   ACCESSIBILITY section further down — this one was a dead duplicate,
   removed in the v14 CSS/a11y audit; see that section for the rationale.) */

/* WS42 Phase 13 — touch ergonomics: kill the ~300ms tap delay / double-tap-zoom
   on anything interactive. [onclick] catches the many legacy inline-handler
   elements (item-card, kpi-card, notif-item, …) that aren't real <button>s. */
button, a, input, select, textarea, summary,
.btn-primary, .btn-secondary, .btn-danger, .btn-success,
.chip-tab, .nav-item, .top-nav-item, .bottom-nav-item, .subtab-btn,
.item-card, .kpi-card, .card, .dept-card, .quick-action-btn,
[onclick], [role="button"] {
  touch-action: manipulation;
}

/* WS42 Phase 13 — .pressable: standardized active-state feedback (transform/opacity
   only). Applied to shell nav rows + list rows; safe to add anywhere else too. */
.pressable { transition: transform 120ms var(--ease, ease), opacity 120ms var(--ease, ease); }
.pressable:active { transform: scale(0.97); opacity: 0.92; }
@media (prefers-reduced-motion: reduce) {
  .pressable { transition: none; }
  .pressable:active { transform: none; opacity: 0.92; }
}

/* WS42 Phase 13 — ≥44px effective hit-area for small icon buttons on touch,
   via an invisible ::after overlay rather than visually enlarging the icon
   (which would look oversized next to its siblings on a compact row).

   .modal-back / .page-panel-back — the two WINDOW BACK ARROWS — joined this
   list in the v14 exit-control fix. They were the only controls in the app
   whose 44px floor was WIDTH-gated instead of POINTER-gated: the real box
   geometry that gives them 44x44 (`padding:10px; margin:-6px`) lives in the
   ≤768px block (~line 2948), and `.modal-close` — the class the modal's button
   used to carry, and the only entry in this list that ever covered it — was
   dropped from that button when it became a back arrow (index.html ~348 keeps
   the id, not the class). Measured on #modal-close at 768x900 (iPad portrait,
   coarse pointer) and at 812x375 (iPhone landscape): min-width auto,
   min-height auto, box 28 x 31.5 — 16px and 12.5px under the contract, on the
   ONE control the user needs in order to leave the window at all. A coarse
   pointer at ≥769px — any iPad in landscape, any touch laptop — is exactly the
   case a width-gated rule cannot see, so the floor has to be stated here too.
   Redundant with the ≤768px geometry wherever both match (same 44x44, same
   centre), so there is no fight and no growth; the ::after is absolutely
   positioned and therefore never becomes a second flex item in these
   `display:flex` buttons. */
@media (pointer: coarse) {
  .modal-close, .modal-back, .page-panel-back,
  .ms-act-btn, .ms-attach-btn, .ms-send-btn, .drawer-back-btn,
  .topbar-avatar, .ms-thread-menu-btn {
    position: relative;
  }
  .modal-close::after, .modal-back::after, .page-panel-back::after,
  .ms-act-btn::after, .ms-attach-btn::after,
  .ms-send-btn::after, .drawer-back-btn::after,
  .topbar-avatar::after, .ms-thread-menu-btn::after {
    content: '';
    position: absolute;
    top: 50%; left: 50%;
    width: 44px; height: 44px;
    transform: translate(-50%, -50%);
  }
}

body {
  font-family: var(--font);
  background: var(--bg);
  color: var(--text);
  font-size: var(--fs-base); line-height: var(--lh-base);
  min-height: 100dvh; overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  position: relative;
  /* Match overscroll gutter color on iOS */
  background-color: var(--bg);
  /* WS42 Phase 11: stop iOS/Android pull-to-refresh fighting the SPA's own
     pull-to-refresh + prevent vertical rubber-banding revealing the gutter. */
  overscroll-behavior-y: none;
}

/* ── Animated background mesh + decorative orb ──
   WS42: Light & Dark are solid, zero-blur, zero-animation themes. The mesh/aurora
   drift and floating orb are Astral-only (performance budget — no infinite
   animation outside the showpiece theme). Astral re-declares body + these
   pseudo-layers below in its own block. ── */
body::before,
body::after { content: none; }

/* everything inside should sit above bg mesh */
#login-screen, #app-shell { position: relative; z-index: 1; }

/* ══════════════════════════════
   UTILITIES
══════════════════════════════ */
.hidden { display: none !important; }
.text-muted  { color: var(--text-muted); font-size: var(--fs-sm); }
.text-center { text-align: center; }
/* v14 CSS/a11y audit — standard clip-based visually-hidden-but-focusable
   utility. js/*.js uses aria-label 89x with no CSS-supported way to also
   ship a real, focusable, visually-hidden text node (e.g. status changes,
   "3 new messages" live-region text) — this is that utility. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* ── Badges ── */
.badge {
  display: inline-flex; align-items: center; gap: 3px;
  padding: 3px 9px; border-radius: var(--pill);
  font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.2px;
  white-space: nowrap; border: 1px solid transparent;
}
.badge-blue   { background: rgba(10,132,255,0.15);   color: #60B4FF; border-color: rgba(10,132,255,0.20); }
.badge-green  { background: rgba(48,209,88,0.12);    color: #4DE87A; border-color: rgba(48,209,88,0.18); }
.badge-orange { background: rgba(255,159,10,0.12);   color: #FFB830; border-color: rgba(255,159,10,0.18); }
.badge-red    { background: rgba(255,69,58,0.12);    color: #FF7066; border-color: rgba(255,69,58,0.18); }
.badge-gray   { background: rgba(255,255,255,0.06);  color: var(--text-muted-strong, var(--text-muted)); border-color: var(--border); }
.badge-purple { background: rgba(139,92,246,0.14);   color: #B07EFF; border-color: rgba(139,92,246,0.20); }

/* ── PRIORITY STARS (window.priorityStarPicker, js/screens/production.js) ──
   Tap-to-set job priority, used on Production's Orders + Job Orders cards and
   on the Sales Orders table. Buttons, not a select, because the whole point is
   that a planner can re-rank a list without opening anything. */
.pstars { display:inline-flex; align-items:center; gap:1px; vertical-align:middle; }
.pstars .pstar {
  background:none; border:none; padding:0 1px; margin:0; line-height:1;
  font-size:15px; color:var(--text-faint, #8a8a8a); cursor:pointer;
  border-radius:3px; transition:transform .08s ease, color .12s ease;
}
.pstars .pstar.on { color:#F5A623; }
.pstars.pstars-high .pstar.on { color:#F07800; }
.pstars.pstars-urgent .pstar.on { color:var(--danger, #E5484D); }
.pstars .pstar:hover { transform:scale(1.18); color:#F5A623; }
.pstars .pstar:focus-visible { outline:2px solid var(--primary); outline-offset:1px; }
.pstars.pstars-ro .pstar { cursor:default; }
.pstars-lbl { font-size:10px; color:var(--text-muted); margin-left:5px; font-weight:600; }
.pstars.pstars-busy { opacity:.5; pointer-events:none; }
@media print { .pstars .pstar { color:#666; } .pstars .pstar.on { color:#000; } }
.badge-teal   { background: rgba(0,212,170,0.12);    color: #00DDB8; border-color: rgba(0,212,170,0.18); }
.badge-warn,
.badge-amber  { background: var(--warning-soft);     color: var(--warning); border-color: var(--warning-soft); }

/* ── Buttons ── WS42 Phase 9: solid --primary/--on-primary in Light/Dark; Astral keeps gradient */
.btn-primary {
  position: relative; overflow: hidden;
  background: var(--primary-fill, var(--primary));
  color: var(--on-primary); border: none;
  border-radius: var(--r-sm);
  min-height: 36px;
  padding: 10px 18px; font-size: var(--fs-md); font-weight: 600;
  cursor: pointer; letter-spacing: 0.1px;
  transition: transform var(--t1), box-shadow var(--t2), background var(--t2);
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  box-shadow: none;
  white-space: nowrap;
}
.btn-primary::before { content: none; }
@media (hover:hover) {
  .btn-primary:hover { transform: translateY(-1px); box-shadow: var(--sh); }
}
.btn-primary:active { transform: scale(var(--press-scale)); }
.btn-primary.btn-full { width: 100%; padding: 14px; font-size: 0.9375rem; border-radius: var(--r); }
.btn-primary.btn-sm   { padding: 6px 13px; font-size: var(--fs-sm); border-radius: var(--r-xs); }
.btn-primary.btn-lg   { padding: 13px 24px; font-size: var(--fs-lg); border-radius: var(--r-sm); }
.btn-primary:disabled { opacity: .6; cursor: not-allowed; box-shadow: none; }
@media (hover:hover) { .btn-primary:disabled:hover { transform: none; box-shadow: none; } }
/* v13 Phase 123 — busy state: busy() (js) can swap button text; this class is the
   CSS hook for a subtle "working" pulse. JS hookup to add .btn-busy is deferred —
   this only codifies the rule so it's ready to adopt. */
.btn-busy { animation: statePulse 1.4s ease-in-out infinite; pointer-events: none; }
@media (prefers-reduced-motion: reduce) { .btn-busy { animation: none; opacity: .7; } }
@media (pointer:coarse) { .btn-primary, .btn-secondary, .btn-outline, .btn-danger, .btn-success { min-height: 40px; } }
/* v14 Astral de-structuring pass: Astral's .btn-primary now differs from Dark
   ONLY by its gradient fill color — the shimmer sweep pseudo-element, the
   always-on glow shadow, and the infinite gradient-run animation (all
   structural/motion, not color) were removed so it inherits the exact same
   shape/shadow/hover behavior as Dark's .btn-primary. */
html.theme-astral .btn-primary {
  background: var(--grad-pk-bl-btn, var(--grad-pk-bl));
}

.btn-secondary {
  background: var(--surface-2); color: var(--text-2);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: 9px 16px; font-size: var(--fs-md); font-weight: 600;
  cursor: pointer;
  transition: background var(--t1), border-color var(--t1), transform var(--t1);
  display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
}
@media (hover:hover) {
  .btn-secondary:hover { background: var(--surface-3); border-color: var(--border-strong); transform: translateY(-1px); }
}
.btn-secondary:active { transform: scale(var(--press-scale)); }
.btn-secondary.btn-sm { padding: 6px 12px; font-size: var(--fs-sm); border-radius: var(--r-xs); }
.btn-secondary.btn-lg { padding: 13px 24px; font-size: var(--fs-lg); border-radius: var(--r-sm); }
.btn-secondary:disabled { opacity: .6; cursor: not-allowed; }
@media (hover:hover) { .btn-secondary:disabled:hover { background: var(--surface-2); border-color: var(--border); transform: none; } }

.btn-danger {
  background: rgba(255,69,58,0.12); color: #FF7066;
  border: 1px solid rgba(255,69,58,0.22);
  border-radius: var(--r-sm); padding: 9px 16px;
  font-size: var(--fs-md); font-weight: 600; cursor: pointer;
  transition: background var(--t1), box-shadow var(--t1), transform var(--t1);
}
@media (hover:hover) {
  .btn-danger:hover { background: rgba(255,69,58,0.22); box-shadow: 0 2px 12px rgba(255,69,58,0.25); }
}
.btn-danger:active { transform: scale(var(--press-scale)); }
.btn-danger.btn-sm { padding: 6px 13px; font-size: var(--fs-sm); border-radius: var(--r-xs); }
.btn-danger.btn-lg { padding: 13px 24px; font-size: var(--fs-lg); border-radius: var(--r-sm); }
.btn-danger:disabled { opacity: .6; cursor: not-allowed; box-shadow: none; }
@media (hover:hover) { .btn-danger:disabled:hover { background: rgba(255,69,58,0.12); box-shadow: none; } }

.btn-success {
  background: rgba(48,209,88,0.12); color: #4DE87A;
  border: 1px solid rgba(48,209,88,0.22);
  border-radius: var(--r-sm); padding: 9px 16px;
  font-size: var(--fs-md); font-weight: 600; cursor: pointer;
  transition: background var(--t1), transform var(--t1);
}
@media (hover:hover) { .btn-success:hover { background: rgba(48,209,88,0.22); } }
.btn-success:active { transform: scale(var(--press-scale)); }
.btn-success.btn-sm { padding: 6px 13px; font-size: var(--fs-sm); border-radius: var(--r-xs); }
.btn-success.btn-lg { padding: 13px 24px; font-size: var(--fs-lg); border-radius: var(--r-sm); }
.btn-success:disabled { opacity: .6; cursor: not-allowed; }
@media (hover:hover) { .btn-success:disabled:hover { background: rgba(48,209,88,0.12); } }

.btn-link {
  background: none; border: none;
  color: var(--primary); font-size: var(--fs-md); font-weight: 500;
  cursor: pointer; padding: 4px 0;
  transition: color var(--t1), opacity var(--t1);
}
@media (hover:hover) { .btn-link:hover { opacity: 0.8; } }
.btn-link:active { opacity: .65; }
.btn-link:disabled { opacity: .6; cursor: not-allowed; }
.btn-link.btn-sm { font-size: var(--fs-sm); }
.btn-link.btn-lg { font-size: var(--fs-lg); }

@media (pointer: coarse) {
  /* Row-action icon buttons + removable chips need a real tap target on
     touch; a 44px ::after overlay would overlap when they cluster ~4px
     apart, so size the box instead. Desktop keeps the compact padding. */
  .btn-icon { min-width: 40px; min-height: 40px; display: inline-flex;
    align-items: center; justify-content: center; }
  .team-chip, .pt-chip { min-height: 32px; padding: 6px 10px;
    display: inline-flex; align-items: center; gap: 4px; }
}
.btn-icon {
  background: none; border: none; cursor: pointer;
  font-size: 0.9375rem; padding: 6px 8px; border-radius: var(--r-xs);
  transition: background var(--t1); color: var(--text-muted);
}
.btn-icon:hover { background: var(--s2); color: var(--text); }

/* ── Forms ── */
.form-group { margin-bottom: 14px; }
.form-group label {
  display: block; font-size: var(--fs-xs); font-weight: 600;
  margin-bottom: 5px; color: var(--text-muted);
  text-transform: uppercase; letter-spacing: 0.6px;
}
.form-group input,
.form-group textarea,
.form-group select {
  width: 100%; padding: 10px 12px; min-height: 38px;
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  font-size: var(--fs-base); /* base size */
  background: var(--surface); color: var(--text);
  transition: border-color var(--t2), box-shadow var(--t2), background var(--t2);
  outline: none; -webkit-appearance: none;
}
/* ⚠ Checkboxes and radios are NOT text fields, and the rule above treats them
   as if they were: width:100% stretches a tick box across the whole row, and
   -webkit-appearance:none strips the tick itself, so what renders is a big
   empty rounded rectangle with the label shoved to the far right. That is the
   President's 2026-08-10 report — "the tick icon does not make sense" — and it
   affected every checkbox inside a .form-group, not just the meeting invite
   list where it was spotted. The file already had the same carve-out on the
   [readonly] rule below, which is why that one looks right.
   Specificity 0,2,1 beats the 0,1,1 rule above without !important.

   ── WIDENED 2026-08-10 ("check the other forms with checkboxes") ──
   The carve-out is now stated TWICE inside one rule: a bare `input[type=…]`
   (0,1,1) that reaches EVERY checkbox/radio in the app — table cells
   (bir.js reconcile, finance.js bank reconcile), chat member pickers, the
   login row, ad-hoc labels — plus the original `.form-group`-scoped form
   (0,2,1) that still outranks the text-field rules above. A selector list
   carries per-selector specificity, so one rule covers both without
   duplication.

   SIZE: explicit 16px, not the UA's 13px. 13px is the size the fix landed on
   by default and it is too small to hit reliably on a phone; 16px is also
   what most call sites were already hand-setting inline (width:16px;height:16px)
   to fight the broken rule. With one baseline stated here, those inline sizes
   — and the 18px ones that disagreed with them — are redundant and are being
   deleted at source, so every tick in the app is finally the same size.

   PLACED HIGH IN THE FILE ON PURPOSE. Two later rules deliberately override
   this and must keep winning; both are later in source order AND tie or beat
   it on specificity, so the cascade does the work with no !important:
     • `.notif-toggle-wrap input[type="checkbox"]` (~line 7350) — the visually
       hidden input behind the notification SWITCH. If this baseline ever
       out-ranked it, a real 16px tick would paint on top of the switch.
     • `input.ba-recon-chk` (~line 7396) — the deliberately-24px money control
       in the Bank Account drilldown.
   Anything new that needs a different size must beat 0,1,1 and sit below.

   vertical-align:middle only bites in INLINE contexts (a plain
   `<label><input> text</label>`); flex/grid parents ignore it. It replaces the
   UA baseline alignment, which drops the box onto the text baseline and reads
   as sitting low next to its label. In a flex label, `flex:0 0 auto` plus the
   parent's own align-items does the same job. */
input[type="checkbox"],
input[type="radio"],
.form-group input[type="checkbox"],
.form-group input[type="radio"] {
  width: 16px; height: 16px;
  min-height: 0; padding: 0; border: 0;
  background: none; border-radius: 0;
  -webkit-appearance: auto; appearance: auto;
  accent-color: var(--primary-light, var(--primary, #3B5BDB));
  flex: 0 0 auto;                /* never stretch inside a flex label */
  vertical-align: middle;        /* inline labels only; flex parents ignore it */
  margin: 0; cursor: pointer;
}

/* Checkboxes/radios are excluded from the text-field focus treatment: they
   carry `border:0`, so `border-color` does nothing, and the 3px box-shadow
   halo fires on MOUSE/TOUCH focus too — clicking a tick left a blue glow
   stuck around it that reads as a rendering fault, not as focus. Keyboard
   focus is already covered by the universal `:focus-visible` outline in the
   ACCESSIBILITY section (~line 7300), which is pointer-aware. Same :not()
   convention the [readonly] rule below has always used. */
.form-group input:not([type=checkbox]):not([type=radio]):focus,
.form-group textarea:focus,
.form-group select:focus {
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
.form-group textarea { resize: vertical; min-height: 80px; line-height: 1.6; }
/* 16px stops iOS auto-zoom on focus.
   MOBILE FINANCE PASS (2026-08-08) — broadened from the `.form-group`/`.ms-input`
   allowlist to bare input/select/textarea, so hand-rolled controls built with
   inline styles (finance's shared edit-modal textarea, the Records type filter,
   the payroll month pickers, the bank re-tag select) are covered too rather than
   depending on each site remembering to sit inside a .form-group.
   NOTE for the next reader: this is the WEAK guard. The strong one lives at
   ~line 3236 — `@media (hover:none) and (pointer:coarse) { input,textarea,select
   { font-size: var(--fs-lg)!important } }` — and that one already covers every
   iPhone (hover:none + coarse) and beats inline styles outright. This rule's
   real job is the `pointer:coarse` + `hover:hover` gap that the strong guard's
   `hover:none` term excludes (touch laptops, some Android). Element-level
   (0,0,1) specificity on purpose: any deliberate class-based sizing still wins.
   The six finance/HR inline `font-size` declarations were deleted at source in
   the same pass, so nothing has to be fought with !important here. */
@media (pointer: coarse) {
  .form-group input, .form-group textarea, .form-group select, .ms-input,
  input, select, textarea { font-size: 1rem; }
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }

/* V13 Phase 131 — 44px-tall label-wrap so bare checkboxes get a real tap
   target on touch (checkbox itself stays visually small).

   2026-08-10 audit: this class was shipped in V13 and then sat unused for a
   year — every checkbox in the app hand-rolled its own
   `<label style="display:flex;align-items:center;gap:8px">` instead, and some
   left the text outside the label entirely, leaving a ~16px tap target on a
   phone. The per-screen partitions are adopting it now, so it has to hold up
   inside a .form-group as well as outside one.

   Three fixes so it actually does:
   1. The hard-coded 18px checkbox size is GONE. It was one of the three
      competing sizes in the app (13 / 16 / 18); the single baseline above now
      governs, so a .check-row tick matches every other tick.
   2. Typography reset, so adopters stop having to write one inline (meetings.js
      already carries a hand-written copy). `.form-group label` (~line 320) is
      `display:block; text-transform:uppercase; font-size:var(--fs-xs);
      color:var(--text-muted)` — right for a field caption, wrong for a person's
      name or a sentence sitting next to a tick, which is how the meeting invite
      list came to render staff as "JUAN DELA CRUZ" in muted grey caps.
      The `.form-group label.check-row` half of the selector is what makes this
      stick: plain `label.check-row` is 0,1,1 and only beats `.form-group label`
      (also 0,1,1) on source order — and the ≤640px density block near the end
      of the file re-states `.form-group label { font-size:11px }` LATER, so on
      every phone the caption styling would have won again. 0,2,1 settles it at
      all widths. Size is --fs-base (the body default) so a .check-row outside
      any form reads identically to the text around it.
   3. `.check-row-top` for labels that WRAP. `align-items:center` is right for
      one line and wrong for three — it floats the tick into the middle of the
      paragraph. The modifier pins it to the first line (the 0.30em nudge drops
      the 16px box off the line's top edge onto its cap height). Use it on long
      labels — finance's "Active (uncheck to close — kept forever, hidden from
      pickers)" wraps on every phone, and chat.js/meetings.js are currently
      hand-writing `align-items:flex-start` + a 4px nudge inline to get this. */
label.check-row,
.form-group label.check-row {
  display: inline-flex; align-items: center; gap: 8px;
  min-height: 44px; padding: 4px 8px; cursor: pointer;
  font-size: var(--fs-base); font-weight: 500; color: var(--text);
  text-transform: none; letter-spacing: normal; margin-bottom: 0;
}
label.check-row.check-row-top,
.form-group label.check-row.check-row-top { align-items: flex-start; }
.check-row-top input[type="checkbox"],
.check-row-top input[type="radio"] { margin-top: 0.30em; }

/* V13 Phase 124 — Form kit: required marker, hint/error slots, disabled/readonly/error
   states. Purely additive: only fires on opt-in classes (.required, .has-error) or the
   :disabled/[readonly] pseudo-states, so default-state forms render unchanged. */
.form-group.required label::after { content: ' *'; color: var(--danger); }
.form-hint {
  display: block; margin-top: 5px;
  font-size: var(--fs-sm); color: var(--text-muted);
}
.form-error {
  display: none; margin-top: 5px;
  font-size: var(--fs-sm); color: var(--danger);
}
.form-group.has-error .form-error { display: block; }
.form-group.has-error input,
.form-group.has-error textarea,
.form-group.has-error select {
  border-color: var(--danger);
}
.form-group input:disabled,
.form-group textarea:disabled,
.form-group select:disabled {
  opacity: .6; cursor: not-allowed; background: var(--s2, var(--surface-2));
}
.form-group input[readonly]:not([type=checkbox]):not([type=radio]),
.form-group textarea[readonly] {
  background: var(--s2, var(--surface-2)); cursor: default;
}

.select-sm {
  padding: 7px 10px; border: 1px solid var(--border);
  border-radius: var(--r-xs); font-size: var(--fs-sm); font-weight: 500;
  background: var(--s1); color: var(--text);
  cursor: pointer; outline: none;
  transition: border-color var(--t2); -webkit-appearance: none;
}
.select-sm:focus { border-color: var(--pink); }

.error-msg {
  color: #FF7066; font-size: var(--fs-md); padding: 9px 12px;
  background: rgba(255,69,58,0.08); border: 1px solid rgba(255,69,58,0.18);
  border-radius: var(--r-sm); margin-bottom: 10px;
}
.success-msg {
  color: #4DE87A; font-size: var(--fs-md); padding: 9px 12px;
  background: rgba(48,209,88,0.08); border: 1px solid rgba(48,209,88,0.18);
  border-radius: var(--r-sm);
}
/* Session-persistence disclosure on the login screen (#login-persist-note).
   Amber, not red: "you won't stay signed in" is a consequence the user has to
   be told about before they hit Sign In, but it isn't a failed action. */
.persist-note {
  color: #F0B429; font-size: var(--fs-sm); padding: 8px 12px;
  background: rgba(240,180,41,0.08); border: 1px solid rgba(240,180,41,0.20);
  border-radius: var(--r-sm); margin-bottom: 10px; line-height: 1.4;
}
.spinner {
  display: inline-block; width: 16px; height: 16px;
  border: 2px solid rgba(255,255,255,0.25);
  border-top-color: #fff; border-radius: 50%;
  animation: spin 0.65s linear infinite;
}

/* ══════════════════════════════
   LOGIN SCREEN
══════════════════════════════ */
.screen { display: flex; min-height: 100dvh; }

#login-screen {
  flex-direction: column;
  align-items: center; justify-content: center;
  padding: 24px; position: relative; overflow: hidden;
  background: var(--bg);
}

/* v14 Astral de-structuring pass: the decorative aurora orbs + gold pulse orb
   (::before/::after pseudo-elements with infinite animation) were removed —
   Astral now shares Light/Dark's flat, zero-ambient-glow, zero-animation
   login background exactly; only its palette tokens differ. */
.login-bg { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
#login-screen::after { display: none; }

/* Minimal Apple-style card: centered, ≤380px, flat + token-driven in Light/Dark.
   Astral keeps the glass/gold-trim showpiece look. */
.login-container {
  position: relative; z-index: 2;
  background: var(--login-card-bg);
  border-radius: var(--r-xl);
  padding: 36px 28px 28px;
  width: 100%; max-width: 380px;
  border: 1px solid var(--border);
  box-shadow: var(--sh-lg);
  text-align: center;
  animation: fadeUp 0.5s var(--spring);
}
/* v14 Astral de-structuring pass: the login-container glass/blur/gold-trim
   showpiece card was removed entirely — Astral's card now falls through to
   the exact same flat, token-driven base rule Dark uses (its --login-card-bg/
   --border tokens already give it a distinct tint; see the palette block). */

/* Circular logo (Phase 25 spec) — same shape/border in every theme; only the
   Astral palette tokens differ. The cosmic sheen/gold-trim/rounded-square
   variant was removed (v14 de-structuring pass) — Astral keeps the same
   circular logo wrap as Light/Dark. */
.login-logo-wrap {
  position: relative; overflow: hidden;
  width: 80px; height: 80px; margin: 0 auto 18px;
  border-radius: 50%;
  background: var(--surface-2);
  border: 1px solid var(--border);
  display: flex; align-items: center; justify-content: center;
  padding: 6px;
}
.login-logo-img {
  width: 100%; height: 100%; object-fit: contain;
  border-radius: 50%; position: relative; z-index: 1;
}
.login-logo-fallback {
  width: 100%; height: 100%;
  background: var(--primary-fill, var(--primary));
  color: var(--on-primary); border-radius: 50%;
  font-size: var(--fs-4xl); font-weight: 800;
  display: flex; align-items: center; justify-content: center; margin: 0 auto;
  position: relative; z-index: 1;
}
/* Color-only: Astral's brand gradient fill instead of the flat --primary; same
   circular shape as Light/Dark (the 16px rounded-square override was removed). */
html.theme-astral .login-logo-fallback { background: var(--grad-pk-bl); }
.login-title {
  font-family: var(--font-display);
  font-size: var(--fs-3xl); font-weight: 800;
  color: var(--text);
  margin-bottom: 4px; letter-spacing: -0.4px;
}
/* Color-only: Astral's signature gradient-fill title text. The continuous
   gradRun animation (motion, not color) was removed — v14 de-structuring pass. */
html.theme-astral .login-title {
  background: var(--grad-pk-bl);
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
  background-clip: text;
}
.login-sub { font-size: var(--fs-md); color: var(--text-muted); margin-bottom: 20px; }
.login-form { text-align: left; }
.pw-wrap { position: relative; }
.pw-wrap input { padding-right: 44px; }
.pw-toggle {
  position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
  background: none; border: none; cursor: pointer;
  color: var(--text-muted); padding: 6px; display: flex; align-items: center;
  transition: color var(--t1);
}
.pw-toggle:hover { color: var(--text); }
.pw-toggle svg { width: 16px; height: 16px; stroke: currentColor; }
.login-version { margin-top: 20px; font-size: var(--fs-xs); color: var(--text-light); }

/* ── Company Page ── */
.co-hero {
  position: relative; overflow: hidden;
  border-radius: var(--r); margin-bottom: 20px;
  padding: 36px 28px; display: flex; align-items: center; gap: 20px;
  background: linear-gradient(135deg, #1a1a1e 0%, #111113 60%, #1c1a0e 100%);
  border: 1px solid rgba(255,214,10,0.15);
}
.co-hero-bg {
  position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(ellipse at 80% 50%, rgba(255,214,10,0.08) 0%, transparent 60%);
}
.co-hero-logo { width: 60px; height: 60px; object-fit: contain; flex-shrink: 0; position: relative; z-index: 1; }
.co-hero-text { position: relative; z-index: 1; }
.co-hero-title { font-size: 1.375rem; font-weight: 900; letter-spacing: 0.12em; color: #fff; margin-bottom: 6px; }
.co-hero-tagline { font-size: var(--fs-md); color: var(--gold); font-style: italic; font-weight: 500; }

.co-section { background: var(--s1); border: 1px solid var(--border); border-radius: var(--r); padding: 20px; margin-bottom: 16px; }
.co-section-title { font-size: var(--fs-md); font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 14px; }
.co-body { font-size: var(--fs-md); color: var(--text-2); line-height: 1.8; }

/* Business cards */
.co-biz-grid { display: flex; flex-direction: column; gap: 12px; }
.co-biz-card {
  display: flex; align-items: flex-start; gap: 14px;
  padding: 14px; background: var(--s2); border-radius: var(--r-sm);
  border: 1px solid var(--border);
}
.co-biz-logo { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; border-radius: 8px; }
.co-biz-name { font-size: var(--fs-base); font-weight: 700; margin-bottom: 4px; }
.co-biz-desc { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.6; margin-bottom: 6px; }

/* President card */
.co-president-card {
  display: flex; gap: 20px; align-items: flex-start;
}
.co-president-left { flex-shrink: 0; text-align: center; width: 100px; }
.co-president-photo {
  width: 88px; height: 88px; border-radius: 50%; object-fit: cover;
  border: 2px solid var(--gold); margin-bottom: 8px;
}
.co-president-initials {
  width: 88px; height: 88px; border-radius: 50%; background: var(--gold);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-4xl); font-weight: 900; color: #000; margin: 0 auto 8px;
}
.co-president-name { font-size: var(--fs-md); font-weight: 700; color: var(--text); }
.co-president-title { font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.5; margin-top: 2px; }
.co-president-msg { flex: 1; }
.co-president-msg p { font-size: var(--fs-md); color: var(--text-2); line-height: 1.8; font-style: italic; margin-bottom: 10px; }
.co-quote-mark { font-size: 3rem; line-height: 1; color: var(--gold); opacity: 0.4; font-family: Georgia, serif; margin-bottom: -10px; }

/* Core values */
.co-values-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.co-value-card { background: var(--s2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 14px; }
.co-value-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-bottom: 10px; }
.co-value-name { font-size: var(--fs-md); font-weight: 700; margin-bottom: 4px; }
.co-value-desc { font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.6; }

/* COMPANY-AND-CALENDAR-SPEC-2026-08-12 §1.4/§1B — percentages-only health
   tiles + Year in Review tiles. Reuses the .co-value-card visual language
   (same surface/border/radius) rather than a new layout system; 2 columns
   at 375px per spec, auto-fit above. */
.co-stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
@media (min-width: 480px) { .co-stat-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } }
.co-stat-card { background: var(--s2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 14px; text-align: left; }
.co-stat-value { font-size: var(--fs-3xl); font-weight: 800; color: var(--text); line-height: 1.1; }
.co-stat-label { font-size: var(--fs-sm); font-weight: 600; color: var(--text-2); margin-top: 4px; }
.co-stat-sub { font-size: var(--fs-xs); color: var(--text-muted); margin-top: 2px; }

/* "What We're Working On" — projects list, mobile-first single column, no
   money anywhere in this block for any viewer. */
.co-proj-list { display: flex; flex-direction: column; gap: 8px; }
.co-proj-row { background: var(--s2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 12px 14px; }
.co-proj-name { font-size: var(--fs-md); font-weight: 700; color: var(--text); margin-bottom: 6px; overflow-wrap: break-word; }
.co-proj-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.co-proj-target { font-size: var(--fs-xs); color: var(--text-muted); }
.co-proj-more { font-size: var(--fs-xs); color: var(--text-muted); text-align: center; padding: 4px 0; }

/* Year in Review — growth line under Revenue/Projects finished tiles. */
.yr-growth-up { font-size: var(--fs-xs); color: var(--success); font-weight: 700; margin-top: 4px; }
.yr-growth-down { font-size: var(--fs-xs); color: var(--danger); font-weight: 700; margin-top: 4px; }
.yr-year-chips { margin-bottom: 12px; }

/* Doc cards (memos) */
.co-doc-card {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 14px; background: var(--s1); border: 1px solid var(--border);
  border-radius: var(--r-sm); margin-bottom: 10px; cursor: pointer;
  transition: background 0.15s;
}
.co-doc-card:hover { background: var(--s2); }
.co-doc-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.co-doc-body { flex: 1; min-width: 0; }
.co-doc-title { font-size: var(--fs-base); font-weight: 700; margin-bottom: 3px; }
.co-doc-meta { font-size: var(--fs-xs); color: var(--text-muted); margin-bottom: 5px; }
.co-doc-preview { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* Download rows */
.co-dl-row {
  display: flex; align-items: center; gap: 12px;
  padding: 12px 14px; background: var(--s1); border: 1px solid var(--border);
  border-radius: var(--r-sm); margin-bottom: 8px; text-decoration: none; color: var(--text);
  transition: background 0.15s;
}
.co-dl-row:hover { background: var(--s2); }
.co-dl-icon { width: 32px; height: 32px; border-radius: 7px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.co-dl-info { flex: 1; min-width: 0; }
.co-dl-name { font-size: var(--fs-md); font-weight: 600; }
.co-dl-desc { font-size: var(--fs-xs); color: var(--text-muted); }

/* Handbook accordion */
.handbook-accordion { display: flex; flex-direction: column; gap: 8px; }
.handbook-item { background: var(--s1); border: 1px solid var(--border); border-radius: var(--r-sm); overflow: hidden; }
.handbook-header {
  width: 100%; display: flex; align-items: center; justify-content: space-between;
  padding: 14px 16px; background: none; border: none; color: var(--text);
  cursor: pointer; font-size: var(--fs-md); font-weight: 600; text-align: left;
  min-height: 44px;
}
.handbook-header:hover { background: var(--s2); }
.handbook-icon { width: 28px; height: 28px; background: rgba(255,214,10,0.08); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.handbook-chevron { transition: transform 0.2s; }
.handbook-body { padding: 0 16px 16px; }
.handbook-text { font-size: var(--fs-md); color: var(--text-2); line-height: 1.8; white-space: pre-wrap; font-family: var(--font); margin: 0; }

/* Badge variants */
.badge-gold { background: rgba(255,214,10,0.15); color: var(--gold); }

/* Light mode overrides */
html.light .co-hero { background: linear-gradient(135deg, #1D1D1F 0%, #111113 60%, #1c1a0e 100%); }
html.light .co-section { background: #fff; border-color: rgba(0,0,0,0.08); }
html.light .co-biz-card { background: #f7f7f9; border-color: rgba(0,0,0,0.07); }
html.light .co-value-card { background: #f7f7f9; border-color: rgba(0,0,0,0.07); }
html.light .co-stat-card { background: #f7f7f9; border-color: rgba(0,0,0,0.07); }
html.light .co-proj-row { background: #f7f7f9; border-color: rgba(0,0,0,0.07); }
html.light .co-doc-card { background: #fff; border-color: rgba(0,0,0,0.08); }
html.light .co-doc-card:hover { background: #f7f7f9; }
html.light .co-dl-row { background: #fff; border-color: rgba(0,0,0,0.08); }
html.light .co-dl-row:hover { background: #f7f7f9; }
html.light .handbook-item { background: #fff; border-color: rgba(0,0,0,0.08); }
html.light .handbook-header:hover { background: #f7f7f9; }
html.light .co-body { color: rgba(29,29,31,0.75); }
html.light .co-biz-desc { color: rgba(29,29,31,0.55); }
html.light .co-president-msg p { color: rgba(29,29,31,0.75); }
html.light .co-doc-preview { color: rgba(29,29,31,0.50); }
html.light .handbook-text { color: rgba(29,29,31,0.75); }

@media (max-width: 480px) {
  .co-president-card { flex-direction: column; align-items: center; }
  .co-president-left { width: auto; }
  .co-values-grid { grid-template-columns: 1fr; }
  .co-hero-title { font-size: var(--fs-xl); }
}

/* System credit card */
.co-credit-card {
  display: flex; align-items: flex-start; gap: 14px;
  padding: 14px 16px;
  background: rgba(10,132,255,0.06);
  border: 1px solid rgba(10,132,255,0.15);
  border-radius: 14px;
}
.co-credit-icon {
  width: 38px; height: 38px; flex-shrink: 0;
  border-radius: 10px;
  background: rgba(10,132,255,0.12);
  display: flex; align-items: center; justify-content: center;
}
.co-credit-body { flex: 1; }
.co-credit-title { font-size: var(--fs-base); font-weight: 700; color: var(--text-1); margin-bottom: 2px; }
.co-credit-sub   { font-size: var(--fs-sm); color: var(--primary-light); margin-bottom: 4px; }
.co-credit-note  { font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.5; }

/* ── Help / Guide Page ── */
.help-guide { max-width: 700px; }
.help-hero {
  display: flex; align-items: center; gap: 16px;
  padding: 20px; background: var(--s1); border-radius: var(--r);
  border: 1px solid var(--border); margin-bottom: 20px;
}
.help-hero-icon {
  width: 56px; height: 56px; border-radius: 14px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
}
.help-hero h2 { font-size: var(--fs-xl); font-weight: 800; margin-bottom: 4px; }
.help-hero p  { font-size: var(--fs-md); color: var(--text-muted); }
.help-section {
  background: var(--s1); border: 1px solid var(--border);
  border-radius: var(--r); padding: 18px 20px; margin-bottom: 14px;
}
.help-section h3 {
  font-size: var(--fs-base); font-weight: 700; color: var(--text);
  display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
}
.help-h-icon { width: 16px; height: 16px; stroke: var(--text-muted); flex-shrink: 0; }
.help-steps {
  list-style: decimal; padding-left: 20px;
  display: flex; flex-direction: column; gap: 8px;
}
.help-steps li { font-size: var(--fs-md); color: var(--text-2); line-height: 1.6; }
.help-list {
  list-style: none; padding: 0;
  display: flex; flex-direction: column; gap: 8px;
}
.help-list li {
  font-size: var(--fs-md); color: var(--text-2); line-height: 1.6;
  padding-left: 16px; position: relative;
}
.help-list li::before {
  content: '›'; position: absolute; left: 0;
  color: var(--text-muted); font-weight: 700;
}
.help-tip {
  margin-top: 14px; padding: 10px 14px; border-radius: var(--r-sm);
  background: rgba(255,214,10,0.07); border: 1px solid rgba(255,214,10,0.18);
  font-size: var(--fs-sm); color: var(--text-2); line-height: 1.6;
}
.help-section p { font-size: var(--fs-md); color: var(--text-2); line-height: 1.7; }
.help-section code { font-family: var(--font-mono); font-size: var(--fs-sm); background: var(--s2); padding: 1px 5px; border-radius: 4px; }

/* ── Login Role Picker — segmented 3-portal control (v12 WS42 Phase 25) ── */
.login-pick-label {
  font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.8px;
  text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px;
}
.login-role-cards {
  display: flex; gap: 6px; margin-bottom: 14px;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--r); padding: 4px;
}
.login-role-card {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  flex: 1 1 0; min-width: 0;
  padding: 12px 6px 10px;
  background: transparent; border: none;
  border-radius: var(--r-sm); cursor: pointer; text-align: center;
  transition: background var(--t2), transform var(--t1);
}
@media (hover:hover) { .login-role-card:hover { background: var(--surface); } }
.login-role-card:active { transform: scale(0.97); }
.lrc-icon {
  width: 36px; height: 36px; border-radius: var(--r-sm); flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--primary-soft);
}
.lrc-icon svg { width: 18px; height: 18px; stroke: var(--primary); }
.lrc-label {
  font-size: var(--fs-xs); font-weight: 700; color: var(--text);
  display: block; line-height: 1.2; white-space: nowrap;
}
/* Sign Up / Apply — a quiet text link below the segmented control, not a card */
.login-signup-link {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  width: 100%; background: none; border: none; cursor: pointer;
  padding: 10px; font-size: var(--fs-sm); font-weight: 600;
  color: var(--success); border-radius: var(--r-sm);
  transition: background var(--t1);
}
.login-signup-link:hover { background: var(--success-soft); }
.login-signup-link svg { width: 16px; height: 16px; stroke: currentColor; }

/* ── Login Form Header (back + pill) ── */
.login-form-header {
  display: flex; align-items: center; gap: 10px; margin-bottom: 18px;
}
.login-back-btn {
  display: flex; align-items: center; justify-content: center;
  width: 36px; height: 36px; flex-shrink: 0;
  background: var(--s1); border: 1px solid var(--border);
  border-radius: 50%; cursor: pointer; color: var(--text-muted);
  transition: background var(--t1), color var(--t1);
}
.login-back-btn:hover { background: var(--s2); color: var(--text); }
.login-back-btn svg { width: 16px; height: 16px; stroke: currentColor; }
.login-type-pill {
  flex: 1; padding: 5px 14px; border-radius: var(--pill);
  background: var(--s2); border: 1px solid var(--border);
  font-size: var(--fs-sm); font-weight: 700; color: var(--text-2);
  letter-spacing: 0.3px; text-align: center;
}
@keyframes slideInFromLeft {
  from { opacity: 0; transform: translateX(-16px); }
  to   { opacity: 1; transform: translateX(0); }
}
.login-form-slide-in {
  animation: slideInFromLeft 0.25s var(--ease) both;
}

/* ══════════════════════════════
   APP SHELL
══════════════════════════════ */
#app-shell { flex-direction: column; }

/* ── Topbar ── (merged into single rule at the LIQUID GLASS copy, see below) */
.menu-toggle {
  /* Owner req — seamless top-bar hamburger/back (no box border/fill at rest),
     and LARGER + more visible: bumped from var(--fs-xl) and full-strength text
     colour so it reads clearly beside the wordmark on every page. */
  background: transparent; border: none;
  border-radius: var(--r-xs); color: var(--text);
  font-size: 1.6rem; line-height: 1; cursor: pointer; padding: 4px 8px;
  display: none; transition: background var(--t1), color var(--t1);
}
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
@media (hover:hover) { .menu-toggle:hover { background: var(--s2); color: var(--text); } }

/* ── Desktop Back / Forward (owner request 2026-08-09) ──
   Browser-chrome semantics in the app's own chrome, for the installed PWA and
   for anyone who navigates from the sidebar rather than the address bar. Both
   buttons drive the real History API (navBack/navForward in js/app.js), so
   they stay in lockstep with the browser's own controls and with the mobile
   chevron. Hidden under 768px, where #topbar itself is display:none. */
.nav-hist { display: flex; align-items: center; gap: 2px; margin-right: 4px; }
.nav-hist-btn {
  background: transparent; border: none; border-radius: var(--r-xs);
  color: var(--text); font-size: 1.6rem; line-height: 1; cursor: pointer;
  padding: 4px 10px; transition: background var(--t1), color var(--t1), opacity var(--t1);
}
.nav-hist-btn[disabled] { opacity: .32; cursor: default; pointer-events: none; }
@media (hover:hover) { .nav-hist-btn:not([disabled]):hover { background: var(--s2); } }
.nav-hist-btn:not([disabled]):active { background: var(--s3, var(--s2)); }
@media (max-width: 768px) { .nav-hist { display: none; } }

.topbar-logo { display: flex; align-items: center; gap: 10px; flex: 1; }

/* ID-card-style logo pill */
.topbar-logo-card {
  position: relative; overflow: hidden;
  width: 34px; height: 34px; flex-shrink: 0;
  background: linear-gradient(140deg, #0A0A1A 0%, #131330 60%, #1A0A2E 100%);
  border: 1px solid rgba(255,214,10,0.35);
  border-radius: 9px;
  box-shadow: 0 0 10px rgba(255,214,10,0.18), inset 0 1px 0 rgba(255,255,255,0.06);
  display: flex; align-items: center; justify-content: center;
  padding: 3px;
}
/* static top gold trim line */
.topbar-logo-card::after {
  content: '';
  position: absolute; top: 0; left: 12%; right: 12%; height: 1.5px;
  background: var(--grad-gold);
  border-radius: var(--pill); opacity: 0.80;
}
/* static inner sheen */
.topbar-logo-card::before {
  content: '';
  position: absolute; inset: 0;
  background: linear-gradient(135deg, rgba(255,255,255,0.05) 0%, transparent 60%);
  border-radius: inherit; pointer-events: none;
}
.topbar-logo-img { width: 100%; height: 100%; object-fit: contain; border-radius: 6px; }
.topbar-logo-fallback {
  width: 100%; height: 100%; border-radius: 6px;
  background: var(--grad-pk-bl);
  color: #fff; font-size: var(--fs-xs); font-weight: 800;
  display: flex; align-items: center; justify-content: center;
}

.topbar-brand-sub {
  font-size: 0.5rem; font-weight: 600; letter-spacing: 2px;
  color: rgba(255,255,255,0.38); line-height: 1;
  text-transform: uppercase;
}
.topbar-right { display: flex; align-items: center; gap: 6px; }

/* ── Top Nav Strip (mobile nav below topbar) ──
   v14 CSS audit note: .top-nav-strip is a base rule here and again at
   ~line 5096 and ~line 5697; .top-nav-item likewise here (~906), ~5107, and
   ~5704 — each later block adds/overrides a handful of properties without
   fighting the earlier ones (additive refinement, not dead duplication —
   verified manually for this family). Flagged as a follow-up hygiene item:
   a scripted duplicate-selector scan (`grep -oE '^\.[a-zA-Z0-9_-]+' css/styles.css | sort | uniq -c`)
   would confirm whether other 3x-declared selectors elsewhere are similarly
   additive or actually dead cruft. */
.top-nav-strip {
  display: none; /* shown on mobile only */
  position: fixed;
  top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px));
  left: 0; right: 0;
  height: var(--top-nav-h);
  background: var(--topbar-bg);
  border-bottom: 1px solid var(--border);
  z-index: var(--z-shell-2);
  overflow-x: auto; overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  align-items: center;
  padding: 0 max(6px, env(safe-area-inset-right, 0px)) 0 max(6px, env(safe-area-inset-left, 0px));
  gap: 2px;
  box-sizing: border-box;
}
.top-nav-strip::-webkit-scrollbar { display: none; }
/* v12 WS42 nav-consolidation — #top-nav-strip holds three zones: #tn-lead
   (menu/back, mobile only), #tn-tabs (the scrollable page tabs — the only
   thing buildTopNavStrip() writes into), and #tn-actions (search/notif/menu/
   avatar, mobile only). On desktop tn-lead/tn-actions stay empty (their
   controls live in #topbar) so this is a no-op there. */
#tn-lead, #tn-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
#tn-lead:empty, #tn-actions:empty { display: none; }
#tn-tabs {
  display: flex; align-items: center; gap: 2px;
  flex: 1 1 auto; min-width: 0;
  overflow-x: auto; overflow-y: hidden;
  -webkit-overflow-scrolling: touch; scrollbar-width: none;
}
#tn-tabs::-webkit-scrollbar { display: none; }
.top-nav-item {
  display: flex; flex-direction: column; align-items: center;
  gap: 2px; padding: 4px 10px;
  border-radius: var(--r-sm);
  color: var(--text-muted); cursor: pointer;
  background: none; border: none;
  font-size: 0.5625rem; font-weight: 600; white-space: nowrap;
  flex-shrink: 0;
  transition: color var(--t2), background var(--t1);
  -webkit-tap-highlight-color: transparent;
  position: relative;
}
.top-nav-item:active { background: var(--s2); }
.top-nav-item .bn-icon { font-size: var(--fs-xl); line-height: 1; transition: transform var(--t2); }
.top-nav-item .bn-icon svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; display: block; }
.top-nav-item .tn-label { font-size: var(--fs-xs); font-weight: 600; }
/* v14 CSS/a11y audit: dead duplicate .top-nav-item.active (pink, unthemed)
   removed here — fully overridden by the later token-driven block further
   down (var(--primary), with its own Astral-only pink variant), same
   specificity, later in source. Never rendered; see that block for the live
   active-state rule. */
.top-nav-item .bn-badge,
.top-nav-item .tn-badge {
  position: absolute; top: -2px; right: -4px;
  /* v14 a11y fix: --pink-badge-bg (not --pink) — white text on plain --pink
     at this font-size computes to ~3.5:1, under the 4.5:1 AA floor. */
  background: var(--pink-badge-bg); color: #fff; font-size: 0.5625rem;
  min-width: 14px; height: 14px; display: none;
  align-items: center; justify-content: center;
  padding: 0 3px; border-radius: var(--pill); font-weight: 700;
  box-shadow: 0 0 8px var(--pink-glow); line-height: 1;
}

.notif-btn {
  /* Owner req — seamless top-bar icons: no box border/fill at rest, hover
     still gives a subtle highlight for affordance. */
  position: relative; background: transparent;
  border: none; border-radius: var(--r-xs);
  padding: 7px 10px; cursor: pointer;
  font-size: 1.0625rem; color: var(--text-muted);
  transition: background var(--t1), color var(--t1), box-shadow var(--t2);
}
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
@media (hover:hover) { .notif-btn:hover { background: var(--s2); color: var(--text); box-shadow: var(--sh-pink); } }
.notif-badge {
  position: absolute; top: 1px; right: 1px;
  /* v14 a11y fix: --grad-pk-bl-badge (darkened pink→blue) not --grad-pk-bl —
     white text over the plain gradient's pink end computes to ~3.5:1 (blue
     end ~3.6:1), both under the 4.5:1 AA floor at this font-size. */
  background: var(--grad-pk-bl-badge);
  color: #fff; font-size: 0.5625rem; font-weight: 700;
  border-radius: var(--pill); padding: 1px 5px;
  min-width: 16px; text-align: center;
}
.topbar-avatar {
  width: 32px; height: 32px;
  background: var(--grad-pk-bl);
  color: #fff; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-sm); font-weight: 700; cursor: pointer;
  border: 1.5px solid rgba(255,255,255,0.18);
  transition: transform var(--t1), box-shadow var(--t2);
  overflow: hidden;
}
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
/* This one was the worst of the set: :hover GROWS the avatar to 1.08 on the
   same property the new :active uses to shrink it, so on the phone tapping
   your own avatar left it permanently enlarged. */
@media (hover:hover) { .topbar-avatar:hover { transform: scale(1.08); box-shadow: var(--sh-pink); } }

/* v12 WS41 topbar-depts-btn/topbar-chat-btn mobile-hide rule retired WS42
   nav-consolidation — both buttons were removed from index.html (Chat lives
   in the center top-nav-strip tab; Departments reachable via the sidebar). */
#depts-panel .depts-item {
  display:flex; align-items:center; gap:10px; width:100%;
  padding:11px 14px; background:none; border:none; cursor:pointer;
  color:var(--text); font-size:var(--fs-base); text-align:left;
  border-bottom:1px solid var(--border);
}
#depts-panel .depts-item:hover { background: var(--s2); }
#depts-panel .depts-item:last-child { border-bottom:none; }

/* ── Notification Panel ── */
.notif-panel {
  position: fixed; top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) + 8px);
  right: max(12px, env(safe-area-inset-right, 0px));
  width: 320px; max-height: 440px;
  background: var(--notif-bg);
  border-radius: var(--r-lg);
  border: 1px solid var(--border);
  z-index: var(--z-panel); display: flex; flex-direction: column; overflow: hidden; max-width: calc(100vw - 24px);
  animation: dropDown 0.22s var(--spring);
}
.notif-panel-header {
  padding: 13px 16px; border-bottom: 1px solid var(--border);
  display: flex; justify-content: space-between; align-items: center;
  font-weight: 700; font-size: var(--fs-base); color: var(--text);
  background: rgba(255,255,255,0.02);
}
.notif-list { overflow-y: auto; overflow-x: hidden; flex: 1; -webkit-overflow-scrolling: touch; }
.notif-backdrop { position: fixed; inset: 0; z-index: var(--z-panel-scrim); }
.notif-item {
  padding: 11px 14px; border-bottom: 1px solid var(--border);
  /* var(--ease) named explicitly — same gap as .item-card: without it this ran
     the UA default `ease` while the rest of the press system ran --ease, and the
     notification inbox is one of the highest-traffic tap surfaces in the app. */
  cursor: default; transition: background var(--t1) var(--ease);
  position: relative;
  display: flex; align-items: center; gap: 10px;
}
.notif-item:hover { background: var(--s1); }
.notif-item.unread { background: rgba(255,59,48,0.06); }
.notif-item.unread::before {
  content: '';
  position: absolute; left: 5px; top: 50%; transform: translateY(-50%);
  width: 5px; height: 5px; border-radius: 50%;
  background: #FF3B30; box-shadow: 0 0 6px rgba(255,59,48,0.50);
}
.notif-item.read { background: transparent; }
.notif-item-emoji {
  font-size: var(--fs-2xl); flex-shrink: 0; line-height: 1;
  width: 32px; display: flex; align-items: center; justify-content: center;
}
.notif-item-text  { flex: 1; min-width: 0; }
.notif-item-title { font-size: var(--fs-md); font-weight: 600; margin-bottom: 2px; color: var(--text); }
.notif-item.read .notif-item-title { color: var(--text-2); font-weight: 500; }
.notif-item-body  { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.notif-item-time  { font-size: var(--fs-2xs); color: var(--text-light); margin-top: 3px; }
.notif-item-actions {
  display: flex; align-items: center; gap: 4px; flex-shrink: 0;
}
.notif-action-btn {
  width: 30px; height: 30px; border-radius: 50%;
  border: none; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: background var(--t1), transform 80ms;
  flex-shrink: 0;
  -webkit-tap-highlight-color: transparent;
}
.notif-action-btn:active { transform: scale(0.88); }
.notif-action-btn svg {
  display: block;
  flex-shrink: 0;
  pointer-events: none;
}
.notif-read-btn {
  background: rgba(48,209,88,0.13);
  color: #30D158;
  border: 1px solid rgba(48,209,88,0.22);
}
.notif-read-btn:hover { background: rgba(48,209,88,0.24); }
.notif-read-btn svg { stroke: #30D158; }

.notif-view-btn {
  background: rgba(10,132,255,0.12);
  color: #0A84FF;
  border: 1px solid rgba(10,132,255,0.20);
}
.notif-view-btn:hover { background: rgba(10,132,255,0.22); }
.notif-view-btn svg { stroke: #0A84FF; }
.notif-unread-btn {
  background: rgba(255,159,10,0.12);
  color: #FF9F0A;
  border: 1px solid rgba(255,159,10,0.22);
}
.notif-unread-btn:hover { background: rgba(255,159,10,0.22); }
.notif-unread-btn svg { stroke: #FF9F0A; }

/* ══════════════════════════════
   SIDEBAR
══════════════════════════════ */
#sidebar {
  width: var(--sidebar-w);
  background: var(--sidebar-bg);
  position: fixed; top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px)); bottom: 0; left: 0;
  padding-left: env(safe-area-inset-left, 0px);
  z-index: var(--z-shell); display: flex; flex-direction: column;
  overflow: hidden;
  /* v14.0.68: was var(--t3) (340ms). The drawer/sidebar is the ONE surface in
     the app where the travel IS the affordance -- it has a scrim and it is
     swipe-dismissible, so the user has to see where it came from and which
     edge to throw it back at. It therefore keeps a real slide while windows
     do not. But 340ms across a full-height panel was the longest interactive
     motion left in the app; 240ms is about the floor at which a full-height
     slide still reads as a slide rather than a jump. Written as a literal
     instead of by editing --t3 in css/tokens.css deliberately: exactly two
     rules consume --t3 (this one and .drawer below) and both are handled
     here, so the token's future consumers are not pre-committed -- and
     tokens.css is not this pass's file to change. */
  transition: transform 240ms var(--ease);
}

/* vertical gradient accent on left edge */
#sidebar::before {
  content: '';
  position: absolute; top: 0; left: 0; bottom: 0; width: 2px;
  background: linear-gradient(180deg, var(--pink), var(--blue), var(--gold));
  opacity: 0.4;
}

.sidebar-top {
  padding: 18px 14px 14px;
  border-bottom: 1px solid rgba(255,255,255,0.06);
  position: relative;
}
.sidebar-user { display: flex; align-items: center; gap: 11px; width: 100%; min-width: 0; }
.sidebar-user-info { flex: 1; min-width: 0; }
.sidebar-user-name-row { display: flex; align-items: center; gap: 6px; justify-content: space-between; }
.sidebar-avatar {
  width: 44px; height: 44px; border-radius: 50%;
  background: var(--grad-pk-bl); color: #fff;
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-lg); font-weight: 700; flex-shrink: 0; overflow: hidden;
  box-shadow: 0 0 0 2px rgba(255,45,120,0.30), var(--sh-pink);
}
.sidebar-avatar img { width: 100%; height: 100%; object-fit: cover; }
.sidebar-user-name {
  font-size: var(--fs-md); font-weight: 700;
  color: var(--text); line-height: 1.2; letter-spacing: -0.1px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  min-width: 0; flex: 1;
}
.sidebar-user-role {
  font-size: var(--fs-2xs); color: var(--text-muted);
  text-transform: uppercase; letter-spacing: 0.7px; margin-top: 2px;
}
.sidebar-user-dept {
  font-size: var(--fs-xs); font-weight: 600;
  background: var(--grad-gold);
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
  background-clip: text; margin-top: 1px;
}

.sidebar-nav { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; padding: 8px 0 calc(env(safe-area-inset-bottom, 0px) + 20px) 2px; scrollbar-width: none; }
.sidebar-nav::-webkit-scrollbar { display: none; }

.nav-section-label {
  font-size: var(--fs-2xs); font-weight: 700; color: var(--text-light);
  text-transform: uppercase; letter-spacing: 1.2px;
  padding: 12px 16px 4px;
}

.nav-item {
  display: flex; align-items: center; gap: 11px;
  padding: 9px 14px 9px 14px;
  color: var(--text-muted); font-size: var(--fs-md); font-weight: 500;
  cursor: pointer; border: none; background: none;
  width: 100%; text-align: left;
  transition: color var(--t1), background var(--t1);
  position: relative; border-radius: 0;
  margin: 1px 0;
}
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
@media (hover:hover) {
  .nav-item:hover { background: var(--s1); color: var(--text); }
  .nav-item:hover .nav-icon { animation: iconPop 0.3s var(--spring); }
}
/* Wave5 M1 — sidebar (desktop) nav badge. Bottom-nav already has a working
   .bottom-nav-item .bn-badge rule (positioned inside .bn-icon-wrap); the
   sidebar had no badge slot at all, so this is new. Reuses the SAME .bn-badge
   class chat.js creates on demand (see Chat._paintChatNavBadge) — kept clear
   of .nav-item.active::after's right:0 indicator bar via right:10px. */
.nav-item .bn-badge {
  position: absolute; top: 50%; right: 10px; transform: translateY(-50%);
  /* v14 a11y fix: --pink-badge-bg (not --pink) — see notif-badge above for
     the contrast rationale. */
  background: var(--pink-badge-bg); color: #fff; font-size: 0.625rem;
  min-width: 16px; height: 16px; padding: 0 4px;
  display: flex; align-items: center; justify-content: center;
  border-radius: var(--pill); font-weight: 700; line-height: 1;
  box-shadow: 0 0 8px var(--pink-glow);
}

/* v14 CSS/a11y audit: dead duplicate .nav-item.active (+ its ::after right-
   edge bar) removed here — fully overridden by the later token-driven pill
   block further down (::after gets display:none there, .active gets
   var(--primary-soft)/var(--primary) instead of var(--s2)/var(--grad-pk-bl)),
   same specificity, later in source. Never rendered; see that block. */

/* ── iOS-style 3D nav icons ── */
.nav-icon {
  width: 30px; height: 30px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 8px; font-size: 0.9375rem; flex-shrink: 0;
  background: rgba(255,255,255,0.07);
  box-shadow: 0 1px 4px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.08);
  transition: background var(--t2), box-shadow var(--t2);
}
/* Per-page gradient icon colors */
.nav-item[data-page="dashboard"] .nav-icon  { background: linear-gradient(135deg,#FF2D78,#FF6B6B); box-shadow: 0 2px 8px rgba(255,45,120,0.35); }
.nav-item[data-page="tasks"] .nav-icon      { background: linear-gradient(135deg,#0A84FF,#40CFFF); box-shadow: 0 2px 8px rgba(10,132,255,0.35); }
.nav-item[data-page="approvals"] .nav-icon  { background: linear-gradient(135deg,#FFD60A,#FF9F0A); box-shadow: 0 2px 8px rgba(255,214,10,0.35); }
.nav-item[data-page="progress"] .nav-icon   { background: linear-gradient(135deg,#30D158,#00D4AA); box-shadow: 0 2px 8px rgba(48,209,88,0.30); }
.nav-item[data-page="departments"] .nav-icon{ background: linear-gradient(135deg,#8B5CF6,#0A84FF); box-shadow: 0 2px 8px rgba(139,92,246,0.35); }
/* ── Icons that were rendering as a flat grey tile ────────────────────────
   Owner, 2026-08-10: "some icons dont have colors at the app drawer". Every
   .nav-item gets its colour from a per-page rule here; a page with no rule
   falls through to the base .nav-icon background (rgba(255,255,255,0.07)) and
   reads as unfinished next to its neighbours. Six were missing — the four
   added in the last two days (calendar, memos, CRM, Ventures) plus HR and
   System Health, which never had one.

   The dept:* entries take their DEPARTMENT's own colour from window.DEPARTMENTS
   (Finance #2F9E44, HR #E64980, CRM #F59F00, Ventures #5F3DC4) so the sidebar
   tile matches the tile that same department shows on the Departments grid.
   The {deptLoop} block builds its icons inline from that gradient; these
   static entries cannot, which is exactly why they were missed. */
.nav-item[data-page="calendar"] .nav-icon      { background: linear-gradient(135deg,#5E5CE6,#7048E8); box-shadow: 0 2px 8px rgba(94,92,230,0.35); }
.nav-item[data-page="memos"] .nav-icon         { background: linear-gradient(135deg,#0A84FF,#5E5CE6); box-shadow: 0 2px 8px rgba(10,132,255,0.32); }
.nav-item[data-page="system-health"] .nav-icon { background: linear-gradient(135deg,#8E8E93,#48484A); box-shadow: 0 2px 8px rgba(142,142,147,0.28); }
.nav-item[data-page="team"] .nav-icon       { background: linear-gradient(135deg,#0A84FF,#5856D6); box-shadow: 0 2px 8px rgba(10,132,255,0.30); }
.nav-item[data-page="analytics"] .nav-icon  { background: linear-gradient(135deg,#FF9F0A,#FFD60A); box-shadow: 0 2px 8px rgba(255,159,10,0.35); }
.nav-item[data-page="company"] .nav-icon    { background: linear-gradient(135deg,#FF2D78,#8B5CF6); box-shadow: 0 2px 8px rgba(139,92,246,0.30); }
.nav-item[data-page="submissions"] .nav-icon{ background: linear-gradient(135deg,#32ADE6,#0A84FF); box-shadow: 0 2px 8px rgba(50,173,230,0.30); }
.nav-item[data-page="cash"] .nav-icon       { background: linear-gradient(135deg,#30D158,#34C759); box-shadow: 0 2px 8px rgba(48,209,88,0.30); }
.nav-item[data-page="personal-finance"] .nav-icon { background: linear-gradient(135deg,#FFD60A,#FF9F0A); box-shadow: 0 2px 8px rgba(255,214,10,0.30); }
.nav-item[data-page="files"] .nav-icon      { background: linear-gradient(135deg,#32ADE6,#5856D6); box-shadow: 0 2px 8px rgba(50,173,230,0.25); }
/* v14 hotfix (owner screenshot): chat + files-hub had NO gradient rule — the
   rgba fallback tile + hardcoded white stroke was invisible on light theme. */
.nav-item[data-page="chat"] .nav-icon       { background: linear-gradient(135deg,#0A84FF,#8B5CF6); box-shadow: 0 2px 8px rgba(10,132,255,0.32); }
.nav-item[data-page="files-hub"] .nav-icon  { background: linear-gradient(135deg,#32ADE6,#5856D6); box-shadow: 0 2px 8px rgba(50,173,230,0.25); }
.nav-item[data-page="bs-quote-builder"] .nav-icon { background: linear-gradient(135deg,#0A84FF,#32ADE6); box-shadow: 0 2px 8px rgba(10,132,255,0.35); }
.nav-item[data-page="bs-quotations"] .nav-icon    { background: linear-gradient(135deg,#32ADE6,#5856D6); box-shadow: 0 2px 8px rgba(88,86,214,0.30); }
.nav-item[data-page="bs-clients"] .nav-icon       { background: linear-gradient(135deg,#30D158,#0A84FF); box-shadow: 0 2px 8px rgba(48,209,88,0.30); }
.nav-item[data-page="bs-files"] .nav-icon         { background: linear-gradient(135deg,#5856D6,#8B5CF6); box-shadow: 0 2px 8px rgba(88,86,214,0.30); }
/* New module pages */
.nav-item[data-page="posts"] .nav-icon            { background: linear-gradient(135deg,#FF2D78,#FF6B6B); box-shadow: 0 2px 8px rgba(255,45,120,0.35); }
.nav-item[data-page="team-directory"] .nav-icon   { background: linear-gradient(135deg,#0A84FF,#5856D6); box-shadow: 0 2px 8px rgba(10,132,255,0.30); }
.nav-item[data-page="attendance"] .nav-icon       { background: linear-gradient(135deg,#30D158,#00D4AA); box-shadow: 0 2px 8px rgba(48,209,88,0.30); }
.nav-item[data-page="cash-advances"] .nav-icon    { background: linear-gradient(135deg,#FFD60A,#30D158); box-shadow: 0 2px 8px rgba(255,214,10,0.35); }
.nav-item[data-page="help"] .nav-icon             { background: linear-gradient(135deg,#8B5CF6,#5856D6); box-shadow: 0 2px 8px rgba(139,92,246,0.30); }
/* Operations / catalog / security pages */
.nav-item[data-page="inventory"] .nav-icon         { background: linear-gradient(135deg,#00D4AA,#0A84FF); box-shadow: 0 2px 8px rgba(0,212,170,0.32); }
.nav-item[data-page="projects-lifecycle"] .nav-icon{ background: linear-gradient(135deg,#5856D6,#32ADE6); box-shadow: 0 2px 8px rgba(88,86,214,0.32); }
.nav-item[data-page="sales-orders"] .nav-icon      { background: linear-gradient(135deg,#FF9F0A,#FF2D78); box-shadow: 0 2px 8px rgba(255,159,10,0.32); }
.nav-item[data-page="personal-todo"] .nav-icon     { background: linear-gradient(135deg,#12B886,#0CA678); box-shadow: 0 2px 8px rgba(18,184,134,0.32); }
.nav-item[data-page="product-database"] .nav-icon  { background: linear-gradient(135deg,#30D158,#32ADE6); box-shadow: 0 2px 8px rgba(48,209,88,0.30); }
.nav-item[data-page="audit-log"] .nav-icon         { background: linear-gradient(135deg,#8E8E93,#636366); box-shadow: 0 2px 8px rgba(99,99,102,0.32); }
/* 2026-08-12, owner again: "make it a note always to add a color to new icons
   on the drawer" — the SECOND time this has been reported (see the 2026-08-10
   note above). Four were grey this round: notes and my-profile (added today),
   plus partner-projects and bk-quote-builder, which had never had one.
   Teal and violet were picked because every other warm/blue/green slot in this
   block is already taken and a near-duplicate reads as a mistake. */
.nav-item[data-page="notes"] .nav-icon             { background: linear-gradient(135deg,#00C7BE,#40CFFF); box-shadow: 0 2px 8px rgba(0,199,190,0.32); }
.nav-item[data-page="my-profile"] .nav-icon        { background: linear-gradient(135deg,#AF52DE,#8B5CF6); box-shadow: 0 2px 8px rgba(175,82,222,0.32); }
.nav-item[data-page="partner-projects"] .nav-icon  { background: linear-gradient(135deg,#5856D6,#32ADE6); box-shadow: 0 2px 8px rgba(88,86,214,0.32); }
.nav-item[data-page="bk-quote-builder"] .nav-icon  { background: linear-gradient(135deg,#0A84FF,#32ADE6); box-shadow: 0 2px 8px rgba(10,132,255,0.35); }

/* Dept pages use data-page="dept:X" — give all a gradient background */
.nav-item[data-page^="dept:"] .nav-icon { background: linear-gradient(135deg,#FF9F0A,#FF6B6B); box-shadow: 0 2px 8px rgba(255,159,10,0.30); }
/* ⚠ MUST STAY BELOW THE GENERIC dept:* RULE ABOVE.
   Both are specificity 0,2,1, so this is decided purely by SOURCE ORDER — the
   per-department colours were first written above it and silently lost, which
   is why Finance, CRM and Ventures all rendered the same orange. Each takes its
   own department's colour from window.DEPARTMENTS so the sidebar tile matches
   that department's tile on the Departments grid. Any NEW dept:* entry that
   wants its real colour goes here, not above. */
.nav-item[data-page="dept:Finance"] .nav-icon  { background: linear-gradient(135deg,#2F9E44,#00D4AA); box-shadow: 0 2px 8px rgba(47,158,68,0.35); }
.nav-item[data-page="dept:HR"] .nav-icon       { background: linear-gradient(135deg,#E64980,#FF6B9D); box-shadow: 0 2px 8px rgba(230,73,128,0.32); }
.nav-item[data-page="dept:CRM"] .nav-icon      { background: linear-gradient(135deg,#F59F00,#FFB84D); box-shadow: 0 2px 8px rgba(245,159,0,0.32); }
.nav-item[data-page="dept:Ventures"] .nav-icon { background: linear-gradient(135deg,#5F3DC4,#9775FA); box-shadow: 0 2px 8px rgba(95,61,196,0.32); }
/* Emoji icons get same pill/gradient treatment */
.nav-icon.emoji-icon { font-size:var(--fs-lg); display:flex; align-items:center; justify-content:center; }

.nav-badge {
  margin-left: auto; background: var(--grad-red); color: #fff;
  font-size: var(--fs-2xs); padding: 1px 6px; border-radius: var(--pill); font-weight: 700;
}

.btn-logout-inline {
  background: rgba(255,69,58,0.08); border: 1px solid rgba(255,69,58,0.18);
  color: rgba(255,100,90,0.80); border-radius: 5px;
  padding: 3px 9px; height: 22px;
  display: inline-flex; align-items: center; gap: 5px;
  font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.2px;
  cursor: pointer; flex-shrink: 0; white-space: nowrap;
  transition: background var(--t1), box-shadow var(--t1);
}
.btn-logout-inline:hover { background: rgba(255,69,58,0.18); box-shadow: 0 0 6px rgba(255,69,58,0.20); }
.btn-logout-inline svg { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2.2; flex-shrink:0; }

.sidebar-overlay {
  position: fixed; inset: 0;
  background: rgba(0,0,0,0.55); z-index: var(--z-scrim);
}

/* ══════════════════════════════
   MAIN + BOTTOM NAV
══════════════════════════════ */
.main-content {
  margin-top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px)); margin-left: var(--sidebar-w);
  min-height: calc(100dvh - var(--topbar-h));
  padding: 20px;
  /* V13 Phase 141: cap content width on ultrawide monitors. margin-inline:auto
     would fight the sidebar's margin-left offset, so this intentionally hugs
     the sidebar side rather than centering — true centering needs an inner
     wrapper and is deferred (zero-risk variant chosen for this pass). */
  max-width: var(--content-max);
}
/* WS42 Phase 14: global horizontal-overflow guard — never rely on this alone,
   per-widget fixes are the real fix, but this stops any stray offender from
   pushing the whole page into horizontal scroll. */
#page-content { overflow-x: clip; }

/* WS42 Phase 26 — standardized page-enter. Pure CSS, no JS hook needed: each
   navigateTo() call replaces #page-content's innerHTML, so the direct
   children are always freshly-inserted nodes and the animation plays once
   per navigation automatically (nothing to re-trigger). transform/opacity
   only, cheap. The global prefers-reduced-motion kill switch further down
   collapses this to ~0ms for reduced-motion users. */
#page-content > * { animation: pageEnter 160ms var(--ease, ease-out) both; }
@keyframes pageEnter {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}
/* v14.0.68 smoothness pass — ONE entrance per navigation, not two.
   navigateTo() paints window.skeletonHtml(...) into #page-content first and
   swaps the real markup in when the read lands (js/app.js ~2247). Both are
   freshly-inserted DIRECT children, so the rule above fires the 160ms
   pageEnter TWICE for a single tap: the skeleton fades up, then a few hundred
   ms later the real content fades up again. Two entrances read as two page
   loads -- exactly the "why did that load twice" texture being reported as
   slowness. A skeleton is a placeholder, not content: it should simply BE
   there in the frame the tap produces. The real content keeps its entrance,
   which is now the only one the user ever sees.
   `.skl-wrap` is the wrapper every skeletonHtml() variant emits
   (js/ui-states.js ~55/60/65). The shimmer INSIDE it (.skl-text/.skl-row/
   .skl-card statePulse, ~line 2696) is untouched -- that is the loading cue
   and it still plays. */
#page-content > .skl-wrap { animation: none; }

/* WS42 Phase 27 — content-visibility on heavy repeating list rows, so the
   browser can skip layout/paint work for off-screen rows in long lists
   (task feed, chat inbox, team grid, ledger/data tables). Each
   contain-intrinsic-size approximates that row's real rendered height (from
   its own padding/line-height above) to avoid scrollbar/layout jump before
   the row has ever been measured — verify against real data in a live
   preview per the WS42 acceptance gate. */
.task-feed-item { content-visibility: auto; contain-intrinsic-size: auto 48px; }
.chat-inbox-row { content-visibility: auto; contain-intrinsic-size: auto 64px; }
.team-masonry > .team-member-card { content-visibility: auto; contain-intrinsic-size: auto 150px; }
.data-table tbody tr { content-visibility: auto; contain-intrinsic-size: auto 42px; }

/* Lock scroll on body when sidebar is open on mobile */
body.sidebar-open { overflow: hidden; }
body.sidebar-open .main-content { pointer-events: none; user-select: none; }
/* v14 owner report ("looks bad" — drawer had NO scrim, so the page behind
   stayed fully lit and read as two side-by-side panels). A real dimming
   backdrop sits between the content and the open drawer (z just under
   --z-shell-open 96) so the drawer clearly floats over a receded page. */
body.sidebar-open::after {
  content: ''; position: fixed; inset: 0;
  background: rgba(0, 0, 0, 0.48);
  z-index: 95;
  pointer-events: auto;
  animation: scrimFade 0.22s ease both;
}
@keyframes scrimFade { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { body.sidebar-open::after { animation: none; } }
/* Hide top nav strip when sidebar is open — sidebar is the nav */
body.sidebar-open .top-nav-strip { display: none !important; }
/* Sidebar covers only the left column — hide the bottom nav so it doesn't peek
   on the right while the sidebar is open. */
body.sidebar-open .bottom-nav { display: none !important; }

/* .bottom-nav base rule three-way merged into iOS-layer copy below (styles.css ~4050) */
/* Shrunk state — triggered by JS scroll listener */
.bottom-nav.nav-shrunk {
  height: calc(var(--bottom-nav-h) - 14px);
  opacity: 0.72;
  box-shadow: 0 4px 16px rgba(0,0,0,0.35), 0 1px 4px rgba(0,0,0,0.20);
}
.bottom-nav-item {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: 0 12px;
  color: var(--text-muted); cursor: pointer;
  background: none; border: none;
  transition: color var(--t2), transform var(--t1);
  flex: 1; min-width: 0; position: relative;
  height: 100%; box-sizing: border-box;
}
.bottom-nav-item .bn-icon {
  font-size: 1.375rem; line-height: 1;
  transition: transform var(--t2), font-size 0.25s var(--ease);
}
/* Shrunk state — smaller icons */
.bottom-nav.nav-shrunk .bottom-nav-item .bn-icon { font-size: var(--fs-xl); }
.bottom-nav.nav-shrunk .bottom-nav-item .bn-icon svg { width: 18px; height: 18px; }
/* Labels hidden — pill nav is icon-only */
.bottom-nav-item .bn-label { display: none; }
.bottom-nav-item.active { color: var(--primary); }
/* Color-only: Astral's active tab uses its brand-pink accent instead of
   --primary. The extra icon scale + glow drop-shadow (motion/glow, not
   color) were removed — v14 de-structuring pass. */
html.theme-astral .bottom-nav-item.active { color: var(--brand-primary); }
.bottom-nav-item .bn-badge {
  position: absolute; top: 4px; right: 6px;
  /* v14 a11y fix: --pink-badge-bg (not --pink) — see notif-badge above for
     the contrast rationale. */
  background: var(--pink-badge-bg); color: #fff; font-size: 0.5625rem;
  padding: 1px 4px; border-radius: var(--pill); font-weight: 700;
  box-shadow: 0 0 8px var(--pink-glow);
}

/* ══════════════════════════════
   PAGE SECTIONS
══════════════════════════════ */
/* .page-header + .page-header h2 merged into single rule below (see iOS DESIGN OVERHAUL section) */
.page-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }

/* ── KPI Cards ── */
.kpi-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 12px; margin-bottom: 20px;
}
.kpi-card {
  position: relative; overflow: hidden;
  background: var(--surface); border-radius: var(--r);
  padding: 16px;
  border: 1px solid var(--border);
  box-shadow: var(--sh-sm);
  transition: transform var(--t2), box-shadow var(--t2), border-color var(--t2);
  /* v14.0.68: `animation: fadeUp 0.4s var(--ease) both` removed. The KPI row
     already arrives inside #page-content's 160ms pageEnter (~line 1245); this
     second, slower 400ms fade-and-rise ran ON TOP of it, so the first thing
     the user looks at after every dashboard navigation was still visibly
     moving 400ms in. The end state is byte-identical -- fadeUp's `to` frame is
     opacity:1/translateY(0), which is the card's normal rest state -- so this
     is a pure settle-time change with no look change. @keyframes fadeUp
     (~line 27) stays: .login-card still uses it (~line 438). */
}
@media (hover:hover) {
  .kpi-card:hover { box-shadow: var(--sh); border-color: var(--border-strong); }
}
/* v14 Astral de-structuring pass: the hover-lift transform, the shimmer-sweep
   ::before, the gradient ::after stripe (+ its per-variant glow shadows), and
   the border-left negation were all removed. Astral's KPI cards now use the
   exact same structure as Dark's: a plain bordered card with a 3px accent-
   color left border (below) — no gradient stripe, no glow, no extra lift. */
.kpi-card.accent { border-left: 3px solid var(--warning); }
.kpi-card.green  { border-left: 3px solid var(--success); }
.kpi-card.warn   { border-left: 3px solid var(--warning); }
.kpi-card.red    { border-left: 3px solid var(--danger); }

.kpi-label {
  font-size: var(--fs-2xs); font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.6px;
  color: var(--text-muted); margin-bottom: 8px;
}
/* .kpi-value base rule merged into iOS-layer copy (protected clamp() rule, see below) */
.kpi-sub { font-size: var(--fs-xs); color: var(--text-muted); margin-top: 5px; }
.kpi-icon-wrap {
  width: 32px; height: 32px; border-radius: 8px; margin-bottom: 10px;
  display: flex; align-items: center; justify-content: center;
}
.kpi-icon-wrap svg { width: 18px; height: 18px; stroke: currentColor; }
/* v12 WS42 Phase 24 — Apple-style stat tile: icon-tile pinned top-right,
   number/label sit to its left so the tile never overlaps the figure. */
.kpi-card { display: flex; flex-direction: column; }
.kpi-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.kpi-card-top .bi-icon-tile { margin-top: -2px; }

/* ── KPI Progress Bar ── */
.kpi-bar-track {
  height: 6px; border-radius: 3px; background: var(--s2); overflow: hidden;
}
.kpi-bar-fill {
  height: 100%; border-radius: 3px; transition: width 0.8s var(--ease);
  background: var(--primary);
}

/* ── Alert Banners ── */
.alert-banner {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 16px; border-radius: var(--r-sm); margin-bottom: 14px;
  font-size: var(--fs-md); font-weight: 600; cursor: pointer;
  transition: opacity var(--t1), transform var(--t1);
  border: 1px solid transparent;
}
.alert-banner:hover { opacity: 0.88; transform: translateX(2px); }
.alert-banner:active { transform: scale(0.98); }
.alert-danger {
  background: rgba(255,69,58,0.12); border-color: rgba(255,69,58,0.25); color: #FF453A;
}
.alert-warn {
  background: rgba(255,170,0,0.10); border-color: rgba(255,170,0,0.22); color: #FFAA00;
}
.alert-chevron { font-size: var(--fs-xl); font-weight: 700; opacity: 0.7; }

/* ── Task Feed ── */
/* padding/separator now owned solely by the later "inset separators" copy
   (~line 5016, iOS DESIGN OVERHAUL section) — this rule used to also set
   padding:11px 18px + border-bottom, but that copy always won the cascade
   (same selector, later in source), leaving those two declarations dead.
   Removed at the source (SEAMLESS MOBILE PASS v2 consolidation) instead of
   layering another override on top. */
.task-feed-item {
  display: flex; align-items: center; gap: 12px;
  transition: background var(--t1); cursor: pointer;
}
.task-feed-item:hover { background: var(--s2); }
.task-feed-item.task-overdue { background: rgba(255,69,58,0.04); }
.task-feed-item.task-overdue:hover { background: rgba(255,69,58,0.08); }
.task-feed-dot {
  width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.priority-dot-high   { background: var(--danger); box-shadow: 0 0 6px rgba(255,69,58,0.5); }
.priority-dot-medium { background: var(--gold-3); box-shadow: 0 0 6px rgba(255,170,0,0.4); }
.priority-dot-low    { background: var(--success); }
.task-feed-title {
  font-size: var(--fs-md); font-weight: 600; color: var(--text);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.task-feed-meta {
  font-size: var(--fs-xs); color: var(--text-muted); margin-top: 2px;
}

/* ── Quick Action Buttons ── */
.quick-action-btn {
  display: flex; align-items: center; gap: 10px;
  width: 100%; padding: 11px 14px; border-radius: var(--r-sm);
  background: var(--s1); border: 1px solid var(--border); cursor: pointer;
  font-size: var(--fs-md); font-weight: 600; color: var(--text-2);
  transition: background var(--t1), color var(--t1), border-color var(--t1);
  text-align: left; min-height: 44px;
}
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
@media (hover:hover) { .quick-action-btn:hover { background: var(--s2); color: var(--text); border-color: rgba(255,255,255,0.15); } }
.quick-action-btn:active { background: var(--s3); transform: scale(0.98); }
.quick-action-btn svg { width: 16px; height: 16px; stroke: currentColor; flex-shrink: 0; }

/* ── Payslip Rows ── */
.payslip-row {
  display: flex; justify-content: space-between; align-items: center;
  padding: 8px 0; border-bottom: 1px solid var(--border);
  font-size: var(--fs-md); color: var(--text-2);
}
.payslip-row:last-child { border-bottom: none; }
.payslip-row strong { color: var(--text); }

/* ── Live Clock ── */
.live-clock-line {
  font-size: var(--fs-sm); color: var(--text-muted); font-weight: 600;
  margin: -12px 0 16px; letter-spacing: 0.2px;
}

/* ── Cards ── (base .card rule merged into LIQUID GLASS copy below) */
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
@media (hover:hover) { .card:hover { border-color: rgba(255,255,255,0.12); } }
/* subtle gradient interior */
.card::before {
  content: '';
  position: absolute; inset: 0; pointer-events: none;
  background: var(--grad-card); border-radius: inherit;
}
.card-header {
  padding: 14px 18px; border-bottom: 1px solid var(--border);
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  background: rgba(255,255,255,0.01);
  position: relative; z-index: 1;
}
.card-header h3 {
  font-size: var(--fs-base); font-weight: 700; color: var(--text); letter-spacing: -0.1px;
}
.card-body { padding: 16px 18px; position: relative; z-index: 1; }
.dashboard-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
.chart-wrap { position: relative; height: 200px; }

/* ══════════════════════════════
   EMPLOYEE ID CARD
══════════════════════════════ */
.id-card {
  position: relative; overflow: hidden;
  max-width: 380px;
  border-radius: var(--r-lg); padding: 22px;
  background: linear-gradient(140deg, #0A0A1A 0%, #131330 40%, #1A0A2E 100%);
  border: 1px solid rgba(255,255,255,0.10);
  box-shadow: var(--sh-md), 0 0 0 1px rgba(255,214,10,0.12);
  color: #fff;
}
/* static gold sheen — shared by every theme (no animation anywhere, including
   Astral — v14 de-structuring pass removed the Astral-only infinite shine). */
.id-card::before {
  content: '';
  position: absolute; top: -2px; left: -120%; width: 80%; height: calc(100% + 4px);
  background: linear-gradient(90deg, transparent, rgba(255,214,10,0.08), rgba(255,255,255,0.06), transparent);
  transform: skewX(-15deg) translateX(0%);
  border-radius: inherit;
}
/* top gold border */
.id-card::after {
  content: '';
  position: absolute; top: 0; left: 8%; right: 8%; height: 1.5px;
  background: var(--grad-gold); border-radius: var(--pill); opacity: 0.80;
}
/* bg pattern */
.id-card-bg {
  position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.id-card-bg::before {
  content: '';
  position: absolute; right: -50px; top: -50px;
  width: 200px; height: 200px; border-radius: 50%;
  background: radial-gradient(circle, rgba(255,45,120,0.12) 0%, transparent 70%);
}
.id-card-bg::after {
  content: '';
  position: absolute; left: -30px; bottom: -40px;
  width: 150px; height: 150px; border-radius: 50%;
  background: radial-gradient(circle, rgba(10,132,255,0.10) 0%, transparent 70%);
}
.id-card-top {
  display: flex; align-items: center; gap: 12px;
  margin-bottom: 18px; position: relative; z-index: 1;
}
.id-card-logo {
  width: 38px; height: 38px; object-fit: contain;
  border-radius: 9px;
  background: rgba(255,255,255,0.08);
  border: 1px solid rgba(255,214,10,0.25);
  padding: 4px;
}
.id-card-company {
  font-size: var(--fs-md); font-weight: 800; letter-spacing: 0.5px;
  background: linear-gradient(90deg, #FFFFFF, rgba(255,214,10,0.85));
  -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.id-card-company-sub { font-size: 0.5625rem; opacity: 0.55; letter-spacing: 2px; text-transform: uppercase; }
.id-card-body { display: flex; gap: 16px; align-items: flex-start; position: relative; z-index: 1; }
.id-card-photo {
  width: 72px; height: 88px;
  background: rgba(255,255,255,0.06);
  border-radius: var(--r-sm);
  border: 1px solid rgba(255,214,10,0.25);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-4xl); flex-shrink: 0; overflow: hidden; cursor: pointer;
  position: relative;
  transition: border-color var(--t2), box-shadow var(--t2);
}
.id-card-photo:hover { border-color: rgba(255,214,10,0.55); box-shadow: 0 0 12px var(--gold-glow); }
.id-card-photo img { width: 100%; height: 100%; object-fit: cover; }
.id-card-photo-hint {
  position: absolute; bottom: 0; left: 0; right: 0;
  background: rgba(0,0,0,0.60); color: rgba(255,255,255,0.80);
  font-size: 0.5rem; text-align: center; padding: 3px; letter-spacing: 0.3px;
}
.id-card-info { flex: 1; min-width: 0; }
.id-card-name {
  font-size: var(--fs-lg); font-weight: 800; line-height: 1.2;
  margin-bottom: 3px; letter-spacing: -0.2px;
  background: linear-gradient(90deg, #fff, rgba(255,214,10,0.80));
  -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.id-card-title { font-size: var(--fs-xs); opacity: 0.60; margin-bottom: 10px; text-transform: capitalize; letter-spacing: 0.3px; }
.id-card-detail { font-size: var(--fs-xs); opacity: 0.58; display: flex; gap: 6px; margin-bottom: 4px; line-height: 1.4; }
.id-card-detail strong { opacity: 1; font-weight: 600; }
.id-card-footer {
  margin-top: 16px; padding-top: 12px;
  border-top: 1px solid rgba(255,214,10,0.15);
  display: flex; justify-content: space-between; align-items: center;
  position: relative; z-index: 1;
}
.id-card-id {
  font-size: var(--fs-sm); font-weight: 700; letter-spacing: 2px;
  font-family: var(--font-mono);
  background: var(--grad-gold);
  -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.id-card-status {
  font-size: 0.5625rem; letter-spacing: 1px; text-transform: uppercase;
  font-weight: 700; padding: 3px 10px; border-radius: var(--pill);
  background: rgba(48,209,88,0.15); color: #4DE87A;
  border: 1px solid rgba(48,209,88,0.25);
  box-shadow: 0 0 8px rgba(48,209,88,0.20);
}
.id-card-qr {
  margin-top: 14px; display: flex; justify-content: center;
  position: relative; z-index: 1;
}

/* ── Calling (back) face — class-driven; dark default, light override below ── */
.id-card--calling {
  background: linear-gradient(135deg,#1a1a2e 0%,#16213e 50%,#0f3460 100%);
  color: #fff; text-align: center; padding: 24px 20px;
  display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.id-card--calling .idc-photo {
  width: 72px; height: 72px; border-radius: 50%; overflow: hidden; margin-bottom: 4px;
  display: flex; align-items: center; justify-content: center; font-size: 2.25rem;
  background: rgba(255,255,255,0.10); border: 3px solid rgba(255,255,255,0.30);
}
.id-card--calling .idc-photo img { width: 100%; height: 100%; object-fit: cover; }
.id-card--calling .idc-name { font-size: var(--fs-xl); font-weight: 800; letter-spacing: .5px; }
.id-card--calling .idc-role { font-size: var(--fs-sm); font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: rgba(255,255,255,0.70); }
.id-card--calling .idc-dept { font-size: var(--fs-xs); color: rgba(255,255,255,0.50); margin-top: 2px; }
.id-card--calling .idc-divider { width: 100%; height: 1px; background: rgba(255,255,255,0.15); margin: 10px 0; }
.id-card--calling .idc-contact { font-size: var(--fs-sm); color: rgba(255,255,255,0.80); }
.id-card--calling .idc-brand { font-size: var(--fs-2xs); color: rgba(255,255,255,0.40); margin-top: 8px; letter-spacing: .1em; }

/* ══════════════════════════════════════════════════
   ID CARD — professional LIGHT variant (office / aurora / astral / slate)
   Clean white corporate card: flat, no gold shimmer, blue accent, ink text.
══════════════════════════════════════════════════ */
html.light .id-card {
  background: #FFFFFF; color: var(--text);
  border: 1px solid var(--border); box-shadow: var(--sh-md);
}
/* kill the animated gold shimmer + swap the top trim to the brand-blue accent */
html.light .id-card::before { display: none; }
html.light .id-card::after {
  left: 0; right: 0; height: 3px; opacity: 1;
  background: linear-gradient(90deg, var(--primary), var(--primary-light));
}
html.light .id-card-bg::before { background: radial-gradient(circle, rgba(15,108,189,0.05) 0%, transparent 70%); }
html.light .id-card-bg::after { background: radial-gradient(circle, rgba(15,108,189,0.04) 0%, transparent 70%); }
html.light .id-card-logo { background: #F3F2F1; border-color: var(--border); }
/* solid ink text (undo the gradient text-clip) */
html.light .id-card-company {
  background: none; -webkit-text-fill-color: var(--text); color: var(--text);
}
html.light .id-card-company-sub { color: var(--text-muted); opacity: 1; }
html.light .id-card-photo { background: #F3F2F1; border-color: var(--border); }
html.light .id-card-photo:hover { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(15,108,189,0.15); }
html.light .id-card-name {
  background: none; -webkit-text-fill-color: var(--text); color: var(--text);
}
html.light .id-card-title { color: var(--text-muted); opacity: 1; }
html.light .id-card-detail { color: var(--text-2); opacity: 1; }
html.light .id-card-detail strong { color: var(--text); }
html.light .id-card-footer { border-top-color: var(--border); }
html.light .id-card-id {
  background: none; -webkit-text-fill-color: var(--primary); color: var(--primary);
}
html.light .id-card-status {
  background: rgba(16,124,65,0.10); color: #107C41; border-color: rgba(16,124,65,0.28); box-shadow: none;
}
/* back / calling face — clean white too */
html.light .id-card--calling {
  background: #FFFFFF; color: var(--text);
}
html.light .id-card--calling .idc-photo { background: #F3F2F1; border-color: var(--primary); }
html.light .id-card--calling .idc-role { color: var(--primary); }
html.light .id-card--calling .idc-dept { color: var(--text-muted); }
html.light .id-card--calling .idc-divider { background: var(--border); }
html.light .id-card--calling .idc-contact { color: var(--text-2); }
html.light .id-card--calling .idc-brand { color: var(--text-muted); }

/* ══════════════════════════════
   PULL-TO-REFRESH
══════════════════════════════ */
/* ══════════════════════════════
   SPLASH SCREEN
══════════════════════════════ */
#splash-screen {
  /* WS42 Phase 27 hygiene: background/display/flex/wordmark/logo-size/bar
     styling below was fully superseded by the token-driven splash block
     further down (search "Logo card — flat surface"); that block redefines
     every property here except position/inset/z-index/transition/.hiding,
     which stay. The dead .splash-wordmark rule and unused @keyframes splashIn
     were removed; @keyframes splashBarRun is kept — the new .splash-bar::after
     still references it by name. */
  position: fixed; inset: 0; z-index: var(--z-splash);
  /* v14.0.68: 0.4s -> 0.18s. This fade is the TAIL of the launch budget, not
     part of it -- app.js holds the splash for _SPLASH_MIN_MS and then waits a
     hide timeout sized to MATCH this duration before removing the node, so
     every millisecond here is a millisecond of warm launch during which the
     app is already interactive underneath. 180ms still reads as a dissolve
     rather than a cut. Keep this number and app.js's hide timeout in step: if
     one moves the other has to move with it, or the splash node is removed
     mid-fade (flicker) or lingers inert over the app. */
  transition: opacity 0.18s ease, visibility 0.18s ease;
}
#splash-screen.hiding {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}
@keyframes splashBarRun {
  0%   { transform: translateX(-250%); }
  100% { transform: translateX(500%); }
}

/* ══════════════════════════════
   PULL-TO-REFRESH  (arc-ring style)
══════════════════════════════ */
#ptr-indicator {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 56px);
  left: 50%; transform: translateX(-50%) translateY(-90px);
  z-index: var(--z-ptr);
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 0;
  background: transparent;
  border: none; box-shadow: none;
  transition: none;
  pointer-events: none;
  opacity: 0;
}
/* iOS / Instagram-style radial spinner — 12 fading spokes */
.ptr-spinner {
  position: relative;
  width: 26px; height: 26px;
  color: var(--text-muted);
}
.ptr-spinner i {
  position: absolute; top: 0; left: 50%;
  width: 2.5px; height: 7px; margin-left: -1.25px;
  border-radius: 2px;
  background: currentColor;
  transform-origin: 50% 13px;   /* pivot at the container centre (26/2) */
}
.ptr-spinner i:nth-child(1)  { transform: rotate(0deg);   opacity: 1;   }
.ptr-spinner i:nth-child(2)  { transform: rotate(30deg);  opacity: .92; }
.ptr-spinner i:nth-child(3)  { transform: rotate(60deg);  opacity: .84; }
.ptr-spinner i:nth-child(4)  { transform: rotate(90deg);  opacity: .76; }
.ptr-spinner i:nth-child(5)  { transform: rotate(120deg); opacity: .68; }
.ptr-spinner i:nth-child(6)  { transform: rotate(150deg); opacity: .60; }
.ptr-spinner i:nth-child(7)  { transform: rotate(180deg); opacity: .52; }
.ptr-spinner i:nth-child(8)  { transform: rotate(210deg); opacity: .44; }
.ptr-spinner i:nth-child(9)  { transform: rotate(240deg); opacity: .36; }
.ptr-spinner i:nth-child(10) { transform: rotate(270deg); opacity: .28; }
.ptr-spinner i:nth-child(11) { transform: rotate(300deg); opacity: .20; }
.ptr-spinner i:nth-child(12) { transform: rotate(330deg); opacity: .13; }
/* Ready to release / hard-reload — tint the spokes (still static; spins on refresh) */
#ptr-indicator.ptr-ready .ptr-spinner { color: #30D158; }
#ptr-indicator.ptr-hard  .ptr-spinner { color: #FF9F0A; }
/* Refreshing — the wheel of fading spokes ticks around (native iOS cadence) */
#ptr-indicator.ptr-refreshing .ptr-spinner {
  animation: ptrSpin 0.8s steps(12) infinite;
}
@keyframes ptrSpin {
  to { transform: rotate(360deg); }
}
/* On mobile, clear the top-nav-strip */
@media (max-width: 768px) {
  #ptr-indicator {
    top: calc(env(safe-area-inset-top, 0px) + 56px + 50px + 8px);
  }
}

/* ══════════════════════════════
   ID CARD 3D FLIP
══════════════════════════════ */
/* ── Per-face rotation: no preserve-3d needed.
   Avoids iOS Safari backface-visibility bug caused by ancestor overflow:hidden. ── */
.id-flip-scene {
  perspective: 1000px;
  -webkit-perspective: 1000px;
  width: 100%; max-width: 380px; margin: 0 auto;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.id-flip-inner {
  position: relative;
  width: 100%;
  /* No transform-style:preserve-3d — each face handles its own rotation */
}
.id-flip-front,
.id-flip-back {
  width: 100%;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  transition: transform 0.55s cubic-bezier(.4,0,.2,1);
  will-change: transform;
}
/* Default state: front visible at 0°, back pre-rotated to 180° */
.id-flip-front { transform: rotateY(0deg); }
.id-flip-back {
  position: absolute;
  top: 0; left: 0;
  transform: rotateY(180deg);
  pointer-events: none;
}
/* Flipped state: front hides at -180°, back returns to 0° */
.id-flip-inner.is-flipped .id-flip-front {
  transform: rotateY(-180deg);
  pointer-events: none;
}
.id-flip-inner.is-flipped .id-flip-back {
  transform: rotateY(0deg);
  pointer-events: auto;
}

/* Swipe visual hint */
.id-flip-hint {
  text-align: center; font-size: var(--fs-2xs);
  color: var(--text-muted); margin-top: 6px;
  display: flex; align-items: center; justify-content: center; gap: 6px;
}
.id-flip-hint span { opacity: 0.5; }
/* v14 de-structuring pass: Astral's infinite bounce animation on this hint
   removed — same static hint as Light/Dark in every theme. */
.id-flip-dots {
  display: flex; justify-content: center; gap: 6px; margin-top: 8px;
}
.id-flip-dot {
  width: 22px; height: 4px; border-radius: 2px;
  background: rgba(255,255,255,0.15);
  transition: background 0.3s;
}
.id-flip-dot.active { background: var(--primary-light); }

/* ══════════════════════════════
   SUBTABS
══════════════════════════════ */
.subtab-bar {
  display: flex; gap: 3px; margin-bottom: 18px;
  background: rgba(255,255,255,0.04);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 4px;
  overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none;
  flex-wrap: nowrap;
}
.subtab-bar::-webkit-scrollbar { display: none; }
/* .subtab-btn base rule merged into iOS-layer copy below */
/* v14 CSS/a11y audit: dead .subtab-btn.active gradient-pill treatment (+ its
   Astral gradRun animation) removed here — fully overridden by the later
   token-driven block further down (background: var(--primary-soft), plain
   solid color, no background-position for gradRun to animate), same
   specificity, later in source. Never painted in any theme; see that block
   for the live active-subtab style. */
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
@media (hover:hover) { .subtab-btn:hover:not(.active) { background: rgba(255,255,255,0.06); color: var(--text); } }
/* v14 CSS/a11y audit — desktop-mouse users have no visual cue that this row
   (and #tn-tabs) scrolls once the native scrollbar is hidden below (touch
   users already have the swipe affordance). Edge-fade mask restores a "there
   is more" cue without bringing back a visible scrollbar. Scoped to
   hover:hover+pointer:fine so touch/mobile is untouched. */
@media (hover: hover) and (pointer: fine) {
  #tn-tabs, .subtab-bar {
    mask-image: linear-gradient(to right, transparent 0, #000 20px, #000 calc(100% - 20px), transparent 100%);
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 20px, #000 calc(100% - 20px), transparent 100%);
  }
}

/* ── Chip-style subtabs (declutter pattern) ──────────────
   Wraps to multiple rows instead of horizontal-scrolling, with an
   optional count pill. Use window.chipTabs()/bindChipTabs() (config.js). */
.chip-tabs {
  display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px;
}
.chip-tab {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 7px 13px; border-radius: var(--pill);
  border: 1px solid var(--border); background: var(--surface-2);
  font-size: var(--fs-md); font-weight: 500; color: var(--text-muted);
  cursor: pointer; white-space: nowrap; min-height: 34px;
  transition: background var(--t2), color var(--t2), border-color var(--t2);
}
@media (hover:hover) { .chip-tab:hover:not(.active) { background: var(--surface-3); color: var(--text); } }
.chip-tab.active {
  background: var(--primary-soft);
  color: var(--primary); font-weight: 600; border-color: transparent;
}
.chip-count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 18px; height: 18px; padding: 0 5px; border-radius: var(--pill);
  font-size: var(--fs-xs); font-weight: 700; line-height: 1;
  background: var(--surface-3); color: var(--text-muted);
}
.chip-tab.active .chip-count { background: var(--primary-fill, var(--primary)); color: var(--on-primary); }
/* v14 a11y fix: --danger-badge-bg (not --danger) — white text on plain
   --danger at this font-size fails AA in Dark (~4.1:1) and Astral (~3.4:1);
   Light already cleared AA so --danger-badge-bg just aliases --danger there
   (see tokens.css / the Dark+Astral theme blocks for the per-theme override). */
.chip-count.on { background: var(--danger-badge-bg, #e5484d); color: #fff; }
.chip-tab.active .chip-count.on { background: var(--danger-badge-bg, #e5484d); color: #fff; }
@media (pointer:coarse){ .chip-tab { min-height: 38px; } }
/* Color-only: Astral's gradient-fill active chip instead of the flat
   --primary-soft/--primary pair. The glow box-shadow + gradRun animation
   (motion, not color) were removed — v14 de-structuring pass. */
html.theme-astral .chip-tab.active {
  background: var(--grad-pk-bl);
  color: #fff;
}

/* ══════════════════════════════
   ITEM LISTS
══════════════════════════════ */
.item-list { display: flex; flex-direction: column; gap: 8px; }
.item-card {
  background: var(--surface); border-radius: var(--r);
  border: 1px solid var(--border); padding: 13px 15px;
  cursor: pointer; position: relative; overflow: hidden;
  /* Timing function named explicitly: without it this ran the UA default
     `ease` while every other press surface ran var(--ease), and .item-card is
     the most-tapped surface in the app. (68 other `transition:` declarations in
     this file still omit a timing function — sweeping them all is a whole-app
     feel change with no way to validate it, so only the press surfaces this
     pass touches are converted.) */
  transition: background var(--t2) var(--ease), box-shadow var(--t2) var(--ease), transform var(--t1) var(--ease), border-color var(--t2) var(--ease);
  border-left: 3px solid transparent;
}
.item-card::before {
  content: '';
  position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.03), transparent);
  transform: translateX(-100%) skewX(-12deg);
  pointer-events: none; /* decorative shimmer only — must never intercept clicks on card content */
}
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
/* The list row is the single most-tapped surface in the app and the clearest
   case of the latch: :hover lifts it translateY(-1px) on the SAME property
   :active uses to press it in, so :active won only while the finger was down
   and :hover took over the moment it lifted. The net visible result of
   tapping a row was a permanent LIFT -- the opposite of a press. */
@media (hover:hover) {
  .item-card:hover {
    background: var(--s2); transform: translateY(-1px);
    box-shadow: var(--sh-sm);
  }
  .item-card:hover::before { animation: shimmer 0.5s ease forwards; }
}
/* WS42 Phase 13 — .pressable-equivalent feedback baked directly into the row
   class (rather than sweeping every JS template literal that builds .item-card
   to add the utility class): same transform/opacity-only press feedback. */
.item-card:active, .notif-item:active, .chat-inbox-row:active {
  transform: scale(0.98); opacity: 0.92; transition-duration: 90ms;
}
@media (prefers-reduced-motion: reduce) {
  .item-card:active, .notif-item:active, .chat-inbox-row:active { transform: none; opacity: 0.92; }
}
.item-card.priority-urgent { border-left-color: var(--danger);  box-shadow: inset 3px 0 0 var(--danger); }
.item-card.priority-high   { border-left-color: var(--gold-3);  }
.item-card.priority-medium { border-left-color: var(--blue);    }
.item-card.priority-low    { border-left-color: var(--success); }
.item-card.status-done     { opacity: 0.45; }

.item-top { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 5px; align-items: flex-start; }
.item-title { font-size: var(--fs-base); font-weight: 600; color: var(--text); line-height: 1.35; }
.item-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: var(--fs-xs); color: var(--text-muted); align-items: center; }
.item-badges { display: flex; gap: 4px; flex-wrap: wrap; }

/* ── Comments ── */
.comments-section { margin-top: 14px; }
.comments-section h4 { font-size: var(--fs-xs); font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 10px; }
.comment-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; max-height: 240px; overflow-y: auto; }
.comment-item { background: var(--s1); border-radius: var(--r-sm); padding: 10px 12px; border: 1px solid var(--border); }
.comment-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.comment-author {
  font-size: var(--fs-sm); font-weight: 700;
  background: var(--grad-pk-bl);
  -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.comment-time { font-size: var(--fs-xs); color: var(--text-light); }
.comment-text { font-size: var(--fs-md); line-height: 1.4; color: var(--text-2); }
.comment-input-row { display: flex; gap: 8px; }
.comment-input-row input {
  flex: 1; padding: 9px 12px; border: 1.5px solid var(--border);
  border-radius: var(--r-sm); font-size: var(--fs-md); outline: none;
  background: var(--s1); color: var(--text);
  transition: border-color var(--t2);
}
.comment-input-row input:focus { border-color: var(--pink); box-shadow: 0 0 0 3px var(--pink-glow); }

/* ══════════════════════════════
   TABLES
══════════════════════════════ */
.data-table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); }
.data-table th {
  padding: 9px 12px; text-align: left;
  font-size: var(--fs-2xs); font-weight: 700; color: var(--text-muted);
  text-transform: uppercase; letter-spacing: 0.5px;
  border-bottom: 1px solid var(--border); white-space: nowrap;
  background: var(--bg-2);
  position: sticky; top: 0; z-index: 1;
}
.data-table td { padding: 11px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.data-table tr:last-child td { border-bottom: none; }
@media (hover:hover) { .data-table tr:hover td { background: var(--surface-2); } }
/* v13 Phase 149 — selection & copy ergonomics: explicitly re-enable text
   selection on data surfaces. No broad user-select:none exists on body/table
   ancestors today (audited — the only user-select:none rules in this file are
   scoped to controls: sidebar-open lock, decorative pull-to-refresh icon, the
   ID-card flip scene); this makes the "selectable" intent explicit and
   future-proofs against a control-scoped rule accidentally widening later. */
.data-table td, .mono, code { user-select: text; }
.table-wrap, .table-scroll {
  overflow-x: auto; -webkit-overflow-scrolling: touch;
  scrollbar-width: thin;
}
.table-wrap::-webkit-scrollbar, .table-scroll::-webkit-scrollbar { height: 6px; }
.table-wrap::-webkit-scrollbar-thumb, .table-scroll::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: var(--pill); }

/* v13 Phase 126 — table kit (additive, opt-in; zero drift to existing tables).
   1. Numeric-column utility: apply .num to a <td> (or .numeric-r to the table)
      for right-aligned, tabular-figure number columns. Opt-in — no table is
      affected unless it uses the class. */
.data-table .num, .data-table.numeric-r td.num {
  text-align: right; font-variant-numeric: tabular-nums;
}
/* 2. Sticky-header opt-in for wrappers that want an explicit sticky head
   beyond the table's own default sticky th (useful when a wrapper itself
   scrolls vertically, e.g. a fixed-height .table-scroll panel). Only fires
   when .sticky-head is present on the wrapper. */
.table-scroll.sticky-head thead th, .table-wrap.sticky-head thead th {
  position: sticky; top: 0; z-index: 1; background: var(--surface, var(--bg-2));
}
/* 3. Mobile scroll affordance: a right-edge fade hint suggesting more
   content is scrollable. Pure decoration — pointer-events:none so it never
   blocks the existing overflow-x scroll gesture. Desktop untouched.
   MOBILE FINANCE PASS (2026-08-08) — .chip-tabs joins the same rule. At ≤640px
   it is already a nowrap/overflow-x:auto row with BOTH scrollbars suppressed
   (scrollbar-width:none + ::-webkit-scrollbar{display:none}, ~line 7330), and
   Finance's 7 group labels measure ~720px inside a ~355px box, so roughly 3 of
   7 chips were visible with nothing on screen indicating the rest existed. The
   pre-existing mask-image fade (~line 1878) is gated on hover:hover+pointer:fine
   and names only #tn-tabs/.subtab-bar, so touch had no cue at all.
   `position:absolute` matters: an ::after on a flex container would otherwise
   become a flex ITEM and take real layout space — out-of-flow keeps it pure
   decoration. It anchors to the right edge of the scrollport at scrollLeft 0
   (identical behaviour to .table-wrap's, which is the point of reusing it). */
@media (max-width: 640px) {
  .table-wrap, .table-scroll, .chip-tabs { position: relative; }
  .table-wrap::after, .table-scroll::after, .chip-tabs::after {
    content: ""; position: absolute; top: 0; right: 0; bottom: 0; width: 24px;
    background: linear-gradient(to right, transparent, var(--bg, var(--bg-2)) 92%);
    pointer-events: none;
  }
  /* The BIR books/worksheets are a WHITE filing artifact — the themed --bg
     fade above would paint a dark band over the paper in Dark/Astral. Repoint
     it to white for the on-screen BIR sheet only (the printed copy and the
     openPrintableDoc panel copy are untouched — see the .bir-print block). */
  #page-content .bir-print .table-wrap::after {
    background: linear-gradient(to right, rgba(255,255,255,0), #fff 92%);
  }
}
/* 4. Row-action button set: consistent trailing action cluster for table
   rows (icon buttons, right-aligned). Opt-in via .row-actions — existing
   ad-hoc action cells are untouched. */
.data-table .row-actions {
  display: flex; gap: 4px; align-items: center; justify-content: flex-end;
}
.data-table .row-actions .icon-btn {
  width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center;
}
/* 5. Responsive table→card pattern (v14 Wave 4 Batch F4 — reusable, Wave 6
   reuses it). Opt-in via .table-cards on a <table class="data-table">.
   APPROACH: same single <table> markup at every width — no parallel/duplicate
   card render. At ≤700px each <tr> becomes a flex "card"; mark cells with a
   role class so CSS (not JS) decides layout:
     .tc-avatar / .tc-name / .tc-net  — always-visible header row of the card
     .tc-detail                       — the rest of the breakdown, one per
                                         line via data-label::before, hidden
                                         until the row is tapped
     .tc-actions                      — action buttons, always visible
   Tapping the card toggles .tc-expanded on the <tr> (JS just flips a class —
   the cell markup/values never change). A literal <details> element can't
   validly wrap a subset of <td>s inside a <tr> (HTML5 table parsing foster-
   parents anything that isn't td/th out of the row), so "tap to expand" is
   done as a class toggle instead — same interaction, valid table markup,
   zero duplicated data. Desktop (>700px) is completely unaffected: this
   whole block is inside the max-width query. */
@media (max-width: 700px) {
  .table-cards thead { display: none; }
  .table-cards, .table-cards tbody { display: block; width: 100%; }
  .table-cards tr {
    display: flex; flex-wrap: wrap; align-items: center;
    background: var(--surface, var(--s1)); border: 1px solid var(--border);
    border-radius: var(--r-sm); padding: 10px 12px; margin-bottom: 10px;
    gap: 4px 10px;
  }
  .table-cards tr:last-child { margin-bottom: 0; }
  .table-cards td { border: none; padding: 0; }
  .table-cards td.tc-avatar { flex: 0 0 auto; order: 1; }
  .table-cards td.tc-name   { flex: 1 1 120px; order: 1; min-width: 0; }
  .table-cards td.tc-net    { flex: 0 0 auto; order: 1; text-align: right; }
  .table-cards td.tc-detail {
    flex: 1 1 100%; order: 2; display: none;
    justify-content: space-between; align-items: center;
    padding: 4px 0; border-top: 1px dashed var(--border); font-size: var(--fs-sm);
  }
  .table-cards tr.tc-expanded td.tc-detail { display: flex; }
  .table-cards td.tc-detail[data-label]::before {
    content: attr(data-label); font-weight: 700; font-size: var(--fs-2xs);
    color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;
  }
  .table-cards td.tc-actions {
    flex: 1 1 100%; order: 3; display: flex; justify-content: flex-end;
    align-items: center; gap: 6px; padding-top: 6px; border-top: 1px solid var(--border);
  }
  .table-cards .tc-caret { transition: transform var(--t2); color: var(--text-muted); }
  .table-cards tr.tc-expanded .tc-caret { transform: rotate(180deg); }
  /* 5b. tfoot support (Wave 6) — line-item tables (RFQ/PR) carry a Totals row
     below tbody. Without this the tfoot keeps its table-footer-group display
     with a display:block ancestor, which renders inconsistently across
     browsers. Render it as one flex row: label (colspan cell) left, total
     right — same visual weight regardless of how many <td>s the row has. */
  .table-cards tfoot { display: block; }
  .table-cards tfoot tr {
    display: flex; justify-content: space-between; align-items: center;
    padding: 8px 12px; margin-top: 4px; border-top: 2px solid var(--border);
    font-weight: 700;
  }
  .table-cards tfoot td { border: none; padding: 0; }
  /* 5c. No-toggle variant (Wave 6) — for short line-item tables where every
     cell matters at a glance and hiding data would break inline editing/
     reading (e.g. RFQ/PR item rows: qty+unit+price+total need to stay
     together). Opt-in via .table-cards.no-toggle — every <td> becomes an
     always-visible label:value line (data-label required), no tap/caret/JS. */
  .table-cards.no-toggle td {
    flex: 1 1 100%; display: flex; justify-content: space-between; align-items: center;
    padding: 4px 0; border-top: 1px dashed var(--border); gap: 10px;
  }
  .table-cards.no-toggle tr td:first-child { border-top: none; }
  .table-cards.no-toggle td[data-label]::before {
    content: attr(data-label); font-weight: 700; font-size: var(--fs-2xs);
    color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;
  }
  /* 5c-i. Composing .no-toggle with the §5 ROLE classes (MOBILE FINANCE PASS
     2026-08-08). Until now every .no-toggle table in the app used plain
     data-label <td>s only — Finance's Bank Accounts table is the first to use
     .no-toggle together with .tc-name/.tc-actions, and the generic
     `.table-cards.no-toggle td { display:flex; justify-content:space-between }`
     above is wrong for both of them:
       · .tc-name is the card's TITLE cell, not a label:value line. It holds a
         <strong>nickname</strong> plus a sub-label <div>, which display:flex
         flattened onto ONE line — squeezing the sub-label (bank + account
         number) against the cell edge instead of stacking it under the name.
         It also carries no data-label by design, so it gains nothing from the
         flex row. Back to block.
       · .tc-actions' own rule sets justify-content:flex-end (a trailing button
         cluster), which the no-toggle rule ties on specificity and beats on
         source order — so Edit and Delete were flung to opposite ends of the
         card. Restore flex-end.
     Both selectors are (0,3,1) vs the generic (0,2,1), so they win on
     specificity rather than relying on source order. Verified against every
     other .no-toggle call site: none uses a .tc-* role class, so this changes
     exactly one table. */
  .table-cards.no-toggle td.tc-name { display: block; }
  .table-cards.no-toggle td.tc-actions { justify-content: flex-end; }
}
@media (min-width: 701px) {
  .table-cards .tc-caret { display: none; }
}

/* ══════════════════════════════
   QUOTE BUILDER
══════════════════════════════ */
.quote-sheet {
  background: var(--s1); border-radius: var(--r);
  border: 1px solid var(--border); padding: 22px;
}
/* v14 de-structuring pass: Astral's backdrop-filter blur on .quote-sheet was
   removed — same flat surface as every other theme. */
.quote-sheet h3 { font-size: 1.0625rem; font-weight: 800; color: var(--text); margin-bottom: 18px; display: flex; align-items: center; gap: 8px; }

/* ══════════════════════════════
   CASH / EXPENSE
══════════════════════════════ */
.upload-area {
  display: block;
  border: 2px dashed rgba(255,255,255,0.14); border-radius: var(--r);
  padding: 24px 20px; text-align: center; cursor: pointer;
  background: var(--s0); position: relative;
  transition: border-color var(--t2), background var(--t2);
}
.upload-area:hover, .upload-area.drag-over {
  border-color: var(--pink); background: rgba(255,45,120,0.05);
}
.upload-area.drag-over { transform: scale(1.01); }
.upload-area-inner { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.upload-icon-wrap {
  width: 44px; height: 44px; border-radius: 12px;
  background: var(--s2); display: flex; align-items: center; justify-content: center;
  margin-bottom: 4px;
}
.upload-label-text { font-size: var(--fs-md); font-weight: 600; color: var(--text-2); }
.upload-hint { font-size: var(--fs-xs); color: var(--text-muted); }
.upload-area input[type="file"] { display: none; }

/* Upload progress */
.upload-progress { margin-top: 10px; }
.upload-progress.hidden { display: none; }
.upload-bar-track { height: 5px; border-radius: 3px; background: var(--s2); overflow: hidden; margin-bottom: 6px; }
.upload-bar-fill { height: 100%; border-radius: 3px; background: var(--primary); width: 0%; transition: width 0.4s var(--ease); }
.upload-status { font-size: var(--fs-sm); color: var(--text-muted); }

/* Uploaded file chips */
.uploaded-files-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.file-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 6px 12px; border-radius: var(--pill);
  background: var(--s2); border: 1px solid var(--border);
  font-size: var(--fs-sm); font-weight: 500; color: var(--text-2);
  text-decoration: none; cursor: pointer;
  transition: background var(--t1), color var(--t1);
  max-width: 220px;
}
.file-chip span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-chip:hover { background: var(--s3); color: var(--text); }
.file-chip-src {
  font-size: var(--fs-2xs); color: var(--text-muted); flex-shrink: 0;
  padding: 2px 5px; border-radius: var(--pill); background: var(--s1);
}

/* Storage status card */
.storage-status-card {
  padding: 16px 18px; border-radius: var(--r); border: 1px solid var(--border);
  background: var(--s1);
}
.storage-status-card.drive-on  { border-color: rgba(255,214,10,0.20); }
.storage-status-card.drive-off { border-color: rgba(10,132,255,0.18); }
.storage-icon-wrap {
  width: 40px; height: 40px; border-radius: 10px; background: var(--s2);
  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}

/* ══════════════════════════════
   DEPARTMENTS
══════════════════════════════ */
.dept-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.dept-card {
  background: var(--s1); border-radius: var(--r); border: 1px solid var(--border);
  padding: 20px;
  border-top: 2px solid transparent;
  background-clip: padding-box;
  transition: box-shadow var(--t2), transform var(--t2), border-color var(--t2);
  position: relative;
}
.dept-card::before {
  content: '';
  position: absolute; top: -1px; left: 0; right: 0; height: 2px;
  background: var(--grad-pk-bl); border-radius: var(--r) var(--r) 0 0;
  opacity: 0.7;
}
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
@media (hover:hover) { .dept-card:hover { box-shadow: var(--sh-sm), 0 0 24px rgba(255,45,120,0.08); transform: translateY(-2px); } }
.dept-name { font-size: 0.9375rem; font-weight: 700; margin-bottom: 4px; color: var(--text); }
.dept-head { font-size: var(--fs-sm); color: var(--text-muted); margin-bottom: 12px; }
.dept-members { display: flex; flex-wrap: wrap; gap: 6px; }

/* ══════════════════════════════
   POLICY CARDS
══════════════════════════════ */
.policy-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.policy-card {
  background: var(--s1); border-radius: var(--r); border: 1px solid var(--border);
  padding: 18px; cursor: pointer; position: relative; overflow: hidden;
  transition: box-shadow var(--t2), transform var(--t2);
}
.policy-card::before {
  content: '';
  position: absolute; top: 0; left: 0; right: 0; height: 2px;
  background: var(--grad-gold); opacity: 0.75;
}
.policy-card:hover { box-shadow: var(--sh-sm), 0 0 24px rgba(255,214,10,0.08); transform: translateY(-2px); }
.policy-icon { font-size: var(--fs-4xl); margin-bottom: 10px; }
.policy-title { font-size: var(--fs-base); font-weight: 700; margin-bottom: 6px; color: var(--text); }
.policy-desc { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.5; }

/* ══════════════════════════════
   ACCESS DENIED
══════════════════════════════ */
.access-denied {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: 64px 20px; text-align: center;
  background: var(--s1); border-radius: var(--r); border: 1px solid var(--border);
}
.access-denied .ad-icon { font-size: 3.5rem; margin-bottom: 16px; opacity: 0.35; }
.access-denied h3 { font-size: var(--fs-2xl); font-weight: 700; color: var(--text); margin-bottom: 8px; }
.access-denied p { font-size: var(--fs-base); color: var(--text-muted); max-width: 280px; line-height: 1.6; }

/* ══════════════════════════════
   MODAL
══════════════════════════════ */
.modal-overlay {
  position: fixed; inset: 0;
  z-index: var(--z-modal); display: flex; align-items: center; justify-content: center;
  /* WS42 Phase 11: notch/home-indicator clearance (landscape phones, desktop unaffected
     since env() resolves to 0 there); mobile bottom-sheet breakpoint overrides this to 0. */
  padding: max(16px, env(safe-area-inset-top, 0px)) max(16px, env(safe-area-inset-right, 0px))
    max(16px, env(safe-area-inset-bottom, 0px)) max(16px, env(safe-area-inset-left, 0px));
}
.modal-overlay.active { display: flex; }
/* .modal-box + ::before merged into LIQUID GLASS copy below */
/* Roomier variants for content-heavy popups (forms, payslips, sales orders) so
   they don't render as a cramped small dialog. Applied via openModal({size}). */
/* WS42 Phase 15: cap 'wide' modals to the viewport on tablet/narrow desktop
   windows too, not just phones — a 920px modal on an 820px iPad would itself
   force horizontal scroll otherwise. */
.modal-box.modal-wide { max-width: min(920px, calc(100vw - 48px)); }
.modal-box.modal-full { max-width: min(1200px, 96vw); max-height: 94dvh; }

/* ── Full-screen page panel (v12 WS10 — openPage) ──
   Owner report: a pushed page (task detail, etc.) "feels like a popup" —
   it used to slide UP from the bottom with a fade, which reads as a modal/
   bottom-sheet presentation rather than a page navigated INTO. Native
   iOS-style push instead: slides in from the RIGHT, full width, no fade —
   the page underneath is already hidden (`.page-under{visibility:hidden}`,
   set by openPage()) so there's nothing to cross-fade against. */
.page-panel{ position:fixed; top:calc(var(--topbar-h) + env(safe-area-inset-top,0px));
  left:0; right:0; bottom:0; background:var(--bg); z-index:var(--z-page-panel); display:flex; flex-direction:column;
  /* v14.0.68 -- `transition:none` PROMOTED FROM THE <=768px BLOCK TO THE BASE
     RULE. It used to read `transform .3s cubic-bezier(.4,0,.2,1)` here and be
     cancelled only under @media (max-width:768px) (~line 2938), which left
     the ORIGINAL owner-reported defect fully live above that breakpoint: on
     desktop and on iPad LANDSCAPE (812pt > 768) a pushed window still slid
     300ms across a fully-painted topbar / nav strip / main content, i.e.
     literally "overlays of different pages". The phone tier was fixed in
     v14.0.67 and the wide tier was not; this makes them the same window.
     The entrance is a SWAP everywhere now, and the motion that sells it lives
     on the panel's BODY instead (see .page-panel .page-panel-body below).

     `transition:none`, NOT `transform:none` -- this is the footgun, restated
     here because the rule now carries it at every width. The rest state MUST
     stay translateX(100%) so a CLOSING panel leaves the screen in the same
     frame `.open` is removed. Killing the transform instead would leave the
     closed panel sitting opaque over the already-repainted shell until
     openPage's 300ms removal timeout fires, and Back would look frozen. */
  transform:translateX(100%); transition:none; overflow:hidden; }
.page-panel.open{ transform:translateX(0); }
@media (prefers-reduced-motion: reduce) { .page-panel { transition: none; } }
.page-panel-head{ display:flex; align-items:center; gap:12px;
  /* WS42 Phase 11: landscape left/right notch clearance */
  padding:12px max(16px, env(safe-area-inset-right,0px)) 12px max(16px, env(safe-area-inset-left,0px));
  background:var(--surface); border-bottom:1px solid var(--border); flex-shrink:0; }
/* WINDOW EXIT CONTROL — ONE declaration list for BOTH window types, at EVERY
   width. The v14 "one window" pass moved openModal's exit control to a
   top-left back arrow (index.html ~348: `<button class="modal-back"
   id="modal-close">`), but the styling written for it went only into
   @media (max-width: 639px). Above that breakpoint `.modal-back` matched no
   rule in this file at all and fell through to the User-Agent button sheet.
   Measured on #modal-close at 1280x800, 700x800 and 812x375 (iPhone
   LANDSCAPE), all three themes: 28 x 31.5px, background rgb(239,239,239),
   border 2px OUTSET rgb(0,0,0), color rgb(0,0,0), Arial 13.33px,
   border-radius 0 — a Windows-95 push button. In Dark and Astral that is a
   light-grey bevelled block sitting on a near-black header, and on the owner's
   iPhone it appears MID-GESTURE the moment he rotates an open modal to
   landscape (812pt > 639).
   Fixed by sharing .page-panel-back's list rather than writing a second one:
   the owner chose "back arrow, top-left" as the universal window affordance,
   and the 133 .page-panel windows already draw it exactly this way at every
   width — so a desktop modal matching them is the consistent outcome of that
   decision, not a new one. Sharing the selector also makes the two controls
   byte-identical by construction, which is the property the parity block at
   the end of this file was trying to achieve by hand-copying values.
   The 44x44 tap-target half of this lives in two other places on purpose —
   `@media (pointer: coarse)` (~line 121) and the ≤768px block (~line 2948) —
   because it is a hit-area concern, not an appearance one. */
.page-panel-back, .modal-back{ background:none; border:none; color:var(--primary-light); cursor:pointer; padding:4px; display:flex; }

/* ── WINDOW ENTRANCE -- v14.0.68 ───────────────────────────────────────────
   The problem this solves: v14.0.67 removed the 300ms slide (correctly -- it
   was what made a pushed window read as a sheet laid over a lit page) and
   removed openModal's sheetRise with it. That killed the overlap, and it also
   killed every cue that a tap had been received, so a window that takes
   200-600ms to fetch its data now shows the user NOTHING between finger-up
   and content. The reported symptom flipped from "overlapping" to "slow".

   The panel itself still does NOT animate -- `transition:none` and a rest
   state of translateX(100%), base rule ~line 2298. It SWAPS. Two screens on
   screen at once stays structurally impossible, not merely avoided. The
   motion goes on the panel's CONTENTS instead, and that is safe for one
   specific, verified reason: by the first frame the panel exists it is
   already opaque (`background:var(--bg)`) and full-bleed, and at the phone
   tier body.page-open has already dropped #topbar / #top-nav-strip /
   #bottom-nav / .main-content to visibility:hidden with a 0s delay (~line
   3057) in that same frame. So this fade reveals --bg and can never reveal
   the previous screen. That is the whole reason the entrance lives here and
   not on .page-panel.

   Deliberately NOT `.page-panel-head` as well: the title and the Back arrow
   are the acknowledgement that the tap landed, and they must be solid in the
   first frame. Only the region that is still loading fades.

   OPACITY ONLY -- NOT transform. .page-panel already carries a transform and
   is therefore the containing block for its position:fixed descendants; the
   chat reaction picker measures itself against exactly that (js/chat.js
   _positionPicker, and .chat-reaction-picker ~line 4868). A transform here
   would insert a SECOND containing block between them and shift the popover.

   A TRANSITION, not an @keyframes. An entrance keyframe with
   animation-fill-mode:both can stick at opacity:0 when the class lands while
   the tab is backgrounded and no frame is produced -- the hazard already
   flagged against `#page-content > *` (~line 1245). A transition's end state
   is class-derived, so a panel foregrounded later is always correct: no
   frames ran, the property is simply at its `.open` value. */
.page-panel .page-panel-body { opacity: 0; transition: opacity 140ms var(--ease); }
.page-panel.open .page-panel-body { opacity: 1; }
.page-panel-title{ flex:1; font-size:var(--fs-lg); font-weight:700; color:var(--text);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* SAFE-AREA DOUBLE-COUNT — the footless-panel case (recon Hazard 11, third
   site). The same correction already carried by .page-panel-foot (≤768px
   block, ~line 2809) and .messenger-input-row (~line 4639), needed here too
   because openPage() HIDES the foot outright when a page passes no footerHTML
   (`foot.classList.toggle('hidden', !footerHTML)` in js/app.js, plus
   `.page-panel-foot.hidden{display:none}` just below). On those pages — the
   worker-profile panel and friends — .page-panel-body IS the window's bottom
   edge and inherits none of the foot's fix. Under the v14 mobile window model
   that edge now ends at the TOP OF THE KEYBOARD, where the home indicator is
   not drawn, yet iOS keeps reporting a non-zero env(safe-area-inset-bottom);
   left alone the last row sits ~34px above the keys — exactly the dead band
   the foot fix exists to remove.
   Subtract --kb-h (published on <html> by window.ViewportSync, js/config.js)
   and clamp at 0. Same expression as the other two sites, not a second
   formulation:
     keyboard up   → max(0px, 34px - ~300px) = 0px  → flat 16px
     keyboard down → max(0px, 34px - 0px)    = 34px → the old
                                                      calc(16px + inset)
   Edited in place inside the shorthand rather than re-declared as a longhand,
   so the landscape left/right insets and the 16px top on this same line are
   untouched.
   NO-OP AT REST, at every width and on desktop. Two independent guards, either
   one alone sufficient: (a) with the keyboard down --kb-h is 0px, so
   max(0px, inset - 0px) is just `inset` and the whole calc collapses to the
   previous calc(16px + inset); (b) on any screen with no home indicator the
   inset is 0px, so the max() clamps to 0px no matter what --kb-h says. Desktop
   satisfies both. A --kb-h that was never published falls back to 0px via the
   var() fallback, i.e. today's behaviour is also the worst failure mode.
   Deliberately NOT width-scoped, matching .messenger-input-row (~line 4639).
   The only state where it changes anything above the 768px window tier is a
   tablet with the software keyboard UP, and there the effect is invisible: at
   those widths .page-panel still uses the base `bottom:0`, so the last
   `inset`-worth of padding sits at the physical bottom of the screen — inside
   the band the keyboard is drawing over. Nothing the user can see moves. */
.page-panel-body{ flex:1; overflow-y:auto; -webkit-overflow-scrolling:touch;
  padding:16px max(16px, env(safe-area-inset-right,0px)) calc(16px + max(0px, env(safe-area-inset-bottom,0px) - var(--kb-h, 0px))) max(16px, env(safe-area-inset-left,0px)); }
.page-panel-foot{ flex-shrink:0; border-top:1px solid var(--border);
  padding:12px max(16px, env(safe-area-inset-right,0px)) calc(12px + env(safe-area-inset-bottom,0px)) max(16px, env(safe-area-inset-left,0px));
  display:flex; gap:8px; justify-content:flex-end; background:var(--surface); }
.page-panel-foot.hidden{ display:none; }
/* Batch 1 (V14 Wave 1, page-stack primitives): a page-panel pushed under a
   newer one gets this class instead of being removed — stays in the DOM
   (scroll position + form state preserved) but invisible until it's back on
   top.
   ── v14.0.67 "one window" pass ───────────────────────────────────────────
   visibility:hidden is the FLOOR and stays at every width and on every engine
   (incl. iOS < 18): not painted, not hit-tested, out of the a11y tree and out
   of the focus order, box and layout state retained. It is also the one
   occlusion primitive @media print can still override FROM A DESCENDANT
   (`body *{visibility:hidden}` + `#page-content *{visibility:visible}`, see
   the generic print system near the end of this file), so the whole print
   path stays untouched by anything below.
   This declaration is byte-for-byte what shipped before the "one window"
   pass, and it is deliberately NOT width-scoped and deliberately carries no
   transition: at >768px the desktop slide is still on and a buried panel must
   keep behaving exactly as it always has. The phone-only additions live in
   the @media block immediately below.
   The inline `el.style.visibility` writes that used to do this job in
   js/app.js are GONE — an inline declaration outranks this rule and would
   defeat both the phone additions below and any future change here. Do not
   re-add them. */
.page-under{ visibility:hidden; }

/* Phone shell only, and SCREEN only — the "one window at a time" primitive.
   content-visibility:hidden (Safari/iOS 18.0+, Chrome 85+) is strictly
   ADDITIVE on top of the visibility floor above: it turns on layout + style +
   paint + size containment, so a buried window's entire subtree is SKIPPED —
   it stops being laid out at all. That is the point: window.ViewportSync
   (js/config.js) republishes --vvh/--vv-top on vv-resize, vv-scroll, scroll,
   resize, orientationchange and focusin/focusout, i.e. continuously while the
   soft keyboard animates, and every one of those ticks currently re-lays-out
   every buried panel in the stack even though not a pixel of them is on
   screen. With this rule the phone lays out exactly one window.

   The @supports gate is DOCUMENTATION, not mechanism: an engine that doesn't
   know content-visibility drops the declaration at parse time anyway, and the
   `visibility:hidden` above IS the fallback — iOS < 18 degrades to exactly
   today's shipped behaviour, never to something worse.

   NO contain-intrinsic-size, deliberately: .page-panel is position:fixed with
   left:0;right:0 from its base rule and, inside this same 768px tier, an
   explicit top + height (see the .page-panel override in the <=768px block
   below). Both axes are fully determined by the element's own properties, so
   size containment has nothing to collapse and an intrinsic size would be
   cargo cult.

   `screen` scoping is what keeps @media print's descendant overrides working:
   unlike visibility, content-visibility CANNOT be undone from a descendant,
   so a buried panel holding a print target would otherwise print blank.

   #chat-thread-panel is EXCLUDED. Its onSnapshot listeners are Overlay-scoped
   and keep firing while it is buried (js/chat.js), and every gate they run
   through is _isNearBottomEl — scrollHeight - scrollTop - clientHeight, all 0
   in a skipped subtree, so the `< 60` test is permanently true. That would
   write a FALSE read receipt for a thread nobody is looking at, and pin the
   reader to the top of the thread on reveal. The id is stamped on the panel
   synchronously right after openPage() returns (js/chat.js), i.e. long before
   anything can be pushed on top of it, so this :not() always matches when it
   matters. Lift the exclusion only after those reads are guarded with a
   closest('.page-under') test. */
@media screen and (max-width: 768px) {
  /* NO delayed-hide transition here, deliberately — the "one blank frame at
     stack depth >= 1" hazard that `body.page-open`'s .35s delay USED TO guard
     against (that delay is 0s as of the v14.0.67 motion pass, for the very
     reason spelled out in #2 below) does not exist on this path either, and
     three separate findings say adding a delay here would be worse than
     useless:
     1. content-visibility is NOT a transitionable property, so the subtree
        stops being rendered the instant .page-under lands no matter what the
        visibility transition says — the delay cannot cover the frame it was
        meant to cover.
     2. There is no frame to cover. With `transition:none` below, the incoming
        panel is at translateX(0) in its FIRST painted frame: openPage()
        buries the old panel and appends the new one in one task, and the rAF
        that adds `.open` runs in the next frame's rendering steps BEFORE that
        frame's paint. Both panels have identical geometry and background at
        this tier, so the screen is never uncovered. The identical argument
        is what retired body.page-open's own .35s delay in the same pass (its
        stated purpose was covering a 300ms slide that no longer exists at
        this tier — see the OCCLUSION comment further down this block).
     3. Omitting it keeps `.page-under` byte-for-byte what shipped, at every
        width, so content-visibility is the ONLY behavioural delta here and
        desktop cannot be touched by accident.
     If you ever do add one, note it must be written `.page-panel.page-under`
     (0,2,0): a bare `.page-under` loses to `.page-panel{transition:none}`
     below, which is equal specificity and later in the file. Measured. */
  @supports (content-visibility: hidden) {
    .page-under:not(#chat-thread-panel) { content-visibility: hidden; }
  }
}

/* ── Confirm / prompt dialog (v12 WS11 — confirmDialog/promptDialog) ── */
.dialog-overlay{ position:fixed; inset:0; background:rgba(0,0,0,.4);
  z-index:var(--z-dialog); display:flex; align-items:center; justify-content:center;
  /* WS42 Phase 11: notch/home-indicator clearance in all four directions */
  padding:max(20px, env(safe-area-inset-top,0px)) max(20px, env(safe-area-inset-right,0px))
    max(20px, env(safe-area-inset-bottom,0px)) max(20px, env(safe-area-inset-left,0px)); }
html.theme-dark .dialog-overlay, html.theme-astral .dialog-overlay { background: rgba(0,0,0,.6); }
/* v14 de-structuring pass: Astral's extra backdrop-filter blur on this scrim
   was removed — same flat scrim as Dark (shared rule above). */
.dialog-overlay.hidden{ display:none; }
.dialog-box{ background:var(--modal-bg); border:1px solid var(--border); border-radius:var(--r-lg);
  box-shadow:var(--sh-lg); width:100%; max-width:400px; padding:22px; animation:popIn .22s var(--spring); }
.dialog-title{ font-size:var(--fs-lg); font-weight:700; margin-bottom:8px; color:var(--text); }
.dialog-msg{ font-size:var(--fs-base); color:var(--text-muted); line-height:1.5; margin-bottom:16px; white-space:pre-wrap; }
.dialog-actions{ display:flex; gap:8px; justify-content:flex-end; }

.modal-header {
  padding: 16px 20px; border-bottom: 1px solid var(--border);
  display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
}
/* `letter-spacing: -0.2px` REMOVED. It was the last typographic difference
   between the two window titles: measured at 375x812 in all three themes,
   .page-panel-title (~line 2244) tracked `normal` while this one tracked
   -0.2px, so the SAME string, set from the same `title` argument, in the same
   window role, truncated at a DIFFERENT CHARACTER depending on which window
   type happened to render it. Aligned onto `normal` — the page window's value
   — on the same grounds as the rest of this pass: .page-panel is the
   133-call-site majority against openModal's 21, so the side that is wrong far
   more often is the side that moves. `normal` is also the local default (no
   other heading in this file tightens tracking), so this is a value already in
   use, not one invented for the occasion. */
.modal-header h3 { font-size: var(--fs-lg); font-weight: 700; color: var(--text); }

/* ── HEADER LAYOUT for the openModal window, at EVERY width ──
   index.html (~348) emits THREE header children UNCONDITIONALLY —
   [button.modal-back][h3#modal-title][div.modal-header-right, a 40px spacer] —
   an exact mirror of the markup openPage() builds for .page-panel-head
   (js/app.js ~3617). The layout that compensates for the third child was
   written only inside @media (max-width: 639px), so above that the base
   `justify-content: space-between` above kept distributing the free space
   AROUND what is now a middle child: measured at 1280x800, #modal-title's left
   edge sat 222.7px in from the header's content box (baseline: flush left at
   20px), and at 812x375 the title rendered dead centre.
   The two declarations are copied from .page-panel-head (~line 2216), not
   re-derived, so the two window types cannot drift: flex-start plus a 12px
   gap, title takes the remaining track. `min-width: 0` is what makes the
   ellipsis possible — a flex item's default `min-width: auto` refuses to
   shrink below its own text, so a long title would push the header into
   overflow instead of truncating. .page-panel-title gets that for free (it is
   a block-level flex:1 already carrying the same nowrap/hidden/ellipsis
   triple).
   SCOPED TO .modal-box DELIBERATELY. These class names are not private to this
   window: js/screens/partners.js (~970) hand-builds the New Partner Deal
   dialog as .modal-overlay > .modal (NO .modal-box) and reuses all of
   .modal-header / .modal-body / .modal-footer. That dialog still has the OLD
   two-child [title][✕] header, where space-between is correct and flex-start
   would jam the ✕ up against the title — and where `flex:1 + ellipsis` on the
   h3 would truncate a long client name that currently wraps. Every rule in
   this pass that could reach it is scoped the same way; see the SCOPE note in
   the parity block at the end of this file. */
.modal-box .modal-header { justify-content: flex-start; gap: 12px; }
.modal-box .modal-header h3 {
  flex: 1; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.modal-close {
  background: var(--s2); border: 1px solid var(--border);
  font-size: var(--fs-md); cursor: pointer; color: var(--text-muted);
  width: 26px; height: 26px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  transition: background var(--t1), color var(--t1);
}
.modal-close:hover { background: var(--s3); color: var(--text); }
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
.modal-footer {
  padding: 14px 20px; border-top: 1px solid var(--border);
  display: flex; gap: 8px; justify-content: flex-end;
  flex-shrink: 0; background: rgba(255,255,255,0.01);
}

/* ── Drawer (consolidated — Batch 2 / spec 2d: the four `.drawer{}` base-rule
   definitions that used to live at ~2213/~3623/~4417/~4608 are grouped here,
   in their original relative order, with exact-duplicate declarations
   (background/border-left, both `var(--drawer-bg)` / `1px solid var(--border)`
   in the first two) merged into one. Companion selectors — .drawer.open,
   .drawer::before, .drawer-header, .drawer-body — are unchanged and stay at
   their original locations; only the bare `.drawer` rule moved. No computed-
   style change: equal-specificity cascade only depends on relative document
   order, which is preserved. ── */
.drawer {
  position: fixed; top: 0; right: 0; bottom: 0;
  width: min(400px, 100vw);
  background: var(--drawer-bg);
  border-left: 1px solid var(--border);
  box-shadow: var(--sh-lg);
  z-index: var(--z-drawer); display: flex; flex-direction: column;
  transform: translateX(100%);
  /* v14.0.68: 340ms -> 240ms. Same reasoning as #sidebar above -- this is one
     of the two surfaces that keep a visible slide, shortened to the floor
     where the travel still reads as travel. */
  transition: transform 240ms var(--ease);
}
.drawer.open { transform: translateX(0); }
/* Phase 58: inline z-index moved out of index.html:326 (#drawer-overlay) — was a literal 190 override on .modal-overlay */
#drawer-overlay.modal-overlay { z-index: var(--z-drawer-scrim); }
/* WS42 Phase 11: right-edge notch clearance for the desktop/right-panel drawer only —
   scoped above the 639px bottom-sheet breakpoint so it never touches the mobile sheet. */
@media (min-width: 640px) {
  .drawer { padding-right: env(safe-area-inset-right, 0px); }
}
/* WS42 Phase 10 — mobile bottom sheet breakpoint (≤639px). See the
   ≤768px full-screen override directly below, which decisively wins at
   ≤639px too since both media queries match on a phone and it comes later
   in source order (unchanged from before consolidation). */
@media (max-width: 639px) {
  .drawer {
    top: auto; bottom: 0; left: 0; right: 0; width: 100%;
    height: auto; max-height: calc(100dvh - env(safe-area-inset-top, 0px) - 24px);
    border-radius: var(--r-xl) var(--r-xl) 0 0;
    border-left: none; border-top: 1px solid var(--border);
    transform: translateY(100%);
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }
}
/* Drawer — full-screen slide-up on mobile (≤768px). Decisively overrides the
   639px bottom-sheet block above (both media queries match on a phone): the
   sheet's capped max-height left a gap at the bottom where the bottom nav
   peeked through, and its flat header padding clipped the title under the
   notch. Make it a true full-screen panel instead. */
@media (max-width: 768px) {
  .drawer {
    top: 0; bottom: 0; left: 0; right: 0;
    width: 100%; height: auto; max-height: none;
    border-left: none; border-radius: 0;
    transform: translateY(100%);
    z-index: var(--z-drawer-2); /* above sidebar overlay + bottom nav */
  }
}
.drawer-header {
  padding: 12px 16px; border-bottom: 1px solid var(--border);
  padding-top: calc(12px + env(safe-area-inset-top, 0px));
  display: flex; align-items: center; justify-content: space-between;
  background: var(--surface-2);
  gap: 8px;
}
.drawer-header h3 { font-size: 0.9375rem; font-weight: 700; color: var(--text); flex: 1; text-align: center; }

.drawer-back-btn {
  display: flex; align-items: center; gap: 5px;
  background: var(--s1); border: 1px solid var(--border);
  border-radius: var(--r-sm); padding: 7px 12px;
  color: var(--blue-2); font-size: var(--fs-md); font-weight: 600;
  cursor: pointer; white-space: nowrap; flex-shrink: 0;
  transition: background var(--t1), color var(--t1), transform var(--t1);
}
.drawer-back-btn:hover { background: var(--s2); color: var(--blue); transform: translateX(-2px); }
.drawer-back-btn:active { transform: scale(0.96); }
.drawer-back-btn svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2.5; }
.drawer-body { flex: 1; overflow-y: auto; padding: 20px; -webkit-overflow-scrolling: touch; }

/* ══════════════════════════════
   MISC
══════════════════════════════ */
.loading-placeholder {
  color: var(--text-muted); font-size: var(--fs-base);
  padding: 48px 20px; text-align: center;
  animation: statePulse 1.8s ease-in-out infinite;
}
/* v14 de-structuring pass: Astral's gradient-sweep text-shimmer was removed —
   same flat statePulse fade (above) as every other theme. */

/* v13 Phase 128 — Loading skeletons. One loading language for list/table/card
   screens: token-based surface blocks pulsing via the existing statePulse
   keyframe (Phase 57) in every theme, including Astral (v14 de-structuring
   pass removed its gradient-sweep variant). JS adoption (ui-states.js /
   withLoadingAndError) is deferred; these are pure, ready-to-use CSS primitives. */
.skl-text, .skl-row, .skl-card {
  background: var(--surface-2);
  border-radius: var(--r-xs);
  animation: statePulse 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .skl-text, .skl-row, .skl-card { animation: none; opacity: .65; }
}
/* Single line of skeleton text — width is left to the caller (inline style or
   a utility class) so callers can vary line length without new CSS. */
.skl-text {
  height: 0.875em;
  width: 100%;
  border-radius: var(--r-xs);
}
.skl-text + .skl-text { margin-top: 8px; }
.skl-text.skl-text-sm { height: 0.75em; }
.skl-text.skl-text-lg { height: 1.25em; }
/* One list/table row: leading avatar-ish block + two text lines, matching the
   list-item anatomy (leading icon, two-line text, trailing meta) used across
   task feed / notif items / approval rows / file rows. */
.skl-row {
  display: flex; align-items: center; gap: 12px;
  padding: 12px 14px;
  border-radius: var(--r-sm);
  background: var(--surface);
  border: 1px solid var(--border);
  animation: none; /* row shell is static; only its inner blocks pulse */
}
.skl-row .skl-row-avatar {
  flex-shrink: 0; width: 36px; height: 36px; border-radius: 50%;
  background: var(--surface-2); animation: statePulse 1.8s ease-in-out infinite;
}
.skl-row .skl-row-lines { flex: 1; min-width: 0; }
/* A self-contained card skeleton: header line + body lines, for KPI/dept/item
   card placeholders. */
.skl-card {
  padding: 16px;
  border-radius: var(--r-lg, var(--r-sm));
  background: var(--surface);
  border: 1px solid var(--border);
  animation: none; /* card shell is static; only its inner blocks pulse */
}
.skl-card .skl-text:first-child { width: 55%; height: 1em; margin-bottom: 12px; }
/* Table-row variant (v14 B6, window.skeletonHtml('table', n)) — reuses the
   .skl-row shell as N equal-width inline columns instead of avatar+lines,
   for tabular async screens (orders, transactions, quotations, stock). */
.skl-row.skl-row-cols { gap: 20px; }
.skl-row.skl-row-cols .skl-text { flex: 1; margin-top: 0; }
.skl-row.skl-row-cols .skl-text + .skl-text { margin-top: 0; }
/* v14 de-structuring pass: Astral's sweep-gradient shimmer variant was
   removed — same flat statePulse skeleton (above) as every other theme. */

.empty-state { text-align: center; padding: 56px 20px; color: var(--text-muted); }
.empty-state .empty-icon { font-size: 3.25rem; margin-bottom: 14px; opacity: 0.30; }
.empty-state h4 { font-size: var(--fs-lg); font-weight: 700; margin-bottom: 6px; color: var(--text-2); }
.empty-state p { font-size: var(--fs-md); line-height: 1.6; }

.divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }

.progress-bar-wrap { background: rgba(255,255,255,0.06); border-radius: var(--pill); height: 6px; overflow: hidden; }
.progress-bar-fill {
  height: 100%; border-radius: var(--pill);
  background: var(--primary);
  transition: width 0.7s var(--ease);
}
/* Color-only: Astral's gradient fill instead of the flat --primary. The
   gradRun animation (motion, not color) was removed — v14 de-structuring pass. */
html.theme-astral .progress-bar-fill {
  background: var(--grad-pk-bl);
}

/* ══════════════════════════════
   MINI CALENDAR
══════════════════════════════ */
#mini-cal table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); }
#mini-cal th { padding: 5px; text-align: center; color: var(--text-muted); font-size: var(--fs-2xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
#mini-cal td { padding: 5px; text-align: center; border-radius: 6px; cursor: default; color: var(--text-2); transition: background var(--t1); }
#mini-cal td.today {
  background: var(--primary-fill, var(--primary));
  color: var(--on-primary); font-weight: 700; border-radius: 50%;
  box-shadow: var(--sh-pink);
}
/* Color-only: Astral's gradient fill instead of the flat --primary. The
   gradRun animation (motion, not color) was removed — v14 de-structuring pass. */
html.theme-astral #mini-cal td.today {
  background: var(--grad-pk-bl);
}
#mini-cal td.other-month { opacity: 0.25; }
#mini-cal td:not(.other-month):hover { background: var(--s2); }

/* ══════════════════════════════
   PROFILE DRAWER
══════════════════════════════ */
.profile-photo-wrap { display: flex; flex-direction: column; align-items: center; padding: 24px 0 16px; gap: 10px; }
.profile-photo-large {
  width: 88px; height: 88px; border-radius: 50%;
  background: var(--grad-pk-bl); color: #fff;
  display: flex; align-items: center; justify-content: center;
  font-size: 2rem; font-weight: 700; overflow: hidden; cursor: pointer;
  box-shadow: var(--sh-pink), 0 0 0 2px rgba(255,45,120,0.25);
  transition: transform var(--t2), box-shadow var(--t2);
}
.profile-photo-large img { width: 100%; height: 100%; object-fit: cover; }
.profile-photo-large:hover { transform: scale(1.05); box-shadow: 0 8px 32px rgba(255,45,120,0.45), 0 0 0 3px rgba(255,45,120,0.30); }

/* ══════════════════════════════
   RESPONSIVE — MOBILE
══════════════════════════════ */
@media (max-width: 768px) {
  /* v12 WS42 nav-consolidation — owner decision: "keep the central page-tab
     strip; merge the topbar icons into it." The standalone 56px #topbar row
     is dropped entirely on mobile; its controls (menu-toggle/nav-back-btn,
     search/notif/menu/avatar) are relocated into #tn-lead/#tn-actions inside
     #top-nav-strip by placeTopbarActions() (js/app.js). #top-nav-strip now
     sits at the very top, reclaiming ~56px of vertical space. */
  #topbar { display: none; }
  /* Facebook-style top bar: one hamburger only (left → sidebar). The second
     hamburger (#topbar-menu-btn → profile drawer) is redundant now that tapping
     the avatar opens that same drawer — hide it to declutter the top. */
  #topbar-menu-btn { display: none !important; }
  /* v14 hotfix: the drawer + top strip must OWN the notch band — anchoring
     them below env(safe-area-inset-top) left a transparent strip where page
     content visibly scrolled through (iPhone report 2026-08-03). Anchor at 0
     and pad the inset instead. */
  /* v14 hotfix 2: keep the PROVEN geometry (bar below the notch — padding-top
     +top:0 misbehaved on-device, iOS env() quirk) and cap the notch band with
     an opaque ::before extension so content can never show through above. */
  #sidebar { transform: translateX(-100%); top: env(safe-area-inset-top, 0px); }
  /* z-index 96 = above top-nav-strip (94) so user info is fully visible */
  #sidebar.open { transform: translateX(0); box-shadow: 4px 0 48px rgba(0,0,0,0.60); z-index: var(--z-shell-open); }
  .menu-toggle { display: block; }
  .top-nav-strip {
    top: env(safe-area-inset-top, 0px);
    /* opaque underlay: --topbar-bg may be translucent (glass themes) */
    background: linear-gradient(var(--topbar-bg), var(--topbar-bg)), var(--bg);
  }
  /* v14 hotfix 2: one fixed opaque cap over the notch band. The strip/sidebar
     keep the PROVEN below-the-notch geometry (top:0+padding misbehaved
     on-device — iOS env() quirk); this cap sits above shell chrome (z 100 <
     stacked surfaces at 300+, and full-bleed panels cover it) so page content
     can never scroll visibly through the status-bar band again. */
  body::after {
    content: ''; position: fixed; top: 0; left: 0; right: 0;
    height: env(safe-area-inset-top, 0px);
    background: linear-gradient(var(--topbar-bg), var(--topbar-bg)), var(--bg);
    z-index: var(--z-topbar);
    pointer-events: none;
  }
  .main-content {
    margin-left: 0;
    margin-top: calc(var(--top-nav-h) + env(safe-area-inset-top, 0px));
    /* padding/padding-bottom removed here (SEAMLESS MOBILE PASS v2
       consolidation) — this was always beaten by the "Mobile padding
       refinements" block further down (same ≤768px breakpoint, later in
       source), which is itself beaten at ≤640px by the density pass at the
       end of this file. One property, one live declaration per width tier. */
  }
  /* Facebook-style shell: clean top bar (brand + action icons) + full-width
     bottom nav for primary tabs. */
  .top-nav-strip { display: flex; }
  /* Brand wordmark fills the top strip's center zone (left-aligned). */
  .tn-tabs { justify-content: flex-start; overflow: hidden; }
  /* Owner report — "Operating System" sub-label never shows on mobile: this
     stack (name + BRAND.systemName, built by buildTopNavStrip()) is the
     mobile replacement for .topbar-wordmark-stack, which is display:none'd
     on phones along with the rest of #topbar. */
  .tn-brand-stack {
    display: flex; flex-direction: column; justify-content: center;
    line-height: 1.15; min-width: 0; overflow: hidden; padding-left: 4px;
  }
  .tn-brand {
    font-size: var(--fs-lg); font-weight: 800; letter-spacing: -0.02em;
    color: var(--text); white-space: nowrap; overflow: hidden;
    text-overflow: ellipsis;
  }
  .tn-brand-sub {
    font-size: var(--fs-2xs); font-weight: 600; letter-spacing: 0.08em;
    text-transform: uppercase; color: var(--text-muted); opacity: 0.72;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  /* Full-screen page panel (openPage) — was pinned below the old #topbar;
     now covers from the very top, same as it used to cover the old
     top-nav-strip underneath the topbar.

     v14 MOBILE WINDOW MODEL (Batch 2) — anchor the panel to the VISUAL
     viewport instead of the layout viewport. The base rule's `bottom:0`
     tracks the LAYOUT viewport, and iOS does not shrink the layout viewport
     for the soft keyboard: it keeps its size and PANS it upward. So a
     bottom-anchored panel kept its footer/composer exactly where the keyboard
     now is — the single defect this whole batch exists to fix.
     --vv-top / --vvh are published on <html> by window.ViewportSync
     (js/config.js) from visualViewport.offsetTop / .height, so top+height
     together describe precisely the strip of screen the user can actually
     see, keyboard up or down, and the panel's flex column (head / body /
     foot) lays itself out inside that strip for free.

     `bottom:auto` is MANDATORY, not tidiness: the base rule sets `bottom:0`,
     and top+bottom+height over-constrains an absolutely-positioned box — the
     UA would drop `height` and keep `bottom`, silently reverting the fix.

     Driven via top/height and deliberately NOT via transform: .page-panel's
     slide-in animation owns `transform` (`transition:transform .3s`, base
     rule above), so a translate here would fight the open/close animation and
     the two would interleave mid-slide.

     At rest --vv-top is 0px, i.e. the panel is full-bleed from the very top
     of the screen — this generalizes to every page what the chat thread panel
     already did on its own. The notch clearance it used to get from
     `top: env(safe-area-inset-top)` moves into .page-panel-head's own
     padding-top, immediately below. */
  /* AT REST the bottom edge is `bottom: 0` — NOT a measurement. Deriving it
     from the visual viewport with no keyboard presented is what left a dead
     band under the chat composer (owner-reported three times, ~60 CSS px, and
     a light-theme screenshot proved it was page-painted background, i.e. the
     panel really was short of a viewport that extended past it). Clamping
     --vvh against window.innerHeight did not fix it, because when the layout
     viewport reads short too, max() of two short numbers is still short.
     With nothing to track, "the bottom of the window" should simply be the
     bottom of the viewport — no measurement can be wrong if none is taken. */
  .page-panel {
    top: var(--vv-top, 0px);
    bottom: 0;
    height: auto;
  }
  /* Keyboard up → switch to the measured visual-viewport rect. `kb-open` is set
     by ViewportSync (js/config.js) off the SAME threshold that produces --kb-h,
     so the class and the var can never disagree. This is the geometry the whole
     visual-viewport anchoring exists for: the panel's bottom edge becomes the
     keyboard's top edge, and --vv-top counters iOS's focus-reveal pan. */
  html.kb-open .page-panel {
    height: var(--vvh, 100%);
    bottom: auto;
  }
  /* ONE WINDOW AT A TIME — on the phone the entrance is a SWAP, not a slide.
     The 300ms translateX travel is precisely what makes a pushed page read as
     a sheet laid OVER a lit page rather than as the next window: the shell
     behind it stays fully painted for the whole slide (the .35s visibility
     delay body.page-open used to carry below existed specifically to keep it
     painted — it is 0s now, dropped in this same pass because the slide it
     covered is gone), only ONE layer moves, and there is no dim, no
     counter-parallax and no
     leading-edge shadow to sell the depth. Owner's report, verbatim: pages
     "overlapped each other", they should be "a different window, no overlaps".
     This is byte-for-byte what the prefers-reduced-motion branch already does
     (~line 2195), and the same fix already shipped for openModal at <=639px
     after the 2026-08-03 "popup floating in front of the previous page"
     report. Consistency with an approved fix, not a new motion language.

     `transition:none`, NOT `transform:none` — this is the footgun. The rest
     state MUST stay translateX(100%) so a CLOSING panel leaves the screen in
     the same frame .open is removed. Killing the transform instead would
     leave the closed panel sitting opaque over the already-repainted shell
     until openPage's 300ms removal timeout fires, and Back would look frozen.

     v14.0.68 UPDATE -- "Desktop (>768px) is untouched and keeps the slide"
     was the bug: it meant the exact defect this comment describes was still
     shipping at every width above 768px, including the owner's iPad in
     landscape. `transition: none` now lives in the BASE .page-panel rule
     (~line 2298) and applies at every width, so the declaration that used to
     sit here is gone as a duplicate. The reasoning above is unchanged and is
     kept HERE because this is where it was written and where it is searched
     for; only its scope widened. */
  /* Notch clearance for the window's own header row, now that the window
     itself starts at y=0 instead of being inset by the safe area. Longhand so
     the landscape left/right insets already in the base rule's `padding`
     shorthand (see .page-panel-head above) survive untouched — this
     re-declares the top edge only. */
  .page-panel-head { padding-top: calc(12px + env(safe-area-inset-top, 0px)); }
  /* SAFE-AREA DOUBLE-COUNT (recon Hazard 11). The foot pads
     `calc(12px + env(safe-area-inset-bottom))` to clear the home indicator.
     That was right while the panel ended at the bottom of the SCREEN; now it
     ends at the TOP EDGE OF THE KEYBOARD, where the home indicator is not
     drawn at all — yet iOS does NOT report env(safe-area-inset-bottom) as 0
     while the keyboard is up. Left alone it opens a ~34px dead gap between
     the footer buttons and the keys on every one of the ~108 footer-bearing
     pages.
     Subtract the keyboard height from the inset and clamp the result at 0:
       keyboard up   (--kb-h ≈ 300px) → max(0px, 34px - 300px) = 0px → flat 12px
       keyboard down (--kb-h: 0px)    → max(0px, 34px - 0px)   = 34px → the
                                        old calc(12px + inset), unchanged.
     If --kb-h were never published at all the var() fallback of 0px yields
     that same old value, so the worst failure mode is today's behaviour. */
  .page-panel-foot {
    padding-bottom: calc(12px + max(0px, env(safe-area-inset-bottom, 0px) - var(--kb-h, 0px)));
  }
  /* ── OCCLUSION. While a window is open on the phone tier the shell behind it
     must not paint. The window is opaque and covers the viewport, but
     #topbar / #top-nav-strip / #bottom-nav are position:fixed siblings on
     <body> with their own stacking contexts — on iOS they can still bleed at
     the notch/home-indicator bands and, worse, stay tappable underneath.
     body.page-open is owned SOLELY by Overlay._sync() (js/config.js); feature
     code never adds or removes it, so it cannot desynchronize from the scroll
     lock (the lock's kind list is a superset of the cover list).
     `visibility` and not `display`: display:none on #topbar would collapse
     .main-content's margin-top and shift/scroll the page underneath, so
     closing the window would land the user somewhere else. visibility keeps
     the layout frozen exactly as it was.
     THE .35s HIDE DELAY IS GONE (v14.0.67 motion pass). The comment that used
     to sit here justified it as load-bearing — "the window's entrance is a
     300ms transform slide, so the shell has to stay painted for the whole
     slide, otherwise the user watches a half-slid panel over a blank screen".
     That slide does not exist at this tier any more: `.page-panel {
     transition: none; }` is ~40 lines above, inside this same @media block,
     and openModal's sheetRise was deleted in the same pass (~line 5288).
     There is no longer anything for the shell to stay painted FOR, so the
     delay is now `0s` — the shell stops painting in the same frame the window
     appears.

     VERIFIED there is no frame where the shell is hidden but the window is
     not yet painted (that would flash blank):
       • openPage() (js/app.js) appends the panel, schedules
         `requestAnimationFrame(() => p.classList.add('open'))`, and calls
         Overlay.push() — whose _sync() applies body.page-open — all inside ONE
         task. rAF callbacks run in the next frame's rendering steps BEFORE
         that frame's paint, so the first frame that has the shell hidden is
         the same frame that has the panel at translateX(0) (instant, no
         transition), opaque `background:var(--bg)`, spanning
         top:var(--vv-top) → +var(--vvh). This is finding #2 of the .page-under
         block's own comment (~line 2314), re-verified for this path.
       • openModal() (js/app.js) has the same shape: `.hidden` off / `.active`
         on, then Overlay.push() in the same task; at ≤639px the overlay is
         already opaque `background: var(--bg)` across inset:0.
       • Nothing else can turn page-open on. It is a pure function of
         Overlay._stack (js/config.js _sync), and 'modal' is a cover kind only
         below 640px (_COVER_KINDS getter) — in the 640-768px band a modal is a
         translucent-scrim bottom sheet and page-open never applies, so this
         rule cannot blank the strip above one.

     WHY NOT just leave a harmless 350ms: it is harmless only while every
     window is BOTH opaque and full-bleed. It is invisible today purely by
     coincidence of those two facts. The moment one stops holding — an Astral
     panel background, a non-zero --vv-top band with the keyboard up, any
     inset or translucent window — the shell paints through the window for
     350ms and the "overlays of different pages" defect returns, in the one
     place nobody would look for it. Removing it makes that regression
     impossible instead of latent, and costs nothing measurable now.

     The `transition` shorthand is KEPT (as `visibility 0s linear 0s`) rather
     than deleted: the shorthand also resets any other transition on these four
     elements while a window is open, which is today's shipped behaviour, and
     it pins visibility to instant even if some later rule ever gives #topbar a
     transition that would otherwise catch it.

     On close the elements stop matching this rule entirely, so there is no
     transition to run and the shell repaints in the same frame — unchanged by
     any of the above.

     `.page-under` (the buried-window primitive, ~line 2264) has NO matching
     delay to remove: it is a bare `visibility:hidden` with no transition at
     all, and the ≤768px block that adds content-visibility to it documents at
     length why it never got one.

     v14.0.68 — WHY THIS BLOCK STAYS INSIDE @media (max-width: 768px) even
     though `.page-panel { transition: none }` was promoted OUT of it in the
     same pass. The two are not symmetric, and MEASURED geometry is the
     reason:
       • ≤768px the panel is full-bleed — the block above re-declares
         `top: var(--vv-top, 0px); height: var(--vvh, 100%)`, so it covers the
         topbar and the nav strip. Hiding them changes nothing visible and
         only stops them bleeding at the notch band and staying tappable.
       • >768px the panel is NOT full-bleed. The base rule (~line 2298) starts
         it at `calc(var(--topbar-h) + env(safe-area-inset-top))` — measured
         56px at 1280x800 — and #topbar (top:0, height 56px) plus the desktop
         #top-nav-strip (which ≥1024px is re-anchored to `top:0; height:
         var(--topbar-h)`, ~line 5891) sit ABOVE that edge, fully exposed.
         Hiding them there would not occlude anything; it would replace the
         desktop chrome with a blank 56px band of --bg for as long as any
         window is open, and take the app's only visible nav with it.
     The defect this block guards against — shell painting THROUGH a window —
     is a property of the 300ms slide, not of the shell being visible. With
     the slide gone at every width there is no frame in which a partially
     placed panel exposes what is behind it, so >768px needs no occlusion: the
     window swaps in below a persistent topbar, which is the desktop window
     model this app already ships. Unifying the MOTION was the fix; unifying
     the occlusion would have been a layout regression wearing the same name.
     If a future change makes the desktop panel full-bleed, move this block
     out to the base cascade at the same time — not before. */
  body.page-open #topbar,
  body.page-open #top-nav-strip,
  body.page-open #bottom-nav,
  body.page-open .main-content {
    visibility: hidden;
    transition: visibility 0s linear 0s;
  }
  /* Primary nav bottom bar — shown on mobile, hidden on desktop. */
  .bottom-nav { display: flex !important; }
  /* Show the small tab labels (icon + label, like Facebook) — v14 de-structuring
     pass: Astral no longer gets a separate icon-only pill, so this now applies
     to every theme identically (the :not(.theme-astral) scoping was removed). */
  .bottom-nav-item .bn-label {
    display: block; font-size: 0.5625rem; font-weight: 600;
    margin-top: 2px; line-height: 1; letter-spacing: -0.01em;
    max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .bottom-nav-item .bn-icon { font-size: var(--fs-xl); }
  .bottom-nav-item .bn-icon svg { width: 22px; height: 22px; }
  .dashboard-grid { grid-template-columns: 1fr; }
  .kpi-row { grid-template-columns: repeat(2, 1fr); gap: 10px; }
  .form-row { grid-template-columns: 1fr; }
  /* Bell navigates to page on mobile — panel/backdrop never show */
  .notif-panel, .notif-backdrop { display: none !important; }
  .modal-box { max-height: 94dvh; border-radius: var(--r-lg) var(--r-lg) 0 0; align-self: flex-end; margin-bottom: 0; margin-top: auto; }
  .page-header h2 { font-size: 1.1875rem; }
  /* 44px minimum tap targets — v13 Phase 123: .btn-outline/.btn-success added
     to join the rescue (were missing; only primary/secondary/danger had it). */
  .btn-primary, .btn-secondary, .btn-danger, .btn-outline, .btn-success { min-height: 44px; }
  /* bottom-nav-item tap target provided by pill container height */
  .notif-btn, .theme-toggle-btn { min-width: 44px; min-height: 44px; }
  /* .modal-back joins .modal-close here because index.html's #modal-close
     button DROPPED the .modal-close class when it became a back arrow (it
     keeps only the id, which is what every handler binds to), which silently
     took this floor off the modal's only exit control. Belt-and-braces beside
     the real 10px/-6px geometry below: 10+24+10 satisfies this exactly, so it
     adds nothing while the Lucide glyph renders, and it catches the case where
     the glyph does not inject (a 20px-wide button). The negative margin below
     keeps the OUTER footprint at 32px either way, so growing the minimum here
     can never shove the title. */
  .modal-close, .modal-back { min-width: 44px; min-height: 44px; }
  /* The window's BACK ARROW was the one control this 44px floor never covered.
     Measured at 375x812: .page-panel-back is 32x32 (`padding:4px` around a 24px
     Lucide arrow-left, base rule ~line 2243) — the primary exit control of all
     133 openPage windows, and the smallest tap target in the header row, 12px
     under the minimum the three buttons above already meet.
     GROW THE HIT AREA, NOT THE GLYPH. A bare `min-width/min-height: 44px` (the
     technique used one line up) would work for .modal-close because that one is
     a centered circle in its own corner, but here it would widen the flex item
     from 32px to 44px and shove .page-panel-title 12px to the right — the title
     would visibly jump between a page window and any header that doesn't have a
     back arrow. So: pad out by 6px on every side (4px → 10px, giving exactly
     10+24+10 = 44) and pull the same 6px back off with a negative margin, which
     leaves the OUTER box 32x32 for layout purposes and the arrow itself on the
     exact pixel it occupied before.
       icon left edge  = head padding 16 + margin -6 + padding 10 = 20px (was 16+4)
       title left edge = (16-6) + 44 - 6 + gap 12 = 60px (was 16+32+12)
       head height     = 12 + (44-12) + 12 + 1px border = 57px (unchanged)
     — all three verified equal to the pre-change measurement.
     This is the same intent as the `@media (pointer: coarse)` invisible-::after
     overlay near the top of this file (~line 121), done with real box geometry
     instead, so the 44x44 shows up in getBoundingClientRect() and in the
     accessibility/hit-test tree rather than only in a pseudo-element. Both
     back arrows now take it from ONE selector list, so the two windows' exit
     controls are byte-identical by construction. That matters because
     .modal-back used to be floored only inside the ≤639px parity block at the
     end of this file, which left it measuring 28 x 31.5 on an iPad (768x900)
     and on a phone in landscape (812x375). Above 768px the `pointer: coarse`
     ::after overlay (~line 121), which now names both selectors too, is what
     holds the floor for touch hardware. */
  .page-panel-back, .modal-back { padding: 10px; margin: -6px; }
  /* Roomier subtab chips for finger taps */
  .subtab-btn { min-height: 40px; padding-top: 9px; padding-bottom: 9px; }
  /* Table scroll on mobile */
  .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
  /* Modal body scroll */
  .modal-body { overflow-y: auto; -webkit-overflow-scrolling: touch; }
  /* Card body safe scroll */
  .card-body { overflow-anchor: none; }
  /* Alert banners full width */
  .alert-banner { font-size: var(--fs-sm); padding: 10px 14px; }
  /* Task feed compact. (padding: 10px 14px used to live here too, but a
     later UNCONDITIONAL .task-feed-item rule — "inset separators", ~line
     5023 — always won regardless of this media query, so it never actually
     applied at any width. Removed at the source; the ≤640px density pass
     at the end of this file adds a real, winning override instead.) */
  .task-feed-title { font-size: var(--fs-sm); }
}

/* ══════════════════════════════
   WS42 PHASE 15 — TABLET TIER
   • 769–819px (portrait small/older tablets): same "no sidebar, top-nav-strip
     instead" treatment as phones — the canvas is just wider.
   • 820–1023px (landscape tablets / iPad Air-class): a 72px icon-only rail
     replaces the full sidebar (nav-label hidden, title="" gives a native
     hover tooltip); the top-nav-strip stays hidden here (the rail is nav).
   • ≥1024px is untouched — existing full-sidebar desktop behavior above.
══════════════════════════════ */
@media (min-width: 769px) and (max-width: 819px) {
  #sidebar { transform: translateX(-100%); }
  .top-nav-strip { display: flex; }
  /* Primary tabs moved to the bottom bar (same as phones) — show it here too,
     otherwise this tier has no primary nav. The #topbar stays visible on this
     tier, so tn-tabs just holds the brand wordmark. */
  .bottom-nav { display: flex !important; }
  .bottom-nav-item .bn-label {
    display: block; font-size: var(--fs-xs); font-weight: 600; margin-top: 2px; line-height: 1;
  }
  .main-content {
    margin-left: 0;
    margin-top: calc(var(--topbar-h) + var(--top-nav-h) + env(safe-area-inset-top, 0px));
    padding-bottom: calc(var(--bottom-nav-h) + 24px + env(safe-area-inset-bottom, 0px));
  }
  .kpi-row { grid-template-columns: repeat(2, 1fr); }
  .dashboard-grid { grid-template-columns: 1fr; }
}
@media (min-width: 820px) and (max-width: 1023px) {
  #sidebar { width: 72px; }
  #sidebar .sidebar-user-info, #sidebar .nav-section-label, .nav-label { display: none; }
  #sidebar .sidebar-top { padding: 16px 8px; }
  #sidebar .sidebar-user { justify-content: center; }
  .nav-item { justify-content: center; padding: 10px 0; gap: 0; }
  .main-content { margin-left: 72px; }
  .kpi-row { grid-template-columns: repeat(2, 1fr); }
  .dashboard-grid { grid-template-columns: 1fr; }
}

@media (max-width: 420px) {
  .login-container { padding: 24px 16px 20px; }
  /* .kpi-row grid-cols/gap, .kpi-card padding, .page-header h2 font-size and
     .card-header h3 font-size used to live here too, but every one of them
     was always beaten by later same-breakpoint-or-narrower rules (the ≤640px
     density pass at the end of this file) — dead weight, removed at the
     source (SEAMLESS MOBILE PASS v2 consolidation). */
  .page-header { flex-wrap: wrap; gap: 8px; }
  .login-role-cards { gap: 8px; }
  .login-role-card { padding: 12px 14px; }
  .lrc-icon { width: 36px; height: 36px; }
  .lrc-icon svg { width: 18px; height: 18px; }
}

/* Android-specific font rendering */
@media (max-width: 768px) {
  body {
    -webkit-text-size-adjust: 100%;
    text-rendering: optimizeLegibility;
  }
}

/* ── iOS / Android: prevent auto-zoom on input focus ──
   iOS Safari zooms in when any input font-size < 16px.
   Force 16px on all touch devices.                    */
@media (hover: none) and (pointer: coarse) {
  input, textarea, select,
  .form-group input, .form-group textarea, .form-group select {
    font-size: var(--fs-lg) !important;
    transform: translateZ(0); /* GPU layer prevents reflow zoom */
  }
}

/* ══════════════════════════════
   SCROLLBAR
══════════════════════════════ */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.22); }

/* ══════════════════════════════════════════════════
   WS42 Phase 27 hygiene: the dead theme-midnight/theme-office/theme-pink/
   theme-grey token blocks that lived in this file (and the html.light,
   theme-pink, theme-grey compound selectors scattered through every
   component section) have been removed. initTheme() (js/app.js) migrates
   every stored legacy theme name to light/dark once on load, and setTheme()
   still strips all six legacy classes on every theme switch for old
   sessions/links — those class names are kept in that cleanup list
   deliberately even though no CSS targets them anymore.
══════════════════════════════════════════════════ */

/* ══════════════════════════════════════════════════
   LIGHT THEME — WS42 Phase 2 — "Fluent × Drive minimal"
   Pure token override. Flat, airy, zero glass/blur/gradient on surfaces.
   New default theme (js/app.js initTheme()).
══════════════════════════════════════════════════ */
html.light {
  --bg:              #F7F8FA;
  --bg-2:            #EFF1F4;
  --surface:         #FFFFFF;
  --surface-2:       #F2F4F7;
  --surface-3:       #E8EBEF;
  --border:          rgba(16,24,40,0.10);
  --border-strong:   rgba(16,24,40,0.18);
  --text:            #1C1E21;
  --text-2:          rgba(28,30,33,0.72);
  --text-muted:      rgba(28,30,33,0.48);
  --text-muted-strong: rgba(28,30,33,0.62); /* Phase 187: badge-gray text, AA 4.5:1 min */
  --primary:         #0866FF;
  --primary-soft:    rgba(8,102,255,0.10);
  --on-primary:      #FFFFFF;
  --success:         #1B873F;
  --success-soft:    rgba(27,135,63,0.12);
  --warning:         #B54708;
  --warning-soft:    rgba(181,71,8,0.12);
  --danger:          #D92D20;
  --danger-soft:     rgba(217,45,32,0.12);
  --info:            #0866FF;
  --info-soft:       rgba(8,102,255,0.10);
  --sh-sm:           0 1px 2px rgba(16,24,40,0.06);
  --sh:              0 2px 8px rgba(16,24,40,0.08);
  --sh-lg:           0 12px 32px rgba(16,24,40,0.14);
  --r-sm:            10px;
  --r:               14px;
  --r-lg:            18px;
  --r-xl:            24px;
  --pill:            999px;
  --topbar-bg:       #FFFFFF;
  --sidebar-bg:      #F7F8FA;
  --bottom-nav-bg:   rgba(255,255,255,0.97);
  --modal-bg:        #FFFFFF;
  --drawer-bg:       #FFFFFF;
  --notif-bg:        #FFFFFF;
  --login-card-bg:   #FFFFFF;
  --chat-bg:         #FFFFFF;
  --bubble-in-bg:    #F0F2F5;
  --bubble-in-text:  #1C1E21;
  --bubble-out-bg:   linear-gradient(135deg,#0866FF,#0A7CFF);
  --bubble-out-text: #FFFFFF;
  --theme-color:     #FFFFFF;

  /* Legacy aliases some components still read directly (Phase 9/28 retire these) */
  --black:      var(--bg);      --black-2: var(--surface);   --black-3: var(--bg-2);
  --s0:         var(--bg-2);    --s1: var(--surface);        --s2: var(--surface-2); --s3: var(--surface-3);
  --surface2:   var(--surface-2);
  --glass:      var(--surface); --glass-lt: var(--surface-2);
  --grad-card:  none;
  --primary-light: #3B82F6;
  --accent:        var(--primary); --accent-light: #3B82F6;
  --text-light: rgba(28,30,33,0.32);
  --text-inv:   #FFFFFF;
  --border-glow: var(--primary-soft); --border-gold: rgba(181,71,8,0.16);
  --sh-xs: var(--sh-sm); --sh-md: var(--sh); --sh-pink: var(--sh); --sh-blue: var(--sh); --sh-gold: var(--sh);
  --toast-success: #16A34A; --toast-error: #DC2626; --toast-info: #2563EB; --toast-text: var(--text);
}
html.light body { background: var(--bg); }

/* ══════════════════════════════════════════════════
   DARK THEME — WS42 Phase 3 — "Messenger dark minimal"
   Neutral dark, NOT the old glass/cosmic look. Solid surfaces, subtle
   borders, no glow shadows, no mesh background.
══════════════════════════════════════════════════ */
html.theme-dark {
  --bg:              #0F1114;
  --bg-2:            #0A0C0E;
  --surface:         #1A1D21;
  --surface-2:       #24282D;
  --surface-3:       #2E333A;
  --border:          rgba(255,255,255,0.09);
  --border-strong:   rgba(255,255,255,0.16);
  --text:            #E4E6EB;
  --text-2:          rgba(228,230,235,0.72);
  --text-muted:      rgba(228,230,235,0.45);
  --text-muted-strong: rgba(228,230,235,0.55); /* Phase 187: badge-gray text, AA 4.5:1 min */
  --primary:         #4599FF;
  --primary-fill:    #0866FF;
  --primary-soft:    rgba(69,153,255,0.14);
  --on-primary:      #FFFFFF;
  --success:         #31A24C;
  --success-soft:    rgba(49,162,76,0.16);
  --warning:         #F7B928;
  --warning-soft:    rgba(247,185,40,0.16);
  --danger:          #F0284A;
  --danger-soft:     rgba(240,40,74,0.16);
  /* v14 a11y fix: raw --danger only clears ~4.1:1 with white count-badge
     text (below 4.5:1 AA) — darkened, same-family red for badge use only. */
  --danger-badge-bg: #D91F42;
  --info:            #4599FF;
  --info-soft:       rgba(69,153,255,0.14);
  --sh-sm:           0 1px 3px rgba(0,0,0,0.30);
  --sh:              0 2px 10px rgba(0,0,0,0.36);
  --sh-lg:           0 12px 32px rgba(0,0,0,0.48);
  --r-sm:            10px;
  --r:               14px;
  --r-lg:            18px;
  --r-xl:            24px;
  --pill:            999px;
  --topbar-bg:       #16181B;
  --sidebar-bg:      #121417;
  --bottom-nav-bg:   rgba(22,24,27,0.97);
  --modal-bg:        #1A1D21;
  --drawer-bg:       #16181B;
  --notif-bg:        #1A1D21;
  --login-card-bg:   #1A1D21;
  --chat-bg:         #0F1114;
  --bubble-in-bg:    #2E333A;
  --bubble-in-text:  #E4E6EB;
  --bubble-out-bg:   linear-gradient(135deg,#0866FF,#0A7CFF);
  --bubble-out-text: #FFFFFF;
  --theme-color:     #16181B;

  /* Legacy aliases (see html.light note above) */
  --black:      var(--bg);      --black-2: var(--surface);   --black-3: var(--bg-2);
  --s0:         var(--bg-2);    --s1: var(--surface);        --s2: var(--surface-2); --s3: var(--surface-3);
  --surface2:   var(--surface-2);
  --glass:      var(--surface); --glass-lt: var(--surface-2);
  --grad-card:  none;
  --primary-light: #7CB2FF;
  --accent:        var(--primary); --accent-light: #7CB2FF;
  --text-light: rgba(228,230,235,0.30);
  --text-inv:   #0F1114;
  --border-glow: var(--primary-soft); --border-gold: rgba(247,185,40,0.16);
  --sh-xs: var(--sh-sm); --sh-md: var(--sh); --sh-pink: var(--sh); --sh-blue: var(--sh); --sh-gold: var(--sh);
  --toast-success: #31A24C; --toast-error: #F0284A; --toast-info: #4599FF; --toast-text: var(--text);
}
html.theme-dark body { background: var(--bg); }

/* ══════════════════════════════════════════════════
   ASTRAL THEME — WS42 Phase 4, de-structured v14
   A pure recolor of Dark: same layout/shape/spacing/blur(none)/motion(none)
   as Dark everywhere in the app — only this palette (deep-space canvas,
   lavender primary, brand-pink/blue/gold accents) differs. The former
   mesh/aurora drift, CSS starfield, and glass/blur chrome (backdrop-filter
   scoped to topbar/modal/bottom-nav) were removed in the v14 "Astral should
   just be about colors, not styles" pass — see the removed-rule comments
   throughout this file for what used to live here.
   :root already carries these values as the astral-ish fallback; this
   block restates them explicitly so every session has one true theme.
══════════════════════════════════════════════════ */
html.theme-astral {
  --bg:       #070710;
  --bg-2:     #0D0D1A;
  --surface:  rgba(255,255,255,0.05);
  --surface-2:rgba(255,255,255,0.08);
  --surface-3:rgba(255,255,255,0.11);
  --border:        rgba(255,255,255,0.09);
  --border-strong: rgba(255,255,255,0.16);
  --text:       #F0F0FA;
  --text-2:     rgba(240,240,250,0.80);
  --text-muted: rgba(240,240,250,0.45);
  --text-muted-strong: rgba(240,240,250,0.50); /* Phase 187: badge-gray text, AA 4.5:1 min */
  --primary:      #9BA8FF;
  --primary-soft: rgba(155,168,255,0.14);
  --on-primary:   #FFFFFF;
  --grad-pk-bl-btn: linear-gradient(135deg, #DF2769 0%, #0975E2 100%); /* Phase 187: darkened button-only variant so white --on-primary text clears AA 4.5:1 (decorative --grad-pk-bl left untouched) */
  --success: #30D158; --success-soft: rgba(48,209,88,0.16);
  --warning: #FFAA00; --warning-soft: rgba(255,170,0,0.16);
  --danger:  #FF453A; --danger-soft:  rgba(255,69,58,0.16);
  /* v14 a11y fix: raw --danger only clears ~3.4:1 with white count-badge
     text (below 4.5:1 AA) — darkened, same-family red for badge use only. */
  --danger-badge-bg: #C42B22;
  --info:    #0A84FF; --info-soft:    rgba(10,132,255,0.16);
  --topbar-bg:     rgba(7,7,16,0.88);
  --sidebar-bg:    rgba(7,7,16,0.94);
  --bottom-nav-bg: rgba(7,7,16,0.90);
  --modal-bg:      rgba(13,13,26,0.92);
  --drawer-bg:     rgba(10,10,20,0.94);
  --notif-bg:      rgba(13,13,26,0.92);
  --login-card-bg: rgba(13,13,26,0.88);
  --chat-bg:         var(--bg);
  --bubble-in-bg:    var(--surface-2);
  --bubble-in-text:  var(--text);
  --bubble-out-bg:   linear-gradient(135deg,#FF2D78,#0A84FF);
  --bubble-out-text: #FFFFFF;
  --theme-color:     #070710;
  /* v14 de-structuring pass: alias the glow-colored shadow tokens to the
     plain --sh (same fix Light/Dark already apply in their own blocks above)
     so Astral never gets an ambient pink/blue/gold glow that Dark doesn't
     have — e.g. topbar-avatar/sidebar-avatar hover, notif-btn hover,
     profile-photo-large, mini-cal today. Color-only palette otherwise. */
  --sh-xs: var(--sh-sm); --sh-md: var(--sh); --sh-pink: var(--sh); --sh-blue: var(--sh); --sh-gold: var(--sh);
}
/* Color-only: Astral's gradient canvas instead of Dark's flat --bg. No
   decorative overlay layers, no animation — see below. */
html.theme-astral body { background: linear-gradient(150deg, #070710 0%, #0D0D2B 35%, #120820 65%, #070710 100%); }
/* v14 de-structuring pass ("Astral should just be about colors, not
   styles" — owner directive): the mesh/aurora drift body::before/::after
   overlays, the #app-shell/#login-screen CSS starfield layers, and their
   reduced-motion guard were all removed. Astral had these decorative,
   infinitely-animated pseudo-element overlays and Dark did not — Astral now
   renders with zero extra overlays and zero animation, exactly like Dark;
   only the palette above (and the plain gradient body background) differs. */

/* topbar — border/bg/rainbow-trim now read tokens directly (var(--topbar-bg)/var(--border));
   topbar-brand keeps its own gradient identity (not a plain token). */

/* sidebar — border/nav-item/nav-item.active/nav-item:hover now token-driven
   (var(--border)/var(--primary-soft)/var(--surface-2)); sidebar-user-dept keeps its gradient. */
html.light .sidebar-user-dept {
  background: linear-gradient(90deg, #8B6000, #C47A00);
  -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
html.light .nav-section-label { color: rgba(29,29,31,0.35); }
html.light .btn-logout-inline {
  color: #c9282d;
  background: rgba(255,69,58,0.06);
  border-color: rgba(255,69,58,0.16);
}
html.light .btn-logout-inline:hover { background: rgba(255,69,58,0.12); }
html.light .sidebar-overlay { background: rgba(0,0,0,0.30); }

/* bottom nav / notif panel / modal / drawer / tables — all retired here: base rules now
   read var(--bottom-nav-bg)/var(--primary)/var(--notif-bg)/var(--modal-bg)/var(--drawer-bg)/
   var(--border)/var(--surface-2)/var(--bg-2) directly, no per-theme patch needed. */

/* badges */
html.light .badge-gray { background: rgba(0,0,0,0.06); border-color: rgba(0,0,0,0.10); color: var(--text-muted-strong); }
html.light .badge-blue { color: #0055CC; }
html.light .badge-green { color: #1A8838; }
html.light .badge-orange { color: #BA5D00; }
html.light .badge-red { color: #C42B22; }
html.light .badge-purple { color: #5C34B0; }
html.light .badge-teal   { color: #00695C; } /* v14 a11y fix: badge-teal was skipped in the Phase 187 AA pass — raw #00DDB8 on white is ~1.7:1 */

/* scrollbar */
html.light ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.14); }
html.light ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.24); }

/* login role cards / back-btn / type-pill — WS42 Phase 25: token-driven base
   rules (var(--text)/var(--surface)/var(--text-muted)) already read correctly
   in Light; these hardcoded overrides are superseded. */

/* alert banners */
html.light .alert-danger { background: rgba(255,69,58,0.08); color: #C42B22; }
html.light .alert-warn { background: rgba(255,170,0,0.08); color: #C06000; }

/* task feed */
html.light .task-feed-item { border-bottom-color: rgba(0,0,0,0.05); }
html.light .task-feed-item:hover { background: rgba(0,0,0,0.04); }
html.light .task-feed-item.task-overdue { background: rgba(255,69,58,0.04); }
html.light .task-feed-title { color: #1D1D1F; }
html.light .task-feed-meta { color: rgba(29,29,31,0.50); }

/* quick actions */
html.light .quick-action-btn { color: rgba(29,29,31,0.70); }
@media (hover:hover) { html.light .quick-action-btn:hover { background: rgba(0,0,0,0.06); color: #1D1D1F; } }

/* payslip */
html.light .payslip-row { border-bottom-color: rgba(0,0,0,0.06); color: rgba(29,29,31,0.70); }

/* kpi bar */
html.light .kpi-bar-track { background: rgba(0,0,0,0.08); }

/* help guide */
html.light .help-hero { background: #fff; border-color: rgba(0,0,0,0.08); }
html.light .help-section { background: #fff; border-color: rgba(0,0,0,0.08); }
html.light .help-section h3 { color: #1D1D1F; }
html.light .help-steps li { color: rgba(29,29,31,0.75); }
html.light .help-list  li { color: rgba(29,29,31,0.75); }
html.light .help-list  li::before { color: rgba(29,29,31,0.35); }
html.light .help-tip { background: rgba(255,214,10,0.08); border-color: rgba(255,214,10,0.25); color: rgba(29,29,31,0.70); }
html.light .help-section p { color: rgba(29,29,31,0.75); }
html.light .help-section code { background: rgba(0,0,0,0.06); }

/* ══════════════════════════════════════════════════
   THEME PICKER — WS42 Phase 4 (Light / Dark / Astral preview cards + Auto)
══════════════════════════════════════════════════ */
.theme-picker {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
}
@media (max-width: 420px) {
  .theme-picker { grid-template-columns: repeat(2, 1fr); }
}
.theme-card {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  background: var(--surface-2); border: 1.5px solid var(--border);
  border-radius: var(--r-sm); color: var(--text-muted);
  padding: 8px; font-size: var(--fs-xs); font-weight: 600;
  cursor: pointer; transition: background var(--t1), color var(--t1), border-color var(--t1), box-shadow var(--t1);
}
.theme-card:hover { background: var(--surface-3); color: var(--text); }
.theme-card.active { border-color: var(--primary); color: var(--text); box-shadow: 0 0 0 1px var(--primary); }
.theme-card-mock {
  width: 100%; height: 40px; border-radius: calc(var(--r-sm) - 3px);
  position: relative; overflow: hidden;
  box-shadow: inset 0 0 0 1px rgba(0,0,0,0.10);
}
.theme-card-mock-card {
  position: absolute; left: 5px; bottom: 5px; right: 5px; height: 18px;
  border-radius: 5px; border: 1px solid transparent;
}
.theme-card-mock-dot {
  position: absolute; top: 5px; right: 5px;
  width: 8px; height: 8px; border-radius: 50%;
}
.theme-card-mock-auto {
  background: linear-gradient(90deg, #F7F8FA 0%, #F7F8FA 50%, #0F1114 50%, #0F1114 100%);
}
.theme-card-mock-auto .theme-card-mock-card {
  background: linear-gradient(90deg, #FFFFFF 0%, #FFFFFF 50%, #1A1D21 50%, #1A1D21 100%);
}
.theme-card-mock-auto .theme-card-mock-dot { background: var(--primary); }
.theme-card-label { display: flex; align-items: center; gap: 4px; }
.theme-card-label i { width: 13px; height: 13px; }

/* ══════════════════════════════════════════════════
   THEME TOGGLE BUTTON
══════════════════════════════════════════════════ */
.theme-toggle-btn {
  background: var(--s1); border: 1px solid var(--border);
  border-radius: var(--r-xs); color: var(--text-muted);
  padding: 7px 8px; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: background var(--t1), color var(--t1), box-shadow var(--t2);
}
.theme-toggle-btn:hover {
  background: var(--s2); color: var(--gold);
  box-shadow: 0 0 12px var(--gold-glow);
}
.theme-toggle-btn svg {
  width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 2;
}

/* ══════════════════════════════════════════════════
   LUCIDE ICON SIZING
══════════════════════════════════════════════════ */

/* ══════════════════════════════════════════════════
   v12 WS42 Phase 21 — BI icon-tile system
   Rounded squircle duotone tiles (window.iconTile / window.deptIconTile).
   Size/radius/background/icon-size are per-call inline styles; this class only
   supplies structural centering + a soft shadow so tiles read as one system.
══════════════════════════════════════════════════ */
.bi-icon-tile {
  display: inline-flex; align-items: center; justify-content: center;
  flex-shrink: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.18);
}
.bi-icon-tile i, .bi-icon-tile svg { display: block; fill: none; }

/* nav-icon: colored rounded container holds Lucide svg */
.nav-icon svg {
  width: 15px; height: 15px;
  stroke: #fff; stroke-width: 2; fill: none;
  display: block;
}
/* emoji fallback */
.nav-icon.emoji-icon { font-size: 0.9375rem; }

/* bottom nav icon */
.bn-icon {
  display: flex; align-items: center; justify-content: center;
  font-size: 1.375rem; line-height: 1;
  transition: transform var(--t2);
}
.bn-icon svg {
  width: 22px; height: 22px;
  stroke: currentColor; stroke-width: 2; fill: none;
  display: block;
}
.bn-icon.emoji-icon { font-size: 1.375rem; display: block; }

/* active bottom nav icon — lucide version */
.bottom-nav-item.active .bn-icon svg {
  transform: scale(1.10);
}
/* v14 de-structuring pass: Astral's glow drop-shadow filter on the active
   icon was removed — same plain scale as every other theme. */

/* Topbar icons */
.notif-btn svg {
  width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 2;
}

/* Prevent double-scale for Lucide bn-icons on active */
.bottom-nav-item.active .bn-icon:not(.emoji-icon) { transform: none; }

/* Light mode topbar logo card */
html.light .topbar-logo-card {
  background: linear-gradient(140deg, #1A1A2E 0%, #16213E 60%, #0F3460 100%);
  border-color: rgba(255,214,10,0.40);
  box-shadow: 0 0 10px rgba(255,214,10,0.14), inset 0 1px 0 rgba(255,255,255,0.08);
}
html.light .topbar-brand-sub { color: rgba(29,29,31,0.35); }

/* ══════════════════════════════════════════════
   NEW MODULES — Posts, Team, Attendance,
                 Cash Advance, Finance, Company
══════════════════════════════════════════════ */

/* ── Posts ─────────────────────────────────── */
.post-card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 14px; padding: 16px; margin-bottom: 14px;
  transition: box-shadow var(--t1);
}
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
@media (hover:hover) { .post-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.12); } }
.post-header { display:flex; align-items:center; gap:10px; margin-bottom:10px; }
.post-avatar {
  width:38px; height:38px; border-radius:50%;
  background:var(--primary-light); display:flex; align-items:center;
  justify-content:center; font-weight:700; color:#fff; font-size:0.9375rem;
  overflow:hidden; flex-shrink:0;
}
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-meta { flex:1; }
.post-author { font-weight:700; font-size:var(--fs-base); color:var(--text); }
.post-time { font-size:var(--fs-xs); color:var(--text-muted); }
.post-title { font-size:var(--fs-lg); font-weight:700; color:var(--text); margin-bottom:8px; }
.post-body { font-size:var(--fs-base); color:var(--text); line-height:1.6; white-space:pre-wrap; margin-bottom:10px; }
.post-image { width:100%; max-height:320px; object-fit:cover; border-radius:10px; cursor:pointer; margin-bottom:10px; }
.post-attachment { display:inline-flex; align-items:center; gap:6px; padding:6px 12px; background:var(--s1); border:1px solid var(--border); border-radius:8px; font-size:var(--fs-md); color:var(--primary-light); text-decoration:none; margin-bottom:10px; }
.post-actions { display:flex; gap:8px; padding-top:10px; border-top:1px solid var(--border); flex-wrap:wrap; align-items:center; }
.post-heart-btn {
  display: inline-flex; align-items: center; gap: 5px;
  background: none; border: 1.5px solid rgba(255,107,43,0.20);
  border-radius: 20px; padding: 5px 10px; cursor: pointer;
  color: var(--text-muted); transition: background var(--t1), border-color var(--t1), transform 0.1s;
}
.post-heart-btn svg { width: 15px; height: 15px; transition: transform 0.15s, fill 0.15s, stroke 0.15s; }
.post-heart-btn .heart-count { font-size: var(--fs-sm); font-weight: 600; color: var(--text-muted); min-width: 6px; transition: color 0.15s; }
.post-heart-btn:hover { background: rgba(255,107,43,0.08); border-color: rgba(255,107,43,0.45); }
.post-heart-btn.hearted { border-color: rgba(255,107,43,0.50); background: rgba(255,107,43,0.07); }
.post-heart-btn.hearted .heart-count { color: #FF6B2B; }
@keyframes heartPop {
  0%   { transform: scale(1); }
  40%  { transform: scale(1.45); }
  70%  { transform: scale(0.88); }
  100% { transform: scale(1); }
}
.post-heart-btn.heart-pop svg { animation: heartPop 0.36s var(--ios-spring-out, cubic-bezier(0.34,1.28,0.64,1)) both; }

/* ── Team Cards (legacy) ──────────────────── */
.team-card { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:16px; text-align:center; }
.team-card-avatar { width:64px; height:64px; border-radius:50%; background:var(--primary-light); margin:0 auto 10px; display:flex; align-items:center; justify-content:center; font-size:var(--fs-3xl); font-weight:700; color:#fff; overflow:hidden; }
.team-card-avatar img { width:100%; height:100%; object-fit:cover; }
.team-card-name { font-size:var(--fs-base); font-weight:700; color:var(--text); margin-bottom:4px; }
.team-card-dept { font-size:var(--fs-xs); color:var(--text-muted); margin-top:4px; }
.team-card-id { font-size:var(--fs-2xs); color:var(--text-muted); font-family:monospace; margin-top:2px; }

/* ── Team Tab — Aesthetic Redesign ───────────── */
.team-masonry {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 16px;
}
.team-member-card {
  position: relative;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 20px;
  padding: 20px 14px 14px;
  text-align: center;
  cursor: pointer;
  transition: transform .2s ease, box-shadow .2s ease, border-color .2s;
  display: flex; flex-direction: column; align-items: center; gap: 5px;
}
.team-member-card:hover {
  transform: translateY(-4px) scale(1.02);
  box-shadow: var(--sh);
  border-color: var(--primary-light);
}
/* v14 de-structuring pass: Astral's glass-surface background/border, blur,
   and heavier hover glow shadow were removed — this card now falls through
   to the base rule above (its own --surface/--border palette tokens still
   give it a distinct tint) and the shared .team-member-card:hover box-shadow. */
/* Cheap CSS-only list-enter stagger (no JS): first 8 cards fade/rise in, capped. */
.team-masonry > .team-member-card { animation: pageEnter 200ms var(--ease, ease-out) both; }
.team-masonry > .team-member-card:nth-child(1) { animation-delay: 0ms; }
.team-masonry > .team-member-card:nth-child(2) { animation-delay: 25ms; }
.team-masonry > .team-member-card:nth-child(3) { animation-delay: 50ms; }
.team-masonry > .team-member-card:nth-child(4) { animation-delay: 75ms; }
.team-masonry > .team-member-card:nth-child(5) { animation-delay: 100ms; }
.team-masonry > .team-member-card:nth-child(6) { animation-delay: 125ms; }
.team-masonry > .team-member-card:nth-child(7) { animation-delay: 150ms; }
.team-masonry > .team-member-card:nth-child(n+8) { animation-delay: 175ms; }
/* ── TEAM-PAGE-ORG-SPEC-2026-08-25 — Fabricators + vacant-positions sections.
   Neither section may share .team-masonry's 150px column floor: these cards
   carry more text (type badges, role descriptions, a CTA/status footer), and
   at 150px the vacant card's status pill overflowed the dashed border and
   card bottoms landed ragged. Grid rows stretch by default, so every card in
   a row is equal height; the base card is already a flex column, so
   margin-top:auto pins each footer (Create-login button / Vacant pill) to
   the bottom edge and the whole row lines up. */
.team-fab-grid, .team-vacant-grid { display: grid; gap: 16px; }
.team-fab-grid    { grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); }
.team-vacant-grid { grid-template-columns: repeat(auto-fill, minmax(228px, 1fr)); }
.team-fab-card    { cursor: default; }
.team-fab-card .fab-badges { display: flex; gap: 4px; flex-wrap: wrap; justify-content: center; }
.team-fab-card .fab-cta    { margin-top: auto; width: 100%; }
/* A read-only placeholder must not pretend to be a live card: transparent
   face, dashed frame, and the shared hover lift/glow suppressed. */
.team-vacant-card {
  cursor: default; opacity: .82;
  background: transparent; border: 1.5px dashed var(--border); box-shadow: none;
}
.team-vacant-card:hover { transform: none; box-shadow: none; border-color: var(--border); }
.team-vacant-card .vacant-desc { font-size: 11.5px; color: var(--text-muted); line-height: 1.55; margin-top: 4px; }
.team-vacant-card .vacant-pill {
  margin-top: auto;
  padding: 4px 12px; border-radius: 20px;
  font-size: 10.5px; font-weight: 600; letter-spacing: .02em; line-height: 1.4;
  background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted);
  max-width: 100%; white-space: normal;   /* wrap INSIDE the card, never bleed past it */
}
.team-section-head { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; margin: 0 0 12px; }
.team-section-count {
  font-size: 11px; font-weight: 600; color: var(--text-muted);
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: 20px; padding: 1px 9px;
}
.team-note-bubble {
  position: absolute;
  top: -12px; left: 50%; transform: translateX(-50%);
  background: var(--primary);
  color: #fff;
  font-size: var(--fs-2xs);
  font-weight: 600;
  padding: 4px 10px;
  border-radius: 20px;
  white-space: nowrap;
  max-width: 130px;
  overflow: hidden;
  text-overflow: ellipsis;
  box-shadow: 0 4px 12px rgba(0,0,0,0.25);
  pointer-events: none;
  z-index: 2;
}
.team-note-bubble::after {
  content: '';
  position: absolute;
  bottom: -5px; left: 50%; transform: translateX(-50%);
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-top: 5px solid var(--primary);
}
.team-member-avatar-wrap {
  position: relative;
  width: 68px; height: 68px;
  margin: 8px 0 6px;
}
.team-member-avatar {
  width: 100%; height: 100%;
  border-radius: 50%;
  background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
  display: flex; align-items: center; justify-content: center;
  font-size: 1.375rem; font-weight: 800; color: #fff;
  overflow: hidden;
  border: 2.5px solid rgba(255,255,255,0.15);
  box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.team-member-avatar img { width:100%; height:100%; object-fit:cover; border-radius:50%; }
.team-online-dot {
  position: absolute; bottom: 2px; right: 2px;
  width: 14px; height: 14px; border-radius: 50%;
  border: 2.5px solid var(--surface);
}
.team-online-dot--on { background: #30D158; box-shadow: 0 0 8px rgba(48,209,88,0.6); }
.team-online-dot--off { background: var(--border); }
.team-member-name {
  font-size: var(--fs-md); font-weight: 700; color: var(--text);
  line-height: 1.3; text-align: center;
}
.team-member-role {
  font-size: var(--fs-2xs); font-weight: 700; text-transform: uppercase;
  letter-spacing: .06em; opacity: .85;
}
.team-member-dept {
  font-size: var(--fs-2xs); color: var(--text-muted); text-align: center;
  line-height: 1.4;
}
.team-status-pill {
  font-size: 0.5625rem; font-weight: 700; padding: 2px 8px;
  border-radius: 20px; margin-top: 2px;
  background: var(--surface2); color: var(--text-muted);
  letter-spacing: .04em;
}
.team-status-pill--on {
  background: rgba(48,209,88,0.12); color: #30D158;
}
/* BETA F13 (owner screenshot, 2026-08-08) — the 4th button, which is the
   DESTRUCTIVE "Remove from system" / "Reinstate", was clipped off the card on
   the 2-column phone grid.
   Measured at 375px with the coarse-pointer sizes that a real iPhone gets
   (.team-card-btn min-width:40px/padding:8px 12px, ~line 7062): card 169.5px,
   content box 139.5px, four 42px buttons + three 6px gaps = 186px of row in a
   139.5px box → 46.5px of overflow, and because .team-member-card centers its
   children (align-items:center) it hung 23.3px off BOTH sides. The Remove
   button's right edge landed at 373.3px — 8.3px past the card's own border box
   and past #page-content's overflow-x:clip edge, so it rendered sliced.
   Two declarations, both needed:
     align-self: stretch — the row is a flex ITEM of the column card, so
       align-items:center made its cross size fit-content, i.e. max-content
       (186px) regardless of how narrow the card is. Stretching binds its width
       to the card's content box, which is what gives wrapping an actual limit.
     flex-wrap: wrap — with it, the row's min-content width drops to the widest
       single button instead of the sum of all four, so the line breaks instead
       of overflowing: three buttons fit the 139.5px line and Remove reflows to
       a second, centered line.
   Also the safer read: the destructive action no longer sits shoulder-to-
   shoulder with Message and Nudge on one line. (It is already behind a
   danger-styled confirmDialog in js/screens/people.js, so a mis-tap was
   recoverable — but an unreachable button was not.)
   No JS change: js/screens/people.js's markup is untouched. */
.team-card-actions {
  margin-top: 6px;
  display: flex; gap: 6px; justify-content: center;
  align-self: stretch; flex-wrap: wrap;
}
.team-card-btn {
  background: var(--surface2); border: 1px solid var(--border);
  border-radius: 8px; padding: 4px 10px; cursor: pointer;
  font-size: var(--fs-base); line-height: 1; transition: background .15s;
}
.team-card-btn:hover { background: var(--primary); }

/* light mode */

/* ── Employee of the Month (Barro Industries) ── */
/* v12 WS42 Phase 24 — flat --surface card, a thin gold left-accent border for
   identity (no gradient, no blur). v14 de-structuring pass: Astral's separate
   "full gold glass treatment" (its own border-radius, gradient background,
   backdrop-filter, and glow shadow — which also dropped the left-accent
   border) was removed; Astral now uses this exact same card as every theme. */
.eom-banner {
  position: relative;
  border-radius: var(--r-lg);
  margin-bottom: 18px;
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--border);
  border-left: 3px solid #FFB800;
  box-shadow: var(--sh-sm);
}
.eom-ribbon {
  font-size: var(--fs-xs); font-weight: 800; letter-spacing: .08em; text-transform: uppercase;
  color: #FFB800; padding: 11px 16px 0;
}
.eom-body { display: flex; align-items: center; gap: 14px; padding: 8px 16px 16px; }
.eom-avatar {
  flex: 0 0 auto;
  width: 62px; height: 62px; border-radius: 50%;
  background: linear-gradient(135deg, #FFB800 0%, #FF8A00 100%);
  display: flex; align-items: center; justify-content: center;
  font-size: 1.375rem; font-weight: 800; color: #fff; overflow: hidden;
  border: 2.5px solid rgba(255,196,0,0.5);
  box-shadow: 0 6px 18px rgba(255,138,0,0.35);
}
.eom-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.eom-info { flex: 1 1 auto; min-width: 0; }
.eom-name { font-size: 1.0625rem; font-weight: 800; color: var(--text); line-height: 1.2; }
.eom-role { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin-top: 3px; }
.eom-reason { font-size: 0.78125rem; color: var(--text); opacity: .85; font-style: italic; margin-top: 6px; line-height: 1.4; }
.eom-auto-tag { font-size: 0.65625rem; font-weight: 600; color: var(--text-muted); margin-top: 6px; opacity: .9; }
.eom-edit-btn {
  flex: 0 0 auto; align-self: flex-start;
  background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border, rgba(255,255,255,0.15));
  border-radius: 8px; padding: 5px 9px; cursor: pointer; font-size: var(--fs-md); line-height: 1;
  transition: background .15s;
}
.eom-edit-btn:hover { background: rgba(255,196,0,0.25); }
.eom-banner--empty { display: flex; align-items: center; gap: 14px; padding: 16px; }
.eom-empty-icon { font-size: var(--fs-4xl); flex: 0 0 auto; }
.eom-empty-text { flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px; }
.eom-empty-text strong { font-size: var(--fs-base); color: var(--text); }
.eom-empty-text span { font-size: var(--fs-sm); color: var(--text-muted); }

/* ── Attendance Calendar ────────────────────── */
.att-cal-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 4px;
}
.att-cal-hdr {
  text-align:center; font-size:var(--fs-xs); font-weight:700;
  color:var(--text-muted); padding:6px 0;
}
.att-cal-day {
  border-radius: 8px; padding: 6px 4px; text-align:center;
  min-height: 52px; font-size:var(--fs-md); position:relative;
  background: var(--surface); border:1px solid var(--border);
  display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
  gap:2px;
}
.att-day-num { font-size:var(--fs-sm); font-weight:600; color:var(--text); }
.att-mark { font-size:var(--fs-base); font-weight:800; }
/* v14 CSS/a11y audit: att-mark/present/half/absent colors below are
   deliberately raw hex, not var(--success)/(--warning)/(--danger) — same
   "real-world state, not theme-relative" rationale documented for
   --presence-* in tokens.css. Do not "fix" these into the theme tokens; that
   would shift the color at these rgba() opacities differently per theme. */
.att-present { background:rgba(48,209,88,.12); border-color:rgba(48,209,88,.3); }
.att-present .att-mark { color:#30d158; }
.att-half { background:rgba(255,170,0,.12); border-color:rgba(255,170,0,.3); }
.att-half .att-mark { color:#ffaa00; }
.att-absent { background:rgba(255,69,58,.10); border-color:rgba(255,69,58,.25); }
.att-absent .att-mark { color:#ff453a; }
.att-weekend { background:var(--s1); opacity:0.45; }
.att-holiday { background:rgba(255,214,0,0.10); border-color:rgba(255,214,0,0.35); }
.att-holiday .att-mark { color:rgba(180,140,0,0.9); }
.att-holiday.att-dot { background:rgba(255,214,0,0.6); border:1px solid rgba(180,140,0,0.4); }
.att-future { background:var(--s1); opacity:0.7; }
.att-today { outline:2px solid var(--primary-light); outline-offset:1px; }
.att-edit-btn {
  display:none; position:absolute; bottom:3px; right:3px;
  font-size:var(--fs-xs); cursor:pointer; color:var(--text-muted);
  background:var(--s2); border-radius:4px; padding:1px 4px;
}
.att-cal-day:hover .att-edit-btn { display:block; }
/* v14 a11y fix: this hover-only variant had no way for a keyboard user to
   ever reveal/reach the button (display:none until :hover). The button in
   current markup always also carries .att-edit-visible (always display:block
   — see that rule below), so this specific variant isn't hit today, but a
   :focus-within companion costs nothing and makes this variant correct too
   if it's ever used on its own. */
.att-cal-day:focus-within .att-edit-btn { display:block; }
.att-dot {
  display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:4px;
}
.att-present.att-dot, .att-dot.att-present { background:#30d158; }
.att-half.att-dot, .att-dot.att-half { background:#ffaa00; }
.att-absent.att-dot, .att-dot.att-absent { background:#ff453a; }
.att-holiday.att-dot { background:var(--s2); border:1px solid var(--border); }

/* ── Cash Advance Cards ─────────────────────── */
.ca-card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 14px; padding: 16px; margin-bottom: 14px;
}
.ca-card-header { display:flex; align-items:center; gap:10px; margin-bottom:12px; flex-wrap:wrap; }
.ca-card-name { flex:1; font-weight:600; font-size:var(--fs-base); }
.ca-amount { font-size:1.375rem; font-weight:800; color:var(--text); }
.ca-card-body { display:flex; flex-direction:column; gap:6px; }
.ca-detail { display:flex; justify-content:space-between; font-size:var(--fs-md); color:var(--text); }
.ca-detail span:first-child { color:var(--text-muted); }
.ca-card-actions { display:flex; gap:8px; margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
.ca-calc-box {
  background:var(--s2); border:1.5px solid var(--border);
  border-radius:10px; padding:12px 14px; margin-bottom:8px;
  display:flex; flex-direction:column; gap:6px;
}

/* ── Company Overview ───────────────────────── */
.company-hero {
  text-align:center; padding:28px 20px 20px;
  background:var(--surface); border:1px solid var(--border);
  border-radius:16px; margin-bottom:16px;
}
.company-hero-logo { width:80px; height:80px; border-radius:50%; object-fit:cover; margin-bottom:12px; }
.company-hero-name { font-size:1.375rem; font-weight:800; color:var(--text); margin:0 0 4px; }
.company-hero-tagline { font-size:var(--fs-md); color:var(--text-muted); margin:0; }
.trademark-card { display:flex; align-items:flex-start; gap:14px; }
.trademark-icon { font-size:var(--fs-4xl); flex-shrink:0; }
.trademark-name { font-weight:700; font-size:0.9375rem; color:var(--text); margin-bottom:4px; }
.trademark-desc { font-size:var(--fs-md); color:var(--text-muted); line-height:1.5; }

/* ── Subtab bar responsive (scrolls horizontally, no wrapping needed) ── */
@media (max-width: 480px) {
  .subtab-btn { padding: 7px 10px; font-size: var(--fs-sm); }
}



/* ── Stat cards (quotations summary) ──────────────── */
.stat-card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 12px; padding: 14px 16px; text-align: center;
}
.stat-num  { font-size: 1.375rem; font-weight: 800; color: var(--text); }
.stat-label{ font-size: var(--fs-xs); color: var(--text-muted); margin-top: 3px; }


/* ── Bottom nav individual icon colors ──────────── */
.bottom-nav-item[data-page="dashboard"] .bn-icon svg  { stroke: #FF6B6B; }
.bottom-nav-item[data-page="tasks"]     .bn-icon svg  { stroke: #40CFFF; }
.bottom-nav-item[data-page="posts"]     .bn-icon svg  { stroke: #FF2D78; }
.bottom-nav-item[data-page="cash-advances"] .bn-icon svg { stroke: #FFD60A; }
.bottom-nav-item[data-page="personal-finance"] .bn-icon svg { stroke: #FF9F0A; }
.bottom-nav-item[data-page="approvals"] .bn-icon svg  { stroke: #FFD60A; }
.bottom-nav-item[data-page="team-directory"] .bn-icon svg { stroke: #40CFFF; }
.bottom-nav-item[data-page="attendance"] .bn-icon svg { stroke: #30D158; }
.bottom-nav-item[data-page="analytics"] .bn-icon svg  { stroke: #FF9F0A; }
.bottom-nav-item[data-page="files"]     .bn-icon svg  { stroke: #5856D6; }
.bottom-nav-item[data-page="bs-quote-builder"] .bn-icon svg { stroke: #40CFFF; }
.bottom-nav-item[data-page="bs-quotations"]    .bn-icon svg { stroke: #5856D6; }
.bottom-nav-item[data-page="bs-clients"]       .bn-icon svg { stroke: #30D158; }
.bottom-nav-item[data-page="bs-files"]         .bn-icon svg { stroke: #8B5CF6; }
/* Keep active state override */
.bottom-nav-item.active .bn-icon svg { stroke: var(--primary) !important; }
html.theme-astral .bottom-nav-item.active .bn-icon svg { stroke: var(--pink) !important; }

/* ── Attendance edit button — always visible for admin ── */
.att-edit-btn.att-edit-visible {
  display: block !important;
  position: absolute; bottom: 2px; right: 2px;
  font-size: var(--fs-2xs); cursor: pointer;
  color: #fff; background: var(--primary-light);
  border: none; border-radius: 4px;
  padding: 1px 5px; line-height: 1.4;
  opacity: 0.85;
}
.att-edit-btn.att-edit-visible:hover { opacity: 1; }


/* ── Departments grid — clickable cards ─────────── */
.dept-card-clickable { transition: transform var(--t1), box-shadow var(--t1); }
/* v14.0.68 HOVER-LATCH GUARD -- see the press sweep for the full
   rationale (~line 6182). On touch, :hover applies on tap and STICKS until the next tap
   elsewhere, so an unguarded hover is a permanent post-tap state change.
   Wrapping in @media (hover:hover) is a no-op for mouse users. */
@media (hover:hover) { .dept-card-clickable:hover { transform: translateY(-3px); box-shadow: 0 8px 28px rgba(0,0,0,.18); } }
.dept-subtabs-preview { display:flex; flex-wrap:wrap; gap:5px; margin:8px 0 6px; }
.dept-subtab-chip {
  font-size:var(--fs-2xs); padding:2px 8px;
  background:var(--s2); border:1px solid var(--border);
  border-radius:6px; color:var(--text-muted);
}
.dept-open-hint { font-size:var(--fs-xs); color:var(--primary-light); margin-top:4px; font-weight:600; }
.dept-icon-large { font-size:2.25rem; line-height:1; margin-bottom:4px; }

/* Dept quick-tab buttons on employee dashboard */
.dept-quick-tab {
  display: flex; align-items: center; gap: 8px;
  padding: 10px 14px; border-radius: 10px;
  background: var(--s2); border: 1.5px solid var(--border);
  color: var(--text); font-size: var(--fs-md); font-weight: 600;
  /* v14.0.68: was `transition: all var(--t1)`. `all` silently animates layout
     properties (padding, border-width, font-size) if any rule ever changes
     one, which is both a jank source and impossible to reason about. Named
     properties only, and an explicit timing function -- the token durations
     in this file overwhelmingly omit one and silently run the UA default. */
  cursor: pointer;
  transition: background 90ms var(--ease), border-color 90ms var(--ease),
              transform 90ms var(--ease), color 90ms var(--ease);
}
@media (hover:hover) { .dept-quick-tab:hover { background: var(--s3); border-color: var(--primary-light); transform: translateY(-1px); } }
.dept-quick-tab:active { transform: scale(0.97); transition-duration: 0s; }
@media (prefers-reduced-motion: reduce) { .dept-quick-tab:active { transform: none; } }


/* ── Remember me / Guest save row ──────────────── */
.remember-row {
  display: flex; align-items: center; gap: 8px;
  font-size: var(--fs-md); color: var(--text-muted);
  cursor: pointer; margin-bottom: 14px;
  user-select: none;
}
/* The `.remember-row input[type="checkbox"]` rule that used to sit here
   (width:16px; height:16px; accent-color:var(--primary-light); cursor:pointer)
   was deleted in the 2026-08-10 checkbox audit — it is now byte-for-byte what
   the global checkbox baseline (~line 377) already gives every tick in the
   app, and keeping a second copy is exactly how the app ended up with three
   different checkbox sizes. `.remember-row` itself still owns the row layout
   (flex + gap + the label wrap that makes "Keep me signed in on this device"
   tappable). Nothing here to re-add. */


/* ═══════════════════════════════════════════════════════════
   LIQUID GLASS — Design Layer v1
   Apple-inspired frosted glass surfaces with specular edge
   lighting, deep saturation blur, and iridescent accents.
   Written as cascade overrides — all prior rules still apply.
═══════════════════════════════════════════════════════════ */

/* v14 de-structuring pass: this entire "Liquid Glass" token block
   (--glass-blur/-surface/-border/-spec/-shadow, all Astral-exclusive) was
   removed, along with the iridescent/glassBreath keyframes it fed. Every
   component below that used to read these tokens for a bespoke glass
   treatment now falls through to the same base rule Dark uses — see the
   removed-rule comments at each section. */

/* ══════ TOPBAR ══════ WS42 Phase 6 — token-driven */
.topbar {
  height: calc(var(--topbar-h) + env(safe-area-inset-top, 0px));
  padding-top: env(safe-area-inset-top, 0px);
  display: flex; align-items: center;
  padding-left: max(16px, env(safe-area-inset-left, 0px));
  padding-right: max(16px, env(safe-area-inset-right, 0px));
  gap: 12px;
  position: fixed; top: 0; left: 0; right: 0; z-index: var(--z-topbar);
  background: var(--topbar-bg);
  border-bottom: 1px solid var(--border);
  box-shadow: var(--sh-sm);
}
/* v14 de-structuring pass: Astral's glass-blur topbar background/border/
   shadow, and its separate animated rainbow-trim ::before (+ reduced-motion
   guard), were removed — Astral's topbar is now the exact same bar as
   Dark's, just tinted by its own --topbar-bg/--border tokens above. */

/* ══════ SIDEBAR ══════ WS42 Phase 7 — token-driven */
#sidebar {
  background: var(--sidebar-bg);
  border-right: 1px solid var(--border);
  box-shadow: none;
}
/* v14 de-structuring pass: Astral's glow border-right/box-shadow override
   was removed — same plain border + no shadow as Dark. */
#sidebar::before {
  background: linear-gradient(180deg,
    rgba(255,45,120,0.45) 0%,
    rgba(10,132,255,0.38) 48%,
    rgba(255,214,10,0.32) 100%);
  opacity: 0.55; width: 1.5px;
}
html.light #sidebar::before, html.theme-dark #sidebar::before { opacity: 0; }
/* v14 de-structuring pass: Light/Dark already zero out this rainbow accent
   line; Astral was the only theme still showing it (a decorative-pseudo
   left over from the glass design, not a color choice) — zeroed here too so
   the sidebar edge is identical in every theme. */
html.theme-astral #sidebar::before { opacity: 0; }

/* ══════ NOTIFICATION PANEL ══════ WS42 Phase 10 (drawer/dropdown treatment) */
.notif-panel {
  background: var(--notif-bg);
  border: 1px solid var(--border);
  box-shadow: var(--sh-lg);
}
/* v14 de-structuring pass: Astral's glass background/border/shadow override
   was removed — falls through to the rule above (--notif-bg/--border already
   give it a distinct tint). */
.notif-panel-header {
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
}

/* ══════ CARDS ══════ WS42 Phase 9 — token-driven, same structure in every theme */
.card {
  position: relative; overflow: hidden;
  border-radius: var(--r); margin-bottom: 16px;
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: var(--sh-sm);
  transition: box-shadow var(--t2), border-color var(--t2), transform var(--t2);
}
@media (hover:hover) {
  .card:hover { border-color: var(--border-strong); box-shadow: var(--sh); transform: translateY(-1px); }
}
.card::before { background: none; }
/* v12 WS42 Phase 24 — shared "hero card" treatment for dashboard highlight
   cards (Current Standing, partner intro cards, etc). Flat --surface + a
   normal-weight primary border (no glow — Phase 9 rule). */
.dash-hero-card { border: 1.5px solid var(--primary); }
/* Color-only: Astral's soft gradient identity instead of the flat --surface.
   The extra ambient glow shadow (structural, not color) was removed — v14
   de-structuring pass; same box-shadow as every other theme (base .card rule
   above). */
html.theme-astral .dash-hero-card {
  background: linear-gradient(135deg, var(--surface), var(--surface-2));
}
.card-header {
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
}
/* v14 de-structuring pass: Astral's glass .card/.card:hover/.card::before/
   .card-header overrides (glass-surface background, glow border/shadow, and
   a decorative gradient-sheen pseudo-element) were removed entirely — Astral
   now falls through to the exact same rules as Dark, above. */

/* ══════ KPI CARDS ══════ WS42 Phase 24 */
/* v14 de-structuring pass: Astral's sheen-overlay ::before, glass-surface
   background/border/shadow, and glow hover state were removed — same plain
   bordered card (base .kpi-card rule, further up) as every other theme. */

/* ══════ MODAL ══════ WS42 Phase 10 — token-driven, same structure in every theme */
.modal-overlay {
  background: rgba(0,0,0,0.40);
}
html.theme-dark .modal-overlay, html.theme-astral .modal-overlay { background: rgba(0,0,0,0.60); }
.modal-box {
  border-radius: var(--r-lg);
  width: 100%; max-width: 620px; max-height: 92dvh;
  display: flex; flex-direction: column; overflow: hidden;
  animation: popIn 0.26s var(--spring);
  position: relative;
  background: var(--modal-bg);
  border: 1px solid var(--border);
  box-shadow: var(--sh-lg);
}
.modal-box::before { content: none; }
.modal-header {
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
}
.modal-footer {
  background: var(--surface-2);
  border-top: 1px solid var(--border);
}
.modal-close {
  background: var(--surface-2);
  border: 1px solid var(--border);
}
.modal-close:hover { background: var(--surface-3); }
/* v14 de-structuring pass: Astral's backdrop-filter blur on the overlay, its
   glass .modal-box (background/blur/border/shadow), its animated iridescent
   trim ::before (+ reduced-motion guard), and its glass modal-header/footer/
   close overrides were all removed — Astral's modal is now the exact same
   shape/shadow as Dark's, just tinted by its own --modal-bg/--border/
   --surface-2 tokens above. */

/* ══════ DRAWER ══════ WS42 Phase 10 — token-driven, same structure in every theme.
   The base .drawer{} rule (background/border-left/box-shadow) was merged into
   the consolidated Drawer section near its first definition (Batch 2 / spec
   2d) — background and border-left were exact duplicates of that rule's
   values, box-shadow was folded in there. */
.drawer-header {
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
}
/* v14 de-structuring pass: Astral's glass .drawer/.drawer-header overrides
   were removed — falls through to the base rules (--drawer-bg/--surface-2/
   --border tokens still give it a distinct tint). */

/* ══════ SUBTAB BAR ══════ token-driven, no blur */
.subtab-bar {
  background: var(--surface-2);
  border: 1px solid var(--border);
  box-shadow: none;
}
.subtab-btn.active {
  box-shadow: none;
  text-shadow: none;
}
@media (hover:hover) {
  .subtab-btn:hover:not(.active) { background: var(--surface-3); }
}
/* v14 de-structuring pass: Astral's glass .subtab-bar override and its
   glow box-shadow/text-shadow .subtab-btn.active override were removed here
   (a second, color-bearing .subtab-btn.active override survives further
   down — see that section for what was kept). */

/* ══════ ITEM CARDS (tasks, list items) — token-driven, no blur ══════ */
@media (hover:hover) {
  .item-card:hover { background: var(--surface-2); }
}
.item-card::before {
  background: none;
  transform: none !important; animation: none !important;
  pointer-events: none !important; /* this override cancels the off-screen transform, so it must also stay non-interactive or it blocks every click on the card */
}
@media (hover:hover) { .item-card:hover::before { animation: none !important; } }
/* v14 de-structuring pass: Astral's glass .item-card/:hover/::before overrides
   were removed — falls through to the base .item-card rule (further up),
   whose --surface/--border tokens still give Astral a distinct tint. */

/* v14 de-structuring pass: the Astral-only animated rainbow-trim
   .bottom-nav::before (+ reduced-motion guard) was removed — Dark has no
   equivalent pseudo-element, so Astral's bottom nav no longer has one either. */

/* ══════ FORM INPUTS ══════ token-driven, no blur
   All three of these re-state the TEXT-FIELD look (surface fill, 1px border,
   focus halo) far below the checkbox baseline at ~line 377, so they must
   exclude checkboxes/radios explicitly or source order hands them the win:
     • the dark-theme line below is `html.theme-dark .form-group input` —
       specificity 0,2,2, which OUT-RANKS the baseline's .form-group form
       (0,2,1) outright, so it was the one rule in the file that survived the
       carve-out entirely. Measured before this change on Dark: a checkbox in
       a .form-group computed `background-color: rgb(36,40,45)` — a text-field
       fill re-applied to a tick. Chrome doesn't paint a background on an
       `appearance:auto` checkbox so it wasn't visible there, but the rule had
       no business matching, and other engines are not obliged to ignore it.
     • the :focus rule is the same stuck-blue-glow-on-click problem excluded up
       at ~line 398; excluded here too or the exclusion only half-works.
   Same :not() convention as the [readonly] rule in the Forms section. */
.form-group input:not([type=checkbox]):not([type=radio]),
.form-group textarea,
.form-group select {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: none;
}
.form-group input:not([type=checkbox]):not([type=radio]):focus,
.form-group textarea:focus,
.form-group select:focus {
  background: var(--surface);
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-soft);
}
html.theme-dark .form-group input:not([type=checkbox]):not([type=radio]), html.theme-dark .form-group textarea, html.theme-dark .form-group select { background: var(--surface-2); }

/* ══════ BUTTONS ══════ token-driven, no blur */
.btn-secondary {
  background: var(--surface-2);
  border: 1px solid var(--border);
  box-shadow: none;
}
.btn-secondary:hover {
  background: var(--surface-3);
  border-color: var(--border-strong);
  box-shadow: none;
}
/* Reusable outline variant */
.btn-outline {
  background: transparent;
  border: 1px solid var(--border);
  box-shadow: none;
  color: var(--text-2); border-radius: var(--r-sm);
  padding: 9px 16px; font-size: var(--fs-md); font-weight: 500; cursor: pointer;
  transition: background var(--t1), border-color var(--t1), box-shadow var(--t1), color var(--t1);
  display: inline-flex; align-items: center; gap: 6px;
}
.btn-outline:hover {
  background: var(--surface-2);
  border-color: var(--border-strong); color: var(--text);
}
.btn-outline:active { transform: scale(var(--press-scale)); }
.btn-outline.btn-sm { padding: 5px 11px; font-size: var(--fs-sm); border-radius: var(--r-xs); }
.btn-outline.btn-lg { padding: 13px 24px; font-size: var(--fs-lg); border-radius: var(--r-sm); }
.btn-outline:disabled { opacity: .6; cursor: not-allowed; }
@media (hover:hover) { .btn-outline:disabled:hover { background: transparent; border-color: var(--border); color: var(--text-2); } }

/* ══════ ALERT BANNERS ══════ */
/* v14 de-structuring pass: Astral's backdrop-filter blur + glow box-shadow
   override was removed — same flat banner as every theme. (.alert-warn/
   .alert-danger below are unthemed base rules, unchanged.) */
.alert-warn {
  background: rgba(255,170,0,0.08);
  border-color: rgba(255,170,0,0.24);
  box-shadow: 0 0 22px rgba(255,170,0,0.08);
}
.alert-danger {
  background: rgba(255,69,58,0.08);
  border-color: rgba(255,69,58,0.24);
  box-shadow: 0 0 22px rgba(255,69,58,0.08);
}

/* ══════ LOGIN CARD ══════ WS42 Phase 25 — token-driven, same structure in
   every theme. v14 de-structuring pass: Astral's extra glass layer on top of
   the shared login card (background/blur/border/shadow) and its animated
   iridescent trim ::before were removed entirely. */

/* ══════ LOGIN ROLE CARDS ══════ WS42 Phase 25 — same flat segmented-control
   styling (defined in the main login block) in every theme now; Astral's
   glass override + hover glow were removed — v14 de-structuring pass. */

/* ══════ QUICK ACTION BUTTONS ══════ same flat --s1/--s2/--s3 look (base rule
   above) in every theme now; Astral's glass background/blur/border/shadow +
   hover override were removed — v14 de-structuring pass. */

/* ══════ DEPT CARDS ══════ same flat --s1 card (base rule above) in every
   theme now; Astral's glass background/blur/border/shadow + hover glow were
   removed — v14 de-structuring pass. */

/* ══════ POLICY CARDS ══════ same flat --s1 card (base rule above) in every
   theme now; Astral's glass background/blur/border/shadow + hover glow were
   removed — v14 de-structuring pass. */

/* ══════ DATA TABLE HEADER ══════ (token-driven — see base rule) */

/* ══════ UPLOAD AREA ══════ same flat --s0/dashed-border look (base rule
   above) in every theme now; Astral's glass background/blur + hover/drag-over
   glow were removed — v14 de-structuring pass. */

/* ══════ SIDEBAR OVERLAY ══════ (blur removed — backdrop-filter restricted to topbar/modal/bottom-nav, which no longer use it either) */

/* ══════ LIGHT MODE TINTS ══════ WS42 Batch B — topbar/#sidebar/modal-box/drawer/
   notif-panel/bottom-nav/card/kpi-card/item-card/subtab-bar/btn-secondary/form-group
   input retired: all now read var(--topbar-bg|sidebar-bg|modal-bg|drawer-bg|notif-bg|
   bottom-nav-bg|surface|surface-2) directly. The malformed `html.light html.light`
   selectors previously here were dead (an html element can't nest inside another). */
/* login-container / login-role-card glass tints — WS42 Phase 25: removed
   (opaque token-driven card now lives in the main login block). */
/* Tinted action buttons: the dark-theme light-green / light-red text is
   unreadable on a pale tint in light mode — use darker, WCAG-legible ink. */
html.light .btn-success { background: rgba(16,124,65,0.10); color: #0E6B33; border-color: rgba(16,124,65,0.32); }
html.light .btn-success:hover { background: rgba(16,124,65,0.18); }
html.light .btn-danger { background: rgba(200,32,26,0.09); color: #B42318; border-color: rgba(200,32,26,0.30); }
html.light .btn-danger:hover { background: rgba(200,32,26,0.16); }
/* .quick-action-btn/.dept-card/.policy-card translucent-white light overrides
   removed (WS42 Phase 28 hygiene) — the base rules already resolve to a solid
   var(--s1)/var(--border) surface in Light (Phase 9: no glass outside Astral);
   the 0.55-alpha tint here was redundant leftover glass styling. */

/* ── WS42 Phase 15 — Chat page two-pane layout SCAFFOLDING ──────────────────
   CSS container rules only: at >=1024px the inbox becomes a fixed 320px left
   column and #chat-thread-panel (built in chat.js, appended to <body>, fixed
   full-width) is pushed to start after it instead of covering the inbox — so
   opening a thread already reads as "inbox + thread" side-by-side even before
   Batch D's Messenger-grade restyle rewrites the thread/bubble markup itself.
   Below 1024px: unchanged single-pane behavior (thread overlays full-screen). */
.chat-page { display: flex; flex-direction: column; height: 100%; }
.chat-page-inbox { display: flex; flex-direction: column; min-width: 0; }
/* Wave1 P2 fix #15 — the reserved second column (below) was pure empty space
   until a thread was opened; hidden by default (mobile has no room/need for
   it — the inbox is the whole single-pane view there). */
.chat-page-empty-pane { display: none; }
@media (min-width: 1024px) {
  /* Row layout so a future second pane (Batch D moving the thread panel into
     the DOM as an actual sibling) lays out side-by-side automatically. */
  .chat-page { flex-direction: row; gap: 0; }
  .chat-page-inbox {
    width: 320px; flex-shrink: 0;
    border-right: 1px solid var(--border);
    padding-right: 16px;
    min-height: calc(100dvh - var(--topbar-h) - 40px);
  }
  /* #chat-thread-panel is position:fixed relative to the VIEWPORT (chat.js
     appends it to <body>, not inside .chat-page), so its left offset must
     account for the full sidebar too: sidebar-w + .main-content's 20px
     padding + the 320px inbox column + its 16px padding-right. Recompute
     this if any of those layout constants change. */
  #chat-thread-panel {
    left: calc(var(--sidebar-w) + 20px + 320px + 16px) !important;
    border-left: 1px solid var(--border);
  }
  /* Wave1 P2 fix #15 — static "Select a conversation" placeholder for the
     empty desktop reading pane (completes the two-pane scaffolding comment
     above: "a future second pane... lays out side-by-side automatically").
     #chat-thread-panel is position:fixed and covers exactly this region once
     a thread IS open, so this simply shows through whenever nothing is. */
  .chat-page-empty-pane {
    display: flex; align-items: center; justify-content: center;
    flex: 1; min-width: 0;
    min-height: calc(100dvh - var(--topbar-h) - 40px);
  }
}

/* ── Chat thread window — what survives of `body.chat-fullscreen`.

   That class and its entire ≤640px block are DELETED as of the v14 mobile
   window model. The chat thread panel is an ordinary openPage .page-panel
   (chat.js just stamps id="chat-thread-panel" on it), so it now inherits the
   shared phone-tier window behaviour from the ≤768px block far above —
   visual-viewport anchoring (--vv-top/--vvh), the body.page-open occlusion of
   #topbar/#top-nav-strip/#bottom-nav, and the body scroll lock — instead of
   hand-rolling a parallel, 640px-only version of all three. The soft-keyboard
   offset it used to carry (`bottom: var(--kb-offset)`) is likewise obsolete:
   --vvh already ends the window at the top edge of the keyboard, and it does
   so for every page rather than for chat alone.

   The block also had to go rather than simply be re-scoped 640px → 768px:
   its `height:auto !important` on the header would then have grown
   .ms-thread-header from 56px to 61px on EVERY device including zero-inset
   ones (recon §6.5 Delta 2).

   Two of its effects were genuinely load-bearing and are re-expressed below.
   Both selectors are id-scoped — specificity (1,1,0) — so they beat the
   class-level base rules further down the file regardless of source order,
   with no !important needed. They are NOT scoped alike, though: (1) is
   phone-tier only and (2) applies at every width. See each.

   1. Notch clearance on the thread header, WITHOUT growing it — inside
      @media (max-width:768px) ONLY. The base rule (.ms-thread-header) is a
      hard `height:56px` under the global box-sizing:border-box, so a height
      that INCLUDES the inset plus a matching padding-top leaves the content
      box at exactly 56px:
        zero-inset device → height: calc(56px + 0px) = 56px, padding-top: 0
                            — byte-identical to today.
        iPhone with notch  → header occupies 56px + inset, of which the inset
                            is padding, so all 56px of chrome sits below the
                            status bar.
      The width scoping is load-bearing, not tidiness. This rule's entire
      premise is that the window's top edge IS the top of the screen, with
      nothing but the status bar above it — and that is true only inside the
      ≤768px block far above, which re-anchors .page-panel to
      `top: var(--vv-top, 0px)` (at rest: 0). Above 768px the base .page-panel
      rule still applies —
        top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px))
      — which has ALREADY cleared both the status bar and the app topbar, so
      adding the inset again here counts it twice. Confirmed failure: iPad
      Pro 11" portrait (834px) installed to the home screen, where
      env(safe-area-inset-top) is ~20-24px — the thread header rendered
      76-80px tall with a 20-24px empty band at its top, sitting below a
      topbar that had already cleared the status bar, and the message list
      lost exactly that much height.

   2. Exactly ONE header per window. openPage renders its own generic
      .page-panel-head (back chevron + plain-text title) ABOVE chat's much
      richer .ms-thread-header (avatar + presence subtitle + ⓘ / wallpaper
      menu, whose popover is position:relative-parented to it and so cannot be
      split into openPage's header slot). That left two stacked bars. chat.js
      has been suppressing it with an inline `style.display='none'`
      (js/chat.js:1076) and explicitly flagged the real fix as belonging to
      "the CSS owner" (js/chat.js:944-948) — this is that fix. Deliberately
      left unscoped, unlike (1): a duplicate header bar is wrong at every
      width, and hiding one makes no assumption about where the window's top
      edge is, so it has nothing to double-count. ── */
@media (max-width: 768px) {
  #chat-thread-panel .ms-thread-header {
    height: calc(56px + env(safe-area-inset-top, 0px));
    padding-top: env(safe-area-inset-top, 0px);
  }
}
#chat-thread-panel .page-panel-head { display: none; }

/* ── Owner req (2026-08-03 mobile-shell batch) — Quote Builder fullscreen on
   phone/tablet, mirroring body.chat-fullscreen directly above. app.js's
   renderQuoteBuilderIframe()/enterQbFullscreen()/exitQbFullscreen() toggle
   `body.qb-fullscreen` when the iframe page mounts/unmounts on a ≤768px
   viewport — the SAME breakpoint the rest of the mobile shell uses
   (top-nav-strip/bottom-nav/sidebar), unlike chat's own one-off 640px. Desktop
   (>768px) is untouched — the iframe keeps its old inline-sized layout there. */
@media (max-width: 768px) {
  body.qb-fullscreen #topbar,
  body.qb-fullscreen #top-nav-strip,
  body.qb-fullscreen #bottom-nav {
    display: none !important;
  }
  /* Full-bleed cover (edge to edge), same reasoning as chat's thread panel —
     the iframe is a separate document and pads its OWN safe areas internally;
     this shell rule only needs to keep #page-content from peeking through at
     the notch/home-indicator bands. */
  body.qb-fullscreen #qb-frame {
    position: fixed;
    /* Owner report ('top bar can't be seen'): env() resolves to 0 INSIDE an
       iframe document, so the builder can't clear the notch itself — the
       PARENT offsets the frame below the inset; the opaque body::after cap
       (z-topbar) keeps the status-bar band clean above. */
    top: env(safe-area-inset-top, 0px); left: 0; right: 0; bottom: 0;
    width: 100vw; height: calc(100dvh - env(safe-area-inset-top, 0px));
    min-height: 0;
    border-radius: 0;
    z-index: var(--z-dialog, 5000);
    background: #f5f6fa;
  }
}
/* Desktop Quote Builder (owner ruling 2026-09-01): the builder brings its own
   header and padding, so the shell gets out of the way — .main-content's 20px
   goes to 0 and the iframe fills the content area edge-to-edge. Applied by
   renderQuoteBuilderIframe(), cleared by every navigateTo(). The review
   banner / reopen strip keep a small inset so they don't touch the edges. */
body.qb-desktop .main-content { padding: 0; }
body.qb-desktop #page-content > * { animation: none; }
body.qb-desktop #qb-review-bar { margin: 10px 12px; }
body.qb-desktop #qb-frame {
  display: block; width: 100%; border: none; border-radius: 0;
  height: calc(100dvh - var(--topbar-h) - env(safe-area-inset-top, 0px));
  min-height: 460px; background: #f5f6fa;
}

/* Floating exit pill (top-left, safe-area aware) — built/torn down by
   js/app.js alongside the body class, so its presence in the DOM already
   gates its visibility; no body.qb-fullscreen scoping needed here. Tapping it
   routes through window.Overlay.dismissTop() (same as device Back, since
   entering fullscreen pushes an Overlay entry — see enterQbFullscreen). */
.qb-exit-pill {
  position: fixed;
  /* bottom-left thumb zone — the old top-left spot sat exactly on the
     builder's hamburger once the frame was offset below the notch */
  bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  left: calc(12px + env(safe-area-inset-left, 0px));
  /* --z-toast (9990) is the token already documented for exactly this kind of
     ephemeral floating chrome ("toast host + gesture pill" — see tokens.css);
     z-dialog+1 (5001) isn't on the app's allowed stacking scale and trips the
     dev-only devCheckStacking lint (js/app.js _Z_TOKEN_VALUES / off-scale
     z-index check) the first time an Overlay entry pushes after this mounts. */
  z-index: var(--z-toast, 9990);
  width: 38px; height: 38px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface, #1a1d21); color: var(--text, #fff);
  border: 1px solid var(--border, rgba(255,255,255,.14));
  box-shadow: var(--sh-lg, 0 8px 24px rgba(0,0,0,.35));
  cursor: pointer;
}
.qb-exit-pill svg { width: 18px; height: 18px; }

/* ── Messenger-style chat UI — WS42 Batch D (Phases 16–20) ──────────────────
   Shared by chat.js (#chat-thread-panel) AND the task-chat comment thread
   (departments.js renderComments) — both consumers get this look for free by
   using the same messenger-/ms- class names; keep it that way.

   NEVER put a star character in this comment. CSS has no nested comments and
   no escaping inside them: a star followed by a slash ENDS the comment, wherever
   it appears. The previous wording wrote the two class prefixes as globs — a
   dot, "messenger-", a star, a slash — and that star-slash pair closed the
   comment four words early. The tail that was left ("class names; keep it that
   way." plus the real terminator) then had to be parsed as CSS; it contains no
   opening brace, so the parser kept consuming forward, absorbed the
   .messenger-wrap selector on the next line into one invalid selector list, and
   threw the whole rule away. Every browser, silently, for as long as the typo
   existed. Two of these were introduced and caught while writing THIS comment,
   which is why the warning is the first line of it.
   Verified in-browser against this stylesheet before the fix (localhost,
   375x812): document.styleSheets held .messenger-header, .messenger-body,
   .messenger-empty and .messenger-input-row but NO .messenger-wrap rule at all,
   and the live element computed display:block, border 0px, border-radius 0px,
   transparent background, margin-top 0px.
   The lost "display:flex; flex-direction:column" is why the messages area could
   never absorb the task panel's leftover height: laid out as a BLOCK, this
   wrap gave .messenger-body's own flex:1 (just below) no flex container to grow
   in. See the 2026-08-08 note on #task-info-scroll in js/screens/tasks.js.
   Sole consumer of this class is js/departments.js's doc-comments widget
   (tasks / quotes / biddings / design) — grepped: chat.js builds its thread out
   of the ms- classes and #chat-thread-scroll and never uses .messenger-wrap, so
   restoring this rule cannot move the chat thread or its composer. */
/* TASK DETAIL BODY (2026-08-08) — the panel body is a flex COLUMN (set in
   js/screens/tasks.js), so the comments region can absorb the panel's spare
   height. Without this the body was display:block with a definite height and
   nobody owned the slack: on a long-description / few-messages task ~139px sat
   empty BELOW the composer. flex-shrink:0 matters as much as grow — the wrap
   must never be squashed when the description is long; in that case the body
   simply scrolls, which is the pre-existing behaviour. min-height:0 lets the
   inner .messenger-body scroll rather than forcing the wrap taller than the
   column. Scoped to this class so every other .messenger-wrap consumer (the
   doc-comment lists in js/departments.js) is untouched. */
.page-panel-body.task-detail-body > #task-comments-wrap { flex: 1 0 auto; min-height: 0; }
.page-panel-body.task-detail-body > #task-comments-wrap > .messenger-wrap { flex: 1 0 auto; min-height: 0; }

.messenger-wrap {
  display: flex; flex-direction: column;
  border: 1.5px solid var(--border); border-radius: var(--r-lg, 16px);
  overflow: hidden; background: var(--surface);
  margin-top: 12px;
}
.messenger-header {
  display: flex; justify-content: space-between; align-items: center;
  padding: 10px 16px; border-bottom: 1px solid var(--border);
  background: var(--surface-2); font-size: var(--fs-md);
}
.messenger-body {
  flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 16px 12px;
  display: flex; flex-direction: column;
  max-height: 380px; min-height: 120px;
  background: var(--chat-bg);
  position: relative;
  /* Wave5 M2 — the swipe-to-reply gesture (chat.js _onSwipeMove) translates a
     .ms-row up to SWIPE_REPLY_CAP (64px) right during the drag; own-message
     rows already sit close to the right inner edge (margin-left:auto), so a
     transient horizontal overflow is possible mid-drag. Clip it instead of
     flashing a scrollbar — the transform always resets on release/abort. */
  overflow-x: hidden;
}
#task-fullscreen-panel .messenger-body,
#chat-thread-panel .messenger-body { max-height: none; }
/* Owner request — a thread with only a few messages showed them stuck at the
   TOP with a big empty gap above the composer. Bottom-align the real chat
   thread's message column so short threads hug the composer like a normal
   chat; a long thread still just scrolls (overflow-y:auto above, unchanged).

   IT MUST NOT BE `justify-content: flex-end` — that was the original rule and
   it silently made the whole thread UNSCROLLABLE. On an overflowing flex
   COLUMN (.messenger-body is display:flex/flex-direction:column, ~line 4331)
   flex-end pushes the surplus out through the START edge, which no engine
   lets you scroll back to: the box reports scrollHeight === clientHeight and
   scrollTop is pinned at 0, so every message above the fold is unreachable.
   Reproduced against this stylesheet at 375x600 with 40 rows — flex-end:
   scrollHeight 600 / clientHeight 600 / max scrollTop 0, with the first row
   rendered at -2616px; the same DOM without it: scrollHeight 3232, max
   scrollTop 2632. The old comment here asserted the opposite ("flex-end only
   redistributes leftover space when content is SHORTER than the viewport") —
   that is true of the free-space distribution, but it is not what happens
   once content overflows.

   `margin-top:auto` on the first item is the canonical equivalent: with free
   space it absorbs all of it and bottom-aligns the column exactly as before;
   once content overflows, auto margins resolve to 0, so scrolling is normal.

   Scoped to the #chat-thread-scroll id (js/chat.js's own DM/group/dept
   thread), not the base .messenger-body class, which the department
   doc-comments widget (js/departments.js #msbody-*) also uses and isn't part
   of this request. */
#chat-thread-scroll > :first-child { margin-top: auto; }
.messenger-empty {
  text-align: center; font-size: var(--fs-md); color: var(--text-muted);
  padding: 32px 16px;
}

/* ── Wallpaper presets (Phase 18) — pure CSS, no images, ink flips per theme.
   Applied as a class on .messenger-body (chat.js thread scroll only — task
   chat never gets a wp-* class, so it always stays on plain --chat-bg). ── */
.messenger-body.wp-doodle {
  background-image: radial-gradient(circle, color-mix(in srgb, var(--text) 7%, transparent) 1.4px, transparent 1.6px);
  background-size: 22px 22px;
}
.messenger-body.wp-gradient-blue {
  background-image: linear-gradient(165deg,
    color-mix(in srgb, #0866FF 18%, var(--chat-bg)) 0%,
    color-mix(in srgb, #7C3AED 14%, var(--chat-bg)) 100%);
}
.messenger-body.wp-gradient-sunset {
  background-image: linear-gradient(165deg,
    color-mix(in srgb, #FF6B9D 18%, var(--chat-bg)) 0%,
    color-mix(in srgb, #FFD60A 14%, var(--chat-bg)) 100%);
}
.messenger-body.wp-astral {
  background-color: #070710;
  background-image:
    radial-gradient(1.4px 1.4px at 20% 30%, rgba(255,255,255,.5) 50%, transparent 52%),
    radial-gradient(1.2px 1.2px at 60% 70%, rgba(255,255,255,.4) 50%, transparent 52%),
    radial-gradient(1px 1px at 80% 15%, rgba(255,255,255,.32) 50%, transparent 52%),
    radial-gradient(1.6px 1.6px at 35% 85%, rgba(255,255,255,.42) 50%, transparent 52%),
    radial-gradient(1px 1px at 90% 55%, rgba(255,255,255,.28) 50%, transparent 52%);
  background-size: 240px 240px;
}
/* Contrast guard: on a gradient/astral wallpaper, incoming bubbles get a solid
   surface bg + border so text never sits directly on the gradient. */
.messenger-body.wp-gradient-blue .ms-bubble-theirs,
.messenger-body.wp-gradient-sunset .ms-bubble-theirs,
.messenger-body.wp-astral .ms-bubble-theirs {
  background: var(--surface); border-color: var(--border-strong); color: var(--text);
}
.messenger-body.wp-astral .ms-day-sep span,
.messenger-body.wp-astral .ms-time-sep { color: rgba(240,240,250,0.6); }

.ms-row {
  display: flex; align-items: flex-end; gap: 8px;
  max-width: 72%; margin: 10px 0 0;
  overflow-wrap: anywhere;
}
@media (max-width: 399px) { .ms-row { max-width: 85%; } }
.ms-row-mine  { align-self: flex-end;  flex-direction: row-reverse; margin-left: auto; }
.ms-row-theirs { align-self: flex-start; }
/* Grouped continuations (same sender, <2min apart) sit closer together */
.ms-row.ms-grp-mid, .ms-row.ms-grp-last { margin-top: 2px; }
.ms-row:first-child { margin-top: 0; }
/* Wave5 M2 — swipe-to-reply drag (chat.js _onSwipeMove sets an inline
   translateX up to 64px directly, per-frame; no base transition is declared
   on .ms-row so the reset-on-release snap is instant by default — this class
   only exists to defensively kill any transition a future rule might add
   here, so the drag itself never fights a CSS animation mid-gesture). */
.ms-row-swiping { transition: none; }

.ms-avatar {
  width: 24px; height: 24px; border-radius: 50%;
  background: var(--bubble-out-bg); color: var(--bubble-out-text, #fff);
  font-size: 0.5625rem; font-weight: 700;
  display: flex; align-items: center; justify-content: center;
  flex-shrink: 0; overflow: hidden;
}
.ms-avatar-mine { background: var(--primary); }
.ms-avatar-md { width: 36px; height: 36px; font-size: var(--fs-xs); }
/* Messenger restyle Fix 1 — 52px inbox-row avatar (owner spec). Only ever
   used by .chat-inbox-row today, so bumping the size here has no effect on
   any other screen. */
.ms-avatar-lg { width: 52px; height: 52px; font-size: var(--fs-md); border: 2px solid var(--surface); }
.ms-avatar-spacer { width: 24px; flex-shrink: 0; }

.ms-bubble-wrap { display: flex; flex-direction: column; gap: 3px; min-width: 0; max-width: 100%; }
.ms-row-mine .ms-bubble-wrap { align-items: flex-end; }
/* Owner req #3 — heart button sits BESIDE the bubble (row 4.a), not stacked
   under it: reversed for own messages so the heart still reads "outward". */
.ms-bubble-row { display: flex; align-items: flex-end; gap: 2px; min-width: 0; max-width: 100%; }
.ms-row-mine .ms-bubble-row { flex-direction: row-reverse; }
.ms-name { font-size: var(--fs-2xs); color: var(--text-muted); font-weight: 600; padding: 0 4px; }

.ms-bubble {
  padding: 8px 12px; border-radius: 18px;
  max-width: 100%; word-break: break-word; overflow-wrap: anywhere;
  cursor: pointer;
  /* Messenger restyle Fix 3 — positioned ancestor for .ms-heart-burst
     (double-tap-to-like feedback), which centers itself absolutely. */
  position: relative;
}
.ms-bubble-mine  { background: var(--bubble-out-bg); color: var(--bubble-out-text); }
.ms-bubble-theirs {
  background: var(--bubble-in-bg); color: var(--bubble-in-text);
  border: 1px solid var(--border);
}
/* Grouping corner-radius (mirrored mine/theirs — shared side is the aligned edge) */
.ms-bubble-mine.ms-grp-single  { border-radius: 18px; }
.ms-bubble-mine.ms-grp-first   { border-radius: 18px 18px 4px 18px; }
.ms-bubble-mine.ms-grp-mid     { border-radius: 18px 4px 4px 18px; }
.ms-bubble-mine.ms-grp-last    { border-radius: 18px 4px 18px 18px; }
.ms-bubble-theirs.ms-grp-single { border-radius: 18px; }
.ms-bubble-theirs.ms-grp-first  { border-radius: 18px 18px 18px 4px; }
.ms-bubble-theirs.ms-grp-mid    { border-radius: 4px 18px 18px 4px; }
.ms-bubble-theirs.ms-grp-last   { border-radius: 4px 18px 18px 18px; }

/* ── LONG-PRESS-TO-REACT: stop iOS's own long press from competing ─────────
   Owner report (2026-08): "long press is not working well". Two causes; this
   is the half that lives in CSS.

   On iOS a ~500ms press on TEXT is ALREADY a system gesture: it raises the
   selection magnifier and the "Copy · Look Up · Share" callout. chat.js arms
   its own 500ms picker timer on the same touch (LONG_PRESS_MS, js/chat.js
   ~2992), so the two run on the same timeline and race. In practice the
   system UI wins the touch, the finger shifts a pixel dismissing it, and the
   app's picker either never opens or opens underneath a blue selection
   highlight over the message text. Nothing in this file was suppressing the
   native gesture, so the app was competing with the OS for the same press.

   TRADE-OFF, taken deliberately: this GIVES UP long-press-to-select-text on
   message bubbles, on touch devices only. That is an acceptable exchange
   because the picker this gesture opens carries an explicit Copy button
   (js/chat.js ~2593, added in Wave5 M1) which copies the whole message — the
   exact thing the selection gesture was being used for. Copy is a first-class
   control in the picker, not a fallback, so the affordance is replaced rather
   than removed.

   SCOPE — three deliberate narrowings, all load-bearing:
   1. @media (pointer: coarse). A mouse has no magnifier to fight, so killing
      selection there would be a pure regression (desktop chat keeps normal
      click-drag select + Cmd-C). Same gate as the 44px hit-area block at the
      top of this file (~line 120).
   2. `.ms-bubble.chat-bubble-tap` — the bubble's OWN tap target only. NOT
      .messenger-body, NOT .ms-row, NOT the thread panel: the header, day
      separators, the composer, the reply quote strip's surroundings and the
      whole chat info page all still select normally.
   3. Nothing here can reach an <input>/<textarea>. The composer (.ms-input)
      is a sibling of the scroller, never a descendant of a bubble, and no
      bubble renders a form control.

   NO EXISTING RULE TO FIGHT: the only user-select declarations in this file
   are `body.sidebar-open .main-content` (~1235), `.remember-row` (~1724),
   `.id-flip-scene` (~3839), `.ms-lightbox-img` (~5093) and the explicit
   `user-select: text` on data surfaces (~1937). None is an ancestor of a
   bubble, so this neither overrides nor is overridden by any of them. ── */
@media (pointer: coarse) {
  .ms-bubble.chat-bubble-tap {
    -webkit-touch-callout: none;   /* kills the "Copy · Look Up" callout bubble */
    -webkit-user-select: none;     /* kills the selection+magnifier gesture itself */
    user-select: none;             /* (the callout rule alone does NOT stop selection) */
  }
}

.ms-text { font-size: var(--fs-base); line-height: var(--lh-snug); }
/* Timestamp/status: hidden by default — tap toggles it (mobile), hover reveals
   it (desktop pointer:fine). Phase 17. */
.ms-meta { display: none; gap: 5px; align-items: center; margin-top: 4px; }
.ms-bubble.ms-time-shown .ms-meta { display: flex; }
/* Wave1 P2 fix #11 — resting timestamp: the LAST bubble of a cluster
   (chat.js sets this on ms-grp-last/ms-grp-single) always shows its
   timestamp; mid-cluster bubbles (ms-grp-first/ms-grp-mid) stay tap-to-
   reveal via .ms-time-shown above, untouched. Independent selector (not a
   swap-in for .ms-time-shown) so a tap toggling THAT class on a
   resting-timestamp bubble can never hide it — the two simply OR together. */
.ms-bubble.ms-time-rest .ms-meta { display: flex; }
@media (hover: hover) { .ms-bubble:hover .ms-meta { display: flex; } }
.ms-time { font-size: var(--fs-2xs); opacity: .7; }
.ms-edited { font-size: var(--fs-2xs); opacity: .6; font-style: italic; }

/* Attachment cards (Phase 17) — link/file messages read as a rounded card
   inside the bubble instead of a bare pill. */
.ms-file-chip {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: var(--fs-sm); text-decoration: none;
  background: color-mix(in srgb, currentColor 10%, transparent);
  color: inherit;
  padding: 8px 12px; border-radius: var(--r-sm, 10px); margin-top: 5px;
  max-width: 220px;
}
.ms-bubble-theirs .ms-file-chip { background: var(--surface); color: var(--primary); border: 1px solid var(--border); }
.ms-bubble-mine .ms-file-chip { background: rgba(255,255,255,0.16); color: inherit; }
.ms-file-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.ms-actions { display: none; gap: 4px; padding: 0 2px; }
.ms-row:hover .ms-actions { display: flex; }

/* Messenger restyle Fix 3 — the always-visible quick-heart button (.ms-heart-btn)
   is gone; reacting is DOUBLE-TAP the bubble now (see chat.js
   _wireThreadDelegation). This is that gesture's only visual trace: a brief
   floating ❤️ burst, purely cosmetic (no storage/state), auto-removed by
   chat.js on animationend. */
.ms-heart-burst {
  position: absolute; left: 50%; top: 50%; z-index: 1;
  transform: translate(-50%, -50%) scale(.3);
  font-size: 34px; line-height: 1; pointer-events: none;
  animation: msHeartBurst 650ms cubic-bezier(.17,.89,.32,1.49) forwards;
}
@keyframes msHeartBurst {
  0%   { transform: translate(-50%, -50%) scale(.3); opacity: 0; }
  35%  { transform: translate(-50%, -50%) scale(1.15); opacity: 1; }
  100% { transform: translate(-50%, -60%) scale(1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .ms-heart-burst { animation: none; opacity: 0; }
}

/* Wave5 M2 (J3) — hover-revealed reply button (desktop only, by design: touch
   devices use the swipe-right gesture instead — see chat.js _onSwipeStart).
   @media(hover:hover) means it never renders on a touch-only device, so it
   doesn't compete with the swipe affordance there. */
.ms-reply-btn {
  background: none; border: none; cursor: pointer; padding: 2px 4px;
  color: var(--text-muted); flex-shrink: 0; align-self: flex-end;
  opacity: 0; transition: opacity .15s, color .15s;
}
@media (hover: hover) {
  .ms-row:hover .ms-reply-btn { opacity: .6; }
  .ms-reply-btn:hover { opacity: 1; color: var(--primary); }
}

.ms-act-btn {
  background: none; border: none; cursor: pointer; padding: 2px 5px;
  font-size: var(--fs-sm); color: var(--text-muted); border-radius: 6px;
  transition: background .15s;
}
.ms-act-btn:hover { background: var(--surface-2); color: var(--text); }
.ms-del-btn:hover { color: var(--danger); }

/* ══ LONG-PRESS REACTION PICKER (.chat-reaction-picker) ═══════════════════
   Owner report (2026-08): the picker's "options are getting cut off".

   WHY IT WAS CLIPPED. It rendered as a plain inline `display:flex` row inside
   .ms-bubble-wrap with no wrap and no overflow handling, holding SIX reaction
   emoji + Copy + Forward + (Pin, if the user can manage the conversation) +
   on touch UIs the edit/remove/remove-permanently buttons that moved in here
   from the hover-only .ms-actions row — 10+ controls on one unbreakable line,
   inside a column capped at 72% of the thread width (.ms-row, ~line 4398;
   85% under 400px). On a 375px phone that column is ~150-280px wide, so
   everything past roughly the third emoji sat outside the box. Making the row
   scroll would have hidden the same controls behind a gesture nobody would
   discover, so it becomes a POPOVER that wraps instead.

   OWNERSHIP SPLIT WITH js/chat.js. That file mounts this element as a
   position:fixed popover and positions it from the pressed bubble's rect —
   it owns GEOMETRY (top / left / max-width, written inline) and the
   open/close toggle. This block owns APPEARANCE plus the one guarantee that
   matters: the content FITS inside whatever max-width it is handed, at any
   width, with every button still hittable. Nothing here declares top or left,
   and the max-width below is a fallback for the un-positioned case only — an
   inline value from chat.js always wins over it.

   CONTAINING BLOCK — READ BEFORE CHANGING THE GEOMETRY IN chat.js. This
   popover is NOT positioned against the viewport even though it is
   position:fixed. Its nearest ancestor with a transform is .page-panel
   (~line 2211), which carries `transform: translateX(100%)` at rest and
   `translateX(0)` when open — a non-none transform makes that element the
   containing block for fixed descendants, at every width, including the
   phone tier where the slide TRANSITION is disabled but the transform
   remains. #chat-thread-panel IS a .page-panel (chat.js just stamps the id
   on an openPage window). So `top`/`left` here resolve from the PANEL's
   top-left corner, not from (0,0) of the screen, and .page-panel's
   `overflow:hidden` clips this popover at the panel edge. On phones the panel
   is the visible strip (`top: var(--vv-top); height: var(--vvh)`, ~line 2761)
   so panel-space and visual-viewport-space differ by exactly --vv-top; at
   >=1024px the panel is the right-hand reading pane and is additionally
   inset by `left: calc(var(--sidebar-w) + 20px + 320px + 16px)` (~line 4176).
   The upside of the same fact: the fixed popover escapes both the bubble's
   box and #chat-thread-scroll's overflow clipping, which is what actually
   fixes the report.

   DISPLAY TOGGLE. Hidden by default here; `.open` shows it. chat.js toggles
   `style.display` inline instead (_openPickerFor, ~2801), and an inline value
   beats both of these declarations in BOTH directions, so this pair is inert
   while that is true and takes over unchanged the moment the inline display
   is dropped. If chat.js ever switches to a class it must be `.open` — that
   is the contract this block states. Same story for `position`, `flex-wrap`
   and `max-width`: chat.js writes all three inline (~2811-2870) with the same
   values this block declares, so the two agree and the declarations here are
   the standalone fallback, not a competing opinion.

   Z-INDEX — chat.js sets 20 inline (~2815) and that wins; the 30 here is the
   fallback for the same reason. Either way it is a small PANEL-LOCAL value,
   not a new top-of-app layer. The popover's stacking context is .page-panel's
   (Overlay hands the panel an inline 300+depth*2, js/config.js ~1214); inside
   it nothing else establishes a context, so the value only has to beat the
   other panel-scoped popovers and chrome: .ms-scroll-fab 2, .ms-mention-dd /
   .ms-emoji-grid 5, .ms-wallpaper-menu 20 (a tie, resolved in this popover's
   favour by document order — it is emitted after the header), and the
   header/composer rows which are position:relative at z-index auto. The
   dev-only stacking lint (window.devCheckStacking, js/app.js ~3798) does NOT
   flag either value: it walks document.body.children only, and this element
   lives deep inside the panel, never as a direct child of <body>.

   DO NOT ADD AN OPAQUE-BACKGROUND-KILLING CHANGE HERE. _ensurePickerSkin
   (js/chat.js ~2910) reads this element's computed backgroundColor and, if it
   is transparent, paints a minimal emergency skin inline — the guard against
   this stylesheet going missing. `background: var(--modal-bg)` below is what
   keeps that fallback dormant (it is opaque in Light/Dark and .92-alpha in
   Astral, none of which the transparency test matches). ══ */
.chat-reaction-picker {
  position: fixed;
  display: none;
  /* THE FIX: wrap. With the emoji sized to exactly six per row below, a
     phone gets a clean two-row pill (reactions / actions) instead of one row
     running off the edge — and any future extra button lands on a new row
     rather than off-box. */
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 4px;
  padding: 6px;
  /* Same popover treatment as every other floating surface in this file —
     .ms-mention-dd (~4989), .ms-emoji-grid (~5005), .ms-wallpaper-menu
     (~5340), .dialog-box (~2387) — rather than a bespoke one. --modal-bg is
     opaque in Light (#FFFFFF) and Dark (#1A1D21) and rgba(13,13,26,.92) in
     Astral, so it reads as a solid pill over a bubble in all three and keeps
     Astral's slight translucency over the wallpaper exactly like the other
     four popovers do. */
  background: var(--modal-bg);
  border: 1px solid var(--border);
  border-radius: var(--r, 14px);
  box-shadow: var(--sh-lg);
  z-index: 30;
  /* Fallback max-width ONLY — chat.js sets the real one inline (clamped to
     the visible strip) and that wins. This exists so the popover still wraps
     rather than stretching if it is ever shown before/without positioning.
     Value copied from .ms-emoji-grid so the two agree. */
  max-width: calc(100vw - 24px);
  /* The finger is still down when this appears (it opens ON the long press),
     so a drag off the bubble must not start selecting the picker's own
     contents. There is no selectable prose in here — only buttons. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  /* OPACITY-ONLY ENTRANCE — NEVER a transform/scale one (popIn, msPopIn…).
     _positionPicker (js/chat.js ~2831) parks this element at its containing
     block's origin, calls getBoundingClientRect() and derives BOTH the
     popover's wrapped size and the origin's viewport coordinates from that
     one synchronous read. getBoundingClientRect() returns the TRANSFORMED
     box, and a transform-based entrance is at its t=0 keyframe on exactly
     that tick — popIn starts at scale(.88) translateY(14px), which would
     hand the positioner a box ~12% too small sitting 14px low and put the
     pill visibly off the bubble it belongs to. Opacity does not participate
     in layout or in the measured rect, so this is invisible to that code.
     msLightboxIn is the existing opacity-only fade in this section. */
  animation: msLightboxIn .16s var(--ease);
}
.chat-reaction-picker.open { display: flex; }
@media (prefers-reduced-motion: reduce) { .chat-reaction-picker { animation: none; } }

/* Buttons. Both kinds are given the SAME cell height so a wrapped row reads
   as a deliberate grid instead of a ragged mix of sizes.
   NOTE ON INLINE STYLES: chat.js writes `font-size:16px; padding:2px 4px`
   inline on .chat-pick-emoji, which beats the font-size/padding here until
   that file drops them; min-width/min-height/display/border-radius are NOT
   set inline, so the tap-target floor below applies either way. */
.chat-reaction-picker .chat-pick-emoji {
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: none; cursor: pointer;
  padding: 2px 4px; font-size: 20px; line-height: 1;
  border-radius: var(--r-sm, 10px);
  transition: background .15s, transform .12s;
}
.chat-reaction-picker .chat-pick-emoji:hover { background: var(--surface-2); }
.chat-reaction-picker .chat-pick-emoji:active { transform: scale(var(--press-scale, .96)); }
/* Copy / Forward / Pin / edit / remove — .ms-act-btn keeps its own colors and
   hover from the base rule above; only the box changes in here. */
.chat-reaction-picker .ms-act-btn {
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: var(--r-sm, 10px);
}
/* chat.js groups the touch-only edit/remove buttons in a bare
   `display:inline-flex` <span> divider group, which is ONE unbreakable flex
   item of this popover. Let that group wrap internally too, so a very narrow
   max-width can never push its last button out of the pill. */
.chat-reaction-picker > span { flex-wrap: wrap; justify-content: center; }
/* The leading group divider has to go — ON COARSE POINTERS ONLY. chat.js
   writes `border-left:1px solid var(--border); padding-left:6px;
   margin-left:2px` inline on the Copy button (js/chat.js ~2593) to fence the
   reactions off from the actions — correct while everything sits on ONE line,
   wrong once the reactions occupy a row of their own: the rule ends up as a
   stray 1px tick hanging off the FIRST button of row two, separating nothing.
   The wrap itself is then what reads as the group boundary.
   WHY THE MEDIA QUERY (it is the whole point of this rule, not decoration):
   that row-of-their-own only exists where the six-per-row `flex` basis below
   applies, i.e. `@media (pointer: coarse)`. With a FINE pointer nothing wraps
   — measured at 375x812, all ten children land on one row — and killing the
   border there deletes the only group separator the picker has, because
   isTouchUI (hover:none) is false so chat.js does not emit the touch-actions
   <span> that carries the second divider either. Unscoped, this rule left the
   desktop picker with ZERO separation between the emoji and Copy/Forward/Pin,
   a regression against HEAD. Keep the condition identical to the sizing
   block's.
   The second divider (on the touch-actions <span>, between Pin and the
   edit/remove group) still sits mid-row and is deliberately left alone.
   `!important` is unavoidable — the declarations it overrides are inline —
   and the adjacent-sibling selector matches ONLY the action button that
   directly follows an emoji, i.e. exactly that one leading divider. Delete
   this rule the moment chat.js stops writing those three properties. */
@media (pointer: coarse) {
  .chat-reaction-picker .chat-pick-emoji + .ms-act-btn {
    border-left: none !important;
    padding-left: 0 !important;
    margin-left: 0 !important;
  }
}

/* ── Touch sizing. Two separate problems solved together ──
   (a) TAP TARGETS. The emoji buttons compute to ~24x20 from their inline
       `font-size:16px; padding:2px 4px` — half the 44px floor this file
       enforces everywhere else. .ms-act-btn is already in the coarse-pointer
       list at the top of the file (~line 122), but it gets there via an
       invisible 44x44 ::after OVERLAY rather than real size, which works for
       a lone corner button and works badly here: a dozen ~26px buttons on one
       row means a dozen 44px phantom areas overlapping each other by ~18px,
       so neighbouring taps steal each other. Real geometry instead — the
       ::after then sits exactly coincident with the real box and does
       nothing.
   (b) THE GROUPING. `flex: 0 0 calc((100% - 5*gap) / 6)` makes the six
       reactions (REACTIONS, js/chat.js:23) fill exactly one row at ANY
       max-width chat.js hands us (_positionPicker, ~2831), which forces every action button onto the
       next row. That is what makes the wrap read as "reactions, then
       actions" rather than as an arbitrary break wherever the arithmetic
       happened to land — and it holds without this file needing to know the
       max-width, which it does not own. min-width keeps the cell usable if
       the popover is ever squeezed narrower than 6x40px, at the cost of
       falling back to a plain wrap there (still never clipped). ── */
@media (pointer: coarse) {
  .chat-reaction-picker .chat-pick-emoji,
  .chat-reaction-picker .ms-act-btn { min-height: 44px; }
  .chat-reaction-picker .ms-act-btn { min-width: 44px; }
  .chat-reaction-picker .chat-pick-emoji {
    flex: 0 0 calc((100% - 5 * 4px) / 6);
    min-width: 40px;
  }
}

/* ── Upper bound for coarse pointers WIDER THAN A PHONE (tablet, foldable,
   touchscreen laptop) ──
   The `flex: 0 0 calc(100%/6)` basis above has no cap, and the popover itself
   is shrink-to-fit at max-content whenever the inline clamp is generous, so
   the six cells simply grow with the device. Measured on the pre-fix build,
   own message with all 12 buttons, emoji cell width by viewport width:
   320→44, 375→53, 430→63, 480→71, 540→81, and 600px upward it saturates at
   the 571px max-content pill with 89x44 cells — beside a 286px bubble, six
   enormous emoji and a half-empty second row. Nothing is clipped (this is
   presentation, not function), but it is a regression against HEAD's compact
   single row at every width above a phone.
   WHY `width` AND NOT `max-width`: _positionPicker (js/chat.js ~2831) writes
   max-width INLINE as the real clamp (band minus margins) and inline wins
   over this file. A plain `width` cooperates with that clamp instead of
   fighting it — max-width always beats width when both apply — so a narrow
   band still shrinks the pill exactly as before (measured at bands of 420 /
   300 / 240 / 160px: pill 340 / 280 / 220 / 140, i.e. the clamp keeps
   winning) while a generous band no longer lets it balloon. 340px keeps the
   phone pill's proportions (cells land at 51px, between the 320 tier's 44 and
   the 375 tier's 53) and still leaves the four action items — Copy, Forward,
   Pin and the 3-button touch-actions <span>, 284px at their 44px floor — on
   one row.
   SCOPED TO `pointer: coarse` because the flex basis it bounds is; the
   fine-pointer picker has no basis, sizes to its single row of content, and
   would just become a mostly-empty 340px pill.
   480px, NOT this file's usual 768px phone boundary: the defect starts
   immediately above phone widths (a 768px iPad portrait balloons to the full
   571px pill), and 480 is the first round number clear of the widest phone
   portrait in use (430px). Both measured phone cases stay byte-identical —
   375 → 355x106 / 53x44, 320 → 300x154 / 44x44, unchanged. ── */
@media (pointer: coarse) and (min-width: 480px) {
  .chat-reaction-picker { width: 340px; }
}

/* Read receipts: reader avatars (shared with task-chat "Seen by …" text row)
   + own-message sent/read glyphs (Lucide, chat.js only). */
.ms-seen {
  font-size: var(--fs-2xs); color: var(--text-muted); margin-top: 2px;
  display: flex; align-items: center; gap: 4px;
}
.ms-seen .ms-avatar { width: 14px; height: 14px; font-size: 0.4375rem; }
.ms-seen::before { content: '✓✓'; font-size: var(--fs-xs); color: var(--primary); }
.ms-status { display: flex; align-items: center; margin-top: 2px; color: var(--text-muted); }
.ms-status svg { width: 13px; height: 13px; }
/* Wave1 P2 fix #16 — .ms-status-read (dead CSS, confirmed unused: no element
   in js/chat.js or js/departments.js ever sets this class) removed. */

/* Day / time-gap separators (Phase 17) */
.ms-day-sep { text-align: center; margin: 14px 0 8px; }
.ms-day-sep span {
  font-size: 0.6875rem; font-weight: 700; color: var(--text-muted);
  background: var(--surface-2); padding: 3px 12px; border-radius: var(--pill);
}
.ms-time-sep { text-align: center; margin: 10px 0 4px; font-size: 0.6875rem; color: var(--text-muted); }

/* ══════════════════════════════
   Wave5 Batch M1 — messaging core feel
   (optimistic send, drafts, unread divider/badges, scroll FAB, tombstones)
══════════════════════════════ */

/* "New messages" divider (J7) — same pill language as .ms-day-sep, distinct
   accent color so it reads as a boundary marker rather than a date. */
.ms-new-divider { text-align: center; margin: 14px 0 10px; display: flex; align-items: center; gap: 10px; }
.ms-new-divider::before, .ms-new-divider::after {
  content: ''; flex: 1; height: 1px; background: var(--primary-soft, var(--border));
}
.ms-new-divider span {
  font-size: 0.6875rem; font-weight: 700; color: var(--primary);
  background: var(--primary-soft); padding: 3px 12px; border-radius: var(--pill);
  white-space: nowrap;
}

/* Optimistic send bubbles (J2) — pending (○ sending) / failed (⚠ tap-to-retry).
   Rendered in their own tail container (#chat-pending-tail, see chat.js
   _ensurePendingTailEl) — visually just another .ms-row-mine bubble. */
.ms-bubble-pending { opacity: .6; }
.ms-bubble-failed {
  cursor: pointer;
  background: color-mix(in srgb, var(--danger, #FF3B30) 14%, var(--bubble-out-bg));
  border: 1px solid color-mix(in srgb, var(--danger, #FF3B30) 45%, transparent);
}
.ms-pending-status { font-size: 11px; line-height: 1; opacity: .85; }
.ms-pending-retry-label { font-size: var(--fs-2xs); color: var(--danger, #FF3B30); font-weight: 600; }

/* Unsend tombstone (J3) — italic placeholder, muted, no tap affordance. */
.ms-bubble-tombstone { cursor: default; opacity: .75; }
.ms-tombstone-text { font-style: italic; color: var(--text-muted); }

/* Scroll-to-bottom FAB (J7) — a sibling of #chat-thread-scroll inside
   #chat-thread-scroll-wrap (position:relative, non-scrolling), so it stays
   visually fixed regardless of the message list's own scroll offset. Small
   local z-index — scoped to that wrapper's own stacking context, not a new
   top-of-app overlay layer. */
.ms-scroll-fab {
  position: absolute; right: 14px; bottom: 14px; z-index: 2;
  width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--border);
  background: var(--surface); color: var(--text); box-shadow: 0 2px 10px rgba(0,0,0,0.18);
  display: flex; align-items: center; justify-content: center; cursor: pointer;
  transition: transform .12s, background .15s;
}
.ms-scroll-fab:hover { transform: scale(1.06); background: var(--surface-2); }
.ms-scroll-fab-badge {
  position: absolute; top: -4px; right: -4px;
  /* v14 a11y fix: --pink-badge-bg (not --pink) — see notif-badge above for
     the contrast rationale. */
  background: var(--pink-badge-bg); color: #fff; font-size: 0.5625rem;
  min-width: 16px; height: 16px; padding: 0 4px; border-radius: var(--pill);
  display: flex; align-items: center; justify-content: center; font-weight: 700;
  box-shadow: 0 0 6px var(--pink-glow);
}

/* Send pop-in micro-interaction (Phase 19) — transform/opacity only, and only
   applied to genuinely-new bubbles (see chat.js _lastMsgIds bookkeeping), so a
   snapshot re-render from an edit/reaction never replays it on old messages. */
@keyframes msPopIn { from { transform: scale(.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.ms-bubble.ms-pop-in { animation: msPopIn 150ms cubic-bezier(.34,1.56,.64,1); transform-origin: bottom right; }
.ms-row-theirs .ms-bubble.ms-pop-in { transform-origin: bottom left; }
@media (prefers-reduced-motion: reduce) { .ms-bubble.ms-pop-in { animation: none; } }

/* Typing indicator — incoming mini-bubble with 3 bouncing dots (Phase 19) */
.ms-typing-row { max-width: 72%; }
.ms-typing-bubble { display: flex; align-items: center; gap: 4px; padding: 10px 14px; cursor: default; }
.ms-typing-dot {
  width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted);
  animation: msTypingBounce 1.2s infinite ease-in-out;
}
.ms-typing-dot:nth-child(2) { animation-delay: .15s; }
.ms-typing-dot:nth-child(3) { animation-delay: .3s; }
@keyframes msTypingBounce {
  0%, 60%, 100% { transform: translateY(0); opacity: .5; }
  30% { transform: translateY(-4px); opacity: 1; }
}
.ms-typing-names { font-size: 0.6875rem; color: var(--text-muted); margin: 2px 0 0 32px; }
@media (prefers-reduced-motion: reduce) { .ms-typing-dot { animation: none; opacity: .8; } }

/* ── Composer (Phase 19) — pill auto-grow textarea + circular icon buttons ── */
.messenger-input-row {
  display: flex; align-items: flex-end; gap: 6px;
  /* WS42 Phase 11: every consumer of this row (task chat, chat.js thread panel) is
     bottom:0-anchored full-screen, so it always needs home-indicator clearance. */
  padding: 8px 10px calc(8px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--border);
  background: var(--chat-bg);
  /* Wave5 M2 — positioning context for the @mention typeahead and emoji-grid
     popovers (both anchor bottom:100% off THIS row, not the scroll container). */
  position: relative;
}
/* Owner request — the DM/group chat thread (js/chat.js, always rendered
   inside the #chat-thread-panel pushed page) showed a visible gray divider
   directly above "Type a message", reading as a seam between the messages
   area and the composer instead of one seamless surface. Scoped to that
   page id only — the task/quote/bidding doc-comments composer
   (js/departments.js) shares this same .messenger-input-row class and keeps
   its border (a real "own section" of a page, not this request). */
#chat-thread-panel .messenger-input-row { border-top: none; }
/* SAFE-AREA DOUBLE-COUNT (recon Hazard 11) — the composer's twin of the
   .page-panel-foot fix in the ≤768px block. The shorthand above adds
   env(safe-area-inset-bottom) for the home indicator on the premise (stated in
   its own comment) that every consumer is a bottom:0-anchored full-screen
   surface. Under the v14 mobile window model that surface now ends at the TOP
   EDGE OF THE KEYBOARD, where the home indicator is not drawn — but iOS still
   reports a non-zero inset, so the composer would float ~34px above the keys.
   Subtract the keyboard height (--kb-h, published on <html> by
   window.ViewportSync in js/config.js) and clamp at 0:
     keyboard up   → max(0px, 34px - ~300px) = 0px  → flat 8px
     keyboard down → max(0px, 34px - 0px)    = 34px → unchanged from before
   Longhand, so the 8px top / 10px sides of the shorthand above survive.
   Deliberately NOT width-scoped and NOT scoped to #chat-thread-panel: the same
   row is also the task/doc comment composer (js/departments.js), which flows
   inside .page-panel-body's scroller rather than being viewport-anchored. Its
   keyboard-CLOSED value is unchanged, so this is a no-op for that consumer and
   for every viewport wider than a phone (--kb-h is 0px there, and the inset is
   0px on non-notched screens). */
/* PHANTOM SAFE AREA (2026-08-07) — env(safe-area-inset-bottom) is the inset of
   the SCREEN's system UI, and iOS reports it even when the layout viewport does
   not reach the screen bottom, i.e. when the home indicator is not inside our
   box at all. Measured on the owner's iPhone from a pixel decomposition of his
   own screenshot: the panel's bottom edge sits 59 CSS px above the physical
   screen bottom while env(safe-area-inset-bottom) still reads 34, so this rule
   was reserving 34px to clear a bar 59px below the bottom of our own viewport.
   --sab-eff (js/config.js, window.ViewportSync) is that inset with the
   unpainted band subtracted — the part of it genuinely inside our viewport.
   The env() fallback keeps the old value verbatim if ViewportSync has not run
   yet, and on a device whose viewport DOES reach the screen bottom the two are
   equal, so nothing changes there. */
.messenger-input-row {
  padding-bottom: calc(8px + max(0px, var(--sab-eff, env(safe-area-inset-bottom, 0px)) - var(--kb-h, 0px)));
}
/* …and the reason the REMAINING, legitimate clearance still reads as a defect:
   in dark theme --chat-bg and --bg are byte-identical (#0F1114, contrast
   1.000), so .messenger-input-row, .messenger-body above it and .page-panel
   behind it are four stacked layers of one colour — and this page's border-top
   was deliberately removed (see the rule above; the owner asked for that seam
   to go). The composer therefore has no surface of its own: what the eye sees
   is a pill and a send button floating on unbroken dark, and the safe-area
   strip beneath them has no object to belong to, so it reads as void rather
   than as the bar's body. Every other bottom-anchored surface in the app
   already has its own fill (.page-panel-foot and .ms-thread-header both use
   var(--surface)); this was the sole exception, and the sole thing complained
   about. A background — not a border — fixes the read without bringing back
   the seam. Scoped to the chat thread: the task/doc comment composer keeps its
   own look. */
#chat-thread-panel .messenger-input-row { background: var(--surface); }
.ms-attach-btn {
  width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; color: var(--text-muted); background: transparent;
  transition: background .15s, color .15s;
}
.ms-attach-btn:hover { background: var(--surface-2); color: var(--text); }
/* Messenger restyle Fix 5 — the 3 attach controls collapse into one ➕
   toggle; .ms-attach-expand is just another item in .messenger-input-row's
   own flex layout (no popover/position:absolute needed — showing/hiding it
   simply changes which icons occupy the row). The toggle icon rotates 45°
   into an "×" read while expanded. */
.ms-attach-expand { display: flex; align-items: center; gap: 2px; }
.ms-attach-expand.hidden { display: none; }
.ms-attach-toggle-icon { transition: transform .18s ease; }
.ms-attach-toggle.ms-attach-toggle-open .ms-attach-toggle-icon { transform: rotate(45deg); }
.ms-input {
  flex: 1; min-width: 0; border: none; border-radius: var(--pill);
  padding: 9px 16px; background: var(--surface-2); color: var(--text);
  font-size: var(--fs-base); line-height: var(--lh-snug); outline: none;
  resize: none; max-height: 120px; overflow-y: auto;
  font-family: inherit;
  transition: box-shadow .15s;
}
.ms-input:focus { box-shadow: 0 0 0 3px var(--primary-soft); }
.ms-send-btn {
  width: 36px; height: 36px; border-radius: 50%;
  background: var(--primary-fill, var(--primary)); color: var(--on-primary, #fff); border: none;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; flex-shrink: 0; transition: background .15s, transform .1s;
}
.ms-send-btn:hover:not(:disabled) { transform: scale(1.06); }
.ms-send-btn:disabled { opacity: .4; cursor: default; }

/* ══════════════════════════════
   Wave5 Batch M2 — reply, forward, mentions, emoji
══════════════════════════════ */

/* Reply quote block — rendered INSIDE the bubble, above the text/attachment
   (real messages: _renderMessagePart; optimistic bubbles: _renderPendingBubble
   — both funnel through the SAME _replyQuoteHtml() so the markup matches).
   mine/theirs variants: the accent bar reads against each bubble's own bg. */
.ms-reply-quote {
  display: block; margin-bottom: 5px; padding: 4px 8px;
  border-left: 3px solid var(--primary); border-radius: 4px;
  background: color-mix(in srgb, currentColor 8%, transparent);
  cursor: pointer; max-width: 100%;
}
.ms-bubble-mine .ms-reply-quote { border-left-color: rgba(255,255,255,.7); }
.ms-reply-quote-author { font-size: var(--fs-2xs); font-weight: 700; opacity: .85; }
.ms-reply-quote-snippet {
  font-size: var(--fs-xs); opacity: .75;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* Composer quoted-snippet chip (armed via swipe or the hover ↩ button). */
.ms-reply-chip {
  display: flex; align-items: stretch; gap: 8px;
  margin: 0 14px 6px; padding: 6px 8px;
  background: var(--surface-2); border-radius: var(--r-sm, 10px);
}
.ms-reply-chip.hidden { display: none; }
.ms-reply-chip-bar { width: 3px; border-radius: 2px; background: var(--primary); flex-shrink: 0; }
.ms-reply-chip-body { flex: 1; min-width: 0; }
.ms-reply-chip-author { font-size: var(--fs-2xs); font-weight: 700; color: var(--primary); }
.ms-reply-chip-snippet {
  font-size: var(--fs-xs); color: var(--text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ms-reply-chip-close {
  background: none; border: none; cursor: pointer; color: var(--text-muted);
  font-size: var(--fs-sm); padding: 0 4px; flex-shrink: 0; align-self: center;
}
.ms-reply-chip-close:hover { color: var(--text); }

/* "Tap the quote to jump there" flash (chat.js _scrollToMessage). */
@keyframes msFlash {
  0%, 100% { box-shadow: none; }
  30% { box-shadow: 0 0 0 3px var(--primary-soft); }
}
.ms-flash { animation: msFlash .9s ease; }
@media (prefers-reduced-motion: reduce) {
  .ms-flash { animation: none; box-shadow: 0 0 0 3px var(--primary-soft); }
}

/* "Forwarded" label — sits above the bubble like .ms-name, own messages
   right-align to match the row's own alignment. */
.ms-forwarded-label {
  font-size: var(--fs-2xs); color: var(--text-muted); font-style: italic;
  display: flex; align-items: center; gap: 3px; padding: 0 4px; margin-bottom: 1px;
}
.ms-forwarded-label svg { width: 10px; height: 10px; }
.ms-row-mine .ms-forwarded-label { justify-content: flex-end; }

/* @mention highlight — applied AFTER escHtml by chat.js _highlightMentions,
   so this only ever wraps text that was already safely escaped. */
.ms-mention {
  font-weight: 700; color: var(--primary);
  background: var(--primary-soft); border-radius: 4px; padding: 0 2px;
}
.ms-bubble-mine .ms-mention { color: inherit; background: rgba(255,255,255,.22); }

/* @mention typeahead dropdown — anchored off .messenger-input-row (position:
   relative, see above), opens UPWARD (bottom:100%) since the composer sits
   at the bottom of the thread panel. Small local z-index scoped to that one
   positioned ancestor's stacking context — not a new top-of-app layer,
   mirrors .ms-wallpaper-menu's z-index:20 immediately above. */
.ms-mention-dd {
  position: absolute; left: 6px; right: 6px; bottom: calc(100% + 4px);
  background: var(--modal-bg); border: 1px solid var(--border);
  border-radius: var(--r, 14px); box-shadow: var(--sh-lg);
  max-height: 200px; overflow-y: auto; padding: 4px; z-index: 5;
}
.ms-mention-dd.hidden { display: none; }
.ms-mention-opt {
  display: block; width: 100%; text-align: left;
  padding: 7px 10px; border-radius: var(--r-sm, 10px); border: none;
  background: none; color: var(--text); font-size: var(--fs-base); cursor: pointer;
}
.ms-mention-opt:hover { background: var(--surface-2); }

/* Composer emoji picker — compact static grid (REACTIONS + EMOJI_GRID, no
   library), same anchoring convention as the mention dropdown above. */
.ms-emoji-grid {
  position: absolute; left: 6px; bottom: calc(100% + 4px);
  display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px;
  background: var(--modal-bg); border: 1px solid var(--border);
  border-radius: var(--r, 14px); box-shadow: var(--sh-lg);
  padding: 8px; width: 264px; max-width: calc(100vw - 24px); z-index: 5;
}
.ms-emoji-grid.hidden { display: none; }
.ms-emoji-opt {
  background: none; border: none; cursor: pointer; font-size: 18px;
  line-height: 1; padding: 5px; border-radius: 6px;
}
.ms-emoji-opt:hover { background: var(--surface-2); }

/* ══════════════════════════════
   Wave5 Batch M3 — media + lightbox
══════════════════════════════ */

/* Messenger-style multi-photo grid bubble (J4) — 1=full-width, 2=split,
   3+=uniform grid with a "+N" overlay on the last shown tile past the
   6-photo/message cap. Shared by BOTH the confirmed-message renderer
   (_mediaGridHtml) and the optimistic pending-bubble preview
   (_renderPendingBubble), and by the Shared Media tab's thumbnail grid
   (.chat-mediatab-grid, its own layout below — same tile look, different
   grid shape since it's not "one message's photos" but "every photo"). */
.ms-media-grid {
  display: grid; gap: 2px; border-radius: var(--r-sm, 10px); overflow: hidden;
  max-width: 260px;
}
.ms-media-1 { grid-template-columns: 1fr; }
/* Single-photo tile: aspect-ratio:auto alone would leave the tile with no
   definite height for the img's height:100% (below) to resolve against —
   give the tile an intrinsic size via its own img (object-fit:contain,
   natural size up to the cap) instead of the cover/aspect-ratio:1 grid tiles use. */
.ms-media-1 .ms-media-tile { aspect-ratio: auto; height: auto; max-height: 260px; }
.ms-media-1 .ms-media-tile img { width: auto; height: auto; max-width: 100%; max-height: 260px; object-fit: contain; }
.ms-media-2 { grid-template-columns: 1fr 1fr; }
.ms-media-grid3 { grid-template-columns: 1fr 1fr; }

/* ── Chat photo-lag fix — reserve the photo's box BEFORE its bytes arrive ──
   Everything above sizes a media bubble from its IMAGE. That works once the
   photo has loaded and fails completely before it has: the .ms-row →
   .ms-bubble-wrap → .ms-bubble → .ms-media-grid chain is entirely
   shrink-to-fit, so an un-loaded (0x0) <img> collapses the grid column to 0
   wide, and an aspect-ratio resolved against a 0 width is 0 tall. Measured in
   WebKit at iPhone width: EVERY photo bubble — with stored w/h, without it,
   single or grid — reserved 0x0 while downloading, leaving a 16px bubble
   holding nothing but the timestamp and the sent tick. That is the empty
   bubble in the owner's screenshot, and it is why photos "keep lagging": on
   mobile data the bubble stays empty for the whole download.
   The missing ingredient in every case is a definite WIDTH. */

/* 2+ photos: the grid was already capped at 260px but never given a width, so
   it had none until the images loaded. Same 260px, now definite — and
   max-width:100% so a bubble narrower than 260px (very small screens, or a
   deeply indented row) shrinks it instead of letting it spill out. */
.ms-media-grid.ms-media-2, .ms-media-grid.ms-media-grid3 { width: 260px; max-width: 100%; }

/* Single photo WITH stored dimensions — chat.js sets an inline width + exact
   aspect-ratio computed from w/h, so the reserved box equals the loaded box
   to the pixel and nothing shifts when the photo paints. The aspect-ratio
   keeps the height correct even if max-width has to shrink the width. */
.ms-media-1 .ms-media-tile.ms-tile-sized { height: auto; max-height: none; max-width: 100%; }
.ms-media-1 .ms-media-tile.ms-tile-sized img {
  width: 100%; height: 100%; max-width: none; max-height: none; object-fit: contain;
}

/* Single photo with NO stored dimensions — legacy docs written before w/h were
   always recorded. Nothing can know the true shape here, so this is a min-size
   FLOOR rather than an exact box: a real placeholder while it downloads, then
   natural sizing (identical to the pre-fix loaded look) once it paints. */
.ms-media-1 .ms-media-tile.ms-tile-unsized {
  width: auto; height: auto; aspect-ratio: auto;
  min-width: 180px; min-height: 135px; max-height: 260px;
}
.ms-media-1 .ms-media-tile.ms-tile-unsized img {
  width: auto; height: auto; max-width: 100%; max-height: 260px; object-fit: contain;
}

/* Legacy single-image messages (m.fileUrl, no media[]) have no stored w/h
   either, and measured 0px tall while loading for the same reason. */
.ms-legacy-img { min-width: 160px; min-height: 120px; object-fit: contain; background: var(--surface-2); }

/* Determinate upload progress in the optimistic bubble. The UploadTask's
   state_changed bytes were always available and never used, so a photo send
   over mobile data showed a motionless ⏳ — indistinguishable from a stuck one. */
.ms-pending-bar {
  display: inline-block; vertical-align: middle;
  width: 46px; height: 3px; margin-left: 5px;
  border-radius: 2px; overflow: hidden;
  background: color-mix(in srgb, currentColor 25%, transparent);
}
.ms-pending-bar > i {
  display: block; height: 100%; width: 0;
  border-radius: 2px; background: currentColor;
  transition: width .18s linear;
}
.ms-media-tile { position: relative; overflow: hidden; aspect-ratio: 1; background: var(--surface-2); }
.ms-media-tile img { width: 100%; height: 100%; object-fit: cover; display: block; cursor: pointer; }
.ms-media-more {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  background: rgba(0,0,0,0.55); color: #fff; font-size: var(--fs-lg); font-weight: 700;
  pointer-events: none;
}

/* Drag-and-drop highlight (J4, desktop) — chat.js toggles .ms-drop-active on
   the thread panel (#chat-thread-panel) while a file is dragged over it. */
#chat-thread-panel.ms-drop-active #chat-thread-scroll-wrap {
  outline: 2px dashed var(--primary); outline-offset: -2px;
  position: relative;
}
#chat-thread-panel.ms-drop-active #chat-thread-scroll-wrap::after {
  content: 'Drop photo to attach';
  position: absolute; inset: 0; z-index: 3;
  display: flex; align-items: center; justify-content: center;
  background: color-mix(in srgb, var(--primary) 12%, transparent);
  color: var(--primary); font-weight: 700; font-size: var(--fs-md);
  pointer-events: none;
}

/* ── Lightbox (J1) — full-window overlay riding the Overlay stack; its
   z-index is set dynamically by Overlay.push(kind, teardown, el) in chat.js
   _openLightbox — never a literal in this file. */
.ms-lightbox {
  position: fixed; inset: 0; background: rgba(6,6,10,0.94);
  display: flex; flex-direction: column;
  animation: msLightboxIn .15s ease;
}
@keyframes msLightboxIn { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .ms-lightbox { animation: none; } }
.ms-lightbox-top {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: calc(10px + env(safe-area-inset-top,0px)) 14px 10px;
  flex-shrink: 0;
}
.ms-lightbox-close, .ms-lightbox-download {
  width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,0.12); color: #fff; border: none; cursor: pointer;
  text-decoration: none;
}
.ms-lightbox-close:hover, .ms-lightbox-download:hover { background: rgba(255,255,255,0.22); }
.ms-lightbox-count { color: rgba(255,255,255,0.85); font-size: var(--fs-sm); font-weight: 600; }
.ms-lightbox-stage {
  flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center;
  overflow: hidden; position: relative; touch-action: none;
}
.ms-lightbox-img {
  max-width: 100%; max-height: 100%; object-fit: contain;
  touch-action: none; user-select: none; -webkit-user-select: none;
  transition: transform .08s linear, opacity .08s linear;
  will-change: transform;
}
.ms-lightbox-img.ms-lightbox-dragging { transition: none; }
.ms-lightbox-nav {
  position: absolute; top: 50%; transform: translateY(-50%);
  width: 40px; height: 40px; border-radius: 50%;
  background: rgba(255,255,255,0.12); color: #fff; border: none; cursor: pointer;
  display: none; align-items: center; justify-content: center; font-size: 22px; line-height: 1;
}
.ms-lightbox-nav:hover { background: rgba(255,255,255,0.22); }
.ms-lightbox-prev { left: 12px; }
.ms-lightbox-next { right: 12px; }
@media (hover: hover) { .ms-lightbox-nav.ms-lightbox-multi { display: flex; } }

/* ── Shared Media/Files/Links tab (J4) — thread-info page opened from the
   ⓘ button; body built once from a one-shot query (_openMediaTab), chip-
   switched between the three panes. ── */
.chat-mediatab-grid {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px;
}
.chat-mediatab-thumb { aspect-ratio: 1; overflow: hidden; border-radius: var(--r-sm, 8px); cursor: pointer; background: var(--surface-2); }
.chat-mediatab-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.chat-mediatab-file-row {
  display: flex; align-items: center; gap: 10px; padding: 10px 4px;
  border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text);
}
.chat-mediatab-file-row:last-child { border-bottom: none; }
.chat-mediatab-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.chat-mediatab-file-date { font-size: var(--fs-2xs); color: var(--text-muted); flex-shrink: 0; }

/* ── Chat inbox rows (Phase 16) ── */
/* Messenger restyle Fix 2 — slim inbox header (replaces the big .page-header
   title + big "+ New Message" button band): "Chats" wordmark-weight title
   left, one small round compose-icon button right. .ms-compose-btn reuses
   .ms-thread-menu-btn's exact round-icon-button chrome (width/height/hover) —
   no new button style, just the shared token-driven one. */
.ms-inbox-header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 6px 0 4px;
}
.ms-inbox-header-title {
  font-family: var(--font-display); font-size: var(--fs-2xl); font-weight: 800;
  letter-spacing: -0.4px; color: var(--text);
}
.ms-compose-btn { color: var(--primary); }
.ms-search-wrap { margin: 10px 0; }
.ms-search-input {
  width: 100%; border: 1px solid var(--border); border-radius: var(--pill);
  padding: 9px 16px; background: var(--surface-2); color: var(--text);
  font-size: var(--fs-base); outline: none; transition: box-shadow .15s;
}
.ms-search-input:focus { box-shadow: 0 0 0 3px var(--primary-soft); }
/* Messenger rows sit contiguous (no card-gap between them) — scoped to
   #chat-inbox only so .item-list's normal 8px gap is untouched everywhere
   else it's used (tasks, finance, etc.). */
#chat-inbox .item-list { gap: 0; }
/* Messenger restyle Fix 1 — flat, borderless, full-bleed row (no .item-card
   shell — chat.js no longer applies that class here). Own layout, own hover/
   active feedback; nothing here is shared with the generic item-card look
   used by tasks/finance/etc. elsewhere in the app. */
.chat-inbox-row {
  min-height: 64px; padding: 8px 14px;
  display: flex; align-items: center; gap: 12px; cursor: pointer;
  background: transparent;
}
/* Specificity matches .ms-inbox-row-wrap .chat-inbox-row's own unconditional
   background rule below (Wave5 M4 section) so hover reliably wins regardless
   of source order. */
@media (hover: hover) { .ms-inbox-row-wrap .chat-inbox-row:hover { background: var(--surface-2); } }
.chat-inbox-row-body { flex: 1; min-width: 0; }
.chat-inbox-row-name { display: flex; align-items: center; gap: 6px; }
.chat-inbox-row-name-text {
  font-weight: 600; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Owner request — DM inbox rows: the other person's role/dept, small and
   muted, between their name and the last-message preview. */
.chat-inbox-row-title {
  font-size: var(--fs-xs); font-weight: 500; color: var(--text-muted); opacity: 0.8;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chat-inbox-row-preview {
  font-size: var(--fs-sm); font-weight: 400; color: var(--text-muted); margin-top: 1px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Unread: bold name/preview (Messenger convention) + a small blue dot on the
   right — no numeric badge (exact per-message unread counts aren't tracked;
   a dot never overclaims a specific number). */
.chat-inbox-row.ms-inbox-unread .chat-inbox-row-name-text { font-weight: 700; }
.chat-inbox-row.ms-inbox-unread .chat-inbox-row-preview { font-weight: 600; color: var(--text); }
.chat-inbox-row .ms-unread-dot {
  width: 10px; height: 10px; border-radius: 50%; background: var(--primary);
  flex-shrink: 0; margin-left: 2px;
}
.chat-inbox-row .ms-presence-dot {
  position: absolute; bottom: -1px; right: -1px; width: 12px; height: 12px;
  border-radius: 50%; border: 2px solid var(--surface);
}

/* ══════════════════════════════
   Wave5 Batch M4 — conversation management + cost
   (pin/mute/archive, group admin About section, ⋯ menu)
══════════════════════════════ */

/* Inbox row shell — .ms-inbox-row-wrap holds the swipe-actions row UNDER the
   real .chat-inbox-row content (which slides left via inline transform, see
   chat.js _onInboxSwipeMove/_toggleConvFlag). position:relative + overflow
   hidden is what lets the actions row hide off the right edge at rest. */
/* Messenger restyle Fix 1 — full-bleed rows (no rounded-card corners; the
   pinned tint is the only per-row background cue now). */
.ms-inbox-row-wrap { position: relative; overflow: hidden; }
.ms-inbox-row-wrap .chat-inbox-row { position: relative; z-index: 1; background: var(--surface); transition: transform .18s ease; }
.ms-inbox-swipe-actions {
  position: absolute; top: 0; right: 0; bottom: 0; width: 132px;
  display: flex; align-items: stretch; z-index: 0;
}
.ms-inbox-act {
  flex: 1; border: none; cursor: pointer; color: #fff;
  display: flex; align-items: center; justify-content: center;
  background: var(--text-muted);
}
.ms-inbox-act-pin { background: var(--primary); }
.ms-inbox-act-mute { background: var(--gold-3, #FF9F0A); }
.ms-inbox-act-archive { background: var(--danger, #FF3B30); }

/* Pinned rail — pinned rows float to the top of the list (chat.js sorts them
   there); a subtle tint + the pin glyph next to the title are the only visual
   cue beyond position, so they don't compete with the unread badge. */
.ms-inbox-pinned-label {
  display: flex; align-items: center; gap: 4px; padding: 6px 10px 2px;
  font-size: var(--fs-2xs); font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
  color: var(--text-muted);
}
.ms-inbox-pinned-label i, .ms-inbox-pinned-label svg { width: 11px; height: 11px; }
.ms-inbox-row-wrap.ms-inbox-pinned .chat-inbox-row { background: color-mix(in srgb, var(--primary) 5%, var(--surface)); }
.ms-inbox-pin-glyph, .ms-inbox-mute-glyph { width: 12px; height: 12px; color: var(--text-muted); flex-shrink: 0; }

/* Desktop hover ⋯ menu ONLY (Messenger restyle Fix 1: "no visible per-row ⋯
   on mobile" — swipe reveals the same Pin/Mute/Archive actions there
   instead, see .ms-inbox-swipe-actions). The button still renders in the DOM
   on touch devices (its data-cid wiring is unconditional in chat.js) but is
   fully hidden there rather than dimly visible. */
.ms-row-more-btn {
  width: 30px; height: 30px; flex-shrink: 0; border-radius: 50%;
  background: none; border: none; cursor: pointer; color: var(--text-muted);
  display: flex; align-items: center; justify-content: center;
  opacity: 0; transition: opacity .15s, background .15s, color .15s;
}
@media (hover: hover) {
  .ms-inbox-row-wrap:hover .ms-row-more-btn { opacity: .7; }
  .ms-row-more-btn:hover { opacity: 1; background: var(--surface-2); color: var(--text); }
}
@media (hover: none) { .ms-row-more-btn { display: none; } }
.ms-row-menu {
  position: absolute; top: 44px; right: 10px; z-index: 20;
  background: var(--modal-bg); border: 1px solid var(--border);
  border-radius: var(--r, 14px); box-shadow: var(--sh-lg);
  padding: 6px; min-width: 170px;
}
.ms-row-menu.hidden { display: none; }
.ms-row-menu-item {
  display: block; width: 100%; text-align: left;
  padding: 8px 10px; border-radius: var(--r-sm, 10px); border: none;
  background: none; color: var(--text); font-size: var(--fs-base); cursor: pointer;
}
.ms-row-menu-item:hover { background: var(--surface-2); }
.ms-row-menu-note { padding: 8px 10px; font-size: var(--fs-2xs); color: var(--text-muted); max-width: 200px; }

/* About section (thread info page, above the Media/Files/Links chips) —
   group photo/name/members; creator-or-admin sees the edit affordances,
   everyone else (incl. dm/dept) sees a plain read-only header. */
.chat-about { text-align: center; padding: 6px 8px 14px; }
.chat-about-avatar-wrap { position: relative; display: inline-block; margin-bottom: 8px; }
.chat-about-avatar { width: 72px; height: 72px; font-size: var(--fs-lg); margin: 0 auto; }
.chat-about-avatar[title] { cursor: pointer; }
.chat-about-avatar-edit {
  position: absolute; bottom: -2px; right: -2px; width: 24px; height: 24px;
  border-radius: 50%; background: var(--primary); color: #fff;
  display: flex; align-items: center; justify-content: center;
  border: 2px solid var(--surface); pointer-events: none;
}
.chat-about-title-row { display: flex; align-items: center; justify-content: center; gap: 4px; }
.chat-about-title { font-size: var(--fs-lg); font-weight: 700; color: var(--text); }
.chat-about-subtitle { font-size: var(--fs-xs); color: var(--text-muted); margin-top: 2px; }
.chat-about-members { text-align: left; margin-top: 14px; max-height: 220px; overflow-y: auto; }
.chat-about-member-row { display: flex; align-items: center; gap: 10px; padding: 6px 2px; }
.chat-about-member-name { flex: 1; min-width: 0; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-about-admin-tag {
  font-size: var(--fs-2xs); font-weight: 700; color: var(--primary);
  background: var(--primary-soft); padding: 2px 8px; border-radius: var(--pill); flex-shrink: 0;
}
.chat-about-divider { height: 1px; background: var(--border); margin: 4px 0 10px; }
#chat-about-addmember-btn { margin-top: 10px; }

/* Messenger restyle Fix 4 — settings rows at the bottom of the About section:
   "Chat wallpaper" (any conv type, expands the SAME preset list the old
   header ⋮ popover used) and, group-only, a red "Leave group" row — both
   RELOCATED off the thread header. Plain list-row chrome, no card/border. */
.chat-about-rows { text-align: left; margin-top: 14px; }
.chat-about-row {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 11px 4px; border: none; background: none; cursor: pointer;
  border-top: 1px solid var(--border); color: var(--text); font-size: var(--fs-base);
  text-align: left;
}
.chat-about-row:hover { background: var(--surface-2); }
.chat-about-row-icon { display: flex; flex-shrink: 0; color: var(--text-muted); }
.chat-about-row-label { flex: 1; min-width: 0; }
.chat-about-row-chevron {
  display: flex; flex-shrink: 0; color: var(--text-muted);
  transition: transform .18s ease;
}
.chat-about-row.chat-about-row-open .chat-about-row-chevron { transform: rotate(180deg); }
.chat-about-row-danger { color: var(--danger); }
.chat-about-row-danger .chat-about-row-icon { color: var(--danger); }

/* ── Thread panel mobile shell (Phase 20) — true fullscreen, 56px header,
   44px back-button hit-area (edge swipe-back also reaches this via Gestures). ── */
.ms-thread-header {
  display: flex; align-items: center; gap: 10px;
  height: 56px; padding: 0 8px 0 4px; flex-shrink: 0;
  background: var(--surface); border-bottom: 1px solid var(--border);
  position: relative;
}
.ms-thread-back {
  width: 44px; height: 44px; flex-shrink: 0; border-radius: 50%;
  background: none; border: none; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  color: var(--primary);
}
.ms-thread-back:hover { background: var(--surface-2); }
.ms-thread-info { flex: 1; min-width: 0; }
.ms-thread-title {
  font-size: 0.9375rem; font-weight: 700; color: var(--text);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ms-thread-subtitle { font-size: 0.6875rem; color: var(--text-muted); }
.ms-thread-menu-btn {
  width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%;
  background: none; border: none; cursor: pointer; color: var(--text-muted);
  display: flex; align-items: center; justify-content: center;
}
.ms-thread-menu-btn:hover { background: var(--surface-2); color: var(--text); }
.ms-wallpaper-menu {
  position: absolute; top: 100%; right: 8px; margin-top: 4px;
  background: var(--modal-bg); border: 1px solid var(--border);
  border-radius: var(--r, 14px); box-shadow: var(--sh-lg);
  padding: 6px; min-width: 180px; z-index: 20;
}
.ms-wallpaper-menu.hidden { display: none; }
/* Messenger restyle Fix 4 — the SAME .ms-wallpaper-menu/.ms-wallpaper-opt
   preset-list markup, reused inline inside the info page's About section
   (chat-about-wallpaper-list) instead of floating off a header ⋮ button:
   normal document flow, no absolute positioning/shadow/border — it just
   expands directly under its "Chat wallpaper" trigger row. */
.ms-wallpaper-menu-inline {
  position: static; margin: 0; border: none; box-shadow: none;
  border-radius: 0; padding: 2px 0 6px; min-width: 0;
}
.ms-wallpaper-menu-title {
  font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
  color: var(--text-muted); padding: 6px 8px 4px;
}
.ms-wallpaper-opt {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 7px 8px; border-radius: var(--r-sm, 10px); border: none;
  background: none; color: var(--text); font-size: var(--fs-base);
  cursor: pointer; text-align: left;
}
.ms-wallpaper-opt:hover { background: var(--surface-2); }
.ms-wallpaper-swatch {
  width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0;
  border: 1px solid var(--border);
}
.ms-wallpaper-swatch.wp-default { background: var(--chat-bg); }
.ms-wallpaper-swatch.wp-doodle { background: var(--surface-2); background-image: radial-gradient(circle, var(--text-muted) 1px, transparent 1.2px); background-size: 6px 6px; }
.ms-wallpaper-swatch.wp-gradient-blue { background: linear-gradient(135deg, #0866FF, #7C3AED); }
.ms-wallpaper-swatch.wp-gradient-sunset { background: linear-gradient(135deg, #FF6B9D, #FFD60A); }
.ms-wallpaper-swatch.wp-astral { background: radial-gradient(circle at 30% 30%, #2a1a4a, #070710); }

/* Legacy per-theme overrides retired — the ms-bubble- variants, .messenger-body
   and .ms-input are fully token-driven now
   (--bubble-in-bg / --chat-bg / --surface-2).

   NO STAR CHARACTERS IN THIS COMMENT — same trap as the .messenger-wrap comment
   (~line 4617). This one read ".ms-bubble-" followed by a star and a slash, and
   that pair ENDED the comment on its first line. The prose tail
   (".messenger-body/.ms-input are fully token-driven now (...)." plus the real
   terminator) was then parsed as CSS; it has no opening brace, so the parser ran
   forward and swallowed the FIRST REAL RULE after it into one invalid selector
   list. Verified in Chrome against this stylesheet: .ms-thread-search-bar was
   absent from document.styleSheets while its own siblings
   (.ms-thread-search-bar.hidden, .ms-thread-search-input, .ms-thread-search-nav,
   .ms-thread-search-count) were all present — i.e. exactly one rule lost.
   That rule is the chat thread's in-thread search bar (js/chat.js:1063,
   `<div id="chat-search-bar" class="ms-thread-search-bar hidden">`), so the bar
   was rendering as an unstyled block with no flex row, no padding, no
   border-bottom and no background. */

/* ══════════════════════════════
   Wave2 practicality batch — in-thread search, record-link chips,
   dept-grouped New Message picker, offline attachment retry,
   pinned messages + announcement channels
══════════════════════════════ */

/* In-thread search bar — collapsible row under .ms-thread-header, same slim
   56px-header visual language (icon buttons, muted count text). */
.ms-thread-search-bar {
  display: flex; align-items: center; gap: 4px;
  padding: 6px 10px; border-bottom: 1px solid var(--border);
  background: var(--surface);
}
.ms-thread-search-bar.hidden { display: none; }
.ms-thread-search-input {
  flex: 1; min-width: 0; border: none; border-radius: var(--pill);
  padding: 6px 12px; background: var(--surface-2); color: var(--text);
  font-size: var(--fs-sm); outline: none;
}
.ms-thread-search-input:focus { box-shadow: 0 0 0 3px var(--primary-soft); }
.ms-thread-search-nav {
  width: 30px; height: 30px; flex-shrink: 0; border-radius: 50%;
  background: none; border: none; cursor: pointer; color: var(--text-muted);
  display: flex; align-items: center; justify-content: center;
}
.ms-thread-search-nav:hover { background: var(--surface-2); color: var(--text); }
.ms-thread-search-count {
  font-size: var(--fs-2xs); color: var(--text-muted); white-space: nowrap;
  padding: 0 2px; min-width: 44px; text-align: center;
}
/* Search hit highlight — rendered as <mark> inside an already-escHtml'd
   .ms-text (see chat.js _highlightSearchMatch); the "active" (currently
   navigated-to) hit gets a stronger accent so next/prev is visually obvious. */
.ms-search-hit {
  background: color-mix(in srgb, var(--gold-3, #FFD60A) 55%, transparent);
  color: inherit; border-radius: 3px; padding: 0 1px;
}
.ms-search-hit-active {
  background: var(--primary); color: var(--on-primary, #fff);
}

/* Pinned-messages bar — slim summary row under the header/search bar;
   tapping expands a dropdown list of every pin (Messenger-style). */
.ms-pinned-bar { position: relative; border-bottom: 1px solid var(--border); background: var(--surface); }
.ms-pinned-bar.hidden { display: none; }
.ms-pinned-summary {
  display: flex; align-items: center; gap: 6px; width: 100%;
  padding: 7px 12px; border: none; background: none; color: var(--text);
  cursor: pointer; text-align: left; font-size: var(--fs-xs);
}
.ms-pinned-summary:hover { background: var(--surface-2); }
.ms-pinned-summary svg, .ms-pinned-summary i { color: var(--primary); flex-shrink: 0; width: 13px; height: 13px; }
.ms-pinned-count { font-weight: 700; flex-shrink: 0; }
.ms-pinned-snippet {
  flex: 1; min-width: 0; color: var(--text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ms-pinned-list {
  position: absolute; top: 100%; left: 0; right: 0; z-index: 15;
  background: var(--modal-bg); border: 1px solid var(--border); border-top: none;
  box-shadow: var(--sh-lg); max-height: 260px; overflow-y: auto;
}
.ms-pinned-list.hidden { display: none; }
.ms-pinned-row {
  display: flex; align-items: center; gap: 8px; padding: 8px 12px;
  cursor: pointer; border-bottom: 1px solid var(--border);
}
.ms-pinned-row:last-child { border-bottom: none; }
.ms-pinned-row:hover { background: var(--surface-2); }
.ms-pinned-row-body { flex: 1; min-width: 0; }
.ms-pinned-row-author { font-size: var(--fs-2xs); font-weight: 700; color: var(--primary); }
.ms-pinned-row-snippet {
  font-size: var(--fs-xs); color: var(--text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ms-pinned-row-unpin {
  background: none; border: none; cursor: pointer; color: var(--text-muted);
  padding: 4px; flex-shrink: 0; display: flex; align-items: center;
}
.ms-pinned-row-unpin:hover { color: var(--danger); }

/* Announcement channel — read-only banner shown in place of a hidden
   composer (chat.js _buildThreadPanel's canPost gate). */
.ms-announcement-readonly {
  display: flex; align-items: center; gap: 8px;
  margin: 0 14px 8px; padding: 8px 12px;
  background: var(--surface-2); border-radius: var(--r-sm, 10px);
  color: var(--text-muted); font-size: var(--fs-xs);
}
.ms-announcement-readonly svg, .ms-announcement-readonly i { flex-shrink: 0; }
.ms-composer-hidden { display: none !important; }

/* Dept-grouped New Message picker — section header shared by "Recent" and
   each department group (same visual language as the inbox's own pinned-rail
   label). */
.ms-picker-group-label {
  display: flex; align-items: center; gap: 4px; padding: 10px 4px 4px;
  font-size: var(--fs-2xs); font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
  color: var(--text-muted);
}
.ms-picker-group-label i, .ms-picker-group-label svg { width: 11px; height: 11px; }

/* Offline-queued attachment send (P1) — distinct from .ms-bubble-failed:
   this isn't an error the sender needs to fix, just connectivity, so it uses
   the info token instead of danger and reads as "queued" not "broken". */
.ms-bubble-offline {
  cursor: pointer;
  background: color-mix(in srgb, var(--info, #0866FF) 12%, var(--bubble-out-bg));
  border: 1px solid color-mix(in srgb, var(--info, #0866FF) 40%, transparent);
}
.ms-pending-offline-label { font-size: var(--fs-2xs); color: var(--info, #0866FF); font-weight: 600; }

/* ── Task fullscreen panel messenger fills height ── */
#task-fullscreen-panel .messenger-wrap {
  flex: 1; margin-top: 0; border-radius: 0; border: none;
  overflow: hidden; display: flex; flex-direction: column;
}
#task-fullscreen-panel .messenger-body {
  max-height: none; flex: 1;
}
#task-fullscreen-panel #task-comments-wrap {
  flex: 1; display: flex; flex-direction: column; overflow: hidden;
}

/* ═══════════════════════════════════════════════════════════
   iOS DESIGN OVERHAUL — v1
   Large titles · Spring easing · Grouped surfaces · Inset lists
   Appended as cascade layer — overrides prior rules on cascade.
═══════════════════════════════════════════════════════════ */

/* iOS motion tokens — merged into base :root */

/* ── Large Title Page Headers ── (merged, see PAGE SECTIONS above) */
.page-header {
  display: flex; align-items: center; gap: 12px;
  flex-wrap: wrap;
  padding: 6px 0 8px;
  margin-bottom: 18px;
}
.page-header h2 {
  font-family: var(--font-display);
  font-size: var(--fs-4xl);
  font-weight: 800;
  letter-spacing: -0.7px;
  line-height: 1.12;
  color: var(--text);
  flex: 1;
}

@media (max-width: 768px) {
  .page-header h2 {
    font-size: 2rem;
    font-weight: 800;
    letter-spacing: -1px;
    line-height: 1.1;
  }
  .page-header {
    margin-bottom: 20px;
    padding: 8px 0 4px;
  }
}

/* ── Task feed — inset separators (not full-width) ── */
.task-feed-item {
  padding: 13px 16px;
  border-bottom: none;
  position: relative;
  transition: background 120ms var(--ios-spring);
}
.task-feed-item::after {
  content: '';
  position: absolute;
  bottom: 0; left: 52px; right: 0;
  height: 0.5px;
  background: var(--border);
}
.task-feed-item:last-child::after { display: none; }
.task-feed-item:active { background: var(--s2); }

/* ── Top nav strip — iOS tab bar feel ── */
.top-nav-strip {
  border-bottom: 1px solid var(--border);
  height: 50px;
  padding: 0 6px;
  gap: 0;
}
/* v14 de-structuring pass: Astral's backdrop-filter blur + hairline border
   override on the mobile top-nav-strip was removed — falls through to the
   base rule (its own --topbar-bg/--border tokens already give it the same
   tint). */
.top-nav-item {
  padding: 6px 11px;
  border-radius: 10px;
  gap: 3px;
  min-height: 44px;
  transition: color 160ms var(--ios-spring), background 160ms var(--ios-spring);
}
.top-nav-item .bn-icon { font-size: var(--fs-2xl); }
.top-nav-item .tn-label { font-size: var(--fs-xs); font-weight: 600; letter-spacing: -0.1px; }
.top-nav-item.active {
  color: var(--primary);
  background: var(--primary-soft);
}
.top-nav-item.active .bn-icon {
  transform: scale(1.10);
}
/* Color-only: Astral's brand-pink active tab. The icon glow drop-shadow
   filter (not color) was removed — v14 de-structuring pass. */
html.theme-astral .top-nav-item.active { color: var(--pink); background: rgba(255,45,120,0.10); }
/* Phase 6: desktop icon-tab strip centered in the topbar band */
@media (min-width: 1024px) {
  .top-nav-strip {
    display: flex; position: fixed; top: 0; height: var(--topbar-h);
    left: 50%; right: auto; transform: translateX(-50%);
    width: max-content; max-width: 60vw;
    background: none; border: none; box-shadow: none;
    padding-top: env(safe-area-inset-top, 0px);
    z-index: var(--z-topbar-2); /* above .topbar (100) so the centered icon-tabs are visible/clickable */
  }
  .top-nav-item { flex-direction: column; width: 48px; min-height: auto; border-radius: 0; padding: 6px 0; }
  .top-nav-item.active { background: none; border-bottom: 3px solid var(--primary); border-radius: 0; }
  html.theme-astral .top-nav-item.active { background: none; }
  .top-nav-item .tn-label { display: none; }
}

/* ── Bottom sheet modal on mobile ──
   `@keyframes sheetRise` (translateY(60px) + opacity .7 → 0/1 over 360ms)
   used to be declared here. It is DELETED, not merely left unreferenced — see
   the `animation: none` on .modal-box below for the why. Grepped css/js/html
   before removing: the declaration below was its only consumer anywhere in the
   repo. Do not re-add it; a named entrance keyframe sitting in the file is an
   invitation to re-wire the entrance this pass deliberately removed. */
@media (max-width: 768px) {
  .modal-overlay {
    align-items: flex-end !important;
    padding: 0 !important;
  }
  .modal-box {
    width: 100% !important;
    max-width: 100% !important;
    max-height: 92dvh !important;
    border-radius: 22px 22px 0 0 !important;
    margin: 0 !important;
    align-self: flex-end !important;
    /* ONE WINDOW AT A TIME, part 2 — on the phone/tablet tier the modal
       entrance is a SWAP, byte-for-byte the same decision .page-panel took at
       this tier (`transition: none`, ~line 2701 in the ≤768px block above).

       The app shipped TWO kinds of full-screen window with two different
       entrances. Tap a task row: openPage's window replaces the screen with
       zero motion. On that window tap Edit: openModal's window — visually
       identical, equally opaque, equally full-cover at ≤639px — rose 60px and
       faded in from opacity 0.7 over 360ms. Same surface, same geometry, two
       physics. That is the "redundancies" half of the owner's brief ("an
       independent window, not overlays of different pages … no overlaps, no
       redundancies"), and the 60px rise is also the exact cue that reads as a
       sheet sliding OVER the previous screen rather than as the next window.

       `animation: none`, not a shorter duration: any non-zero entrance still
       spends frames with the window somewhere other than where it will be,
       which is the whole of what's being removed.

       This ONE declaration kills BOTH entrances that can reach a modal at
       this tier — the sheetRise that used to be declared right here
       (!important) and the base `animation: popIn 0.26s var(--spring)`
       (~line 3835, no !important) that it was overriding. Neither can win
       against `none !important`.

       SCOPE: ≤768px only. Above 768px nothing is touched and desktop modals
       keep popIn exactly as before.

       The 640-768px band is deliberately included even though a modal is
       still a bottom SHEET there, not a full-cover window (this same block's
       align-items:flex-end + max-height:92dvh; the full-cover treatment
       starts in the ≤639px block below, and Overlay's _COVER_KINDS getter in
       js/config.js promotes 'modal' to a cover kind only below 640px). The
       owner was told and accepted it: one motion language for openModal at
       every phone/tablet width beats a sheet that animates at 700px and a
       window that doesn't at 620px.

       NO CONFLICT with the reduced-motion branch at the end of this section
       (`@media (prefers-reduced-motion: reduce) { .modal-box { animation:
       none !important } }`): identical property, identical value, later in
       the file, and it still owns >768px on its own.

       CLOSE PATH: unaffected, and it never depended on the open animation.
       Teardown is `#modal-overlay.classList.add('hidden')` →
       `.hidden{display:none !important}` (js/app.js, openModal's teardown) —
       a display flip with no exit animation and no read of the entrance's end
       state, so the dropped `both` fill-mode was holding nothing. */
    animation: none !important;
  }
  /* Pull handle */
  .modal-box::after {
    content: '';
    position: absolute; top: 8px; left: 50%;
    transform: translateX(-50%);
    width: 36px; height: 4px; border-radius: 2px;
    background: rgba(255,255,255,0.20);
    z-index: 10; pointer-events: none;
  }
  .modal-header {
    /* v14: the no-popup fix made the mobile modal cover top:0 (over the
       notch), so its header must clear the status bar — a flat 26px let
       'More' + the X collide with the clock/battery (owner screenshot). */
    padding-top: calc(14px + env(safe-area-inset-top, 0px));
  }
}

/* ── WS42 Phase 10 — mobile bottom sheet refinement, ≤639px DELTA over the
   ≤768px baseline above (Batch 2 / spec 2c merge). This used to be a full
   duplicate @media(max-width:639px) block; since ≤639 ⊆ ≤768 both blocks
   always matched together and the later (this) one won per-property in the
   cascade. Only the properties that actually differ from the baseline are
   kept here — everything identical to the ≤768px block was dropped (it
   already applies unchanged) and every kept !important is required to beat
   the competing !important declaration of the same property above. Verified
   computed-equivalent at 375px/700px/800px — see Batch 2 report. ── */
@media (max-width: 639px) {
  /* v14 Wave 6 — "parallel window" fix (owner report 2026-08-03): openModal
     surfaces on phone rendered as a translucent-scrim bottom sheet with a
     visible gap at the top, reading as a popup floating in front of the
     previous page rather than a pushed screen. ≤639px ONLY (641-768px
     tablet keeps the ≤768px bottom-sheet baseline above, unchanged; desktop
     is untouched) the modal becomes a full-cover opaque page, visually
     identical to .page-panel: .modal-box takes itself out of the overlay's
     flex layout via position:fixed so a translucent scrim can no longer let
     the page behind show through the top gap, and the scrim itself goes
     fully opaque so there's zero background bleed even mid-transition.
     Pull-handle / glass-shimmer decorations are dropped — they're "sheet"
     affordances that no longer make sense on a full page.
     ENTER ANIMATION: none. This block adds nothing of its own — the ≤768px
     baseline above now carries `animation: none !important` on .modal-box, so
     a full-cover modal simply replaces the screen in one frame, identically
     to a .page-panel. (It used to read "stays the existing transform/opacity
     -only sheetRise (untouched)"; sheetRise is gone — v14.0.67 motion pass.) */
  .modal-overlay {
    background: var(--bg) !important;
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }
  .modal-box {
    position: fixed !important;
    /* v14 MOBILE WINDOW MODEL (Batch 2) — identical defect to .page-panel's,
       identical fix. This box is position:fixed with top:0/bottom:0 and
       height:100dvh, and 100dvh does NOT shrink for the soft keyboard: `dvh`
       tracks the retracting URL bar (the LAYOUT viewport), not the keyboard.
       Every full-cover modal with a form in it therefore put its last field
       and its footer buttons underneath the keys. --vv-top/--vvh (published on
       <html> by window.ViewportSync, js/config.js) describe the actually
       visible strip, keyboard up or down.
       `bottom:auto` is mandatory: top+bottom+height over-constrains the box,
       and the UA resolves that by dropping `height` — leaving bottom:0 in
       charge and silently reverting the fix.
       min-height:100dvh is GONE outright (it would force the box taller than
       the visible strip). max-height, however, could NOT simply be deleted:
       its job here was never 100dvh as a limit but beating two competing
       declarations — `max-height: 92dvh !important` from the ≤768px
       bottom-sheet block and `max-height: 94dvh` on .modal-box.modal-full —
       either of which would clamp a full-cover modal back to ~92% of the
       screen. It becomes `none !important`, which keeps winning exactly the
       same two cascade fights while letting `height` alone decide the size.
       Every other !important in this rule is left precisely where it was:
       this block is a per-property DELTA over the ≤768px baseline (see the
       block comment above) and each one beats a specific competitor. */
    /* Bottom edge at rest is `bottom: 0`, not a measurement — same reasoning as
       .page-panel's (see the ≤768px block): a visual-viewport height taken with
       no keyboard presented left a dead band below the surface. `html.kb-open`
       (below) switches to the measured rect only while a keyboard is actually up. */
    top: var(--vv-top, 0px) !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
    width: 100% !important; max-width: 100% !important;
    height: auto !important; max-height: none !important;
    margin: 0 !important;
    border-radius: 0 !important;
    /* PARITY 3 — BORDER + SHADOW. This rule already reset radius/background/
       backdrop-filter, but NOT the two decorations .modal-box carries from its
       base rule (~line 3957: `border: 1px solid var(--border)` +
       `box-shadow: var(--sh-lg)`). Off-screen on a centered desktop dialog;
       on a full-cover phone window they are drawn ON the screen edges:
       measured at 375x812 light, a 1px rgba(16,24,40,0.1) hairline framing all
       four sides plus a rgba(16,24,40,0.14) 0 12px 32px drop shadow with
       nothing behind it to fall on. Worse, the 1px border INSETS the whole
       flex column: .modal-header measured x=1 width=373 against
       .page-panel-head's x=0 width=375, so the modal's title, back arrow and
       footer buttons all sat one pixel in from where a page window puts them.
       .page-panel has neither decoration at any width — it is the 133-call-site
       majority and the parity target. `0` not `none` for border so the shorthand
       cannot resurrect a style/color; both need !important only because the
       whole rule is !important-per-property by design (see the note above) and
       consistency here is cheaper than reasoning about which competitor each
       one beats. >=640px is untouched: desktop modals keep border + shadow. */
    border: 0 !important;
    box-shadow: none !important;
    align-self: stretch !important;
    background: var(--bg) !important;
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }
  /* Keyboard up → measured visual-viewport rect, same switch .page-panel uses. */
  html.kb-open .modal-box {
    height: var(--vvh, 100dvh) !important;
    bottom: auto !important;
  }
  .modal-box::after { display: none; }
  /* MOVED — the .modal-header / .modal-body / .modal-footer padding that used
     to live here (16/20/20px sides, max(14px, env-bottom) on the foot,
     calc(20px + env-bottom) on the body) was DEAD CODE and is gone.
     The v14 ≤640px density pass at the end of this file re-declares
     `.modal-body{padding:14px}` and `.modal-footer{padding:10px 14px}` as flat
     FOUR-SIDED SHORTHANDS. ≤640px matches every real phone width, so it always
     matches together with this ≤639px block, and being later in the file it
     won every one of those properties outright — including the two bottom
     safe-area corrections, which is how a full-cover modal's Save/Cancel row
     ended up 10px off the bottom of an iPhone (inside the home-indicator band)
     while a page window's sat at 46px. Re-declaring them here again would just
     lose the same cascade fight a second time.
     They now live — corrected, and aligned to the page window's values rather
     than restored as-was — in the "FULL-COVER MODAL ⇄ PAGE WINDOW PARITY"
     block at the very END of this file, which is the first position later in
     the cascade than the density pass. Search for that heading. */
  /* Confirm/prompt dialogs: the box itself stays centered (unlike openModal)
     — only the scrim goes opaque, so no previous page shows behind it either. */
  .dialog-overlay {
    background: var(--bg) !important;
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }
  /* Drawers (notifications, dept switcher): same sheet treatment on mobile.
     (base .drawer{} rule for this breakpoint consolidated into the grouped
     Drawer section near its first definition — Batch 2 / spec 2d; these
     companion selectors are unchanged.) */
  .drawer.open { transform: translateY(0); }
  .drawer::before {
    content: ''; position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
    width: 36px; height: 4px; border-radius: 2px; background: var(--border-strong);
    z-index: 10; pointer-events: none;
  }
  .drawer-header { padding-top: 20px; }
}
@media (prefers-reduced-motion: reduce) {
  .modal-box { animation: none !important; }
}

/* ── Sidebar nav items — iOS Settings style ── */
.nav-item {
  padding: 11px 14px;
  border-radius: 10px;
  margin: 1px 8px;
  width: calc(100% - 16px);
  font-size: var(--fs-base);
  font-weight: 500;
  min-height: 44px;
  transition: background 160ms var(--ios-spring), color 160ms var(--ios-spring);
}
/* Remove right-edge bar indicator; use background instead — WS42 Phase 7: full-pill active state */
.nav-item.active::after { display: none; }
.nav-item.active {
  background: var(--primary-soft);
  color: var(--primary);
  font-weight: 600;
  border-radius: var(--pill);
}
@media (hover:hover) {
  .nav-item:hover { background: var(--surface-2); }
}
/* Color-only: Astral's brand-pink active nav pill. The border-radius: 10px
   override (a rounded-rectangle shape, not the full var(--pill) every other
   theme uses) was removed — v14 de-structuring pass; same pill shape now. */
html.theme-astral .nav-item.active { background: rgba(255,45,120,0.12); color: var(--pink); }

/* v13 Phase 143 — hover completeness: consolidated mouse-only hover affordances
   for interactive classes that had no hover state at all. Matches the token
   family already used by sibling hovers (.nav-item:hover / .data-table tr:hover
   → var(--surface-2)). No transforms/shadows here — those are covered per-class
   above and would fight the .pressable active-state feel. */
@media (hover:hover) and (pointer:fine) {
  .bottom-nav-item:hover { color: var(--text); }
}

/* ── Back buttons — iOS text+chevron style ── */
.drawer-back-btn {
  background: none;
  border: none;
  box-shadow: none;
  color: var(--blue-2);
  padding: 8px 6px;
  font-size: var(--fs-lg);
  font-weight: 500;
  border-radius: var(--r-sm);
}
.drawer-back-btn:hover {
  background: rgba(10,132,255,0.10);
  color: var(--blue);
  box-shadow: none;
  transform: none;
}
.drawer-back-btn:active { transform: scale(0.96) !important; }
.drawer-back-btn svg { width: 16px; height: 16px; }

/* ── Haptic-feel button press ── v13 Phase 123: scale now sourced from
   --press-scale (declared once, root :active rules above already match it —
   no !important needed since there's no longer a competing value). The 70ms
   speed-up on press is kept (it's a timing tweak, not a scale contradiction). */
.btn-primary:active    { transform: scale(var(--press-scale)); transition-duration: 70ms; }
.btn-secondary:active  { transform: scale(var(--press-scale)); transition-duration: 70ms; }
.btn-danger:active     { transform: scale(var(--press-scale)); transition-duration: 70ms; }
.btn-success:active    { transform: scale(var(--press-scale)); transition-duration: 70ms; }
.btn-outline:active    { transform: scale(var(--press-scale)); transition-duration: 70ms; }
.login-role-card:active { transform: scale(0.97) !important; transition-duration: 70ms !important; }
.quick-action-btn:active { background: var(--s3) !important; transform: scale(0.99) !important; }

/* ══ PRESS COVERAGE SWEEP — v14.0.68 ═══════════════════════════════════════
   WHY THIS EXISTS. `html { -webkit-tap-highlight-color: transparent }` (~line
   71) is INHERITED, so it removed iOS Safari's default grey press flash from
   EVERY element in the document — and the flash was then replaced for only a
   partial set (the .btn-* family via --press-scale ~line 6174, .item-card /
   .notif-item / .chat-inbox-row ~line 1960, the .pressable utility ~line 93).
   Everything else in this file's interactive vocabulary has produced zero
   pixels between finger-down and whatever happens next ever since. The most
   important omission is .page-panel-back / .modal-back: the exit control for
   every one of the ~161 windows in the app, whose declaration (~line 2347)
   carries no :active, no :hover and no transition at all.

   That gap is most of what "the clicks feel slow" is. A drill-down that has
   to await Firestore before its window exists cannot show the window sooner,
   but it CAN acknowledge the touch in the frame the finger lands — and an
   acknowledged tap followed by 300ms of work reads as a fast app doing
   something, while an unacknowledged one reads as a dropped tap.

   PHYSICS — duration on the BASE rule, `transition-duration: 0s` on :active.
   This is backwards from how the existing press rules in this file are
   written and the direction matters: a tap is ~80ms end to end, so ANY in-
   duration means the cue is still travelling when the finger leaves and its
   target state is never reached. Press must land on contact and ease OUT on
   release. 90ms out is ~5 frames — visible as a release, short enough not to
   queue behind the next tap in a fast list-scan.

   transform / opacity / background / color / border-color ONLY. No layout
   properties: everything here animates on the compositor or is a paint-only
   change, so a press on a 200-row list cannot trigger layout.

   PLACEMENT is load-bearing. This block sits at top level (no at-rule
   nesting) and AFTER every component rule it needs to beat — .chip-tab
   (~1888), .item-card (~1927), .dept-card (~2216), .page-panel-back (~2347),
   .post-card (~3636), the topbar cluster (~768/~899/~923) — and BEFORE the
   global prefers-reduced-motion collapse near the end of the file. Two
   selectors deliberately do NOT appear here because a LATER rule would
   swallow them: .subtab-btn (~line 6402) and .dept-quick-tab (~line 4010)
   carry their own press state in place at their own declaration site. */
.page-panel-back, .modal-back, .modal-close,
.menu-toggle, .notif-btn, .topbar-avatar,
.chip-tab, .dept-card, .dept-card-clickable, .post-card,
.att-status-opt, .bs-qfolder-card {
  /* box-shadow is in the list even though nothing here presses with one: the
     shorthand REPLACES each element's existing transition, and .dept-card /
     .post-card / .dept-card-clickable / .notif-btn all lift with a shadow on
     desktop hover. Omitting it would leave those shadows snapping on a mouse
     — a desktop regression paid for a touch fix. */
  transition: transform 90ms var(--ease), opacity 90ms var(--ease),
              background 90ms var(--ease), color 90ms var(--ease),
              border-color 90ms var(--ease), box-shadow 90ms var(--ease);
}
/* Icon-sized controls press DEEPER (0.90) than cards (0.97). A 24px glyph
   scaled 0.97 moves sub-pixel and is invisible; a 340px-wide card scaled 0.90
   would look like it collapsed. Equal-looking motion needs unequal numbers. */
.page-panel-back:active, .modal-back:active, .modal-close:active,
.menu-toggle:active, .notif-btn:active, .topbar-avatar:active {
  transform: scale(0.90); opacity: 0.75; transition-duration: 0s;
}
.chip-tab:active, .dept-card:active, .dept-card-clickable:active,
.post-card:active, .att-status-opt:active, .bs-qfolder-card:active {
  transform: scale(0.97); opacity: 0.92; transition-duration: 0s;
}
/* Table rows get BACKGROUND, not transform: transform on a <tr> is unreliable
   across engines and would break the table's own layout box. `.data-table
   tr:hover td` exists only inside @media (hover:hover) (~line 2013), so on
   touch a tapped table row had nothing whatsoever — and the finance/HR/CRM
   screens are almost entirely tables. */
.data-table tbody tr:active td { background: var(--surface-3); }

/* Correct the press direction on the three rows that ALREADY had a press
   state. Their :active rule (~line 1960) puts `transition-duration: 90ms` on
   :active itself, i.e. it animates the press IN and snaps it OUT — the wrong
   way round per the physics note above. Both END states are unchanged; only
   which half of the gesture is animated moves. .item-card's base transition
   (~line 1927) is left declaring its own property list — this overrides the
   durations only, so its property coverage is untouched. */
.item-card:active, .notif-item:active, .chat-inbox-row:active { transition-duration: 0s; }
.item-card { transition-duration: 90ms; }

/* Reduced motion: drop the SCALE only. The global collapse near the end of
   this file already flattens every duration above to ~0, and opacity /
   background / colour are not vestibular triggers — so the press still
   acknowledges the tap, it just stops moving. Same shape as the existing
   branches at ~line 96 and ~line 1963. */
@media (prefers-reduced-motion: reduce) {
  .page-panel-back:active, .modal-back:active, .modal-close:active,
  .menu-toggle:active, .notif-btn:active, .topbar-avatar:active,
  .chip-tab:active, .dept-card:active, .dept-card-clickable:active,
  .post-card:active, .att-status-opt:active, .bs-qfolder-card:active {
    transform: none;
  }
}

/* ── KPI cards — cleaner feel ── (base .kpi-value props ported up from earlier copy; protected clamp/nowrap/tabular-nums lines below are untouched — window.fitKpiValues depends on them) */
.kpi-value {
  font-family: var(--font-display);
  color: var(--text); line-height: 1;
  /* Responsive: 28px on desktop, scales down on phones so large peso figures
     (6-7 digits) don't clip in half-width cards. tabular-nums keeps alignment. */
  font-size: clamp(15px, 4.6vw, var(--fs-5xl));
  font-weight: 800;
  letter-spacing: -0.6px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.kpi-label {
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.3px;
}

/* ── Card headers ── */
.card-header h3 {
  font-size: 0.9375rem;
  font-weight: 700;
  letter-spacing: -0.2px;
}

/* ── Notification items — more iOS-spacious ── */
.notif-item {
  padding: 13px 16px;
}
.notif-item-title { font-size: var(--fs-base); font-weight: 600; }
.notif-item-body  { font-size: var(--fs-md); }
.notif-item-time  { font-size: var(--fs-xs); }
/* Press feedback — the `transition` SHORTHAND here used to undo the app-wide
   press system for notification rows: being last at equal specificity (0,2,0)
   it reset transition-duration back to 80ms AND narrowed transition-property to
   `background` alone, so the press sweep's `transition-duration: 0s` correction
   (which makes a press snap IN and ease OUT, not the reverse) silently did
   nothing here. The notification inbox is one of the highest-traffic tap
   surfaces in the app and was the only row type not matching the system.
   Longhand `background-color`, so it tints without touching duration/property. */
.notif-item:active { background: var(--s2); }

/* ── Bottom nav (WS42 Phase 8) — token-driven, solid, same full-width bar in every theme ── */
.bottom-nav {
  display: none;
  position: fixed;
  left: 0; right: 0; bottom: 0; transform: none;
  width: 100%; max-width: none; min-width: 0;
  height: calc(var(--bottom-nav-h, 56px) + env(safe-area-inset-bottom, 0px));
  padding-bottom: env(safe-area-inset-bottom, 0px);
  padding-left: max(4px, env(safe-area-inset-left, 0px));
  padding-right: max(4px, env(safe-area-inset-right, 0px));
  /* v14 hotfix: opaque underlay — --bottom-nav-bg can be translucent (glass
     themes) and page content was visibly bleeding through the bar + the
     home-indicator band on iPhone (2026-08-03 report). */
  background: linear-gradient(var(--bottom-nav-bg), var(--bottom-nav-bg)), var(--bg);
  border-radius: 0;
  border: none;
  border-top: 1px solid var(--border);
  box-shadow: none;
  z-index: var(--z-bottom-nav);
  justify-content: space-around; align-items: center;
  box-sizing: border-box;
  transition: height 0.25s var(--ease), opacity 0.25s var(--ease);
}
.bottom-nav-item .bn-icon { font-size: var(--fs-2xl); }
.bottom-nav-item .bn-icon svg { width: 24px; height: 24px; }
.bottom-nav-item { min-height: 44px; }
.bottom-nav-item::after {
  content: ''; position: absolute; top: 0; left: 50%; transform: translateX(-50%) scaleX(0);
  width: 28px; height: 3px; border-radius: 0 0 3px 3px; background: var(--primary);
  transition: transform 160ms var(--ease);
}
.bottom-nav-item.active::after { transform: translateX(-50%) scaleX(1); }
.bottom-nav-item:active .bn-icon { transform: scale(0.92); transition-duration: 120ms; }
/* v14 de-structuring pass (owner directive: "Astral should just be about
   colors, not styles"): the detached floating glass pill — its own bottom/
   left/width/height/background/backdrop-filter/border-radius/border/box-shadow
   — was removed entirely, along with the active-tab underline suppression and
   the extra icon scale+glow. Astral's bottom nav is now the exact same
   full-width bar as every other theme, just tinted by its own
   --bottom-nav-bg/--border/--primary tokens above. */

/* ── 'More' tab + sheet (v14 mobile-shell batch, ruled decision N3) ──────────
   Any NAV_REGISTRY.bottom variant with more than 5 items (post-Profile-strip)
   collapses to its first 4 + this 5th tab (js/app.js buildBottomNav /
   _bottomNavSplit) so every column stays roomy at 375px instead of cramming
   6-7 icons in. #bottom-nav-more IS a .bottom-nav-item — it gets the same
   active-state/underline/icon-color rules above for free, no extra CSS. The
   sheet itself is just openModal() (a bottom sheet on mobile already), so no
   new sheet chrome is needed — only the row list inside it. */
.more-nav-sheet { display: flex; flex-direction: column; }
.more-nav-row {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 11px 4px; background: none; border: none; cursor: pointer;
  color: var(--text); font-size: var(--fs-base); font-weight: 600; text-align: left;
  border-bottom: 1px solid var(--border); min-height: 44px; box-sizing: border-box;
}
.more-nav-row:last-child { border-bottom: none; }
.more-nav-row:hover, .more-nav-row:active { background: var(--s1); }
.more-nav-row.active { color: var(--primary); }
/* Own icon tile (NOT the sidebar's .nav-icon — that hardcodes stroke:#fff for
   its colored gradient tiles and renders invisible on this row's plain
   var(--s1) surface). currentColor here tracks the row's own text color, so
   it's automatically visible in light/dark AND tints to --primary on .active,
   matching .bn-icon's pattern instead. */
.more-nav-row-icon {
  width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--s1);
}
.more-nav-row-icon svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; display: block; }
.more-nav-row-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.more-nav-row-chevron { width: 18px; height: 18px; color: var(--text-light); flex-shrink: 0; }
.more-nav-row-badge {
  /* v14 a11y fix: --pink-badge-bg (not --pink) — see notif-badge above for
     the contrast rationale. */
  background: var(--pink-badge-bg); color: #fff; font-size: 0.6875rem; font-weight: 700;
  padding: 1px 6px; border-radius: var(--pill); line-height: 1.4; flex-shrink: 0;
  box-shadow: 0 0 8px var(--pink-glow);
}
/* Safety margin on the narrowest phones (iPhone SE-class, 375px) so an
   8-char label like "Projects" never ellipsizes with 5 comfortably-spaced
   tabs (first 4 + More) sharing the bar. */
@media (max-width: 380px) {
  .bottom-nav-item { padding: 0 8px; }
}

/* ── Subtab bar — iOS segmented control (token-driven) ── */
.subtab-bar {
  border-radius: 12px;
  padding: 3px;
  background: var(--surface-2);
  border: 1px solid var(--border);
}
.subtab-btn {
  border: none; background: none;
  color: var(--text-muted);
  cursor: pointer; white-space: nowrap; flex-shrink: 0;
  min-height: 36px; display: flex; align-items: center;
  border-radius: 9px;
  font-size: var(--fs-md);
  font-weight: 600;
  padding: 7px 14px;
  /* v14.0.68: was `transition: all 200ms var(--ios-spring)`. Two problems.
     (1) `all` animates layout properties too. (2) This rule is LATER in the
     cascade than the press sweep (~line 6182), so its 200ms shorthand
     silently swallowed the sweep's press timing for subtabs -- the segmented
     control was the one nav surface with no press feedback at all. Named
     properties, and the press state below. */
  transition: background 90ms var(--ease), color 90ms var(--ease),
              transform 90ms var(--ease);
}
.subtab-btn:active { transform: scale(0.97); transition-duration: 0s; }
@media (prefers-reduced-motion: reduce) { .subtab-btn:active { transform: none; } }
.subtab-btn.active {
  background: var(--primary-soft);
  color: var(--primary);
  box-shadow: none;
}
/* Color-only: Astral's brand-pink active subtab. The glow + specular-inset
   box-shadow (structural, not color) was removed — v14 de-structuring pass;
   same box-shadow:none as every other theme. */
html.theme-astral .subtab-btn.active {
  background: rgba(255,45,120,0.18);
  color: var(--pink);
}

/* ── Drawer — full-screen slide-up on mobile ──
   (base .drawer rule for this breakpoint consolidated into the grouped
   Drawer section near its first definition — Batch 2 / spec 2d) */
@media (max-width: 768px) {
  .drawer.open { transform: translateY(0); }
  .drawer::before { display: none; } /* no drag handle on a full-screen panel */
  .drawer-header { padding-top: calc(14px + env(safe-area-inset-top, 0px)); }
  .drawer-body { padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px)); }
}

/* ── Sidebar mobile top-offset — REMOVED (v14 mobile-shell batch, 2026-08-03) ──
   This block predated "hotfix 2" (984afc7) and duplicated/overrode the
   #sidebar rule near the Sidebar section above (top: env(safe-area-inset-top)
   — the PROVEN below-the-notch geometry hotfix 2 restored after top:0+padding
   misbehaved on-device). Because this rule came LATER in the cascade with the
   same #sidebar specificity, it silently won and re-applied top:0 +
   padding-top: env(inset) on #sidebar — stacked with .sidebar-top's OWN
   padding-top below, that double-counted the inset into ~2× its height of
   dead space above the user card (the "giant dead band" from the owner's
   screenshot) and quietly undid hotfix 2 for the sidebar specifically (the
   top-nav-strip had no such leftover duplicate, so only the drawer regressed).
   #sidebar's own `top` position already clears the notch — .sidebar-top needs
   no extra inset padding on top of that; its base rule (18px 14px 14px, see
   the Sidebar section above) is enough. */

/* ── Item cards — rounded, tablet/large-phone tier only (641–768px) ──
   SEAMLESS MOBILE PASS v2: this used to run unscoped at ≤768px with
   !important on every property, so it silently out-shouted whatever the
   ≤640px phone layer below wanted for the exact same selectors — a textbook
   case of "more overrides" instead of "edit at the source." Narrowed to the
   tablet/landscape-phone gap the ≤640px density language deliberately
   doesn't touch; the !importants are gone since nothing above 768px
   competes for these properties at this range. ≤640px gets its own flatter,
   ~10px-radius language — see the SURFACES section in the mobile density
   block near the end of this file. */
@media (min-width: 641px) and (max-width: 768px) {
  .item-card { border-radius: 14px; }
  .kpi-card  { border-radius: 16px; padding: 16px 14px; }
  .card      { border-radius: 18px; }
}

/* PTR arc ring styles are in the PULL-TO-REFRESH block */

/* ── Item title — slightly larger ── */
.item-title { font-size: 0.9375rem; font-weight: 600; }

/* ── Badge — iOS red dot style ── */
.nav-badge {
  background: #FF3B30;
  min-width: 18px; height: 18px;
  font-size: var(--fs-xs); padding: 0 5px;
  border-radius: var(--pill);
  display: inline-flex; align-items: center; justify-content: center;
  box-shadow: 0 1px 6px rgba(255,59,48,0.50);
}

/* ── Mobile padding refinements — sole surviving .main-content padding rule
   for the 641–768px tablet/large-phone gap (the ≤768px main-shell block
   above deliberately no longer sets this property — see its comment). At
   ≤640px this is itself superseded by the density pass at the end of the
   file. The .kpi-row gap:10px this block used to duplicate is already set
   by the ≤768px main-shell block above (identical value) — removed here. ── */
@media (max-width: 768px) {
  .main-content {
    padding: 16px;
    /* Clear the fixed bottom nav (its height already includes the safe-area
       inset) plus breathing room, so the last cards never hide under it. */
    padding-bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom, 0px) + 28px);
  }
}

/* ── Form labels — iOS style ── */
.form-group label {
  font-size: var(--fs-sm);
  font-weight: 600;
  letter-spacing: 0.04em;
  margin-bottom: 6px;
}

/* ── Login container on mobile ── */
@media (max-width: 480px) {
  .login-container {
    padding: 36px 24px 28px;
    border-radius: 28px;
  }
}

/* ── Light-mode legacy tints retired WS42 Batch B — .top-nav-strip/.nav-item/.bottom-nav/
   .subtab-btn.active now read tokens directly (--topbar-bg/--primary/--primary-soft/
   --bottom-nav-bg). .modal-box::after (mobile sheet pull handle) kept below since it's
   not yet token-mapped. ── */
html.light .modal-box::after, html.theme-dark .modal-box::after { background: var(--border-strong); }

/* ══════════════════════════════════════════
   TASK 4 — Splash + Topbar Logo + Light Mode
══════════════════════════════════════════ */

/* ── Splash (v12 WS42 Phase 25) — token-driven; simple one-shot fade/scale in
   Light/Dark (--bg canvas, matches whatever theme is persisted — the inline
   <head> script in index.html applies that theme class before first paint, so
   there's no light/dark flash). Astral keeps its own fancy cosmic treatment. */
#splash-screen {
  background: var(--bg);
  display: flex;
  align-items: center;
  justify-content: center;
}
html.theme-astral #splash-screen {
  background:
    radial-gradient(900px 480px at 50% -10%, rgba(139,92,246,0.16), transparent 65%),
    var(--bg);
}

.splash-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  animation: splashEnter 0.5s cubic-bezier(0.33,1,0.68,1) both;
}
@keyframes splashEnter {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Logo card — flat surface in Light/Dark; Astral gets the cosmic sheen card */
.splash-logo-card {
  width: 96px; height: 96px;
  border-radius: 22px;
  background: var(--surface);
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
  margin-bottom: 22px;
  border: 1px solid var(--border);
  box-shadow: var(--sh);
}
/* Color-only: Astral's dark navy/gold-trim gradient card. The glow + inset-
   specular box-shadow (structural, not color) was removed — v14
   de-structuring pass; same box-shadow: var(--sh) as every other theme. */
html.theme-astral .splash-logo-card {
  background: linear-gradient(140deg, #0A0A1A 0%, #131330 60%, #1A0A2E 100%);
  border: 1px solid rgba(255,214,10,0.35);
}
.splash-logo {
  width: 100%; height: 100%;
  object-fit: cover;
  border-radius: 0;   /* override the legacy circular crop — the card clips */
}
.splash-bi-fallback {
  width: 100%; height: 100%;
  background: var(--primary);
  align-items: center; justify-content: center;
  font-size: 2.375rem; font-weight: 800; color: var(--on-primary);
  letter-spacing: -2px;
}
html.theme-astral .splash-bi-fallback { background: var(--grad-pk-bl); }

/* Wordmark */
.splash-wordmark-stack {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-bottom: 30px;
  gap: 4px;
}
.splash-word-main {
  font-size: var(--fs-2xl);
  font-weight: 700;
  letter-spacing: -0.2px;
  color: var(--text);
}
.splash-word-sub {
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--primary);
}

/* Loading bar — thin, quiet */
.splash-bar {
  position: relative;
  width: 148px; height: 3px;
  background: var(--surface-2);
  border-radius: 2px;
  overflow: hidden;
}
.splash-bar::after {
  content: "";
  position: absolute; top: 0; left: 0;
  width: 40%; height: 100%;
  border-radius: 2px;
  background: var(--primary);
  animation: splashBarRun 1.3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .splash-inner { animation: none; }
  .splash-bar::after { animation: none; width: 100%; opacity: .5; }
}

/* ── Topbar: logo area ── */
.topbar-logo-area {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
  min-width: 0;
}
.topbar-logo-card {
  width: 32px; height: 32px;
  border-radius: 9px;
  overflow: hidden;
  flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  background: rgba(108,142,245,0.15);
}
.topbar-logo-img {
  width: 32px; height: 32px;
  object-fit: contain;
}
.topbar-logo-fallback {
  width: 32px; height: 32px;
  align-items: center; justify-content: center;
  font-size: var(--fs-md); font-weight: 800;
  color: var(--primary-light);
  letter-spacing: -0.5px;
}
.topbar-wordmark-stack {
  display: flex;
  flex-direction: column;
  line-height: 1.15;
  min-width: 0;
}
.topbar-brand-main {
  font-size: var(--fs-base);
  font-weight: 700;
  letter-spacing: -0.3px;
  color: rgba(255,255,255,0.95);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.topbar-brand-sub {
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgba(255,255,255,0.62);
}

/* ── Light mode topbar text ── (.topbar background is token-driven now: var(--topbar-bg)) */
html.light .topbar-brand-main { color: rgba(0,0,0,0.85); }
html.light .topbar-brand-sub { color: rgba(0,0,0,0.52); }
html.light .topbar-logo-card { background: rgba(108,142,245,0.10); }
html.light .menu-toggle { color: var(--text-primary); }
html.light .notif-btn { color: var(--text-primary); }

/* Hide sub-label on very small screens, keep brand name */
@media (max-width: 360px) {
  .topbar-brand-sub { display: none; }
  .topbar-brand-main { font-size: var(--fs-md); }
}
html.light .drawer-back-btn:hover { background: rgba(10,132,255,0.07); }

/* ══════════════════════════════════════════
   Top Nav — equal-space tabs, no notif tab
══════════════════════════════════════════ */
.top-nav-strip {
  justify-content: space-around;
  /* WS42 Phase 11: landscape notch/home-indicator clearance (this is the
     last-cascade winner for .top-nav-strip padding — the live mobile nav). */
  padding: 0 max(4px, env(safe-area-inset-right, 0px)) 0 max(4px, env(safe-area-inset-left, 0px));
  overflow-x: hidden;
}
.top-nav-item {
  flex: 1;
  justify-content: center;
  padding: 6px 4px;
  min-width: 0;
}
.top-nav-item .tn-label {
  font-size: var(--fs-xs);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}

/* ══════════════════════════════════════════
   Notifications — left content, centered actions
══════════════════════════════════════════ */
.notif-item {
  flex-direction: column;
  align-items: stretch;
  gap: 0;
}
.notif-item-main {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}
.notif-item-emoji { margin-top: 1px; }
.notif-item-text  { text-align: left; }
.notif-item-body  { white-space: normal; }
.notif-item-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin-top: 10px;
  padding-top: 8px;
  border-top: 0.5px solid var(--border);
}
.notif-action-btn {
  display: flex;
  align-items: center;
  gap: 5px;
  border-radius: 20px;
  padding: 5px 14px;
  height: auto;
  width: auto;
}
.notif-btn-label { font-size: var(--fs-sm); font-weight: 600; }

/* ══════════════════════════════════════════
   TASK 5 — Profile Drawer Polish
══════════════════════════════════════════ */

/* ── Hero section ── */
.profile-hero {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 28px 20px 20px;
  text-align: center;
}
.profile-avatar-wrap {
  position: relative;
  width: 88px; height: 88px;
  border-radius: 50%;
  overflow: visible;
  cursor: pointer;
  margin-bottom: 14px;
}
.profile-avatar-img {
  width: 88px; height: 88px;
  border-radius: 50%;
  object-fit: cover;
  display: block;
  box-shadow: 0 4px 24px rgba(0,0,0,0.35);
}
.profile-avatar-initials {
  width: 88px; height: 88px;
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 2.25rem; font-weight: 700; color: #fff;
  background: linear-gradient(135deg, var(--primary-light), var(--pink));
  box-shadow: 0 4px 24px rgba(108,142,245,0.40);
}
.profile-avatar-edit-badge {
  position: absolute;
  bottom: 2px; right: 2px;
  width: 26px; height: 26px;
  border-radius: 50%;
  background: var(--s2);
  border: 2px solid var(--border);
  display: flex; align-items: center; justify-content: center;
}
.profile-avatar-edit-badge svg {
  width: 13px; height: 13px;
  stroke: var(--text-muted);
  fill: none; stroke-width: 2;
}
.profile-hero-name {
  font-size: 1.375rem; font-weight: 700; letter-spacing: -0.4px;
  color: var(--text);
  margin-bottom: 4px;
}
.profile-hero-role {
  font-size: var(--fs-md); color: var(--text-muted);
  font-weight: 500;
  margin-bottom: 4px;
}
.profile-hero-id {
  font-size: var(--fs-xs); color: var(--text-muted);
  font-family: monospace;
  background: var(--s1);
  padding: 3px 10px;
  border-radius: 20px;
  margin-top: 4px;
  letter-spacing: 0.05em;
}

/* ── Section labels ── */
.profile-section-label {
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.08em;
  color: var(--text-muted);
  padding: 14px 20px 6px;
}

/* ── Inset grouped card ── */
.profile-inset-card {
  margin: 0 16px 4px;
  background: var(--s1);
  border-radius: 14px;
  border: 1px solid var(--border);
  overflow: hidden;
}
.profile-info-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 0.5px solid var(--border);
  gap: 12px;
}
.profile-info-row.no-border { border-bottom: none; }
.pir-label {
  font-size: var(--fs-base);
  color: var(--text);
  font-weight: 500;
  flex-shrink: 0;
}
.pir-value {
  font-size: var(--fs-base);
  color: var(--text-muted);
  text-align: right;
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.pir-mono { font-family: monospace; font-size: var(--fs-sm); }
.pir-phone { font-size: var(--fs-base); display: flex; align-items: center; }

/* ── Editable row ── */
.profile-row-edit {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
}
.profile-inline-input {
  flex: 1;
  background: transparent;
  border: none;
  outline: none;
  font-size: var(--fs-base);
  color: var(--text);
  min-width: 0;
}
.profile-inline-input::placeholder { color: var(--text-muted); }

/* ── Sign out ── */
.profile-signout-btn {
  width: calc(100% - 32px);
  margin: 16px 16px 0;
  display: block;
  padding: 14px;
  font-size: 0.9375rem;
  font-weight: 600;
  border-radius: 14px;
}

/* ── Notification toggle switch ── */
.notif-toggle-wrap {
  position: relative;
  display: inline-block;
  width: 44px;
  height: 26px;
  cursor: pointer;
}
/* This is the hidden input BEHIND the switch — not a checkbox the user ever
   sees. The global checkbox baseline (~line 377) would give it a real 16px
   box, which would paint a tick on top of the slider. Qualified with
   [type="checkbox"] (0,2,1 vs the baseline's 0,1,1) so it wins on specificity
   as well as on source order — this rule must never depend on staying below
   the baseline in the file. */
.notif-toggle-wrap input[type="checkbox"] { opacity: 0; width: 0; height: 0; min-height: 0; position: absolute; margin: 0; appearance: none; -webkit-appearance: none; }
.notif-toggle-slider {
  position: absolute;
  inset: 0;
  background: rgba(255,255,255,0.15);
  border-radius: 26px;
  transition: background .2s;
}
.notif-toggle-slider::before {
  content: '';
  position: absolute;
  width: 20px; height: 20px;
  left: 3px; top: 3px;
  background: #fff;
  border-radius: 50%;
  transition: transform .2s;
  box-shadow: 0 1px 4px rgba(0,0,0,0.3);
}
.notif-toggle-wrap input:checked + .notif-toggle-slider { background: var(--primary); }
.notif-toggle-wrap input:checked + .notif-toggle-slider::before { transform: translateX(18px); }
html.light .notif-toggle-slider { background: rgba(0,0,0,0.15); }

/* ── Touch tap-targets — comfortable hit areas on phones/tablets ──────
   Small icon/chip buttons measured under ~32px (hard to tap one-handed):
   notification Unread/Open chips, team-directory 📇/👋 buttons, attendance ✎.
   Scoped to coarse pointers so desktop density is untouched. */
@media (hover: none) and (pointer: coarse) {
  .notif-action-btn { min-height: 38px; padding: 9px 16px; }
  .team-card-btn    { min-height: 38px; min-width: 40px; padding: 8px 12px; }
  /* Attendance pencil lives in a small calendar cell — modest, cell-safe bump */
  .att-edit-btn.att-edit-visible { font-size: var(--fs-sm); padding: 3px 9px; line-height: 1.5; }

  /* MOBILE FINANCE PASS (2026-08-08) — the two sub-20px controls in the Bank
     Account drilldown that write MONEY data with no confirm step:
       .ba-recon-chk  a bare UA checkbox (~13px on iOS) whose change handler
                      writes `reconciled` straight onto a ledger doc
                      (js/screens/finance.js);
       .ba-retag-sel  a ~21px-tall select whose change handler re-tags a ledger
                      row to a DIFFERENT bank account.
     Both sit in the same card row on a phone, so a fat-finger miss on one hits
     the other. Size only — no behaviour change; the handlers are untouched. */
  /* 2026-08-10: qualified `.ba-recon-chk` → `input.ba-recon-chk`. As a bare
     class it was 0,1,0 and the new global checkbox baseline (~line 377) is
     0,1,1, so the baseline's 16px would have OUT-RANKED this regardless of
     source order and quietly shrunk the money control back below the size it
     was deliberately given. Tying at 0,1,1 and sitting later, it wins again. */
  input.ba-recon-chk { width: 24px; height: 24px; flex-shrink: 0; }
  .ba-retag-sel { min-height: 44px; }
}

/* ── Light mode overrides ── */
html.light .profile-inset-card { background: rgba(0,0,0,0.03); border-color: rgba(0,0,0,0.08); }
html.light .profile-info-row { border-color: rgba(0,0,0,0.07); }
html.light .profile-section-label { color: rgba(0,0,0,0.40); }
html.light .profile-hero-id { background: rgba(0,0,0,0.05); }

/* ══════════════════════════════════════════════════
   ACCESSIBILITY — keyboard focus visibility
   Universal :focus-visible rule (was a hand-curated allowlist) —
   future-proof, no new component ever ships without a focus ring.
══════════════════════════════════════════════════ */
:focus-visible {
  /* v14 CSS/a11y audit: was var(--brand-primary) (== --pink, #FF2D78, never
     re-themed) — every other active/selected indicator in the app
     (nav-item/top-nav-item/subtab-btn/chip-tab) uses var(--primary) (blue in
     Light/Dark), so tabbing flashed a mismatched hot-pink ring that also
     only computed to ~3.5:1 on Light's white surfaces (at the WCAG 1.4.11
     floor for non-text UI components). var(--primary) matches everywhere
     else and clears contrast in all 3 themes. */
  outline: 2px solid var(--primary);
  outline-offset: 2px;
  border-radius: var(--r-xs);
}
/* Pill-shaped controls (border-radius: var(--pill)) would show a squared-off
   ring corner poking out past their own rounded edge at the r-xs default —
   match the ring's radius to the control's for these. */
.chip-tab:focus-visible, .badge:focus-visible {
  border-radius: var(--pill);
}
/* Programmatically-focused scroll/containers must not show a ring */
[tabindex="-1"]:focus-visible { outline: none; }
/* Suppress the UA ring on non-keyboard focus (kept, broadened to all elements) */
:focus:not(:focus-visible) { outline: none; }

/* ══════════════════════════════════════════════════
   REDUCED MOTION — neutralize decorative infinite anims
   Keep functional transitions, just make them subtle
══════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  /* Background drift / orbs */
  body::before,
  body::after,
  .login-bg::before,
  .login-bg::after {
    animation: none !important;
  }
  /* Decorative infinite loops: gradient run, gold shine, pulse, float */
  .btn-primary,
  .id-card-shine,
  [class*="float"],
  [class*="pulse"] {
    animation: none !important;
    will-change: auto !important;
  }
  /* Any element animated with the decorative keyframes */
  *,
  *::before,
  *::after {
    animation-iteration-count: 1 !important;
    animation-duration: 0.001ms !important;
    animation-delay: 0.001ms !important;
    /* WS42/v13 Phase 189: was var(--t1) (120ms) — still a visible slide/scale
       for sheets, drawers, and chip presses. Reduced-motion means state
       changes should be instant, not merely faster. Color/opacity end-states
       are unaffected (only duration collapses), so hover/focus feedback and
       pressed-state color changes still register — only the vestibular
       transform/opacity motion (sheet slide-up, drawer slide-in, chip
       press-scale) becomes a snap instead of an animated move. */
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* ══════════════════════════════════════════════════
   FORCED COLORS (Windows High Contrast Mode)
   Gradient/-webkit-background-clip:text sites render invisible text under
   forced-colors — the UA suppresses background images/gradients but
   -webkit-text-fill-color:transparent stays, leaving nothing to paint the
   glyphs with. Force a solid system-color fallback for all 7 sites in the
   app that use this technique (login-title, sidebar-user-dept incl. its
   Light variant, the 3 id-card gradient-text fields, comment-author — the
   Astral loading-placeholder gradient-text shimmer was removed in the v14
   de-structuring pass, so it's no longer one of these sites). !important is
   deliberate here — this is the standard, expected way to win forced-colors
   overrides regardless of each site's own specificity/theme-scoping. ── */
@media (forced-colors: active) {
  .login-title,
  .sidebar-user-dept,
  .id-card-company,
  .id-card-name,
  .id-card-id,
  .comment-author {
    background: none !important;
    -webkit-background-clip: initial !important;
    background-clip: initial !important;
    -webkit-text-fill-color: CanvasText !important;
    color: CanvasText !important;
  }
}

/* ══════════════════════════════════════════════════
   PREFERS-CONTRAST: MORE
   Token system already centralizes border/muted-text values, so bumping
   them here covers every themed component that reads var(--border) /
   var(--border-strong) / var(--text-muted) without touching per-component
   CSS. Same specificity as each theme's own block above, later in source,
   so these win only for the 3 properties actually listed here — everything
   else in the theme block is untouched. ── */
@media (prefers-contrast: more) {
  html.light {
    --border:        rgba(16,24,40,0.28);
    --border-strong: rgba(16,24,40,0.40);
    --text-muted:    rgba(28,30,33,0.68);
  }
  html.theme-dark {
    --border:        rgba(255,255,255,0.22);
    --border-strong: rgba(255,255,255,0.34);
    --text-muted:    rgba(228,230,235,0.68);
  }
  html.theme-astral, :root {
    --border:        rgba(255,255,255,0.22);
    --border-strong: rgba(255,255,255,0.34);
    --text-muted:    rgba(240,240,250,0.68);
  }
}

/* ── The Payslip — ONE branded template (v12 WS24) ──────────────────────
   Renders in-app via renderPayslipPage (no pop-ups — same-document print).
   .lh-* classes match js/letterhead.js's buildLetterhead() output, which
   this template consumes directly (WS14). */
.payslip-print{max-width:210mm;margin:0 auto;background:#fff;color:#000;padding:14mm;font:11px/1.4 Arial,sans-serif;}
.payslip-print .lh-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;padding-bottom:7px;border-bottom:2.5px solid var(--lh-accent,#1E3A5F);}
.payslip-print .lh-id{display:flex;align-items:flex-start;gap:12px;}
.payslip-print .lh-logo{height:56px;flex-shrink:0;margin-top:1px;}
.payslip-print .lh-name{font-size:15pt;font-weight:900;color:var(--lh-accent,#1E3A5F);letter-spacing:.4px;line-height:1.1;}
.payslip-print .lh-idline{font-size:8.5pt;color:#555;margin-top:1px;}
.payslip-print .lh-doc{text-align:right;}
.payslip-print .lh-doctitle{font-size:13pt;font-weight:900;color:var(--lh-accent,#1E3A5F);letter-spacing:1px;}
.payslip-print .lh-docno{font-size:10pt;font-weight:700;color:#333;margin-top:4px;}
.payslip-print .lh-docdate,.payslip-print .lh-docmeta{font-size:9pt;color:#555;margin-top:2px;}
.payslip-print .lh-sig-row{display:grid;gap:24px;margin-top:24px;}
.payslip-print .lh-sig{text-align:center;}
.payslip-print .lh-sig-line{border-top:1px solid #000;margin-top:20px;}
.payslip-print .lh-sig b{display:block;font-size:10px;color:#000;margin-top:4px;}
.payslip-print .lh-sig span{font-size:9px;color:#444;}
.payslip-print .lh-footer{margin-top:16px;border-top:1px solid #ddd;padding-top:7px;font-size:8pt;color:#999;text-align:center;}
.payslip-print .ps-badge-proj{background:#c62828;color:#fff;font-weight:700;font-size:9pt;padding:3px 8px;border-radius:4px;display:inline-block;margin:6px 0;}
.payslip-print .ps-sec-h{background:#1E3A5F;color:#fff;font-weight:700;font-size:9pt;text-transform:uppercase;letter-spacing:.05em;padding:4px 8px;margin-top:12px;}
.payslip-print table.ps-t{width:100%;border-collapse:collapse;}
.payslip-print .ps-t td,.payslip-print .ps-t th{border:1px solid #ccc;padding:4px 6px;font-size:9.5pt;}
.payslip-print .ps-t .lbl{font-weight:700;font-size:8.5pt;text-transform:uppercase;color:#444;width:14%;}
.payslip-print .ps-t .num{text-align:right;}
.payslip-print .ps-gross td{font-weight:800;background:#e8eaf6;}
.payslip-print .ps-sub td{font-weight:700;background:#f2f4f8;}
.payslip-print .ps-net td{font-weight:900;font-size:13pt;background:#1E3A5F;color:#fff;}
.payslip-print .ps-sigs{display:flex;gap:24px;margin-top:28px;}
.payslip-print .ps-sig{flex:1;text-align:center;}
.payslip-print .ps-sig-line{border-top:1px solid #000;padding-top:5px;min-height:28px;font-weight:700;}
.payslip-print .ps-sig-lbl{font-size:8pt;color:#666;margin-top:2px;}
.payslip-print .ps-foot{margin-top:20px;padding-top:8px;border-top:1px solid #eee;font-size:8pt;color:#999;text-align:center;}
/* ── Generic print system (v13 Phase 56) — shared visibility reset for any
   .print-target plus the two existing print classes; keyed off existing
   classes so no markup changes are needed. Also a global dark/Astral-theme
   print fallback so Ctrl+P from any page/theme is legible. ─────────────── */
@media print{
  body *{visibility:hidden;}
  .print-target,.print-target *,
  .payslip-print,.payslip-print *,
  .bir-print,.bir-print *,
  #page-content,#page-content *{visibility:visible;}
  .no-print{display:none!important;}

  /* recon Hazard 7 — neutralize the phone scroll lock for printing. While any
     overlay is open, window.ScrollLock (js/config.js) parks <body> at
     `position:fixed; top:-<scrollY>px; overflow:hidden`, which is a
     viewport-sized, viewport-CLIPPED box. Printing from that state paginates
     only the visible slice and drops everything below it — so this ships with
     the lock or desktop Ctrl+P breaks. Costs nothing when no lock is held:
     these are already body's used values in the unlocked state. */
  body{position:static!important;top:auto!important;height:auto!important;overflow:visible!important;}

  /* dark/Astral fallback: force light, legible chrome-free output everywhere */
  body,#page-content,.card,.data-table,.data-table th,.data-table td,
  table,th,td{background:#fff!important;color:#000!important;box-shadow:none!important;filter:none!important;}
  .sidebar,.topbar,.top-nav,.bottom-nav,.chip-tabs,.chip-tab,.tabbar,
  .fab,.no-print{display:none!important;}

  /* v13 Phase 147 — on-screen Ctrl+P polish for #page-content screens
     (ledger, income statement, approvals, team roster, inventory count
     form). Payslip/BIR/inventory-generated docs print via a separate
     window.open() document (their own .payslip-print/.bir-print root,
     no nav chrome ever present) and are untouched by this block. */
  #page-content{position:static!important;width:100%!important;padding:0!important;margin:0!important;}
  .page-actions,.page-header .btn-primary,.page-header .btn-secondary,
  .page-header .btn-outline,.page-header .btn-danger,
  .btn,.btn-primary,.btn-secondary,.btn-outline,.btn-danger,.fab{display:none!important;}
  #page-content .data-table{font-size:10pt;}
  #page-content .data-table th{background:#eee!important;color:#000!important;-webkit-print-color-adjust:exact;print-color-adjust:exact;}
  #page-content .data-table thead{display:table-header-group;}
  #page-content .data-table tr{page-break-inside:avoid;break-inside:avoid;}
  #page-content .card{box-shadow:none!important;border:1px solid #ccc!important;}
  @page{size:A4 portrait;margin:12mm;}
}
@media print{
  .payslip-print{position:absolute;left:0;top:0;width:100%;padding:8mm;}
  /* two-tier page-break: tables flow, rows/headers stay whole (WS14 aab024a pattern) */
  .payslip-print table.ps-t{page-break-inside:auto;break-inside:auto;}
  .payslip-print thead{display:table-header-group;}
  .payslip-print tr,.payslip-print .ps-sigs,.payslip-print .lh-sig-row{page-break-inside:avoid;break-inside:avoid;}
  @page{size:A4 portrait;margin:11mm 10mm 7mm;}
  /* PAYSLIP-OVERHAUL-SPEC.md §3 — neutralize the on-screen scale-to-fit
     transform + fixed box model for print; @page margins take over so the
     .a4-sheet's own padding/shadow/border would just double them. */
  .a4-stage{transform:none!important;height:auto!important;width:auto!important}
  .a4-sheet{transform:none!important;width:100%!important;min-height:0;
            padding:0!important;box-shadow:none;border:none}
}

/* ── §3 A4 on-screen preview — fixed-layout sheet + scale-to-fit ──────────
   PAYSLIP-OVERHAUL-SPEC.md §3. .a4-stage is the scroll/centering container
   (renderPayslipPage / the Print-All flow, js/screens/hr.js); .a4-sheet is
   the fixed 794×1123px (210×297mm @96dpi) "paper" — a stable layout width
   so the letterhead's two-column header never reflows/collides on a narrow
   viewport; window.fitA4Sheet() sets --a4-scale so it visually shrinks to
   fit instead. Forced-light so dark/Astral themes can't bleed into the
   "paper" look — every token used inside buildPayslipHTML (hr.js's hrNote
   box, ps-sec-h, etc.) is re-pinned to a light value for anything nested
   under .a4-sheet, regardless of what html.theme-dark/astral set at the root. */
.a4-stage{
  --a4-scale:1;
  /* --a4-h is the sheet's REAL layout height, written by window.fitA4Sheet()
     (js/screens/hr.js). It defaults to exactly one A4 page so the reservation
     is right before any measurement runs and for any stage the fitter misses. */
  --a4-h:1123px;
  display:flex;justify-content:center;
  /* MUST NOT be `stretch` (the flex default). Stretch sets a DEFINITE cross-axis
     height on .a4-sheet, and a definite height makes overflowing content spill
     instead of growing the box — so every payslip was capped at exactly one
     page no matter how much was on it. Measured 2026-08-09: a sheet needing
     2443px of content still had a 1123px box, i.e. the bottom half of a long
     payslip (many allowances/deductions) was silently cut off the paper. */
  align-items:flex-start;
  width:calc(794px * var(--a4-scale));
  height:calc(var(--a4-h) * var(--a4-scale));
  margin:0 auto;padding:16px 8px;
}
.a4-sheet{
  /* height:auto + min-height = "at least one page, more if the content needs
     it". Paired with the stage's align-items above; neither works alone. */
  width:794px;height:auto;min-height:1123px;flex-shrink:0;
  background:#fff;color-scheme:light;
  padding:42px 38px 26px;
  box-shadow:0 2px 16px rgba(0,0,0,.22);border:1px solid #ddd;
  transform:scale(var(--a4-scale,1));transform-origin:top left;
  --surface:#fff;--surface2:#f4f4f4;--border:#ddd;--text:#222;--text-muted:#666;
}

/* ── BIR suite prints — books/worksheets/FS (v12 WS39) ───────────────────
   Renders in-app via window.birPrintPage (no pop-ups — same-document print,
   the WS24/payslip pattern). js/letterhead.js's buildLetterhead().printCSS is
   injected inline per-print (a <style> tag ahead of the content) for .lh-*
   styling — this block only handles page isolation + BIR-specific tables. */
.bir-print{max-width:210mm;margin:0 auto;background:#fff;color:#000;padding:14mm;font:11px/1.4 Arial,sans-serif;position:relative;}
.bir-print table.bir-t{width:100%;border-collapse:collapse;margin-top:10px;}
.bir-print .bir-t td,.bir-print .bir-t th{border:1px solid #ccc;padding:4px 6px;font-size:9.5pt;}
.bir-print .bir-t th{background:#1E3A5F;color:#fff;font-size:8.5pt;text-transform:uppercase;letter-spacing:.04em;text-align:left;}
.bir-print .bir-t td.num{text-align:right;}
.bir-print .bir-sec-h{background:#1E3A5F;color:#fff;font-weight:700;font-size:9pt;text-transform:uppercase;letter-spacing:.05em;padding:4px 8px;margin-top:14px;}
.bir-print .bir-total-row td{font-weight:800;background:#eef0f5;}
.bir-print .bir-banner{background:#fff3cd;border:1px solid #e0a800;color:#7a5b00;padding:8px 12px;border-radius:4px;font-size:10.5pt;margin-bottom:10px;}
.bir-print .bir-watermark::before{content:"DRAFT — UNVERIFIED RATES";position:fixed;top:45%;left:50%;transform:translate(-50%,-50%) rotate(-30deg);font-size:52px;font-weight:900;color:rgba(200,0,0,0.15);z-index:1;pointer-events:none;white-space:nowrap;}
@media print{
  .bir-print{position:absolute;left:0;top:0;width:100%;padding:8mm;}
  .bir-print table.bir-t{page-break-inside:auto;break-inside:auto;}
  .bir-print thead{display:table-header-group;}
  .bir-print tr,.bir-print .lh-sig-row{page-break-inside:avoid;break-inside:avoid;}
  /* birBuildPrintHTML now wraps every doc body in a .table-wrap scrollport
     (see js/bir.js) so the 10-column Cash Disbursements Book is reachable on a
     phone. .table-wrap is overflow-x:auto, and an overflow container CLIPS in
     paged media — release it here so the filed artifact is byte-identical to
     what it printed before the wrapper existed. (.bir-t is width:100% so it
     never actually overflows the printed page; this is belt-and-braces.) */
  .bir-print .table-wrap{overflow:visible!important;}
  .bir-print .table-wrap::after{display:none!important;}
  @page{size:A4 portrait;margin:11mm 10mm 7mm;}
}
/* ── BIR on a phone (MOBILE FINANCE PASS 2026-08-08) ──────────────────────
   `.bir-print{max-width:210mm;padding:14mm}` above is an ON-SCREEN rule (the
   @media print block starts after it), so at 375px it left a ~249px content
   box — 14mm ≈ 53px of dead padding on EACH side — for tables of 7-10 columns.
   #page-content is `overflow-x: clip` (~line 1240), which creates no scrollport,
   so Input VAT / VAT Treatment / Tax Withheld were unreachable by any gesture.
   Scoped to `#page-content` on purpose: the SAME .bir-print markup is reused as
   the printable-doc sheet inside an openPage panel (window.birPrintDoc), which
   lives outside #page-content and must keep true A4 geometry for the
   scale-to-fit + PDF capture. @media print is likewise untouched. */
@media (max-width: 640px){
  #page-content .bir-print{max-width:100%;padding:10px;}
  /* Same failure mode as the table above, in the letterhead. buildLetterhead's
     .lh-header is a two-column flex row (.lh-id | right-aligned .lh-doc) whose
     min-content is ~389px — measured 214px + 175px — inside a 335px content box,
     and .lh-doc's default `min-width:auto` stops it shrinking below the widest
     word ("DISBURSEMENTS" at 14pt). It overhung #page-content by 44px, and
     `overflow-x: clip` means clipped-and-unreachable, not scrollable: the doc
     title was cut in half with no gesture to reveal it. Let the row wrap and the
     title break. Scoped to the on-screen sheet only — @media print and the
     openPrintableDoc panel copy keep the real two-column A4 letterhead. */
  #page-content .bir-print .lh-header{flex-wrap:wrap;}
  #page-content .bir-print .lh-doc{text-align:left;min-width:0;}
  #page-content .bir-print .lh-doctitle{overflow-wrap:anywhere;}
}

/* ── v12 WS18 — keyboard-shortcuts cheat sheet (? modal) ── */
.kbd-cheatsheet .kbd-table { width:100%; border-collapse:collapse; }
.kbd-cheatsheet .kbd-table td { padding:7px 10px; border-bottom:1px solid var(--border); font-size:0.875rem; color:var(--text); }
.kbd-cheatsheet .kbd-cell { white-space:nowrap; width:1%; }
.kbd-cheatsheet kbd {
  display:inline-block; min-width:22px; text-align:center; padding:2px 7px;
  font:600 12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
  background:var(--surface); color:var(--text);
  border:1px solid var(--border); border-bottom-width:2px; border-radius:6px;
}
.kbd-cheatsheet .kbd-subhead { margin:16px 0 6px; font-size:0.75rem; text-transform:uppercase;
  letter-spacing:.04em; color:var(--text-muted); }

/* ══════════════════════════════════════════
   WS43 — PHONE BREATHABILITY PASS (owner directive: "less clutter on
   phone, more breathable layout. modern, minimal.")
   Scope: ≤640px only.
   SEAMLESS MOBILE PASS v2 (2026-08-03) — this block's own header used to
   claim "desktop and the existing ≤768px tablet-tier blocks above are
   untouched," but WS43 and the V14 MOBILE DENSITY PASS below it went in
   literally opposite directions on the same selectors (WS43: bigger
   min-heights/gaps for "breathability"; v14: smaller, for "maximize
   space" — the owner's newer and current directive). Every declaration
   that v14 already re-declares for the same selector+property has been
   deleted here instead of left as dead weight silently losing the cascade
   — only the rules that are still the ONE live source for their property
   remain, edited in place to match the new density language. ══════════════════════════════ */
@media (max-width: 640px) {

  /* CALMER CARDS: lighter borders. (Padding/margin for .card/.card-body/
     .item-card/.kpi-row/.item-list moved to a single source — the V14
     MOBILE DENSITY PASS below — removed here to stop the two blocks
     fighting over the same properties.) */
  .card, .kpi-card, .item-card {
    border-color: color-mix(in srgb, var(--border) 60%, transparent);
  }

  /* CHIP ROWS: single-row horizontal scroll, no wrap clutter. Still the one
     live source for this; the scroll/overflow setup is unchanged. */
  .chip-tabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    gap: var(--space-2);
    padding-bottom: 2px;
  }
  .chip-tabs::-webkit-scrollbar { display: none; }
  /* MOBILE FINANCE PASS (2026-08-08) — 32px → 44px. This declaration sits
     LATER in source than the repo's own coarse-pointer floor
     (`@media (pointer:coarse){ .chip-tab{min-height:38px} }`, ~line 1930) at
     equal specificity, so the density pass's 32px silently won on every phone:
     below that 38px floor AND below the 44px floor `.btn-primary/.btn-secondary/
     .btn-danger/.btn-outline/.btn-success` get at ≤768px (~line 3125). The V14
     block header below claims "no tap target drops below 44px effective" — that
     claim was false four lines above it until now. Finance's chip rows are the
     most-tapped control on the phone (7 group chips + a sub-chip row on every
     screen), so they get the real 44px, not the compromise 38px. Padding stays
     tight — min-height does the work, so the row grows ~12px, not the label. */
  .chip-tab { min-height: 44px; padding: 6px 12px; font-size: var(--fs-sm); flex-shrink: 0; }

  /* FORMS: full-width primary actions */
  .modal-footer .btn-primary,
  .page-content-form .btn-primary,
  form .btn-primary { width: 100%; }

  /* TOPBAR/HEADER SLIM: compact action buttons so title owns the line */
  .page-header { align-items: center; }
  .page-actions .btn-primary,
  .page-actions .btn-secondary,
  .page-actions .btn-outline,
  .page-actions .btn-danger,
  .page-actions .btn-success {
    padding: 6px 13px;
    font-size: var(--fs-sm);
    border-radius: var(--r-xs);
    min-height: 36px;
  }
}

/* ══════════════════════════════════════════
   V14 MOBILE DENSITY PASS (owner directive 2026-08-03: "Mobile: maximize
   space, fix layout etc for every page." iPhone screenshots showed
   desktop-scaled padding everywhere on phone: huge page titles ("Command
   Center" ~44px + a loose date line), tall KPI tiles with dead air, thick
   card padding, wide grid/flex gaps, roomy banner padding.
   Scope: ≤640px only. Appended AFTER the WS43 "phone breathability" pass
   above (which had gone the OPPOSITE direction — bigger min-heights/gaps for
   a "calmer" feel) so this block wins the cascade on every shared selector
   at equal specificity (later rule wins, source order). Desktop (>640px) and
   @media print are untouched by this block.
   SEAMLESS MOBILE PASS v2 (2026-08-03) update — the KPI/SURFACES sections
   at the end of this block are new in this pass (owner: "looks bad... must
   look seamless... paddings take so much space"); they kill the remaining
   "boxes-in-boxes" look (heavy radius/shadow/borders) and collapse the KPI
   tile's icon+label+value vertical stack into a ~64px stat strip. See their
   own comments for what changed and why it's still safe re: fitKpiValues.
   Guardrails respected:
   - Input/select/textarea font-size is NOT touched — the existing
     @media (pointer:coarse) rule (~line 346) forces 16px there to block iOS
     auto-zoom-on-focus; only height/padding are tightened here.
   - No tap target drops below 44px effective (buttons/nav already enforce
     44px min-height elsewhere; this pass only tightens surrounding padding).
   - .sop-panel is built with inline styles in js/config.js (window.sopPanel),
     so its rules here need !important to win over the inline attribute —
     the one deliberate, scoped use of !important in this block.
══════════════════════════════════════════ */
@media (max-width: 640px) {

  /* 1 — TYPE: page titles, dashboard subline, section headers */
  .page-header h2 {
    font-size: clamp(1.375rem, 5.5vw, 1.625rem); /* ~22-26px, was 2rem/32px at ≤768 */
    font-weight: 800;
    letter-spacing: -0.4px;
    line-height: 1.15;
  }
  .page-header { margin-bottom: 12px; padding: 4px 0 2px; }
  /* Dashboard date/time subline (.live-clock-line) — smaller, tighter */
  .live-clock-line { font-size: 12px; margin: -8px 0 10px; }
  /* Section headers (card headers) — already 0.9375rem (~15px); just tighten
     the surrounding chrome so the label doesn't float in empty padding. */
  .card-header { padding: 10px 12px; }
  .card-header h3 { font-size: 15px; font-weight: 600; }

  /* 2 — CONTENT PADDING: shell, cards, kpi tiles, gaps */
  .main-content {
    padding: 10px max(10px, env(safe-area-inset-right, 0px)) 10px max(10px, env(safe-area-inset-left, 0px));
    padding-bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom, 0px) + 18px);
  }
  .card, .kpi-row { margin-bottom: 12px; }
  .card { padding: 12px; }
  .card-body { padding: 12px; }
  .item-card { padding: 10px 12px; }
  /* Real, winning override — see the removed dead copy near ~line 2628 for
     why the old one never applied. */
  .task-feed-item { padding: 12px 14px; }
  .item-list { gap: 8px; }
  .kpi-row { gap: 8px; }
  /* Kill the WS43 72px kpi-card floor — let content set height, tighten
     padding. Right padding is wider to clear the icon badge, which the
     KPI TILES section below corner-pins instead of stacking it above the
     label (that stacking was the main reason tiles read as "billboards"). */
  .kpi-card { min-height: 0; padding: 12px 34px 12px 12px; }
  .dashboard-grid { gap: 10px; }
  .dept-grid { gap: 10px; }
  .form-row { gap: 8px; }

  /* 3 — CHROME: chip-tabs, sop-panel, alert/banner */
  .chip-tabs { margin-bottom: 10px; }
  /* sopPanel() (js/config.js) is inline-styled — !important needed to win */
  .sop-panel { padding: 8px 12px !important; margin-bottom: 10px !important; }
  .sop-panel summary { font-size: 12px !important; }
  .sop-panel ol { font-size: 12px !important; margin: 6px 0 0 !important; }
  .alert-banner { padding: 10px 12px; margin-bottom: 10px; }

  /* 4 — FORMS: tighter margins, ~40px input height, 11px labels.
     Font-size is deliberately left alone — the pointer:coarse rule (~line
     346) forces 16px there to stop iOS auto-zoom-on-focus. */
  .form-group { margin-bottom: 10px; }
  .form-group label { font-size: 11px; margin-bottom: 3px; }
  /* 2026-08-10 checkbox audit — CHECKED, and this one is NOT a live bug, but
     it is one edit away from becoming the mobile version of the original
     defect, so the exclusion is stated rather than left to be re-derived.
     `.form-group input` here is 0,1,1 (a media query adds no specificity), so
     the checkbox baseline's (~line 377) `.form-group input[type=…]` form at 0,2,1 already
     beats it and a tick keeps its 16px box at ≤640px — measured at 375x812,
     coarse pointer: 16x16, min-height 0, padding 0. What it would have done
     unopposed is `min-height:40px; padding:8px 10px` — a 16px tick stretched
     into a 40px-tall padded slab on every phone. The baseline's bare
     `input[type=…]` half is only 0,1,1 and TIES this rule, so anything that
     ever drops the .form-group-scoped half would hand this the win silently.
     Same :not() convention as the [readonly] rule in the Forms section. */
  .form-group input:not([type=checkbox]):not([type=radio]),
  .form-group select {
    min-height: 40px;
    padding: 8px 10px;
  }
  .form-group textarea { padding: 8px 10px; min-height: 64px; }

  /* 5 — SURFACES: page-panel body, modal/dialog body.
     .page-panel-head keeps its height — it's a touch target (back button/title). */
  /* This shorthand RE-DECLARES all four sides at the compact 12px density, so
     it would overwrite the keyboard-aware bottom padding set on the base
     .page-panel-body rule (~line 2280) on exactly the widths that have a
     keyboard — every real phone. Carry the same correction through here at the
     12px density: subtract --kb-h from the home-indicator inset and clamp at
     0, so a FOOTLESS window (openPage() hides .page-panel-foot when a page
     passes no footerHTML) ends flush against the keys instead of ~34px above
     them. Keyboard down → --kb-h is 0px → max() returns the inset → this is
     byte-for-byte the previous calc(12px + inset). See the long note on the
     base rule for the full rationale. */
  .page-panel-body {
    padding: 12px max(12px, env(safe-area-inset-right,0px))
      calc(12px + max(0px, env(safe-area-inset-bottom,0px) - var(--kb-h, 0px)))
      max(12px, env(safe-area-inset-left,0px));
  }
  /* v14 hotfix (owner-reported, repeat #3): this density-pass shorthand used
     to reset ALL FOUR sides flat, silently wiping the safe-area-inset-top
     clearance the ≤639px "full-cover sheet" rule above had set on
     .modal-header — since ≤640px matches every real phone width and sits
     LATER in the cascade, its flat `padding: 12px 14px` always won, so
     sheet titles (e.g. the 'More' bottom sheet, which covers top:0 on
     mobile) rendered directly under the iPhone status bar/notch, unreadable
     and unclickable. Restore top clearance as its own declaration so the
     compact 12px/14px sides are kept but the top pads out past the notch
     on every device (env() resolves to 0 on non-notched screens, so this is
     still a flat 12px there — no regression for those). Scoped to mobile
     only (this whole block is ≤640px) — centered desktop modals are
     untouched. */
  .modal-header { padding: 12px 14px; padding-top: calc(12px + env(safe-area-inset-top, 0px)); }
  .modal-body { padding: 14px; }
  .modal-footer { padding: 10px 14px; }
  .dialog-box { padding: 14px; }

  /* 6 — LISTS/TABLES: table-cards rows, plain data-table cells (mostly
     card-mode already via .table-cards at ≤700px, but non-card tables and
     the always-visible tc-* cells still carry desktop-scaled padding). */
  .table-cards tr { padding: 8px 10px; margin-bottom: 8px; }
  .data-table th { padding: 8px 10px; }
  .data-table td { padding: 8px 10px; }

  /* 7 — SURFACES (SEAMLESS MOBILE PASS v2): kill "boxes-in-boxes". Cards
     flatten to hairline-bordered surfaces instead of border+shadow+big-
     radius blocks — iOS inset-grouped language (FB Messenger / Apple system
     apps, per owner reference). Border color is already softened by the
     WS43 color-mix rule above; this just drops the shadow and shrinks the
     radius from the 14–18px "tablet tier" values (see the ── Item cards ──
     block, now scoped to 641–768px only) down to ~10px. Priority/accent
     left-bars thin from 3px to 2px so they read as a color hint, not a
     colored sidebar. */
  .card, .kpi-card, .item-card { border-radius: 10px; box-shadow: none; }
  .item-card { border-left-width: 2px; }
  .kpi-card.accent, .kpi-card.green, .kpi-card.warn, .kpi-card.red { border-left-width: 2px; }

  /* 8 — KPI TILES (SEAMLESS MOBILE PASS v2): collapse the icon+label+value
     vertical stack into a ~64px-tall stat strip. The icon badge used to sit
     ABOVE the label as a full-width block (see .kpi-icon-wrap's base rule),
     which is most of why every tile read as a "billboard card" — it moves
     to a small corner mark instead; .kpi-card's padding above already
     clears room for it on the right. fitKpiValues (js/app.js) is untouched:
     it reads the computed font-size at runtime and only ever shrinks
     further on overflow, so raising the preferred size here (was ~17px at
     375px via the desktop clamp) is safe and still content-aware. */
  .kpi-icon-wrap {
    position: absolute; top: 10px; right: 10px;
    width: 22px; height: 22px; border-radius: 6px; margin-bottom: 0;
  }
  .kpi-icon-wrap svg { width: 12px !important; height: 12px !important; }
  .kpi-label { font-size: var(--fs-2xs); margin-bottom: 4px; }
  .kpi-value { font-size: clamp(17px, 5.6vw, 22px); }
}

/* ═══════════════════════════════════════════════════════════════════════════
   FULL-COVER MODAL ⇄ PAGE WINDOW PARITY — v14 "one window" pass, phone tier.

   The app has TWO kinds of full-screen window on a phone: openPage panels
   (.page-panel, 133 call sites) and openModal surfaces (#modal-overlay >
   #modal-box, 21 call sites). Earlier passes made them ENTER and LEAVE
   identically — both instant, both anchored to --vvh/--vv-top, both occluding
   the shell. A measured audit at 375x812 then found that they still did not
   LOOK the same once open. Owner's brief, verbatim: "an independent window, not
   overlays of different pages … no overlaps, no redundancies." Two chrome
   colours, two content insets and two exit controls for the same gesture is
   exactly the "redundancies" half of that.

   THE PAGE WINDOW IS THE TARGET, not a new middle ground: it is the 133-call-
   site majority, it is what the owner sees on nearly every navigation, and it
   already handles the notch, the home indicator and the software keyboard
   correctly. Every value below is copied from a .page-panel-* rule earlier in
   this file, not invented here. Where a page rule carries a safe-area or
   keyboard expression, the SAME expression is used — not a variant — so the
   two windows cannot drift apart the next time one of them is tuned.

   WHY THIS BLOCK IS AT THE VERY END OF THE FILE, and not merged into the
   ≤639px "full-cover" block ~3000 lines up where it belongs topically:
   the v14 ≤640px density pass (the block immediately above this one) declares
   `.modal-header{padding:12px 14px}`, `.modal-body{padding:14px}` and
   `.modal-footer{padding:10px 14px}` as flat four-sided SHORTHANDS. ≤640px
   matches every real phone width, so it always matches together with the
   ≤639px block, and being later in the file it wins every padding property at
   equal specificity. That is precisely how the ≤639px block's bottom
   safe-area handling became dead code (see the MOVED breadcrumb there). Any
   fix written above the density pass loses the same fight again. Equal
   specificity + later position is the whole mechanism — deliberately NO
   `!important` here, so this stays overridable by anything more specific and
   the cascade order remains the only thing to reason about.

   SCOPE — TWO AXES. Both were wrong in the first cut of this block, in the
   same way: style was media-scoped for a MARKUP change that was not.

   BY WIDTH. What remains below is ≤639px only, matching the tier at which
   Overlay's _COVER_KINDS (js/config.js) promotes 'modal' to a cover kind and
   the ≤639px block turns .modal-box into a full-cover window. 640-768px keeps
   the bottom-sheet baseline; ≥640px desktop modals keep --surface-2 chrome,
   their border, shadow and radius, all untouched.
   What did NOT belong here — and has MOVED OUT, to unconditional rules earlier
   in the file — is everything that follows from the markup rather than from
   the full-cover presentation. index.html emits the three-child
   [back][title][spacer] header at EVERY width; it is not media-queried. Left
   media-scoped, the exit control fell through to the UA button sheet and the
   title was flung to the middle of the header on every desktop modal AND on
   any phone in landscape (812pt > 639) — i.e. mid-rotation, on the owner's own
   device. Those rules now live with .page-panel-back (~line 2243), with
   .modal-header (~line 2393), and in the two 44px-floor sites (~line 121 and
   ~line 2918/2948).
   CORRECTION for anyone who has read an older revision of this comment: it
   used to assert that "≥640px desktop modals keep … the right-side circular
   close button". That has not been true since the markup change — the control
   is a top-left back arrow at every width. The circular .modal-close treatment
   (~line 2442, ~line 3976) is NOT dead code and must not be deleted: it is
   still the live style for js/screens/partners.js's #deal-modal-close (~970),
   the only remaining element in the app wearing that class.

   BY SELECTOR. Every rule below is scoped `.modal-box …`, never bare
   `.modal-header` / `.modal-body` / `.modal-footer`. Those three names are not
   private to this window — js/screens/partners.js (~970) hand-builds the New
   Partner Deal dialog as .modal-overlay > .modal (no .modal-box) and reuses
   all three. It is a small centred dialog; none of the full-cover rationale
   above applies to it, yet the bare selectors leaked the whole treatment onto
   it. Measured at 375x812 against a structural replica: header/footer
   background --surface-2 → --surface (white strips against the --bg scrim,
   losing the tonal step that is the ONLY thing giving that otherwise
   transparent .modal box a visible body — there is no `.modal` rule in this
   file), header padding 12px 14px → 12px 16px, body 14px → 12px, footer
   10px 14px → 12px 16px, and its <h3> picked up flex:1/min-width:0/nowrap/
   ellipsis so a long client name truncated instead of wrapping.
   Scoping to .modal-box also changes the mechanism by which these rules win:
   (0,2,0) beats the v14 density pass's (0,1,0) on SPECIFICITY, so the
   "must sit later in the file than the density pass" constraint described
   above is now belt-and-braces rather than the sole mechanism. The block stays
   at the end of the file regardless — moving it would silently re-open that
   fight the moment any of these rules is re-scoped back.
   ═══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 639px) {

  /* ── PARITY 1 — CHROME COLOUR. The most perceptible of the differences and
     the one that made a modal read as a different KIND of surface: the modal's
     header/footer are var(--surface-2) (~line 3968/3972, the desktop dialog
     treatment — a raised strip against the dialog body) while a page window's
     head/foot are var(--surface). Measured side by side, dark #24282D vs
     #1A1D21, light #F2F4F7 vs #FFFFFF. On a centered desktop dialog the tonal
     step is doing real work; on a full-cover window there is no dialog to step
     up FROM, so it just reads as "a different app screen".
     Colour only — the 1px var(--border) bottom/top rules those same selectors
     carry are correct and kept, because .page-panel-head/-foot carry the
     identical hairline. Above 639px --surface-2 stands — as it also does, at
     every width, for the Partners deal dialog these selectors used to reach
     (see the BY SELECTOR note above). */
  .modal-box .modal-header { background: var(--surface); }
  .modal-box .modal-footer { background: var(--surface); }

  /* ── PARITY 5a — HEADER LAYOUT. MOVED OUT of this media query, to
     `.modal-box .modal-header` / `.modal-box .modal-header h3` beside the
     .modal-header base rule (~line 2393). The markup it compensates for —
     index.html's three-child [back][title][spacer] header — is emitted at
     every width, so the layout that makes it correct has to be stated at every
     width too; measured at 1280x800 the title's left edge was 222.7px in from
     the header content box instead of flush at 20px. Full rationale, and the
     .modal-box scoping that keeps it off the Partners dialog, are at the new
     site. Nothing to declare here: those rules already apply on this tier.

     ── PARITY 5b / 5c — EXIT CONTROL AND ITS 44x44 TAP TARGET. Also MOVED OUT,
     for the same reason: .modal-back is in the markup at every width, and left
     styled only here it fell through to the UA button sheet above 639px —
     measured 28 x 31.5px, rgb(239,239,239) fill, 2px OUTSET black border, on
     desktop and on any phone in landscape.
       • Appearance is now the SHARED selector list
         `.page-panel-back, .modal-back` (~line 2243), so the two windows' exit
         controls are one declaration list and cannot drift apart.
       • The 44x44 floor is `.page-panel-back, .modal-back{padding:10px;
         margin:-6px}` in the ≤768px block (~line 2948), plus the
         `@media (pointer: coarse)` ::after overlay (~line 121) which covers
         coarse pointers WIDER than 768px (iPad landscape, touch laptops).
     The rules that stood here also carried `width/height: auto` and a `:hover`
     reset in order to beat .modal-close's 26px circle and its --surface-3
     hover chip. Both are dead weight now and were dropped rather than moved:
     the button dropped the .modal-close CLASS when it became a back arrow
     (index.html ~348 — it keeps only the id, which is what every handler binds
     to), so no .modal-close rule has selected it since. To be explicit,
     because it is the obvious next cleanup and it would be WRONG: the
     .modal-close rules themselves (~line 2442, ~line 2449, ~line 3976,
     ~line 3980, and the ≤768px floor ~line 2918) are NOT dead — grep confirms
     js/screens/partners.js:970 still renders #deal-modal-close with that
     class, and it is the only element left that does. Leave them. */

  /* ── PARITY 4 — CONTENT INSETS, and a real landscape defect with them.
     Measured at 375x812: modal header/footer sides 14px vs the page window's
     16px, modal body 14px vs the page body's 12px. Small numbers, but they are
     what makes a form's fields fail to line up with the same form's fields on
     the screen it was opened from.
     The landscape defect: the density pass's flat `padding: 12px 14px` on
     .modal-header WIPES the `max(20px, env(safe-area-inset-left/right))` the
     ≤639px block used to set, so on a notched iPhone held sideways the modal's
     title and exit control sit UNDER THE NOTCH while .page-panel-head clears
     it via the `max(16px, env(...))` in its base shorthand. Restored below, at
     the page window's 16px floor rather than the old 20px.
     Each of the three is the page window's own expression, copied:
       header ← .page-panel-head (~2216) + its ≤768px notch top-up (~2794)
       body   ← .page-panel-body as re-declared by the density pass (~6772),
                which is why the body floor is 12px and not 16px
       foot   ← .page-panel-foot (~2282) + its ≤768px keyboard correction (~2809)
     PARITY 2 — the bottom safe-area, the one outright DEFECT in this set —
     rides along in the body and foot shorthands. `calc(12px + max(0px,
     env(safe-area-inset-bottom,0px) - var(--kb-h,0px)))` is the codebase's
     standard keyboard-aware inset, used verbatim at four other sites
     (.page-panel-body, .page-panel-foot, .messenger-input-row, the density
     pass's .page-panel-body) — deliberately not a fifth formulation:
       keyboard down → max(0px, 34px - 0px)    = 34px → 46px, clear of the
                                                 home indicator
       keyboard up   → max(0px, 34px - ~300px) = 0px  → flat 12px, flush to
                                                 the keys with no dead band
     Before this, the density pass's flat `padding: 10px 14px` put a full-cover
     modal's Save/Cancel row 10px off the bottom of an iPhone — inside the
     home-indicator band, on 21 call sites — while the page window it was
     opened from put the same buttons at 46px. --kb-h is published on <html> by
     window.ViewportSync (js/config.js); if it were never published the var()
     fallback of 0px collapses the max() to the plain inset, so the worst
     failure mode is the pre-keyboard-model behaviour rather than a broken one. */
  .modal-box .modal-header {
    padding: calc(12px + env(safe-area-inset-top, 0px))
             max(16px, env(safe-area-inset-right, 0px))
             12px
             max(16px, env(safe-area-inset-left, 0px));
  }
  .modal-box .modal-body {
    padding: 12px max(12px, env(safe-area-inset-right, 0px))
      calc(12px + max(0px, env(safe-area-inset-bottom, 0px) - var(--kb-h, 0px)))
      max(12px, env(safe-area-inset-left, 0px));
  }
  .modal-box .modal-footer {
    padding: 12px max(16px, env(safe-area-inset-right, 0px))
      calc(12px + max(0px, env(safe-area-inset-bottom, 0px) - var(--kb-h, 0px)))
      max(16px, env(safe-area-inset-left, 0px));
  }
}

/* v14 (owner: "receivables look bad") — analytics two-panel rows (Top Clients |
   Receivables, and the other renderAnalytics side-by-side cards) used a fixed
   2-column inline grid that never collapsed, so on a phone each panel was ~180px
   and card values clipped off the right. Stack them below 700px. The inline
   grid-template-columns keeps the desktop ratios.
   MOBILE FINANCE PASS (2026-08-08) — .bke-row2 joins it: Finance → Break-even
   shipped the identical defect (`<div class="form-row" style="grid-template-
   columns:1fr 1fr">`, js/screens/finance.js), where the inline attribute
   unconditionally outranks the ≤768px `.form-row{grid-template-columns:1fr}`
   collapse, rendering the Fixed Costs and Variable Costs tables side by side at
   ~165px each on a 375px phone. Same shape, same cure — an !important author
   declaration is the only thing that beats a non-important inline style, which
   is exactly why this rule exists rather than editing .form-row's base (that
   would still lose to the inline attribute, and would move every other
   .form-row's breakpoint from 768 to 700 as collateral). */
@media (max-width: 700px) {
  .an-row2, .bke-row2 { grid-template-columns: 1fr !important; }
}

/* ══════════════════════════════════════════════════════════════════════
   CALENDAR — month grid + day chips (js/meetings.js)
   Phone-first: the seven columns must fit 375px with NO horizontal scroll,
   so the grid is fractional (never fixed px) and the chips clip rather than
   widen the track. minmax(0,1fr) — not 1fr — because a bare 1fr track has
   min-width:auto and a long chip would push the row wider than the screen.
══════════════════════════════════════════════════════════════════════ */
.cal-bar { display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin-bottom:10px; }
.cal-bar .cal-title { flex:1; min-width:120px; font-weight:800; font-size:15px; text-align:center; }
.cal-dow { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; margin-bottom:3px; }
.cal-dow span { text-align:center; font-size:10px; font-weight:700; letter-spacing:.04em;
  text-transform:uppercase; color:var(--text-muted); }
.cal-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }
.cal-cell {
  min-height:64px; padding:3px; border:1px solid var(--border); border-radius:7px;
  background:var(--surface); color:var(--text); text-align:left; cursor:pointer;
  display:flex; flex-direction:column; gap:2px; overflow:hidden;
  font:inherit; transition:background var(--t1), border-color var(--t1);
}
.cal-cell.cal-pad { background:transparent; border-color:transparent; cursor:default; }
.cal-cell .cal-num { font-size:11px; font-weight:700; color:var(--text-muted); line-height:1.1; }
.cal-cell.cal-today { border-color:var(--accent, #E64980); }
.cal-cell.cal-today .cal-num { color:var(--accent, #E64980); }
.cal-cell.cal-has .cal-num { color:var(--text); }
.cal-chip {
  font-size:9px; line-height:1.25; padding:1px 3px; border-radius:3px;
  background:var(--s2); color:var(--text);
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;
}
/* A 375px phone gives each column about 50px. A chip reading "09:00 Weekly
   produ…" ellipsises immediately after the time, so the title never survives —
   the user learns only that SOMETHING is at 9am, which the time alone already
   said. So show the time alone on a phone (honest and legible) and let the day
   agenda carry the detail; the title returns as soon as there is room for it. */
.cal-chip-n { display:none; }
@media (min-width:769px) { .cal-chip-n { display:inline; } }
.cal-chip-fu { background:var(--warning-bg, var(--s2)); font-style:italic; }
.cal-more { font-size:9px; color:var(--text-muted); }
@media (hover:hover) { .cal-cell:not(.cal-pad):hover { background:var(--s2); } }
/* Desktop has the room for a taller, more readable month. */
@media (min-width:769px) {
  .cal-cell { min-height:96px; padding:5px; gap:3px; }
  .cal-cell .cal-num { font-size:12px; }
  .cal-chip { font-size:11px; padding:2px 5px; }
  .cal-more { font-size:11px; }
}

/* COMPANY-AND-CALENDAR-SPEC-2026-08-12 §2.9 — one merged calendar reader now
   feeds this grid (holidays/task deadlines/leave/bidding deadlines/delivery
   dates alongside meetings), so non-meeting chips get their own kind colour:
   a left border + the file's existing "-soft" tint pattern, always visible
   (unlike meeting chips, which hide their title <769px — these carry no time
   to show instead, so hiding the whole chip on a phone would leave an empty
   coloured bar; the base .cal-chip ellipsis rule truncates instead). */
.cal-chip-hol   { border-left:3px solid var(--gold);    background:rgba(255,214,10,0.12); }
.cal-chip-task  { border-left:3px solid var(--danger);  background:var(--danger-soft); }
.cal-chip-leave { border-left:3px solid var(--success); background:var(--success-soft); }
.cal-chip-bid   { border-left:3px solid #7e57c2;        background:rgba(126,87,194,0.14); }
.cal-chip-del   { border-left:3px solid #FF9F0A;        background:rgba(255,159,10,0.14); }

/* Legend row between .cal-bar and .cal-dow — only the kinds THIS viewer can
   ever receive are rendered (js/meetings.js _visibleLegendKinds). Wraps at
   375px, never scrolls sideways. */
.cal-legend { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:8px; }
.cal-legend-swatch { display:inline-flex; align-items:center; gap:5px; font-size:11px; color:var(--text-muted); }
.cal-legend-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; }
.cal-legend-dot-hol   { background:var(--gold); }
.cal-legend-dot-meet  { background:var(--info); }
.cal-legend-dot-task  { background:var(--danger); }
.cal-legend-dot-leave { background:var(--success); }
.cal-legend-dot-bid   { background:#7e57c2; }
.cal-legend-dot-del   { background:#FF9F0A; }

/* Dashboard mini calendar dots (renderMiniCal, js/screens/dashboards.js) —
   up to 3 per day, one per distinct kind, priority order holiday > meeting/
   follow-up > task > leave > bidding > delivery. */
.minical-dots { position:absolute; bottom:3px; left:50%; transform:translateX(-50%); display:flex; gap:2px; }
.minical-dot { width:4px; height:4px; border-radius:50%; display:inline-block; }

/* ══════════════════════════════════════════════════════════════════════
   PAYSLIP GENERATOR — the live-total bar (js/screens/hr.js)
   Owner, 2026-08-10: "why is there a floating payslip" + "not mobile
   optimized". The bar is position:sticky;bottom:0 so the figure stays on
   screen while you fill the form in — which is right, and is why it was
   built. What was wrong is that on a phone it is ~150px tall, so it covered
   the very fields being typed into: the rate, allowance and hours inputs
   scroll BEHIND it and cannot be brought clear.

   Two fixes, no behaviour change on desktop:
   1. On a phone the bar collapses to ONE line — the net figure, which is the
      only one worth watching while typing. The breakdown is still there on
      the payslip itself and in Preview.
   2. Whatever height it ends up, the form gets that much bottom padding so
      the last field can always be scrolled above it. A sticky footer without
      this always hides its own form's tail.
══════════════════════════════════════════════════════════════════════ */
@media (max-width: 700px) {
  .ps-live-total { padding: 8px 12px !important; margin-top: 10px !important; }
  /* Collapse to the net line: hide the three component rows and the basis
     note, keep the row that carries Net pay. */
  .ps-live-total > div:not(:nth-last-child(2)) { display: none !important; }
  .ps-live-total > div:nth-last-child(2) { border-top: 0 !important; padding-top: 0 !important; margin: 0 !important; }
  .ps-live-total #ps-sum-net { font-size: 18px !important; }
  /* The form's tail must be able to clear the bar. */
  .page-panel-body:has(.ps-live-total) { padding-bottom: 84px; }
}

/* ══════════════════════════════════════════════════════════════════════
   FLOATING ACTION BUTTON — .fab
   Owner, 2026-08-10: "add contact button, let it be a floating button on the
   right bottom side which follows along when scrolling, modern design".

   The CRM directories run to 129 rows, so the "+ Add Contact" that lived in
   the filter row scrolled out of reach the moment you started looking for a
   duplicate. position:fixed keeps it with you.

   `.fab` was already reserved by the print rule (~line 7587,
   `.fab,.no-print{display:none!important}`) but never defined — so printing a
   directory already knew to drop it, and nothing else claimed the name.

   GEOMETRY. It has to clear two things this app has and a desktop app does
   not: the mobile bottom nav, and the iPhone home indicator. --sab-eff is
   ViewportSync's corrected safe-area inset (js/config.js) — the plain
   env(safe-area-inset-bottom) over-reports on this device, which is what put
   a 59px gap under the chat composer once. Desktop has neither, so it sits at
   a plain 24px.

   Extends to a pill on hover/focus so the label is discoverable without
   costing width at rest — collapsed it is a 56px circle, which is the minimum
   comfortable thumb target and well past the 44px floor.
══════════════════════════════════════════════════════════════════════ */
.fab {
  position: fixed; right: 20px; z-index: var(--z-fab, 90);
  bottom: calc(24px + max(0px, var(--sab-eff, env(safe-area-inset-bottom, 0px))));
  display: inline-flex; align-items: center; gap: 0;
  height: 56px; min-width: 56px; padding: 0 16px;
  border: 0; border-radius: 28px; cursor: pointer;
  background: linear-gradient(135deg, var(--primary, #3B5BDB), var(--primary-light, #5C7CFA));
  color: #fff; font: inherit; font-size: var(--fs-base); font-weight: 700;
  box-shadow: 0 6px 20px rgba(0,0,0,.28), 0 2px 6px rgba(0,0,0,.18);
  transition: transform var(--t1), box-shadow var(--t1), gap var(--t1);
  -webkit-appearance: none; appearance: none;
}
.fab .fab-icon { display: inline-flex; flex-shrink: 0; }
/* The label is width-animated rather than display-toggled so the pill grows
   smoothly instead of snapping, and so it is still in the accessibility tree
   when collapsed. */
.fab .fab-label {
  max-width: 0; overflow: hidden; white-space: nowrap; opacity: 0;
  transition: max-width var(--t2), opacity var(--t1), margin var(--t1);
}
@media (hover: hover) {
  .fab:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(0,0,0,.34), 0 3px 8px rgba(0,0,0,.2); }
  .fab:hover .fab-label, .fab:focus-visible .fab-label { max-width: 200px; opacity: 1; margin-left: 8px; }
}
.fab:focus-visible .fab-label { max-width: 200px; opacity: 1; margin-left: 8px; }
.fab:active { transform: translateY(0) scale(.96); }
/* On a phone the bottom nav owns the bottom of the screen, so clear it. */
@media (max-width: 768px) {
  .fab {
    right: 16px;
    bottom: calc(var(--bottom-nav-h, 64px) + 16px + max(0px, var(--sab-eff, env(safe-area-inset-bottom, 0px))));
    height: 52px; min-width: 52px; padding: 0 14px;
  }
  /* No hover on touch, so the label would never appear — show it always, which
     also makes the action legible to someone who has not used the app before. */
  .fab .fab-label { max-width: 200px; opacity: 1; margin-left: 8px; }
}
/* The sidebar drawer covers the screen on mobile; a button floating over it
   would tap through to the wrong thing. */
body.sidebar-open .fab { display: none !important; }
