/* ═══════════════════════════════════════════════════════════════
   FLOW — LAYOUT
   Structural rules: body, sidebar, topbar, content, page system.
   Nothing visual here — only structure and positioning.
   ═══════════════════════════════════════════════════════════════ */

html {
  overflow-x: hidden;
  /* overflow-y explicit (not just body's) — otherwise body's own
     `overflow:hidden` doesn't fully suppress root-level vertical scroll per
     the CSS Overflow spec, leaving an ambiguous scroll root. On mobile this
     let the browser's native "scroll focused element into view" behavior
     act on the document root instead of the real scroll container
     (.content/.conv-main), which is what made focusing the Conversations
     compose box jump the whole page to the very top instead of just
     nudging the input above the keyboard (2026-07-09). */
  overflow-y: hidden;
}
body {
  font-family: var(--font-body);
  background: var(--bg);
  color: var(--text);
  display: flex;
  height: 100vh;
  overflow: hidden;
  font-size: var(--text-base);
}

.app-hidden { display: none !important; }

/* ═══ SIDEBAR ═══════════════════════════════════════════════════ */
#sidebar {
  width: var(--sidebar-width);
  background: var(--surface);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
  overflow-y: auto;
  overflow-x: hidden;
  transition: width var(--transition-slide);
  height: 100vh;
}
#sidebar nav { flex: 1; }

/* Collapsed state */
#sidebar.collapsed { width: 52px; }
#sidebar.collapsed .logo-text,
#sidebar.collapsed .nav-section,
#sidebar.collapsed .nav-label,
#sidebar.collapsed .nav-badge,
#sidebar.collapsed .logo-separator,
#sidebar.collapsed .logo-sub { display: none; }
#sidebar.collapsed .logo           { padding: 14px 10px; justify-content: center; }
#sidebar.collapsed .logo-icon-full { max-width: 34px; }
#sidebar.collapsed .nav-item       { padding: 10px; justify-content: center; }
#sidebar.collapsed .nav-item .ni   { width: auto; font-size: 18px; }
#sidebar.collapsed .nav-item.active { border-left-color: var(--accent); }
#sidebar.collapsed .sidebar-toggle  { padding: 10px 0; font-size: 16px; }

/* ── Logo area ── */
.logo {
  padding: 34px 16px 8px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.logo-icon-full {
  width: 100%;
  max-width: 175.5px;
  height: auto;
  object-fit: contain;
}
.logo-icon {
  width: 45px; height: 45px;
  object-fit: contain;
  flex-shrink: 0;
}
.logo-text  { display: flex; flex-direction: column; }
.logo-name  {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  font-weight: 700;
  color: var(--accent);
  letter-spacing: -1px;
  line-height: 1;
}
.logo-separator {
  height: 1px;
  background: var(--border);
  margin: 4px 16px 8px;
}
.logo-sub {
  font-size: 9px;
  color: #9b9ba8;
  text-transform: uppercase;
  letter-spacing: 1.5px;
  padding: 0 16px 14px;
  border-bottom: 1px solid var(--border);
}

/* ── Nav ── */
nav { flex: 1; padding: 10px 0; }

.nav-section {
  padding: 10px 14px 4px;
  font-size: 9px;
  color: var(--text3);
  text-transform: uppercase;
  letter-spacing: 1.5px;
  font-weight: 600;
}
.nav-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 8px 14px;
  color: var(--text3);
  cursor: pointer;
  border-left: 2px solid transparent;
  transition: all var(--transition);
  font-size: var(--text-base);
  font-weight: 500;
}
.nav-item:hover        { background: var(--surface2); color: var(--text2); }
.nav-item.active       { background: var(--accent-glow); color: var(--accent-bright); border-left-color: var(--accent); }
.nav-item .ni          { width: 17px; height: 17px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.nav-item .ni .icon    { width: 17px; height: 17px; }
.icon                  { display: inline-block; vertical-align: middle; }
.nav-badge {
  margin-left: auto;
  /* Red (var(--danger)), matching the bell icon's own .notif-badge
     (components.css) — was the accent purple, which read as just another
     brand accent rather than an "you have unread things" alert color
     (2026-07-28 feedback). */
  background: var(--danger);
  color: #fff;
  font-size: 9px;
  font-weight: 700;
  border-radius: 20px;
  padding: 1px 6px;
  min-width: 18px;
  text-align: center;
}

/* Collapse toggle */
.sidebar-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  padding: 8px 0;
  cursor: pointer;
  font-size: 13px;
  font-weight: 700;
  color: var(--accent-light);
  border-top: 1px solid var(--border);
  background: transparent;
  transition: all var(--transition);
  flex-shrink: 0;
  letter-spacing: 1px;
  user-select: none;
}
.sidebar-toggle:hover { background: var(--accent-glow); color: var(--accent-bright); }

/* ═══ MAIN ════════════════════════════════════════════════════ */
#main {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  min-width: 0;
}

/* ── Topbar ── */
.topbar {
  background: var(--bg);
  border-bottom: 1px solid var(--border);
  padding: 0 var(--space-5) 16px;
  height: var(--topbar-height);
  display: flex;
  align-items: flex-end;
  gap: var(--space-3);
  flex-shrink: 0;
}
.page-title {
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 700;
  white-space: nowrap;
}
.topbar-right { margin-left: auto; display: flex; gap: var(--space-2); align-items: center; }

/* ── Content area ── */
.content {
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
  padding: var(--space-5);
  min-height: 0;
  position: relative;
  /* overscroll-behavior-y:contain (2026-07-20 feedback: "I can still scroll
     past the soft stop", plus the mystery ~45px gray bar seen on Contacts) —
     without this, dragging past the real end of a page's content on mobile
     Chrome/Safari rubber-bands past the (correctly keyboard-positioned) nav
     bar, revealing blank html/body background beneath it. This is the exact
     same fix already applied to .conv-messages on 2026-07-26 for the
     identical symptom in Conversations' compose bar — it was scoped to that
     one scroll container only; this is every other regular page's shared
     scroll surface (Items & Services, Estimates, Invoices, Calendar, etc.)
     that was still missing it. */
  overscroll-behavior-y: contain;
}

/* ── Page system ── */
.page        { display: none; }
.page.active { display: block; min-height: 0; background: var(--bg); }

/* Full-bleed pages (Conversations, Internal Chat, Contacts) */
.page-fullbleed         { position: absolute; top: 0; left: 0; right: 0; bottom: 0; padding: 0; margin: 0; overflow-x: hidden; }
.page-fullbleed.active  { display: flex !important; flex-direction: column; }
.page-subtab-panel      { display: block; }
.page-subtab-panel[style*="display: none"] { display: none !important; }

/* ═══ CONTACT SLIDE PANEL ══════════════════════════════════════ */
#custSlidingContainer {
  flex: 1;
  overflow: hidden;
  display: flex;
  position: relative;
  min-height: 0;
}
#custPanel-main.conv-subpanel.active {
  position: absolute;
  inset: 0;
  display: block;
}

/* ═══ MOBILE RESPONSIVE LAYER — outer app shell ═══════════════════
   Same codebase as desktop, not a separate app. REVISED 2026-07-06:
   the desktop sidebar (even collapsed to an icon rail, the first
   approach tried) is hidden entirely on mobile now — replaced by a
   centered logo added into the existing .topbar (notification bell
   untouched) and #mobileBottomBar (5-icon Instagram-style tab bar,
   each opening a submenu sheet of that group's pages).
   Deliberately placed at the END of this file — the plain (non-media)
   .content/.topbar/.page-fullbleed rules above set padding/position
   properties this block also touches; since media-query rules don't
   get any specificity boost, whichever declaration for the same
   property/selector comes LAST in source order wins once the media
   condition is true. Putting this block first (tried initially) meant
   the later plain rules silently re-overrode it back to desktop
   values — real bug, caught via direct getComputedStyle checks in
   preview testing, not visually obvious. Keep any future mobile-only
   override of these same selectors below this comment, not above. */
.topbar-mobile-logo { display: none; }
#mobileBottomBar, #mobileNavSheet, #mobileFab { display: none; }
#mobileCallOverlay { display: none; }
/* Settings drill-down back button is a mobile-only affordance (desktop shows
   every category's content directly, no drill-down to back out of) — had no
   base rule at all outside the mobile media query below, so it fell back to
   a plain visible <button> on desktop (2026-07-09 bug report). */
.settings-back-btn { display: none; }

/* Desktop-default baseline for the mobile pipeline picker/sort toggle below
   (2026-07-26 feedback: "pipeline picker is nowhere to be found") — these
   two rules used to sit AFTER the mobile media query's own display:block/
   flex overrides instead of before it. Equal-specificity rules resolve ties
   by source order regardless of which one is inside a media query, so this
   unconditional display:none was winning even on mobile screens, hiding the
   picker <select> completely instead of only hiding it on desktop as
   intended. Moved here, ahead of the mobile block, so the mobile overrides
   correctly win on mobile again. */
.pipeline-tabs-mobile-select { display: none; }
.opp-sort-mobile { display: none; }

@media (max-width: 768px) {
  /* Text Size knob (Settings → Appearance → Text Size, mobile-only control —
     see .settings-row-mobile-only in index.html and saveTextScalePref()/
     renderSettings() in js/snippets.js). --ui-text-scale is set on
     documentElement by JS from a stored preference (default 1 = no change).
     Only these mobile-scoped token overrides read it — the plain :root
     values in variables.css are untouched, so desktop never sees this. */
  :root {
    --text-2xs:  calc(10px * var(--ui-text-scale));
    --text-xs:   calc(11px * var(--ui-text-scale));
    --text-sm:   calc(12px * var(--ui-text-scale));
    --text-base: calc(14px * var(--ui-text-scale));
    --text-md:   calc(15px * var(--ui-text-scale));
    --text-lg:   calc(17px * var(--ui-text-scale));
    --text-xl:   calc(20px * var(--ui-text-scale));
    --text-2xl:  calc(26px * var(--ui-text-scale));
    /* Standardize every button/input/select/textarea to one touch-target
       height on mobile (matches the .btn-icon-mobile 44px convention already
       used for icon-only buttons) — .btn and the base input/select/textarea
       rule in components.css both already key off this one variable, so this
       single override cascades to every field and button app-wide, including
       search bars, with no per-element edits needed. */
    --control-height: 44px;
  }
  /* Dropdown-label hiding is mobile-only (2026-07-18 feedback reverses the
     earlier app-wide version) — desktop keeps showing the label above a
     "Select {Field}..." dropdown. */
  .form-group label.select-label-hidden { display: none !important; }

  /* Exactly one field per row on mobile (2026-07-19 feedback) — desktop's
     side-by-side .form-row grid (1fr 1fr / three / four) is too cramped
     once the touch-target height above kicks in. */
  .form-row, .form-row.full, .form-row.three, .form-row.four { grid-template-columns: 1fr !important; }

  /* Floating label, mobile only (2026-07-19 feedback — reverses the
     2026-07-17 "labels-as-placeholders" approach, js/ui.js's now-removed
     compactMobileFormLabel(): Ricardo wants the label to stay VISIBLE, just
     moved inside the field's own box at the top-left, in the smallest font
     token, instead of disappearing into the placeholder. Since that JS
     approach worked by overwriting field.placeholder at runtime and never
     touched the real placeholder attribute in index.html, removing the call
     automatically restores every field's original "e.g. ..." example text —
     no placeholder values needed to be re-typed anywhere.
     .form-group is a flex column (label, gap, field) on desktop; on mobile
     it becomes a positioning context instead so the label can sit OVER the
     field's own top-left corner rather than above it, and the field itself
     grows a little taller with padding-top to make room below the label. */
  .form-group { position: relative; }
  .form-group > label {
    position: absolute;
    top: 4px;
    left: var(--control-padding-x);
    right: var(--control-padding-x);
    z-index: 1;
    margin: 0;
    font-size: var(--text-2xs);
    line-height: 1;
    pointer-events: none;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .form-group > input:not([type="checkbox"]):not([type="radio"]),
  .form-group > select {
    height: calc(var(--control-height) + 14px);
    padding-top: 14px;
  }
  .form-group > textarea { padding-top: 20px; }
  /* Pending Booking modal's Drop Off / Estimated Pick Up / Details fields
     (2026-07-29 feedback: "labels sitting inside/overlapping the field's own
     content area") — these three readonly textareas (index.html,
     #modal-pendingBooking) carry their own inline `padding:8px`, which as an
     inline style overrides the shared `.form-group > textarea` rule directly
     above regardless of specificity (only !important can win that back) —
     that's why the floating label still overlapped the textarea's own text
     even though every other textarea already gets this same room. */
  #pbm-dropoff, #pbm-pickup, #pbm-details { padding-top: 20px !important; }
  /* A hidden label (select-label-hidden above, or a field with no visible
     label at all) has nothing to make room for. */
  .form-group > label.select-label-hidden ~ select { padding-top: 0; height: var(--control-height); }
  /* Contact modal's Phone field never got this floating-label room
     (2026-07-27 feedback: "still the old height") — the selectors above
     only match a bare <input>/<select> as .form-group's DIRECT child, but
     this field wraps its real control in an extra div (.phone-field-wrap,
     the +1/number row) so the label sat on top of an unchanged-height box.
     No explicit height needed here since it's a flex row that grows with
     its own padding.
     (Tags used to need the same treatment via .tag-input-wrap, but that
     wrapper was removed 2026-07-22 — tags is a plain <input> now, so it
     already gets the shared floating-label rule above like every other
     bare field.) */
  .form-group > .phone-field-wrap { padding-top: 14px; }
  /* Date-picker fields (Schedule Appointment's Appointment Start/End, Internal
     Block, Estimate's Creation/Expiration Date, etc.) never got this
     floating-label room either (2026-07-21 feedback: "add the padding at the
     top to make up for the label inside the field, on the date fields") —
     the visible box here is .flow-date-trigger, a <button>, not a bare
     <input>/<select>, so it fell outside the selector above the same way
     .phone-field-wrap/.tag-input-wrap did. Same 14px room, applied to the
     trigger itself since that's what actually renders the field's height. */
  .form-group > .flow-date-wrap .flow-date-trigger {
    height: calc(var(--control-height) + 14px);
    padding-top: 14px;
  }
  /* Same floating-label room, three more non-bare-input fields that fell
     outside the shared rule the same way phone/date fields did above
     (2026-07-29 feedback — "the label sits inside the field now" on
     Attachments, and the Edit Line Item modal's Description/Material/
     Discounts fields):
     - #est-documents-list (Estimate modal) is a plain div, not an input.
     - #elsm-desc/#elsm-discounts (Edit Line Item modal) are plain divs too.
     - #elsm-material-wrap wraps its real <input> in an extra positioning
       div (for its search-dropdown), same reason .phone-field-wrap needed
       its own rule above. */
  .form-group > #est-documents-list,
  .form-group > #elsm-desc,
  .form-group > #elsm-discounts,
  .form-group > .elsm-material-wrap {
    padding-top: 14px;
  }
  /* Services field on the New/Edit Appointment modal (2026-07-29 feedback:
     "add spacing to the 'services' label" — it read as cramped against
     #ev-services-container right below it). Split out from the shared
     14px rule above into its own, slightly larger value — this label also
     carries a required-field asterisk + an ⓘ info-tooltip alongside the
     text, so it reads visually busier/taller than a plain one-word label
     like the other fields sharing that rule, and 14px wasn't quite enough
     clearance for it specifically. */
  .form-group > #ev-services-container {
    padding-top: 22px;
  }

  /* Modals: bump six text categories by one font-size token each (2026-07-25
     feedback) — scoped to .modal-overlay (every real modal) plus the three
     other surfaces Ricardo also calls "a modal" in practice: .est-detail-slider
     (shared by the Estimate/Items&Services/Event Detail sliders) and
     #custPanel-main/#custDetailPanel (the Contacts edit slide-in). Scoping
     this way — instead of editing the base app-wide rules — keeps list-page
     search bars/toolbar buttons/sort controls untouched, since those live
     outside all four of these containers. */
  .modal-overlay .form-group > label,
  .est-detail-slider .form-group > label,
  #custPanel-main .form-group > label,
  #custDetailPanel .form-group > label {
    font-size: var(--text-xs) !important; /* was --text-2xs, the floating-label override two rules above */
  }
  .modal-overlay input, .modal-overlay select, .modal-overlay textarea,
  .est-detail-slider input, .est-detail-slider select, .est-detail-slider textarea,
  #custPanel-main input, #custPanel-main select, #custPanel-main textarea,
  #custDetailPanel input, #custDetailPanel select, #custDetailPanel textarea {
    /* was --control-font-size (--text-sm) — placeholder text has no font-size
       rule of its own (input::placeholder only sets color), so it inherits
       this same bump automatically. */
    font-size: var(--text-base) !important;
  }
  .modal-overlay .form-hint, .est-detail-slider .form-hint,
  #custPanel-main .form-hint, #custDetailPanel .form-hint {
    font-size: var(--text-sm) !important; /* was --text-xs */
  }
  .modal-overlay th, .est-detail-slider th,
  #custPanel-main th, #custDetailPanel th {
    font-size: var(--text-sm) !important; /* was --text-xs */
  }
  .modal-overlay .badge, .est-detail-slider .badge,
  #custPanel-main .badge, #custDetailPanel .badge,
  .modal-overlay .tag, .est-detail-slider .tag,
  #custPanel-main .tag, #custDetailPanel .tag {
    font-size: var(--text-sm) !important; /* was --text-xs */
  }
  .modal-overlay .status-pill, .est-detail-slider .status-pill,
  #custPanel-main .status-pill, #custDetailPanel .status-pill {
    font-size: var(--text-sm) !important; /* was 11px hardcoded */
  }
  .modal-overlay .tag-pill, .est-detail-slider .tag-pill,
  #custPanel-main .tag-pill, #custDetailPanel .tag-pill {
    font-size: var(--text-base) !important; /* was 12px hardcoded */
  }

  /* Contact modal padding (2026-07-27 feedback: "far too big compared to
     anything else in the app") — .ec-section's desktop spacing (24px
     margin-bottom PLUS 24px padding-top on the next section, 48px combined)
     is the outlier here, not the modal's own shared 20px shell padding
     every bottom-sheet modal already uses. Cut to --space-4 (16px) each
     side on mobile, a still-clear but much less padding-heavy gap between
     sections on a small screen. Approximation — please confirm this reads
     right on a real device. */
  .ec-section { margin-bottom: var(--space-4); }
  .ec-section + .ec-section { padding-top: var(--space-4); }
  /* !important — components.css's plain .settings-row-mobile-only{display:none}
     rule loads after this file and wins at equal specificity otherwise, same
     cascade-order gotcha as every other mobile-only override in this file. */
  .settings-row-mobile-only { display: flex !important; }

  #sidebar { display: none; }
  #main { width: 100%; }

  /* Restored (2026-07-22 — the 100vh revert one round ago was wrong): body
     still needs --app-vh here. This has nothing to do with the keyboard
     specifically — it's the original 2026-07-08 fix, and it was never about
     the keyboard alone. window.innerHeight/100vh reports the LARGEST
     possible viewport (as if the browser's own address bar were fully
     collapsed); window.visualViewport.height reports what's ACTUALLY
     visible right now, which is smaller than that pretty much always, since
     Chrome's address bar is showing most of the time. Switching body to a
     bare `100vh` (previous round) made body taller than the real visible
     screen at ALL times, at rest, with no keyboard involved at all — which
     is exactly why the nav bar ended up sitting below the actually-visible
     area and content looked like it disappeared behind it on every page
     (2026-07-21 feedback). --app-vh fixes that by tracking the real,
     currently-visible height instead. Since .content/.page-fullbleed are
     normal (non-fixed) flex children of body, shrinking body here is what
     correctly shrinks THEM too, via ordinary flex layout — completely
     independent of whatever interactive-widget mode the viewport meta uses,
     since that setting only affects window.innerHeight/position:fixed's
     containing block, not visualViewport or plain box layout. The
     env(keyboard-inset-height) padding added to .content/.page-fullbleed one
     round ago is removed below — it was stacking a second, redundant
     keyboard-reservation on top of this one (double-counting), and relied
     on a CSS feature (env(keyboard-inset-height)) that isn't confirmed to
     even be supported/behaving correctly on the real device being tested
     on, which is the far more likely explanation for "can't scroll down
     when the keyboard opens" than anything about --app-vh itself. */
  body { height: var(--app-vh, 100vh); }

  /* Mobile topbar is just an icon bar (logo + bell), not the full
     title/subtitle desktop header — 56px total (15px padding above the
     34px bell + 7px below) rather than reusing the much taller desktop
     --topbar-height (92px), which used to leave the bell sitting in a box
     ~2.5x its own size (2026-07-07 feedback). Plus env(safe-area-inset-top)
     on top of that 56px (2026-07-07 feedback: "top of the conversations page
     hidden too far up" — root cause was the topbar/content having zero
     notch/status-bar safe-area handling, unlike the bottom bar which already
     accounted for env(safe-area-inset-bottom); the bell + content below it
     were sitting partly under the device's notch/status bar on real phones,
     which no amount of scrolling to hide the bell bar could fix since the
     "expanded" resting position was computed the same unsafe way. Requires
     viewport-fit=cover on the viewport meta tag — added alongside this —
     since env() resolves to 0 without it. */
  .topbar {
    position: fixed; top: 0; left: 0; right: 0; z-index: 3500;
    background: var(--bg);
    height: calc(56px + env(safe-area-inset-top));
    padding: calc(15px + env(safe-area-inset-top)) var(--space-5) 7px;
    align-items: center;
    transition: transform 0.25s ease;
  }
  .topbar.topbar-hidden { transform: translateY(-100%); }
  .page-title, #topbarTitleAction { display: none !important; }
  .topbar-mobile-logo {
    display: block; position: absolute; left: 50%; top: 50%;
    transform: translate(-50%, -50%); max-height: 40px; width: auto;
  }
  /* Bell removed entirely on mobile (2026-07-08 feedback) — the bottom
     nav's own per-group/per-item notification dots (task #261) already
     cover everything the bell used to surface, so it's redundant chrome
     now rather than "Dashboard-only" chrome. !important needed —
     .notif-bell's own unconditional display:flex lives in components.css,
     loaded after this file. */
  .notif-bell { display: none !important; }
  .content {
    padding-top: calc(var(--space-5) + 56px + env(safe-area-inset-top)) !important;
    /* env(keyboard-inset-height) term removed (2026-07-22) — body's own
       --app-vh shrink (restored above) already handles keeping this
       flex-child's box above the keyboard; adding env() padding here too
       was reserving the keyboard's height twice. */
    /* Was a hardcoded, unmeasured 78px approximation — the exact same figure
       already root-caused and replaced with the real measured bar height on
       Conversations/Contacts/Internal Chat (var(--nav-bar-height),
       updateAppViewportHeight(), js/ui.js) after it was found to clip
       content on real devices where the bar renders taller than expected.
       .content itself (every non-fullbleed page — Calendar, Estimates,
       Items & Services, etc.) never got that same fix, so it inherited the
       identical "cut off at the very bottom" risk (2026-07-26 feedback:
       "fit the whole calendar within the screen, its currently cut out"). */
    padding-bottom: calc(var(--nav-bar-height, 43px) + var(--space-5)) !important;
    transition: padding-top 0.25s ease;
  }
  /* Full-bleed pages (Conversations, Internal Chat) are position:absolute
     with inset:0 — that resolves against .content's PADDING box (padding
     included, border excluded), not its content box, so .content's own
     padding above does NOT create a gap for these pages automatically.
     Sits flush against the (now much shorter) topbar — no extra gap here,
     unlike regular pages which still get the normal --space-5 breathing
     room above via .content's own padding (2026-07-07 feedback: the
     conversation view's own topbar should sit flush with the bell bar). */
  .page-fullbleed {
    top: calc(56px + env(safe-area-inset-top)) !important;
    /* env(keyboard-inset-height) term removed (2026-07-22) — same reasoning
       as .content above. */
    bottom: 78px !important;
    transition: top 0.25s ease;
  }
  /* Full-bleed pages (Conversations, Internal Chat) manage 100% of their own
     internal scrolling via .conv-list/.conv-messages — .content itself
     should never be a scrollable surface underneath them. Left as the
     shared overflow-y:auto every other (non-fullbleed) page also uses, a
     rounding/viewport-unit hiccup on mobile Safari while the keyboard is
     resizing the visual viewport (see --app-vh, js/ui.js) could let
     .content itself become draggable, exposing empty space below the
     (correctly positioned) compose bar (2026-07-27 feedback: "I can still
     scroll under the compose bar when the keyboard is open"). Locking it
     down removes that scroll surface entirely rather than chasing the
     exact rounding case that triggers it. */
  .content:has(.page-fullbleed.active) { overflow: hidden !important; }
  /* Conversations top spacing (2026-07-17 feedback): the flush-to-topbar
     look from the earlier "close the 5px gap" pass read as cramped once
     compared side-by-side with a regular page like Estimates, which gets
     the standard --space-5 (20px) breathing room above its search bar via
     .content's own padding. Matching that same 20px here instead of sitting
     flush. Bottom stays its own closer-to-nav-bar value — only the top gap
     was the ask. */
  /* 2026-07-18 feedback: top sitting ~50px too low, bottom reserving ~30px
     too much — dropping the extra --space-5 breathing room added on
     2026-07-17 (back to flush with the topbar) and trimming the bottom
     inset accordingly. That pass's own comment flagged the 43px as "an
     approximation, not a measured pixel fix" — it wasn't confirmed, and on
     real devices the bottom nav bar's actual rendered height came out
     taller than 43px, clipping the conversation list's last item behind
     it (2026-07-28 feedback: "the very last item gets clipped halfway").
     --nav-bar-height (updateAppViewportHeight(), js/ui.js) now measures
     the bar's real on-screen height instead of guessing a constant —
     43px stays only as the fallback before that property is ever set. */
  #page-conversations.page-fullbleed {
    top: calc(56px + env(safe-area-inset-top)) !important;
    /* env(keyboard-inset-height) term removed (2026-07-22) — same reasoning
       as .content above. */
    bottom: var(--nav-bar-height, 43px) !important;
  }
  /* Contacts (2026-07-20 feedback: "the bar is actually always there
     directly above the nav bar" — not keyboard-related at all, present at
     rest) — #page-customers is full-bleed just like Conversations but never
     got the --nav-bar-height fix above, so it was still reserving the
     generic .page-fullbleed 78px fallback (layout.css, this file) instead of
     the bar's real measured height. Whatever the real bar height actually
     is, reserving the wrong (78px, unmeasured) amount left a permanent gap
     of leftover page background sitting between the contact list and the
     real nav bar — that gap is "the little bar". Same fix as Conversations:
     reserve the real measured height instead of a guess. */
  #page-customers.page-fullbleed {
    bottom: var(--nav-bar-height, 43px) !important;
  }
  /* Internal Chat has the identical generic-78px gap latent (same
     .page-fullbleed default, never given its own measured override either)
     — applying the same fix proactively rather than waiting for it to be
     separately reported. */
  #page-internalchat.page-fullbleed {
    bottom: var(--nav-bar-height, 43px) !important;
  }
  /* Keyboard-open collapse (2026-07-24 feedback: "3 pages [Conversations,
     Contacts, Internal Chat]... about a 45px bar showing up above the
     keyboard... the content does scroll correctly... it's just that the bar
     reduces the actual screen size above the keyboard") — the var(--nav-bar-
     height) reservations above (and .content's/.page-fullbleed's generic
     78px, layout.css) exist to keep content from sitting behind the nav
     bar's real VISIBLE height. Now that the bar is pinned via a fixed top
     (pinKeyboardImmuneBars, js/ui.js) instead of a reactive bottom, once the
     keyboard opens the bar sits behind/under it — nothing visible is left to
     clear there, so that reservation was just sitting as unused space
     between the real content and the keyboard. body.keyboard-open (toggled
     the instant a field gains/loses focus — js/ui.js's focusin/focusout
     listeners) collapses it back to 0 while a field is focused, freeing that
     space — same collapse-to-0 shape as the mbb-depth-hidden rule below,
     just keyed off the keyboard instead of one-level-deep navigation. */
  body.keyboard-open .content { padding-bottom: 0 !important; }
  body.keyboard-open .page-fullbleed,
  body.keyboard-open #page-conversations.page-fullbleed,
  body.keyboard-open #page-customers.page-fullbleed,
  body.keyboard-open #page-internalchat.page-fullbleed {
    bottom: 0 !important;
  }
  /* Root cause of "the top gap never actually moves" (2026-07-19 feedback):
     setMobileTopbarHidden() (js/ui.js) forces the topbar hidden on EVERY
     non-Dashboard page — including Conversations — the instant showPage()
     runs, not just on scroll (activePage !== 'dashboard' short-circuits the
     OR regardless of the passed-in value). That toggles .topbar-content-
     expanded on .page-fullbleed, but the generic
     `.page-fullbleed.topbar-content-expanded` rule below (2 classes, no ID)
     can never win against this page's own ID-scoped rule above (1 ID beats
     any number of classes in CSS specificity) — so #page-conversations kept
     reserving a full 56px for a topbar that was never actually visible on
     this page to begin with. Every previous round tweaking the resting-
     state 56px number was adjusting a value that was already being
     overridden the wrong way; this is the rule that was actually missing. */
  #page-conversations.page-fullbleed.topbar-content-expanded {
    top: env(safe-area-inset-top) !important;
  }
  /* Open conversation thread (2026-07-17 feedback: "compose sits far too
     high") — the 73px bottom inset above reserves room for the bottom nav
     bar, but setMobileDepthHidden() slides that bar fully off-screen once a
     thread is open (one level deep), leaving that whole 73px as dead empty
     space below the compose box instead of the bar. Collapse the inset to
     just clear the home-indicator safe area once the bar is actually hidden,
     so the compose box's bottom edge tracks the real screen edge again. */
  /* No artificial floor (2026-07-29 fix: "compose box doesn't meet the
     bottom of the screen, and it's a different size keyboard-open vs
     keyboard-closed") — the previous max(8px, env(...)) always reserved AT
     LEAST 8px even on devices with zero safe-area-inset-bottom, so the
     compose box could never actually sit flush with the true screen edge.
     It also produced the reported inconsistency: iOS Safari reports
     env(safe-area-inset-bottom) as the real home-indicator inset (commonly
     ~20-34px) while the keyboard is closed, but drops it to ~0 once the
     keyboard is open and covering that area — so the old rule resolved to
     ~20px closed vs the 8px floor once open, two different numbers for what
     should be the same "hug the real edge" behavior. Using the safe-area
     value directly (no floor) means both states now correctly reserve
     exactly what's needed to clear the home-indicator gesture area — zero
     on devices/keyboard-states with none, the real inset otherwise. */
  body:has(#mobileBottomBar.mbb-depth-hidden) #page-conversations.page-fullbleed {
    bottom: env(safe-area-inset-bottom) !important;
  }
  /* Applied by setMobileTopbarHidden() (js/ui.js) in lockstep with the
     topbar's own slide-away — without this, .content/.page-fullbleed always
     reserved the same top gap regardless of whether the topbar was actually
     visible, so the content never actually expanded into the vacated space
     (and looked like it had a permanent gap once the topbar hid). Still
     reserves env(safe-area-inset-top) even in the "expanded" state — the
     topbar sliding away should never let content scroll up under the notch. */
  .content.topbar-content-expanded {
    padding-top: calc(var(--space-5) + env(safe-area-inset-top)) !important;
  }
  .page-fullbleed.topbar-content-expanded {
    top: calc(var(--space-5) + env(safe-area-inset-top)) !important;
  }
  /* Mirror of the above for the BOTTOM side — applied by
     setMobileDepthHidden() (js/ui.js) in lockstep with the bottom nav
     bar's own slide-away when a conversation/contact detail opens.
     Without this, .content/.page-fullbleed always reserved 78px at the
     bottom for the nav bar even while it was actually hidden, which is
     why the message composer looked like it was leaving room for a bar
     that wasn't there (2026-07-07 feedback). */
  .content.depth-bottom-expanded {
    padding-bottom: max(0px, env(safe-area-inset-bottom)) !important;
  }
  .page-fullbleed.depth-bottom-expanded {
    bottom: max(0px, env(safe-area-inset-bottom)) !important;
  }

  /* ═══ Bottom tab bar (Instagram-style) + submenu sheet ═══
     15px padding above the icons; bottom padding grown another 15px
     (2026-07-07 feedback — was sitting ~15px too high on real devices)
     so the bar's own background reaches further down, plus a 12px
     downward shift on the whole bar to close the remaining gap above
     the true bottom edge. Bottom padding still grows past 30px on
     devices with a home-indicator safe-area inset, never shrinks below it. */
  #mobileBottomBar {
    display: none; position: fixed; left: 0; right: 0; z-index: 4000;
    /* `bottom: 0` here is only the pre-JS fallback (first paint, or a
       browser with JS disabled). pinKeyboardImmuneBars() (js/ui.js) actively
       overrides this with an explicit `top` in real pixels, measured once
       while the keyboard is verifiably closed, and sets `bottom: auto` —
       see that function's own comment for the full reasoning (2026-07-23):
       resizes-content (index.html's viewport meta) is back, since it's the
       only thing that's actually made .content correctly stop-scrolling
       above the keyboard on the real device this was tested on — but that
       mode shrinks the containing block for every position:fixed element
       uniformly, nav bar included, which is exactly why a plain `bottom: 0`
       chased the keyboard (2026-07-20/22 feedback). A fixed pixel `top`
       doesn't have that problem — only the containing block's BOTTOM edge
       moves for the keyboard under resizes-content, not its top. */
    bottom: 0;
    background: var(--surface); border-top: 1px solid var(--border);
    padding: 15px 0 max(30px, env(safe-area-inset-bottom));
    transform: translateY(12px);
    transition: transform 0.25s ease;
  }
  #mobileBottomBar:not(.app-hidden) { display: flex; }
  /* Slid away one level deep in a page (contact detail, open conversation
     thread) via setMobileDepthHidden() (js/ui.js) — reappears on back-out.
     Offset by the same 12px resting shift above so it still fully clears
     the screen when hidden. */
  #mobileBottomBar.mbb-depth-hidden { transform: translateY(calc(100% + 12px)); }
  .mbb-item {
    flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 2px; padding: 2px 0; cursor: pointer; color: var(--text3); position: relative;
  }
  .mbb-item .icon { width: 20px; height: 20px; }
  .mbb-label { font-size: 9px; font-weight: 600; color: var(--text3); }
  /* Matches the FAB's exact purple (var(--accent)) rather than the
     brighter/pinker --accent-bright previously used here — Ricardo's
     2026-07-08 feedback that the selected tab read as "too pink". Only the
     icon changes color when active — the label stays gray (2026-07-09). */
  .mbb-item.active .icon { color: var(--accent-light); filter: drop-shadow(0 0 6px var(--accent-glow)); }

  /* Notification-count dots — updateMobileNavBadges() (js/ui.js), driven by
     the same real per-page counts the desktop sidebar's .nav-badge elements
     already show (conversations unread, draft estimates, unpaid invoices,
     internal chat unread, PTO pending, calendar conflicts/pending bookings).
     Pages with no existing pending-count concept (Contacts, Opportunities,
     Items & Services, Photo Library, Rewards, Reviews) show no dot yet. */
  .mbb-dot {
    position: absolute; top: 0; right: calc(50% - 22px);
    background: var(--danger); color: #fff; font-size: 9px; font-weight: 700;
    border-radius: 20px; min-width: 15px; height: 15px; padding: 0 4px;
    display: flex; align-items: center; justify-content: center; line-height: 1;
  }
  .mns-item { position: relative; }
  .mns-item-dot {
    position: absolute; top: 4px; right: 4px;
    background: var(--danger); color: #fff; font-size: 10px; font-weight: 700;
    border-radius: 20px; min-width: 16px; height: 16px; padding: 0 4px;
    display: flex; align-items: center; justify-content: center; line-height: 1;
  }

  /* ═══ Floating "+" quick-add button ═══ shown on Dashboard + every
     bottom-nav submenu page (hidden on Settings, which has no "add new"
     concept) — see updateMobileFabVisibility()/mobileFabAction() (js/ui.js).
     Sits just above the (now-shorter) bottom bar. */
  #mobileFab {
    display: none; position: fixed; right: 16px; z-index: 3900;
    /* Pre-JS fallback only — see #mobileBottomBar's comment above.
       pinKeyboardImmuneBars() (js/ui.js) overrides with an explicit `top`. */
    bottom: calc(15px + 20px + 2px + 11px + max(30px, env(safe-area-inset-bottom)) + 16px - 12px);
    width: 52px; height: 52px; border-radius: 50%; border: none;
    background: var(--accent); color: #fff; cursor: pointer;
    align-items: center; justify-content: center;
    box-shadow: 0 4px 14px rgba(0,0,0,0.4);
    transition: transform 0.2s ease, opacity 0.2s ease;
  }
  #mobileFab:not(.app-hidden):not(.fab-page-hidden) { display: flex; }
  /* pointer-events:none added to both (2026-07-24 feedback: "if I click on
     [the compose arrow] low enough, the New Conversation modal appears") —
     opacity:0 alone never disables clicks, and the FAB's own translate here
     shifts its real (still fully clickable) hit-box down/across into
     whatever screen content happens to sit underneath it, which on the
     Conversations thread view landed right under the compose row's arrow
     button, silently triggering the FAB's New-Conversation action instead
     of whatever the arrow itself was supposed to do. */
  #mobileFab.fab-scroll-hidden { transform: translateX(90px); opacity: 0; pointer-events: none; }
  #mobileFab.fab-depth-hidden { transform: translateY(80px); opacity: 0; pointer-events: none; }
  /* FAB (z-index 3900) sits above every .modal-overlay (z-index 1000) — with
     no rule to hide it, it visibly floats on top of any open modal. Never
     surfaced before this file's own bugfix above, since the FAB was
     accidentally invisible on every non-Dashboard page until just now. */
  body:has(.modal-overlay.open) #mobileFab, body.modal-open #mobileFab { display: none !important; }
  /* Same problem, same fix, for the bottom nav bar (z-index below, see
     #mobileBottomBar's own rule) — it was only ever hidden by 3 specific
     call sites remembering to call setMobileDepthHidden(true) first
     (Contacts' desktop slide-in panel, opening a conversation thread,
     Settings drill-down), so any modal opened WITHOUT going through one of
     those first (New Conversation, Schedule/Edit Appointment, Internal
     Block, and — critically — the Contacts page's own tap-a-contact path,
     which resolves to the SAME openModal('contact',...) call as the
     Conversations path but is never preceded by setMobileDepthHidden)
     rendered with its footer/Close button sitting underneath the bar
     (z-index 4000 vs the modal's 1000) instead of over it (2026-07-14
     feedback: "modal sits too low"). One blanket rule, keyed off any open
     modal-overlay, covers every case instead of relying on each call site
     to remember. */
  body:has(.modal-overlay.open) #mobileBottomBar, body.modal-open #mobileBottomBar { display: none !important; }
  /* Same gap, one level deeper (2026-07-28 feedback: "3 clicks deep, the
     page is still scrollable down into empty space exactly where the nav
     bar would sit, even though it isn't shown there") — the bar being
     display:none via the blanket rule above does NOT by itself release
     .content's 78px padding-bottom reservation for it. That release was
     only ever wired to the .depth-bottom-expanded class toggled by
     setMobileDepthHidden() (see that pairing a few lines up), and the same
     Contacts tap-a-contact path named above never calls it on mobile — so
     the bar disappears but the empty space it was reserving stays
     scrollable. Keying the release off the identical blanket modal-open
     selector closes this for every current and future path, the same way
     the bar's own visibility was fixed above, instead of patching one
     call site. */
  body:has(.modal-overlay.open) .content, body.modal-open .content {
    padding-bottom: max(0px, env(safe-area-inset-bottom)) !important;
  }
  body:has(.modal-overlay.open) .page-fullbleed, body.modal-open .page-fullbleed {
    bottom: max(0px, env(safe-area-inset-bottom)) !important;
  }

  /* The FAB "+" choice menu (Calendar's Schedule Appointment/Internal
     Block, Items & Services' New Item/Service, etc. — toggleMobileFabMenu()/
     closeMobileFabMenu(), js/ui.js) is shared across every page that has
     more than one FAB action, so this one change covers both Calendar and
     Items & Services at once. Converted from a small floating card anchored
     above the FAB into a real full-width bottom sheet (2026-07-27 feedback:
     "make the + button pop up menu become a slider from the bottom of the
     screen") — same rounded-top-corners/slide-up-from-bottom convention as
     #mobileNavSheet's .mns-panel and every bottom-sheet modal in this file,
     just without a separate backdrop layer (this menu is a single existing
     div, not a two-layer wrapper — reuses closeMobileFabMenu()'s existing
     outside-tap-to-close behavior unchanged). */
  #mobileFabMenu {
    display: none; position: fixed; left: 0; right: 0; z-index: 3901;
    /* --keyboard-inset term removed (2026-07-20) — see #mobileBottomBar above. */
    bottom: 0;
    background: var(--surface); border-top: 1px solid var(--border2);
    border-radius: 16px 16px 0 0;
    /* Bottom padding also reserves var(--nav-bar-height) (2026-07-26 feedback:
       "the + bottom slider is hidden behind the bottom nav bar" — Calendar
       and Items & Services) — #mobileBottomBar sits at bottom:0 with a HIGHER
       z-index (4000 vs. this menu's 3901) and was never accounted for here,
       so the real nav bar visually clipped whatever fell in that same
       reserved strip at the bottom of this menu. */
    padding: 10px 10px calc(max(10px, env(safe-area-inset-bottom)) + var(--nav-bar-height, 43px));
    box-shadow: var(--shadow-lg);
  }
  #mobileFabMenu.open { display: block; animation: modalSlideUp 0.22s ease-out; }
  body:has(.modal-overlay.open) #mobileFabMenu, body.modal-open #mobileFabMenu { display: none !important; }

  /* Settings page: drill-down list-then-detail instead of a horizontal chip
     row (2026-07-08 feedback — scrolling through 18 chips to find a category
     was worse than just picking one full-width). Shows a plain vertical list
     of category names first (renderSettings() resets to this view on every
     fresh page entry); tapping one drills into ONLY that category's content
     plus a back button (openSettingsDrilldown()/closeSettingsDrilldown(),
     js/ui.js — same setMobileDepthHidden() call Contacts/Conversations
     already use for their own mobile drill-down panels). !important needed
     on every property that collides with components.css's own base
     .settings-nav/.settings-nav-inner/.settings-content rules — that file
     loads AFTER this one, so at equal specificity its plain (non-media-query)
     declarations otherwise win regardless of this media query, same
     cascade-order gotcha documented elsewhere in this file. */
  .settings-shell { flex-direction: column !important; margin: 0 !important; }
  .settings-nav {
    width: 100% !important; border-right: none !important;
    border-bottom: none; align-self: auto !important;
    /* Desktop's background:var(--surface) sits darker than the page's own
       --bg and reads as a boxed card once the sidebar goes full-width on
       mobile (2026-07-16 feedback) — drop it so the list blends flush into
       the page, matching Contacts/Conversations' un-card mobile lists. */
    background: transparent !important;
  }
  .settings-nav-inner {
    position: static !important; display: flex !important; flex-direction: column !important;
    gap: 2px !important; overflow-x: visible !important; padding: 10px 12px !important;
  }
  .settings-nav-item { white-space: normal; border-left: none; border-radius: 8px; }
  .settings-nav-item.active { border-left: none; background: var(--accent-glow); color: var(--accent-bright); }
  /* Full 20px inset padding on all sides read as a boxed "card" next to
     Conversations' flush list (2026-07-16 feedback: "not within a card") —
     .settings-row already carries its own bottom-border-per-row look with
     zero horizontal padding of its own, so trimming this to a small side-only
     value (matching the established Contacts-list un-card precedent) is
     enough to read as a flush list instead of an inset box. */
  .settings-content { padding: 4px 16px 20px !important; }

  /* List view (default) vs. drilled-in detail view — #page-settings.settings-drilled-in
     toggles which half shows. */
  .settings-nav { display: block; }
  .settings-content { display: none; }
  #page-settings.settings-drilled-in .settings-nav { display: none; }
  #page-settings.settings-drilled-in .settings-content { display: block; }
  .settings-back-btn { display: none; }
  #page-settings.settings-drilled-in .settings-back-btn {
    display: inline-flex; align-items: center; gap: 6px;
    background: none; border: none; color: var(--accent-light);
    font-size: 14px; font-weight: 600; padding: 0 0 14px; cursor: pointer;
  }

  #mobileNavSheet { display: none; }
  #mobileNavSheet.open:not(.app-hidden) { display: block; position: fixed; inset: 0; z-index: 4500; }
  .mns-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
  .mns-panel {
    position: absolute; left: 0; right: 0; bottom: 0;
    background: var(--surface); border-top: 1px solid var(--border);
    border-radius: 16px 16px 0 0;
    padding: 16px 10px max(16px, env(safe-area-inset-bottom));
    max-height: 65vh; overflow-y: auto;
  }
  .mns-title {
    font-size: 12px; font-weight: 700; color: var(--text3);
    text-transform: uppercase; letter-spacing: 0.5px; padding: 4px 10px 12px;
  }
  .mns-item {
    display: flex; align-items: center; gap: 14px; padding: 13px 10px;
    border-radius: 10px; cursor: pointer; font-size: 15px; font-weight: 600; color: var(--text);
  }
  .mns-item:active { background: var(--surface2); }
  .mns-item .icon { color: var(--text2); width: 25px; height: 25px; flex-shrink: 0; }
  .mns-item.active { color: var(--accent-bright); }
  .mns-item.active .icon { color: var(--accent-bright); }

  /* Generic grid overrides — .grid-4 (Dashboard/Rewards stat cards) and
     .grid-2 (Dashboard panels, Estimates/Invoices/Reviews settings, etc.)
     both squeeze into unreadably narrow columns (~80-160px) at phone
     widths with no override at all today. 2 columns for the 4-stat-card
     layout keeps it compact without the "$0.00" / "REVENUE (PAID)" style
     labels wrapping into illegible mush; .grid-2 just stacks full-width,
     which is the right default absent a page-specific reason not to.
     !important needed — components.css defines the base .grid-4/.grid-2
     rules and loads AFTER this file, so without it the later same-
     specificity rule would win regardless of this media query. */
  .grid-4 { grid-template-columns: repeat(2, 1fr) !important; }
  .grid-2 { grid-template-columns: 1fr !important; }

  /* ═══ Full-screen mobile call overlay ═══ (2026-07-08 feedback) — same
     incoming/active/outbound call states #voiceCallBar (desktop's thin top
     bar) already handles, just a real full-screen "phone call" layout
     instead. Only ever shown via the .open class (showVoiceCallBar()/
     hideVoiceCallBar(), js/voice.js) — display:none by default here so it
     can never appear above this breakpoint even if .open were left set. */
  #mobileCallOverlay { display: none; }
  #mobileCallOverlay.open {
    display: flex; flex-direction: column; align-items: center;
    position: fixed; inset: 0; z-index: 9500; background: var(--bg);
    padding: max(60px, env(safe-area-inset-top)) 24px max(40px, env(safe-area-inset-bottom));
  }
  .mcall-status {
    font-size: 14px; font-weight: 600; color: var(--text2);
    letter-spacing: 0.3px; margin-bottom: 28px; min-height: 18px;
  }
  .mcall-avatar { margin-bottom: 20px; }
  .mcall-avatar .initials-avatar { font-size: 48px !important; }
  .mcall-name { font-size: 24px; font-weight: 700; color: var(--text); text-align: center; }
  .mcall-phone { font-size: 15px; color: var(--text3); margin-top: 4px; }
  .mcall-speaker {
    display: flex; flex-direction: column; align-items: center; gap: 4px;
    margin-top: 32px; padding: 12px 18px; border-radius: 14px; border: none;
    background: var(--surface2); color: var(--text2); font-size: 11px; font-weight: 600;
    cursor: pointer;
  }
  .mcall-speaker.active { background: var(--accent); color: #fff; }
  .mcall-actions {
    margin-top: auto; display: flex; gap: 32px; align-items: center; justify-content: center;
    width: 100%;
  }
  .mcall-action-btn {
    display: flex; flex-direction: column; align-items: center; gap: 8px;
    border: none; cursor: pointer; background: none; color: var(--text2); font-size: 12px; font-weight: 600;
  }
  .mcall-action-btn .mcall-action-circle {
    width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
  }
  .mcall-action-btn.accept .mcall-action-circle  { background: var(--success); color: #fff; }
  .mcall-action-btn.decline .mcall-action-circle { background: var(--danger); color: #fff; }
  .mcall-action-btn.neutral .mcall-action-circle { background: var(--surface2); color: var(--text); }
  .mcall-action-btn.neutral.toggled .mcall-action-circle { background: var(--accent); color: #fff; }

  /* ═══ Modals become bottom sheets on mobile ═══ (2026-07-08 feedback) —
     a centered floating dialog is a desktop pattern; anchoring every modal
     to the bottom with rounded top corners and a slide-up entrance matches
     #mobileNavSheet's .mns-panel convention and reads as native on a phone.
     Applies app-wide to every .modal-overlay/.modal pair (Edit Contact's
     own further overrides, .modal-edit-contact, still layer on top of this
     exactly as they already do at desktop widths). !important needed on the
     properties components.css's own base .modal/.modal-overlay rules set,
     since that file loads AFTER this one and would otherwise win at equal
     specificity — same reasoning as the .grid-4/.grid-2 overrides above. */
  /* height (not just inset:0's implicit 100%) tracks --app-vh's real
     visual-viewport measurement (2026-07-19 feedback: "Create Opportunity
     modal gets hidden behind the keyboard") — inset:0 alone sizes the
     overlay to the LAYOUT viewport, which most mobile browsers don't shrink
     when the on-screen keyboard opens, so align-items:flex-end anchored the
     sheet to the bottom of the full (keyboard-covered) screen instead of
     the space actually visible above the keyboard. Shrinking the overlay's
     own box to the real visible height makes flex-end land the modal
     directly above the keyboard instead. */
  .modal-overlay { align-items: flex-end !important; padding: 0 !important; height: var(--app-vh, 100vh); }
  .modal {
    width: 100% !important; max-width: 100% !important;
    /* Based on --app-vh (real visual-viewport height, js/ui.js), not a
       static 88vh — vh units track the LAYOUT viewport, which most mobile
       browsers don't shrink when the on-screen keyboard opens, so a modal
       with several inputs (e.g. Schedule Appointment) could size itself
       taller than the space actually visible above the keyboard, leaving
       its Save button unreachable even though the modal itself still
       reports overflow-y:auto (2026-07-09 feedback: "modal doesn't scroll
       all the way to the bottom so I can't save"). Falls back to 88vh if
       the variable isn't set yet (first paint / no Visual Viewport API). */
    max-height: calc(var(--app-vh, 100vh) * 0.88) !important;
    border-radius: 16px 16px 0 0 !important;
    /* Left/right/bottom borders removed (2026-07-08 feedback) — full-width
       and flush against the physical screen edges, the desktop dialog's
       all-sides 1px border read as a second nested box floating inside the
       dark scrim rather than one clean sheet. A top border is kept as the
       only visible edge, separating the sheet from the scrim above it. */
    border-left: none !important; border-right: none !important; border-bottom: none !important;
    border-top: 1px solid var(--border2) !important;
    /* Side padding reduced to a flat 10px (2026-07-29 feedback) — was
       var(--space-5) (20px) on every side; top/bottom unchanged, only the
       left/right gutter shrinks, giving mobile bottom-sheet content more
       usable width. */
    padding: var(--space-5) 10px max(var(--space-5), env(safe-area-inset-bottom)) !important;
    animation: modalSlideUp 0.22s ease-out;
  }
  /* Contact modal padding standardization (2026-07-26 feedback: "still too
     high, make it match the same padding the Opportunities modal has —
     20px") — every other modal's content sits directly in .modal's own
     20px padding above; the Contact modal is the one exception, wrapping
     its real content in #newContactForm/#editContactMainView/
     #editContactChangeLogView, each with its OWN inline 20-24px padding
     stacked on top of the outer .modal's 20px — nearly double. Zeroing
     these three inner wrappers on mobile leaves exactly the same single
     20px shell every other modal (including Opportunities) already has. */
  #newContactForm, #editContactMainView, #editContactChangeLogView { padding: 0 !important; }
  /* .modal-footer's sticky bleed-through math (components.css) is hardcoded
     to var(--space-6) (24px), matching desktop's .modal padding — but the
     mobile .modal override two lines up drops that to var(--space-5) (20px,
     or more on the bottom edge via the safe-area-inset-bottom max()). Left
     unfixed, the footer bleeds through by the WRONG amount on mobile, so it
     no longer sits flush with the modal's real padding box — this is the
     "slide-in sits too low, can't close it" bug (2026-07-14 feedback) on
     every bottom-sheet modal (Estimate/Invoice/Item/Service/etc). Re-deriving
     the same margin/padding/bottom values using the mobile padding amounts
     keeps the footer flush again. */
  .modal-footer {
    margin: 18px calc(-1 * var(--space-5)) calc(-1 * max(var(--space-5), env(safe-area-inset-bottom))) !important;
    padding: var(--space-4) var(--space-5) max(var(--space-5), env(safe-area-inset-bottom)) !important;
    bottom: calc(-1 * max(var(--space-5), env(safe-area-inset-bottom))) !important;
  }
  /* Header/footer side padding, 15px (2026-07-31 feedback: "make the headers
     and footers have 15px padding on the sides on all modals") — a real,
     explicit value instead of relying on the bleed-through math above,
     which was hardcoded to var(--space-5) (20px) and was only ever
     coincidentally netting the right number since .modal's own real side
     padding was separately dropped to 10px (2026-07-29). Overriding just
     the horizontal margin/padding here (not top/bottom, which the rules
     above/below still own) keeps the sticky bleed-through trick's vertical
     behavior completely untouched. .modal-title-row never had an explicit
     mobile override at all before this — it was silently inheriting the
     base desktop rule's own var(--space-6) (24px) bleed math, which had the
     exact same "doesn't match .modal's real 10px padding" mismatch. */
  .modal-title-row {
    margin-left: -10px !important;
    margin-right: -10px !important;
    padding-left: 15px !important;
    padding-right: 15px !important;
  }
  .modal-footer {
    margin-left: -10px !important;
    margin-right: -10px !important;
    padding-left: 15px !important;
    padding-right: 15px !important;
  }
  /* Package editor modal's footer is frozen on desktop only (2026-07-28
     feedback) — .modal-footer's base rule (components.css) freezes it at
     every width by default, same sticky-bleed trick as every other modal's
     footer, so unfreezing here for mobile only just needs position:static
     (same technique already used for the Contact modal's footer above). */
  #modal-est-package-editor .modal-footer {
    position: static !important;
  }
  /* .modal-title-row's own sticky bleed-through (components.css) still uses
     `top`/margin-top/padding-top all hardcoded to var(--space-6) (24px) —
     the same generic-vs-Estimate-modal-specific bug the mobile-only
     overflow fix above (#modal-estimate) was a one-off patch for, just
     never corrected here for every OTHER modal (Item/Service/Internal
     Block/Event/etc). .modal's real mobile top padding is var(--space-5)
     (20px, see the mobile .modal rule above), so the frozen title row
     overshoots its bleed-through by 4px on every modal that isn't Estimate.
     Re-deriving with var(--space-5) keeps it flush like the footer already is. */
  .modal-title-row {
    top: calc(-1 * var(--space-5)) !important;
    margin-top: calc(-1 * var(--space-5)) !important;
    padding-top: var(--space-5) !important;
  }
  /* Edit Estimate/Invoice modal header+footer padding (2026-08 feedback:
     "padding seems off on both the header and footer") — a little extra
     breathing room specific to these two modals, on top of the generic
     15px-sides/var(--space-5)-vertical rules above every other modal
     already gets. */
  #modal-estimate .modal-title-row, #modal-invoice .modal-title-row {
    padding-top: calc(var(--space-5) + 6px) !important;
    padding-left: var(--space-5) !important;
    padding-right: var(--space-5) !important;
  }
  #modal-estimate .modal-footer, #modal-invoice .modal-footer {
    padding-left: var(--space-5) !important;
    padding-right: var(--space-5) !important;
    padding-bottom: calc(max(var(--space-5), env(safe-area-inset-bottom)) + 6px) !important;
  }
  /* Service/Item modal footer Cancel button — reverted (2026-08 feedback:
     "return the cancel/close button at the footer of all modals they were
     removed from 2 requests ago"). Hiding it and relying on the header ✕
     alone was itself a change from an earlier round; both are visible again
     on mobile now, same as desktop. */
  /* Service modal's "Requires Material Line" checkbox row (2026-08 feedback:
     "currently sits on top of the duration field overlapping it") — its
     form-group carries an explicit inline margin-bottom:0 (tight spacing was
     fine on desktop's smaller row height), but the mobile floating-label
     field treatment above gives every OTHER row in this same section extra
     vertical padding the checkbox row doesn't get (checkboxes are
     deliberately excluded from that rule), so this one row ends up shorter
     than its neighbors expect and the Duration row below crowds up into it.
     Restoring real spacing here just for mobile fixes the collision without
     touching desktop's already-correct tight layout. */
  #sv-requires-material-group { margin-bottom: var(--space-4) !important; }
  /* Item modal's Taxable checkbox (2026-08 feedback: "I'm unable to hit the
     taxable checkbox on mobile") — .check-inline never gives the checkbox
     itself an explicit size, so it renders at the browser's tiny native
     default (~13px), well under this app's 44px touch-target standard.
     Whole-row tapping was deliberately removed for catalog checkboxes
     (2026-07-25 fix, "should only toggle on direct click, not whole row") to
     stop accidental toggles from tapping the label/info-tip — so the fix
     here is a genuinely bigger checkbox, not a bigger clickable row. */
  #invtf-taxable {
    width: 22px !important;
    height: 22px !important;
  }
  /* Unfreeze the Contact modal's footer, mobile only (2026-07-31 feedback:
     "unfreeze the footer on the modal, on mobile") — .modal-edit-contact's
     own base rule (components.css, unconditional at every width) splits
     into a flex:1 scrolling #editContactForm plus a flex-shrink:0
     #editContactFooter sibling that structurally never scrolls away,
     unlike every other modal's footer (which use the sticky-bleed trick
     and CAN unfreeze via position:static). Desktop is deliberately left
     exactly as-is here — task #715 explicitly asked for this footer to
     stay frozen there, and that request wasn't scoped to mobile, so this
     override only undoes the split on mobile, where it's now being asked
     for again, specifically. Collapsing the flex-column split into a
     single scrolling block lets the footer flow at the very end of the
     content and scroll away with everything else, same as any ordinary
     unfrozen footer. !important needed since components.css (loads after
     this file) sets these same properties without it. */
  .modal-edit-contact {
    display: block !important;
    overflow-y: auto !important;
  }
  .modal-edit-contact #editContactForm {
    flex: none !important;
    display: block !important;
    overflow: visible !important;
  }
  .modal-edit-contact #editContactMainView,
  .modal-edit-contact #editContactChangeLogView {
    flex: none !important;
    overflow: visible !important;
  }
  /* Un-stick the footer while actually editing (2026-07-24 feedback: "the
     footer comes to the top of the keyboard and takes up a quarter of the
     remaining screen" the instant you tap into any field) — position:sticky
     pins it to the bottom of the modal's own scrollport permanently, which
     on mobile means directly above the on-screen keyboard once it opens and
     shrinks the visible viewport, eating a chunk of the little space left
     for the field actually being typed into. Left alone for VIEW-ONLY mode
     (.view-locked/.est-locked) — nothing to type there, so a
     permanently-visible Close/Edit footer is still correct. Editing modals
     now let the footer scroll away like ordinary content instead, reachable
     by scrolling down same as any other field — higher-specificity
     descendant selector wins over the plain .modal-footer rule above
     regardless of source order. */
  .modal-overlay:not(.view-locked):not(.est-locked) .modal-footer {
    position: static;
  }
  /* Pending Booking modal footer (2026-07-26 feedback) — the only footer in
     the app with 4 buttons at once (Cancel/Deny/Suggest New Time/Confirm
     Booking), never given any mobile-safe layout before (still a single
     un-wrapped row, per components.css). Stacked one per row instead, top
     to bottom in right-to-left visual order (Confirm Booking, Suggest New
     Time, Deny, then Cancel at the bottom) — column-reverse flips the
     visual stacking order of the existing Cancel/Deny/Suggest/Confirm DOM
     order without touching the DOM or tab order at all. */
  #modal-pendingBooking .modal-footer {
    flex-direction: column-reverse;
    align-items: stretch;
  }
  #modal-pendingBooking .modal-footer > button { width: 100%; margin-right: 0 !important; }
  @keyframes modalSlideUp {
    from { transform: translateY(100%); }
    to   { transform: translateY(0); }
  }

  /* ═══ Opportunities board (Pipeline) ═══ — the board's core interaction,
     dragging a card between stage columns, is native HTML5 drag-and-drop
     (js/pipeline.js), which never fires from touch input at all. On mobile
     the card instead shows a same-row pair of icon buttons (View Contact +
     Move To, js/db.js renderPipelineBoard()) in place of desktop's
     full-width "View Contact" text button — Move To opens a stage-picker
     dropdown menu (toggleLeadMoveMenu(), js/pipeline.js) as the touch-usable
     stand-in for dragging. */
  /* Pipeline tabs (Active Leads/Inactive/etc.) use flex:1 equal-share with no
     min-width:0/shrink protection — content forces items wider than their
     share, and with nothing scrolling the row, an item can end up pushed
     fully off-screen with no way to reach it (surfaced by the Text Size
     knob above: at 130% "Retention" disappeared off the right edge
     entirely). Switch to natural-width, horizontally-scrollable tabs, same
     pattern .pipeline-board already uses right below this row. */
  .pipeline-tabs { overflow-x: auto !important; }
  .pipeline-tab { flex: 0 0 auto !important; }
  /* !important needed — components.css's plain .lead-actions-mobile{display:none}
     rule loads after this file and wins at equal specificity otherwise, the
     same cascade-order gotcha documented elsewhere in this file. */
  .lead-actions-desktop { display: none !important; }
  .lead-actions-mobile { display: flex !important; }

  .pipeline-tabs { display: none !important; }
  /* Sits inline with the ⋮ menu in .opp-pipelines-title-row now (2026-07-19
     feedback: picker + ⋮ menu "not aligned properly") rather than as its
     own full-width row below — flex:1 lets it take whatever space the
     buttons next to it don't need. */
  .pipeline-tabs-mobile-select { display: block; flex: 1; min-width: 0; }
  /* Label-only text, redundant with the board itself once the card's own
     chrome is stripped below (2026-07-19 feedback: "remove the 'Stages'
     text"). */
  #oppStagesLabel { display: none; }

  /* Opportunities (2026-07-17 feedback) — replaces the 2026-07-15 dropdown-
     driven single-stage view: real horizontal swiping between stages again,
     but with CSS scroll-snap so a swipe lands exactly centered on one full-
     width stage (with all of its leads) instead of a loose, partial-column
     free scroll. .pipeline-board is the snap container; each .pipeline-col
     is a full-width snap point. */
  .pipeline-board {
    overflow-x: auto;
    /* proximity, not mandatory (2026-08 feedback: "near impossible to switch
       stages... the magnetic effect is too high") — mandatory forces a snap
       back to the current column on anything short of a hard, fast flick,
       which reads as the swipe fighting back. proximity only pulls toward a
       snap point once the scroll position is already close to one, so an
       ordinary careful swipe can actually settle a screen-width over
       instead of springing back to where it started. That alone didn't fix
       it (2026-08 feedback: "still shakes in place and settles back unless
       flicked hard") — -webkit-overflow-scrolling:touch is a legacy iOS
       momentum-scrolling shim known to fight scroll-snap's own momentum
       calculation on WebKit, producing exactly this "shakes, then snaps
       back" symptom; modern mobile Safari already has native momentum
       scrolling without it, so it's removed outright rather than tuned. */
    scroll-snap-type: x proximity;
    /* Zero out the desktop 12px inter-column gap (2026-07-26 feedback: "a
       block sits at the very edge in front of the stage cards, on both
       sides") — with a full-width (100%) column snapped centered AND a
       horizontal gap still present, the adjacent (off-screen) column's own
       bordered/backgrounded header+cards can peek in by a sliver at both
       edges simultaneously on imprecise snap settling. Zero gap removes any
       possibility of that bleed-through; each column's own internal
       vertical gap (--space-2, unrelated axis) is untouched. */
    gap: 0;
  }
  .pipeline-col {
    min-width: 100%; max-width: 100%; width: 100%;
    scroll-snap-align: center;
    scroll-snap-stop: always;
    /* Side padding moved here from .pipeline-board (2026-07-23 feedback:
       "the sides of the sliding part cut off the cards") — .pipeline-board
       bleeds edge-to-edge (see the negative-margin rule below) so each
       column's snap width truly matches the screen, but that left the
       column header/cards themselves with zero breathing room, sitting
       flush against the physical screen edge. box-sizing:border-box
       (global reset) keeps this inside the already-100%-width column
       instead of overflowing it. */
    /* 30px total gutter between adjacent stages while swiping (2026-07-25
       feedback, reduced from the prior 20px-per-side/40px-total) — split
       15px/15px rather than touching .pipeline-board's gap:0, which exists
       specifically to prevent the adjacent-column bleed-through bug fixed
       2026-07-26 (see note above). */
    padding: 0 15px;
    box-sizing: border-box;
  }

  /* "Pipelines" card collapses to a plain dropdown on mobile — the title
     text and Card Fields/New Opportunity buttons are redundant/relocated
     elsewhere already (New Opportunity → mobile FAB, Card Fields → the ⋮
     menu), so only the pipeline-picker select + the ⋮ menu trigger need to
     stay visible; the card's own chrome (border/background/padding) drops
     so it reads as a plain toolbar row instead of a boxed card. */
  #pipelinesPickerCard { background: none !important; border: none !important; padding: 0 !important; }
  #oppPipelinesLabel { display: none; }

  /* "Stages" card loses its surrounding card chrome too (2026-07-17
     feedback) — the board itself now bleeds full-width so the swipe/snap
     interaction above isn't visually boxed in. */
  #stagesCard { background: none !important; border: none !important; padding: 0 !important; margin-top: 10px !important; }
  /* With #oppStagesLabel hidden above, this row's own justify-content:
     space-between has nothing left to space the search/sort group away
     from, which collapses it to the left edge — force it back to the
     right explicitly. */
  #stagesCard > div:first-child { justify-content: flex-end !important; }
  /* Search bar stretches to fill the whole row instead of sitting at its
     fixed 220px desktop width (2026-07-26 feedback: "stretch the search bar
     to the left of the screen also") — matches Contacts'/Conversations'
     own mobile search bars, which already fill their row the same way. */
  #pipelineSearchInput { flex: 1 !important; width: auto !important; max-width: none !important; }

  /* Mobile sort control swaps the field+direction dropdown for a single
     up/down arrow toggle, mirroring Contacts' .cont-sort-mobile/
     .cont-sort-desktop pattern exactly. */
  .opp-sort-desktop { display: none !important; }
  .opp-sort-mobile { display: flex !important; }

  /* 2026-07-27 feedback (5-item batch): */
  /* a. Hide the 3-dot ⋮ menu entirely — Export/Import were already desktop-
     only conveniences on Contacts with no mobile replacement (same
     reasoning here); Card Fields also stays desktop-only rather than
     living in a now-hidden menu with nothing else in it. */
  #oppMoreMenuBtn { display: none !important; }
  /* c. Search bar still wasn't full width — #pipelineSearchInput{flex:1}
     only stretched inside its own immediate wrapper row, which itself
     was never told to grow and so still hugged its own content width at
     the right edge (a leftover gap from a button removed a few sessions
     back). Growing the wrapper row itself is what actually frees the
     space for the search input's own flex:1 to fill. */
  #oppSearchSortRow { flex: 1 !important; }
  /* d. Line separator below the search row, matching every other list's
     search-row divider (Contacts/Conversations/Items & Services). */
  #stagesCard > div:first-child { padding-bottom: 10px; border-bottom: 1px solid var(--border); }
  /* e. Stages board goes fully edge-to-edge — #stagesCard's own padding
     was already stripped above, but .pipeline-board still inherited the
     page's ambient 20px side padding from .content, so the swipeable
     stage columns never actually touched the screen edges. */
  .pipeline-board { margin-left: calc(-1 * var(--space-5)); margin-right: calc(-1 * var(--space-5)); }
}
/* b. The "Vehicle" label on the Create Opportunity flow's vehicle select
   was getting auto-hidden by the app-wide "Select {Field}..." placeholder
   standardization (standardizeSelectPlaceholder(), js/ui.js — every select
   with a blank first option gets this treatment, mobile-only) — restored
   specifically for this one field rather than touching that app-wide
   behavior, which stays exactly as Ricardo asked for back on 2026-07-17.
   Selector updated 2026-07-25 for the Create Opportunity + Edit Opportunity
   modal merge — the Vehicle select now lives in renderStageEditForm()'s
   #se-vehicle inside the shared #modal-opportunity, not the old standalone
   #modal-stagePicker/#sp-vehicle. */
@media (max-width: 768px) {
  #modal-opportunity .form-group:has(#se-vehicle) > label.select-label-hidden { display: block !important; }
  /* c. Forcing the label visible above didn't also give the field the same
     extra top room every other visibly-labeled field gets — #se-vehicle was
     still sized/padded as if its label were hidden (the label.select-label-
     hidden ~ select rule, css/layout.css ~343, still matched and won), so
     the "Vehicle" text sat on top of the select's own value instead of
     above it (2026-07-26 feedback). Matches the normal labeled-field box
     exactly: height = --control-height + 14px, padding-top: 14px. */
  #modal-opportunity #se-vehicle { height: calc(var(--control-height) + 14px) !important; padding-top: 14px !important; }
}

/* ═══ Pull-to-refresh indicator ═══ — appended directly to <body> by
   initPullToRefresh() (js/ui.js) since it needs to float above whichever
   scrollable container is currently being pulled, not live inside any one
   of them. Only ever toggled visible by JS on mobile widths, so no media
   query needed here — it simply never gets shown on desktop. */
#pullToRefreshIndicator {
  position: fixed; top: 0; left: 50%; z-index: 5500;
  width: 34px; height: 34px; border-radius: 50%;
  background: var(--surface); border: 1px solid var(--border2);
  display: flex; align-items: center; justify-content: center;
  color: var(--accent-light);
  opacity: 0; pointer-events: none;
  transform: translateX(-50%) translateY(-40px);
  transition: opacity 0.15s ease;
}
#pullToRefreshIndicator.visible { opacity: 1; }
#pullToRefreshIndicator.ready { color: var(--accent-bright); border-color: var(--accent); }
#pullToRefreshIndicator.spinning { animation: ptr-spin 0.7s linear infinite; }
@keyframes ptr-spin { to { transform: translateX(-50%) translateY(20px) rotate(360deg); } }
