/* =====================================================================
   FLUXI Tenant Dashboard — shared design tokens & component styles.
   Tokens extracted from src/frontend/landing/index.html (light mode)
   and src/frontend/portal/index.html (dark mode, real toggle mechanism).
   See docs/design/tenant-dashboard-visual-system.md for the extraction
   discipline this file follows. Loaded via plain <link> — no build step.

   v2 (Dashboard Polish Phase 0): component execution upgraded per the
   frontend-design skill — same locked palette, same class names. New
   opt-in pieces: .fx-selectw/.fx-datew (JS-enhanced custom select &
   date-picker, auto-mounted by dashboard.js), .fx-switch, .fx-modal,
   .fx-cta-dock (mobile thumb-zone CTA), .fx-reveal, .fx-eyebrow.
   See docs/design/tenant-dashboard-component-upgrade-spec.md.
   ===================================================================== */

:root {
    --fx-bg: #F8FAFC;              /* slate-50  — landing body bg + portal light canvas */
    --fx-surface: #FFFFFF;          /* landing .fx-card */
    --fx-surface-inner: #F1F5F9;    /* slate-100 — nested tiles inside a card */
    --fx-border: #E2E8F0;           /* slate-200 — landing .fx-card border */
    --fx-text: #0F172A;             /* slate-900 — landing body color */
    --fx-muted: #64748B;            /* slate-500 — landing .fx-muted */
    --fx-accent: #10B981;           /* emerald-500 — landing --accent / portal emerald-500 */
    /* [Impeccable low-contrast, Inquiry 203/204, re-confirmed still live at
       Inquiry 236] emerald-600 (#059669) on --fx-accent-soft (~#def5ed) or
       --fx-surface-inner (#f1f5f9) measured 3.3:1 and 3.4:1, both under
       WCAG AA's 4.5:1 floor -- this is the real color behind .fx-pill's
       own default state and .fx-stat-icon, both text/icon-on-soft-fill
       pairs. Darkened one step to emerald-700; every real usage of this
       token in this file is a text/icon/border color on a lighter surface
       (never a background fill under light text), so darkening it can
       only improve contrast, never regress it. emerald-700 on
       --fx-accent-soft measures ~4.8:1, on plain white ~5.5:1. Dark mode's
       own --fx-accent-strong (below) already measures ~6.9:1 on its own
       soft background and is unchanged. */
    --fx-accent-strong: #047857;    /* emerald-700 */
    --fx-accent-soft: color-mix(in srgb, var(--fx-accent) 14%, white);
    --fx-danger: #DC2626;
    --fx-danger-soft: color-mix(in srgb, var(--fx-danger) 12%, white);
    --fx-warning: #D97706;             /* amber-600 — EXPIRED pill, distinct from success/danger/muted */
    --fx-warning-soft: color-mix(in srgb, var(--fx-warning) 14%, white);
    /* [Inquiry 212, Marc's direct ask] Recent Sales package-color palette —
       7 hues, deliberately clear of every semantic color already in use on
       the dashboard (emerald accent/success, red danger, amber warning) and
       of the Revenue chart's own orange (#FDBA74) / sky-blue (#38BDF8) data
       series, so a package chip is never mistaken for a status or a chart
       legend swatch. Assignment is deterministic (package index within
       GET /packages' own stable ordering, modulo 7) — see
       fxLoadPackageColorMap() in dashboard/index.html — never randomized. */
    --fx-pkg-1: #0891B2; --fx-pkg-1-soft: color-mix(in srgb, var(--fx-pkg-1) 14%, white); /* cyan-600 */
    --fx-pkg-2: #2563EB; --fx-pkg-2-soft: color-mix(in srgb, var(--fx-pkg-2) 14%, white); /* blue-600 */
    --fx-pkg-3: #4F46E5; --fx-pkg-3-soft: color-mix(in srgb, var(--fx-pkg-3) 14%, white); /* indigo-600 */
    --fx-pkg-4: #7C3AED; --fx-pkg-4-soft: color-mix(in srgb, var(--fx-pkg-4) 14%, white); /* violet-600 */
    --fx-pkg-5: #9333EA; --fx-pkg-5-soft: color-mix(in srgb, var(--fx-pkg-5) 14%, white); /* purple-600 */
    --fx-pkg-6: #C026D3; --fx-pkg-6-soft: color-mix(in srgb, var(--fx-pkg-6) 14%, white); /* fuchsia-600 */
    --fx-pkg-7: #DB2777; --fx-pkg-7-soft: color-mix(in srgb, var(--fx-pkg-7) 14%, white); /* pink-600 */
    --fx-radius-tight: 8px;         /* dense elements: dropdown/menu options */
    --fx-radius-inner: 12px;        /* buttons, inputs, selects, stat-icon containers */
    --fx-radius-outer: 16px;        /* cards, panels, banners, modals */
    --fx-shadow-card: 0 8px 24px -14px rgba(15, 23, 42, 0.15);
    /* v2 additions — all derived from the locked palette above */
    --fx-ring: color-mix(in srgb, var(--fx-accent) 28%, transparent);
    --fx-border-strong: #CBD5E1;    /* slate-300 — hover border */
    --fx-shadow-pop: 0 12px 32px -8px rgba(15, 23, 42, 0.22), 0 2px 8px -2px rgba(15, 23, 42, 0.08);
    --fx-shadow-btn: 0 2px 8px -2px color-mix(in srgb, var(--fx-accent-strong) 45%, transparent);
    /* [Interaction-system audit] The single, NEUTRAL hover-elevation token
       shared by every subordinate interactive control -- secondary buttons
       (Tier 2, adds a 1px lift) and top-bar/filter controls like the calendar
       and enhanced selects (Tier 3, shadow only, no lift since they read as
       inputs). Deliberately colourless: only the ONE primary action per page
       (.fx-btn-primary) is allowed the emerald fill + accent glow, so the
       hierarchy reads as intentional rather than every button competing. */
    --fx-shadow-hover: 0 4px 12px -6px rgba(15, 23, 42, 0.18);
    --fx-scrim: rgba(2, 6, 23, 0.55);
    /* [Inquiry 239, fluxi-mobile-nav-standard skill §2.3] Mobile-only
       calendar-icon accent — a deliberate, scoped exception to reusing the
       existing emerald system, since this one control's whole job is to be
       the first thing the eye lands on (Spotify's own bright-highlight
       convention). Real computed contrast, not assumed: lime-600 (#65A30D,
       the initial pick) measured only 2.95:1 on --fx-bg (#F8FAFC) —
       under even the lenient 3:1 icon floor — so lime-700 (#4D7C0F) is
       used instead, measuring 4.77:1 on --fx-bg / 4.99:1 on white, clearing
       real body-text AA, not just icon-AA. */
    --fx-calendar-accent: #4D7C0F;   /* lime-700 */
}

html.dark {
    --fx-bg: #020617;               /* slate-950 — portal dark canvas + chrome bars */
    --fx-surface: #0F172A;          /* slate-900 / fluxiNavy — one step lighter than bg */
    --fx-surface-inner: #1E293B;    /* slate-800 */
    --fx-border: #1E293B;           /* slate-800 — portal border-slate-800 */
    --fx-text: #F1F5F9;             /* slate-100 — portal dark body color */
    --fx-muted: #94A3B8;            /* slate-400 — portal carrier-status-bar / toast text */
    --fx-accent: #10B981;
    --fx-accent-strong: #34D399;    /* emerald-400 — portal's lighter dark-mode accent */
    --fx-accent-soft: color-mix(in srgb, var(--fx-accent) 18%, #0F172A);
    --fx-danger: #F87171;
    --fx-danger-soft: color-mix(in srgb, var(--fx-danger) 14%, #0F172A);
    --fx-warning: #FBBF24;          /* amber-400 — lighter dark-mode counterpart to --fx-warning */
    --fx-warning-soft: color-mix(in srgb, var(--fx-warning) 18%, #0F172A);
    /* Package palette, dark-mode counterparts — 400-weight for the same
       lift-off-the-canvas legibility the accent/danger/warning tokens
       already get in dark mode above. */
    --fx-pkg-1: #22D3EE; --fx-pkg-1-soft: color-mix(in srgb, var(--fx-pkg-1) 18%, #0F172A);
    --fx-pkg-2: #60A5FA; --fx-pkg-2-soft: color-mix(in srgb, var(--fx-pkg-2) 18%, #0F172A);
    --fx-pkg-3: #818CF8; --fx-pkg-3-soft: color-mix(in srgb, var(--fx-pkg-3) 18%, #0F172A);
    --fx-pkg-4: #A78BFA; --fx-pkg-4-soft: color-mix(in srgb, var(--fx-pkg-4) 18%, #0F172A);
    --fx-pkg-5: #C084FC; --fx-pkg-5-soft: color-mix(in srgb, var(--fx-pkg-5) 18%, #0F172A);
    --fx-pkg-6: #E879F9; --fx-pkg-6-soft: color-mix(in srgb, var(--fx-pkg-6) 18%, #0F172A);
    --fx-pkg-7: #F472B6; --fx-pkg-7-soft: color-mix(in srgb, var(--fx-pkg-7) 18%, #0F172A);
    --fx-shadow-card: 0 10px 30px -12px rgba(0, 0, 0, 0.5);
    --fx-ring: color-mix(in srgb, var(--fx-accent) 35%, transparent);
    --fx-border-strong: #334155;    /* slate-700 */
    --fx-shadow-pop: 0 16px 40px -8px rgba(0, 0, 0, 0.65), 0 2px 8px -2px rgba(0, 0, 0, 0.4);
    --fx-shadow-btn: 0 2px 10px -2px color-mix(in srgb, var(--fx-accent) 40%, transparent);
    /* [Interaction-system audit] Neutral hover elevation, dark theme -- see
       the light-theme token above for the full rationale. */
    --fx-shadow-hover: 0 6px 16px -6px rgba(0, 0, 0, 0.55);
    --fx-scrim: rgba(2, 6, 23, 0.72);
    /* lime-400, ~11.8:1 on --fx-surface (#0F172A), computed not assumed —
       comfortably clears AA with real margin to spare in dark mode. */
    --fx-calendar-accent: #A3E635;   /* lime-400 */
}

* { box-sizing: border-box; }

/* [Marc's direct report, real device testing: "snapping off the browser
   edges kind of like a spring effect... has to be static as hell on all
   corners no stretches"] The rubber-band/elastic overscroll effect at the
   top/bottom (and, via the x-axis property, any accidental left/right
   overscroll) of the page -- disabled at the root, not just body, since
   the bounce is a property of the actual scrolling viewport and some
   browsers honor it on html rather than body. */
html { overscroll-behavior: none; }

/* [Inquiry 243, real root cause: "sticky top bar shifts position slightly
   depending on swipe direction"] `overscroll-behavior: none` above only
   suppresses the rubber-band BOUNCE/scroll-chaining effect -- it does not
   stop the browser's own edge-swipe gesture recognition. With no
   `touch-action` declared anywhere in this file, every element defaulted
   to `auto`, which leaves horizontal touch drags free to be claimed by the
   browser chrome itself (iOS Safari's edge-swipe back/forward gesture,
   Android Chrome's overscroll-history-navigation) -- a chrome-level
   transform of the whole page that visibly nudges a `position: sticky`
   element sideways, in a direction and magnitude that depends on which way
   the swipe went, then springs back. Confirmed live: `docScrollWidth ===
   innerWidth` on every page (no real horizontal overflow exists to
   legitimately pan through at the root), so constraining the page to
   vertical-only panning loses no real capability -- the three genuinely
   horizontally-scrollable strips (.fx-mobile-topbar-row2, the bottom nav,
   .fx-settings-tabs) and every `.overflow-x-auto` table wrapper get their
   own explicit override below so THEIR real horizontal swipe still works. */
html { touch-action: pan-y; }
/* Every real horizontally-scrollable table wrapper on this project's pages
   uses the Tailwind `.overflow-x-auto` utility class directly (customers,
   float, disbursements, roles, sales, router, settings-routers, staff,
   usage-analytics, voucher-batches, vouchers) -- restore free panning on
   exactly those, not the page root, so their real horizontal scroll still
   works under the `pan-y`-only default above. */
.overflow-x-auto { touch-action: pan-x pan-y; }

body.fx-shell {
    background: var(--fx-bg);
    color: var(--fx-text);
    font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    transition: background-color 200ms ease, color 200ms ease;
    overscroll-behavior: none;
    touch-action: pan-y;
    /* Atmosphere: one faint emerald wash top-left, one deeper wash far right —
       depth without leaving the palette. Fixed so it doesn't scroll-band. */
    background-image:
        radial-gradient(1100px 520px at 8% -10%, color-mix(in srgb, var(--fx-accent) 6%, transparent), transparent 60%),
        radial-gradient(900px 600px at 105% 30%, color-mix(in srgb, var(--fx-accent) 4%, transparent), transparent 55%);
    background-attachment: fixed;
}

.fx-font-display { font-family: 'Lexend', 'Inter', sans-serif; }

/* Focus discipline: one consistent ring everywhere, keyboard-only. */
:where(button, a, input, select, textarea, [tabindex]):focus-visible {
    outline: 2px solid var(--fx-accent);
    outline-offset: 2px;
    border-radius: 8px;
}

@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}

/* ---------- layout shell ---------- */

.fx-app {
    display: flex;
    /* [Marc's direct report: intermittent empty space at the bottom of pages
       on mobile, varies "across different browser types", worse after a page
       switch] Classic mobile-viewport bug: `100vh` is measured against the
       browser's LARGE viewport (URL bar hidden), so while the URL bar is
       actually showing the shell is taller than the visible area -- and as
       the bar hides/shows on scroll, or a boosted nav resets scroll to the
       top and brings the bar back, a dead band appears below the content.
       `100dvh` (dynamic viewport height) always equals the *currently*
       visible area, so the shell never over- or under-shoots. `100vh` stays
       as the fallback for browsers without dvh. */
    min-height: 100vh;
    min-height: 100dvh;
}

.fx-sidebar {
    width: 264px;
    flex-shrink: 0;
    background: var(--fx-surface);
    border-right: 1px solid var(--fx-border);
    display: flex;
    flex-direction: column;
    position: sticky;
    top: 0;
    height: 100vh;
    height: 100dvh;
    overflow-y: auto;
    transition: background-color 200ms ease, border-color 200ms ease, transform 260ms cubic-bezier(0.32, 0.72, 0, 1);
    scrollbar-width: thin;
    scrollbar-color: var(--fx-border-strong) transparent;
}
.fx-sidebar::-webkit-scrollbar { width: 8px; }
.fx-sidebar::-webkit-scrollbar-track { background: transparent; }
.fx-sidebar::-webkit-scrollbar-thumb { background-color: var(--fx-border-strong); border-radius: 8px; border: 2px solid var(--fx-surface); background-clip: padding-box; }
.fx-sidebar::-webkit-scrollbar-thumb:hover { background-color: var(--fx-muted); }

/* [Marc's direct mobile-scroll report, Inquiry 214] padding-bottom was 96px
   unconditionally, on every page -- real dead space at max scroll on any
   page without a .fx-cta-dock (which reserves 132px separately, further
   down this file, via body.has-cta-dock .fx-main). 32px/28px is a normal
   bottom gutter, not space reserved for anything fixed-position. */
.fx-main {
    flex: 1;
    min-width: 0;
    padding: 24px 24px 32px;
}

@media (max-width: 900px) {
    .fx-sidebar {
        position: fixed;
        z-index: 40;
        width: 100vw;
        transform: translateX(-100%);
        box-shadow: var(--fx-shadow-card);
    }
    .fx-sidebar.is-open { transform: translateX(0); }
    .fx-main { padding: 16px 16px 28px; }
}

/* [Inquiry 220, Marc's direct report: "upon scrolling the logo area that
   shows fluxi disappears...it should be fixed just like it was with
   mobile view"] .fx-sidebar itself is `position: sticky` against the page
   but internally `overflow-y: auto` with its OWN scrollbar (a tall nav
   link list scrolls within the sidebar's fixed 100vh box, independent of
   the page/main-content scroll) -- .fx-sidebar-brand was only ever a
   plain in-flow child of that internal scroll container on desktop (the
   sticky+background treatment below existed ONLY inside the mobile media
   query further down), so scrolling the nav list on desktop carried the
   logo away with it. Sticky-within-its-own-scroll-container on desktop
   too, matching the mobile treatment exactly. */
/* [Real bug, Marc's direct report: the FLUXI logo doesn't navigate to the
   dashboard when clicked] dashboard.js now wraps .fx-sidebar-brand's real
   content in a real <a href="index.html"> -- default anchor styling
   (underline, link-blue) is reset here so it reads exactly as it did as
   plain text, just genuinely clickable now. inline-flex/gap keeps the
   wordmark's own inline color-split span ("FLUX" + accent "I") laid out
   the same as before this wrapper existed. */
.fx-sidebar-brand-link {
    display: inline-flex;
    align-items: center;
    gap: 10px;
    color: inherit;
    text-decoration: none;
    cursor: pointer;
}
.fx-sidebar-brand {
    padding: 20px 20px 16px;
    display: flex;
    align-items: center;
    gap: 10px;
    border-bottom: 1px solid var(--fx-border);
    position: sticky;
    top: 0;
    z-index: 5;
    background: var(--fx-surface);
}

/* ---------- mobile full-screen nav: sticky glass header + close button ----------
   Matches the landing page's own #fx-nav "liquid glass" pattern (transparent
   at rest, frosted-blur once the content beneath has scrolled) rather than a
   flat opaque bar — adapted here to a drawer's own internal scroll instead of
   page scroll. fxInitMobileSidebarChrome() (dashboard.js) injects the close
   button and wires the scroll listener; this is pure display CSS.
   Base rule MUST come before the media query below — equal-specificity CSS
   resolves ties by source order, so a later unconditional `display: none`
   would silently beat the media query's `display: flex` at every viewport
   width regardless of the query matching (a real bug caught live: the
   close button existed in the DOM but computed display stayed "none" on a
   375px viewport until this was reordered to match .fx-hamburger's own
   correct base-then-override pattern above). */
.fx-sidebar-close-btn { display: none; }
@media (max-width: 900px) {
    .fx-sidebar-brand {
        position: sticky;
        top: 0;
        z-index: 5;
        justify-content: space-between;
        background: transparent;
        border-bottom-color: transparent;
        transition: background-color 200ms ease, border-color 200ms ease, backdrop-filter 200ms ease, box-shadow 200ms ease;
    }
    .fx-sidebar-brand.is-scrolled {
        background: color-mix(in srgb, var(--fx-surface) 82%, transparent);
        backdrop-filter: blur(14px);
        -webkit-backdrop-filter: blur(14px);
        border-bottom-color: var(--fx-border);
        box-shadow: var(--fx-shadow-card);
    }
    .fx-sidebar-close-btn {
        display: flex;
        width: 36px;
        height: 36px;
        border-radius: var(--fx-radius-inner);
        border: 1px solid var(--fx-border);
        background: var(--fx-surface);
        color: var(--fx-text);
        align-items: center;
        justify-content: center;
        flex-shrink: 0;
        transition: background-color 150ms ease, transform 150ms ease;
    }
    .fx-sidebar-close-btn:hover { background: var(--fx-surface-inner); }
    .fx-sidebar-close-btn:active { transform: scale(0.94); }
}

.fx-site-switcher {
    position: relative;
    margin: 16px 16px 8px;
    padding: 10px 12px;
    border-radius: var(--fx-radius-inner);
    background: var(--fx-surface-inner);
    border: 1px solid var(--fx-border);
    display: flex;
    align-items: center;
    gap: 10px;
    width: calc(100% - 32px);
    text-align: left;
    font: inherit;
    color: inherit;
    /* [Inquiry 265] Only the interactive (multi-site) switcher looks
       clickable. A single-site tenant sees a plain, static label -- no
       false affordance for a choice that does not exist. */
    cursor: default;
    transition: border-color 150ms ease, box-shadow 150ms ease;
}
.fx-site-switcher--interactive { cursor: pointer; }
.fx-site-switcher--interactive:hover {
    border-color: var(--fx-accent);
    box-shadow: 0 0 0 3px var(--fx-ring);
}

/* [Inquiry 265] Multi-site "Switch Hotspot" dropdown. Anchored under the
   sidebar switcher; overlays rather than pushing the nav. */
.fx-site-dropdown {
    position: absolute;
    z-index: 60;
    left: 16px;
    right: 16px;
    /* [Inquiry 283] `top` is set in JS on open (btn.offsetTop + height + gap) so
       the menu always drops DOWN below the switcher, never up. */
    padding: 6px;
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: var(--fx-radius-inner);
    box-shadow: var(--fx-shadow-card, 0 8px 24px rgba(0,0,0,0.18));
    max-height: 320px;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 2px;
}
/* [Inquiry 283] The switcher's JS (dashboard-auth.js renderSiteSwitcher)
   opens/closes this menu with the `hidden` attribute (menu.hidden = true/
   false), which every dismiss path relies on -- toggle, click-outside,
   Escape, and selecting a site. But the `.fx-site-dropdown { display:flex }`
   rule above has the same specificity as the UA's `[hidden]{display:none}`
   and, being author CSS, wins on source order -- so setting `hidden` had NO
   visual effect and the dropdown could not be closed by ANY of those paths
   once a tenant had 2+ sites (Marc's live report). This rule (class +
   attribute, higher specificity) restores the attribute's meaning so all
   the existing dismiss handlers work. */
.fx-site-dropdown[hidden] { display: none; }
.fx-site-option {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    padding: 8px 10px;
    border: none;
    background: transparent;
    border-radius: 8px;
    cursor: pointer;
    text-align: left;
    font: inherit;
    color: var(--fx-text);
    transition: background-color 120ms ease;
}
.fx-site-option:hover { background: var(--fx-surface-inner); }
.fx-site-option.is-selected { background: var(--fx-accent-soft); }
/* [Inquiry 283] Create Hotspot action pinned at the bottom of the switcher. */
.fx-site-dropdown-sep { height: 1px; background: var(--fx-border); margin: 4px 2px; }
.fx-site-create { color: var(--fx-accent-strong); font-weight: 600; }
.fx-site-create .fx-site-option-name { color: var(--fx-accent-strong); }

/* [Inquiry 283] First-run onboarding guide card (non-blocking). */
.fx-firstrun-card {
    display: flex;
    align-items: center;
    gap: 16px;
    padding: 16px 18px;
    margin-bottom: 20px;
    border: 1px solid var(--fx-border);
    border-radius: var(--fx-radius-card, 14px);
    background: var(--fx-accent-soft);
}
.fx-firstrun-icon {
    display: flex; align-items: center; justify-content: center;
    width: 40px; height: 40px; flex-shrink: 0;
    border-radius: 10px;
    background: var(--fx-surface);
    color: var(--fx-accent-strong);
}
.fx-firstrun-body { flex: 1; min-width: 0; }
.fx-firstrun-title { font-size: 14px; font-weight: 700; color: var(--fx-text); }
.fx-firstrun-text { font-size: 12px; color: var(--fx-muted); margin-top: 2px; }
.fx-firstrun-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
@media (max-width: 640px) {
    .fx-firstrun-card { flex-direction: column; align-items: flex-start; }
    .fx-firstrun-actions { width: 100%; }
    .fx-firstrun-actions .fx-btn-primary { flex: 1; text-align: center; }
}
.fx-site-option-body { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.fx-site-option-name {
    font-size: 13px;
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.fx-site-option-address {
    font-size: 11px;
    color: var(--fx-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.fx-site-option-check {
    width: 16px;
    height: 16px;
    color: var(--fx-accent-strong);
    flex-shrink: 0;
}

.fx-nav-group-label {
    padding: 16px 20px 6px;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--fx-muted);
}

.fx-sidebar-link {
    position: relative;
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 20px;
    margin: 4px 10px;
    border-radius: var(--fx-radius-inner);
    font-size: 14px;
    font-weight: 500;
    color: var(--fx-text);
    text-decoration: none;
    transition: background-color 150ms ease, color 150ms ease;
}

/* Active-state indicator: emerald bar that grows in from the left edge. */
.fx-sidebar-link::before {
    content: '';
    position: absolute;
    left: 6px;
    top: 50%;
    height: 18px;
    width: 3px;
    border-radius: 9999px;
    background: var(--fx-accent);
    transform: translateY(-50%) scaleY(0);
    transition: transform 200ms cubic-bezier(0.34, 1.4, 0.64, 1);
}

.fx-sidebar-link:hover:not(.is-disabled) {
    background: var(--fx-surface-inner);
    color: var(--fx-text);
}

.fx-sidebar-link.is-active {
    background: var(--fx-accent-soft);
    color: var(--fx-accent-strong);
    font-weight: 600;
}
.fx-sidebar-link.is-active::before { transform: translateY(-50%) scaleY(1); }

.fx-sidebar-link.is-disabled {
    opacity: 0.45;
    cursor: not-allowed;
    pointer-events: none;
}

.fx-sidebar-link .fx-soon-pill {
    margin-left: auto;
    font-size: 9px;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    padding: 2px 7px;
    border-radius: 9999px;
    background: var(--fx-surface-inner);
    color: var(--fx-muted);
}

/* [Inquiry 234, Marc's direct report: "make it stick as well just like
   the top that shows the business logo"] Same sticky-within-its-own-
   scroll-container treatment as .fx-sidebar-brand above (Inquiry 220),
   mirrored to the bottom edge -- margin-top:auto still pins it to the
   end of the flex column when the nav list is short; position:sticky
   keeps it visible at the bottom of .fx-sidebar's own internal scroll
   once a long nav list actually overflows, on desktop and the mobile
   drawer alike (.fx-sidebar's height:100vh/overflow-y:auto base rule is
   unchanged by the mobile media query, only position/width are). */
.fx-sidebar-footer {
    margin-top: auto;
    padding: 16px 20px;
    border-top: 1px solid var(--fx-border);
    display: flex;
    align-items: center;
    gap: 10px;
    position: sticky;
    bottom: 0;
    z-index: 5;
    background: var(--fx-surface);
}

/* [Inquiry 234, Marc's direct report: "the tenant and the logout icon...
   isnt clear at all...make it more vivid against the white background
   and dark"] Scoped override, not a change to the shared .fx-icon-btn
   default used elsewhere in the app (refresh/close/recheck buttons in
   plenty of other contexts that aren't reported as a problem) -- the
   base treatment (faint border, muted icon, background only on hover)
   reads as an almost-invisible ghost button specifically here, sitting
   directly on the sidebar's own near-identical surface color at a small
   36px size, with no hover state at all on the touch devices most FLUXI
   staff actually use. A permanent, visible chip -- tinted background,
   full-strength icon color -- fixes that at rest, in both themes, since
   every value here is already a theme-aware custom property. */
.fx-sidebar-footer .fx-icon-btn {
    background: var(--fx-surface-inner);
    border-color: var(--fx-border-strong);
    color: var(--fx-text);
}
.fx-sidebar-footer .fx-icon-btn:hover {
    background: var(--fx-accent-soft);
    border-color: var(--fx-accent);
    color: var(--fx-accent-strong);
}

/* ---------- topbar ---------- */

/* [Inquiry 221, Marc's direct report: "a header that scrolls away once a user
   scrolls...makes them wonder what page they are on...apply to all pages, mobile
   AND desktop"] Sticky page header — the page identity (title + subtitle) AND
   its same-level action controls (Float's Top Up/Withdraw, Vouchers' Generate,
   the site + date-range pickers) all stay pinned no matter how deep the user
   scrolls or swipes, on every page. Opaque page-bg fill + negative margins bleed
   the header full-width over .fx-main's own padding so scrolled content passes
   cleanly behind it, with a hairline divider underneath. Deliberately NO
   transform/filter/backdrop-filter here: any of those would turn this into a
   containing block for the date-range panel's position:fixed mobile centering
   and break it (both the sticky behavior and the picker staying centered were
   verified live on desktop + a 375px viewport before shipping). */
.fx-topbar {
    position: sticky;
    top: 0;
    z-index: 30;
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
    flex-wrap: wrap;
    background: var(--fx-bg);
    margin: -24px -24px 20px;
    padding: 24px 24px 14px;
    box-shadow: 0 1px 0 0 var(--fx-border);
}

.fx-page-title {
    font-size: 24px;
    font-weight: 700;
    letter-spacing: -0.01em;
}

.fx-page-subtitle {
    font-size: 13px;
    color: var(--fx-muted);
    margin-top: 4px;
}

/* ---------- mobile/tablet nav standard (Inquiry 239, fluxi-mobile-nav-standard
   skill) ----------
   Breakpoint reuses the existing 900px mobile-drawer cutoff (skill's own
   open decision 7.1: not yet verified against a real tablet device, a
   pragmatic starting point, not a settled fact -- revisit against real
   iPad-class testing).

   Mechanism: fxInitMobileChrome() (dashboard.js) moves .fx-topbar's real
   children (not clones -- ids/onclick/enhanced-select state stay intact)
   into .fx-page-header-mobile, then hides the now-empty original .fx-topbar
   at ALL widths via an inline style (not CSS), so .fx-topbar's own rule
   above is untouched. .fx-page-header-mobile's DEFAULT (desktop) styling
   below is a deliberate pixel-for-pixel match of the old .fx-topbar rule,
   so desktop's real rendered output is unchanged even though the DOM
   container is now a different element; the mobile media query further
   down then un-stickies it, matching XenFi's first-screen header per the
   skill. .fx-mobile-topbar/.fx-mobile-bottom-nav/.fx-diffuse-slot default
   to display:none (desktop-hidden) and are only shown inside the mobile
   query -- this is the fix for a real bug caught before shipping: without
   an explicit desktop-hidden default, these unstyled new elements would
   otherwise render at desktop too since nothing else declares their
   display outside the media query.

   The skill's own §1 rule (ONE sticky bar, content cross-fades in place,
   never two visible brand marks) is enforced structurally here: at rest
   the sticky .fx-mobile-topbar shows only the logo (no title text yet, so
   nothing duplicates the big heading sitting in the non-sticky header
   right below it); once scrolled past the diffusion threshold the logo
   fades out and the compact title fades in, in the same slot, exactly as
   the real heading scrolls out of view above it. */
.fx-page-header-mobile {
    position: sticky;
    top: 0;
    z-index: 30;
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
    flex-wrap: wrap;
    background: var(--fx-bg);
    margin: -24px -24px 20px;
    padding: 24px 24px 14px;
    box-shadow: 0 1px 0 0 var(--fx-border);
}

.fx-mobile-topbar, .fx-mobile-bottom-nav { display: none; }

@media (max-width: 900px) {
    .fx-page-header-mobile {
        position: static;
        margin: -16px -16px 20px;
        padding: 16px 16px 14px;
        box-shadow: none;
    }
    .fx-page-header-mobile .fx-page-title { font-size: 21px; }

    /* [Inquiry 256] settings-routers' two header CTAs overflowed off the right
       edge on mobile (the inner flex row never wrapped). Give the group the
       full width and stack both buttons full-width, so neither is clipped. */
    .fx-router-cta {
        width: 100%;
        flex-wrap: wrap;
    }
    .fx-router-cta > .fx-btn-primary,
    .fx-router-cta > .fx-btn-secondary {
        flex: 1 1 100%;
        justify-content: center;
    }

    /* [Skill §5.2-5.6] Repetitive descriptive paragraph removed at mobile
       widths only on pages that opt in via this marker class -- desktop
       keeps the real subtitle text regardless. Applied per-page as
       <p class="fx-page-subtitle fx-subtitle-mobile-hide">. */
    .fx-subtitle-mobile-hide { display: none; }

    /* Column container: row1 (logo/title diffusion + actions) is always
       present; row2 (skill §5.3/§5.4's compact filter/search controls) is
       appended only on pages that need it (FX_MOBILE_ROW2, dashboard.js)
       and hidden via JS when empty -- both rows pin together as ONE sticky
       unit, matching Marc's own "arranged horizontally on the sticky bar's
       second line" instruction (the second line stays pinned, not a
       separately-scrolling block). */
    /* [Marc's direct report: "the fluxi logo looks like a baby... probably
       an issue with size of the top bar"] Bar grown from a cramped
       10px/32px-row1 footprint to real, deliberate size -- padding, row
       gap, diffuse-slot height, and the logo's own font-size all scaled up
       together, not just the logo in isolation (a bigger logo alone inside
       an unchanged cramped bar would just clip/crowd). */
    .fx-mobile-topbar {
        position: sticky;
        top: 0;
        z-index: 30;
        display: flex;
        flex-direction: column;
        gap: 10px;
        margin: -16px -16px 16px;
        padding: 14px 16px;
        background: color-mix(in srgb, var(--fx-bg) 85%, transparent);
        backdrop-filter: blur(14px);
        -webkit-backdrop-filter: blur(14px);
        border-bottom: 1px solid var(--fx-border);
        box-shadow: var(--fx-shadow-card);
    }
    .fx-mobile-topbar-row1 {
        display: flex;
        align-items: center;
        /* [Inquiry 256, follow-up] Marc's final call for every page: logo on
           the LEFT, everything else (Export/CSV, calendar, hamburger) on the
           RIGHT -- the standard app-bar layout, hamburger pinned to the trailing
           corner. space-between does exactly that. (This supersedes the earlier
           flex-start "collapse the gap" attempt; the middle space is inert
           anyway since Inquiry 255 killed the dead-zone.) */
        justify-content: space-between;
        gap: 10px;
        min-height: 44px;
    }

    /* Diffusion slot: logo and title occupy the same box, cross-fading via
       the --fx-diffuse custom property (0 = logo fully solid, 1 = title
       fully solid), driven by fxInitMobileChrome()'s scroll handler as a
       continuous value -- a real gradual blend, not a class-toggle snap,
       per the skill's own "diffuses" requirement (§2.2). */
    /* [Real bug, Marc's direct report: tapping ANYWHERE on the top bar
       navigated home as if the logo was tapped.] The slot was flex:1, so the
       <a href="index.html"> stretched across the whole left half of the bar
       and every accidental tap in that dead space fired the link. It is now
       sized to its own content (inline-grid, both cross-fading texts stacked
       in one cell), so only the logo/title glyphs themselves are tappable --
       the empty space beside them is inert. The diffusion cross-fade is
       unchanged (opacity on the two stacked children). */
    /* [Inquiry 255] The slot is no longer itself the <a>. It is an inert
       wrapper sized to the WIDER of the two cross-fading texts; the two texts
       are now each their own <a href="index.html"> sized to their own glyphs.
       This closes the Inquiry-248 dead-zone that recurred here: the old single
       <a> stretched to the title width, so on every page whose title is wider
       than "FLUXI" the empty space beside the (narrower) logo, at top-of-
       scroll, still fired the home link. Now that empty space belongs to this
       non-clickable div, and only the phase-active anchor is pointer-active. */
    .fx-diffuse-slot {
        position: relative;
        height: 30px;
        display: inline-grid;
        align-items: center;
        justify-items: start;
        min-width: 0;
        max-width: 62vw;
        overflow: hidden;
    }
    .fx-diffuse-logo, .fx-diffuse-title {
        grid-area: 1 / 1;
        white-space: nowrap;
        width: max-content;
        max-width: 100%;
        overflow: hidden;
        text-overflow: ellipsis;
        color: inherit;
        text-decoration: none;
        cursor: pointer;
        /* Only the currently-shown text catches taps; the faded one (which
           still occupies its grid cell) must never be a hit target, or its
           box would re-create the dead-zone. Phase set in dashboard.js. */
        pointer-events: none;
    }
    .fx-mobile-topbar[data-fx-diffuse-phase="logo"] .fx-diffuse-logo,
    .fx-mobile-topbar[data-fx-diffuse-phase="title"] .fx-diffuse-title {
        pointer-events: auto;
    }
    .fx-diffuse-logo {
        font-size: 26px;
        font-weight: 900;
        letter-spacing: -0.01em;
        opacity: calc(1 - var(--fx-diffuse, 0));
    }
    /* [Real bug, Marc's direct report: "the text size on the navigation
       bar on mobile is small and looks out of place compared to the text
       sizes on the rest of the pages"] Was 17px, an arbitrary in-between
       value -- bumped to 21px to match .fx-page-header-mobile .fx-page-
       title's own real mobile size exactly (line 502 above), the actual
       in-page heading this text takes over from mid-scroll. Same size
       before and after the cross-fade reads as one continuous heading;
       a smaller size here read as a demotion/mismatch instead. */
    .fx-diffuse-title {
        font-size: 21px;
        font-weight: 700;
        opacity: var(--fx-diffuse, 0);
    }
    @media (prefers-reduced-motion: no-preference) {
        .fx-diffuse-logo, .fx-diffuse-title { transition: opacity 60ms linear; }
    }

    .fx-mobile-topbar-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }

    /* [Real bug, Marc's direct report: "the export csv button on the sales
       page is preventing the logo render in full"] FX_MOBILE_TOPBAR_PIN
       (dashboard.js) moves the page's REAL #fx-sales-export/#fx-vouchers-
       export button into this row unchanged -- a labeled .fx-btn-secondary
       ("Export CSV" text + icon, ~120px+ wide), not the 44px icon-only
       shape every other control in this row already has. flex-shrink:0
       above means this row never gives back space once it needs it, so the
       diffuse logo/title slot (flex:1, no min-width) absorbed the entire
       overflow and truncated. Collapsed to the same icon-only 44px chip as
       the calendar/hamburger buttons it now sits beside -- font-size:0
       hides the text label without touching the icon's own explicit
       width/height classes (data-lucide SVGs are sized via attributes, not
       em units, so this never shrinks the icon itself). The button's real
       onclick/id/desktop appearance are completely unaffected -- this rule
       only ever matches while the node lives inside .fx-mobile-topbar-
       actions, i.e. only after fxSyncMobileConditionalMoves() has moved it
       there for the mobile breakpoint. */
    .fx-mobile-topbar-actions .fx-btn-secondary,
    .fx-mobile-topbar-actions .fx-btn-primary {
        width: 44px;
        height: 44px;
        padding: 0;
        border-radius: 9999px;
        font-size: 0;
        gap: 0;
        flex-shrink: 0;
    }

    /* [Marc's direct correction, interaction-quality pass: "the previous CSV
       download button fix went too far... the intent has been lost... a
       user should be able to look at the interface and understand: that
       control downloads a CSV" -- extended, second brief: "the same
       broader problem we identified with the CSV controls" applies to
       Create Package too: "reduce visual clutter, not semantic meaning"]
       The generic icon-only collapse above is still right for the
       calendar/hamburger buttons (universally understood icons with
       nothing else in the row to confuse them with, and no real "primary
       action" semantics riding on them), but wrong for any PRIMARY action
       control -- Export CSV and Create Package both need their intent to
       survive being pinned into this row, not just their icon. These IDs
       (higher specificity than the class rule above, so they win
       regardless of source order) restore a compact labeled pill instead
       of a bare circle: icon + a short label (Option C from Marc's own
       brief -- "CSV" for export, "New Package" for create, shorter than
       each one's own full desktop text but never just an icon) -- reduces
       chrome without reducing meaning. aria-label carries the FULL action
       text on every one of these regardless of which short label is
       visually shown, for assistive tech. */
    .fx-mobile-topbar-actions #fx-sales-export,
    .fx-mobile-topbar-actions #fx-vouchers-export,
    .fx-mobile-topbar-actions #fx-float-export,
    .fx-mobile-topbar-actions #fx-packages-create {
        width: auto;
        height: 40px;
        padding: 0 12px;
        border-radius: 9999px;
        font-size: 11px;
        font-weight: 700;
        gap: 5px;
    }
    .fx-mobile-topbar-actions #fx-sales-export .fx-btn-label-full,
    .fx-mobile-topbar-actions #fx-vouchers-export .fx-btn-label-full,
    .fx-mobile-topbar-actions #fx-float-export .fx-btn-label-full,
    .fx-mobile-topbar-actions #fx-packages-create .fx-btn-label-full {
        display: none;
    }
    .fx-mobile-topbar-actions #fx-sales-export .fx-btn-label-compact,
    .fx-mobile-topbar-actions #fx-vouchers-export .fx-btn-label-compact,
    .fx-mobile-topbar-actions #fx-float-export .fx-btn-label-compact,
    .fx-mobile-topbar-actions #fx-packages-create .fx-btn-label-compact {
        display: inline;
    }

    /* Overrides the pre-existing .fx-hamburger mobile rule (below, Inquiry
       221 -- position:absolute against .fx-topbar as its containing block,
       now moot since .fx-topbar is JS-hidden). The moved hamburger here is
       a normal flex child of .fx-mobile-topbar-actions instead; specificity
       (2 classes) beats the older bare .fx-hamburger rule (1 class) so this
       wins regardless of source order. */
    .fx-mobile-topbar .fx-hamburger {
        position: static;
        width: 44px;
        height: 44px;
    }
    .fx-mobile-topbar-actions .fx-icon-btn {
        width: 44px;
        height: 44px;
    }

    /* [Skill §2.3] Deliberate scoped exception to "no arbitrary new colors" --
       this is the one control meant to draw the eye first (the highest-
       frequency control on the app, Marc's own framing), so it gets its own
       accent distinct from the emerald system used everywhere else. */
    /* 2-class specificity, deliberately -- .fx-icon-btn's own base rule
       (dashboard.css, ~line 951) sets color: var(--fx-muted) at equal
       specificity to a bare .fx-mobile-calendar-btn selector and sits
       LATER in the file, so it would silently win on source order alone;
       confirmed live (real bug caught before shipping: computed color came
       back as --fx-muted, not the calendar accent, until this was scoped
       one level deeper). */
    .fx-mobile-topbar-actions .fx-mobile-calendar-btn { color: var(--fx-calendar-accent); }
    .fx-mobile-topbar-actions .fx-mobile-calendar-btn:hover { background: color-mix(in srgb, var(--fx-calendar-accent) 16%, transparent); }

    /* [Marc's direct correction] The calendar icon REPLACES each page's own
       inline date-range button entirely -- it does not merely scroll to
       reveal a second, still-visible control sitting right next to it.
       fxMobileCalendarTap() (dashboard.js) clicks the real, hidden
       .fx-rangepicker-btn directly, opening the exact same real widget
       (.fx-rangepanel already renders position:fixed/viewport-centered at
       this breakpoint, see further down this file -- it never depended on
       its trigger button's own position, so hiding the button changes
       nothing about how the real panel opens or looks). Scoped to the five
       real page-LEVEL range pickers (FX_MOBILE_RANGE_CONTROL, dashboard.js).
       [Marc's direct pushback, Inquiry 248] Float's own
       #fx-float-history-range was previously excluded here as scoping one
       History card's table rather than the whole page -- Marc reported
       that distinction as exactly the visual inconsistency he meant, so it
       gets the same treatment as every other page-level range control now. */
    #fx-overview-range .fx-rangepicker-btn,
    #ua-overview-range .fx-rangepicker-btn,
    #fx-support-hub-range .fx-rangepicker-btn,
    #fx-sales-range .fx-rangepicker-btn,
    #fx-vouchers-range .fx-rangepicker-btn,
    #fx-float-history-range .fx-rangepicker-btn {
        display: none;
    }
    /* Real bug caught from Marc's own live report ("truncating"): Sales'
       and Vouchers' range field-group was hidden with display:none, which
       hides EVERY descendant regardless of its own position property --
       including .fx-rangepanel, a position:fixed element that's supposed
       to escape to the viewport when opened. With display:none on this
       ancestor, tapping the calendar icon toggled is-open correctly but
       the real panel never had a chance to render at all. Collapsed to a
       real zero-size box instead (width/height:0, overflow:visible) so
       .fx-rangepanel can still escape and render centered on the real
       viewport exactly like Dashboard's/Usage Analytics'/Support Hub's own
       (never display:none'd) range pickers already correctly do. */
    .fx-field-group:has(#fx-sales-range),
    .fx-field-group:has(#fx-vouchers-range) {
        width: 0;
        height: 0;
        min-width: 0;
        padding: 0;
        margin: 0;
        border: 0;
        overflow: visible;
    }
    .fx-field-group:has(#fx-sales-range) > .fx-label,
    .fx-field-group:has(#fx-vouchers-range) > .fx-label {
        display: none;
    }

    /* Pages with no date-range concept at all (confirmed by direct grep,
       not assumed -- 14 of the 20 dashboard pages have none) get no
       calendar icon rather than a dead button that opens nothing; honest-
       incompleteness precedent (DESIGN.md §4), not a fake affordance. */
    .fx-mobile-calendar-btn.fx-no-range { display: none; }

    /* [Skill §5.3/§5.4] Row2: real .fx-field-group nodes moved here from
       each page's own .fx-card filter block (fxSyncMobileConditionalMoves(),
       dashboard.js) -- desktop keeps them in their real grid position
       untouched; this only fires inside this media query, on the exact
       same nodes, reversibly. Horizontally scrollable rather than wrapped,
       so 2-3 filters fit on one pinned line at 320px without pushing the
       bar's own height around per filter count. */
    /* Real regression caught before shipping: Sales' own filters card is a
       dedicated .fx-card holding nothing but Date range/Package/Channel/
       Sold By -- once all four move out (row2, or hidden per the calendar
       consolidation above), the empty card itself stayed visible as a
       blank white box with real padding. Vouchers' equivalent filter grid
       lives inside the same card as its tabs/table, so it never had this
       problem -- Sales' case is real and specific, not a pattern to
       generalize to every page.
       Real second bug this same fix caused and had to correct: display:none
       hides EVERY descendant regardless of its own position property --
       #fx-sales-range's own .fx-rangepanel (position:fixed) lives inside
       this exact card, so hiding the card outright silently broke the
       calendar consolidation above for Sales specifically (confirmed live:
       .fx-rangepanel computed a real 0x0 rect with the card as
       display:none, and a normal, real rect the instant it was collapsed
       this way instead). Same zero-size-not-display-none technique as the
       .fx-field-group fix above, for the same underlying reason. */
    #fx-sales-filters-card,
    /* [Marc's direct follow-up: "on the customers page the search bar
       isnt in the sticky bar at all"] Same real shape as Sales' own
       filters card -- #fx-customer-search moved into row2 (FX_MOBILE_ROW2,
       dashboard.js) leaves this dedicated .fx-card with nothing left
       inside it. Same zero-size-not-display-none collapse, same reasoning. */
    #fx-customers-filters-card {
        width: 0;
        height: 0;
        min-width: 0;
        padding: 0;
        margin: 0;
        border: 0;
        box-shadow: none;
        background: transparent;
        overflow: visible;
    }

    /* [Marc's direct report: "the void space on the vouchers page re-surfaced"]
       Vouchers' filter grid lives INSIDE the tabs/table card (unlike Sales',
       which is its own card collapsed above), so it can't be collapsed by
       hiding a whole card. On mobile its real controls relocate to the sticky
       bar (Search + Package -> row2, Export CSV -> pinned, Date range -> the
       calendar icon), but the emptied grid -- plus the Export button's leftover
       &nbsp; spacer field-group -- was left occupying an ~88px band between the
       status tabs and the table header. Collapse the grid itself to zero and
       hide every leftover field-group, so nothing sits in that gap. Anchored on
       #fx-vouchers-app (a wrapper that never moves) + `.fx-settings-tabs + .grid`
       (the filter row is the grid immediately after the status tabs), which
       stays valid whether the grid holds 2 or 4 fields. The one field-group that
       wraps #fx-vouchers-range is kept present-but-zero-size instead of hidden,
       so its position:fixed .fx-rangepanel can still escape when the mobile
       calendar icon opens it (same reason the Sales/Vouchers range field-groups
       collapse rather than display:none above). */
    #fx-vouchers-app .fx-settings-tabs + .grid {
        display: block;
        height: 0;
        min-height: 0;
        margin: 0;
        padding: 0;
        gap: 0;
        overflow: visible;
    }
    #fx-vouchers-app .fx-settings-tabs + .grid > .fx-field-group { display: none; }
    #fx-vouchers-app .fx-settings-tabs + .grid > .fx-field-group:has(#fx-vouchers-range) {
        display: block;
        width: 0;
        height: 0;
        min-width: 0;
        padding: 0;
        margin: 0;
        border: 0;
        overflow: visible;
    }

    /* [Marc's direct report: row2 controls "look small compared to the
       room and other texts on the page... as if they can scroll"] Real
       measured baseline, not a guess: the app's own .fx-select-btn is
       14px/10px-14px-padding everywhere else in this codebase (desktop
       filter cards, every settings page). The first mobile pass undercut
       that to 12px/30px-tall/132px-capped, which is exactly why it read as
       a smaller, different-feeling control than the rest of the page.
       Brought up to 13px/36px -- one deliberate step below the 14px/40px
       desktop-equivalent (this row is real secondary filtering, not the
       page's primary action) rather than matching it exactly, and the
       132px hard cap is gone so a control sizes to its own real content
       instead of truncating. A right-edge fade signals real, remaining
       scrollable content when a row genuinely doesn't fit (Sales' 3 real
       filters can still exceed 375px) -- confirms the scroll affordance
       rather than leaving an ambiguous hard-cut edge. */
    .fx-mobile-topbar-row2 {
        align-items: center;
        gap: 10px;
        overflow-x: auto;
        touch-action: pan-x pan-y;
        scrollbar-width: none;
        -webkit-overflow-scrolling: touch;
        -webkit-mask-image: linear-gradient(to right, black calc(100% - 20px), transparent 100%);
        mask-image: linear-gradient(to right, black calc(100% - 20px), transparent 100%);
    }
    .fx-mobile-topbar-row2::-webkit-scrollbar { display: none; }

    /* [Marc's direct correction] "Let the dropdown be the button itself --
       no further descriptions on the sides." The separate <label>Package</label>
       sitting next to a button that already reads "All Packages" was real,
       literal repetition (Package / All Packages), not a compact-chip
       pairing worth keeping -- the enhanced select's own trigger button
       already carries the real, current value text (fxEnhanceSelect()'s
       syncLabel(), unchanged), so the field-group's separate label is
       dropped entirely in row2, everywhere, not just on the search field. */
    .fx-mobile-topbar-row2 .fx-field-group {
        display: flex;
        align-items: center;
        flex-shrink: 0;
        margin-bottom: 0 !important;
    }
    .fx-mobile-topbar-row2 .fx-label {
        display: none;
    }
    .fx-mobile-topbar-row2 .fx-selectw,
    .fx-mobile-topbar-row2 .fx-select-btn {
        min-width: 0;
    }
    .fx-mobile-topbar-row2 .fx-select-btn {
        height: 36px;
        padding: 0 12px;
        font-size: 13px;
    }
    /* Any row2 search field (Vouchers, Customers -- FX_MOBILE_ROW2's own
       searchSelector entries, dashboard.js): a real icon prefixed inside
       the input via padding-left + a positioned <i>, rather than
       duplicating the input -- the input itself is the same real node
       with its own working oninput/keyup handler, untouched. Sized to
       match row1's own 40px controls (search is this row's primary
       action, not a secondary filter, so it gets the fuller size rather
       than the 36px filter-chip height). Listed explicitly by id, same
       convention as FX_MOBILE_RANGE_CONTROL, rather than a blanket
       input[type=text] selector that could accidentally catch an
       unrelated future field. */
    .fx-mobile-topbar-row2 .fx-field-group:has(#fx-vouchers-search),
    .fx-mobile-topbar-row2 .fx-field-group:has(#fx-customer-search) {
        position: relative;
        flex: 1;
        min-width: 140px;
    }
    .fx-mobile-topbar-row2 .fx-field-group:has(#fx-vouchers-search) .fx-label,
    .fx-mobile-topbar-row2 .fx-field-group:has(#fx-customer-search) .fx-label {
        display: none;
    }
    .fx-mobile-topbar-row2 #fx-vouchers-search,
    .fx-mobile-topbar-row2 #fx-customer-search {
        width: 100%;
        height: 40px;
        padding: 0 12px 0 34px;
        font-size: 14px;
    }
    .fx-mobile-topbar-row2-search-icon {
        position: absolute;
        left: 10px;
        top: 50%;
        transform: translateY(-50%);
        color: var(--fx-muted);
        pointer-events: none;
    }

    /* ---------- floating bottom nav (skill §3) ----------
       Glass treatment intentionally matches .fx-sidebar-brand.is-scrolled's
       already-established recipe above (Inquiry 234) rather than a new one.
       Icons only, 7 destinations (Dashboard/Usage Analytics/Sales/Vouchers/
       Disbursements/Packages/Settings, Marc's own additions), order matched
       to FX_NAV_STRUCTURE's real sequence -- see fxInitMobileChrome().
       [Marc's direct report, light-mode contrast] Real computed values, not
       assumed: --fx-muted on white measures 4.76:1 -- technically clears
       text AA but reads weak on a thin-stroke 20px outline icon, especially
       floating over varied card content rather than a flat background.
       --fx-text measures 17.85:1 light / 16.30:1 dark, comfortably strong
       in both themes, so inactive icons use it instead of muted. Glass
       opacity raised 80%→92% for a more reliably legible surface regardless
       of what's actually scrolling underneath it.
       [Marc's direct report: "icons are too small... relative to the
       overall text size"] Real anchor, not a guess: this app's page-title
       (the largest real text on any page) is 24px; this is a PRIMARY,
       always-visible navigation element, not an inline glyph next to body
       text, so it earns a size at or above that scale -- 24px glyphs
       (up from a 20px w-5/h-5) in a 44px touch target, the same real
       44px floor already used for row1's hamburger/calendar buttons
       (dashboard.js). A 44px×7-icon row doesn't fit inside 320px with any
       real margin (344px vs. 320px, checked by direct arithmetic, not
       assumed) -- rather than shrinking touch targets back down to force
       a fit at the one width this skill's own §7.1 explicitly calls a
       validation floor and never a design target, the nav scrolls
       horizontally at that narrow an edge case, with the same real
       right-edge fade used on row2 above so it never reads as an
       ambiguous hard cut. At 360px+ (the real majority of devices) all 7
       sit with genuine room to spare. */
    /* [Marc's direct report: "bottom bar background in light mode is
       blending in on the page... adapt to permanent dark glass just like
       on the website"] Original recipe read directly from
       src/frontend/landing/index.html's own #fx-nav scrolled state:
       `bg-[#0D1B2A]/80 backdrop-blur-lg border border-white/10 shadow-lg`.
       [Marc's direct follow-up correction: "too dark and its color
       crashing with the app... balanced to a point that its not blended
       into its backgrounds but its vivid enough"] The landing page's own
       recipe is tuned for a permanently-dark hero background; against the
       dashboard's real light-mode surface the same near-opaque near-black
       pill read as a flat, disconnected slab rather than glass. Two real
       changes, not a guess: (1) the base color is now a deliberate 85%
       --fx-surface (dark mode)/15% --fx-accent mix instead of plain navy
       -- pulls in the app's own real emerald brand color for "vivid,"
       reusing existing tokens rather than inventing a new hex value; (2)
       opacity dropped 80% -> 62% so more of whatever is actually behind it
       shows through (real glass, not a solid slab), while still reading as
       a distinct floating element against a light page. Recomputed
       contrast against the NEW tinted base, not assumed carried over:
       white/80 measures 9.55:1, emerald-400 (active-state color) measures
       7.34:1 -- both still comfortably past AA. */
    .fx-mobile-bottom-nav {
        position: fixed;
        left: 50%;
        bottom: 18px;
        transform: translateX(-50%);
        z-index: 35;
        display: flex;
        align-items: center;
        gap: 4px;
        padding: 9px;
        max-width: calc(100vw - 32px);
        overflow-x: auto;
        touch-action: pan-x pan-y;
        scrollbar-width: none;
        -webkit-overflow-scrolling: touch;
        border-radius: 9999px;
        background: color-mix(in srgb, color-mix(in srgb, #0F172A 85%, #10B981 15%) 62%, transparent);
        backdrop-filter: blur(20px);
        -webkit-backdrop-filter: blur(20px);
        border: 1px solid rgba(255, 255, 255, 0.14);
        box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
    }
    /* [Real bug, Marc's direct report: "fades on the edges instead of a
       uniform border tone", and "the settings icon is almost peaking out
       of the pill... not balanced across different device resolutions"]
       Two real, separate causes, not one: (1) the mask-image edge-fade
       used to apply UNCONDITIONALLY, so a phone wide enough to fit all 7
       icons with real room to spare (Marc's own "bigger phone" test) still
       showed a fade baked into both edges of an otherwise non-scrolling
       pill -- a uniform border reads as intentional; a fade on a
       non-scrolling element reads as a rendering defect. Now applied ONLY
       via .has-overflow, a class fxBuildMobileBottomNav()/resize handling
       toggles from real scrollWidth>clientWidth measurement (dashboard.js),
       never assumed from viewport width alone. (2) padding raised 6px->9px:
       on a genuinely narrower phone where 7x44px icons truly don't fit
       (this skill's own §7.1 arithmetic: 344px of icons+gaps vs. 320px of
       viewport), the last icon's flat edge sat close enough to the pill's
       own 9999px border-radius curve that the curve visually clipped into
       it -- more end-cap padding gives the curve room to close without
       touching icon content, matching the comfortable clearance already
       visible on a wider phone. */
    .fx-mobile-bottom-nav.has-overflow {
        -webkit-mask-image: linear-gradient(to right, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
        mask-image: linear-gradient(to right, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
    }
    .fx-mobile-bottom-nav a {
        display: flex;
        align-items: center;
        justify-content: center;
        width: 44px;
        height: 44px;
        flex-shrink: 0;
        border-radius: 9999px;
        color: rgba(255, 255, 255, 0.8);
        transition: background-color 150ms ease, color 150ms ease;
    }
    .fx-mobile-bottom-nav a.is-active {
        background: rgba(52, 211, 153, 0.18);
        color: #34D399;
    }

    /* [Marc's direct correction] The floating nav is used far more often
       than any single page's own CTA dock, so it stays thumb-closest (its
       normal bottom:18px, unmoved) on every page including Float/Packages/
       Packages-Create -- the CTA dock is the one that gets pushed up clear
       of it instead. See the real body.has-cta-dock .fx-cta-dock /
       .fx-main overrides further down this file (same selectors as the
       CTA dock's own original rule, kept together rather than duplicated
       at a second breakpoint to avoid a real source-order conflict between
       this file's two media queries). */
    .fx-main { padding-bottom: 96px; }

    /* Sidebar drawer (z-index 40, dashboard.css line ~174) covers both new
       elements when open -- both stay at z-index 35/30, never compete. */
}

/* Eyebrow pill — the locked "pill/badge eyebrow label" landing pattern
   (visual-system §2): uppercase, letter-spaced, above a heading. */
.fx-eyebrow {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 12px;
    border-radius: 9999px;
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    background: var(--fx-accent-soft);
    color: var(--fx-accent-strong);
    border: 1px solid color-mix(in srgb, var(--fx-accent) 25%, transparent);
}

/* ---------- cards ---------- */

/* [Impeccable gpt-thin-border-wide-shadow, Inquiry 203/204, re-confirmed
   still live and fixed at Inquiry 236] A 1px border on all four sides
   paired with a wide diffuse box-shadow at rest is a known generated-UI
   signature ("commit to one -- a defined edge or a soft elevation --
   rather than both at once", the detector's own guidance). .fx-card is
   this app's single most reused component (every page's panels/tables/
   filter bars), so this one shared rule was the dominant source of most
   of the 70 findings. Committed to the defined-edge half: the shadow is
   dropped, --fx-border already does real, visible separation work in
   both themes (a real border color against --fx-surface, not decorative)
   without needing a second, largely-imperceptible soft-elevation cue on
   top of it. */
.fx-card {
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: var(--fx-radius-outer);
    transition: background-color 200ms ease, border-color 200ms ease;
}

.fx-card-inner {
    background: var(--fx-surface-inner);
    border-radius: var(--fx-radius-inner);
}

.fx-stat-card {
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: var(--fx-radius-outer);
    padding: 20px;
    transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease, background-color 200ms ease;
}
.fx-stat-card:hover {
    /* [Impeccable gpt-thin-border-wide-shadow, Inquiry 236] Same fix as
       .fx-card above: the --fx-shadow-card layer is dropped, the accent
       ring stays as the real hover cue. */
    border-color: color-mix(in srgb, var(--fx-accent) 35%, var(--fx-border));
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--fx-accent) 8%, transparent);
    transform: translateY(-1px);
}

.fx-stat-icon {
    width: 40px;
    height: 40px;
    border-radius: var(--fx-radius-inner);
    background: var(--fx-accent-soft);
    color: var(--fx-accent-strong);
    display: flex;
    align-items: center;
    justify-content: center;
}

.fx-stat-value {
    font-size: 24px;
    font-weight: 700;
    font-family: 'Lexend', 'Inter', sans-serif;
    letter-spacing: -0.01em;
    font-variant-numeric: tabular-nums;
}

.fx-stat-label {
    font-size: 12px;
    color: var(--fx-muted);
    font-weight: 600;
}

.fx-stat-sub {
    font-size: 11px;
    color: var(--fx-muted);
    margin-top: 6px;
}

/* [Inquiry 236] Usage Analytics' 2-up mobile stat grid (fx-stats-compact,
   usage-analytics.html) -- tightens padding/icon/label size specifically
   where cards sit at roughly half their normal mobile width, so a longer
   label ("Session Engagement Time", "Network Consumption Volume") wraps to
   2 lines instead of 3-4. Scoped to this marker class, not the shared
   .fx-stat-card default, so the Dashboard homepage's own single-column
   mobile stat cards (Net Sales/Voucher Sales/Available Balance/System
   Diagnostics -- confirmed via live measurement to already render fully
   within the first mobile screen, no Screen Test issue) are unaffected. */
@media (max-width: 639px) {
    .fx-stats-compact .fx-stat-card { padding: 14px; }
    .fx-stats-compact .fx-stat-card > div:first-child { gap: 6px; }
    .fx-stats-compact .fx-stat-icon { width: 30px; height: 30px; flex-shrink: 0; }
    .fx-stats-compact .fx-stat-icon svg { width: 14px; height: 14px; }
    .fx-stats-compact .fx-stat-label { font-size: 11px; line-height: 1.25; }
    .fx-stats-compact .fx-stat-value { font-size: 19px; }
}

/* [Inquiry 220] Net Sales card's real MTN/Airtel brand marks, replacing a
   plain "MM:" text label per Marc's explicit ask. White backing chip since
   both source SVGs are colour marks meant to sit on a light card, same as
   how the payment portal already presents them. */
.fx-mm-badge {
    display: block;
    background: #fff;
    border-radius: 4px;
    padding: 1px;
    object-fit: contain;
}

/* ---------- System Diagnostics card (dashboard home, Row 1 4th slot) ----------
   Three-metric layout matching XenFi's own "System Insights" panel
   (Active / CPU / Data Usage) rather than this card's old single-number
   layout — Marc's direct request. On mobile this card also moves to the
   FIRST position in the row via order (see the max-width query below),
   ahead of Net Sales/Voucher Sales/Balance, while staying in its natural
   4th position on desktop — order changes paint position only, not DOM
   order, so no markup duplication or JS reordering is needed. */
.fx-diagnostics-row {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 8px;
}
.fx-diagnostics-metric {
    text-align: center;
    padding: 8px 4px;
    border-radius: var(--fx-radius-tight);
    background: var(--fx-surface-inner);
}
.fx-diagnostics-metric i { color: var(--fx-muted); }
.fx-diagnostics-value {
    font-size: 15px;
    font-weight: 700;
    font-family: 'Lexend', 'Inter', sans-serif;
    font-variant-numeric: tabular-nums;
    margin: 4px 0 1px;
}
.fx-diagnostics-label {
    font-size: 10px;
    color: var(--fx-muted);
    font-weight: 600;
}
@media (max-width: 900px) {
    .fx-diagnostics-card { order: -1; }
}

/* ---------- status dot / badges ---------- */

.fx-status-dot {
    width: 8px;
    height: 8px;
    border-radius: 9999px;
    background: var(--fx-accent);
    display: inline-block;
    position: relative;
}

.fx-status-dot.is-pulsing::after {
    content: '';
    position: absolute;
    inset: -4px;
    border-radius: 9999px;
    background: var(--fx-accent);
    opacity: 0.35;
    animation: fx-pulse-ring 1.8s ease-out infinite;
}

.fx-status-dot.is-offline { background: var(--fx-danger); }
.fx-status-dot.is-offline.is-pulsing::after { background: var(--fx-danger); }

@keyframes fx-pulse-ring {
    0% { transform: scale(1); opacity: 0.5; }
    100% { transform: scale(2.4); opacity: 0; }
}

.fx-pill {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 10px;
    border-radius: 9999px;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.02em;
    /* A status pill is a single atomic label; it must never break across
       lines (e.g. "Not checked yet" wrapping to two rows inside a narrow
       mobile table cell). Safe as a shared default because pill text is
       always short, and the tables that host pills already live inside an
       overflow-x:auto container. [Inquiry 257 Step 1a] */
    white-space: nowrap;
    background: var(--fx-accent-soft);
    color: var(--fx-accent-strong);
    transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
}

.fx-pill.is-muted {
    background: var(--fx-surface-inner);
    color: var(--fx-muted);
}

.fx-pill.is-danger {
    background: var(--fx-danger-soft);
    color: var(--fx-danger);
}

.fx-pill.is-warning {
    background: var(--fx-warning-soft);
    color: var(--fx-warning);
}

/* Interactive pills (the History/Vouchers tab strips set cursor:pointer inline) */
button.fx-pill:hover { box-shadow: 0 0 0 3px var(--fx-ring); }

/* ---------- coming soon / disabled ---------- */

.fx-coming-soon {
    position: relative;
    opacity: 0.6;
}

.fx-coming-soon::before {
    content: '';
    position: absolute;
    inset: 0;
    cursor: not-allowed;
    z-index: 5;
}

.fx-coming-soon-badge {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    padding: 3px 9px;
    border-radius: 9999px;
    background: var(--fx-surface-inner);
    color: var(--fx-muted);
    border: 1px dashed var(--fx-border);
}

/* ---------- buttons ---------- */

.fx-btn-primary {
    /* [Impeccable low-contrast, Inquiry 203/204, re-confirmed still live]
       White text on the plain --fx-accent (#10B981) fill measures 2.5:1,
       well under WCAG AA's 4.5:1 floor for normal-weight 14px text. The
       gradient's lighter (top) stop is now #047857 (emerald-700, already
       used elsewhere in this file as the darker mix target) instead of
       --fx-accent -- white-on-#047857 measures ~5.5:1. Darkens the button
       slightly; the emerald identity and gradient depth are unchanged. */
    background: linear-gradient(180deg, #047857, color-mix(in srgb, #047857 82%, black));
    color: #fff;
    font-weight: 600;
    font-size: 14px;
    border-radius: var(--fx-radius-inner);
    padding: 10px 18px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    border: 1px solid color-mix(in srgb, var(--fx-accent-strong) 55%, transparent);
    box-shadow: var(--fx-shadow-btn), inset 0 1px 0 rgba(255, 255, 255, 0.18);
    transition: filter 150ms ease, transform 150ms ease, box-shadow 150ms ease;
}
.fx-btn-primary:hover:not(:disabled) {
    filter: brightness(1.06);
    transform: translateY(-1px);
    box-shadow: 0 4px 14px -3px color-mix(in srgb, var(--fx-accent-strong) 55%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.fx-btn-primary:active:not(:disabled) {
    transform: translateY(0) scale(0.985);
    filter: brightness(0.97);
}
.fx-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }

.fx-btn-secondary {
    background: var(--fx-surface);
    color: var(--fx-text);
    border: 1px solid var(--fx-border);
    font-weight: 600;
    font-size: 14px;
    border-radius: var(--fx-radius-inner);
    padding: 10px 18px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    transition: border-color 150ms ease, background-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
}
/* [Interaction-system audit — Tier 2, standard/secondary action]
   Was a full emerald recolour (border+bg+text -> accent), which made every
   utility button in this vocabulary -- Export CSV AND every modal Cancel/Back,
   Test Connection, Copy, etc. -- light up in the brand's confirm colour on
   hover, competing with the real primary action and giving a "Cancel" the
   visual language of "go". Now a neutral elevation: the border strengthens,
   the surface deepens one step, and it lifts 1px with the shared neutral
   shadow -- clearly interactive, clearly subordinate to .fx-btn-primary. */
.fx-btn-secondary:hover {
    border-color: var(--fx-border-strong);
    background: var(--fx-surface-inner);
    transform: translateY(-1px);
    box-shadow: var(--fx-shadow-hover);
}

/* Default (desktop/full-label) state: only the full "Export CSV" text shows.
   The compact "CSV"-only variant is opt-in, switched on above only inside
   .fx-mobile-topbar-actions (the pinned mobile chip). */
.fx-btn-label-compact { display: none; }
.fx-btn-secondary:active { transform: translateY(0) scale(0.985); }

/* Loading affordance: add .is-loading to either button style. */
.fx-btn-primary.is-loading, .fx-btn-secondary.is-loading {
    position: relative;
    color: transparent !important;
    pointer-events: none;
}
.fx-btn-primary.is-loading::after, .fx-btn-secondary.is-loading::after {
    content: '';
    position: absolute;
    width: 16px;
    height: 16px;
    border: 2px solid rgba(255, 255, 255, 0.35);
    border-top-color: #fff;
    border-radius: 50%;
    animation: fx-spin 0.7s linear infinite;
}
.fx-btn-secondary.is-loading::after {
    border-color: var(--fx-ring);
    border-top-color: var(--fx-accent);
}

.fx-icon-btn {
    width: 36px;
    height: 36px;
    border-radius: var(--fx-radius-inner);
    border: 1px solid var(--fx-border);
    background: var(--fx-surface);
    color: var(--fx-muted);
    display: flex;
    align-items: center;
    justify-content: center;
    transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease, transform 150ms ease;
}
.fx-icon-btn:hover {
    color: var(--fx-accent-strong);
    border-color: var(--fx-accent);
    background: var(--fx-accent-soft);
}
.fx-icon-btn:active { transform: scale(0.94); }

/* ---------- form fields — label above input, 12px gap, 24px between groups ---------- */

.fx-field-group { margin-bottom: 24px; }
.fx-field-group:last-child { margin-bottom: 0; }

.fx-label {
    display: block;
    font-size: 13px;
    font-weight: 600;
    color: var(--fx-text);
    margin-bottom: 12px;
}

.fx-input, .fx-select {
    width: 100%;
    background: var(--fx-surface-inner);
    border: 1px solid var(--fx-border);
    color: var(--fx-text);
    border-radius: var(--fx-radius-inner);
    padding: 10px 14px;
    font-size: 14px;
    transition: border-color 150ms ease, box-shadow 150ms ease, background-color 200ms ease;
}
.fx-input::placeholder { color: color-mix(in srgb, var(--fx-muted) 75%, transparent); }
.fx-input:hover:not(:disabled):not(:focus), .fx-select:hover:not(:disabled):not(:focus) {
    border-color: var(--fx-border-strong);
}
.fx-input:focus, .fx-select:focus {
    outline: none;
    border-color: var(--fx-accent);
    box-shadow: 0 0 0 3px var(--fx-ring);
}
.fx-input:disabled { opacity: 0.6; cursor: not-allowed; }

/* Kill the native number spinners (vouchers quantity etc.) */
.fx-input::-webkit-outer-spin-button, .fx-input::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}
.fx-input[type="number"] { -moz-appearance: textfield; appearance: textfield; }

/* ---------- OTP digit boxes (sign-in Step 2) — six individual boxes, not
   one field, per Marc's own explicit convention: "matches how users read
   codes, easier to spot mistakes". Boxed with a background, same radius
   token as every other input (radius-inner), not an underline style. ---------- */

.fx-otp-row {
    display: flex;
    gap: 10px;
    justify-content: space-between;
}

.fx-otp-box {
    width: 100%;
    aspect-ratio: 1 / 1;
    max-width: 52px;
    background: var(--fx-surface-inner);
    border: 1px solid var(--fx-border);
    color: var(--fx-text);
    border-radius: var(--fx-radius-inner);
    font-size: 20px;
    font-weight: 700;
    text-align: center;
    font-variant-numeric: tabular-nums;
    font-family: 'Lexend', 'Inter', sans-serif;
    transition: border-color 150ms ease, box-shadow 150ms ease, background-color 200ms ease;
}
.fx-otp-box:focus {
    outline: none;
    border-color: var(--fx-accent);
    box-shadow: 0 0 0 3px var(--fx-ring);
}
.fx-otp-box.is-invalid {
    border-color: var(--fx-danger);
    box-shadow: 0 0 0 3px var(--fx-danger-soft);
}
.fx-otp-box::-webkit-outer-spin-button, .fx-otp-box::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

/* When dashboard.js has mounted a custom widget over a native control,
   the native element stays in the DOM as the value store, visually gone. */
.fx-native-hidden {
    position: absolute !important;
    width: 1px; height: 1px;
    opacity: 0;
    pointer-events: none;
    overflow: hidden;
    clip-path: inset(50%);
}

/* ---------- custom select (mounted by dashboard.js over select.fx-select) ---------- */

.fx-selectw { position: relative; width: 100%; }
.fx-selectw.is-auto { width: auto; min-width: 160px; }

.fx-select-btn {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    background: var(--fx-surface-inner);
    border: 1px solid var(--fx-border);
    color: var(--fx-text);
    border-radius: var(--fx-radius-inner);
    padding: 10px 14px;
    font-size: 14px;
    text-align: left;
    cursor: pointer;
    transition: border-color 150ms ease, box-shadow 150ms ease, background-color 200ms ease;
}
/* [Interaction-system audit — Tier 3] Enhanced-select trigger: the other
   top-bar/filter control (site filters, package/channel filters). Same
   neutral elevation as the calendar so the whole top-bar control family
   shares one hover language. */
.fx-select-btn:hover { border-color: var(--fx-border-strong); box-shadow: var(--fx-shadow-hover); }
.fx-select-btn:disabled, .fx-select-btn.is-disabled {
    opacity: 0.6;
    cursor: not-allowed;
}
.fx-select-btn:disabled:hover, .fx-select-btn.is-disabled:hover { border-color: var(--fx-border); }
.fx-selectw.is-open .fx-select-btn {
    border-color: var(--fx-accent);
    box-shadow: 0 0 0 3px var(--fx-ring);
}
.fx-select-btn .fx-caret {
    flex-shrink: 0;
    color: var(--fx-muted);
    transition: transform 200ms cubic-bezier(0.34, 1.4, 0.64, 1);
}
.fx-selectw.is-open .fx-caret { transform: rotate(180deg); color: var(--fx-accent-strong); }

.fx-menu {
    position: absolute;
    top: calc(100% + 6px);
    left: 0;
    min-width: 100%;
    z-index: 55;
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: var(--fx-radius-inner);
    box-shadow: var(--fx-shadow-pop);
    padding: 6px;
    max-height: 280px;
    overflow-y: auto;
    display: none;
}
.fx-selectw.is-open .fx-menu, .fx-datew.is-open .fx-menu { display: block; animation: fx-pop-in 160ms cubic-bezier(0.34, 1.3, 0.64, 1); }
.fx-menu.drop-up { top: auto; bottom: calc(100% + 6px); transform-origin: bottom center; }

@keyframes fx-pop-in {
    from { opacity: 0; transform: translateY(-4px) scale(0.98); }
    to { opacity: 1; transform: translateY(0) scale(1); }
}
.fx-menu.drop-up.fx-menu, .fx-selectw.is-open .fx-menu.drop-up { animation: fx-pop-in-up 160ms cubic-bezier(0.34, 1.3, 0.64, 1); }
@keyframes fx-pop-in-up {
    from { opacity: 0; transform: translateY(4px) scale(0.98); }
    to { opacity: 1; transform: translateY(0) scale(1); }
}

.fx-menu::-webkit-scrollbar { width: 8px; }
.fx-menu::-webkit-scrollbar-thumb { background: var(--fx-border-strong); border-radius: 8px; }
.fx-menu::-webkit-scrollbar-track { background: transparent; }

.fx-option {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    width: 100%;
    text-align: left;
    padding: 9px 12px;
    border-radius: var(--fx-radius-tight);
    font-size: 13px;
    font-weight: 500;
    color: var(--fx-text);
    cursor: pointer;
    white-space: nowrap;
    transition: background-color 100ms ease, color 100ms ease;
}
.fx-option:hover, .fx-option.is-hilite { background: var(--fx-surface-inner); }
.fx-option.is-selected {
    background: var(--fx-accent-soft);
    color: var(--fx-accent-strong);
    font-weight: 600;
}
.fx-option .fx-option-check { flex-shrink: 0; opacity: 0; transform: scale(0.5); transition: opacity 120ms ease, transform 160ms cubic-bezier(0.34, 1.5, 0.64, 1); }
.fx-option.is-selected .fx-option-check { opacity: 1; transform: scale(1); }

/* [Inquiry 256] Kebab (vertical-dots) row-action menu — a shared, body-level,
   position:fixed popover so a table row's actions are never clipped by the
   table's own overflow-x scroll container. Replaces per-row edge icons with a
   single kebab that reveals the actions as words (settings-routers Sites). */
.fx-row-menu {
    position: fixed;
    z-index: 60;
    min-width: 184px;
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: var(--fx-radius-inner);
    box-shadow: var(--fx-shadow-pop);
    padding: 6px;
    animation: fx-pop-in 140ms cubic-bezier(0.34, 1.3, 0.64, 1);
}
.fx-row-menu-item {
    display: flex;
    align-items: center;
    gap: 10px;
    width: 100%;
    text-align: left;
    padding: 9px 12px;
    border: 0;
    background: transparent;
    border-radius: var(--fx-radius-tight);
    font-size: 13px;
    font-weight: 500;
    color: var(--fx-text);
    cursor: pointer;
    transition: background-color 100ms ease;
}
.fx-row-menu-item:hover { background: var(--fx-surface-inner); }
.fx-row-menu-item[disabled] { opacity: 0.4; cursor: not-allowed; }
.fx-row-menu-item.is-danger { color: var(--fx-danger); }

/* ---------- custom date-picker (mounted by dashboard.js over input[type=date]) ---------- */

.fx-datew { position: relative; width: 100%; }
.fx-datew.is-auto { width: auto; min-width: 148px; }

.fx-date-btn {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 9px;
    background: var(--fx-surface-inner);
    border: 1px solid var(--fx-border);
    color: var(--fx-text);
    border-radius: var(--fx-radius-inner);
    padding: 10px 14px;
    font-size: 14px;
    text-align: left;
    cursor: pointer;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
    transition: border-color 150ms ease, box-shadow 150ms ease, background-color 200ms ease;
}
.fx-date-btn:hover { border-color: var(--fx-border-strong); }
.fx-datew.is-open .fx-date-btn {
    border-color: var(--fx-accent);
    box-shadow: 0 0 0 3px var(--fx-ring);
}
.fx-date-btn .fx-date-ic { color: var(--fx-muted); flex-shrink: 0; }
.fx-datew.is-open .fx-date-ic { color: var(--fx-accent-strong); }
.fx-date-btn .fx-date-placeholder { color: color-mix(in srgb, var(--fx-muted) 75%, transparent); }

.fx-cal { width: 268px; padding: 12px; max-height: none; overflow: visible; }

.fx-cal-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 6px;
    margin-bottom: 10px;
}
.fx-cal-title {
    font-family: 'Lexend', 'Inter', sans-serif;
    font-size: 13px;
    font-weight: 700;
}
.fx-cal-nav {
    width: 28px;
    height: 28px;
    border-radius: 9px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--fx-muted);
    cursor: pointer;
    transition: background-color 120ms ease, color 120ms ease;
}
.fx-cal-nav:hover { background: var(--fx-surface-inner); color: var(--fx-accent-strong); }

.fx-cal-grid {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 2px;
}
.fx-cal-dow {
    text-align: center;
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--fx-muted);
    padding: 4px 0 6px;
}
.fx-cal-day {
    height: 32px;
    border-radius: 9px;
    font-size: 12.5px;
    font-weight: 500;
    color: var(--fx-text);
    cursor: pointer;
    font-variant-numeric: tabular-nums;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background-color 100ms ease, color 100ms ease, transform 100ms ease;
}
.fx-cal-day:hover { background: var(--fx-surface-inner); }
.fx-cal-day:active { transform: scale(0.9); }
.fx-cal-day.is-outside { color: color-mix(in srgb, var(--fx-muted) 45%, transparent); }
.fx-cal-day.is-today { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--fx-accent) 55%, transparent); font-weight: 700; }
.fx-cal-day.is-selected {
    background: var(--fx-accent);
    color: #fff;
    font-weight: 700;
    box-shadow: var(--fx-shadow-btn);
}

.fx-cal-foot {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 10px;
    padding-top: 10px;
    border-top: 1px solid var(--fx-border);
}
.fx-cal-foot button {
    font-size: 11px;
    font-weight: 700;
    color: var(--fx-accent-strong);
    padding: 4px 8px;
    border-radius: 8px;
    cursor: pointer;
    transition: background-color 120ms ease;
}
.fx-cal-foot button:hover { background: var(--fx-accent-soft); }

/* ---------- radio / checkbox — custom-drawn, zero-markup-change ---------- */

.fx-radio-row { display: flex; flex-direction: column; gap: 10px; }
.fx-radio-option {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 14px;
    border-radius: var(--fx-radius-inner);
    border: 1px solid var(--fx-border);
    background: var(--fx-surface-inner);
    font-size: 13px;
    cursor: pointer;
    transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
}
.fx-radio-option:hover { border-color: var(--fx-border-strong); }
.fx-radio-option:has(input:checked) {
    border-color: var(--fx-accent);
    background: var(--fx-accent-soft);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--fx-accent) 10%, transparent);
}

/* ---------- voucher print-design picker (vouchers.html) ---------- */

/* Real in-progress state while POST /vouchers/batch is in flight (Marc's
   direct request, 2026-08-18: the previous 0.6-opacity dim + abrupt
   modal-close "read like an error message, not like something being
   made"). Covers the grid so no card is clickable mid-request. */
.fx-tpl-loading-overlay {
    position: absolute;
    inset: 0;
    background: color-mix(in srgb, var(--fx-surface) 92%, transparent);
    border-radius: var(--fx-radius-inner);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 12px;
    z-index: 5;
}
.fx-tpl-loading-overlay.hidden { display: none; }

.fx-tpl-grid {
    display: grid;
    /* min(300px, 100%) instead of a bare 300px -- on a narrow phone
       viewport (confirmed live down to 320px) a bare minmax(300px, 1fr)
       forces a 300px-wide track even inside a narrower modal panel,
       overflowing the card and clipping its own text/image at the right
       edge. Capping the minimum at the container's own available width
       lets a single column shrink to fit instead of forcing overflow. */
    grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
    gap: 14px;
    max-height: 72vh;
    overflow-y: auto;
    padding: 2px;
}
.fx-tpl-option {
    text-align: left;
    border: 1px solid var(--fx-border);
    background: var(--fx-surface-inner);
    border-radius: var(--fx-radius-inner);
    padding: 10px;
    cursor: pointer;
    transition: border-color 150ms ease, box-shadow 150ms ease, transform 100ms ease;
    display: flex;
    flex-direction: column;
    gap: 8px;
}
.fx-tpl-option:hover { border-color: var(--fx-border-strong); transform: translateY(-1px); }
.fx-tpl-option.fx-tpl-option--selected {
    border-color: var(--fx-accent);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--fx-accent) 12%, transparent);
}
.fx-tpl-option-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.fx-tpl-option-name { font-size: 13px; font-weight: 600; }
.fx-tpl-check {
    width: 18px; height: 18px; border-radius: 999px;
    display: flex; align-items: center; justify-content: center;
    background: var(--fx-accent); color: white; flex-shrink: 0;
}
.fx-tpl-option-desc { font-size: 11px; color: var(--fx-muted); line-height: 1.35; min-height: 28px; }
.fx-tpl-badges { display: flex; flex-wrap: wrap; gap: 4px; }
.fx-tpl-badge {
    font-size: 10px; padding: 2px 7px; border-radius: 999px;
    background: var(--fx-surface); border: 1px solid var(--fx-border); color: var(--fx-muted);
}

/* Real rendered preview thumbnail (D-2026-08-18-001) — a real, legible
   screenshot of that template's own actual PDF output (generated by
   scripts/generate_voucher_template_preview_images.py against
   representative sample data, cropped to two real card rows), not a CSS
   approximation. Deliberately paper-white regardless of dashboard theme,
   same as how a real print preview always shows white paper; a light
   border keeps the image edge visible against a white page background. */
.fx-tpl-thumb-img {
    display: block;
    width: 100%;
    height: auto;
    border-radius: 6px;
    border: 1px solid #d1d5db;
    background: #ffffff;
}

.fx-radio-option input[type="radio"],
.fx-package-option input[type="radio"] {
    appearance: none;
    -webkit-appearance: none;
    width: 18px;
    height: 18px;
    flex-shrink: 0;
    margin: 0;
    border-radius: 9999px;
    border: 1.5px solid var(--fx-border-strong);
    background: var(--fx-surface);
    display: inline-grid;
    place-content: center;
    cursor: pointer;
    transition: border-color 150ms ease, background-color 150ms ease;
}
.fx-radio-option input[type="radio"]::before,
.fx-package-option input[type="radio"]::before {
    content: '';
    width: 8px;
    height: 8px;
    border-radius: 9999px;
    background: #fff;
    transform: scale(0);
    transition: transform 180ms cubic-bezier(0.34, 1.6, 0.64, 1);
}
.fx-radio-option input[type="radio"]:checked,
.fx-package-option input[type="radio"]:checked {
    border-color: var(--fx-accent);
    background: var(--fx-accent);
}
.fx-radio-option input[type="radio"]:checked::before,
.fx-package-option input[type="radio"]:checked::before { transform: scale(1); }

/* ---------- skeleton loading (perceived performance on slow networks) ----------
   Real placeholder blocks swapped in for a table's <tbody> or a stat
   card's value while a fetch is in flight, replaced with real content (or
   a real empty/error state) once it resolves -- never left showing a
   fabricated "0"/"—" that could read as real data for a moment on a slow
   connection. See fxSkeletonRows() in dashboard.js for the table-row
   generator this pairs with. */

@keyframes fx-skeleton-shimmer { to { background-position: -135% 0; } }

.fx-skeleton {
    display: block;
    border-radius: 6px;
    background: linear-gradient(100deg, var(--fx-surface-inner) 30%, var(--fx-border-strong) 50%, var(--fx-surface-inner) 70%);
    background-size: 250% 100%;
    animation: fx-skeleton-shimmer 1.3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
    .fx-skeleton { animation: none; }
}

/* ---------- skeleton shape variants (geometry only -- always combined
   with .fx-skeleton, which owns paint + shimmer + reduced-motion, so every
   shape below inherits the accessible behavior for free) ----------
   Each variant mirrors a real component's box so the content swap is
   layout-shift-free: pills are pill-shaped, amounts right-aligned bars,
   codes mono-width bars, avatars circles, charts fixed-height blocks. */
.fx-sk-pill { height: 22px; border-radius: 9999px; }
.fx-sk-amount { height: 18px; margin-left: auto; }
.fx-sk-mono { height: 14px; }
.fx-sk-avatar { width: 40px; height: 40px; border-radius: 9999px; flex-shrink: 0; }
.fx-sk-chart { min-height: 220px; border-radius: 8px; }
.fx-sk-value { height: 30px; }
.fx-sk-label { height: 14px; }

/* Reserved layout for async regions: empty containers hold their final
   footprint before JS runs, so first paint already reserves the space. */
#fx-revenue-bars:empty { min-height: 220px; }
#fx-chart-cpu-memory:empty, #fx-chart-traffic:empty,
#fx-chart-active-users:empty { min-height: 200px; }
#fx-recent-sales:empty { min-height: 248px; }

/* Stale-while-revalidate dimming: a re-scoped panel keeps showing its old
   rows (dimmed, honestly stale) instead of flashing back to skeleton. */
.fx-stale-dimming { opacity: 0.55; transition: opacity 300ms ease; }

/* [Marc's direct report: skeleton "scale doesn't match positions", jumps on
   reveal] #fx-page-skeleton (dashboard pre-auth loader) now mirrors .fx-app's
   real shell so nothing shifts when the real content replaces it:
   - Desktop: a 264px sidebar placeholder + a flex-1 main column, so the main
     content skeleton sits exactly where the real .fx-main will.
   - Mobile: the sidebar collapses (as the real one does) and a top-bar
     placeholder reserves the sticky FLUXI bar's height, so cards don't jump
     down ~100px on reveal. */
#fx-page-skeleton { display: flex; min-height: 100vh; min-height: 100dvh; }
.fx-skeleton-sidebar {
    width: 264px;
    flex-shrink: 0;
    background: var(--fx-surface);
    border-right: 1px solid var(--fx-border);
}
.fx-skeleton-main { flex: 1; min-width: 0; padding: 24px 24px 32px; }
.fx-skeleton-mobiletop { display: none; }
@media (max-width: 900px) {
    .fx-skeleton-sidebar { display: none; }
    .fx-skeleton-main { padding: 16px 16px 28px; }
    .fx-skeleton-mobiletop {
        display: flex;
        align-items: center;
        gap: 8px;
        margin-bottom: 14px;
    }
    .fx-skeleton-mobilesite {
        height: 44px;
        width: 160px;
        border-radius: var(--fx-radius-inner);
        margin-bottom: 6px;
    }
}
.fx-skeleton-mobilesite { display: none; }
@media (max-width: 900px) { .fx-skeleton-mobilesite { display: block; } }

/* ---------- pull-to-refresh [Marc's direct report: "i cant swipe down in
   mobile view to refresh across different browser types...a user may need to
   refresh when they are in an area of poor internet"] ----------
   The app sets `overscroll-behavior: none` on the body (stops the rubber-band
   bounce), which as a side effect also disables the browser's OWN native
   pull-to-refresh -- and once installed as a standalone PWA there is no
   browser chrome to pull at all. So we render our own: a small floating
   spinner that a downward swipe from the very top drags into view, and which
   triggers a real reload past a threshold. Hidden on desktop (no touch pull
   there). */
.fx-ptr {
    position: fixed;
    /* [Marc's screenshots, Opus pass: the pill was parked fully on-screen at
       rest -- "the pull to refresh button that is almost on top of
       everything" -- overlapping the logo/calendar/hamburger on every page.
       Root cause: top:-50px was exactly cancelled by the resting
       translateY(50px), so net Y was 0 (dead on the top edge) at rest.]
       The pill now rests HIDDEN: opacity 0 and parked just above the top
       edge. JS (setVisual) fades + slides it in only during an active pull,
       and it never travels far enough down to reach the bottom nav. */
    top: -10px;
    left: 50%;
    transform: translateX(-50%) translateY(0);
    opacity: 0;
    z-index: 60;
    display: none;
    align-items: center;
    gap: 8px;
    height: 38px;
    padding: 0 16px;
    border-radius: 9999px;
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    box-shadow: var(--fx-shadow-pop);
    color: var(--fx-muted);
    font-size: 12px;
    font-weight: 600;
    white-space: nowrap;
    pointer-events: none;
}
@media (max-width: 900px) {
    .fx-ptr { display: flex; }
}
.fx-ptr.is-ready { color: var(--fx-accent); border-color: var(--fx-accent); }
.fx-ptr-spinner { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.fx-ptr.is-refreshing .fx-ptr-spinner { animation: fx-ptr-spin 0.7s linear infinite; }
@keyframes fx-ptr-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
    .fx-ptr.is-refreshing .fx-ptr-spinner { animation: none; }
}

/* ---------- "Install FLUXI" prompt ----------
   Mounted by fxInitInstallPrompt() (dashboard.js) on `beforeinstallprompt`
   (Android/Chrome/Edge) or directly on iOS Safari (which never fires that
   event -- see that function's own comment). Deliberately reuses this file's
   existing floating-card visual language (surface/border/shadow-pop, same
   as .fx-ptr/.fx-modal) rather than inventing new decoration. Bottom-center
   on mobile, clear of both the bottom-nav pill and (where present) the
   floating CTA dock; a small bottom-left card on desktop. */
.fx-install-banner {
    position: fixed;
    z-index: 38; /* above the bottom nav (35), below any open drawer/modal (40) */
    display: flex;
    align-items: center;
    gap: 10px;
    max-width: 360px;
    padding: 12px 12px 12px 14px;
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: var(--fx-radius-outer);
    box-shadow: var(--fx-shadow-pop);
}
.fx-install-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    flex-shrink: 0;
    border-radius: 9999px;
    background: var(--fx-accent-soft);
    color: var(--fx-accent-strong);
}
.fx-install-copy { min-width: 0; }
.fx-install-title { font-size: 13px; font-weight: 700; color: var(--fx-text); }
.fx-install-sub { font-size: 11.5px; color: var(--fx-muted); margin-top: 1px; }
.fx-install-cta {
    flex-shrink: 0;
    padding: 7px 14px;
    border-radius: 9999px;
    background: var(--fx-accent);
    color: #06281F;
    font-size: 12px;
    font-weight: 700;
    border: none;
    cursor: pointer;
}
.fx-install-close {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    border-radius: 9999px;
    border: none;
    background: transparent;
    color: var(--fx-muted);
    cursor: pointer;
}
.fx-install-close:hover { background: var(--fx-surface-inner); color: var(--fx-text); }

@media (max-width: 900px) {
    .fx-install-banner {
        left: 12px;
        right: 12px;
        bottom: 90px; /* clears the bottom-nav pill (bottom:18px + ~62px) */
        max-width: none;
    }
    /* Pages that still carry the floating CTA dock (Float, Packages-Create)
       need the banner pushed clear of THAT too -- the dock itself already
       sits at bottom:84px per its own rule. */
    body.has-cta-dock .fx-install-banner { bottom: 150px; }
}
@media (min-width: 901px) {
    .fx-install-banner { left: 24px; bottom: 24px; }
}

/* ---------- pager (rows-per-page + first/prev/next/last) ----------
   Mounted by fxCreatePager() in dashboard.js onto an empty container
   below a paginated table. See that function's own header comment for
   the mount contract. */

.fx-pager {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    flex-wrap: wrap;
    margin-top: 16px;
    padding-top: 16px;
    border-top: 1px solid var(--fx-border);
}
.fx-pager-size { display: flex; align-items: center; gap: 10px; }
.fx-pager-size-label { font-size: 12px; color: var(--fx-muted); font-weight: 600; white-space: nowrap; }
.fx-pager .fx-selectw.is-auto { min-width: 84px; }
.fx-pager-size-select { padding: 6px 10px !important; font-size: 13px !important; }
.fx-pager-count { font-size: 12px; color: var(--fx-muted); white-space: nowrap; }
.fx-pager-nav { display: flex; align-items: center; gap: 6px; }

@media (max-width: 560px) {
    .fx-pager { justify-content: center; }
    .fx-pager-count { order: 3; width: 100%; text-align: center; }
}

/* ---------- table ---------- */

.fx-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.fx-table th {
    text-align: left;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    color: var(--fx-muted);
    padding: 10px 14px;
    border-bottom: 1px solid var(--fx-border);
}
.fx-table td {
    padding: 12px 14px;
    border-bottom: 1px solid var(--fx-border);
    color: var(--fx-text);
    transition: background-color 100ms ease;
}
.fx-table tr:last-child td { border-bottom: none; }
.fx-table tbody tr:hover td { background: color-mix(in srgb, var(--fx-surface-inner) 55%, transparent); }
.fx-table tbody tr:hover td:first-child { border-radius: 10px 0 0 10px; }
.fx-table tbody tr:hover td:last-child { border-radius: 0 10px 10px 0; }

/* ---------- banner ---------- */

.fx-banner {
    border-radius: var(--fx-radius-outer);
    padding: 16px 20px;
    display: flex;
    align-items: flex-start;
    gap: 12px;
    border: 1px solid;
}
.fx-banner.is-warning {
    background: var(--fx-danger-soft);
    border-color: color-mix(in srgb, var(--fx-danger) 35%, transparent);
    color: var(--fx-text);
}

/* ---------- recent activity row ---------- */

.fx-activity-row {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 0;
    border-bottom: 1px solid var(--fx-border);
}

/* ---------- Recent Sales scroll panel (dashboard home) ----------
   Capped-height, themed-scrollbar container — same scrollbar convention
   as .fx-sidebar/.fx-menu elsewhere in this file, not a bare unstyled
   browser scrollbar. 15 rows fetched per page; "Show more" appends the
   next page rather than replacing, so the scroll position holds. */
/* [Marc's direct mobile report] The scroll thumb used to draw flush against
   the row content (amount figures in particular) since this container had
   no gutter of its own -- padding-right pushes rows in from the thumb, and
   the matching negative margin-right keeps the thumb sitting at the card's
   own padding edge instead of opening up a second, wider gap beyond it. */
.fx-recent-sales-scroll {
    max-height: 420px;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--fx-border-strong) transparent;
    padding-right: 10px;
    margin-right: -10px;
}
.fx-recent-sales-scroll::-webkit-scrollbar { width: 8px; }
.fx-recent-sales-scroll::-webkit-scrollbar-track { background: transparent; }
.fx-recent-sales-scroll::-webkit-scrollbar-thumb { background-color: var(--fx-border-strong); border-radius: 8px; }

.fx-sale-row {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 0;
    border-bottom: 1px solid var(--fx-border);
}
.fx-sale-row:last-child { border-bottom: none; }
.fx-sale-avatar {
    width: 36px;
    height: 36px;
    border-radius: 9999px;
    flex-shrink: 0;
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--fx-accent-soft);
    color: var(--fx-accent-strong);
}
.fx-sale-avatar svg { width: 100%; height: 100%; }
.fx-sale-name { font-size: 13px; font-weight: 600; color: var(--fx-text); }
/* [Marc's direct report] Fixed 3-line layout, consistent on every device
   width: name (.fx-sale-name above) / package (.fx-sale-pkgline) / everything
   else (.fx-sale-subline) -- plain block paragraphs, no display:flex, so the
   relative-time text wraps like normal prose instead of a flex item orphaning
   its last word ("ago") onto its own line on a narrow phone. */
.fx-sale-pkgline { font-size: 11px; color: var(--fx-muted); margin-top: 1px; }
.fx-sale-subline { font-size: 11px; color: var(--fx-muted); margin-top: 1px; }
.fx-sale-subline img { vertical-align: -2px; }
/* [Marc's direct mobile report: "sales with bigger numbers may not be
   viewed well"] flex-shrink:0 guarantees the figure itself is never the
   thing that gives up space when a row is tight -- .fx-sale-name/.fx-sale-
   meta's own min-w-0/truncate above them already shrink first, same as
   before; this only makes that priority explicit rather than relying on
   flex's default shrink behavior happening to land the same way. */
.fx-sale-amount { font-size: 13px; font-weight: 700; color: var(--fx-accent-strong); font-variant-numeric: tabular-nums; white-space: nowrap; flex-shrink: 0; }

/* Recent Sales package colors [Inquiry 212] — a printed-voucher row's
   ticket-icon chip takes its package's color instead of the uniform accent
   green; every row (printed AND mobile-money) additionally gets a small
   solid dot ahead of its meta line so package identity is never
   color-only (the package name is also always printed as text right next
   to the dot). See the --fx-pkg-N/-soft tokens above for the palette. */
.fx-sale-pkg-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 5px; flex-shrink: 0; }
.fx-sale-avatar.fx-pkg-1 { background: var(--fx-pkg-1-soft); color: var(--fx-pkg-1); }
.fx-sale-avatar.fx-pkg-2 { background: var(--fx-pkg-2-soft); color: var(--fx-pkg-2); }
.fx-sale-avatar.fx-pkg-3 { background: var(--fx-pkg-3-soft); color: var(--fx-pkg-3); }
.fx-sale-avatar.fx-pkg-4 { background: var(--fx-pkg-4-soft); color: var(--fx-pkg-4); }
.fx-sale-avatar.fx-pkg-5 { background: var(--fx-pkg-5-soft); color: var(--fx-pkg-5); }
.fx-sale-avatar.fx-pkg-6 { background: var(--fx-pkg-6-soft); color: var(--fx-pkg-6); }
.fx-sale-avatar.fx-pkg-7 { background: var(--fx-pkg-7-soft); color: var(--fx-pkg-7); }
.fx-sale-pkg-dot.fx-pkg-1 { background: var(--fx-pkg-1); }
.fx-sale-pkg-dot.fx-pkg-2 { background: var(--fx-pkg-2); }
.fx-sale-pkg-dot.fx-pkg-3 { background: var(--fx-pkg-3); }
.fx-sale-pkg-dot.fx-pkg-4 { background: var(--fx-pkg-4); }
.fx-sale-pkg-dot.fx-pkg-5 { background: var(--fx-pkg-5); }
.fx-sale-pkg-dot.fx-pkg-6 { background: var(--fx-pkg-6); }
.fx-sale-pkg-dot.fx-pkg-7 { background: var(--fx-pkg-7); }
.fx-activity-row:last-child { border-bottom: none; }

/* ---------- toast (mirrors portal's system-toast) ---------- */

#fx-toast {
    position: fixed;
    top: 20px;
    right: 20px;
    max-width: 340px;
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-left: 3px solid var(--fx-toast-accent, var(--fx-accent));
    border-radius: var(--fx-radius-inner);
    box-shadow: var(--fx-shadow-pop);
    padding: 14px;
    z-index: 60;
    display: flex;
    align-items: flex-start;
    gap: 10px;
    transform: translateY(-16px) scale(0.98);
    opacity: 0;
    pointer-events: none;
    transition: transform 260ms cubic-bezier(0.34, 1.3, 0.64, 1), opacity 200ms ease;
    overflow: hidden;
}
#fx-toast.is-shown { transform: translateY(0) scale(1); opacity: 1; pointer-events: auto; }
#fx-toast.is-error { border-left-color: var(--fx-danger); }

/* Countdown bar injected by dashboard.js — shows time until auto-dismiss.
   Inherits --fx-toast-accent from its #fx-toast parent (fxShowToast sets
   it per-kind), so the drain bar always matches the border it sits under
   even though this element is created dynamically after that property is
   set. .is-error fallback kept for any caller that toggles the class
   directly without going through fxShowToast(). */
.fx-toast-progress {
    position: absolute;
    left: 0;
    bottom: 0;
    height: 2px;
    background: var(--fx-toast-accent, var(--fx-accent));
    width: 100%;
    transform-origin: left;
}
#fx-toast.is-error .fx-toast-progress { background: var(--fx-danger); }
#fx-toast.is-shown .fx-toast-progress { animation: fx-toast-drain 4s linear forwards; }
@keyframes fx-toast-drain { from { transform: scaleX(1); } to { transform: scaleX(0); } }

.fx-hamburger {
    display: none;
}
@media (max-width: 900px) {
    /* English reads left-to-right, so the primary page heading belongs at
       the natural reading-start (left) and the nav control at the far
       right of the topbar — matching XenFi's own mobile layout (Marc's
       direct report) rather than the hamburger leading the title. Pulled
       out of the inline flex flow entirely (rather than reordered within
       it) since it needs to sit at the topbar's own far edge, past
       whatever primary action button follows the title block in markup. */
    /* [Inquiry 221] Mobile sticky-header sizing — keeps position:sticky from
       the base rule (sticky is itself a positioned value, so it still serves
       as the containing block for the absolute hamburger below) while matching
       .fx-main's own 16px mobile padding for the full-bleed background. */
    .fx-topbar { margin: -16px -16px 16px; padding: 16px 16px 12px; padding-right: 52px; }
    .fx-hamburger {
        display: flex;
        position: absolute;
        top: 16px;
        right: 16px;
        width: 36px;
        height: 36px;
        border-radius: var(--fx-radius-inner);
        border: 1px solid var(--fx-border);
        align-items: center;
        justify-content: center;
        color: var(--fx-text);
    }
}

.fx-sidebar-scrim {
    display: none;
}
@media (max-width: 900px) {
    .fx-sidebar-scrim.is-open {
        display: block;
        position: fixed;
        inset: 0;
        background: var(--fx-scrim);
        z-index: 30;
        animation: fx-fade-in 200ms ease;
    }
}
@keyframes fx-fade-in { from { opacity: 0; } to { opacity: 1; } }

/* ---------- spinner (mirrors portal's real spinner) ---------- */

.fx-spinner {
    border: 3px solid color-mix(in srgb, var(--fx-accent) 20%, transparent);
    border-top-color: var(--fx-accent);
    border-radius: 50%;
    animation: fx-spin 0.9s linear infinite;
}
@keyframes fx-spin { to { transform: rotate(360deg); } }

/* Rotates an inline icon in place (e.g. a lucide loader-2 in a step
   checklist) -- distinct from .fx-spinner, which is a standalone ring. */
.fx-spin { animation: fx-spin 0.9s linear infinite; transform-origin: center; }

/* ---------- voucher code display (Agent PoS success state) ---------- */

.fx-voucher-code-box {
    background: var(--fx-surface-inner);
    border: 1.5px dashed var(--fx-accent);
    border-radius: var(--fx-radius-inner);
    padding: 16px;
    text-align: center;
    font-family: 'Lexend', 'Inter', sans-serif;
    font-size: 26px;
    font-weight: 700;
    letter-spacing: 0.08em;
    color: var(--fx-accent-strong);
}

/* ---------- checkbox row (e.g. Free Trial toggle) ---------- */

.fx-checkbox-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 14px;
    border-radius: var(--fx-radius-inner);
    border: 1px solid var(--fx-border);
    background: var(--fx-surface-inner);
    font-size: 13px;
    cursor: pointer;
    transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
}
.fx-checkbox-row:hover { border-color: var(--fx-border-strong); }
.fx-checkbox-row:has(input:checked) {
    border-color: var(--fx-accent);
    background: var(--fx-accent-soft);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--fx-accent) 10%, transparent);
}

.fx-checkbox-row input[type="checkbox"]:not(.fx-switch) {
    appearance: none;
    -webkit-appearance: none;
    width: 18px;
    height: 18px;
    flex-shrink: 0;
    margin: 0;
    border-radius: 6px;
    border: 1.5px solid var(--fx-border-strong);
    background: var(--fx-surface);
    display: inline-grid;
    place-content: center;
    cursor: pointer;
    transition: border-color 150ms ease, background-color 150ms ease;
}
.fx-checkbox-row input[type="checkbox"]:not(.fx-switch)::before {
    content: '';
    width: 10px;
    height: 10px;
    background: #fff;
    clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
    transform: scale(0);
    transition: transform 180ms cubic-bezier(0.34, 1.6, 0.64, 1);
}
.fx-checkbox-row input[type="checkbox"]:not(.fx-switch):checked {
    border-color: var(--fx-accent);
    background: var(--fx-accent);
}
.fx-checkbox-row input[type="checkbox"]:not(.fx-switch):checked::before { transform: scale(1); }

/* Switch variant — add class="fx-switch" to a checkbox input for a pill
   toggle (used for boolean on/off, e.g. the Free Trial toggle). Still a
   real checkbox underneath: same events, same .checked contract. */
input[type="checkbox"].fx-switch {
    appearance: none;
    -webkit-appearance: none;
    width: 38px;
    height: 22px;
    flex-shrink: 0;
    margin: 0;
    border-radius: 9999px;
    border: 1.5px solid var(--fx-border-strong);
    background: var(--fx-surface-inner);
    position: relative;
    cursor: pointer;
    transition: background-color 200ms ease, border-color 200ms ease;
}
input[type="checkbox"].fx-switch::before {
    content: '';
    position: absolute;
    top: 2px;
    left: 2px;
    width: 15px;
    height: 15px;
    border-radius: 9999px;
    background: var(--fx-muted);
    transition: transform 200ms cubic-bezier(0.34, 1.3, 0.64, 1), background-color 200ms ease;
}
input[type="checkbox"].fx-switch:checked {
    background: var(--fx-accent);
    border-color: var(--fx-accent);
}
input[type="checkbox"].fx-switch:checked::before {
    background: #fff;
    transform: translateX(16px);
}

/* ---------- collapsible (e.g. Advanced Options) ---------- */

.fx-collapsible-trigger {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    padding: 10px 14px;
    border-radius: var(--fx-radius-inner);
    border: 1px solid var(--fx-border);
    background: var(--fx-surface-inner);
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    transition: border-color 150ms ease, background-color 150ms ease;
}
.fx-collapsible-trigger:hover { border-color: var(--fx-border-strong); }
.fx-collapsible-trigger .fx-caret, .fx-collapsible-trigger [data-lucide="chevron-down"] {
    transition: transform 200ms cubic-bezier(0.34, 1.4, 0.64, 1);
}
.fx-collapsible-trigger[aria-expanded="true"] .fx-caret,
.fx-collapsible-trigger[aria-expanded="true"] [data-lucide="chevron-down"] { transform: rotate(180deg); }

.fx-collapsible-body {
    padding: 16px 2px 2px;
}

/* Animated variant — wrap .fx-collapsible-body in .fx-collapsible-wrap and
   toggle .is-open on the wrap (see packages-create.html reference). */
.fx-collapsible-wrap {
    display: grid;
    grid-template-rows: 0fr;
    transition: grid-template-rows 260ms cubic-bezier(0.32, 0.72, 0, 1);
}
.fx-collapsible-wrap > .fx-collapsible-body { overflow: hidden; min-height: 0; }
.fx-collapsible-wrap.is-open { grid-template-rows: 1fr; }

/* ---------- modal (opt-in shared classes — see upgrade spec migration note) ---------- */

.fx-modal {
    position: fixed;
    inset: 0;
    z-index: 50;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 16px;
    background: var(--fx-scrim);
    backdrop-filter: blur(3px);
    -webkit-backdrop-filter: blur(3px);
    animation: fx-fade-in 180ms ease;
}
.fx-modal.hidden { display: none; }
.fx-modal-panel {
    width: 100%;
    animation: fx-modal-rise 240ms cubic-bezier(0.34, 1.2, 0.64, 1);
}
@keyframes fx-modal-rise {
    from { opacity: 0; transform: translateY(14px) scale(0.97); }
    to { opacity: 1; transform: translateY(0) scale(1); }
}
.fx-modal.is-closing { animation: fx-fade-out 160ms ease forwards; }
.fx-modal.is-closing .fx-modal-panel { animation: fx-modal-sink 160ms ease forwards; }
@keyframes fx-fade-out { to { opacity: 0; } }
@keyframes fx-modal-sink { to { opacity: 0; transform: translateY(8px) scale(0.98); } }

@media (max-width: 640px) {
    /* Thumb-zone: on phones, modals bottom-sheet instead of center-float. */
    .fx-modal { align-items: flex-end; padding: 0; }
    .fx-modal .fx-modal-panel { max-width: none !important; }
    .fx-modal .fx-card {
        border-radius: var(--fx-radius-outer) var(--fx-radius-outer) 0 0;
        border-bottom: none;
        padding-bottom: calc(24px + env(safe-area-inset-bottom));
    }
}

/* ---------- settings sub-nav tab strip (Settings sector) ---------- */

.fx-settings-tabs {
    display: flex;
    gap: 4px;
    flex-wrap: wrap;
    border-bottom: 1px solid var(--fx-border);
    margin-bottom: 24px;
}
@media (max-width: 900px) {
    /* Swipe-scroll instead of wrapping to a second line — matches XenFi's
       own mobile filter-tab pattern (Marc's direct report) — buttons never
       stack, don't shrink, and the strip just scrolls horizontally. */
    .fx-settings-tabs {
        flex-wrap: nowrap;
        overflow-x: auto;
        touch-action: pan-x pan-y;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none;
    }
    .fx-settings-tabs::-webkit-scrollbar { display: none; }
    .fx-settings-tab { flex-shrink: 0; }
}
.fx-settings-tab {
    position: relative;
    padding: 10px 16px;
    font-size: 13px;
    font-weight: 600;
    color: var(--fx-muted);
    text-decoration: none;
    margin-bottom: -1px;
    border-radius: var(--fx-radius-inner) var(--fx-radius-inner) 0 0;
    transition: color 150ms ease, background-color 150ms ease;
}
.fx-settings-tab::after {
    content: '';
    position: absolute;
    left: 12px;
    right: 12px;
    bottom: 0;
    height: 2px;
    border-radius: 2px;
    background: var(--fx-accent);
    transform: scaleX(0);
    transition: transform 200ms cubic-bezier(0.34, 1.2, 0.64, 1);
}
.fx-settings-tab:hover { color: var(--fx-text); background: var(--fx-surface-inner); }
.fx-settings-tab:hover::after { transform: scaleX(0.6); }
.fx-settings-tab.is-active { color: var(--fx-accent-strong); }
.fx-settings-tab.is-active::after { transform: scaleX(1); }

/* ---------- package-select cards (Agent PoS) ---------- */

.fx-package-option {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 12px 14px;
    border-radius: var(--fx-radius-inner);
    border: 1.5px solid var(--fx-border);
    background: var(--fx-surface-inner);
    cursor: pointer;
    transition: border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
.fx-package-option:hover { border-color: var(--fx-accent); transform: translateY(-1px); }
.fx-package-option.is-selected,
.fx-package-option:has(input:checked) {
    border-color: var(--fx-accent);
    background: var(--fx-accent-soft);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--fx-accent) 10%, transparent);
}

/* ---------- mobile thumb-zone CTA dock (Marc's bottom-third convention) ----------
   Markup: place at end of <body>:
     <div class="fx-cta-dock"> ...primary (and optional secondary) button... </div>
   and add class "has-cta-dock" to <body>. Desktop: hidden (the inline CTA
   row stays). Mobile: fixed bottom bar in the thumb zone; the theme toggle
   lifts above it so the two never collide. */

.fx-cta-dock { display: none; }

@media (max-width: 640px) {
    /* [Marc's direct correction, Inquiry 239 follow-up: "the create
       packages button is rendered below the bar yet it all has to happen
       above it"] The floating bottom nav (dashboard.js/dashboard.css,
       .fx-mobile-bottom-nav) is used far more often than any one page's
       own CTA dock, so it stays thumb-closest at its own normal bottom:18px
       -- this dock is the one pushed up clear of it instead, reversing the
       stacking this originally had before the nav pill existed. 84px
       clears the nav's real 74px-from-edge top (bottom:18px + its own
       ~56px height) with an 8-10px real gap between the two, real border
       radius added since this is now a floating bar rather than a
       flush-to-the-screen-edge one. */
    body.has-cta-dock .fx-cta-dock {
        display: flex;
        gap: 10px;
        position: fixed;
        left: 12px;
        right: 12px;
        bottom: 84px;
        z-index: 45;
        padding: 12px 16px;
        border-radius: var(--fx-radius-outer);
        background: color-mix(in srgb, var(--fx-surface) 92%, transparent);
        backdrop-filter: blur(10px);
        -webkit-backdrop-filter: blur(10px);
        border: 1px solid var(--fx-border);
        box-shadow: var(--fx-shadow-pop);
    }
    body.has-cta-dock .fx-cta-dock .fx-btn-primary { flex: 1; }
    body.has-cta-dock .fx-main { padding-bottom: 172px; }
    /* The inline CTA row duplicates the dock on phones — hide it there. */
    body.has-cta-dock .fx-cta-inline { display: none; }
}

/* ---------- dark hero panel (locked landing pattern: navy floating card
   with emerald accents, deliberately dark even on the light page) ---------- */

.fx-hero-panel {
    background: #0F172A;            /* fluxiNavy — same in both modes by design */
    border: 1px solid #1E293B;
    border-radius: var(--fx-radius-inner);
    color: #F1F5F9;
    box-shadow: 0 12px 32px -14px rgba(2, 6, 23, 0.55);
}
html.dark .fx-hero-panel {
    background: #1E293B;            /* one step lighter than the dark page surface */
    border-color: #334155;
}
.fx-hero-panel .fx-hero-muted { color: #94A3B8; }
.fx-hero-panel .fx-hero-accent { color: #34D399; }

/* Navy rounded-square icon badge with emerald glyph (locked feature-grid pattern) */
.fx-hero-badge {
    width: 40px;
    height: 40px;
    border-radius: 12px;
    background: #0F172A;
    border: 1px solid #1E293B;
    color: #34D399;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
}
html.dark .fx-hero-badge { background: #334155; border-color: #475569; }

/* ---------- page-load reveal (opt-in: add .fx-reveal to <main>) ---------- */

@keyframes fx-rise {
    from { opacity: 0; transform: translateY(10px); }
    to { opacity: 1; transform: translateY(0); }
}
/* [Skeleton alignment] The rise cascade plays on initial document load
   ONLY. #fx-page-root is re-inserted on every hx-boost navigation, which
   used to replay the whole cascade per hop (topbar fading, cards rising
   top-to-bottom = the reported waterfall flash). dashboard.js adds
   body.fx-boosted on the first boosted settle; from then on swaps are
   instant content replacements. A hard refresh starts a fresh document
   (no class) so the entrance still plays once. */
body:not(.fx-boosted) .fx-reveal > * { animation: fx-rise 480ms cubic-bezier(0.22, 0.9, 0.36, 1) backwards; }
body:not(.fx-boosted) .fx-reveal > *:nth-child(1) { animation-delay: 0ms; }
body:not(.fx-boosted) .fx-reveal > *:nth-child(2) { animation-delay: 60ms; }
body:not(.fx-boosted) .fx-reveal > *:nth-child(3) { animation-delay: 120ms; }
body:not(.fx-boosted) .fx-reveal > *:nth-child(4) { animation-delay: 180ms; }
body:not(.fx-boosted) .fx-reveal > *:nth-child(5) { animation-delay: 240ms; }
body:not(.fx-boosted) .fx-reveal > *:nth-child(6) { animation-delay: 300ms; }
body:not(.fx-boosted) .fx-reveal > *:nth-child(n+7) { animation-delay: 360ms; }
@media (prefers-reduced-motion: reduce) {
    .fx-reveal > * { animation: none; }
}

/* ---------- fluid daily bar chart (Dashboard "Revenue Over Time") ----------
   One column per day of the viewed month, columns share the row equally
   (flex:1) so the count is fluid — 4 columns on the 4th, 23 on the 23rd.
   Each bar stacks 3 segments (voucher / mobile money / commission). No
   value axis is drawn; per-bar detail lives in the hover/tap tooltip. */

/* [Inquiry 246, real root cause: "top bar shakes on swipe, Dashboard/Usage
   Analytics only"] Inquiry 243's touch-action:pan-y fix depends on a real,
   confirmed invariant -- no page has genuine horizontal overflow at its
   root, so constraining touch panning to vertical loses no capability. That
   invariant held for every page Inquiry 243 tested, all at the app's short
   default date ranges. It does not hold here: .fx-daybar-col's own
   min-width:3px means a long enough custom date range (the range picker's
   own numeric M/D/Y inputs allow any span, no cap) makes the row's real
   min-content width exceed the card -- and since neither this element nor
   .fx-card set their own overflow, that excess bled straight through both
   and into the document root, reintroducing real scrollWidth > innerWidth
   on exactly these two pages. Confirmed by direct measurement (a same-CSS
   harness, real flex math, no live data needed): zero overflow through 31
   bars, real page-level overflow from 60 bars on. Contained here, the same
   overflow-x-auto + touch-action:pan-x-pan-y idiom every other genuinely
   horizontally-scrollable strip in this file already uses, so a long range
   scrolls within its own card instead of ever reaching the page root. */
.fx-daybar-chart {
    position: relative; /* anchors the moving-average line overlay */
    height: 220px;
    display: flex;
    align-items: flex-end;
    gap: 4px;
    padding-top: 34px; /* headroom so a near-100%-tall bar doesn't touch the card's top edge */
    overflow-x: auto;
    touch-action: pan-x pan-y;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
}
.fx-daybar-chart::-webkit-scrollbar { display: none; }

/* [Inquiry 212] Dashboard row-2 cards (Revenue Over Time / Recent Sales) —
   flex columns so grid's default row-stretch fills each card with real,
   growing content instead of dead padding. */
.fx-panel-fill { display: flex; flex-direction: column; }
.fx-panel-fill > .fx-daybar-chart { flex: 1 1 auto; height: auto; min-height: 220px; }
.fx-panel-fill > .fx-recent-sales-scroll { flex: 1 1 auto; min-height: 0; }

.fx-daybar-col {
    flex: 1 1 0;
    min-width: 3px;
    display: flex;
    flex-direction: column;
    height: 100%;
    cursor: pointer;
}

.fx-daybar-barwrap {
    flex: 1;
    min-height: 0;
    display: flex;
    align-items: flex-end;
}

.fx-daybar { position: relative; width: 100%; }

/* Segments are true flex items (flex-grow set per-segment in JS, not a
   height percentage) so `gap` correctly reserves real space between them
   instead of the segments' percentages overflowing the bar. */
.fx-daybar-fill {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column-reverse;
    gap: 3px;
    transition: transform 160ms ease, filter 160ms ease, box-shadow 160ms ease;
}

.fx-daybar-seg { width: 100%; min-height: 0; border-radius: 2px; }
.fx-daybar-seg.is-voucher { background: var(--fx-accent); }
.fx-daybar-seg.is-mm { background: #FDBA74; }
.fx-daybar-seg.is-commission { background: #38BDF8; }

/* No resizing on hover/tap — the bar just lifts off the baseline and
   glows. Nothing about its own or its neighbors' width ever changes. */
.fx-daybar-col:hover .fx-daybar-fill,
.fx-daybar-col.is-active .fx-daybar-fill {
    transform: translateY(-6px);
    filter: brightness(1.08) saturate(1.1);
    box-shadow: 0 8px 16px -6px color-mix(in srgb, var(--fx-accent) 65%, transparent);
    z-index: 3;
}

.fx-daybar-label {
    height: 14px;
    margin-top: 6px;
    font-size: 9px;
    text-align: center;
    color: var(--fx-muted);
    font-variant-numeric: tabular-nums;
    /* [Inquiry 221] Labels are now short dates ("Aug 15"), shown only on sparse
       days (1st, last, every 5th) — nowrap + visible overflow lets each center
       over its own bar and spill harmlessly into the hidden-label neighbours
       either side, rather than wrapping to two lines inside a ~30px column. */
    white-space: nowrap;
    overflow: visible;
}

.fx-daybar-tip {
    width: max-content;
    min-width: 148px;
    max-width: min(200px, 78vw);
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: 12px;
    box-shadow: var(--fx-shadow-pop);
    padding: 9px 10px;
    z-index: 10;
}

/* [Marc's direct report, real-device screenshot] #fx-daybar-tip-host is the
   ONE shared tooltip instance, a sibling of the scrolling .fx-daybar-chart
   (not nested inside it) so the chart's own overflow-x:auto -- which this
   browser forces overflow-y to `auto` alongside, even with an explicit
   `overflow-y: visible` override tried and confirmed live not to work --
   can never clip it again. position:fixed + real viewport-px left/top set
   by fxShowDaybarTip (index.html), never CSS-only positioning, because the
   correct side (above/below the bar) depends on real available space on
   each side of THAT bar in THAT viewport, not a fixed rule. */
.fx-daybar-tip-host {
    position: fixed;
    top: 0;
    left: 0;
    opacity: 0;
    pointer-events: none;
    transform: scale(0.96);
    transition: opacity 140ms ease, transform 140ms ease;
    z-index: 60;
}
.fx-daybar-tip-host.is-shown {
    opacity: 1;
    transform: scale(1);
}

@media (max-width: 480px) {
    .fx-daybar-tip { min-width: 132px; max-width: 84vw; padding: 8px 9px; }
    .fx-daybar-chart { height: 190px; padding-top: 30px; }
    .fx-panel-fill > .fx-daybar-chart { min-height: 190px; }
}

/* ---------- metrics chart (shared/metrics-chart.js) ----------
   [FLUXI Router Observability session, Workstream B] A real SVG
   polyline/area time-series renderer -- the day-bar rules above only
   ever handle one bar per calendar day (~31 max); this handles up to
   ~2000 continuous points across the Router page's six offered windows. */

.fx-chart-legend {
    display: flex;
    flex-wrap: wrap;
    gap: 14px;
    margin-bottom: 8px;
}

.fx-chart-legend-item {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 11px;
    font-weight: 600;
    color: var(--fx-muted);
}

.fx-chart-legend-dot {
    width: 8px;
    height: 8px;
    border-radius: 9999px;
    display: inline-block;
    flex-shrink: 0;
}

.fx-chart-wrap {
    position: relative;
    width: 100%;
}

.fx-chart-svg {
    width: 100%;
    height: 200px;
    display: block;
    overflow: visible;
    cursor: crosshair;
}

.fx-chart-svg text {
    font-family: inherit;
    font-variant-numeric: tabular-nums;
}

/* ---------- chart-shaped skeleton loader (router historical charts,
   Inquiry 286 item 3) ----------
   A shimmer placeholder shaped like the chart it precedes -- y-axis rail,
   plot area, x-axis rail -- instead of a plain blob or bare "Loading..."
   words. Every bar is .fx-skeleton, so paint, shimmer, and reduced-motion
   inherit for free (same contract as the .fx-sk-* shape variants above). */
.fx-chart-sk {
    display: grid;
    grid-template-columns: 34px 1fr;
    grid-template-rows: 1fr 18px;
    gap: 6px;
    min-height: 220px;
}
.fx-chart-sk-y {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    padding: 2px 0;
}
.fx-chart-sk-y span { height: 9px; }
.fx-chart-sk-plot {
    position: relative;
    overflow: hidden;
    border-radius: 8px;
    min-height: 180px;
}
.fx-chart-sk-plot span {
    position: absolute;
    inset: 0;
}
.fx-chart-sk-x {
    grid-column: 2;
    display: flex;
    justify-content: space-between;
    align-items: start;
}
.fx-chart-sk-x span { height: 9px; width: 11%; }
@media (max-width: 480px) {
    .fx-chart-sk { min-height: 190px; }
    .fx-chart-sk-plot { min-height: 150px; }
}

.fx-chart-cursor { pointer-events: none; }

.fx-chart-tooltip {
    position: absolute;
    top: 6px;
    width: max-content;
    min-width: 140px;
    max-width: min(220px, 82vw);
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: 12px;
    box-shadow: var(--fx-shadow-pop);
    padding: 9px 10px;
    pointer-events: none;
    z-index: 4;
}

.fx-chart-tooltip-time {
    font-size: 10.5px;
    font-weight: 700;
    color: var(--fx-text);
    margin-bottom: 5px;
}

.fx-chart-tooltip-row {
    font-size: 10.5px;
    color: var(--fx-muted);
    line-height: 1.5;
}

.fx-chart-tooltip-row strong {
    color: var(--fx-text);
    font-variant-numeric: tabular-nums;
}

.fx-chart-window-select {
    display: flex;
    gap: 4px;
    flex-wrap: wrap;
}

.fx-chart-window-btn {
    font-size: 11px;
    font-weight: 600;
    padding: 5px 10px;
    border-radius: 8px;
    border: 1px solid var(--fx-border);
    background: var(--fx-surface-inner);
    color: var(--fx-muted);
    cursor: pointer;
    transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}

.fx-chart-window-btn:hover { color: var(--fx-text); }

.fx-chart-window-btn.is-active {
    background: var(--fx-accent-soft);
    color: var(--fx-accent-strong);
    border-color: var(--fx-accent-strong);
}

@media (max-width: 480px) {
    .fx-chart-svg { height: 170px; }
}

/* ---------- date range picker (dual calendar + presets) ---------- */

.fx-rangepicker { position: relative; }

.fx-rangepicker-btn {
    display: flex;
    align-items: center;
    gap: 9px;
    background: var(--fx-surface-inner);
    border: 1px solid var(--fx-border);
    color: var(--fx-text);
    border-radius: var(--fx-radius-inner);
    padding: 10px 14px;
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
    transition: border-color 150ms ease, box-shadow 150ms ease, background-color 200ms ease;
}
/* [Interaction-system audit — Tier 3, top-bar/filter control] The calendar
   is the extreme-right control in every sticky top bar; a bare border shift
   read as "no hover at all" on Dashboard/Usage/Support (their only top-bar
   controls). Adds the shared neutral elevation -- shadow only, no lift, since
   it presents as an input, not a button -- so the top bar feels alive
   consistently everywhere without competing with the primary action. */
.fx-rangepicker-btn:hover { border-color: var(--fx-border-strong); box-shadow: var(--fx-shadow-hover); }
.fx-rangepicker.is-open .fx-rangepicker-btn {
    border-color: var(--fx-accent);
    box-shadow: 0 0 0 3px var(--fx-ring);
}
.fx-rangepicker-btn .fx-date-ic { color: var(--fx-muted); flex-shrink: 0; }
.fx-rangepicker.is-open .fx-date-ic { color: var(--fx-accent-strong); }

.fx-rangepanel {
    position: absolute;
    top: calc(100% + 8px);
    right: 0;
    z-index: 55;
    display: none;
    opacity: 1;
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: var(--fx-radius-outer);
    box-shadow: var(--fx-shadow-pop);
    overflow: hidden;
}
/* [Marc's direct report, interaction-quality correction pass: "there
   appears to be a slight freeze/stutter before the calendar popup
   appears... a calendar popup is supposed to be an immediate interaction"]
   This used to carry its own `fx-rangepanel-pop-in` keyframes (a 160ms
   transform-only pop, added when the PRIOR bug here -- an opacity-based
   animation that never advanced past its first frame -- was fixed). That
   fix was correct for the visibility bug it targeted, but the animation
   itself is exactly the kind of "decorative entrance" this pass explicitly
   ranks below immediate responsiveness: a scale+translate transform on a
   560px-wide panel containing two full calendar grids is real compositing
   work for a mobile GPU to do the instant it's asked to also lay out and
   paint that same panel for the first time, and the two together are the
   likely source of the perceived hitch. Removed entirely -- the panel now
   appears the instant its display:none -> flex toggle applies, no fade, no
   scale, no delay. Immediate response beats smooth animation beats
   decorative animation. */
.fx-rangepicker.is-open .fx-rangepanel { display: flex; }

.fx-rangepanel-main { padding: 16px 18px; width: 560px; max-width: 88vw; }

.fx-rangepanel-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 14px;
    flex-wrap: wrap;
}
.fx-rangepanel-inputs { display: flex; align-items: center; gap: 3px; font-size: 13px; }
.fx-rp-num {
    width: 30px;
    text-align: center;
    background: var(--fx-surface-inner);
    border: 1px solid var(--fx-border);
    border-radius: 8px;
    padding: 6px 2px;
    font-size: 12.5px;
    color: var(--fx-text);
    font-variant-numeric: tabular-nums;
}
.fx-rp-num.is-year { width: 48px; }
.fx-rp-num:focus { outline: none; border-color: var(--fx-accent); box-shadow: 0 0 0 2px var(--fx-ring); }
.fx-rp-dash { color: var(--fx-muted); margin: 0 6px; }

.fx-rangepanel-actions { display: flex; gap: 8px; }

.fx-rp-months { display: flex; gap: 22px; }
.fx-rp-month { width: 240px; }
.fx-rp-month-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 10px;
}
.fx-rp-month-title { font-family: 'Lexend', 'Inter', sans-serif; font-size: 13px; font-weight: 700; }
.fx-rp-nav {
    width: 22px;
    height: 22px;
    border-radius: 7px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--fx-muted);
    cursor: pointer;
    transition: background-color 120ms ease, color 120ms ease;
}
.fx-rp-nav:hover { background: var(--fx-surface-inner); color: var(--fx-accent-strong); }
/* Desktop shows 2 months side by side -- calendar 1's own next arrow
   already exposes the month after calendar 0, so calendar 0's own next
   arrow would be a redundant second control. Hidden here, shown only
   under the mobile breakpoint below, where calendar 1 disappears
   entirely and this becomes the only way to move forward. */
.fx-rp-nav-mobile-next { display: none; }

.fx-rp-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; }
.fx-rp-dow { text-align: center; font-size: 10px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--fx-muted); padding: 2px 0 6px; }
.fx-rp-day {
    height: 30px;
    font-size: 12px;
    font-weight: 500;
    color: var(--fx-text);
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
}
.fx-rp-day.is-outside { color: color-mix(in srgb, var(--fx-muted) 40%, transparent); pointer-events: none; }
.fx-rp-day-dot {
    width: 26px;
    height: 26px;
    border-radius: 9999px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-variant-numeric: tabular-nums;
    transition: background-color 100ms ease, color 100ms ease;
}
.fx-rp-day:not(.is-outside):hover .fx-rp-day-dot { background: var(--fx-surface-inner); }
.fx-rp-day.is-today .fx-rp-day-dot { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--fx-accent) 55%, transparent); }
.fx-rp-day.is-in-range { background: var(--fx-accent-soft); }
.fx-rp-day.is-range-start { border-radius: 9999px 0 0 9999px; }
.fx-rp-day.is-range-end { border-radius: 0 9999px 9999px 0; }
.fx-rp-day.is-range-start.is-range-end { border-radius: 9999px; }
.fx-rp-day.is-endpoint .fx-rp-day-dot { background: var(--fx-accent); color: #fff; font-weight: 700; }

.fx-rangepanel-side {
    width: 150px;
    flex-shrink: 0;
    border-left: 1px solid var(--fx-border);
    padding: 14px 10px;
    display: flex;
    flex-direction: column;
    gap: 2px;
    background: var(--fx-surface-inner);
}
.fx-rp-preset {
    text-align: left;
    padding: 8px 10px;
    border-radius: 10px;
    font-size: 12.5px;
    font-weight: 600;
    color: var(--fx-text);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 6px;
    cursor: pointer;
    transition: background-color 100ms ease, color 100ms ease;
}
.fx-rp-preset:hover { background: var(--fx-surface); }
.fx-rp-preset.is-active { background: var(--fx-accent-soft); color: var(--fx-accent-strong); }
.fx-rp-preset .fx-option-check { flex-shrink: 0; opacity: 0; transform: scale(0.5); transition: opacity 120ms ease, transform 160ms cubic-bezier(0.34, 1.5, 0.64, 1); }
.fx-rp-preset.is-active .fx-option-check { opacity: 1; transform: scale(1); }

/* [Marc's direct report: calendar doesn't render on tap] Widened 640 -> 900
   to match the mobile-chrome breakpoint (FX_MOBILE_MQ / .fx-rangepicker-btn's
   own display:none block, both max-width:900). Between 641-900px the desktop
   trigger button is already hidden, so the ONLY opener is the mobile calendar
   icon -- but the fixed/centered panel treatment used to stop at 640, leaving
   that band to fall back to desktop anchoring against a now-zero-size trigger
   container (panel rendered off in a corner). fxPositionRangePanel() in
   dashboard.js early-returns on the same 900 breakpoint so the two agree. */
@media (max-width: 900px) {
    /* `left:50%` on the base rule centers against the trigger BUTTON's own
       box (its containing block) — fine on desktop where that button sits
       roughly mid-row, but on a narrow phone topbar the trigger can sit far
       off-center, dragging most of the panel off-screen with it. Switching
       to `position:fixed` re-centers against the viewport itself instead,
       the same floating-overlay treatment `.fx-modal` already uses. This
       also guarantees the calendar stays within the visible viewport on
       mobile (Marc's explicit requirement) regardless of scroll position
       or where the trigger button sits on the page. */
    .fx-rangepanel {
        position: fixed;
        top: 50%;
        left: 50%;
        right: auto;
        transform: translate(-50%, -50%);
        flex-direction: column;
        max-height: 86vh;
        width: 92vw;
        overflow-y: auto;
    }
    .fx-rangepanel-main { width: 100%; }
    /* [Inquiry 213, Marc's direct report + XenFi reference screenshot] Was
       `flex-direction: column`, stacking BOTH months vertically -- still
       "squeezing two months into view" on a narrow phone, just stacked
       instead of side-by-side, rather than genuinely showing one at a
       time the way XenFi's own mobile calendar does. Calendar 1 (the
       "next month" panel, only ever needed to make calendar 0's own next
       arrow non-redundant on a 2-up desktop layout) is hidden entirely;
       calendar 0's own next arrow (.fx-rp-nav-mobile-next, hidden on
       desktop above) becomes visible so a single visible month can still
       move forward, matching its already-working prev arrow. */
    .fx-rp-months { display: block; }
    .fx-rp-month[data-cal="1"] { display: none; }
    .fx-rp-month { width: 100%; }
    .fx-rp-nav-mobile-next { display: flex; }
    .fx-rangepanel-side { width: 100%; border-left: none; border-top: 1px solid var(--fx-border); flex-direction: row; flex-wrap: wrap; }
}

/* ============================================================
   Packages as cards [Inquiry 224 — Marc's direct ask]
   List -> responsive card grid. Mobile: single stacked column.
   Desktop/tablet: auto-fit up to 3 per row; the grid itself is the
   scroll region, so it only scrolls once there are enough packages to
   overflow (few packages => no scrollbar). Per-card accent reuses the
   Recent Sales fx-pkg-N palette so a package keeps one colour identity.
   ============================================================ */
.fx-packages-grid {
    display: grid;
    grid-template-columns: 1fr;   /* mobile: stacked, single column */
    gap: 16px;
    margin-bottom: 8px;
}
@media (min-width: 680px) {
    .fx-packages-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1040px) {
    .fx-packages-grid {
        grid-template-columns: repeat(3, minmax(0, 1fr));   /* max 3 per row */
        max-height: calc(100vh - 300px);
        overflow-y: auto;
        padding-right: 6px;
    }
}
.fx-package-card {
    --card-accent: var(--fx-accent-strong);
    --card-accent-soft: var(--fx-accent-soft);
    display: flex;
    flex-direction: column;
    gap: 8px;
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: 16px;
    padding: 18px;
    position: relative;
    overflow: hidden;
}
.fx-package-card::before {
    content: "";
    position: absolute;
    top: 0; bottom: 0; left: 0;
    width: 4px;
    background: var(--card-accent);
}
.fx-package-card.is-inactive { opacity: 0.62; }
.fx-package-card.is-skeleton { pointer-events: none; }

.fx-package-card-head {
    display: flex; align-items: center; justify-content: space-between;
}
.fx-package-swatch {
    width: 34px; height: 34px; border-radius: 10px;
    background: var(--card-accent-soft);
    display: inline-flex; align-items: center; justify-content: center;
}
.fx-package-swatch::after {
    content: ""; width: 14px; height: 14px; border-radius: 4px;
    background: var(--card-accent);
}
.fx-package-status { font-size: 9px; }
.fx-package-name {
    font-family: 'Lexend', 'Inter', sans-serif;
    font-weight: 700; font-size: 15px; color: var(--fx-text);
    line-height: 1.25; margin-top: 2px;
}
.fx-package-badges { display: flex; flex-wrap: wrap; gap: 6px; }
.fx-package-badges:empty { display: none; }
.fx-package-price {
    font-family: 'Lexend', 'Inter', sans-serif;
    font-weight: 800; font-size: 22px; color: var(--card-accent);
    margin-top: 2px;
}
.fx-package-duration {
    display: inline-flex; align-items: center; gap: 6px;
    font-size: 12px; color: var(--fx-muted);
}
.fx-package-priceline { display: flex; flex-direction: column; }
.fx-package-actions {
    display: flex; align-items: center; gap: 8px;
    margin-top: auto; padding-top: 12px;
    border-top: 1px solid var(--fx-border);
}
.fx-btn-ghost {
    display: inline-flex; align-items: center; gap: 6px;
    font-size: 12px; font-weight: 600; color: var(--fx-text);
    padding: 7px 10px; border-radius: 9px;
    border: 1px solid var(--fx-border);
    background: transparent; cursor: pointer; text-decoration: none;
    transition: background .15s ease, border-color .15s ease;
}
.fx-btn-ghost:hover { background: var(--fx-surface-inner); }
.fx-btn-ghost.fx-btn-danger { color: var(--fx-danger); margin-left: auto; padding: 7px 9px; }
.fx-btn-ghost.fx-btn-danger:hover { border-color: var(--fx-danger); }
/* [Packages follow-up backlog 2.3] The active/reactivate toggle only ever
   changed its icon/label, not its colour — on/off state wasn't scannable at
   a glance across a grid of cards. Green mirrors the card's own accent (and
   .is-inactive's existing opacity dimming); off uses the same muted tone as
   inactive card text rather than danger red, since toggling off isn't an
   error state. */
.fx-btn-ghost.is-on { color: var(--fx-accent-strong); border-color: var(--fx-accent-strong); background: var(--fx-accent-soft); }
.fx-btn-ghost.is-off { color: var(--fx-muted); }

/* Per-package accent — same palette + indexing as Recent Sales (fx-pkg-N). */
.fx-package-card.fx-pkg-1 { --card-accent: var(--fx-pkg-1); --card-accent-soft: var(--fx-pkg-1-soft); }
.fx-package-card.fx-pkg-2 { --card-accent: var(--fx-pkg-2); --card-accent-soft: var(--fx-pkg-2-soft); }
.fx-package-card.fx-pkg-3 { --card-accent: var(--fx-pkg-3); --card-accent-soft: var(--fx-pkg-3-soft); }
.fx-package-card.fx-pkg-4 { --card-accent: var(--fx-pkg-4); --card-accent-soft: var(--fx-pkg-4-soft); }
.fx-package-card.fx-pkg-5 { --card-accent: var(--fx-pkg-5); --card-accent-soft: var(--fx-pkg-5-soft); }
.fx-package-card.fx-pkg-6 { --card-accent: var(--fx-pkg-6); --card-accent-soft: var(--fx-pkg-6-soft); }
.fx-package-card.fx-pkg-7 { --card-accent: var(--fx-pkg-7); --card-accent-soft: var(--fx-pkg-7-soft); }

/* [Packages follow-up backlog 2.2] Marc: cards were too tall on mobile.
   Tighter padding/gap plus price+duration sharing one row (instead of
   stacking, the desktop layout) shortens each card noticeably without
   dropping any info. Declared AFTER the base .fx-package-card/.fx-package-
   priceline rules above (same specificity, so source order decides) --
   placing this earlier in the cascade let the base rules win unconditionally
   regardless of viewport, silently no-opping the whole block (caught live,
   not by the Tier-2 structural tests, which don't assert computed style). */
@media (max-width: 640px) {
    .fx-package-card { padding: 14px; gap: 6px; }
    .fx-package-name { margin-top: 0; }
    .fx-package-priceline {
        flex-direction: row; align-items: baseline; justify-content: space-between;
    }
    .fx-package-actions { padding-top: 10px; }
}

/* ---------------------------------------------------------------------
   fxShowResult — centered, backdrop-blurred RESULT modal for the outcome
   of a real action (payout sent, top-up done, voucher batch printed).
   [Marc's direct report: corner toasts auto-dismiss and are easy to miss
   for a real money movement — this pops mid-screen, blurs the backdrop,
   and stays until acknowledged.] Theme-aware via --fx- tokens; motion
   respects prefers-reduced-motion.
   --------------------------------------------------------------------- */
.fx-result-overlay {
    position: fixed;
    inset: 0;
    z-index: 300;
    display: none;
    align-items: center;
    justify-content: center;
    padding: 24px;
    background: color-mix(in srgb, var(--fx-text) 45%, transparent);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
}
.fx-result-overlay.is-shown { display: flex; }

.fx-result-card {
    width: 100%;
    max-width: 340px;
    background: var(--fx-surface);
    border: 1px solid var(--fx-border);
    border-radius: 20px;
    padding: 28px 22px 22px;
    text-align: center;
    box-shadow: 0 24px 64px rgba(0, 0, 0, 0.32);
    animation: fx-result-pop 240ms cubic-bezier(0.2, 0.9, 0.25, 1);
}
@keyframes fx-result-pop {
    from { opacity: 0; transform: translateY(10px) scale(0.95); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
    .fx-result-card { animation: none; }
}

.fx-result-icon {
    width: 64px;
    height: 64px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    margin: 0 auto 16px;
    background: var(--fx-accent-soft);
    color: var(--fx-accent-strong);
}
.fx-result-icon svg { width: 30px; height: 30px; }
.fx-result-card.is-error .fx-result-icon { background: var(--fx-danger-soft); color: var(--fx-danger); }
.fx-result-card.is-pending .fx-result-icon {
    background: color-mix(in srgb, #D97706 15%, var(--fx-surface));
    color: #D97706;
}

.fx-result-title { font-size: 19px; font-weight: 800; color: var(--fx-text); }
.fx-result-message { font-size: 13px; line-height: 1.5; color: var(--fx-muted); margin-top: 6px; }

.fx-result-rows {
    margin-top: 16px;
    text-align: left;
    border: 1px solid var(--fx-border);
    border-radius: 12px;
    padding: 4px 12px;
    background: var(--fx-surface-inner);
}
.fx-result-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 7px 0;
}
.fx-result-row + .fx-result-row { border-top: 1px solid color-mix(in srgb, var(--fx-border) 60%, transparent); }
.fx-result-row-label { font-size: 12px; color: var(--fx-muted); flex-shrink: 0; }
.fx-result-row-value {
    font-size: 12.5px;
    font-weight: 700;
    color: var(--fx-text);
    text-align: right;
    word-break: break-word;
}
.fx-result-btn { margin-top: 20px; width: 100%; justify-content: center; }
