/* ============================================================================
   DispatchIQ — Phase 0 visual refresh
   Pure-CSS reskin layered over Bootstrap 5. No markup changes: every rule here
   overrides existing Bootstrap classes. Functional sections (layout, nav
   structure, error pages, Quill, print) are preserved from the original.
   ============================================================================ */

/* Inter — modern UI typeface. Loaded here (vs. App.razor) so the reskin stays
   contained to this stylesheet. Falls back to the system stack on failure. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');

/* ---- Design tokens -------------------------------------------------------- */
:root {
    /* Brand — the established purple gradient, kept as the identity accent. */
    --brand-start: #667eea;
    --brand-end: #764ba2;
    --brand-gradient: linear-gradient(135deg, var(--brand-start) 0%, var(--brand-end) 100%);

    /* The same gradient for surfaces that carry white TEXT, starting from the
       brand indigo instead of the periwinkle. --brand-start #667eea is too light
       for white — 3.66 — which made every .btn-primary in the product fail AA in
       both themes, the last such failure left on an admin page.

       Not a change to --brand-gradient, which stays exactly as it is on the five
       surfaces that only decorate (.sidebar, .progress-bar, the error icon and
       two others). The identity is intact; this is the text-bearing variant, and
       the split is the same one --*-solid makes for the semantic colours.

       Worst point 5.37 at the start, 5.89 mid, 6.37 at the end. */
    --primary-gradient: linear-gradient(135deg, var(--primary-color) 0%, var(--brand-end) 100%);

    /* The "IQ" in the DispatchIQ wordmark. Two values, not one: the nav rails
       and the portal navbar are always dark chrome, while the mobile strip sits
       on --surface, and no single green stays legible on both. */
    --brand-iq: #047857;        /* emerald-700 — on light surfaces */
    --brand-iq-chrome: #34d399; /* emerald-400 — on the dark rails/navbar */

    /* Primary (aligned to the brand indigo) */
    --primary-color: #5b5bd6;
    --primary-hover: #4f46e5;
    --primary-soft: #eef0fd;
    /* Readable text on --primary-soft, completing the soft/ink pair every other
       semantic colour below already has. Primary went without one, so the rules
       that tint with --primary-soft reached for --primary-hover instead — fine
       in light mode, where the two are the same value, but --primary-hover does
       not flip for dark, so those tints landed at ~2:1 against the dark soft. */
    --primary-ink: #4f46e5;

    /* Semantic — base + soft tint + readable on-tint text */

    /* slate-600, not slate-500. In light mode this token is only ever text or a
       border (.badge.bg-secondary's label, the outline-secondary button), and
       slate-500 was too light for both: the badge measured 4.2 on its #eef1f6
       tint and MudBlazor's outlined-secondary 4.45 on the page background. Two
       near-misses from one tone. Dark mode redefines this to slate-400 below and
       is unaffected. */
    --secondary-color: #475569;
    --success-color: #16a34a;
    --success-soft: #dcfce7;
    --success-ink: #15803d;
    --danger-color: #dc2626;
    --danger-soft: #fee2e2;
    --danger-ink: #b91c1c;
    --warning-color: #d97706;
    --warning-soft: #fef3c7;
    --warning-ink: #b45309;
    --info-color: #0891b2;
    --info-soft: #cffafe;
    --info-ink: #0e7490;

    /* Solid fills that carry WHITE text — the third role, which the pair above
       cannot fill. Measured white-on-fill for --*-color: success 3.30, warning
       3.19, info 3.68. All three fail AA, and darkening --*-color itself is not
       the fix: it doubles as text in .text-success and friends, where darkening
       helps light mode (3.30 -> 5.02) and breaks dark (4.76 -> 3.13).

       So three roles, three tokens: --*-color decorates (borders, rules, marks),
       --*-ink is text and FLIPS per theme, --*-solid is a fill under white and
       must NOT flip — a fill that lightens for dark mode puts white back on a
       pale ground, which is exactly the bug this pass fixed in the MudBlazor
       palette. These are deliberately absent from the [data-bs-theme="dark"]
       block below; leave them that way.

       No --danger-solid: white on #dc2626 already measures 4.83. */
    --success-solid: #15803d;
    --warning-solid: #b45309;
    --info-solid: #0e7490;

    /* Neutrals (slate) */
    --surface: #ffffff;
    --light-bg: #f6f7fb;
    --dark-text: #1e2233;
    --muted-text: #6b7280;
    --border-color: #e7e9f0;
    --border-strong: #d4d8e3;

    /* Shell chrome heights. Anything that docks itself to the top or bottom of
       a page has to clear these, and re-deriving them per page is how a sticky
       bar ends up parked under the strip and unclickable. See the SHELL FLOOR
       block near the bottom of this file.

       These are CONTRACTS, not guesses: each value is >= what its bar's content
       needs, and the bar carries a matching min-height, so the bar renders at
       exactly the token. Both were measured wrong before — the strip declared
       48px and rendered 50.19 (38px buttons + 11.2 padding + 1 border simply
       overran the min-height), and the tab bar declared 56px and rendered
       68.69, a 12.7px error that put every .docked-bottom bar 12.7px inside the
       tab bar it was supposed to sit above.

       An under-stated value is a functional bug — the docked thing hides under
       the chrome and stops taking clicks. An over-stated one is a hairline gap.
       So when in doubt these round UP, and the phone values are separate
       because the phone chrome is genuinely a different size. */
    --shell-strip-h: 52px;
    --shell-tabbar-h: 69px;

    /* Radius scale */
    --radius-sm: 0.5rem;
    --radius: 0.7rem;
    --radius-lg: 1rem;

    /* (--shell-strip-h was declared a second time here, with the same 48px and a
       longer comment. Two declarations of one contract is how the contract stops
       being one; folded into the block above.) */

    /* Soft, layered shadows */
    --shadow-xs: 0 1px 2px rgba(16, 24, 40, 0.04);
    --shadow-sm: 0 1px 3px rgba(16, 24, 40, 0.06), 0 1px 2px rgba(16, 24, 40, 0.04);
    --shadow-md: 0 6px 16px rgba(16, 24, 40, 0.08), 0 2px 6px rgba(16, 24, 40, 0.04);
    --shadow-lg: 0 16px 40px rgba(16, 24, 40, 0.14);
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html, body {
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
    color: var(--dark-text);
    background-color: var(--light-bg);
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;
}

body {
    padding: 0;
}

/* ---- Page layout ---------------------------------------------------------- */
.page {
    /* The "sidebar" is actually the top navbar container; stack it above main. */
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}

.sidebar {
    /* Full-width container for the top Bootstrap navbar. Must not be 0px wide
       or the navbar (and its hamburger toggler) get clipped to nothing. */
    width: 100%;
    background: var(--brand-gradient);
    box-shadow: var(--shadow-sm);
}

main {
    flex: 1;
    display: flex;
    flex-direction: column;
    background-color: var(--light-bg);
}

.top-row {
    background-color: var(--surface);
    border-bottom: 1px solid var(--border-color);
    padding: 0.875rem 1.5rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
    box-shadow: var(--shadow-xs);
}

.top-row.auth {
    justify-content: flex-end;
}

.user-info {
    display: flex;
    align-items: center;
    gap: 1rem;
}

.user-greeting {
    font-weight: 600;
    color: var(--dark-text);
}

.content {
    flex: 1;
    padding: 2rem 1.75rem;
}

/* ---- Navigation ----------------------------------------------------------- */
/* Each portal owns its own bar color via its layout's header wrapper:
   customer = .sidebar (purple gradient), admin = .admin-top (slate),
   subcontractor = .sub-top (orange). So the navbar itself stays transparent
   and lets the wrapper show through. The customer NavMenu markup carries
   Bootstrap's .bg-dark, hence the scoped override below. */
.navbar {
    box-shadow: var(--shadow-sm);
}

.sidebar .navbar {
    background: transparent !important;
}

/* Toggler (mobile hamburger) — softer outline + brand-neutral focus ring that
   reads on any of the three dark bar colors. */
.navbar-toggler {
    border-color: rgba(255, 255, 255, 0.25);
    padding: 0.4rem 0.55rem;
}

.navbar-toggler:focus {
    box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.18);
}

/* Nav badges stay solid (not soft-tinted) so unread counts and the ADMIN tag
   stay punchy against the dark bar. */
.navbar .badge {
    border-radius: 999px;
}

.navbar .badge.bg-danger {
    background-color: var(--danger-color) !important;
    color: #fff !important;
}

.navbar .badge.bg-success {
    background-color: var(--success-solid) !important;
    color: #fff !important;
}

/* ---- Responsive top nav --------------------------------------------------- */
/* Shared by the admin and field-worker bars (class .responsive-navbar). As the
   bar tightens it drops word labels for icon-only links (with tooltips), and
   each unread count floats onto its icon's corner as a bubble instead of
   wrapping the bar onto a second row. Each bar sets --nav-bubble-stroke to its
   own background colour so the bubble reads as floating above the icon.
   Breakpoints: labels >=1300px, icon-only 768-1300px, hamburger <768px. */
.responsive-navbar .nav-icon {
    position: relative;
    display: inline-flex;
    align-items: center;
    line-height: 1;
}

.responsive-navbar .nav-text {
    margin-left: 0.4rem;
}

.responsive-navbar .nav-icon .badge {
    position: absolute;
    top: -0.5rem;
    left: 100%;
    transform: translateX(-55%);
    margin: 0 !important;
    padding: 0.2em 0.4em;
    font-size: 0.6rem;
    line-height: 1;
    border: 1.5px solid var(--nav-bubble-stroke, #1f2937);
}

@media (min-width: 768px) {
    /* Expanded bar stays on one row and shrinks to icons rather than wrapping. */
    .responsive-navbar .navbar-nav { flex-wrap: nowrap; }
    .responsive-navbar .nav-link { white-space: nowrap; }
}

@media (min-width: 768px) and (max-width: 1299.98px) {
    /* Expanded but tight: hide the words, keep icons + corner badges. The
       collapsed (hamburger) menu below 768px is unaffected, so its vertical
       list still shows full labels. */
    .responsive-navbar .nav-text { display: none; margin-left: 0; }
}

.navbar-brand {
    font-weight: 800;
    font-size: 1.25rem;
    letter-spacing: -0.01em;
    color: #fff !important;
    /* Mark-to-wordmark spacing. The rails set their own gap; this bar had none
       and leaned on a margin inside BrandMark, which no longer carries one. */
    gap: 0.3rem;
}

.navbar-brand img {
    margin-right: 0.5rem;
}

/* Scope the white nav-link color to the top navbar only. Other places that
   reuse the .nav-link class (e.g. the top-row Profile/Logout buttons) should
   keep their normal colors. */
.navbar .nav-link {
    color: rgba(255, 255, 255, 0.82) !important;
    font-weight: 500;
    transition: color 0.2s ease, background-color 0.2s ease;
    padding: 0.5rem 0.9rem !important;
    border-radius: var(--radius-sm);
}

.navbar .nav-link:hover {
    color: #fff !important;
    background-color: rgba(255, 255, 255, 0.12);
}

.navbar .nav-link.active {
    color: #fff !important;
    background-color: rgba(255, 255, 255, 0.16);
    font-weight: 600;
}

/* Top-row user-info links (Profile, Logout, Login) — readable on white bg. */
.user-info .nav-link {
    color: var(--primary-color);
    text-decoration: none;
    font-weight: 500;
}

.user-info .nav-link:hover {
    text-decoration: underline;
}

/* ---- Cards ---------------------------------------------------------------- */
.card {
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background-color: var(--surface);
    box-shadow: var(--shadow-sm);
    transition: box-shadow 0.2s ease, border-color 0.2s ease;
}

.card:hover {
    box-shadow: var(--shadow-md);
    border-color: var(--border-strong);
}

.card-header {
    background-color: transparent;
    border-bottom: 1px solid var(--border-color);
    padding: 1rem 1.25rem;
    font-weight: 600;
}

.card-body {
    padding: 1.5rem;
}

/* Dashboard stat cards */
.card.bg-light {
    background-color: var(--surface) !important;
}

.card.bg-light .card-title {
    color: var(--muted-text);
    font-size: 0.8125rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    margin-bottom: 0.75rem;
}

/* ---- Buttons -------------------------------------------------------------- */
.btn {
    font-weight: 600;
    border-radius: var(--radius-sm);
    padding: 0.5rem 1rem;
    transition: background-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease, border-color 0.15s ease;
    text-decoration: none;
}

.btn:focus-visible {
    outline: none;
    box-shadow: 0 0 0 3px rgba(91, 91, 214, 0.30);
}

/* --primary-gradient, not --brand-gradient: this button carries white text. */
.btn-primary {
    background: var(--primary-gradient);
    border: none;
    color: #fff;
    box-shadow: var(--shadow-xs);
}

.btn-primary:hover,
.btn-primary:focus {
    filter: brightness(1.05);
    box-shadow: 0 6px 16px rgba(102, 126, 234, 0.35);
    color: #fff;
}

.btn-primary:active {
    filter: brightness(0.97);
}

.btn-outline-primary {
    /* --primary-ink, not --primary-color: the latter is the brand indigo used for
       FILLED surfaces, and as text on a --surface face it measures 2.92:1 in
       light mode. The hover state one rule below has always used the ink token;
       the resting state — the one that is on screen the entire time — did not.
       6.29:1 now, and the button no longer changes hue on hover. */
    color: var(--primary-ink);
    border: 1px solid var(--border-strong);
    background-color: var(--surface);
}

.btn-outline-primary:hover {
    background-color: var(--primary-soft);
    border-color: var(--primary-color);
    color: var(--primary-ink);
}

.btn-outline-secondary {
    color: var(--secondary-color);
    border: 1px solid var(--border-strong);
    background-color: var(--surface);
}

.btn-outline-secondary:hover {
    background-color: var(--light-bg);
    border-color: var(--secondary-color);
    color: var(--dark-text);
}

.btn-link {
    color: var(--primary-color);
    text-decoration: none;
    font-weight: 600;
}

.btn-link:hover {
    text-decoration: underline;
}

/* ---- Badges --------------------------------------------------------------- */
/* Modernized to soft-tinted pills (light background + colored ink) instead of
   solid uppercase blocks. */
.badge {
    font-weight: 600;
    padding: 0.3rem 0.6rem;
    border-radius: 999px;
    font-size: 0.75rem;
    letter-spacing: 0.01em;
    text-transform: none;
}

.badge.bg-info {
    background-color: var(--info-soft) !important;
    color: var(--info-ink) !important;
}

.badge.bg-success {
    background-color: var(--success-soft) !important;
    color: var(--success-ink) !important;
}

.badge.bg-warning {
    background-color: var(--warning-soft) !important;
    color: var(--warning-ink) !important;
}

.badge.bg-danger {
    background-color: var(--danger-soft) !important;
    color: var(--danger-ink) !important;
}

.badge.bg-secondary {
    background-color: #eef1f6 !important;
    color: var(--secondary-color) !important;
}

.badge.bg-primary {
    background-color: var(--primary-soft) !important;
    color: var(--primary-ink) !important;
}

/* ---- Tables --------------------------------------------------------------- */
.table {
    background-color: var(--surface);
    border-collapse: separate;
    border-spacing: 0;
}

.table thead {
    background-color: var(--light-bg);
}

.table thead th {
    border-color: var(--border-color);
    font-weight: 600;
    text-transform: uppercase;
    font-size: 0.72rem;
    letter-spacing: 0.05em;
    padding: 0.875rem 1rem;
    color: var(--muted-text);
}

.table tbody td {
    padding: 0.875rem 1rem;
    border-color: var(--border-color);
    vertical-align: middle;
}

.table-hover tbody tr:hover {
    background-color: var(--primary-soft);
}

/* ---- Field display (read-only detail-card label/value pairs) ------------- */
.field-display {
    display: flex;
    align-items: flex-start;
    gap: 0.7rem;
    margin-bottom: 1.1rem;
}

.field-display-icon {
    flex: 0 0 auto;
    width: 2rem;
    height: 2rem;
    border-radius: var(--radius-sm);
    background-color: var(--primary-soft);
    color: var(--primary-color);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.95rem;
}

.field-display-body {
    min-width: 0;
}

.field-display-label {
    display: block;
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--muted-text);
    margin-bottom: 0.15rem;
}

.field-display-value {
    font-size: 0.95rem;
    font-weight: 500;
    color: var(--dark-text);
    word-break: break-word;
}

.field-display-value-lg {
    font-size: 1.25rem;
    font-weight: 700;
}

.field-display-value a {
    font-weight: 600;
}

.field-display-empty {
    color: var(--muted-text);
    font-style: italic;
    font-weight: 400;
}

.field-display-map-link {
    font-size: 0.82rem;
    font-weight: 600;
    white-space: nowrap;
}

.field-display-hint {
    font-size: 0.85rem;
    font-weight: 400;
    color: var(--muted-text);
}

/* ---- Forms ---------------------------------------------------------------- */
.form-label {
    font-weight: 600;
    margin-bottom: 0.4rem;
    color: var(--dark-text);
    font-size: 0.9rem;
}

.form-control, .form-select {
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    padding: 0.6rem 0.875rem;
    font-size: 0.95rem;
    background-color: var(--surface);
    transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.form-control:focus, .form-select:focus {
    border-color: var(--primary-color);
    box-shadow: 0 0 0 3px rgba(91, 91, 214, 0.15);
    outline: none;
}

.form-control::placeholder {
    color: #9aa1ad;
}

.form-check {
    padding-left: 1.5em;
}

.form-check.form-switch {
    padding-left: 2.5em;
}

.form-check > .form-check-input {
    margin-left: -1.5em;
}

.form-switch > .form-check-input {
    margin-left: -2.5em;
}

.form-check-input:checked {
    background-color: var(--primary-color);
    border-color: var(--primary-color);
}

.form-check-input:focus {
    border-color: var(--primary-color);
    box-shadow: 0 0 0 3px rgba(91, 91, 214, 0.15);
}

/* Due-date color classes */
.overdue-date {
    color: var(--danger-color);
    font-weight: 700;
}

.due-soon-date {
    color: #ea580c;
    font-weight: 600;
}

/* ---- Alerts --------------------------------------------------------------- */
.alert {
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    padding: 0.875rem 1rem;
    margin-bottom: 1rem;
}

.alert-info {
    background-color: var(--info-soft);
    border-color: #a5f0fc;
    color: var(--info-ink);
}

.alert-success {
    background-color: var(--success-soft);
    border-color: #bbf7d0;
    color: var(--success-ink);
}

.alert-warning {
    background-color: var(--warning-soft);
    border-color: #fde68a;
    color: var(--warning-ink);
}

.alert-danger {
    background-color: var(--danger-soft);
    border-color: #fecaca;
    color: var(--danger-ink);
}

/* ---- List groups ---------------------------------------------------------- */
.list-group-item {
    border: 1px solid var(--border-color);
    padding: 1rem;
    background-color: var(--surface);
    transition: background-color 0.15s ease;
}

.list-group-item:first-child {
    border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}

.list-group-item:last-child {
    border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}

.list-group-item.list-group-item-action:hover {
    background-color: var(--light-bg);
    text-decoration: none;
}

/* ---- Typography ----------------------------------------------------------- */
h1, h2, h3, h4, h5, h6 {
    font-weight: 700;
    margin-bottom: 0.5rem;
    color: var(--dark-text);
    letter-spacing: -0.015em;
}

/* Blazor focuses the first heading on navigation for accessibility.
   Remove the browser's default focus ring since headings aren't interactive. */
h1:focus, h2:focus, h3:focus, h4:focus, h5:focus, h6:focus {
    outline: none;
}

h1 {
    font-size: 1.875rem;
    font-weight: 800;
    margin-bottom: 1rem;
}

h2 {
    font-size: 1.5rem;
}

h5 {
    font-size: 1.1rem;
}

p {
    margin-bottom: 1rem;
}

.text-muted {
    color: var(--muted-text) !important;
}

.text-primary {
    color: var(--primary-color) !important;
}

/* --*-ink, not --*-color. These are TEXT, and the -color values are fills: as
   text on --surface they measured success 3.30, warning 3.19, info 3.68, and
   .text-danger was failing in DARK at 3.25 because -color does not flip. The ink
   tokens do flip, and clear 4.5 on all four grounds — surface and page
   background, both themes — with 4.69 the worst of the sixteen readings. */
.text-success {
    color: var(--success-ink) !important;
}

.text-danger {
    color: var(--danger-ink) !important;
}

.text-warning {
    color: var(--warning-ink) !important;
}

.text-info {
    color: var(--info-ink) !important;
}

.display-4 {
    font-size: 2rem;
    font-weight: 800;
    letter-spacing: -0.02em;
}

.display-6 {
    font-size: 1.5rem;
    font-weight: 700;
}

/* ---- Spacing overrides (kept from original) ------------------------------- */
.mt-5 {
    margin-top: 3rem;
}

.mb-4 {
    margin-bottom: 1.5rem;
}

.mb-3 {
    margin-bottom: 1rem;
}

.mb-2 {
    margin-bottom: 0.5rem;
}

.mb-1 {
    margin-bottom: 0.25rem;
}

.mb-0 {
    margin-bottom: 0;
}

.me-2 {
    margin-right: 0.5rem;
}

.px-4 {
    padding-left: 1.5rem;
    padding-right: 1.5rem;
}

.px-3 {
    padding-left: 1rem;
    padding-right: 1rem;
}

.p-3 {
    padding: 1rem;
}

/* ---- Responsive ----------------------------------------------------------- */
/* Matches Bootstrap's navbar-expand-lg breakpoint (992px) so the container
   behavior stays in sync with the hamburger toggler. */
@media (max-width: 991.98px) {
    .content {
        padding: 1rem 1rem;
    }

    h1 {
        font-size: 1.5rem;
    }

    .display-4 {
        font-size: 1.5rem;
    }

    .table {
        font-size: 0.85rem;
    }

    .btn {
        padding: 0.4rem 0.8rem;
        font-size: 0.875rem;
    }
}

/* ---- Scrollbar ------------------------------------------------------------ */
::-webkit-scrollbar {
    width: 10px;
    height: 10px;
}

::-webkit-scrollbar-track {
    background: transparent;
}

::-webkit-scrollbar-thumb {
    background: #cbd2de;
    border-radius: 999px;
    border: 2px solid var(--light-bg);
}

::-webkit-scrollbar-thumb:hover {
    background: #aab2c2;
}

/* ---- Loading spinner ------------------------------------------------------ */
.spinner-border-sm {
    width: 1rem;
    height: 1rem;
    border-width: 0.2em;
}

/* ---- Sticky top ----------------------------------------------------------- */
.sticky-top {
    position: sticky;
    top: 1rem;
}

/* ---- Error UI ------------------------------------------------------------- */
#blazor-error-ui {
    background: lightyellow;
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
    display: none;
    left: 0;
    padding: 0.6rem 1.25rem;
    position: fixed;
    right: 0;
    z-index: 1000;
}

#blazor-error-ui .dismiss {
    cursor: pointer;
    position: absolute;
    right: 0.75rem;
    top: 0.5rem;
}

/* ---- Error pages (404, 500) ----------------------------------------------- */
.error-page {
    text-align: center;
    padding: 4rem 1.5rem;
    max-width: 480px;
    margin: 0 auto;
}

.error-page .error-icon {
    width: 72px;
    height: 72px;
    border-radius: 50%;
    background: var(--brand-gradient);
    color: #fff;
    font-size: 2rem;
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: center;
    margin: 0 auto 1.5rem;
}

.error-page .error-code {
    font-size: 4rem;
    font-weight: 800;
    color: var(--dark-text);
    margin-bottom: 0.25rem;
    line-height: 1;
}

.error-page .error-title {
    font-size: 1.5rem;
    font-weight: 600;
    color: var(--dark-text);
    margin-bottom: 1rem;
}

.error-page .error-message {
    color: var(--muted-text);
    font-size: 1rem;
    margin-bottom: 2rem;
    line-height: 1.6;
}

.error-page .error-actions {
    display: flex;
    gap: 0.75rem;
    justify-content: center;
    flex-wrap: wrap;
}

/* ---- ErrorBoundary inline fallback ---------------------------------------- */
.error-boundary {
    text-align: center;
    padding: 3rem 1.5rem;
    background-color: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    margin: 2rem auto;
    max-width: 480px;
}

.error-boundary .error-boundary-icon {
    width: 48px;
    height: 48px;
    border-radius: 50%;
    background-color: var(--danger-color);
    color: #fff;
    font-size: 1.25rem;
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: center;
    margin: 0 auto 1rem;
}

.error-boundary h3 {
    margin-bottom: 0.5rem;
}

.error-boundary p {
    margin-bottom: 1.25rem;
}

/* ---- Print ---------------------------------------------------------------- */
@media print {
    body {
        background-color: white;
    }

    .no-print {
        display: none;
    }
}

/* ---- Rich-text editor (Quill via Blazored.TextEditor) --------------------- */
/* Constrains the editable area to a sensible height inside the workorder
   line-item form so it doesn't dominate the panel, with scroll for longer
   notes. */
.rich-notes-wrapper .ql-toolbar.ql-snow {
    border-top-left-radius: 0.25rem;
    border-top-right-radius: 0.25rem;
}
.rich-notes-wrapper .ql-container.ql-snow {
    border-bottom-left-radius: 0.25rem;
    border-bottom-right-radius: 0.25rem;
    min-height: 5rem;
    max-height: 12rem;
    overflow-y: auto;
    font-size: 0.875rem;
}

/* Quill ships 28x24 toolbar buttons, which is a mouse figure. On a phone this
   toolbar is the ONLY way to format a note — there is no keyboard shortcut
   within reach — so the buttons take the 44px floor like every other control
   down here, and the toolbar wraps to as many rows as that needs rather than
   scrolling sideways or squeezing.

   Quill's own layout is margin-based, not flex, so this sets the button box and
   lets .ql-formats wrap; the icons inside are SVGs at a fixed size and are
   deliberately left alone, so the glyphs stay the size they were and only the
   target around them grows. */
@media (max-width: 767.98px) {
    .rich-notes-wrapper .ql-toolbar.ql-snow {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 0.15rem;
        padding: 0.35rem;
    }
    .rich-notes-wrapper .ql-toolbar.ql-snow .ql-formats {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        margin-right: 0.5rem;
    }
    .rich-notes-wrapper .ql-toolbar.ql-snow button,
    .rich-notes-wrapper .ql-toolbar.ql-snow .ql-picker-label {
        width: 44px;
        height: 44px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        padding: 0;
    }
    /* The picker's own dropdown arrow is positioned against the label box, so
       it has to be re-centred once that box is 44px tall. */
    .rich-notes-wrapper .ql-toolbar.ql-snow .ql-picker-label svg {
        position: static;
        margin-top: 0;
    }
}

/* ============================================================================
   Phase 0 polish — metric tiles, modals, dropdowns, controls, fine details.
   Appended last so these overrides win on equal specificity. Still markup-free.
   ============================================================================ */

/* ---- Dashboard metric tiles ----------------------------------------------- */
/* Only re-surface the neutral (bg-light) tiles; the alert-state tiles
   (bg-warning-subtle / bg-danger-subtle + border-*) keep their signal color. */
.stat-card {
    border-radius: var(--radius);
    position: relative;
    overflow: hidden;
}

.stat-card.bg-light {
    background: var(--surface) !important;
    border: 1px solid var(--border-color);
}

/* Thin brand accent strip along the top of each tile. */
.stat-card::before {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 3px;
    background: var(--brand-gradient);
    opacity: 0.9;
}

.stat-card .card-title {
    font-size: 0.75rem !important;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    font-weight: 600;
    color: var(--muted-text) !important;
}

/* Query container for the tile value below, so 100cqi is exactly the width
   the value has to fit in. Scoped with :has() to the bodies that actually
   hold a display-4 value: the admin dashboard's funnel cells are also
   .stat-card, but they sit in a flex row where their width comes from their
   content, and inline-size containment would collapse that. */
.stat-card .card-body:has(> .card-text.display-4) {
    container-type: inline-size;
}

/* Long currency values ("$158,500.00") used to wrap mid-number. Shrink the
   value to fit on one line instead — sized by how long the VALUE is, not by
   how wide the tile is, so a tile showing "3" keeps its full 2.25rem and the
   counts stay visibly bigger than their own uppercase labels.

   --len is the value's width in digit-widths (StatTile.Len); 0.62 is one
   digit's advance in this weight, measured. Missing --len falls back to a
   3-digit count, which lands on the 2.25rem ceiling — i.e. unchanged. */
.stat-card .card-text.display-4 {
    --stat-fit: calc(100cqi / (var(--len, 3) * 0.62));
    font-size: min(2.25rem, var(--stat-fit));
    white-space: nowrap;
    font-weight: 800;
    letter-spacing: -0.02em;
    font-variant-numeric: tabular-nums;
    margin-top: 0.25rem;
}

.stat-card small {
    color: var(--muted-text) !important;
}

.stat-card:hover {
    box-shadow: var(--shadow-md);
}

/* ---- Solid colored buttons ------------------------------------------------ */
/* --*-solid, not --*-color: these carry white text, and the -color values are
   too light for it (success 3.30, warning 3.19, info 3.68). See the token block. */
.btn-success {
    background-color: var(--success-solid);
    border: none;
    color: #fff;
}

.btn-danger {
    background-color: var(--danger-color);
    border: none;
    color: #fff;
}

.btn-warning {
    background-color: var(--warning-solid);
    border: none;
    color: #fff;
}

.btn-info {
    background-color: var(--info-solid);
    border: none;
    color: #fff;
}

.btn-secondary {
    background-color: #475569;
    border: none;
    color: #fff;
}

.btn-success:hover, .btn-danger:hover, .btn-warning:hover,
.btn-info:hover, .btn-secondary:hover {
    filter: brightness(1.06);
    color: #fff;
    box-shadow: var(--shadow-sm);
}

.btn-sm {
    padding: 0.35rem 0.7rem;
    font-size: 0.8rem;
    border-radius: calc(var(--radius-sm) - 1px);
}

/* ---- Modals --------------------------------------------------------------- */
.modal-content {
    border: none;
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-lg);
    overflow: hidden;
}

.modal-header {
    border-bottom: 1px solid var(--border-color);
    padding: 1.1rem 1.25rem;
}

.modal-title {
    font-weight: 700;
    letter-spacing: -0.01em;
}

.modal-body {
    padding: 1.25rem;
}

.modal-footer {
    border-top: 1px solid var(--border-color);
    padding: 1rem 1.25rem;
}

/* ---- Dropdowns ------------------------------------------------------------ */
.dropdown-menu {
    border: 1px solid var(--border-color);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow-md);
    padding: 0.35rem;
}

.dropdown-item {
    border-radius: calc(var(--radius-sm) - 2px);
    padding: 0.5rem 0.75rem;
    font-weight: 500;
}

.dropdown-item:hover, .dropdown-item:focus {
    background-color: var(--primary-soft);
    color: var(--primary-ink);
}

.dropdown-item.active, .dropdown-item:active {
    background-color: var(--primary-color);
    color: #fff;
}

.dropdown-divider {
    border-color: var(--border-color);
}

/* Dark dropdowns (all three nav bars use .dropdown-menu-dark). More specific
   than the light rules above, so these win for nav menus while content-area
   dropdowns keep the light treatment. */
.dropdown-menu-dark {
    background-color: #111827;
    border-color: rgba(255, 255, 255, 0.08);
    box-shadow: var(--shadow-lg);
}

.dropdown-menu-dark .dropdown-item {
    color: rgba(255, 255, 255, 0.85);
}

.dropdown-menu-dark .dropdown-item:hover,
.dropdown-menu-dark .dropdown-item:focus {
    background-color: rgba(255, 255, 255, 0.10);
    color: #fff;
}

.dropdown-menu-dark .dropdown-item.active,
.dropdown-menu-dark .dropdown-item:active {
    background-color: var(--primary-color);
    color: #fff;
}

.dropdown-menu-dark .dropdown-divider {
    border-color: rgba(255, 255, 255, 0.10);
}

/* ---- Page header ---------------------------------------------------------- */
.page-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    gap: 1rem;
    margin-bottom: 1.5rem;
    padding-bottom: 1rem;
    border-bottom: 1px solid var(--border-color);
}

.page-header-titles h1 {
    margin-bottom: 0.25rem;
}

.page-header-subtitle {
    color: var(--muted-text);
    margin-bottom: 0;
    font-size: 0.95rem;
}

.page-header-actions {
    flex-shrink: 0;
    display: flex;
    gap: 0.5rem;
    align-items: center;
    padding-top: 0.25rem;
}

/* Stack the action(s) under the title on narrow screens instead of cramming
   them onto the same row. */
@media (max-width: 575.98px) {
    .page-header {
        flex-direction: column;
        align-items: stretch;
    }

    .page-header-actions {
        padding-top: 0;
    }
}

/* ---- Empty-state & neutral alerts ----------------------------------------- */
.alert-secondary {
    background-color: var(--light-bg);
    border-color: var(--border-color);
    color: var(--muted-text);
}

/* ---- Form validation & disabled states ------------------------------------ */
.form-control.is-invalid,
.form-select.is-invalid {
    border-color: var(--danger-color);
}

.form-control.is-invalid:focus,
.form-select.is-invalid:focus {
    box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}

.form-control.is-valid,
.form-select.is-valid {
    border-color: var(--success-color);
}

/* Text, so ink rather than the fill colour — same reasoning as .text-success. */
.invalid-feedback {
    color: var(--danger-ink);
    font-size: 0.85rem;
}

.valid-feedback {
    color: var(--success-ink);
    font-size: 0.85rem;
}

.form-control:disabled,
.form-select:disabled,
.form-control[readonly] {
    background-color: var(--light-bg);
    color: var(--muted-text);
    cursor: not-allowed;
}

.btn:disabled,
.btn.disabled {
    opacity: 0.55;
    box-shadow: none;
    filter: none;
}

/* ---- Input groups & selects ----------------------------------------------- */
.input-group-text {
    background-color: var(--light-bg);
    border: 1px solid var(--border-strong);
    color: var(--muted-text);
    font-size: 0.9rem;
}

/* ---- Progress bars -------------------------------------------------------- */
.progress {
    background-color: var(--light-bg);
    border-radius: 999px;
    height: 0.6rem;
    overflow: hidden;
}

.progress-bar {
    background: var(--brand-gradient);
}

/* ---- Pagination & nav-pills ----------------------------------------------- */
.page-link {
    color: var(--primary-color);
    border-color: var(--border-color);
    border-radius: var(--radius-sm);
    margin: 0 0.125rem;
    font-weight: 500;
}

.page-link:hover {
    background-color: var(--primary-soft);
    color: var(--primary-ink);
    border-color: var(--border-strong);
}

.page-item.active .page-link {
    background-color: var(--primary-color);
    border-color: var(--primary-color);
    color: #fff;
}

.nav-pills .nav-link {
    color: var(--primary-color);
    border-radius: var(--radius-sm);
    font-weight: 500;
}

.nav-pills .nav-link.active {
    background-color: var(--primary-color);
    color: #fff;
}

/* ---- Fine details --------------------------------------------------------- */
hr {
    border-color: var(--border-color);
    opacity: 1;
}

::selection {
    background: rgba(102, 126, 234, 0.22);
}

/* Brand color for plain in-content links (anchors with no utility class).

   --primary-ink, not --primary-color: the brand indigo #5b5bd6 does not flip
   for dark mode, so on a dark card (--surface #1e2330) every mailto:/tel: link
   in the app measured 2.92:1 — under the 4.5 floor. The ink token is the one
   built to be read as text on a surface and does flip. Same distinction line
   ~360 already draws, and the same mistake Phase 2 made in reverse by using an
   ink token as a fill. */
.content a:not([class]) {
    color: var(--primary-ink);
    text-decoration: none;
}

/* The underline is the hover cue, not a colour shift. --primary-hover has the
   same no-flip problem as --primary-color above, and in light mode it is the
   same value as --primary-ink anyway — so keeping the colour steady loses
   nothing visible and stops the hover state from being the one place these
   links go back to failing contrast in dark mode. */
.content a:not([class]):hover {
    color: var(--primary-ink);
    text-decoration: underline;
}

a:focus-visible {
    outline: none;
    box-shadow: 0 0 0 3px rgba(91, 91, 214, 0.30);
    border-radius: 2px;
}

/* ---- Unread / warning-state highlight ------------------------------------- */
/* Restores the amber "unread" indication that the base .list-group-item /
   .table surface rules above would otherwise flatten to white. Used for
   unread notifications, reminders, and message threads
   (list-group-item-warning / table-warning toggled in markup on !IsRead). */
.list-group-item-warning {
    background-color: #fff8e8;
    border-left: 3px solid var(--warning-color);
    color: var(--dark-text);
}

/* Keep the amber visible (just a touch deeper) on hover for clickable items,
   beating the neutral .list-group-item-action:hover rule. */
.list-group-item-action.list-group-item-warning:hover,
.list-group-item-warning.list-group-item-action:hover {
    background-color: #fdf1d2;
    color: var(--dark-text);
}

/* Table rows flagged unread. !important is needed because Bootstrap drives row
   color through the --bs-table-bg variable on each cell. */
.table-warning,
.table-warning > td,
.table-warning > th {
    background-color: #fff8e8 !important;
}

/* Left accent bar on the row's first cell, matching the unread list-item cue.
   inset box-shadow avoids any column-width shift in dense tables. */
.table-warning > td:first-child,
.table-warning > th:first-child {
    box-shadow: inset 3px 0 0 var(--warning-color);
}

.table-hover tbody tr.table-warning:hover > td,
.table-hover tbody tr.table-warning:hover > th {
    background-color: #fdf1d2 !important;
}

/* ============================================================================
   Dark theme
   Activated by data-bs-theme="dark" on <html> (set in App.razor before paint,
   toggled by /js/theme.js). Bootstrap 5.3 recolors its own components from that
   attribute; here we re-point our design tokens so every rule above that reads
   a --token follows along. Only a handful of rules below target hardcoded
   colors that aren't tokenized. The three brand nav bars (purple gradient /
   slate / orange) are intentionally left alone — they read well on dark too.
   ============================================================================ */
[data-bs-theme="dark"] {
    /* Brand accent stays; just the surfaces/text/borders flip. */
    --primary-soft: #242a4a;
    --primary-ink: #a5b4fc;

    /* --surface goes dark, so the light-surface "IQ" takes the chrome value. */
    --brand-iq: var(--brand-iq-chrome);

    --secondary-color: #94a3b8;

    --success-soft: #11321f;
    --success-ink: #4ade80;
    --danger-soft: #3a1c1c;
    --danger-ink: #f87171;
    --warning-soft: #3a2c12;
    --warning-ink: #fbbf24;
    --info-soft: #0d2b33;
    --info-ink: #38bdf8;

    /* Neutrals — layered: page (--light-bg) sits below cards (--surface). */
    --surface: #1e2330;
    --light-bg: #141821;
    --dark-text: #e6e8ee;
    --muted-text: #9aa3b4;
    --border-color: #2b3344;
    --border-strong: #3b4559;

    /* Deeper shadows so elevation still reads against the dark page. */
    --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.40);
    --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.50), 0 1px 2px rgba(0, 0, 0, 0.40);
    --shadow-md: 0 6px 16px rgba(0, 0, 0, 0.55), 0 2px 6px rgba(0, 0, 0, 0.40);
    --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.65);
}

/* Neutral secondary badge uses a hardcoded slate tint above — darken it. */
[data-bs-theme="dark"] .badge.bg-secondary {
    background-color: #2b3344 !important;
    color: #cbd5e1 !important;
}

/* Alert borders above are light pastel hex values — soften for dark surfaces. */
[data-bs-theme="dark"] .alert-info { border-color: #155e6e; }
[data-bs-theme="dark"] .alert-success { border-color: #166534; }
[data-bs-theme="dark"] .alert-warning { border-color: #92660f; }
[data-bs-theme="dark"] .alert-danger { border-color: #7f1d1d; }

/* Unread highlight (hardcoded ambers) — dark amber wash that still reads. */
[data-bs-theme="dark"] .list-group-item-warning {
    background-color: #34290f;
}
[data-bs-theme="dark"] .list-group-item-action.list-group-item-warning:hover,
[data-bs-theme="dark"] .list-group-item-warning.list-group-item-action:hover {
    background-color: #3f3214;
}
[data-bs-theme="dark"] .table-warning,
[data-bs-theme="dark"] .table-warning > td,
[data-bs-theme="dark"] .table-warning > th {
    /* The bg override alone left table-warning's own dark text on a dark
       amber wash — unreadable. Re-point the text at the dark-mode body color
       and mark the row with an amber edge so "overdue" still reads at a glance. */
    background-color: #34290f !important;
    color: var(--dark-text) !important;
}
[data-bs-theme="dark"] tr.table-warning > td:first-child {
    box-shadow: inset 3px 0 0 var(--warning-ink);
}
[data-bs-theme="dark"] .table-hover tbody tr.table-warning:hover > td,
[data-bs-theme="dark"] .table-hover tbody tr.table-warning:hover > th {
    background-color: #3f3214 !important;
    color: var(--dark-text) !important;
}

/* table-light header bands stay near-white under Bootstrap dark — re-point
   them at the dark surface tokens so they sit with the rest of the table. */
[data-bs-theme="dark"] .table-light,
[data-bs-theme="dark"] .table-light > tr > th,
[data-bs-theme="dark"] .table-light th {
    --bs-table-bg: #232936;
    --bs-table-color: var(--dark-text);
    background-color: #232936 !important;
    color: var(--dark-text) !important;
    border-color: #3b4559 !important;
}

/* Outline buttons take the ink tokens in BOTH themes, not just dark.

   This started as a dark-only override — Bootstrap's light hues read muddy on
   dark surfaces — and light mode was left on the raw hues because nobody
   measured them. They are worse. Bootstrap's warning is #ffc107 and its info is
   #0dcaf0, both used as text directly on a white button face:

       .btn-outline-warning  #ffc107 on #fff  =  1.52:1
       .btn-outline-info     #0dcaf0 on #fff  =  1.83:1

   Found by a page-wide contrast sweep of /admin/messages/9, where "Close Thread"
   and "Create Reminder" are both nearly unreadable in the default theme. The ink
   tokens already carry the right value for each side (#b45309 / #0e7490 in
   light), so the fix is to stop gating the override on the theme — the tokens
   flip on their own, which is the entire reason they exist.

   This is the same shape as round 1's --primary-ink finding, mirrored: there the
   dark side had no token and inherited a light-mode value; here the dark side was
   fixed and the light side was never looked at. Fixing one half of a themed pair
   and calling it done is apparently the recurring failure mode.

   Hover still fills solid with Bootstrap's own colours, so contrast there is
   unchanged. */
.btn-outline-danger {
    --bs-btn-color: var(--danger-ink);
    --bs-btn-border-color: var(--danger-ink);
}
.btn-outline-warning {
    --bs-btn-color: var(--warning-ink);
    --bs-btn-border-color: var(--warning-ink);
}
.btn-outline-success {
    --bs-btn-color: var(--success-ink);
    --bs-btn-border-color: var(--success-ink);
}
.btn-outline-info {
    --bs-btn-color: var(--info-ink);
    --bs-btn-border-color: var(--info-ink);
}
[data-bs-theme="dark"] .btn-outline-primary {
    --bs-btn-color: #a5b4fc;
    --bs-btn-border-color: #818cf8;
}

/* Scrollbar + error banner use hardcoded light values. */
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb {
    background: #3b4559;
    border-color: var(--light-bg);
}
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover {
    background: #4b566b;
}
[data-bs-theme="dark"] #blazor-error-ui {
    background: #3a2c12;
    color: #fbbf24;
}

/* The theme-toggle nav button — inherits nav-link colors; just size the icon. */
.theme-toggle {
    background: none;
    border: none;
    cursor: pointer;
}

/* ---- Public landing page (pre-login hero, feature strip, CTA banner) ------ */
.landing-hero {
    display: grid;
    grid-template-columns: 1fr 1fr;
    align-items: center;
    gap: 2.5rem;
    max-width: 1200px;
    margin: 3rem auto 0;
    padding: 0 1.5rem;
}

.landing-hero-logo {
    display: block;
    height: 40px;
}

.landing-eyebrow {
    display: block;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    font-size: 0.8rem;
    font-weight: 700;
    color: var(--primary-color);
    margin-bottom: 0.5rem;
}

.landing-hero-copy h1 {
    margin-bottom: 1rem;
}

.landing-hero-photo {
    border-radius: var(--radius-lg);
    overflow: hidden;
    box-shadow: var(--shadow-md);
}

.landing-hero-img {
    display: block;
    width: 100%;
    height: 100%;
    max-height: 360px;
    object-fit: cover;
    object-position: 50% 15%;
    filter: grayscale(1) contrast(1.08);
}

.landing-features {
    max-width: 1200px;
    margin: 3rem auto 0;
    padding: 0 1.5rem;
}

.landing-cta-banner {
    max-width: 1200px;
    margin: 3rem auto 0;
    padding: 2.5rem;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1.5rem;
    flex-wrap: wrap;
    border-radius: var(--radius-lg);
    background: var(--brand-gradient);
    box-shadow: var(--shadow-md);
}

.landing-cta-banner h3 {
    color: #fff;
    margin: 0;
}

.landing-cta-btn {
    background: #fff;
    color: var(--primary-color);
    white-space: nowrap;
}

.landing-cta-btn:hover {
    background: #fff;
    filter: brightness(0.96);
    color: var(--primary-hover);
}

@media (max-width: 991.98px) {
    .landing-hero {
        grid-template-columns: 1fr;
    }

    .landing-hero-photo {
        order: -1;
    }
}

@media (max-width: 575.98px) {
    .landing-cta-banner {
        flex-direction: column;
        align-items: flex-start;
        text-align: left;
    }
}

/* ============================================================================
   ADMIN SHELL — persistent left rail (>=md) → bottom tab bar + drawer (<md).
   The rail is fixed dark chrome in BOTH themes (like the old navbar the app
   comment calls out); the content-side top strip and body use theme tokens so
   they flip with light/dark. The collapsed state is driven by
   [data-admin-rail="collapsed"] on <html> (js/adminRail.js), applied before
   paint so there's no flash and it survives Blazor DOM diffing.
   ============================================================================ */
.admin-shell {
    display: flex;
    align-items: stretch;
    min-height: 100vh;
    background-color: var(--light-bg);
}

.admin-rail {
    flex: 0 0 232px;
    width: 232px;
    position: sticky;
    top: 0;
    align-self: flex-start;
    height: 100vh;
    display: flex;
    flex-direction: column;
    background-color: #1f2937; /* slate-800 — matches the retired admin navbar */
    color: rgba(255, 255, 255, 0.82);
    box-shadow: 2px 0 8px rgba(0, 0, 0, 0.18);
    z-index: 1030;
    transition: flex-basis 0.18s ease, width 0.18s ease;
}

/* Collapsed: icons only. Word labels (.rail-label) hide; the rail narrows. */
[data-admin-rail="collapsed"] .admin-rail {
    flex-basis: 64px;
    width: 64px;
}
[data-admin-rail="collapsed"] .admin-rail .rail-label { display: none; }
[data-admin-rail="collapsed"] .admin-rail .rail-search { justify-content: center; gap: 0; }
[data-admin-rail="collapsed"] .admin-rail .rail-collapse i { transform: scaleX(-1); }

.rail-head {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    padding: 0.85rem 0.8rem 0.6rem;
}
.rail-brand {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    min-width: 0;
    flex: 1 1 auto;
    color: #fff;
    font-weight: 800;
    font-size: 1.05rem;
    letter-spacing: -0.01em;
    text-decoration: none;
}
.rail-brand-name { white-space: nowrap; overflow: hidden; }

/* ── Brand wordmark (BrandWordmark.razor) ───────────────────────────────────
   The rails and the portal navbar are dark chrome; .strip-brand is the mobile
   header on --surface and takes the darker green instead. */
.brand-word { white-space: nowrap; }
.brand-iq { color: var(--brand-iq); }
.rail-brand .brand-iq,
.navbar-brand .brand-iq { color: var(--brand-iq-chrome); }
.rail-adm {
    font-size: 0.55rem;
    letter-spacing: 0.05em;
    padding: 0.15em 0.4em;
    align-self: center;
}
.rail-collapse {
    flex: 0 0 auto;
    background: transparent;
    border: 0;
    color: rgba(255, 255, 255, 0.6);
    padding: 0.3rem 0.4rem;
    border-radius: var(--radius-sm);
    cursor: pointer;
    line-height: 1;
}
.rail-collapse:hover { color: #fff; background: rgba(255, 255, 255, 0.12); }

.rail-search {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    margin: 0 0.7rem 0.5rem;
    padding: 0.5rem 0.65rem;
    background: rgba(255, 255, 255, 0.09);
    border: 0;
    border-radius: var(--radius-sm);
    color: rgba(255, 255, 255, 0.7);
    font-size: 0.85rem;
    cursor: pointer;
    text-align: left;
}
.rail-search:hover { background: rgba(255, 255, 255, 0.15); color: #fff; }
.rail-kbd {
    margin-left: auto;
    background: rgba(255, 255, 255, 0.14);
    border-radius: 4px;
    padding: 0 0.35rem;
    font-size: 0.7rem;
    color: #d7dbe4;
}

.rail-scroll { flex: 1 1 auto; overflow-y: auto; padding-bottom: 1rem; }

.rail-nav { display: flex; flex-direction: column; }
/* 0.62 alpha, not 0.40. These headings are ~10.5px, so they need 4.5 and 0.40
   gave 3.62 on the admin rail — and 3.09 on the field and customer rails, which
   are slate-700 rather than slate-800. This one rule serves all three, so it has
   to clear the LIGHTEST of them: 0.62 measures 5.10 there and 6.45 on admin.

   Alpha rather than a flat colour keeps the rule working on all three
   backgrounds, and 0.62 against the rail's own 0.82 body text preserves the
   hierarchy that made these quiet in the first place. The rails are dark chrome
   in both themes, which is why this failed identically in light and dark. */
.rail-sec {
    font-size: 0.66rem;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: rgba(255, 255, 255, 0.62);
    padding: 0.85rem 1.1rem 0.25rem;
}
/* Collapsed: the section label becomes a thin divider between icon groups. */
[data-admin-rail="collapsed"] .admin-rail .rail-sec {
    padding: 0;
    margin: 0.5rem 0.8rem;
    border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.rail-link {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    padding: 0.5rem 1.1rem;
    color: rgba(255, 255, 255, 0.8);
    text-decoration: none;
    font-size: 0.9rem;
    border-left: 3px solid transparent;
    white-space: nowrap;
}
.rail-link:hover { color: #fff; background: rgba(255, 255, 255, 0.08); }
.rail-link.active {
    color: #fff;
    background: rgba(99, 102, 241, 0.28); /* indigo brand wash */
    border-left-color: #7f77dd;
    font-weight: 600;
}
.rail-ico { font-size: 1.05rem; width: 1.2rem; text-align: center; flex: 0 0 auto; }
.rail-pro { font-size: 0.55rem; margin-left: 0.35rem; vertical-align: 1px; }
[data-admin-rail="collapsed"] .admin-rail .rail-link {
    justify-content: center;
    padding: 0.5rem 0;
    gap: 0;
    border-left-width: 0;
}
[data-admin-rail="collapsed"] .admin-rail .rail-link.active { box-shadow: inset 3px 0 0 #7f77dd; }

/* ── Content column + top strip ──────────────────────────────────────────── */
.admin-content-col {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
}
.admin-strip {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.35rem 1rem;
    min-height: var(--shell-strip-h);
    background: var(--surface);
    border-bottom: 1px solid var(--border-color);
    position: sticky;
    top: 0;
    z-index: 1020;
}

/* Sticky Back/Save bar at the top of a full-page record editor (work order,
   contact). It has to clear .admin-strip: both stick to the top of the same
   scroll container, and the strip wins on z-index, so at top:0 this bar sits
   *under* it — elementFromPoint returns the strip, meaning Save is not merely
   hidden but unclickable the moment the page is scrolled at all. That is what
   made "click Create Work Order above" an unfollowable instruction. */
.editor-action-bar {
    position: sticky;
    top: var(--shell-strip-h);
    z-index: 10;
}

.strip-brand { display: none; } /* mobile only — the rail carries the brand on desktop */
.strip-actions { display: flex; align-items: center; gap: 0.1rem; margin-left: auto; }
.strip-btn {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 38px;
    height: 38px;
    border: 0;
    background: transparent;
    color: var(--dark-text);
    border-radius: var(--radius-sm);
    font-size: 1.15rem;
    line-height: 1;
    cursor: pointer;
    text-decoration: none;
}
.strip-btn:hover { background: var(--light-bg); color: var(--primary-color); }

/* ── Gear menu ───────────────────────────────────────────────────────────────
   Twenty-odd entries in seven labelled groups. Two things the default dropdown
   does not do for a menu this long:

   It has no maximum height, so on a 900px laptop the list ran past the bottom
   of the viewport with the last group unreachable — and because the menu is
   positioned, the page behind it does not scroll to rescue it. Capping it
   against the viewport and letting the menu itself scroll is the fix; the
   subtraction is the shell strip plus a margin, so the cap follows the chrome
   rather than restating a number.

   And its dropdown-header is quiet to the point of invisibility, which is how
   the rail's own section labels would have read too if they were not spaced and
   set in caps. A heading nobody sees is a divider with extra height. */
.gear-menu {
    /* max(), not a bare calc: a viewport height the browser reports as zero —
       an embedded pane, a tab rendering off-screen — turns the calc negative and
       collapses the menu to nothing, which is a worse failure than the overflow
       it was added to prevent. The floor keeps a usable menu in every case. */
    max-height: max(16rem, calc(100vh - var(--shell-strip-h) - 1.5rem));
    overflow-y: auto;
    /* Comfortably wider than the longest label ("Company & Branding"), so the
       group headings have room to read as headings. */
    min-width: 15rem;
}

.gear-menu .dropdown-header {
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--muted-text);
    padding-top: 0.6rem;
    padding-bottom: 0.15rem;
}

/* No rule above the first heading — it sits against the menu's own edge. */
.gear-menu .dropdown-header:not(:first-child) {
    margin-top: 0.3rem;
    border-top: 1px solid var(--border-color);
}

/* "All settings…" closes the group it belongs to rather than starting a new
   one, so it reads as the way out of the seven above it. */
.gear-menu .gear-more {
    font-size: 0.85rem;
    color: var(--primary-ink);
}
.strip-ico { position: relative; display: inline-flex; line-height: 1; }
.strip-ico .badge {
    position: absolute;
    top: -0.5rem;
    left: 100%;
    transform: translateX(-55%);
    margin: 0 !important;
    padding: 0.2em 0.4em;
    font-size: 0.6rem;
    line-height: 1;
    border: 1.5px solid var(--surface);
}
.admin-main { flex: 1 1 auto; }

/* ── Mobile: hide rail, show bottom tab bar + drawer ─────────────────────────
   Structural selectors are shared with the field and customer shells
   (comma-extended) so the three shells' tab bars and drawers can't drift
   apart; shell-only deltas live in the FIELD SHELL / CUSTOMER SHELL sections
   below. */
.admin-tabbar, .field-tabbar, .customer-tabbar { display: none; }
.admin-drawer-scrim, .field-drawer-scrim, .customer-drawer-scrim { display: none; }

/* ── iOS "Add to Home Screen" hint (js/ios-install-hint.js) ──────────────────
   Lives here rather than in the script's inline cssText for one reason: the
   script cannot see --shell-tabbar-h, so a hand-written `bottom: 8px` put this
   banner squarely on top of the mobile tab bar. At z-index 2000 against the
   bar's 1035 it did not merely overlap it, it took every tap — so on iPhone,
   first visit, not-yet-installed, the admin had NO working navigation until
   they found the dismiss ×. That is exactly the population the banner exists
   for, and exactly the population least able to diagnose it.

   Same primitive as .docked-bottom: clear the bar AND the home indicator, and
   sit between the sticky strip (1020) and the bar (1035) rather than above
   both. The base rule below is the desktop/tablet case, where the bar is
   display:none and only the inset applies; the phone override lives with
   .docked-bottom so the two offsets stay adjacent and cannot drift. */
.ios-install-hint {
    position: fixed;
    left: 8px;
    right: 8px;
    bottom: calc(8px + env(safe-area-inset-bottom));
    z-index: 1030;
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    border-radius: 12px;
    background: #1a1a2e;
    color: #fff;
    font-size: 14px;
    line-height: 1.3;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    box-shadow: 0 4px 16px rgba(0, 0, 0, .3);
}
.ios-install-hint svg { flex: 0 0 auto; }
.ios-install-hint .hint-text { flex: 1; }
.ios-install-hint .hint-dismiss {
    flex: 0 0 auto;
    /* 44px so the one control that unblocks the banner is itself a real touch
       target. It was a 22px glyph with 4px of padding. */
    min-width: 44px;
    min-height: 44px;
    background: transparent;
    border: 0;
    color: #cfd3e0;
    font-size: 22px;
    line-height: 1;
    cursor: pointer;
}
.ios-install-hint .hint-dismiss:hover { color: #fff; }

@media (max-width: 767.98px) {
    /* The phone chrome is taller than the desktop chrome, because the buttons
       in it are touch targets. Re-point the contract rather than letting the
       bars overrun a value written for the mouse. Strip: 44px button + 8px
       padding + 1px border = 53, so 54 renders exactly 54. */
    :root { --shell-strip-h: 54px; }

    .admin-shell { display: block; }
    .admin-rail { display: none; }

    /* Tighten so the brand + 6 action buttons fit a ~360px phone without
       pushing the page sideways. Vertical padding is 0.25rem rather than the
       desktop 0.35rem so the taller buttons cost the page only 4px of chrome
       instead of 8. */
    .admin-strip { padding: 0.25rem 0.5rem; gap: 0; }
    .strip-brand {
        display: flex;
        align-items: center;
        gap: 0.3rem;
        margin-right: auto;
        min-width: 0;
        overflow: hidden;
        color: var(--dark-text);
        font-weight: 800;
        font-size: 1rem;
        letter-spacing: -0.01em;
        text-decoration: none;
    }
    .strip-brand span { white-space: nowrap; }
    .strip-adm { display: none; } /* drop the ADMIN badge on phones to save width */
    .strip-actions { margin-left: 0; gap: 0; flex: 0 0 auto; }
    /* 44px tall in every shell. The note below explains why admin cannot be 44
       WIDE; it never explained why admin was also left short, and the height was
       always free — this is the same 8px of strip for all three. */
    .strip-btn { width: 31px; height: 44px; font-size: 1rem; }

    /* 31x38 is under the 44px touch floor the rest of this plan holds to, and
       these are the buttons a phone user reaches for most (messages, theme,
       account). Widened for the two phone-first portals only:

         field    4 buttons — 124px of 289px available, 176px at 44px wide
         customer 3 buttons —  93px of 289px available, 132px at 44px wide
         admin    7 buttons — 217px at 31px, 308px at 44px, against 289px

       So admin is left at 31px WIDE deliberately: it is the one strip where the
       floor does not fit, and it is also the one whose users are mostly at a
       desk. Measured on a 375px viewport; a 360px phone gives admin even less.
       31x44 still clears WCAG 2.5.8's 24x24 minimum with room; 44x44 is the
       Apple HIG figure, and it is the width, not the height, that has to give.
       Re-measured 2026-08-14: brand 86.2 + 7x44 (308) + 16 padding = 410 on a
       375px viewport, so this is arithmetic, not preference. */
    .field-strip .strip-btn,
    .customer-strip .strip-btn {
        width: 44px;
    }

    /* Leave room for the fixed tab bar (+ the device's home-indicator inset).
       This was a hardcoded 60px against a bar that measures 68.69 — the last
       8.7px of every page sat behind the tab bar. Derived from the token now,
       so it cannot drift from the bar again. */
    .admin-main, .field-main, .customer-main {
        padding-bottom: calc(var(--shell-tabbar-h) + env(safe-area-inset-bottom));
    }

    .admin-tabbar, .field-tabbar, .customer-tabbar {
        display: flex;
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        /* Height, not just content: .docked-bottom positions itself at exactly
           `--shell-tabbar-h + inset`, so if the bar is free to grow past the
           token it grows straight through whatever is docked above it. The
           token is the ceiling and this is what holds the bar to it — content
           is 67.7px, which fits the 68px this leaves after the border. */
        height: calc(var(--shell-tabbar-h) + env(safe-area-inset-bottom));
        background: var(--surface);
        border-top: 1px solid var(--border-color);
        padding-bottom: env(safe-area-inset-bottom);
        box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.06);
        z-index: 1035;
    }
    .admin-tabbar .tab, .field-tabbar .tab, .customer-tabbar .tab {
        flex: 1 1 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 2px;
        padding: 0.5rem 0 0.55rem;
        background: transparent;
        border: 0;
        color: var(--muted-text);
        font-size: 0.66rem;
        text-decoration: none;
        cursor: pointer;
    }
    .admin-tabbar .tab i, .field-tabbar .tab i, .customer-tabbar .tab i { font-size: 1.25rem; }
    .admin-tabbar .tab:hover, .field-tabbar .tab:hover, .customer-tabbar .tab:hover { color: var(--dark-text); }
    /* --primary-ink, not --primary-color: the brand indigo does not flip, and as
       a 10.6px label on the dark tab bar it measured 2.92:1 — the ACTIVE tab, on
       every page of all three portals. (The inactive labels use --muted-text and
       were always fine; a claim to the contrary in §9 was withdrawn in §11. This
       is the other colour in the same bar, and it really does fail.) Same fix as
       `.content a:not([class])` in Phase 3, same root cause. */
    .admin-tabbar .tab.active, .field-tabbar .tab.active, .customer-tabbar .tab.active { color: var(--primary-ink); }

    .admin-drawer-scrim, .field-drawer-scrim, .customer-drawer-scrim {
        display: block;
        position: fixed;
        inset: 0;
        background: rgba(0, 0, 0, 0.45);
        opacity: 0;
        visibility: hidden;
        transition: opacity 0.2s ease, visibility 0.2s ease;
        z-index: 1050;
    }
    .admin-drawer-scrim.open, .field-drawer-scrim.open, .customer-drawer-scrim.open { opacity: 1; visibility: visible; }
    .admin-drawer, .field-drawer, .customer-drawer {
        position: absolute;
        top: 0;
        bottom: 0;
        left: 0;
        width: 84%;
        max-width: 320px;
        display: flex;
        flex-direction: column;
        background: #1f2937;
        color: rgba(255, 255, 255, 0.82);
        transform: translateX(-100%);
        transition: transform 0.22s ease;
        box-shadow: 2px 0 16px rgba(0, 0, 0, 0.4);
    }
    .admin-drawer-scrim.open .admin-drawer,
    .field-drawer-scrim.open .field-drawer,
    .customer-drawer-scrim.open .customer-drawer { transform: translateX(0); }
}

/* ============================================================================
   FIELD SHELL — the subcontractor/employee counterpart of the admin shell
   (FieldShell.razor). Shares the rail/strip/tab primitives (.rail-*, .strip-*,
   .tab — the admin shell's mobile structural selectors above are
   comma-extended); only the containers differ, so the two shells collapse
   independently ([data-field-rail] vs [data-admin-rail], js/adminRail.js) and
   keep distinct chrome colors: the field rail is slate-700 (#334155), carried
   over from the retired sub top bar, vs the admin's slate-800.
   ============================================================================ */
.field-shell {
    display: flex;
    align-items: stretch;
    min-height: 100vh;
    background-color: var(--light-bg);
}

.field-rail {
    flex: 0 0 232px;
    width: 232px;
    position: sticky;
    top: 0;
    align-self: flex-start;
    height: 100vh;
    display: flex;
    flex-direction: column;
    background-color: #334155; /* slate-700 — matches the retired sub navbar */
    color: rgba(255, 255, 255, 0.82);
    box-shadow: 2px 0 8px rgba(0, 0, 0, 0.18);
    z-index: 1030;
    transition: flex-basis 0.18s ease, width 0.18s ease;
}

/* Collapsed: icons only. Word labels (.rail-label) hide; the rail narrows. */
[data-field-rail="collapsed"] .field-rail {
    flex-basis: 64px;
    width: 64px;
}
[data-field-rail="collapsed"] .field-rail .rail-label { display: none; }
[data-field-rail="collapsed"] .field-rail .rail-collapse i { transform: scaleX(-1); }
[data-field-rail="collapsed"] .field-rail .rail-sec {
    padding: 0;
    margin: 0.5rem 0.8rem;
    border-top: 1px solid rgba(255, 255, 255, 0.1);
}
[data-field-rail="collapsed"] .field-rail .rail-link {
    justify-content: center;
    padding: 0.5rem 0;
    gap: 0;
    border-left-width: 0;
}
[data-field-rail="collapsed"] .field-rail .rail-link.active { box-shadow: inset 3px 0 0 #7f77dd; }

.field-content-col {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
}
.field-strip {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.35rem 1rem;
    min-height: var(--shell-strip-h);
    background: var(--surface);
    border-bottom: 1px solid var(--border-color);
    position: sticky;
    top: 0;
    z-index: 1020;
}
.field-main { flex: 1 1 auto; }

@media (max-width: 767.98px) {
    /* Tab bar / drawer structural rules are shared with the admin shell's
       mobile block above (comma-extended there). The shell/rail/strip
       overrides must live HERE, after this section's base rules — the admin
       media block sits before them in the file, so at equal specificity its
       copies of these would lose the cascade. */
    .field-shell { display: block; }
    .field-rail { display: none; }
    .field-strip { padding: 0.25rem 0.5rem; gap: 0; } /* 44px buttons; see .admin-strip */

    .field-drawer { background: #334155; } /* slate-700 field chrome */

    /* Align tab icon rows. The Messages tab wraps its icon in .strip-ico (so
       the unread badge can overlay it), and that wrapper's line-height:1 made
       its icon shorter than the bare <i> icons in the other tabs — floating
       the "Messages" label higher. Force every tab's icon slot to the same
       fixed height, centered, so all five labels sit on one line. Same fix
       for the customer tab bar — it has the identical .strip-ico Messages tab
       (CustomerShell.razor). */
    .field-tabbar .tab > i,
    .field-tabbar .tab > .strip-ico,
    .customer-tabbar .tab > i,
    .customer-tabbar .tab > .strip-ico {
        height: 1.5rem;
        display: inline-flex;
        align-items: center;
        justify-content: center;
    }

    /* iOS Safari auto-zooms any focused input whose font is under 16px —
       disruptive for field workers filling forms one-handed on phones. */
    .field-main .form-control,
    .field-main .form-select { font-size: 16px; }

    /* Small buttons stay visually small but grow a touch-friendly hit area
       (gloved thumbs on job sites).

       44px, raised from 38. This is the field portal's floor for EVERY small
       button, and 38 was simply under the target the rest of the plan holds
       everything else to — measured on the sub's job detail, where Back to
       Work, Field capture, View current and Replace all sat at exactly 38.0px.

       Round 1 §5 declined a blanket min-height on .btn-sm and that still
       stands: this is not that rule. It is scoped to .field-main AND to phone
       widths — the one portal whose users are outdoors holding the phone — so
       it cannot re-space an admin desktop grid.

       The second clause is the one to be careful with: "regular buttons are
       already ~38px" was wrong here. A plain .btn in this portal measures
       35.8px, i.e. BELOW the small-button floor this rule sets, because
       .btn-sm's own font-size is what the padding is computed from. Hence the
       .btn floor below rather than trusting the default. */
    .field-main .btn-sm {
        min-height: 44px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
    }

    /* See above: Bootstrap's default .btn lands at 35.8px in this portal, so
       every un-suffixed button (the Print and Email dropdown toggles on the job
       detail, among others) was under the floor that .btn-sm cleared. */
    .field-main .btn {
        min-height: 44px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
    }

    /* Dashboard stat tiles sit 2-up on phones — shrink the desktop-scale
       numerals and padding so a pair fits a 375px screen comfortably. */
    .field-main .stat-card .card-body { padding: 0.75rem; }
    .field-main .stat-card .card-title { font-size: 0.85rem; }
    /* A ceiling, not a fixed size — the fit calc above still applies, so a
       long value shrinks past 2rem here rather than overflowing the tile. */
    .field-main .stat-card .display-4 { font-size: min(2rem, var(--stat-fit, 2rem)); }
}

/* ============================================================================
   CUSTOMER SHELL — the third portal (CustomerShell.razor), same rail/strip/tab
   primitives as admin and field (.rail-*, .strip-*, .tab — the admin shell's
   mobile structural selectors above are comma-extended); only the containers
   differ, so all three shells collapse independently ([data-customer-rail] vs
   [data-admin-rail]/[data-field-rail], js/adminRail.js). The customer rail
   matches the field (subcontractor/employee) rail's slate-700 (#334155),
   rather than admin's slate-800 or a portal-specific accent.
   ============================================================================ */
.customer-shell {
    display: flex;
    align-items: stretch;
    min-height: 100vh;
    background-color: var(--light-bg);
}

.customer-rail {
    flex: 0 0 232px;
    width: 232px;
    position: sticky;
    top: 0;
    align-self: flex-start;
    height: 100vh;
    display: flex;
    flex-direction: column;
    background-color: #334155; /* slate-700 — matches the field/subcontractor rail */
    color: rgba(255, 255, 255, 0.82);
    box-shadow: 2px 0 8px rgba(0, 0, 0, 0.18);
    z-index: 1030;
    transition: flex-basis 0.18s ease, width 0.18s ease;
}

/* Collapsed: icons only. Word labels (.rail-label) hide; the rail narrows. */
[data-customer-rail="collapsed"] .customer-rail {
    flex-basis: 64px;
    width: 64px;
}
[data-customer-rail="collapsed"] .customer-rail .rail-label { display: none; }
[data-customer-rail="collapsed"] .customer-rail .rail-collapse i { transform: scaleX(-1); }
[data-customer-rail="collapsed"] .customer-rail .rail-sec {
    padding: 0;
    margin: 0.5rem 0.8rem;
    border-top: 1px solid rgba(255, 255, 255, 0.1);
}
[data-customer-rail="collapsed"] .customer-rail .rail-link {
    justify-content: center;
    padding: 0.5rem 0;
    gap: 0;
    border-left-width: 0;
}
[data-customer-rail="collapsed"] .customer-rail .rail-link.active { box-shadow: inset 3px 0 0 #7f77dd; }

.customer-content-col {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
}
.customer-strip {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.35rem 1rem;
    min-height: var(--shell-strip-h);
    background: var(--surface);
    border-bottom: 1px solid var(--border-color);
    position: sticky;
    top: 0;
    z-index: 1020;
}
.customer-main { flex: 1 1 auto; }

/* CUSTOMER badge — same shape as ADMIN (bg-danger) / SUB-EMPLOYEE (bg-warning). */
.bg-customer { background-color: #6d28d9 !important; color: #fff; }

@media (max-width: 767.98px) {
    /* Tab bar / drawer structural rules are shared with the admin shell's
       mobile block above (comma-extended there). The shell/rail/strip
       overrides must live HERE, after this section's base rules — the admin
       media block sits before them in the file, so at equal specificity its
       copies of these would lose the cascade. */
    .customer-shell { display: block; }
    .customer-rail { display: none; }
    .customer-strip { padding: 0.25rem 0.5rem; gap: 0; } /* 44px buttons; see .admin-strip */

    .customer-drawer { background: #334155; } /* slate-700 — matches the field/subcontractor rail */
}

/* ============================================================================
   FILTER CONTROLS — MudBlazor inputs used as page filters
   ============================================================================ */

/* Long labels on a dense outlined picker get clipped: the resting label sits
   over the input area, and the calendar + clear adornments eat ~56px of it, so
   "Estimate Date From" rendered as "Estimate Date" with the rest under the
   icons. Shrinking the resting label buys the room back without shortening the
   wording — the label still has to say WHICH date, since these filter the
   estimate's own date while "Recently entered" filters when it was keyed in.

   Only the resting state is touched. The floated state (.mud-input-label-shrink,
   applied once a value is picked) is already small and correctly placed. */
.filter-dense-label .mud-input-label:not(.mud-input-label-shrink) {
    font-size: 0.8rem;
    /* Match the smaller text's line box to keep it vertically centered. */
    top: 2px;
}

/* ============================================================================
   SHELL FLOOR — phone rules that apply to every page (Docs/MOBILE_FIRST_INTERFACE.md §3.6)

   This block is deliberately small. It carries only the rules that are
   FUNCTIONAL rather than cosmetic — things that are a bug on a phone, not a
   preference — because anything here lands on ~100 routes at once.
   ============================================================================ */

@media (max-width: 767.98px) {
    /* iOS Safari zooms the viewport whenever a control smaller than 16px takes
       focus, and it does not zoom back out on blur. One tap on a filter field
       leaves the user stranded in a magnified layout with the page scrolled
       sideways — so 16px here is a correctness rule, not a type-scale opinion.
       Only the resting control is touched; MudBlazor's floated labels and
       helper text keep their own sizes.

       .form-control / .form-select are listed EXPLICITLY, and that is the whole
       point of this rule rather than a tidy-up. The bare element selectors below
       are specificity (0,0,1); `.form-control, .form-select { font-size: .95rem }`
       at line 543 is (0,1,0) and beats every one of them. So from the day this
       rule shipped it protected MudBlazor inputs (.mud-input-root is a class, and
       ties are broken by source order in this block's favour) and silently did
       nothing for Bootstrap ones — which is most of the public signing page, the
       whole field portal, and every message composer.

       Measured on /admin/messages/9 at 375px before the fix: the composer's
       select and textarea both computed 15.2px, i.e. the zoom-and-stay-zoomed bug
       was live on the most phone-native surface in the product. Round 1 recorded
       this check as passing because the page it verified (All Work) is built from
       Mud inputs. */
    input:not([type="checkbox"]):not([type="radio"]),
    select,
    textarea,
    .form-control,
    .form-select,
    .mud-input-root {
        font-size: 16px;
    }

    /* A row of controls too wide for a phone — segmented tabs, filter chips —
       scrolls sideways inside its OWN track. Without this the row drags the
       whole page with it, and a page that scrolls horizontally makes every
       sticky element drift out from under the user's thumb.

       The right-edge fade is not decoration: a chip clipped at a hard border
       reads as a broken layout, while a faded one reads as "there's more". */
    .scroll-x-track {
        max-width: 100%;
        min-width: 0;
        overflow-x: auto;
        scrollbar-width: none;
        -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
        mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
    }

    .scroll-x-track::-webkit-scrollbar {
        display: none;
    }

    /* ── All Work chrome ──────────────────────────────────────────────────
       The list put 677px above the first card on a 375px phone — 83% of the
       viewport, on the most-visited page in the product. You arrived at a
       screen of filters and had to scroll to reach any work.

       Two rows caused most of it. Both are flex-wrap on desktop, where they
       sit on one line each; at 375px they wrapped to three and four lines
       (156px and 147px). Every control in them was also 31px tall — so
       raising them to the 44px floor by itself would have made the problem
       worse. Height and reach had to be solved together, and the answer is
       the same one already used for the stage tabs: stop wrapping, scroll.

       These live here rather than in WorkOrderList.razor.css because the rows
       hold MudBlazor components, and Blazor's scoped CSS never reaches inside
       a child component's markup. */

    /* Two lines: search and Recently-entered share the first, the stage tabs
       take the whole of the second.

       The tabs get the full width because they are the page's primary
       segmentation and the track does not auto-scroll to the active one —
       squeezed into the leftover 122px beside Recently-entered, a list
       filtered to Paid showed "ALL ESTIMAT…" and no indication of which tab
       was live. Search gives up the width instead; it is self-evidently a
       search box at any size, and its placeholder is the only casualty. */
    .wo-search-row > .mud-input-control { flex: 1 1 0; min-width: 0; }
    .wo-search-row > .scroll-x-track { flex: 1 1 100%; }

    /* gap-3 is 16px between every pair — fine when the row is one line on a
       desktop, but here it also sets the space between the two stacked lines,
       where it reads as a hole. */
    .wo-search-row { gap: 0.5rem !important; }

    /* Mud uppercases button labels. At 375px that is width the row does not
       have — "Recently entered" is 25px wider shouting than speaking, and it
       takes those 25px straight out of the search box beside it. Sentence
       case also reads better at this size. */
    .wo-search-row .mud-button-root,
    .wo-filter-row .mud-button-root { text-transform: none; }

    /* One line, scrolling. Applied to all three chip rows on the page — the
       presets, the Estimates-tab status sub-filter, and the active-filter
       chips — because they are the same shape and fail the same way; the
       Estimates row alone was 108px over three lines. Bootstrap's .flex-wrap
       is !important, so this has to be too. */
    .wo-filter-row {
        flex-wrap: nowrap !important;
        overflow-x: auto;
        scrollbar-width: none;
        -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
        mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
    }

    .wo-filter-row::-webkit-scrollbar { display: none; }

    /* A flex-grow spacer inside a scrolling track is a rubber band: it either
       absorbs the free space that should let the row scroll, or stretches the
       track past its content. On desktop it still pushes Deleted and More
       filters to the right edge. */
    .wo-filter-row .mud-spacer { display: none; }

    /* Buttons in a nowrap track will happily shrink their labels to nothing. */
    .wo-filter-row > * { flex-shrink: 0; }

    /* The touch floor, applied to the rows as a whole rather than to the
       controls that happened to be edited — the Dashboard's 20px "See all"
       survived two passes precisely because the check was scoped to a diff. */
    .wo-search-row .mud-button-root,
    .wo-filter-row .mud-button-root { min-height: 44px; }

    /* Search is the widest and most-used control in the row, so it gets the
       floor too — 40px is Mud's dense default. The min-height goes on the
       input *container*: put it on .mud-input-root and Mud's own padding
       stacks on top, which took the control to 65px. */
    .wo-search-row .mud-input-control-input-container { min-height: 44px; }

    /* The two remaining strays above the card list. + New is a Bootstrap
       button (34px) and the view links are bare anchors (29px) — neither
       inherits anything from the Mud rules above. */
    .wo-page-header .page-header-actions .btn { min-height: 44px; }

    /* The title's glossary dot renders a 19x20px button. Scoped to this header
       rather than .info-dot-btn globally: most InfoDots sit inline inside a
       field label, where forcing 44px would push the label off its own
       baseline. The general case is worth a pass of its own. */
    .wo-page-header .info-dot-btn {
        min-width: 44px;
        min-height: 44px;
    }

    .work-views a {
        display: inline-flex;
        align-items: center;
        min-height: 44px;
    }

    /* Subtitle drops out under md; see the note in Workorders.razor. Scoped to
       this page's wrapper rather than .page-header-subtitle globally, because
       the other five PageHeader pages have not been measured for it. */
    .wo-page-header .page-header-subtitle { display: none; }

    /* …and with the subtitle gone the title is one short line, so + New fits
       beside it. The global rule at 576px stacks them, which was the right
       call while there were two lines of copy for the button to clear. */
    .wo-page-header .page-header {
        flex-direction: row;
        align-items: center;
        /* One line of title needs less air beneath it than three lines of
           title-plus-copy did. */
        margin-bottom: 0.75rem;
        padding-bottom: 0.5rem;
    }

    /* The All Work card's overflow menu. WorkOrderRowActions renders a
       Size.Small MudIconButton — 26px, which is fine as a grid cell and far
       under the 44px a thumb needs. This lives here rather than in
       WorkOrderList.razor.css because Blazor's scoped CSS never reaches inside
       a child component's markup. */
    .wo-card-top-right .mud-icon-button {
        min-width: 44px;
        min-height: 44px;
    }

    /* Anything that docks itself to the bottom of a phone screen sits ON TOP of
       the tab bar unless it clears both the bar and the home indicator. Pages
       opt in by adding .docked-bottom rather than re-deriving the offsets.

       The z-index matters as much as the offset: the tab bar is 1035 and the
       sticky top strip is 1020, so a docked bar belongs between them. A bar at
       `top: 0` parks UNDER the strip and stops receiving clicks entirely —
       which reads as a dead button, not a hidden one. */
    .docked-bottom {
        position: fixed;
        left: 0;
        right: 0;
        bottom: calc(var(--shell-tabbar-h) + env(safe-area-inset-bottom));
        z-index: 1030;
        display: flex;
        gap: 0.5rem;
        padding: 0.5rem 0.75rem;
        background: var(--surface);
        border-top: 1px solid var(--border-color);
        /* Both consumers carry mb-4 for the desktop sticky variant, where the
           bar is in flow and needs to clear the content under it. Fixed, that
           margin just floats the bar 24px higher than `bottom` says — pushing
           it past the host's reservation and back over the last field. Beats
           Bootstrap's .mb-4, which is itself !important. */
        margin-bottom: 0 !important;
    }

    /* The install hint is not .docked-bottom (it is a floating pill, not a bar
       spanning the viewport), but it has the identical job to do against the
       identical two terms — so it derives from them here rather than carrying
       its own copy of the arithmetic. */
    .ios-install-hint {
        bottom: calc(var(--shell-tabbar-h) + env(safe-area-inset-bottom) + 8px);
    }

    /* MudCardHeader is flex-nowrap with flex-shrink:0 actions, so a header whose
       buttons total more than the card is wide simply pushes past the viewport
       and takes the whole page's X axis with it — the work-order editor's
       "WO #… / Add to project… / Job Hub" row overflowed 375px by 155px. Letting
       it wrap costs nothing on headers that already fit. */
    .mud-card-header {
        flex-wrap: wrap;
    }

    /* Scoped through .mud-card-header on purpose — MudBlazor's own
       flex-shrink: 0 is written at that specificity and beats a bare
       .mud-card-header-actions. */
    .mud-card-header .mud-card-header-actions {
        flex-shrink: 1;
        min-width: 0;
    }

    /* An InfoDot beside a control or a heading turns that row into a flex
       container, and flex children default to min-width:auto — their *content*
       width. So the row refuses to shrink and takes the page's X axis with it.
       Both halves of the pattern need saying: the heading may wrap, and the
       input may be narrower than its content wants. */
    .mud-card-header .mud-typography.d-inline-flex {
        flex-wrap: wrap;
    }

    .mud-input-control {
        min-width: 0;
    }

    /* Whatever a page docks down here is, by definition, its primary action —
       so it gets the touch floor whether or not the page remembered to ask.
       Both button vocabularies are covered: pages built on Bootstrap use .btn,
       pages built on MudBlazor render .mud-button-root, and a Size.Small Mud
       button comes in at 31px on its own. */
    .docked-bottom .btn,
    .docked-bottom .mud-button-root {
        min-height: 44px;
    }

    /* The scroll container of a page using .docked-bottom, so its last control
       isn't trapped behind the bar.

       This mirrors the bar's own offset above — tab bar, home indicator, then
       the bar itself — so the two cannot drift apart. Every term is the same
       one the bar positions against:

       --docked-bar-h is measured and published by js/dockedBar.js, because the
       bar's height is not knowable in CSS. It replaces a flat 4rem, which
       silently assumed a single row: Customers' new-contact bar wrapped to two
       (113px) and buried the last 65px of the form. The measured value also
       reports 0 when a page renders the host with no bar, so that case reserves
       nothing instead of leaving an empty gap. §12.

       env(safe-area-inset-bottom) was missing here while the bar has always
       included it, so on any device with a home indicator this was ~34px short
       of the bar regardless of rows — the two-row case simply made it visible
       on a simulator that has no inset at all.

       The 4rem fallback is what applies for the one frame before the script
       runs, and if it never runs: identical to the old behaviour, so a
       scripting failure degrades to where this started rather than to zero. */
    .docked-bottom-host {
        padding-bottom: calc(
            var(--shell-tabbar-h)
            + env(safe-area-inset-bottom)
            + var(--docked-bar-h, 4rem)
            + 0.5rem
        );
    }
}

@media (min-width: 768px) {
    /* Desktop behaviour is OPT-IN via .sticky-desktop. A short form whose Save
       is already on screen at desktop widths wants no change at all up here —
       promoting its buttons to a sticky header would be a desktop regression
       dressed up as a mobile fix. Only long editors should ask for this. */
    .docked-bottom.sticky-desktop {
        position: sticky;
        top: var(--shell-strip-h);
        z-index: 1030;
        display: flex;
        gap: 0.5rem;
        padding: 0.5rem 0.75rem;
        background: var(--surface);
        border-bottom: 1px solid var(--border-color);
    }
}

/* ============================================================================
   RECORD CARD LIST — Pattern A chrome (Docs/MOBILE_FIRST_INTERFACE.md §3.3)

   A MudDataGrid has no card fallback, so pages that need one render both trees
   and let CSS pick. The *content* of a card is per-page and lives in that
   page's .razor.css; what lives here is the furniture every one of them needs
   and none of them should re-derive: the sort bar that stands in for the column
   headers, the card shell, and the pager that stands in for MudDataGridPager.

   These carry no media query on purpose — the container above them carries
   d-md-none, so the whole tree is already absent at desktop widths. Adding a
   breakpoint here would just be a second, silently divergent copy of that one.

   The 44px minimums are the Apple HIG floor and are not decorative: every
   control in this block is something a user taps one-handed, outdoors.
   ============================================================================ */

.rec-cards-sort {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.6rem;
}

.rec-cards-sort label {
    font-size: 0.75rem;
    color: var(--muted-text);
    white-space: nowrap;
    margin: 0;
}

/* 16px, not the .form-select-sm default: iOS Safari zooms the viewport when a
   sub-16px control takes focus and does NOT zoom back out on blur, so one tap
   on the sort control would leave the user stuck in a magnified layout. */
.rec-cards-sort .form-select {
    font-size: 16px;
    min-height: 44px;
    flex: 1 1 auto;
}

.rec-cards-rev {
    flex: 0 0 auto;
    min-width: 44px;
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--surface);
    color: var(--dark-text);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
}

.rec-card {
    position: relative;
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    box-shadow: var(--shadow-xs);
    padding: 0.6rem 0.7rem;
    margin-bottom: 0.5rem;
    cursor: pointer;

    /* Works as either a <div role="button"> (All Work, Contacts) or a real
       <button> (the message inbox). The four declarations below are what a
       <button> needs to stop looking like a form control, and every one of them
       is already the default for a block div — so this is additive for the two
       existing consumers and byte-identical in their rendering.

       A real <button> is the better element: role="button" + tabindex="0"
       announces itself correctly but does NOT activate on Enter or Space, and
       neither existing card list adds a key handler. Those two are worth
       migrating; this makes it a one-line change when someone does. */
    display: block;
    width: 100%;
    text-align: left;
    font: inherit;
    color: inherit;

    /* Browsers set `align-items: center` on <button> in the UA stylesheet. It is
       inert while the card is display:block, and takes over the moment a card's
       content class makes it a flex column — which centred every line of the
       message cards while text-align stayed dutifully "left". Worth stating
       because the symptom points at the wrong property: the computed text-align
       is correct and the layout still isn't. */
    align-items: stretch;
}

.rec-card:active {
    background: var(--light-bg);
}

.rec-cards-pager {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    font-size: 0.78rem;
    color: var(--muted-text);
    padding: 0.5rem 0 1rem;
}

.rec-cards-page {
    min-width: 44px;
    min-height: 44px;
    background: var(--surface);
    color: var(--dark-text);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
}

.rec-cards-page:disabled {
    opacity: 0.45;
}

/* ============================================================================
   SHELL-LESS LAYOUTS — SigningLayout (mobile-first-plan2.md §3.4)

   The docked-bar primitive above positions itself against two shell heights,
   and .docked-bottom-host reserves scroll space from the same two terms. That
   is what keeps the bar and the reservation from drifting (§13 of round 1).

   SigningLayout has neither shell: no sticky strip, no bottom tab bar. So the
   public /sign and /pay pages zero both tokens for their own subtree, and the
   bar lands on the viewport edge with the reservation following it — without
   either .docked-bottom or .docked-bottom-host learning that a shell-less
   layout exists. Overriding the primitive directly instead would have meant
   two places to keep in step, which is the bug the measured height replaced.
   ============================================================================ */

.signing-page {
    --shell-strip-h: 0px;
    --shell-tabbar-h: 0px;

    /* Replaces Bootstrap's .bg-light, which pinned a near-white that ignores
       data-bs-theme — the public signing page rendered a dark card on a light
       sheet in dark mode. */
    background: var(--light-bg);
}

.signing-container {
    max-width: 820px;
}

/* ── Public signing page (mobile-first-plan2.md §3.4) ────────────────────────
   The signature surface. Two separate defects lived here, and both were
   invisible on a desktop:

   1. The typed-signature preview drew #1a1a2e — a hardcoded near-black — on a
      card that goes dark with the theme. Measured 1.09:1. The customer could not
      see their own signature before committing to it.

   2. The <canvas> was authored 760x200 and displayed at width:100%, i.e. 301px
      on a phone. That is a 2.52x downscale, so the 2.5px stroke painted at 0.99
      CSS px — a hairline — inside an 81px-tall signing box. The pointer maths in
      signature-pad.js was always correct; the geometry was not.

   The canvas is now sized from its own box at devicePixelRatio by the script,
   so this only has to own the box. */

.sig-pad {
    display: block;
    width: 100%;
    height: 180px;
    /* Without this the browser treats a drag on the canvas as a page scroll and
       the signature comes out as a dot. */
    touch-action: none;
    /* Ink is dark, so the pad stays a paper-white sheet in both themes; the
       border is what has to flip to keep it from floating. */
    background: #fff;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
}

@media (min-width: 768px) {
    .sig-pad { height: 200px; }
}

.sig-typed {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 110px;
    padding: 0.5rem 1rem;
    background: #fff;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
}

.sig-typed span {
    font-family: 'Segoe Script', 'Brush Script MT', cursive;
    font-size: 2.4rem;
    line-height: 1.2;
    /* Explicitly the same ink the canvas and the rendered PNG use, on the same
       white sheet — so what the customer previews is what gets stored. */
    color: #1a1a2e;
    word-break: break-word;
}

/* Placeholder ink. Fixed, like the signature ink above, because the sheet is
   always white — but dark enough to read: the first value here was #9aa3b4,
   which is 2.54:1 on white. This is 7.5:1 and still clearly lighter than the
   signature itself. */
.sig-typed span.is-placeholder {
    color: #4b5563;
}

/* The docked total + "Review & sign" bar. It is deliberately NAVIGATIONAL: it
   carries the number being agreed to and scrolls to the signing card, while the
   binding Sign & Accept stays below the ESIGN consent checkbox. A one-way legal
   action does not belong under the thumb from first paint. */
.sign-bar-total {
    line-height: 1.15;
    white-space: nowrap;
}

.sign-bar-total b {
    display: block;
    font-size: 1.05rem;
}

/* Two app-wide colour defaults are corrected HERE rather than globally, because
   this is the page where an untrained stranger makes a binding, revenue-bearing
   decision, and neither correction should ride along inside a phase about the
   signing page. Both are real beyond this page and are recorded as such:

     .btn-success  — white on --success-color measures 3.26:1 app-wide. Every
                     confirm button in the product has this problem.
     .nav-link     — Bootstrap colours nav links with the brand indigo, which is
                     a FILL colour; as text on --surface it measures 2.92:1. Same
                     root cause as .btn-outline-primary, fixed globally earlier.

   And one rule about the tokens themselves, learned by getting it wrong here:
   an --x-ink token is TEXT ON A SOFT TINT. It flips to a LIGHT value in dark
   mode, so using one as a solid background under white text makes contrast
   worse, not better — this block measured 1.74:1 when it first tried exactly
   that. Solid brand fills are theme-independent by design (the same way
   .bg-warning's amber is), so fills below are fixed values chosen to carry white
   text, and ink tokens are used only where they belong: as text. */
/* Second consumer added in Phase 3: .sub-job-bar is the subcontractor job
   detail's docked action bar, whose Accept work button is green and is the
   single most important control on that screen. Measured 3.30:1 there before
   this rule. Extended rather than copied so the hex has one home — but the fact
   that this selector list is now growing is the argument for fixing
   .btn-success app-wide, which stays deliberately out of a page-scoped phase. */
.signing-page .btn-success,
.sub-job-bar .btn-success,
.sub-job-dense .btn-success {
    /* green-700. White on --success-color (#16a34a) is 3.26:1; on this, 5.05:1.
       Fixed rather than tokenised because a solid fill under white text must not
       flip — that is what went wrong the first time. */
    background-color: #15803d;
}

/* Inactive pill: ink token as TEXT on the card surface. Correct usage — it
   flips, and both sides of the comparison flip together.

   Second consumer, Phase 3: the lien-waiver Type/Draw switch on the sub's job
   detail is the same Bootstrap nav-pills control and measured the same 2.92:1
   in dark mode. Both sides of one signing act now read the same. */
.signing-page .nav-pills .nav-link,
.sub-job-dense .nav-pills .nav-link {
    color: var(--primary-ink);
}

/* Active pill: a solid fill, so --primary-color (which does not flip) under
   white text — 5.22:1 on the signing page, 5.37:1 on the job detail. */
.signing-page .nav-pills .nav-link.active,
.sub-job-dense .nav-pills .nav-link.active {
    background-color: var(--primary-color);
    color: #fff;
}

/* Tabs, not pills (Phase 5, §13). Bootstrap colours an inactive
   .nav-tabs .nav-link with --bs-link-color (#0d6efd), which measures 4.20:1 as
   14px text on the admin page background — under the 4.5 floor. Found on the
   Job Hub's two tab strips, which are the only .nav-tabs in the product today:
   grepped, not assumed, after an earlier draft of this comment claimed the
   work-order and customer editors as well. They use MudTabs.

   Written unscoped anyway, unlike the two page-scoped blocks above. Not because
   it reaches other pages now, but because the value is wrong for a tab label in
   any context — so the next .nav-tabs someone adds should inherit the fix
   rather than the bug. .navbar .nav-link (white on the dark bar), .nav-pills,
   and .nav-tabs .nav-link.active all carry their own colour at higher
   specificity and are untouched. */
.nav-tabs .nav-link {
    color: var(--primary-ink);
}

/* The ESIGN disclosure toggle. It is a legally meaningful control that was an
   inline link inside a paragraph, so it inherited the paragraph's line box and
   could not take a touch target without breaking the text flow. Given its own
   line it can. */
.signing-disclosure-toggle {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    font-weight: 600;
    color: var(--primary-ink);
}

@media (max-width: 767.98px) {
    /* Every control on this page is operated once, by a stranger, possibly
       standing in a driveway. All of them take the floor. */
    .signing-page .btn,
    .signing-page .nav-link {
        min-height: 44px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
    }

    /* The consent checkbox gates the signature. The BOX stays 24px — a 44px
       checkbox reads as a broken control — and the row carries the target
       instead, which is what the <label for> makes tappable anyway. */
    .signing-page .form-check {
        display: flex;
        align-items: flex-start;
        gap: 0.6rem;
        min-height: 44px;
        padding-left: 0;
    }

    .signing-page .form-check-input {
        flex: 0 0 auto;
        width: 1.5rem;
        height: 1.5rem;
        margin: 0.15rem 0 0;
    }

    .signing-page .form-check-label {
        flex: 1 1 auto;
    }
}

/* ============================================================================
   MESSAGE THREAD — shared by six surfaces (mobile-first-plan2.md §3.7)

   MessageThreadView renders in the admin inbox, the customer inbox and thread,
   the sub inbox and thread, the sub's job detail, and the customer's work-order
   detail. Its styles used to live in an inline <style> block in the component
   with the bubble backgrounds written as literal hex — #eef5ff and #fff7e6 —
   and no dark-mode counterpart anywhere in the codebase.

   That is the bug this block exists to fix, and it is worth stating precisely
   because it is not "dark mode looks a bit off": .message sets no colour, so
   the body text follows the theme to a light grey while the bubble stayed a
   pinned cream. Measured on /admin/messages/9 at 375x812 in dark mode, the
   message body came out at 1.22:1 against its own bubble — text that is
   present, selectable, and invisible.

   The fix is structural rather than cosmetic: the background becomes a token
   that flips, so it can no longer drift away from the text colour that always
   did. Text deliberately keeps inheriting the body colour instead of taking an
   --x-ink value; ink tokens are for chips and labels, and an amber message body
   would be a styling change nobody asked for on top of a bug fix.
   ============================================================================ */

.thread-messages {
    max-height: 480px;
    overflow-y: auto;
}

.message {
    padding: 0.5rem 0.75rem;
    border-radius: 0.5rem;
    margin-bottom: 0.5rem;
}

/* Direction, not severity: info reads as "the other side", warning as "us".
   Both tints flip with the theme, and body text is left to do the same. */
.message-from-user { background: var(--info-soft); }
.message-from-admin { background: var(--warning-soft); }

/* Sender + timestamp. Derived from the body colour rather than given Bootstrap's
   .text-muted, whose grey is tuned for the page background: on these tints it
   measured 4.34:1 in light mode, under the 4.5 AA floor for small text. Fading
   the body colour instead keeps the "secondary" look and cannot drift from the
   bubble, because both sides of the comparison now flip together. */
.message-meta {
    color: color-mix(in srgb, var(--dark-text) 80%, transparent);
}

@media (max-width: 767.98px) {
    /* A capped, internally-scrolling list inside a page that already scrolls is
       a trap on a phone: the flick is swallowed by the inner list and the page
       reads as frozen. 480px is also 59% of an 812px viewport spent on a
       viewport-within-a-viewport. Let it grow — the page scroll is the only
       scroll, and the composer is reachable via .docked-bottom on the two
       standalone thread routes (see DockComposer). */
    .thread-messages {
        max-height: none;
        overflow-y: visible;
    }

    /* Send is the primary action of this component.

       Written as .btn.thread-send, not .thread-send: the field shell sets
       `.field-main .btn-sm { min-height: 38px }` at (0,2,0), which beats a bare
       class and silently held Send at 38px on the sub portal — the one place
       this component is used by someone standing outside. Specificity is not
       optional when overriding a rule you did not write (round 1 §8 recorded the
       same lesson against MudBlazor).

       Worth flagging beyond this rule: that 38px is the field portal's floor for
       EVERY small button, and 38 < 44. Phase 3 owns it. */
    .btn.thread-send { min-height: 44px; }
}

/* Messaging surfaces: the header/action rows on the three thread pages and the
   two inboxes are all btn-sm (32px). Messaging is the most phone-native task in
   the product — it is what people do walking to the truck — so its controls take
   the floor. Scoped to .msg-actions rather than raising every .btn-sm under md,
   which round 1 §5 declined for good reason: it would silently re-space ~100
   routes for a change nobody asked for. */
@media (max-width: 767.98px) {
    .msg-actions .btn,
    .msg-actions .form-check-input {
        min-height: 44px;
    }

    .msg-actions .btn {
        display: inline-flex;
        align-items: center;
    }

    .msg-actions .form-check-input {
        min-width: 24px;
        width: 24px;
        height: 24px;
    }
}

/* ── Message inbox cards ─────────────────────────────────────────────────────
   Content classes for the phone card list on BOTH inboxes — /admin/messages and
   /portal/contractor/messages. They started as a scoped Messages.razor.css and
   moved here the moment the sub inbox needed them: scoped CSS is per-component,
   so a second consumer would have meant a second copy free to drift. Round 1 §7
   extracted .rec-card for exactly this reason; this is the same call one level
   down.

   No media query — the container carries d-md-none. */

.msg-card {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

.msg-card-top {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.msg-card-name {
    font-weight: 700;
    line-height: 1.25;
    min-width: 0;
}

.msg-card-subject {
    font-size: 0.9rem;
    color: var(--dark-text);
}

/* An unread thread is the reason to open this page. The cue is a border rather
   than Bootstrap's .table-warning row tint, which pins a light amber that does
   not flip for dark mode. */
.msg-card.is-unread {
    border-left: 3px solid var(--primary-color);
}

.msg-card.is-unread .msg-card-subject,
.msg-card.is-unread .msg-card-name {
    font-weight: 700;
}

/* Two lines of preview: one cannot tell apart threads that share a subject,
   three pushes the meta row off the screen. */
.msg-card-preview {
    font-size: 0.82rem;
    color: var(--muted-text);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.msg-card-meta {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.35rem;
    margin-top: 0.25rem;
    font-size: 0.72rem;
    color: var(--muted-text);
}

/* ============================================================================
   VISIT CARD + JUMP CHIPS — Pattern E (mobile-first-plan2.md §3.5)

   The identity block that replaces the page header on a phone — who, when,
   where, and the two links that get you there and get them on the phone — plus
   the chip row that jumps to the sections below it.

   These began as Workorders.razor.css and moved here when the subcontractor's
   job detail (/portal/contractor/{id}) became the second consumer. Same call
   round 1 §7 made for .rec-card: Blazor's scoped CSS is per-component, so the
   alternative was a second copy free to drift from this one. The names keep
   their `wo-` prefix because both consumers are work-order screens.

   No media query: every consumer's container carries d-md-none, which is also
   why these are authored phone-first rather than in app.css's older
   desktop-first max-width style.
   ============================================================================ */

/* ── Visit card ───────────────────────────────────────────────────────────────
   The identity block that replaces the page header on a phone: who, when,
   where, and the two links that get you there and get them on the phone. */

.wo-visit {
    margin-bottom: 1rem;
    padding: 0.9rem 1rem 1rem;
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
}

.wo-visit-kicker {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    text-transform: uppercase;
    color: var(--muted-text);
}

.wo-visit-dot { opacity: 0.6; }

.wo-visit-name {
    margin: 0.15rem 0 0;
    font-size: 1.35rem;
    line-height: 1.2;
    font-weight: 700;
    color: var(--dark-text);
    /* Long business names wrap rather than push the card sideways; two lines is
       enough to recognise a name and short enough to keep the address above the
       fold on an 812px screen. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.wo-visit-org {
    margin-top: 0.1rem;
    font-size: 0.9rem;
    color: var(--muted-text);
}

.wo-visit-when {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin-top: 0.5rem;
    font-size: 0.9rem;
    font-weight: 600;
    color: var(--dark-text);
}

/* Unscheduled is information, not an error — muted and un-bolded so it reads as
   "nothing here yet" rather than competing with the name above it. */
.wo-visit-when.is-unscheduled {
    font-weight: 400;
    color: var(--muted-text);
}

.wo-visit-where {
    display: flex;
    align-items: flex-start;
    gap: 0.5rem;
    margin-top: 0.7rem;
    padding-top: 0.7rem;
    border-top: 1px solid var(--border-color);
    font-size: 0.95rem;
    line-height: 1.4;
    color: var(--dark-text);
}

.wo-visit-where > i {
    margin-top: 0.15rem;
    color: var(--muted-text);
}

.wo-visit-noaddr,
.wo-visit-empty {
    color: var(--muted-text);
    font-size: 0.9rem;
}

.wo-visit-empty {
    margin-top: 0.7rem;
    padding-top: 0.7rem;
    border-top: 1px solid var(--border-color);
}

.wo-visit-access {
    display: flex;
    align-items: flex-start;
    gap: 0.5rem;
    margin-top: 0.6rem;
    padding: 0.5rem 0.6rem;
    border-radius: var(--radius-sm);
    background: var(--warning-soft);
    color: var(--warning-ink);
    font-size: 0.85rem;
    line-height: 1.4;
}

.wo-visit-actions {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    margin-top: 0.8rem;
}

/* Directions is the whole point of the card for someone in a truck, so it gets
   the full width and the only filled treatment on the screen above the Now
   strip's action. */
.wo-visit-go {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    min-height: 48px;
    border-radius: var(--radius-sm);
    background: var(--primary-color);
    color: #fff;
    font-weight: 600;
    text-decoration: none;
}

.wo-visit-go:active { background: var(--primary-hover); color: #fff; }
.wo-visit-go:hover { color: #fff; }

.wo-visit-reach {
    display: flex;
    gap: 0.5rem;
}

.wo-visit-call {
    display: flex;
    flex: 1 1 auto;
    /* Flex children default to min-width:auto, so a long number would refuse to
       shrink and push the Text button off the card. */
    min-width: 0;
    align-items: center;
    gap: 0.5rem;
    min-height: 44px;
    padding: 0 0.75rem;
    border-radius: var(--radius-sm);
    background: var(--primary-soft);
    color: var(--primary-ink);
    font-weight: 600;
    text-decoration: none;
}

.wo-visit-num {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.wo-visit-kind {
    margin-left: auto;
    flex: 0 0 auto;
    font-size: 0.72rem;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    opacity: 0.75;
}

.wo-visit-text {
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    justify-content: center;
    width: 44px;
    min-height: 44px;
    border-radius: var(--radius-sm);
    background: var(--primary-soft);
    color: var(--primary-ink);
    text-decoration: none;
}

.wo-quick {
    margin-bottom: 1rem;
}

/* The chip row is wider than 375px on purpose — it scrolls in its own track
   rather than wrapping to three rows or dragging the page sideways. */
.wo-quick-jumps {
    display: flex;
    gap: 0.4rem;
    margin-top: 0.75rem;
    padding-bottom: 0.15rem;
}

.wo-quick-jump {
    flex: 0 0 auto;
    min-height: 44px;
    padding: 0 0.85rem;
    border-radius: 99px;
    border: 1px solid var(--border-strong);
    background: var(--surface);
    color: var(--dark-text);
    font-size: 0.82rem;
    font-weight: 600;
    white-space: nowrap;
}

.wo-quick-jump:active {
    background: var(--primary-soft);
    color: var(--primary-ink);
    border-color: var(--primary-color);
}
/* ============================================================================
   SUBCONTRACTOR JOB DETAIL — Pattern E applied (mobile-first-plan2.md §10, M2)

   /portal/contractor/{id} and /portal/employee/{id} (ContractorDetail.razor).
   The page reflows fine; what it got wrong was the ORDER. Every write control
   lives in a col-lg-4 "Update Work" card, which below 992px stacks last — under
   the customer card, the work-order card, the ratings, the line items and the
   whole conversation thread. Measured on a real assigned job: Save Changes sat
   3,373px down a 3,616px page, i.e. four and a bit viewport-heights from the
   top, on a screen whose entire purpose is "accept it, then tell the office I'm
   here".

   The fix is not to reorder the DOM (that costs the desktop two-column layout)
   but to put the one action the record is waiting on where the thumb already
   is, and let the chips carry people to the rest.
   ============================================================================ */

/* Targets of the jump chips. --shell-strip-h clears the sticky top strip; the
   extra rem is so the card's header reads as a header rather than as something
   flush against the chrome. Applies at every width: the strip is sticky on
   desktop too. */
.sub-job-anchor {
    scroll-margin-top: calc(var(--shell-strip-h) + 1rem);
}

/* Replaces .sticky-top on the Update Work card.

   Two bugs in one class. `sticky-top` is `top: 0`, which parks the card UNDER
   the sticky strip and stops it receiving clicks — round 1 §3.4's trap, and the
   reason this card carried a z-index high enough to clip the Print menu. And it
   applied at every width, so on a phone (where the card is stacked last and
   full-width) it would stick a 600px form to the top of the screen.

   Now: desktop only, and offset by the strip it has to clear. min-width 992px,
   not 768px, because that is where this page's own col-lg-8 / col-lg-4 grid
   splits — a sticky sidebar is meaningless while the column is full-width. */
@media (min-width: 992px) {
    .sticky-below-strip {
        position: sticky;
        top: calc(var(--shell-strip-h) + 1rem);
        z-index: 1010;
    }
}

@media (max-width: 767.98px) {
    /* The docked bar's two-control form: status picker takes the room, the
       commit button takes what it needs. Both clear 44px via .docked-bottom's
       own floor; the select needs saying because that floor names .btn only. */
    .sub-job-bar {
        align-items: center;
    }

    .sub-job-bar .form-select {
        min-height: 44px;
        flex: 1 1 auto;
        min-width: 0;
    }

    .sub-job-bar .btn {
        flex: 0 0 auto;
    }

    /* The lien-waiver text and the quick-action forms are the densest controls
       on the page: btn-sm, form-select-sm and form-control-sm, ~31px each,
       against a 44px floor for gloved hands. Scoped to this page's own wrapper
       rather than raised globally — round 1 §5 declined a blanket .btn-sm rule
       and that decision stands. */
    .sub-job-dense .form-select-sm,
    .sub-job-dense .form-control-sm,
    .sub-job-dense .btn-sm {
        min-height: 44px;
    }

    /* The waiver's Type / Draw switch. .nav-link is not a .btn, so the field
       shell's button floor above does not reach it — it measured 38px, and
       picking the wrong one of two adjacent 38px targets means signing with a
       blank canvas. Same rule the signing page needed in Phase 2. */
    .sub-job-dense .nav-pills .nav-link {
        min-height: 44px;
        display: inline-flex;
        align-items: center;
    }

    .sub-job-dense .form-check-input {
        min-width: 24px;
        width: 24px;
        height: 24px;
        margin-top: 0.15rem;
    }

    /* Give the radio/checkbox rows a thumb-sized target without inflating the
       box itself, the same shape the signing page uses. */
    .sub-job-dense .form-check {
        display: flex;
        align-items: center;
        gap: 0.5rem;
        min-height: 44px;
        padding-left: 0;
    }

    .sub-job-dense .form-check-label { margin-bottom: 0; }
}

/* The required-field marker beside "Type your full legal name to sign".
   Bootstrap's .text-danger is --danger-color, a FILL red, which as text on the
   dark card measured 3.25:1. --danger-ink is the token built to be read on a
   surface and it flips. Same soft/ink distinction as everything else here;
   scoped to this page rather than corrected app-wide for the same reason
   .btn-success is. */
.sub-job-dense .text-danger {
    color: var(--danger-ink) !important;
}

/* The waiver's own text, which the sub is about to sign. Was inline-styled with
   `background:#fff`, a fixed white that does not flip — the same class of defect
   as the message bubbles in Phase 1 and .bg-light on the signing layout in
   Phase 2, and here it sat under the one document on the page with legal
   consequences. The cap stays at both widths: this is a scroll region the
   reader is meant to recognise AS the document, and unlike a message list it is
   finite and short. */
.waiver-body {
    max-height: 320px;
    overflow: auto;
    white-space: pre-wrap;
    background: var(--surface);
    color: var(--dark-text);
}

/* ============================================================================
   PIPELINE GROUP ACCENTS — the readable half of the pair
   (mobile-first-plan2.md §12; the decorative half stays in PipelineStage.cs)

   The six funnel accents are saturated mid-tones, chosen to read as a brand on a
   white card. As BORDERS and DOTS they are right in both themes. As TEXT they
   were not: measured on /admin/pipeline, five of six failed AA in dark mode and
   one in light —

     Lead 8.14 light / 1.90 dark      Production 4.85 / 3.18
     Estimate 2.16 / 7.15             Invoiced 6.86 / 2.25
     Won 5.13 / 3.01                  Closeout 4.82 / 3.20

   — at 11.5px, where the floor is 4.5:1. One hex cannot fix this: light mode
   needs the colour darker and dark mode needs it lighter, so the value has to
   flip. Same soft/ink split every semantic colour above already has, and the
   same lesson Phase 2 learned by using an ink token as a fill: a token is either
   a fill or a text colour, never both.

   Where a brand hex already passes it is kept, so this changes as little of the
   page's look as the contrast floor allows — in light mode only Estimate moves.

   Consumed via PipelineGroupInfo.AccentInk, which resolves to
   `var(--pipeline-<group>-ink, <the brand hex>)`: a group whose token is missing
   degrades to today's colour rather than to none. The enum names below are the
   contract — renaming a PipelineGroup silently drops it back to the fallback.
   ============================================================================ */

:root {
    --pipeline-lead-ink: #8C2F39;        /*  8.14:1 on white — the brand hex, unchanged */
    --pipeline-opportunity-ink: #8a5a00; /*  5.93:1 — the one light-mode failure (was 2.16) */
    --pipeline-won-ink: #2E7D32;         /*  5.13:1 — unchanged */
    --pipeline-production-ink: #1E6FD9;  /*  4.85:1 — unchanged */
    --pipeline-invoiced-ink: #7B3FA0;    /*  6.86:1 — unchanged */
    --pipeline-closeout-ink: #0E8074;    /*  4.82:1 — unchanged */
    /* The off-ramp's #6C757D already passes at 4.69:1, so this exists only to
       complete the set — nothing paints off-ramp text with it today (the row
       uses --bs-secondary-color, and the step dot is decorative). */
    --pipeline-offramp-ink: #6C757D;     /*  4.69:1 — unchanged */
}

/* Ratios quoted against --surface (#1e2330). Every value here also clears 4.5:1
   against --bs-body-bg (#212529), which is what the funnel cards painted on
   before this phase — so the fix does not depend on that change landing too. */
[data-bs-theme="dark"] {
    --pipeline-lead-ink: #fca5a5;        /*  8.27:1 (was 1.90) */
    --pipeline-opportunity-ink: #E8A33D; /*  7.28:1 — the brand hex, unchanged */
    --pipeline-won-ink: #86efac;         /* 11.18:1 (was 3.01) */
    --pipeline-production-ink: #93c5fd;  /*  8.70:1 (was 3.18) */
    --pipeline-invoiced-ink: #d8b4fe;    /*  8.88:1 (was 2.25) */
    --pipeline-closeout-ink: #5eead4;    /* 10.61:1 (was 3.20) */
    --pipeline-offramp-ink: #adb5bd;     /*  7.56:1 */
}

/* ============================================================================
   MUDBLAZOR TEXT CHIPS — the same fill-used-as-text bug, one layer up
   (mobile-first-plan2.md §12)

   Variant.Text paints the chip's label with the palette's colour for that tone.
   Those values are FILL colours — the same ones a Variant.Filled chip puts white
   text on — so used as 12px text on a near-white chip they land under AA:

     Sent (info) 3.43:1     Approved (success) 3.09:1     Complete 3.09:1

   Measured on /admin/pipeline, where the stage chip is the most informative
   thing on a job card. It is not a phone bug — it is wrong at every width, on
   every text chip in the product.

   This is the third time this plan has met the same mistake: --x-ink tokens
   exist precisely because a colour that works as a fill does not work as text.
   Fixed here rather than per-page because a text chip is BY DEFINITION text on a
   surface, so there is no context where the fill value is the right answer —
   which is what makes a global rule safe here and not for .btn-success, whose
   filled variant is legitimately a fill.

   Filled chips are untouched: they carry white on the fill and are correct.
   .mud-chip-color-default is untouched too — it already inherits body text.
   ============================================================================ */

.mud-chip.mud-chip-text.mud-chip-color-info { color: var(--info-ink); }
.mud-chip.mud-chip-text.mud-chip-color-success { color: var(--success-ink); }
.mud-chip.mud-chip-text.mud-chip-color-warning { color: var(--warning-ink); }
.mud-chip.mud-chip-text.mud-chip-color-error { color: var(--danger-ink); }

/* Outlined chips, added in Phase 5 (§13). Same bug, one variant across: the Job
   Hub's pipeline-value chip is Variant.Outlined and measured 3.08:1 — within
   0.01 of the text chips this block was written for. The border takes the ink
   too, which darkens it from 3.08 to well past the 3:1 that a UI component's
   boundary needs. */
.mud-chip.mud-chip-outlined.mud-chip-color-info { color: var(--info-ink); border-color: var(--info-ink); }
.mud-chip.mud-chip-outlined.mud-chip-color-success { color: var(--success-ink); border-color: var(--success-ink); }
.mud-chip.mud-chip-outlined.mud-chip-color-warning { color: var(--warning-ink); border-color: var(--warning-ink); }
.mud-chip.mud-chip-outlined.mud-chip-color-error { color: var(--danger-ink); border-color: var(--danger-ink); }

/* And the tint under a text chip. MudBlazor paints Variant.Text with a 6% wash
   of the FILL colour, which is composited against whatever is behind the chip —
   so the ink above is measured against a surface that moves. Phase 4 verified
   these on /admin/pipeline, where the chip sits on --surface (#fff) and Complete
   lands at 4.54:1. On the Job Hub the same chip sits on the admin page
   background (#f6f7fb) and the same ink lands at 4.40:1: a fix with a tenth of
   a point of headroom, passing or failing according to the card underneath it.
   Dropping the wash puts the ink back on a known surface. It is 6% of an alpha —
   removing it is not a visual change anyone can point to, and it is the
   difference between a rule that holds and one that holds where it was tested. */
.mud-chip.mud-chip-text { background-color: transparent; }

/* The filled-secondary chip's 2.56:1 in dark mode used to be patched here. It is
   now fixed at the source — PaletteDark.SecondaryContrastText in
   HorizonMudProviders.razor — along with the six other accents that failed the
   same way for the same reason. This override was correct and far too narrow: it
   treated one chip tone as the exception when white-on-lightened-accent was the
   rule across the whole dark palette.

   Left as a comment rather than deleted silently, because "why is there no
   dark-mode chip rule here?" is a reasonable question to have an answer to. */

/* ============================================================================
   RESPONSIVE DISPLAY UTILITIES — restoring Bootstrap's breakpoints
   (mobile-first-plan2.md §12)

   MudBlazor ships its OWN .d-{bp}-{value} utilities under the same class names
   as Bootstrap's, at different breakpoints, and MudBlazor.min.css loads second:

              Bootstrap   MudBlazor    dead band
       sm       576px       600px      576-599
       md       768px       960px      768-959
       lg       992px      1280px      992-1279

   Both libraries write these with !important at identical specificity, so the
   later sheet wins. That makes `d-none d-md-block` resolve like this at 800px:

       .d-none          (MudBlazor, unconditional)  -> display: none  ← wins
       .d-md-block      (Bootstrap, >=768px)        -> display: block
       .d-md-block      (MudBlazor, >=960px)        -> not matched yet

   The element stays hidden. Its `d-md-none` counterpart is hidden too, by
   Bootstrap's rule which DOES match at 768. So a page using the two together —
   which is round 1's Pattern A, and every card-list page built on it — renders
   NEITHER presentation between 768px and 959px. Verified on /admin/pipeline and
   on /admin/workorders, a round 1 page: at 768px both are 1,024px tall and show
   nothing but chrome.

   This is why it went unnoticed: phones are under 768 and laptops are over 960,
   so the gap sits exactly where nobody tests — a 768x1024 tablet in portrait,
   and a half-screen browser window.

   app.css loads after MudBlazor, so re-declaring Bootstrap's versions here is
   enough. Nothing is invented: these are Bootstrap's own rules at Bootstrap's
   own breakpoints, which is what every author of these classes in this codebase
   already believed they were getting.

   Adding a new .d-{bp}-* class the app has not used before? Add it here too, or
   it inherits the same gap.
   ============================================================================ */

@media (min-width: 576px) {
    .d-sm-none { display: none !important; }
    .d-sm-inline { display: inline !important; }
    .d-sm-inline-block { display: inline-block !important; }
    .d-sm-block { display: block !important; }
    .d-sm-flex { display: flex !important; }
    .d-sm-inline-flex { display: inline-flex !important; }
}

@media (min-width: 768px) {
    .d-md-none { display: none !important; }
    .d-md-inline { display: inline !important; }
    .d-md-inline-block { display: inline-block !important; }
    .d-md-block { display: block !important; }
    .d-md-flex { display: flex !important; }
    .d-md-inline-flex { display: inline-flex !important; }
}

@media (min-width: 992px) {
    .d-lg-none { display: none !important; }
    .d-lg-inline { display: inline !important; }
    .d-lg-inline-block { display: inline-block !important; }
    .d-lg-block { display: block !important; }
    .d-lg-flex { display: flex !important; }
    .d-lg-inline-flex { display: inline-flex !important; }
}

/* MudTablePager's toolbar is a nowrap flex row ("Rows per page: 25 · 1-25 of
   318 · ‹ ›"), which needs ~537px. The admin rail takes ~264px, so in a 768px
   window the content column is 478px and the pager pushed the whole PAGE 30px
   sideways — the table body itself scrolls inside .mud-table-container and was
   never the problem.

   Only reachable since the display-utility fix above started rendering these
   tables between 768 and 959px at all; before that the band showed nothing.

   MudBlazor already wraps this toolbar — but only under `max-width: 416px`,
   a threshold that assumes the toolbar's width tracks the VIEWPORT. Here it
   does not: the window is 768px and the toolbar is 478px, so the media query
   never fires and the row overflows instead. Keying off the container rather
   than the viewport is what fixes it, so the rule is unconditional; its three
   groups are all flex-shrink: 0, so they need somewhere to go rather than
   permission to squeeze, and at widths where they already fit, wrapping does
   nothing.

   The class is mud-table-pagination-TOOLBAR. `mud-table-pagination` is a
   different element — an outer inline wrapper — and a rule aimed at it silently
   matches the wrong node and appears to do nothing. */
.mud-toolbar.mud-table-pagination-toolbar {
    flex-wrap: wrap;
    row-gap: 0.25rem;
    justify-content: flex-end;
}
