/**
 * ============================================
 * 🎨 FEAST FINANCIAL MANAGEMENT SYSTEM
 * ============================================
 * 
 * Centralized CSS Styles
 * Theme: Red (Primary), White (Secondary), Black (Tertiary), Gray (Complimentary)
 * 
 * 📅 Last Updated: February 4, 2026
 */

/* ============================================
   CSS VARIABLES (Theme Colors)
============================================ */
:root {
    /* Primary Colors */
    --primary: #DC2626;
    --primary-hover: #B91C1C;
    --primary-light: #FEE2E2;
    --primary-dark: #991B1B;
    
    /* Secondary Colors (text on light backgrounds) */
    --secondary: #1a1a1a;
    --secondary-hover: #333333;

    /* Tertiary Colors */
    --tertiary: #000000;
    --tertiary-light: #f3f4f6;

    /* Complimentary Colors — remapped for light theme */
    --gray-50: #F9FAFB;
    --gray-100: #F3F4F6;
    --gray-200: #E5E7EB;
    --gray-300: #555555;
    --gray-400: #666666;
    --gray-500: #888888;
    --gray-600: #d1d5db;
    --gray-700: #e5e7eb;
    --gray-800: #f3f4f6;
    --gray-900: #f7f7f8;

    /* Background Colors — light theme */
    --bg-dark: #ffffff;
    --bg-surface: #f7f7f8;
    --bg-card: #ffffff;
    
    /* Status Colors */
    --success: #10B981;
    --success-light: #D1FAE5;
    --warning: #F59E0B;
    --warning-light: #FEF3C7;
    --error: #EF4444;
    --error-light: #FEE2E2;
    --info: #3B82F6;
    --info-light: #DBEAFE;
    
    /* Typography */
    --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    --font-size-xs: 0.75rem;
    --font-size-sm: 0.875rem;
    --font-size-base: 1rem;
    --font-size-lg: 1.125rem;
    --font-size-xl: 1.25rem;
    --font-size-2xl: 1.5rem;
    --font-size-3xl: 1.875rem;
    --font-size-4xl: 2.25rem;
    
    /* Spacing */
    --spacing-1: 0.25rem;
    --spacing-2: 0.5rem;
    --spacing-3: 0.75rem;
    --spacing-4: 1rem;
    --spacing-5: 1.25rem;
    --spacing-6: 1.5rem;
    --spacing-8: 2rem;
    --spacing-10: 2.5rem;
    --spacing-12: 3rem;
    
    /* Border Radius */
    --radius-sm: 0.25rem;
    --radius-md: 0.375rem;
    --radius-lg: 0.5rem;
    --radius-xl: 0.75rem;
    --radius-2xl: 1rem;
    --radius-full: 9999px;
    
    /* Shadows */
    --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
    --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
    --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
    
    /* Transitions */
    --transition-fast: 150ms ease;
    --transition-normal: 200ms ease;
    --transition-slow: 300ms ease;
}

/* ============================================
   RESET & BASE STYLES
============================================ */
*, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

html {
    font-size: 16px;
    scroll-behavior: smooth;
}

/* ============================================
   DARK THEME — activated by <html data-theme="dark">.
   Overrides the light-remapped variables above; because the whole app draws
   from these CSS variables, flipping them re-themes almost everything.
   ============================================ */
:root[data-theme="dark"] {
    color-scheme: dark;

    /* Text */
    --secondary: #f3f4f6;
    --secondary-hover: #ffffff;
    --tertiary: #ffffff;
    --tertiary-light: #1f2937;

    /* Grays — dark remap (mirror of the light remap) */
    --gray-50: #1f2937;
    --gray-100: #374151;
    --gray-200: #374151;
    --gray-300: #9ca3af;
    --gray-400: #9ca3af;
    --gray-500: #a8b0bd;
    --gray-600: #4b5563;
    --gray-700: #374151;
    --gray-800: #1f2937;
    --gray-900: #111827;

    /* Backgrounds */
    --bg-dark: #111827;
    --bg-surface: #1f2937;
    --bg-card: #1f2937;

    /* Status tints — subtle translucent instead of bright pastels */
    --primary-light: rgba(220, 38, 38, 0.22);
    --success-light: rgba(16, 185, 129, 0.18);
    --warning-light: rgba(245, 158, 11, 0.18);
    --error-light: rgba(239, 68, 68, 0.18);
    --info-light: rgba(59, 130, 246, 0.18);

    /* Shadows — deeper on dark */
    --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4);
    --shadow-md: 0 4px 8px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.4);
}

body {
    font-family: var(--font-family);
    font-size: var(--font-size-base);
    line-height: 1.6;
    color: var(--secondary);
    background-color: var(--bg-dark);
    min-height: 100vh;
}

/* ============================================
   PAGE LOAD VISIBILITY GATE

   The page itself is NOT gated. It used to be: .main-content sat at
   opacity 0 behind a full-screen "Loading…" spinner until FeastApp.init()
   finished, which meant a blank spinner screen on every sign-in and every
   single navigation, even though the role was already known. The page now
   paints straight away and its panels fill in through their own skeletons.

   .app-ready survives as the PERMISSION gate below, and only that: every
   role-gated element stays hidden until the profile has been applied, so
   the reveal is fail-closed. Nothing an unauthorised person should not see
   can flash on screen while init is still running.
============================================ */

a {
    color: var(--primary);
    text-decoration: none;
    transition: color var(--transition-fast);
}

a:hover {
    color: var(--primary-hover);
}

img {
    max-width: 100%;
    height: auto;
}

/* ============================================
   TYPOGRAPHY
============================================ */
h1, h2, h3, h4, h5, h6 {
    font-weight: 600;
    line-height: 1.3;
    color: var(--secondary);
}

h1 { font-size: var(--font-size-4xl); }
h2 { font-size: var(--font-size-3xl); }
h3 { font-size: var(--font-size-2xl); }
h4 { font-size: var(--font-size-xl); }
h5 { font-size: var(--font-size-lg); }
h6 { font-size: var(--font-size-base); }

.text-primary { color: var(--primary) !important; }
.text-secondary { color: var(--secondary) !important; }
.text-gray { color: var(--gray-400) !important; }
.text-success { color: var(--success) !important; }
.text-warning { color: var(--warning) !important; }
.text-error { color: var(--error) !important; }

.text-sm { font-size: var(--font-size-sm); }
.text-xs { font-size: var(--font-size-xs); }
.text-lg { font-size: var(--font-size-lg); }
.text-xl { font-size: var(--font-size-xl); }

.font-bold { font-weight: 700; }
.font-semibold { font-weight: 600; }
.font-medium { font-weight: 500; }
.font-normal { font-weight: 400; }

.text-center { text-align: center; }
.text-right { text-align: right; }
.text-left { text-align: left; }

/* ============================================
   LAYOUT
============================================ */
.container {
    width: 100%;
    max-width: 1280px;
    margin: 0 auto;
    padding: 0 var(--spacing-4);
}

.container-sm { max-width: 640px; }
.container-md { max-width: 768px; }
.container-lg { max-width: 1024px; }
.container-xl { max-width: 1280px; }

/* Flexbox Utilities */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.flex-wrap { flex-wrap: wrap; }
/* js/feasts.js has asked for this on the View Transactions button since the
   feast card shipped, and it never existed -- so the class did nothing and the
   button sat at its own width with a gap before the edit icon. min-width: 0 is
   part of the utility, not a nicety: a flex item defaults to min-width: auto
   and refuses to shrink below its content, which is how a long label pushes
   its neighbour off a phone (FPFM-67). */
.flex-1 { flex: 1 1 0%; min-width: 0; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.gap-1 { gap: var(--spacing-1); }
.gap-2 { gap: var(--spacing-2); }
.gap-3 { gap: var(--spacing-3); }
.gap-4 { gap: var(--spacing-4); }
.gap-6 { gap: var(--spacing-6); }
.gap-8 { gap: var(--spacing-8); }

/* Grid */
.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, 1fr); }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }

@media (min-width: 768px) {
    .md\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
    .md\:grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
    .md\:grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
}

/* Spacing */
.m-0 { margin: 0; }
.m-1 { margin: var(--spacing-1); }
.m-2 { margin: var(--spacing-2); }
.m-4 { margin: var(--spacing-4); }
.mt-2 { margin-top: var(--spacing-2); }
.mt-4 { margin-top: var(--spacing-4); }
.mt-6 { margin-top: var(--spacing-6); }
.mt-8 { margin-top: var(--spacing-8); }
.mb-2 { margin-bottom: var(--spacing-2); }
.mb-4 { margin-bottom: var(--spacing-4); }
.mb-6 { margin-bottom: var(--spacing-6); }
.mb-8 { margin-bottom: var(--spacing-8); }
.mx-auto { margin-left: auto; margin-right: auto; }

.p-0 { padding: 0; }
.p-2 { padding: var(--spacing-2); }
.p-4 { padding: var(--spacing-4); }
.p-6 { padding: var(--spacing-6); }
.p-8 { padding: var(--spacing-8); }
.px-4 { padding-left: var(--spacing-4); padding-right: var(--spacing-4); }
.py-2 { padding-top: var(--spacing-2); padding-bottom: var(--spacing-2); }
.py-4 { padding-top: var(--spacing-4); padding-bottom: var(--spacing-4); }
.py-8 { padding-top: var(--spacing-8); padding-bottom: var(--spacing-8); }

/* Width & Height */
.w-full { width: 100%; }
.h-full { height: 100%; }
.min-h-screen { min-height: 100vh; }

/* ============================================
   BUTTONS
============================================ */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--spacing-2);
    padding: var(--spacing-3) var(--spacing-6);
    font-size: var(--font-size-base);
    font-weight: 500;
    border-radius: var(--radius-lg);
    border: none;
    cursor: pointer;
    transition: all var(--transition-fast);
    text-decoration: none;
}

.btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

.btn:active:not(:disabled) {
    transform: scale(0.97);
}

/* Primary Button (Red) */
.btn-primary {
    background-color: var(--primary);
    color: #fff;
}

.btn-primary:hover:not(:disabled) {
    background-color: var(--primary-hover);
}

/* Secondary Button (White outline) */
.btn-secondary {
    background-color: transparent;
    color: var(--secondary);
    border: 2px solid var(--secondary);
}

.btn-secondary:hover:not(:disabled) {
    background-color: var(--secondary);
    color: #fff;
}

/* Ghost Button */
.btn-ghost {
    background-color: transparent;
    color: var(--secondary);
}

.btn-ghost:hover:not(:disabled) {
    background-color: var(--gray-700);
}

/* Icon Action Button (surfaced) — for an icon-only control that sits beside a
   full-sized button, e.g. Edit next to View Transactions on a My Feasts card.

   Those used to be .btn-ghost: transparent, no border, so on the card's
   --bg-surface the icon had no surface of its own and read as a decoration
   printed on the card rather than the thing you press. Its neighbour carries a
   2px border, which made the contrast worse -- one heavy control, one glyph.

   A SEPARATE variant rather than a change to .btn-ghost, which 87 call sites
   across 29 files use, many of them on toolbars and dark strips where a
   filled pill is wrong. Same reasoning as .btn-info above: give the one case a
   shape of its own instead of re-cutting a shared one.

   That count is measured, not estimated, and it will drift -- so here is the
   command rather than only its answer (HTML/JS, excluding tests):
       grep -roE "btn-ghost" --include="*.html" --include="*.js" . \
         | grep -v "/tests/" | wc -l      # 87 occurrences
       grep -rlE "btn-ghost" --include="*.html" --include="*.js" . \
         | grep -v "/tests/" | wc -l      # 29 files
   Counting css/styles.css and tests/ as well gives 106 across 33.

   Every colour is a token, so both themes are covered by the same three rules:
   --gray-200 is #E5E7EB on light (against the #f7f7f8 card) and #374151 on
   dark (against the #1f2937 card) -- a step away from the surface in each
   direction. --gray-600 for hover moves the same way on both (#d1d5db light,
   #4b5563 dark), so the hover is visible in either theme rather than only in
   the one it was designed in. */
.btn-icon-action {
    background-color: var(--gray-200);
    color: var(--secondary);
    border: 1px solid var(--gray-600);
}

.btn-icon-action:hover:not(:disabled) {
    background-color: var(--gray-600);
    border-color: var(--gray-500);
}

/* The ring is the only thing a keyboard user has to see where they are, and on
   an icon-only control there is no label to fall back on. --secondary is the
   body text colour, so it reads on both themes (as .denom-toggle does). */
.btn-icon-action:focus-visible {
    outline: 2px solid var(--secondary);
    outline-offset: 2px;
}

/* Link Action Button (surfaced) — for an ANCHOR carrying a label, styled as a
   button, sitting in a .page-actions row beside a real control. Today that is
   the "🌐 3D Map" link in map.html's header, next to the #areaFilter select.

   A SIBLING of .btn-icon-action rather than a reuse of it, for two measured
   reasons:

   1. Geometry. .btn-icon-action's call site sits beside a .btn, so it inherits
      the .btn min-height of 40px and lines up. THIS one sits beside a
      .form-control, which is min-height 44px (the touch-friendly floor used
      throughout this file) -- measured at 1440px, the ghost anchor was 40px
      against a 44px select, centred by .page-actions' align-items:center and
      2px short at the top and bottom. Transparent, nobody saw it; give it a
      surface and it becomes a visibly undersized pill.

      Note what the fix here is NOT about. Adding min-height to a single-class
      .btn-icon-action rule would not have resized the My Feasts edit button --
      it could not have, because that rule would itself have lost to the
      responsive `.btn` block below (equal specificity, later in source order),
      exactly as a single-class .btn-link-action would. The reason for the
      doubled selector is specificity, not spillover onto a neighbouring
      control. See the "Selector is .btn.btn-link-action" note at the end of
      this comment, which is the same problem stated once more.
   2. Backdrop. .btn-icon-action was tuned against --bg-surface (the card it
      sits on). This one sits on the page background -- #ffffff on light,
      #111827 on dark -- so it is a separate contrast question even though the
      same tokens happen to answer both.

   Same token trio as .btn-icon-action, so the two read as one family and the
   dark remap at :root[data-theme="dark"] carries this without a second rule:
   --gray-200 rests (#E5E7EB light, #374151 dark), --gray-600 on hover
   (#d1d5db light, #4b5563 dark). No hex values here on purpose.

   Selector is .btn.btn-link-action, not .btn-link-action: the responsive
   `.btn` block further down this file (see "Button Responsive") re-declares
   min-height: 40px at equal specificity and later in source order, so a
   single-class rule would lose the height and fail silently. */
.btn.btn-link-action {
    background-color: var(--gray-200);
    color: var(--secondary);
    border: 1px solid var(--gray-600);
    /* Matches the adjacent .form-control's touch-friendly floor exactly, so the
       two controls share a top and bottom edge instead of merely centring. */
    min-height: 44px;
}

.btn.btn-link-action:hover:not(:disabled) {
    background-color: var(--gray-600);
    border-color: var(--gray-500);
}

/* An anchor gets a UA focus ring by default, but it is a hairline that changes
   colour with the theme and all but vanishes once the control has a filled
   surface of its own. This is the same explicit ring .btn-icon-action uses. */
.btn.btn-link-action:focus-visible {
    outline: 2px solid var(--secondary);
    outline-offset: 2px;
}

/* Outline Button (Subtle border) */
.btn-outline {
    background-color: transparent;
    color: var(--gray-300);
    border: 1px solid var(--gray-600);
}

.btn-outline:hover:not(:disabled) {
    background-color: var(--gray-800);
    border-color: var(--gray-500);
    color: var(--secondary);
}

/* Danger Button */
.btn-danger,
.btn-error {
    background-color: var(--error);
    color: #fff;
}

.btn-danger:hover:not(:disabled),
.btn-error:hover:not(:disabled) {
    background-color: #DC2626;
}

/* Destructive, OUTLINED — the per-record row standard (FPFM-86, CLAUDE.md
   section 8). A filled red button in an Actions column is the loudest thing
   in a list of them and reads as the primary action of the row, which for a
   Delete is exactly backwards. --error and --error-light are the two tokens
   that render safely in both themes; --gray-800 could not be the border here
   because in dark it IS the card background. */
.btn-danger-outline {
    background-color: transparent;
    color: var(--error);
    border: 1px solid var(--error);
}

.btn-danger-outline:hover:not(:disabled) {
    background-color: var(--error-light);
}

/* The word beside a per-record icon. Hidden by default so every desktop
   Actions column renders exactly as it did; the phone blocks that want the
   word turn it on through a DIFFERENT selector, never by redeclaring this
   one — the same cascade discipline .bdg-list / #budgetPhoneList uses. */
.txn-action__label {
    display: none;
}

/* Touch-target helper for critical action buttons (approve/reject/block) —
   meets the 44px mobile touch rule without enlarging every .btn-sm in tables. */
.btn-touch { min-height: 44px; }

/* Warning Button (Amber) */
.btn-warning {
    background-color: #D97706;
    color: #fff;
}

.btn-warning:hover:not(:disabled) {
    background-color: #B45309;
}

/* Info Button (Blue).

   Added for Quick Actions on the dashboard, where Generate Report was the one
   entry with no shape at all: Record Cash In is filled green, Record Cash Out
   filled red, Register New Feast a white outline, and Generate Report was
   .btn-ghost -- transparent, no border -- so it read as a link that had landed
   among the buttons.

   Blue rather than .btn-primary: primary is #DC2626 and .btn-danger is
   #EF4444, so a red Generate Report would sit two entries below a red Record
   Cash Out in the same short list, two near-identical reds meaning different
   things.

   #2563EB rather than var(--info) (#3B82F6), which is what .wallet-blue uses.
   --info against white text is about 3.6:1, under the 4.5:1 needed for text
   this size; #2563EB is about 5.2:1 and passes. The other filled variants in
   this file are worse than either and are left alone -- that is a separate
   sweep, not something to widen this change with. */
.btn-info {
    background-color: #2563EB;
    color: #fff;
}

.btn-info:hover:not(:disabled) {
    background-color: #1D4ED8;
}

/* Success Button */
.btn-success {
    background-color: var(--success);
    color: #fff;
}

.btn-success:hover:not(:disabled) {
    background-color: #059669;
}

/* Button Sizes */
.btn-sm {
    padding: var(--spacing-2) var(--spacing-4);
    font-size: var(--font-size-sm);
}

.btn-lg {
    padding: var(--spacing-4) var(--spacing-8);
    font-size: var(--font-size-lg);
}

/* Icon Button */
.btn-icon {
    padding: var(--spacing-2);
    border-radius: var(--radius-full);
}

/* ============================================
   CARDS
============================================ */
.card {
    background-color: var(--bg-surface);
    border-radius: var(--radius-xl);
    border: 1px solid var(--gray-700);
    overflow: hidden;
}

/* Clickable selector cards (e.g. report-type cards): hover lift + selected highlight */
.card.cursor-pointer { transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; }
.card.cursor-pointer:hover { border-color: var(--gray-500); transform: translateY(-2px); }
.card.cursor-pointer.selected { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.25); }

/* Report-type picker: compact, auto-wrapping tiles.
   Replaces the brittle `md:grid-cols-5` (that utility was never defined, so the
   5 cards collapsed to one full-width column). auto-fit keeps up to 5 across on
   wide screens and wraps down to 2 on phones — no per-breakpoint classes needed. */
.report-type-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
    gap: var(--spacing-4);
}
.report-type-grid .card-body {
    padding: var(--spacing-5) var(--spacing-4);
}
.report-type-grid h4 {
    font-size: 0.95rem;
    margin-bottom: var(--spacing-1);
}
.report-type-grid .text-sm {
    line-height: 1.35;
}

/* Tile icon badge — size/shape live here; each tile passes its tint via --tile-tint. */
.report-tile-icon {
    width: 48px;
    height: 48px;
    margin: 0 auto var(--spacing-3);
    background: var(--tile-tint, rgba(220, 38, 38, 0.15));
    border-radius: var(--radius-lg);
    display: flex;
    align-items: center;
    justify-content: center;
}
.report-tile-icon svg {
    width: 24px;
    height: 24px;
}

.card-header {
    padding: var(--spacing-4) var(--spacing-6);
    border-bottom: 1px solid var(--gray-700);
}

.card-body {
    padding: var(--spacing-6);
}

.card-footer {
    padding: var(--spacing-4) var(--spacing-6);
    border-top: 1px solid var(--gray-700);
    background-color: var(--bg-card);
}

/* Reports action footer — balanced, equal-width buttons right-aligned on
   desktop; full-width stacked (Preview then Export) on phones. */
.report-actions {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    gap: var(--spacing-3);
    flex-wrap: wrap;
}

.report-actions .btn {
    min-width: 160px;
}

@media (max-width: 640px) {
    .report-actions {
        flex-direction: column;
    }
    .report-actions .btn {
        width: 100%;
        min-width: 0;
    }
}

/* ============================================
   FORMS
============================================ */
.form-group {
    margin-bottom: var(--spacing-4);
}

.form-label {
    display: block;
    margin-bottom: var(--spacing-2);
    font-size: var(--font-size-sm);
    font-weight: 500;
    color: var(--gray-300);
}

/* .denom-toggle is a <button>, not a field, but it sits in the run of fields
   on the Cash In form and is meant to be indistinguishable from them at rest.
   It is in this list rather than repeating the seven declarations, so a change
   to the fields reaches it and the two cannot drift apart. */
.form-input,
.form-select,
.form-control,
.form-textarea,
.denom-toggle {
    width: 100%;
    padding: var(--spacing-3) var(--spacing-4);
    font-size: var(--font-size-base);
    color: var(--secondary);
    background-color: var(--bg-card);
    border: 1px solid var(--gray-600);
    border-radius: var(--radius-lg);
    transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}

.form-input:focus,
.form-select:focus,
.form-control:focus,
.form-textarea:focus {
    outline: none;
    border-color: var(--primary);
    box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.2);
}

.form-input::placeholder {
    color: var(--gray-500);
}

/* Custom chevron (native arrow can't be inset from the field edge) */
.form-select,
.form-control select,
select.form-control,
select.fs-input {
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    -moz-appearance: none;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888888' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 14px center;
    background-size: 14px 14px;
    padding-right: 40px;
}

.form-select option,
.form-control option,
select option {
    background-color: var(--bg-card);
    color: var(--secondary);
}

.form-textarea {
    min-height: 100px;
    resize: vertical;
}

.form-checkbox,
.form-radio {
    width: 1.25rem;
    height: 1.25rem;
    margin-right: var(--spacing-2);
    accent-color: var(--primary);
}

.form-error {
    color: var(--error);
    font-size: var(--font-size-sm);
    margin-top: var(--spacing-1);
}

.form-hint {
    color: var(--gray-400);
    font-size: var(--font-size-sm);
    margin-top: var(--spacing-1);
}

/* File Input */
.form-file {
    position: relative;
}

.form-file-input {
    position: absolute;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: pointer;
}

.form-file-label {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--spacing-2);
    padding: var(--spacing-8);
    border: 2px dashed var(--gray-600);
    border-radius: var(--radius-lg);
    background-color: var(--bg-card);
    cursor: pointer;
    transition: border-color var(--transition-fast);
}

.form-file-label:hover {
    border-color: var(--primary);
}

/* ============================================
   TABLES
============================================ */
.table-container {
    overflow-x: auto;
}

.table {
    width: 100%;
    border-collapse: collapse;
}

.table th,
.table td {
    padding: var(--spacing-3) var(--spacing-4);
    text-align: left;
    border-bottom: 1px solid var(--gray-700);
}

.table th {
    background-color: var(--bg-card);
    font-weight: 600;
    color: var(--gray-300);
    font-size: var(--font-size-sm);
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

.table tbody tr:hover {
    background-color: var(--gray-800);
}

/* ============================================
   BADGES
============================================ */
.badge {
    display: inline-flex;
    align-items: center;
    padding: var(--spacing-1) var(--spacing-3);
    font-size: var(--font-size-xs);
    font-weight: 600;
    border-radius: var(--radius-full);
}

.badge-primary {
    background-color: var(--primary);
    color: #fff;
}

.badge-success {
    background-color: var(--success-light);
    color: var(--success);
}

.badge-warning {
    background-color: var(--warning-light);
    color: #92400E;
}

.badge-error {
    background-color: var(--error-light);
    color: var(--error);
}

.badge-gray {
    background-color: var(--gray-700);
    color: var(--gray-300);
}

/* VOIDED TRANSACTIONS — listed, never hidden, never counted. The words "Void ·
   not counted" under the amount carry the meaning; the strike and the dimming only
   reinforce it, so it survives with the colour removed (FPFM-95). */
.txn-row--void td {
    color: var(--gray-400);
}

.txn-amount--void {
    text-decoration: line-through;
    color: var(--gray-400);
    font-weight: 500;
}

.txn-void-label {
    display: block;
    margin-top: var(--spacing-1);
    font-size: 12px;
    font-weight: 600;
    color: var(--gray-300);
}

.txn-void-record {
    padding: var(--spacing-3);
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-lg);
}

.txn-void-record__head {
    margin: 0 0 var(--spacing-1);
    font-size: 13px;
    font-weight: 600;
}

.txn-void-record__line {
    margin: 0;
    font-size: 13px;
    color: var(--gray-400);
    overflow-wrap: anywhere;
}

/* Void / Delete in the transaction detail: below every safe action, outlined
   red (FPFM-86), side by side, 44px set here rather than left to .btn. */
.txn-danger-actions {
    display: flex;
    gap: var(--spacing-3);
    margin-top: var(--spacing-4);
}

.txn-danger-actions .btn,
.txn-action-dialog__actions .btn {
    flex: 1;
    min-height: 44px;
}

.txn-action-dialog {
    width: 100%;
    max-width: 440px;
    padding: 20px;
    background: var(--gray-800);
    border: 1px solid var(--gray-700);
    border-radius: 12px;
}

/* A <textarea> does not inherit the page font; without this the reason
   is typed in the browser's monospace default. */
.txn-action-dialog textarea {
    font: inherit;
    resize: vertical;
}

.txn-action-dialog__title {
    margin: 0 0 var(--spacing-2);
    font-size: 16px;
    font-weight: 600;
}

.txn-action-dialog__text {
    margin: 0 0 var(--spacing-4);
    font-size: 13px;
    line-height: 1.5;
    color: var(--gray-400);
}

.txn-action-dialog__actions {
    display: flex;
    gap: var(--spacing-3);
    margin-top: var(--spacing-4);
}

/* ============================================
   NAVIGATION
============================================ */
.navbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--spacing-4) var(--spacing-6);
    background-color: var(--bg-surface);
    border-bottom: 1px solid var(--gray-700);
}

.navbar-brand {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    font-size: var(--font-size-xl);
    font-weight: 700;
    color: var(--secondary);
}

.navbar-brand svg {
    width: 32px;
    height: 32px;
    fill: var(--primary);
}

.navbar-menu {
    display: flex;
    align-items: center;
    gap: var(--spacing-6);
    list-style: none;
}

.navbar-link {
    color: var(--gray-400);
    font-weight: 500;
    transition: color var(--transition-fast);
}

.navbar-link:hover,
.navbar-link.active {
    color: var(--secondary);
}

/* Sidebar */
.sidebar {
    position: fixed;
    left: 0;
    top: 0;
    width: 260px;
    height: 100vh;
    background-color: var(--bg-surface);
    border-right: 1px solid var(--gray-700);
    padding: var(--spacing-6);
    overflow-y: auto;
    opacity: 1;
    transition: opacity 0.25s ease;
}

.sidebar-logo {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    /* margin-bottom: var(--spacing-8) stood here and was the same fossil as the
       .sidebar-header one noted below (FPFM-67): every other property in this
       block is redeclared by the IMPROVED SIDEBAR (v2.0) rule further down --
       display, align-items and gap all are -- but margin-bottom is not, so a
       stray 32px outlived the layout it belonged to and sat between the brand
       block and the header rule.

       It was the single largest term in the header-to-first-nav-item gap:
       larger than the header's own bottom padding (16px) and the nav
       container's top padding (12px) combined. Measured in Chrome at 1440 on
       the harness, expanded, against dev at efa46fb: the brand block ended at
       y=60.19 and the first nav item began at y=121.19, so the gap was 61px --
       32 of it this declaration, 16 the header's bottom padding, 12 the nav
       container's top padding, and 1 the header's own bottom rule.

       .sidebar-logo has exactly one consumer in the codebase -- the header
       built in js/sidebar.js -- so removing it changes nothing else. */
}

.sidebar-logo svg {
    width: 40px;
    height: 40px;
}

.sidebar-logo span {
    font-size: var(--font-size-lg);
    font-weight: 700;
    color: var(--secondary);
}

.sidebar-nav {
    list-style: none;
}

.sidebar-item {
    margin-bottom: var(--spacing-1);
}

.sidebar-link {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    padding: var(--spacing-3) var(--spacing-4);
    color: var(--gray-400);
    border-radius: var(--radius-lg);
    transition: all var(--transition-fast);
}

.sidebar-link:hover {
    background-color: var(--gray-700);
    color: var(--secondary);
}

.sidebar-link:active {
    transform: scale(0.98);
}

.sidebar-link.active {
    background-color: var(--primary);
    color: #fff;
}

.sidebar-link svg {
    width: 20px;
    height: 20px;
    fill: currentColor;
}

/* Main Content */
.main-content {
    margin-left: 260px;
    padding: var(--spacing-6);
    min-height: 100vh;
}

/* ============================================
   STATS CARDS (Dashboard)
============================================ */
.stat-card {
    background-color: var(--bg-surface);
    border-radius: var(--radius-xl);
    padding: var(--spacing-6);
    border: 1px solid var(--gray-700);
}

.stat-card-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 48px;
    height: 48px;
    border-radius: var(--radius-lg);
    background-color: var(--primary);
    margin-bottom: var(--spacing-4);
}

.stat-card-icon svg {
    width: 24px;
    height: 24px;
    fill: #fff;
}

.stat-card-value {
    font-size: var(--font-size-3xl);
    font-weight: 700;
    color: var(--secondary);
    margin-bottom: var(--spacing-1);
}

.stat-card-label {
    font-size: var(--font-size-sm);
    color: var(--gray-400);
}

.stat-card-change {
    display: inline-flex;
    align-items: center;
    gap: var(--spacing-1);
    font-size: var(--font-size-sm);
    margin-top: var(--spacing-2);
}

.stat-card-change.positive {
    color: var(--success);
}

.stat-card-change.negative {
    color: var(--error);
}

/* ============================================
   MODALS
============================================ */
.modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.7);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
    opacity: 0;
    visibility: hidden;
    transition: all var(--transition-fast);
}

.modal-overlay.active {
    opacity: 1;
    visibility: visible;
}

/* Modal Backdrop (alternative modal container) */
.modal-backdrop {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.7);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
    backdrop-filter: blur(4px);
}

.modal-backdrop .modal {
    transform: scale(1);
    animation: modalFadeIn 0.2s ease-out;
}

@keyframes modalFadeIn {
    from {
        opacity: 0;
        transform: scale(0.95);
    }
    to {
        opacity: 1;
        transform: scale(1);
    }
}

.modal {
    background-color: var(--bg-surface);
    border-radius: var(--radius-xl);
    border: 1px solid var(--gray-700);
    width: 100%;
    max-width: 500px;
    max-height: 90vh;
    overflow-y: auto;
    overflow-x: visible;
    transform: scale(0.9);
    transition: transform var(--transition-fast);
}

/* Ensure select dropdowns can extend outside modal */
.modal .form-select {
    position: relative;
}

/* Select option styling for dark theme */
.form-select option {
    background-color: var(--bg-surface);
    color: var(--secondary);
    padding: var(--spacing-2);
}

.modal-overlay.active .modal {
    transform: scale(1);
}

.modal-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--spacing-4) var(--spacing-6);
    border-bottom: 1px solid var(--gray-700);
}

.modal-title {
    font-size: var(--font-size-xl);
    font-weight: 600;
}

.modal-close {
    background: none;
    border: none;
    color: var(--gray-400);
    cursor: pointer;
    padding: var(--spacing-2);
    transition: color var(--transition-fast);
}

.modal-close:hover {
    color: var(--secondary);
}

.modal-body {
    padding: var(--spacing-6);
}

.modal-footer {
    display: flex;
    justify-content: flex-end;
    gap: var(--spacing-3);
    padding: var(--spacing-4) var(--spacing-6);
    border-top: 1px solid var(--gray-700);
}

/* MODAL ACTION STANDARD (FPFM-80) - two footer shapes, both desktop-neutral.

   .modal-footer-split is for a footer that also carries a group on the far
   side (today only Edit User in manage-builders, whose Block/Unblock pair sits
   left of Cancel/Save). It replaces an inline
   `style="display:flex; justify-content:space-between;"` on that element -
   inline wins over any stylesheet without !important, so the phone rule could
   not have reached it while the attribute was there.

   .modal-footer-actions is the dismiss+confirm pair inside such a footer. It
   was `class="flex gap-3"`, which matched the generic
   `.flex.gap-3:not(.flex-wrap)` stacker by accident. That selector has four
   other call sites (audit-log, events, transactions, tickets-admin) and is left
   exactly as it is; this pair now has a name of its own so its phone behaviour
   is stated rather than inherited. */
.modal-footer-split {
    justify-content: space-between;
}

.modal-footer-actions {
    display: flex;
    gap: var(--spacing-3);
}

/* ============================================
   ALERTS / NOTIFICATIONS
============================================ */
.alert {
    display: flex;
    align-items: flex-start;
    gap: var(--spacing-3);
    padding: var(--spacing-4);
    border-radius: var(--radius-lg);
    margin-bottom: var(--spacing-4);
}

.alert-icon svg {
    width: 20px;
    height: 20px;
}

.alert-success {
    background-color: rgba(16, 185, 129, 0.1);
    border: 1px solid var(--success);
    color: var(--success);
}

.alert-warning {
    background-color: rgba(245, 158, 11, 0.1);
    border: 1px solid var(--warning);
    color: var(--warning);
}

.alert-error {
    background-color: rgba(239, 68, 68, 0.1);
    border: 1px solid var(--error);
    color: var(--error);
}

.alert-info {
    background-color: rgba(59, 130, 246, 0.1);
    border: 1px solid var(--info);
    color: var(--info);
}

/* Toast Notifications */
.toast-container {
    position: fixed;
    top: var(--spacing-4);
    right: var(--spacing-4);
    z-index: 1100;
    display: flex;
    flex-direction: column;
    gap: var(--spacing-2);
}

.toast {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    padding: var(--spacing-4);
    background-color: var(--bg-surface);
    border-radius: var(--radius-lg);
    border: 1px solid var(--gray-700);
    box-shadow: var(--shadow-lg);
    min-width: 300px;
    animation: slideIn 0.3s ease;
}

@keyframes slideIn {
    from {
        transform: translateX(100%);
        opacity: 0;
    }
    to {
        transform: translateX(0);
        opacity: 1;
    }
}

/* ============================================
   LOADING STATES
============================================ */
.spinner {
    width: 24px;
    height: 24px;
    border: 3px solid var(--gray-700);
    border-top-color: var(--primary);
    border-radius: 50%;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

.spinner-lg {
    width: 48px;
    height: 48px;
}

/* ============================================
   TABS
============================================ */
.tabs {
    display: flex;
    border-bottom: 1px solid var(--gray-700);
    margin-bottom: var(--spacing-6);
}

.tab {
    padding: var(--spacing-3) var(--spacing-6);
    color: var(--gray-400);
    font-weight: 500;
    cursor: pointer;
    border-bottom: 2px solid transparent;
    transition: all var(--transition-fast);
}

.tab:hover {
    color: var(--secondary);
}

.tab.active {
    color: var(--primary);
    border-bottom-color: var(--primary);
}

/* ============================================
   TRANSACTION LIST
============================================ */
.transaction-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--spacing-4);
    border-bottom: 1px solid var(--gray-700);
    transition: background-color var(--transition-fast);
}

.transaction-item:hover {
    background-color: var(--gray-800);
}

.transaction-info {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
}

.transaction-icon {
    width: 40px;
    height: 40px;
    border-radius: var(--radius-lg);
    display: flex;
    align-items: center;
    justify-content: center;
}

.transaction-icon.cash-in {
    background-color: rgba(16, 185, 129, 0.2);
}

.transaction-icon.cash-in svg {
    fill: var(--success);
}

.transaction-icon.cash-out {
    background-color: rgba(239, 68, 68, 0.2);
}

.transaction-icon.cash-out svg {
    fill: var(--error);
}

.transaction-amount {
    font-weight: 600;
}

.transaction-amount.positive {
    color: var(--success);
}

.transaction-amount.negative {
    color: var(--error);
}

/* ============================================
   MAP CONTAINER
============================================ */
.map-container {
    width: 100%;
    height: 400px;
    border-radius: var(--radius-lg);
    overflow: hidden;
    border: 1px solid var(--gray-700);
}

/* The dark basemap: OpenStreetMap's light tiles turned dark here, because no
   keyless raster provider serves a dark style (see TILE_PROVIDERS.DARK in
   config/leaflet-maps.js). On the tile layer only -- area shading, pins and
   popups sit in other panes and keep their colours. */
.feast-tiles-dark {
    filter: invert(1) grayscale(1) brightness(0.7) contrast(1.05);
    /* Its own compositing layer. Without it, on a 2x phone a faint line shows
       where filtered tiles meet: measured at 390, 414 and 430 wide (not at
       360 or 375), gone at all five with this. The unfiltered CARTO tiles
       never showed it. */
    will-change: transform;
}

/* ============================================
   EMPTY STATE
============================================ */
.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: var(--spacing-12);
    text-align: center;
}

.empty-state svg {
    width: 64px;
    height: 64px;
    fill: var(--gray-600);
    margin-bottom: var(--spacing-4);
}

/* Column widths that do not depend on what turns up in the rows.

   A table laid out automatically is re-measured every time its body changes,
   and a loading table's body changes twice: skeleton rows, then either real
   rows or a single empty-state row. Each of those is a different shape, so
   each produces different column widths. Measured on Approved Finance
   Officers: the header row slid up to 200px sideways the moment one row of
   real data arrived, because a full email address is wider than the word
   "Email" the column had been sized for.

   Declaring the widths makes them a property of the table rather than of
   whatever page of data happens to be in it. Values longer than their column
   wrap — the columns are proportional, so nothing is clipped and nothing is
   pushed off a phone screen. Pair it with a <colgroup>; without one, fixed
   layout divides the width equally, which is not what any of these tables
   want. */
.table-stable {
    table-layout: fixed;
}

.table-stable th,
.table-stable td {
    overflow-wrap: anywhere;
}

/* An empty state standing IN a table's rows, in the space the skeleton rows
   had already reserved. The full-page padding would make it taller than the
   rows it replaces, which is the card growing on load — the thing the
   reservation exists to prevent. */
td > .empty-state {
    padding: var(--spacing-6);
}

td > .empty-state svg {
    width: 48px;
    height: 48px;
    margin-bottom: var(--spacing-3);
}

.empty-state-title {
    font-size: var(--font-size-xl);
    font-weight: 600;
    margin-bottom: var(--spacing-2);
}

.empty-state-text {
    color: var(--gray-400);
    max-width: 400px;
}

/* ============================================
   UTILITY CLASSES
============================================ */
.hidden { display: none !important; }
.visible { visibility: visible; }
.invisible { visibility: hidden; }
.opacity-50 { opacity: 0.5; }
.cursor-pointer { cursor: pointer; }
.select-none { user-select: none; }
.truncate {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Prose in a table cell, cut to two lines with an ellipsis.
   `.truncate` cannot do this job: on an inline <span> — which is what every
   table cell in this app puts its text in — `overflow: hidden` does not apply
   at all, so the text spills straight out of the cell and over the columns to
   its right (FPFM-48). This is a block, so it clips; and it is pinned at two
   lines, so a one-word reason and a paragraph make rows of the same height. */
.cell-clamp-2 {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow: hidden;
    overflow-wrap: anywhere;
    line-height: 1.4;
    max-height: 2.8em;
}

/* ============================================
   RESPONSIVE - Professional Mobile-First Design
============================================ */

/* -------------------- BREAKPOINTS --------------------
   - Mobile:  < 640px   (default, no prefix)
   - Tablet:  >= 640px  (sm:)
   - iPad:    >= 768px  (md:)
   - Laptop:  >= 1024px (lg:)
   - Desktop: >= 1280px (xl:)
-------------------------------------------------- */

/* Mobile Base (default) */
.sidebar {
    position: fixed;
    transform: translateX(-100%);
    z-index: 1000;
    transition: transform var(--transition-normal);
}

.sidebar.open {
    transform: translateX(0);
}

.main-content {
    margin-left: 0;
    padding: var(--spacing-4);
}

/* Mobile Menu Toggle Button */
.mobile-menu-toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    background-color: var(--bg-surface);
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-md);
    color: var(--secondary);
    cursor: pointer;
    z-index: 1001;
    position: fixed;
    top: var(--spacing-4);
    left: var(--spacing-4);
    transition: background-color var(--transition-fast);
}

.mobile-menu-toggle:hover {
    background-color: var(--bg-card);
}

.mobile-menu-toggle svg {
    width: 24px;
    height: 24px;
}

/* Mobile Header */
.mobile-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--spacing-4);
    padding-left: 60px;
    background-color: var(--bg-surface);
    border-bottom: 1px solid var(--gray-700);
    position: sticky;
    top: 0;
    z-index: 50;
}

.mobile-header h1 {
    font-size: var(--font-size-lg);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Overlay for mobile menu */
.sidebar-overlay {
    display: none;
    position: fixed;
    inset: 0;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: 999;
}

.sidebar-overlay.active {
    display: block;
}

/* Tablet (>= 640px) */
@media (min-width: 640px) {
    .main-content {
        padding: var(--spacing-6);
    }
    
    .grid-cols-2 {
        grid-template-columns: repeat(2, 1fr);
    }
    
    .mobile-header h1 {
        font-size: var(--font-size-xl);
    }
}

/* iPad (>= 768px) */
@media (min-width: 768px) {
    .sidebar {
        transform: translateX(0);
        position: fixed;
    }
    
    .main-content {
        margin-left: 260px;
        padding: var(--spacing-8);
    }
    
    .mobile-menu-toggle,
    .sidebar-overlay {
        display: none !important;
    }
    
    .mobile-header {
        display: none;
    }
    
    .grid-cols-3 {
        grid-template-columns: repeat(3, 1fr);
    }
    
    .modal {
        max-width: 600px;
        margin: auto;
    }
}

/* Laptop (>= 1024px) */
@media (min-width: 1024px) {
    .main-content {
        padding: var(--spacing-10);
    }
    
    .grid-cols-4 {
        grid-template-columns: repeat(4, 1fr);
    }
    
    .stats-grid {
        grid-template-columns: repeat(4, 1fr);
    }
}

/* Desktop (>= 1280px) */
@media (min-width: 1280px) {
    .main-content {
        max-width: 1440px;
    }
}

/* Mobile-specific grid overrides */
@media (max-width: 639px) {
    .grid-cols-2,
    .grid-cols-3,
    .grid-cols-4 {
        grid-template-columns: 1fr;
    }
    
    .stats-grid {
        grid-template-columns: repeat(2, 1fr);
        gap: var(--spacing-3);
    }
    
    .stat-card {
        padding: var(--spacing-4);
    }
    
    .stat-value {
        font-size: var(--font-size-xl);
    }
    
    /* Mobile Table Scroll */
    .table-container {
        overflow-x: auto;
        -webkit-overflow-scrolling: touch;
    }
    
    /* Mobile Cards */
    .card {
        padding: var(--spacing-4);
    }
    
    /* Mobile Buttons */
    .btn {
        padding: var(--spacing-3) var(--spacing-4);
        font-size: var(--font-size-sm);
    }
    
    .btn-sm {
        padding: var(--spacing-2) var(--spacing-3);
        font-size: var(--font-size-xs);
    }
    
    /* Mobile Form */
    .form-group {
        margin-bottom: var(--spacing-4);
    }
    
    .form-input,
    .form-select {
        padding: var(--spacing-3);
        font-size: 16px; /* Prevents iOS zoom */
    }
    
    /* Mobile Modal */
    .modal {
        margin: var(--spacing-3);
        max-height: 90vh;
        overflow-y: auto;
    }
    
    .modal-header {
        padding: var(--spacing-4);
        position: sticky;
        top: 0;
        background-color: var(--bg-surface);
        z-index: 10;
    }
    
    .modal-body {
        padding: var(--spacing-4);
    }
    
    /* MODAL ACTION STANDARD (FPFM-80) - the ONE rule that decides phone order.
       Desktop is unchanged: the base .modal-footer is a row with
       justify-content:flex-end, so source order puts the dismiss on the LEFT
       and the confirming action on the RIGHT. 17 of 19 dialogs already did
       that and none of them move.

       On a phone the confirming action sits ON TOP and the dismiss beneath it,
       which is `column-reverse` against the SAME source order - the dismiss is
       written first everywhere, so reversing drops it to the bottom without a
       single button being moved in the markup. Order is decided in one place,
       not re-decided per file.

       This rule REPLACES `.wallet-modal .modal-footer { column-reverse }`,
       which used to live alone in the max-width:640px block near the wallet
       styles and was the only thing in the app already doing it the right way.
       It is deleted rather than kept, because two rules for one question is
       how they came to disagree. Wallet modals render EXACTLY as before.

       Breakpoint standardised on 639px, matching .btn-group and every other
       modal rule in this block. The wallet rule used 640px, so at a viewport
       of exactly 640px a wallet footer was a reversed COLUMN while every other
       footer was still a ROW. One breakpoint, one behaviour.

       min-height is spelled out rather than delegated to .btn-touch: a second
       `.btn` further down re-declares min-height:40px at equal specificity and
       later in source, so .btn-touch loses. `.modal-footer .btn` outranks both. */
    .modal-footer {
        padding: var(--spacing-4);
        flex-direction: column-reverse;
        gap: var(--spacing-3);
    }

    /* The one footer with a third, destructive-side group (Edit User in
       manage-builders). Its own children reverse too, so the confirm stays on
       top of its dismiss; the Block/Unblock group then sits furthest from the
       confirm, which is what the destructive rule asks for. It carries a
       dedicated class rather than `.flex.gap-3` so the generic stacking rule
       keeps its four unrelated call sites and is not touched. */
    .modal-footer-actions {
        flex-direction: column-reverse;
    }
    
    .modal-footer .btn {
        width: 100%;
        min-height: 44px;
    }
    
    /* Mobile Page Header */
    .page-header {
        flex-direction: column;
        align-items: flex-start;
        gap: var(--spacing-4);
    }
    
    .page-header .btn {
        width: 100%;
    }
    
    /* Hide text on mobile, show icon only */
    .btn-icon-mobile span {
        display: none;
    }
    
    /* Mobile Alert.
       This stacked the icon above the body and centred every line of it. On
       one short line that reads as a banner; on the three-line note at the top
       of My Feasts it reads as a poster, and centred body copy gives the eye no
       left edge to return to -- the slowest way to present a sentence somebody
       has to actually read. The component already puts the icon beside
       left-aligned text above 640px; a 20px square and a paragraph fit that way
       at 360px too, so there was nothing for the stack to solve.

       align-items goes to flex-start because .alert is align-items: center
       further down this file: centred, the icon floats against the second line
       of a wrapped alert. On the one-line alerts elsewhere in the app it sits
       exactly where it did (FPFM-67). */
    .alert {
        align-items: flex-start;
        gap: var(--spacing-3);
    }
    
    /* Mobile Empty State */
    .empty-state svg {
        width: 64px;
        height: 64px;
    }
    
    .empty-state-title {
        font-size: var(--font-size-lg);
    }
}

/* ============================================
   PRINT STYLES
============================================ */
@media print {
    .sidebar,
    .navbar,
    .btn,
    .no-print {
        display: none !important;
    }
    
    .main-content {
        margin-left: 0;
    }
    
    body {
        background-color: white;
        color: black;
    }
    
    .card {
        border: 1px solid #ccc;
        background-color: white;
    }
}

/* ============================================
   ALERT STYLES
============================================ */
.alert {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    padding: var(--spacing-4);
    border-radius: var(--radius-lg);
    font-size: var(--font-size-sm);
}

.alert-info {
    background-color: rgba(59, 130, 246, 0.08);
    border: 1px solid rgba(59, 130, 246, 0.3);
    color: #1d4ed8;
}

.alert-success {
    background-color: rgba(16, 185, 129, 0.08);
    border: 1px solid rgba(16, 185, 129, 0.3);
    color: #047857;
}

.alert-warning {
    background-color: rgba(245, 158, 11, 0.08);
    border: 1px solid rgba(245, 158, 11, 0.3);
    color: #92400e;
}

.alert-danger {
    background-color: rgba(239, 68, 68, 0.08);
    border: 1px solid rgba(239, 68, 68, 0.3);
    color: #b91c1c;
}

/* ============================================
   PERMISSION STYLES
   All role-gated elements hidden until FeastApp.init()
   finishes and adds .app-ready to body.
   JS applyNavigationPermissions sets inline display
   BEFORE .app-ready is added, so there is no flash.
============================================ */
body:not(.app-ready) .edit-only,
body:not(.app-ready) .finance-only,
body:not(.app-ready) .builder-only,
body:not(.app-ready) .feast-builder-only,
body:not(.app-ready) .admin-only,
body:not(.app-ready) .approvals-only,
body:not(.app-ready) .management-only,
body:not(.app-ready) .district-builder-only,
body:not(.app-ready) .provincial-view-item,
body:not(.app-ready) .area-view-item,
body:not(.app-ready) .district-view-item,
body:not(.app-ready) .register-feast-btn,
body:not(.app-ready) .tithes-panel-item,
body:not(.app-ready) .under-dev-item {
    display: none;
}

/* The sidebar does not wait for the page.

   Those rules above are what stops a role-gated element flashing on screen
   before we know the role, and they still govern everything in the page's own
   markup. The sidebar is different now: bootChrome() builds it and applies the
   role — from the profile already on the device — before the page below it is
   even parsed, so making its menu wait for .app-ready (added at
   DOMContentLoaded, once every script has loaded) would put the pop back that
   all of this exists to remove.

   .chrome-ready is that same gate, granted earlier and only to the sidebar. It
   is set in the same breath as the permission pass, never before, so it is
   equally fail-closed: with no trustworthy cached profile it is never set at
   all and the menu stays hidden until the profile lands. Anything the role may
   NOT have is carrying an inline display:none by then, which outranks this. */
body.chrome-ready #sidebar .finance-only,
body.chrome-ready #sidebar .builder-only,
body.chrome-ready #sidebar .feast-builder-only,
body.chrome-ready #sidebar .admin-only,
body.chrome-ready #sidebar .approvals-only,
body.chrome-ready #sidebar .management-only,
body.chrome-ready #sidebar .category-admin-only,
body.chrome-ready #sidebar .content-admin-only,
body.chrome-ready #sidebar .provincial-view-item,
body.chrome-ready #sidebar .area-view-item,
body.chrome-ready #sidebar .district-view-item {
    display: revert;
}

.view-only-notice {
    background-color: rgba(59, 130, 246, 0.1);
    border: 1px solid rgba(59, 130, 246, 0.3);
    padding: var(--spacing-3) var(--spacing-4);
    border-radius: var(--radius-lg);
    margin-bottom: var(--spacing-4);
    display: flex;
    align-items: center;
    gap: var(--spacing-2);
    font-size: var(--font-size-sm);
    color: #1d4ed8;
}

.view-only-notice svg {
    flex-shrink: 0;
}

/* ============================================
   PROFESSIONAL SIDEBAR - ALTERNATE STRUCTURE
   (Used in map, budget, district-view, etc.)
============================================ */

/* Sidebar Container */
.app-container {
    display: flex;
    min-height: 100vh;
    opacity: 1;
    transition: opacity 0.25s ease;
}

/* Sidebar Header */
/* The .sidebar-header block that stood here was superseded by the IMPROVED
   SIDEBAR (v2.0) one below, and every property in it was either repeated
   there verbatim or already overridden -- except margin-bottom, which the
   v2.0 block never sets, so a stray 16px outlived the layout it belonged to.
   With the nav container's own 16px of top padding underneath it that made a
   32px gap between the logo and the first menu item, which is what it looked
   like: a header that had come loose from its menu (FPFM-67).

   Deleting the whole block changes exactly one computed value --
   margin-bottom 16px -> 0. Everything else it declared is unchanged. */

/* Logo */
.logo {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
}

.logo-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    background: linear-gradient(135deg, var(--primary), var(--primary-dark));
    border-radius: var(--radius-lg);
    color: #fff;
}

.logo-icon svg {
    width: 24px;
    height: 24px;
}

.logo-text {
    font-size: var(--font-size-lg);
    font-weight: 700;
    color: var(--secondary);
}

/* Sidebar Toggle Button */
.sidebar-toggle {
    display: none;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    background: transparent;
    border: none;
    border-radius: var(--radius-md);
    color: var(--gray-400);
    cursor: pointer;
    transition: all var(--transition-fast);
}

.sidebar-toggle:hover {
    background-color: var(--gray-700);
    color: var(--secondary);
}

/* Navigation List */
.nav-list {
    list-style: none;
    padding: 0;
    margin: 0;
}

.nav-item {
    margin-bottom: var(--spacing-1);
}

.nav-link {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    padding: var(--spacing-3) var(--spacing-4);
    color: var(--gray-400);
    border-radius: var(--radius-lg);
    transition: all var(--transition-fast);
    text-decoration: none;
    font-size: var(--font-size-sm);
    font-weight: 500;
}

.nav-link:hover {
    background-color: var(--gray-700);
    color: var(--secondary);
}

.nav-link.active {
    background-color: var(--primary);
    color: #fff;
}

.nav-icon {
    width: 20px;
    height: 20px;
    flex-shrink: 0;
}

.nav-link span {
    white-space: nowrap;
}

/* Navigation Section */
.nav-section {
    margin-top: var(--spacing-6);
    padding-top: var(--spacing-4);
    border-top: 1px solid var(--gray-700);
}

.nav-section-title {
    display: block;
    font-size: var(--font-size-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--gray-500);
    padding: 0 var(--spacing-4);
    margin-bottom: var(--spacing-3);
}

/* Sidebar Footer */
.sidebar-footer {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    padding: var(--spacing-4) var(--spacing-6);
    border-top: 1px solid var(--gray-700);
    background-color: var(--bg-surface);
}

/* User Info - Generic */
.user-info {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
}

.user-avatar {
    width: 40px;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: linear-gradient(135deg, var(--primary), var(--primary-dark));
    border-radius: 50%;
    font-size: var(--font-size-sm);
    font-weight: 600;
    color: #fff;
    flex-shrink: 0;
}

.user-details {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0;
}

.user-name {
    font-size: var(--font-size-sm);
    font-weight: 600;
    color: var(--secondary);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.user-role {
    font-size: var(--font-size-xs);
    color: var(--gray-400);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Button Icon */
.btn-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    background: transparent;
    border: none;
    border-radius: var(--radius-md);
    color: var(--gray-400);
    cursor: pointer;
    transition: all var(--transition-fast);
}

.btn-icon:hover {
    background-color: var(--gray-700);
    color: var(--secondary);
}

.btn-icon svg {
    width: 20px;
    height: 20px;
}

/* .main-header / .header-left / .header-right removed — all pages now use
   the standard .page-header pattern (uniform across every role/page) */

.page-title {
    font-size: var(--font-size-xl);
    font-weight: 600;
    color: var(--secondary);
    margin: 0;
}

.mobile-menu-btn {
    display: none;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    background: transparent;
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-md);
    color: var(--secondary);
    cursor: pointer;
    transition: all var(--transition-fast);
}

.mobile-menu-btn:hover {
    background-color: var(--gray-700);
}

/* Stats Panel (Map/Budget) */
.stats-panel {
    display: flex;
    gap: var(--spacing-4);
    padding: var(--spacing-4);
    background-color: var(--bg-surface);
    border-radius: var(--radius-lg);
    margin-bottom: var(--spacing-4);
}

.stat-item {
    text-align: center;
}

.stat-item .stat-label {
    font-size: var(--font-size-xs);
    color: var(--gray-400);
    margin-bottom: var(--spacing-1);
}

.stat-item .stat-value {
    font-size: var(--font-size-lg);
    font-weight: 600;
    color: var(--secondary);
}

/* Map Container */
.map-container {
    height: calc(100vh - 200px);
    border-radius: var(--radius-lg);
    overflow: hidden;
    border: 1px solid var(--gray-700);
}

/* Sidebar Mobile Responsive (Alternate Structure) */
@media (max-width: 767px) {
    .app-container .sidebar {
        position: fixed;
        top: 0;
        left: 0;
        width: 280px;
        height: 100vh;
        transform: translateX(-100%);
        transition: transform var(--transition-normal);
        z-index: 1000;
    }
    
    .app-container .sidebar.open,
    .app-container .sidebar.show {
        transform: translateX(0);
    }
    
    .sidebar-toggle {
        display: flex;
    }
    
    .mobile-menu-btn {
        display: flex;
    }
    
    .page-title {
        font-size: var(--font-size-lg);
    }
    
    .map-container {
        height: calc(100vh - 160px);
    }
    
    .stats-panel {
        flex-wrap: wrap;
    }
    
    .stat-item {
        flex: 1;
        min-width: 80px;
    }
}

/* Sidebar Overlay (Alternate Structure) */
.sidebar-backdrop {
    position: fixed;
    inset: 0;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: 999;
    opacity: 0;
    visibility: hidden;
    transition: opacity var(--transition-normal), visibility var(--transition-normal);
}

.sidebar-backdrop.active {
    opacity: 1;
    visibility: visible;
}

/* Desktop Sidebar (Alternate Structure) */
@media (min-width: 768px) {
    .app-container .sidebar {
        position: fixed;
        transform: translateX(0);
    }
    
    .app-container .main-content {
        margin-left: 260px;
    }
    
    .sidebar-toggle,
    .mobile-menu-btn,
    .sidebar-backdrop {
        display: none !important;
    }
}

/* ============================================
   COMPREHENSIVE RESPONSIVE FIXES
============================================ */

/* Content Wrapper */
.content-wrapper {
    position: relative;
    width: 100%;
    min-height: calc(100vh - 60px);
}

/* Mobile-First Form Fixes */
.form-control,
.form-select,
.form-input {
    width: 100%;
    min-height: 44px; /* Touch-friendly */
    font-size: 16px; /* Prevents iOS zoom */
}

/* Flex Utility Responsive */
.flex {
    display: flex;
}

.flex-wrap {
    flex-wrap: wrap;
}

.gap-2 {
    gap: var(--spacing-2);
}

.gap-3 {
    gap: var(--spacing-3);
}

.gap-4 {
    gap: var(--spacing-4);
}

/* Grid Responsive Improvements */
.grid {
    display: grid;
    gap: var(--spacing-4);
}

/* Responsive Grid - Mobile First */
/* On mobile (< 768px), stats show 2 columns, everything else 1 column */
@media (max-width: 767px) {
    .grid.grid-cols-1.md\:grid-cols-4 {
        grid-template-columns: repeat(2, 1fr);
    }
    
    .grid.grid-cols-1.md\:grid-cols-3 {
        grid-template-columns: 1fr;
    }
}

/* Tablet (768px+) */
@media (min-width: 768px) {
    .md\:grid-cols-2 {
        grid-template-columns: repeat(2, 1fr);
    }

    .md\:grid-cols-3 {
        grid-template-columns: repeat(3, 1fr);
    }

    .md\:grid-cols-4 {
        grid-template-columns: repeat(4, 1fr);
    }

    /* Column spans — desktop only. Never set `grid-column: span N` inline:
       below 768px the grid is 1 column, so a span > 1 makes the browser add an
       implicit track and the row stops being a clean full-width stack. */
    .md\:col-span-2 {
        grid-column: span 2;
    }
}

/* Spans every column of the current grid — safe at any breakpoint, including
   the 1-column mobile grid. */
.col-span-full {
    grid-column: 1 / -1;
}

/* Large screens (1024px+) */
@media (min-width: 1024px) {
    .lg\:grid-cols-3 {
        grid-template-columns: repeat(3, 1fr);
    }
    
    .lg\:grid-cols-4 {
        grid-template-columns: repeat(4, 1fr);
    }
}

/* Card Responsive */
.card {
    width: 100%;
    overflow: hidden;
}

.card-body {
    padding: var(--spacing-4);
}

@media (min-width: 768px) {
    .card-body {
        padding: var(--spacing-6);
    }
}

/* Table Responsive */
.table-responsive {
    width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
}

.table {
    min-width: 600px;
}

/* Button Responsive */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--spacing-2);
    white-space: nowrap;
    min-height: 40px;
    padding: var(--spacing-2) var(--spacing-4);
    font-size: var(--font-size-sm);
    border-radius: var(--radius-md);
    cursor: pointer;
    transition: all var(--transition-fast);
}

/* Mobile: Stack buttons vertically */
@media (max-width: 639px) {
    .btn-group {
        flex-direction: column;
        width: 100%;
    }
    
    .btn-group .btn {
        width: 100%;
    }
    
    .flex.gap-3:not(.flex-wrap) {
        flex-direction: column;
    }
    
}

/* Stats Cards Responsive */
.stats-grid {
    display: grid;
    gap: var(--spacing-4);
    grid-template-columns: repeat(2, 1fr);
}

@media (min-width: 768px) {
    .stats-grid {
        grid-template-columns: repeat(2, 1fr);
    }
}

@media (min-width: 1024px) {
    .stats-grid {
        grid-template-columns: repeat(4, 1fr);
    }
}

.stat-card {
    padding: var(--spacing-4);
}

@media (min-width: 768px) {
    .stat-card {
        padding: var(--spacing-6);
    }
}

/* Text Responsive */
.text-2xl {
    font-size: var(--font-size-xl);
}

@media (min-width: 768px) {
    .text-2xl {
        font-size: var(--font-size-2xl);
    }
}

/* Hide/Show Helpers */
.hidden-mobile {
    display: none;
}

.visible-mobile {
    display: block;
}

@media (min-width: 768px) {
    .hidden-mobile {
        display: block;
    }
    
    .visible-mobile {
        display: none;
    }
    
    .hidden-desktop {
        display: none;
    }
}

/* Safe Area for Mobile Devices */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
    .main-content {
        padding-bottom: calc(var(--spacing-6) + env(safe-area-inset-bottom));
    }
    
    .sidebar {
        padding-bottom: calc(var(--spacing-6) + env(safe-area-inset-bottom));
    }
}

/* Touch-Friendly Spacing */
@media (pointer: coarse) {
    .btn {
        min-height: 44px;
        padding: var(--spacing-3) var(--spacing-4);
    }
    
    .nav-link {
        min-height: 44px;
    }
    
    .sidebar-link {
        min-height: 44px;
    }
}

/* Scroll Snap for Horizontal Scroll */
.scroll-snap-x {
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
}

.scroll-snap-x > * {
    scroll-snap-align: start;
}

/* Loading States */
.skeleton {
    background: linear-gradient(90deg, var(--gray-700) 25%, var(--gray-600) 50%, var(--gray-700) 75%);
    background-size: 200% 100%;
    animation: skeleton-loading 1.5s infinite;
    border-radius: var(--radius-md);
}

@keyframes skeleton-loading {
    0% {
        background-position: 200% 0;
    }
    100% {
        background-position: -200% 0;
    }
}

/* Skeleton placeholder pieces — used in place of a container's default
   content while its data is still loading (FPFM-1), so a slow connection
   shows "still loading" instead of a premature real empty-state or a blank
   card. Composed from .skeleton (the shimmer) above. */
.skeleton-line {
    height: 13px;
}

.skeleton-circle {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    flex-shrink: 0;
}

.skeleton-row {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    padding: var(--spacing-3) var(--spacing-4);
}

.skeleton-row + .skeleton-row {
    border-top: 1px solid var(--gray-700);
}

.skeleton-row-lines {
    flex: 1;
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.skeleton-card {
    padding: var(--spacing-4);
}

/* Stat-card figure skeleton -- shown until loadDashboardStats() writes the
   real number (FPFM-6). Without this, a slow connection shows a real
   ₱0.00 / 0 first: the exact false empty-state .skeleton-row/.skeleton-card
   exist to avoid, just on the dashboard's most prominent numbers. Text stays
   in the DOM (color: transparent) so the element keeps its natural width --
   no separate placeholder sizing to keep in sync with real values. */
.is-loading {
    color: transparent;
    background: linear-gradient(90deg, var(--gray-700) 25%, var(--gray-600) 50%, var(--gray-700) 75%);
    background-size: 200% 100%;
    animation: skeleton-loading 1.5s infinite;
    border-radius: var(--radius-md);
    display: inline-block;
    min-width: 56px;
}

/* A block element's placeholder has to stay a block.

   The rule above turns whatever it touches into an inline-block, so that a
   bare <span> gets a box to shimmer in. On an <h2> and the <p> under it that
   is a change of LAYOUT, not of paint: two block-level lines become two
   inline-blocks, they flow onto the SAME line, and the moment the real text
   lands they drop back onto two lines and shove everything below them down
   20px. That is the area/district header, a third of a second after the page
   opened. width:fit-content keeps the shrink-wrapped shimmer — the reason the
   inline-block was reached for — without leaving the block flow. */
h1.is-loading, h2.is-loading, h3.is-loading, h4.is-loading,
h5.is-loading, h6.is-loading, p.is-loading, div.is-loading {
    display: block;
    width: fit-content;
}

/* A form control shimmering in place keeps its own corner radius, so the
   field does not change shape at the moment its options arrive (FPFM-13).
   The shorthand background above also drops the chevron while it loads,
   which is wanted: a dropdown arrow on a field you cannot open yet is an
   invitation to tap something that will not answer. */
.form-select.is-loading,
.form-control.is-loading {
    border-radius: var(--radius-lg);
}

@media (prefers-reduced-motion: reduce) {
    .skeleton,
    .is-loading {
        animation: none;
        background-position: 0 0;
    }
}

/* ============================================
   NAVIGATION — THE SIDEBAR DOES NOT RELOAD

   Every page is a real document, so picking a menu item is a full
   navigation: the old page is torn down, the new one is parsed, and the
   sidebar is rebuilt by renderSidebar(). The markup is identical either
   side, but what the user watches is the menu blanking out and coming
   back — the sidebar loading along with the page.

   A cross-document view transition hands that to the browser. It holds the
   outgoing frame until the incoming document is ready to render, and any
   element carrying the same view-transition-name in both documents is
   treated as the SAME element rather than one leaving and another arriving.
   The sidebar and the mobile header are named and given no animation,
   because they do not change — only the content area does.

   Progressive enhancement, and nothing depends on it: a browser without
   cross-document view transitions navigates exactly as it did before
   (Chromium 126+ and Safari 18.2+ have them; Firefox falls back).
============================================ */
@view-transition {
    navigation: auto;
}

.sidebar {
    view-transition-name: feast-sidebar;
}

.mobile-header {
    view-transition-name: feast-mobile-header;
}

/* Carried across, not animated — a menu that fades on every navigation is
   the same complaint in a nicer costume.

   Held and cut on exactly the same clock as the content below, which is not
   the same thing as not animating it. With `animation: none` on old and new,
   the incoming sidebar is painted at full opacity the moment its snapshot is
   taken, while the content is still holding the page you came from. Measured
   on Feast Map → Finance Assistant: the red highlight left Feast Map at 135ms
   and the page did not change until 320ms, so for a fifth of a second the map
   was on screen with nothing selected in the menu — the highlight blinking
   off on its own, which is precisely the twitch this section exists to
   remove. The screen now changes in one step, not two.

   mix-blend-mode stays normal on all of them: the default plus-lighter is for
   cross-fades, and there are none here. */
::view-transition-group(feast-sidebar),
::view-transition-group(feast-mobile-header) {
    animation: none;
    mix-blend-mode: normal;
}

::view-transition-old(feast-sidebar),
::view-transition-old(feast-mobile-header) {
    animation: feast-hold 200ms step-end both;
    mix-blend-mode: normal;
}

::view-transition-new(feast-sidebar),
::view-transition-new(feast-mobile-header) {
    animation: feast-cut 200ms step-end both;
    mix-blend-mode: normal;
}

/* Hold, then cut. Never blend.

   This has been wrong twice, in opposite directions, so both failures are
   written down here.

   It began as a 140ms cross-fade. A fade puts BOTH documents on screen at
   partial opacity for its whole length, so the old page's headings ghost
   through the new one — ~250ms of visible double exposure on every hop.

   The fade was then removed outright (animation: none on old, new and group).
   That killed the ghosting and, on a fast connection, looked perfect. On a
   slow one it was worse than what it replaced: with no animation the new
   document is shown the instant its snapshot is taken, and under 120ms RTT
   with a throttled CPU that snapshot is taken BEFORE #mainContent has been
   parsed. Measured: a blank content area — sidebar drawn, page empty — on
   every single hop, where the old cross-fade had produced none. The fade had
   not been preventing that frame, it had been covering it.

   rel=expect is supposed to hold the first paint until #mainContent is
   parsed, and on a fast connection it does. Under throttling it does not:
   #mainContent is absent at the first animation frame on 5 of 6 hops, and
   that is true of the pre-fade-removal build too. So the empty frame is not
   ours to prevent here — it has to be covered.

   Hence: the OLD page is held at full opacity for the length of the swap, and
   the new one is switched in with step-end, which means opacity 0 for the
   whole duration and then 1. A cut, never a blend. Nothing is ever
   half-transparent, so there is no ghosting; and the window where the new
   page has no content yet is covered by the page the user was already
   reading.

   The 200ms is a cover, not an animation — the user should not perceive a
   transition at all, just the previous page until the next one is ready.

   The group stays silent: its size/position animation is a no-op when the
   layout matches, and it would only keep the transition alive longer.

   Guarded by tests/navigation-blink.test.js. */
::view-transition-group(root) {
    animation: none;
}

::view-transition-old(root) {
    animation: feast-hold 200ms step-end both;
}

::view-transition-new(root) {
    animation: feast-cut 200ms step-end both;
}

@keyframes feast-hold {
    from { opacity: 1; }
    to { opacity: 1; }
}

@keyframes feast-cut {
    from { opacity: 0; }
    to { opacity: 1; }
}

/* ============================================
   IMPROVED SIDEBAR (v2.0)
============================================ */
.sidebar {
    position: fixed;
    left: 0;
    top: 0;
    width: 260px;
    height: 100vh;
    padding: 0;
    background: var(--bg-surface);
    border-right: 1px solid var(--gray-700);
    display: flex;
    flex-direction: column;
    z-index: 1000;
    transition: transform var(--transition-slow), width var(--transition-slow);
}

.sidebar-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    /* Bottom padding is deliberately smaller than the top. The brand block
       carries a reserved scope line under it (js/sidebar.js) that holds its
       height whether or not a scope is known, so a symmetric 16px here read
       as two stacked gaps between the header and the first nav item. The
       line stays reserved; only the padding under it is trimmed.

       8px is the floor that still reads as deliberate. Measured in Chrome at
       1440 against the matching 8px on .sidebar-nav-container: at 4px the
       scope line's descenders sit 5px off the rule and the active nav pill's
       top edge runs into it from below, which reads as collided rather than
       tight. 6px is the last value that does not collide; 8px is the last
       that still looks chosen. */
    padding: var(--spacing-4) var(--spacing-4) var(--spacing-2);
    border-bottom: 1px solid var(--gray-700);
    flex-shrink: 0;
}

.sidebar-logo {
    display: flex;
    align-items: center;
    gap: var(--spacing-2);
}

.sidebar-logo svg {
    width: 32px;
    height: 32px;
    flex-shrink: 0;
}

.sidebar-logo span {
    font-size: var(--font-size-base);
    font-weight: 700;
    color: var(--secondary);
    white-space: nowrap;
}

.sidebar-toggle {
    display: none;
    background: transparent;
    border: none;
    color: var(--gray-400);
    cursor: pointer;
    padding: var(--spacing-2);
    border-radius: var(--radius-md);
}

.sidebar-toggle:hover {
    background: var(--gray-700);
    color: var(--secondary);
}

/* ============================================
   ROLE SWITCHER (System Admin)
   ============================================ */
.role-switcher-container {
    margin: 8px 12px 4px;
    background: var(--gray-800);
    border: 1px solid var(--gray-700);
    border-radius: 10px;
    overflow: hidden;
    transition: border-color 0.2s ease;
}

.role-switcher-container:hover {
    border-color: var(--gray-600);
}

/* Collapsed single-row toggle — keeps the sidebar nav visible */
.viewas-toggle {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    padding: 9px 12px;
    background: none;
    border: none;
    cursor: pointer;
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.03em;
    color: var(--secondary);
    text-align: left;
}

.viewas-toggle svg {
    flex-shrink: 0;
    width: 14px;
    height: 14px;
    stroke: var(--gray-400);
}

.viewas-toggle .viewas-label {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.viewas-toggle .viewas-caret {
    width: 12px;
    height: 12px;
    transition: transform 0.15s ease;
}

.viewas-toggle.open .viewas-caret {
    transform: rotate(180deg);
}

/* Red state when impersonating so it can't be missed */
.viewas-toggle.active {
    color: var(--primary);
}

.viewas-toggle.active svg {
    stroke: var(--primary);
}

.viewas-panel {
    padding: 2px 12px 12px;
    border-top: 1px solid var(--gray-700);
}

.viewas-field-label {
    display: block;
    font-size: 0.6rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--gray-400);
    margin: 10px 0 4px;
}

.viewas-reset {
    width: 100%;
    margin-top: 12px;
    padding: 7px 10px;
    background: rgba(220, 38, 38, 0.12);
    border: 1px solid rgba(220, 38, 38, 0.45);
    border-radius: 8px;
    color: var(--primary);
    font-size: 0.72rem;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.15s ease;
}

.viewas-reset:hover {
    background: rgba(220, 38, 38, 0.22);
}

.role-switcher-select {
    width: 100%;
    padding: 7px 28px 7px 10px;
    background: var(--bg-card);
    border: 1px solid var(--gray-600);
    border-radius: 8px;
    color: var(--secondary);
    font-size: 0.78rem;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s ease;
    appearance: none;
    -webkit-appearance: none;
    -moz-appearance: none;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888888' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 10px center;
    background-size: 12px 12px;
}

/* background-color (not shorthand) so the chevron image survives hover */
.role-switcher-select:hover {
    border-color: var(--gray-500);
    background-color: var(--gray-800);
}

.role-switcher-select:focus {
    outline: none;
    border-color: var(--primary);
    box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.15);
}

.role-switcher-select option {
    background: var(--bg-card);
    color: var(--secondary);
    padding: 4px 8px;
}

/* "Viewing as" banner — fixed across the top of every page while active */
.viewas-banner {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    height: 40px;
    z-index: 1200;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 12px;
    padding: 0 12px;
    background: var(--primary-dark, #991B1B);
    color: #FFFFFF;
    font-size: 0.8rem;
    font-weight: 500;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}

.viewas-banner-text {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.viewas-banner b {
    font-weight: 700;
}

.viewas-banner-exit {
    flex-shrink: 0;
    padding: 4px 12px;
    background: rgba(255, 255, 255, 0.15);
    border: 1px solid rgba(255, 255, 255, 0.4);
    border-radius: 6px;
    color: #FFFFFF;
    font-size: 0.75rem;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.15s ease;
}

.viewas-banner-exit:hover {
    background: rgba(255, 255, 255, 0.28);
}

/* Push the whole app (incl. the fixed sidebar and mobile toggle) below the banner */
body.viewas-active {
    padding-top: 40px;
}

body.viewas-active .sidebar {
    top: 40px;
    height: calc(100vh - 40px);
}

body.viewas-active .mobile-menu-toggle {
    top: calc(var(--spacing-4) + 40px);
}

/* Sticky mobile header must pin below the banner, not under it */
body.viewas-active .mobile-header {
    top: 40px;
}

@media (max-width: 640px) {
    .viewas-banner-note {
        display: none;
    }
}

.sidebar-nav-container {
    flex: 1;
    overflow-y: auto;
    /* Reaching the end of the menu must not start scrolling the page behind
       it — see body.sidebar-open above. */
    overscroll-behavior: contain;
    /* Top padding is the ENTIRE gap between the header rule and the first menu
       item (see the note where the fossil header block was removed), so it is
       set on its own rather than inherited from a vertical shorthand. Verified
       by measurement, not by reading: .sidebar-nav has margin 0 / padding 0 and
       .sidebar-item has no top margin, so nothing else contributes.

       Matched to .sidebar-header's 8px bottom padding so the rule sits in
       even air. The horizontal 12px is untouched -- it is the nav pills' inset
       from the sidebar edge and has nothing to do with this gap. */
    padding: var(--spacing-2) var(--spacing-3) var(--spacing-4);
    
    /* Custom scrollbar */
    scrollbar-width: thin;
    scrollbar-color: var(--gray-600) transparent;
}

/* Webkit scrollbar — sidebar nav */
.sidebar-nav-container::-webkit-scrollbar {
    width: 5px;
}

.sidebar-nav-container::-webkit-scrollbar-track {
    background: transparent;
    margin: 4px 0;
}

.sidebar-nav-container::-webkit-scrollbar-thumb {
    background: var(--gray-600);
    border-radius: 10px;
    transition: background 0.2s;
}

.sidebar-nav-container::-webkit-scrollbar-thumb:hover {
    background: var(--gray-500);
}

/* Show scrollbar only on hover for cleaner look */
.sidebar-nav-container {
    scrollbar-gutter: stable;
}

/* ============================================
   GLOBAL CUSTOM SCROLLBAR
   ============================================ */
/* Firefox */
* {
    scrollbar-width: thin;
    scrollbar-color: var(--gray-600) transparent;
}

/* Webkit (Chrome, Safari, Edge) */
::-webkit-scrollbar {
    width: 6px;
    height: 6px;
}

::-webkit-scrollbar-track {
    background: transparent;
}

::-webkit-scrollbar-thumb {
    background: var(--gray-600);
    border-radius: 10px;
}

::-webkit-scrollbar-thumb:hover {
    background: var(--gray-500);
}

::-webkit-scrollbar-corner {
    background: transparent;
}

.sidebar-nav {
    list-style: none;
    padding: 0;
    margin: 0;
}

.sidebar-item {
    margin-bottom: var(--spacing-1);
}

.sidebar-link {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    padding: var(--spacing-3) var(--spacing-3);
    color: var(--gray-400);
    border-radius: var(--radius-md);
    font-size: var(--font-size-sm);
    font-weight: 500;
    transition: all var(--transition-fast);
    text-decoration: none;
}

.sidebar-link:hover {
    background-color: rgba(0, 0, 0, 0.05);
    color: var(--secondary);
}

.sidebar-link.active {
    background-color: var(--primary);
    color: #fff;
    box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
}

.sidebar-link svg {
    width: 18px;
    height: 18px;
    flex-shrink: 0;
}

.sidebar-link span {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.sidebar-section {
    margin-top: var(--spacing-6);
    padding-top: var(--spacing-4);
    border-top: 1px solid var(--gray-700);
}

/* The heading is a button: it folds its section away. Everything below the
   first four declarations is the <p> it used to be, kept so the menu reads
   exactly as before until you hover it. */
.sidebar-section-title {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-2);
    width: 100%;
    background: none;
    border: none;
    text-align: left;
    cursor: pointer;
    font-family: inherit;
    font-size: 10px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--gray-500);
    padding: var(--spacing-2) var(--spacing-3);
    margin-bottom: 0;
    transition: color var(--transition-fast);
}

.sidebar-section-title:hover {
    color: var(--secondary);
}

.sidebar-section-chevron {
    flex-shrink: 0;
    transition: transform 0.2s ease;
}

/* Folded: the chevron lies on its side and the links go away */
.sidebar-section.section-folded .sidebar-section-chevron {
    transform: rotate(-90deg);
}

.sidebar-section.section-folded .sidebar-nav,
.sidebar-section.section-folded .sidebar-invite-section {
    display: none;
}

/* Folded down to icons there is no heading to unfold with, so a folded
   section must not also hide its links -- they would be unreachable. */
.sidebar.sidebar-collapsed .sidebar-section.section-folded .sidebar-nav {
    display: block;
}

/* A heading is a control, and on a phone a control is thumb-sized */
@media (max-width: 767px) {
    .sidebar-section-title {
        min-height: 44px;
    }
}

/* User Profile in Sidebar - Professional Vertical Design */
/* Compact account footer — one row; actions grouped in the pop-up menu */
.sidebar-account {
    position: relative;
    flex-shrink: 0;
}

.sidebar-user {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    border-top: 1px solid var(--gray-700);
    background: rgba(0, 0, 0, 0.02);
    flex-shrink: 0;
}

.sidebar-user .user-avatar {
    width: 34px;
    height: 34px;
    min-width: 34px;
    background: linear-gradient(135deg, var(--primary) 0%, #c0392b 100%);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 700;
    font-size: 13px;
    letter-spacing: 0.5px;
    color: white;
    flex-shrink: 0;
    box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3);
    border: 3px solid rgba(220, 38, 38, 0.2);
}

.sidebar-user .user-info {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 1px;
    overflow: hidden;
}

.sidebar-user .user-name {
    margin: 0;
    font-size: 12.5px;
    font-weight: 600;
    color: var(--secondary);
    line-height: 1.3;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.sidebar-user .user-role {
    margin: 0;
    font-size: 10.5px;
    font-weight: 500;
    color: var(--gray-400);
    line-height: 1.3;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.account-menu-toggle {
    flex-shrink: 0;
    width: 30px;
    height: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: 1px solid transparent;
    border-radius: 8px;
    color: var(--gray-400);
    cursor: pointer;
    transition: all 0.15s ease;
}

.account-menu-toggle:hover,
.account-menu-toggle[aria-expanded="true"] {
    background: var(--gray-800);
    border-color: var(--gray-600);
    color: var(--secondary);
}

/* Pop-up menu — opens upward, above the account row */
.account-menu {
    position: absolute;
    bottom: calc(100% + 6px);
    left: 10px;
    right: 10px;
    padding: 5px;
    background: var(--bg-card);
    border: 1px solid var(--gray-600);
    border-radius: 10px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
    z-index: 80;
}

.account-menu-item {
    display: flex;
    align-items: center;
    gap: 9px;
    width: 100%;
    padding: 8px 10px;
    background: none;
    border: none;
    border-radius: 7px;
    color: var(--secondary);
    font-size: 12.5px;
    font-weight: 500;
    text-align: left;
    cursor: pointer;
    transition: background 0.12s ease;
}

.account-menu-item svg {
    flex-shrink: 0;
    color: var(--gray-400);
}

.account-menu-item:hover {
    background: var(--gray-800);
}

.account-menu-item.danger {
    color: #e74c3c;
}

.account-menu-item.danger svg {
    color: #e74c3c;
}

.account-menu-item.danger:hover {
    background: rgba(231, 76, 60, 0.1);
}

/* "Try mobile view (experiment)" is not drawn on a desktop. The query is
   MSHL_DESKTOP_QUERY in js/sidebar.js, copied verbatim — its note says what
   "desktop" means and why — and that file refuses the same entry if the row
   is reached anyway. tests/mobile-shell-optin.test.js fails if they differ. */
@media (min-width: 768px) and (pointer: fine), (min-width: 768px) and (pointer: none) {
    #mshlOptInItem {
        display: none;
    }
}

.account-menu-divider {
    height: 1px;
    margin: 4px 6px;
    background: var(--gray-700);
}

/* Mobile Header */
.mobile-header {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    height: 56px;
    background: var(--bg-surface);
    border-bottom: 1px solid var(--gray-700);
    padding: 0 var(--spacing-4);
    align-items: center;
    gap: var(--spacing-3);
    z-index: 999;
}

.mobile-menu-btn {
    background: transparent;
    border: none;
    color: var(--secondary);
    cursor: pointer;
    padding: var(--spacing-2);
}

.mobile-title {
    flex: 1;
    font-weight: 600;
    font-size: var(--font-size-base);
}

.mobile-user {
    width: 32px;
    height: 32px;
    background: var(--primary);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 600;
    font-size: 12px;
    color: white;
}

/* Sidebar Overlay for Mobile */
.sidebar-overlay {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.5);
    z-index: 999;
    opacity: 0;
    transition: opacity var(--transition-normal);
}

.sidebar-overlay.active {
    opacity: 1;
}

/* The drawer is open — freeze what is behind it.

   toggleSidebar() has always written `sidebar-open` onto <body>
   (js/sidebar.js), and nothing in this file ever matched it. So a drag
   anywhere over the dimmed overlay scrolled the document underneath: you shut
   the menu and landed somewhere other than where you opened it.

   overflow:hidden alone does not do it — iOS Safari ignores it on <body> and
   keeps scrolling the document. What actually refuses the gesture are the two
   elements that between them cover the entire screen while the drawer is
   open: touch-action:none on the full-viewport overlay, and
   overscroll-behavior:contain on the sidebar's own scroller so reaching its
   end does not chain the scroll to the body.

   touch-action does not affect tap, so the overlay's onclick still closes the
   menu.

   Deliberately NOT `position: fixed` on <body>. That is the other standard
   fix and it works, but it collapses the document to the viewport and drops
   the scroll position, so closing the menu jumps you to the top — which is
   this same bug wearing a different hat (FPFM-67). */
body.sidebar-open {
    overflow: hidden;
}

.sidebar-overlay {
    touch-action: none;
    overscroll-behavior: contain;
}


/* ============================================
   MAIN CONTENT - FULL SCREEN RESPONSIVE
============================================ */
/* Offset must equal the sidebar width (260px) so content never sits under it */
.main-content {
    margin-left: 260px;
    padding: var(--spacing-6) var(--spacing-8);
    min-height: 100vh;
    width: calc(100% - 260px);
    max-width: 100%;
}

/* Page Header */
.page-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    gap: var(--spacing-4);
    margin-bottom: var(--spacing-6);
    flex-wrap: wrap;
}

.page-header h1 {
    font-size: var(--font-size-2xl);
    font-weight: 700;
    margin-bottom: var(--spacing-1);
}

.page-header p {
    color: var(--gray-400);
    font-size: var(--font-size-sm);
}

.page-actions {
    display: flex;
    gap: var(--spacing-3);
    flex-wrap: wrap;
    align-items: center;
}

/* Header dropdowns size to content and sit inline with action buttons.
   Overrides the global `.form-select { width:100% }` mobile-first rule that
   otherwise forces the select full-width and stacks the button below it. */
.page-actions .form-select,
.page-actions .form-control,
.page-actions .form-input {
    width: auto;
    min-width: 150px;
    max-width: 220px;
}

/* ============================================
   IMPROVED GRID SYSTEMS
============================================ */
.stats-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: var(--spacing-4);
    margin-bottom: var(--spacing-6);
}

.content-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--spacing-6);
}

@media (min-width: 1024px) {
    .content-grid {
        grid-template-columns: 2fr 1fr;
    }
    
    .content-grid.equal {
        grid-template-columns: 1fr 1fr;
    }
}

@media (min-width: 1400px) {
    .stats-grid {
        grid-template-columns: repeat(4, 1fr);
    }
}

/* ============================================
   IMPROVED STAT CARDS
============================================ */
.stat-card {
    background: var(--bg-surface);
    border-radius: var(--radius-xl);
    padding: var(--spacing-5);
    border: 1px solid var(--gray-700);
    display: flex;
    flex-direction: column;
    position: relative;
    overflow: hidden;
    transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}

.stat-card:hover {
    transform: translateY(-2px);
    box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}

.stat-card-icon {
    width: 40px;
    height: 40px;
    border-radius: var(--radius-lg);
    display: flex;
    align-items: center;
    justify-content: center;
    margin-bottom: var(--spacing-4);
}

.stat-card-icon svg {
    width: 20px;
    height: 20px;
    stroke: currentColor;
    fill: none;
}

.stat-card-icon.green {
    background: rgba(16, 185, 129, 0.15);
    color: var(--success);
}

.stat-card-icon.red {
    background: rgba(239, 68, 68, 0.15);
    color: var(--error);
}

.stat-card-icon.blue {
    background: rgba(59, 130, 246, 0.15);
    color: var(--info);
}

.stat-card-icon.yellow {
    background: rgba(245, 158, 11, 0.15);
    color: var(--warning);
}

.stat-card-value {
    font-size: var(--font-size-2xl);
    font-weight: 700;
    color: var(--secondary);
    margin-bottom: var(--spacing-1);
}

.stat-card-value.text-success {
    color: var(--success);
}

.stat-card-value.text-error {
    color: var(--error);
}

.stat-card-label {
    font-size: var(--font-size-sm);
    color: var(--gray-400);
}

.stat-card-trend {
    display: inline-flex;
    align-items: center;
    gap: var(--spacing-1);
    font-size: var(--font-size-xs);
    margin-top: var(--spacing-2);
    padding: 2px 8px;
    border-radius: var(--radius-full);
}

.stat-card-trend.up {
    background: rgba(16, 185, 129, 0.1);
    color: var(--success);
}

.stat-card-trend.down {
    background: rgba(239, 68, 68, 0.1);
    color: var(--error);
}

/* ============================================
   IMPROVED CARDS
============================================ */
.card {
    background: var(--bg-surface);
    border-radius: var(--radius-xl);
    border: 1px solid var(--gray-700);
    overflow: hidden;
}

.card-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: var(--spacing-4) var(--spacing-5);
    border-bottom: 1px solid var(--gray-700);
}

.card-header h3 {
    font-size: var(--font-size-base);
    font-weight: 600;
}

.card-body {
    padding: var(--spacing-5);
}

.card-body.no-padding {
    padding: 0;
}

/* ============================================
   IMPROVED TABLES
============================================ */
.table-container {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
}

.table {
    width: 100%;
    border-collapse: collapse;
    font-size: var(--font-size-sm);
}

.table th {
    background: var(--bg-dark);
    color: var(--gray-400);
    font-weight: 600;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    padding: var(--spacing-3) var(--spacing-4);
    text-align: left;
    white-space: nowrap;
}

.table td {
    padding: var(--spacing-4);
    border-bottom: 1px solid var(--gray-700);
    color: var(--gray-300);
}

.table tr:hover td {
    background: rgba(0, 0, 0, 0.02);
}

.table tr:last-child td {
    border-bottom: none;
}

/* ============================================
   RESPONSIVE BREAKPOINTS
============================================ */

/* Tablet */
@media (max-width: 1024px) {
    .main-content {
        padding: var(--spacing-5) var(--spacing-6);
    }
    
    .stats-grid {
        grid-template-columns: repeat(2, 1fr);
    }
    
    .content-grid {
        grid-template-columns: 1fr;
    }
}

/* Mobile.

   767, not 768. `@media (min-width: 768px)` earlier in this file puts the
   sidebar back on screen and offsets .main-content by 260px, and a
   `max-width: 768px` bound here overlapped it by exactly one pixel — which
   this block won, being later in the file. At 768px the result was the mobile
   chrome with none of the mobile controls: .mobile-header shown, .sidebar
   pushed off-canvas, and .mobile-menu-btn still display:none, because the rule
   that reveals it lives under `@media (max-width: 767px)`. No hamburger, no
   menu, no route to any other page. 768x1024 is iPad Mini portrait and a stock
   preset in Chrome's device toolbar (FPFM-67).

   Every other mobile/desktop split in this file is already 767/768. This block
   was the only outlier, and the one-pixel overlap is the whole bug. */
@media (max-width: 767px) {
    .sidebar {
        transform: translateX(-100%);
        width: 280px;
    }
    
    .sidebar.open {
        transform: translateX(0);
    }
    
    .sidebar-overlay.active {
        display: block;
    }
    
    .mobile-header {
        display: flex;
    }
    
    .main-content {
        margin-left: 0;
        width: 100%;
        padding: var(--spacing-4);
        padding-top: calc(56px + var(--spacing-4));
    }
    
    .page-header {
        flex-direction: column;
        align-items: stretch;
    }
    
    .page-actions {
        width: 100%;
    }
    
    .page-actions .btn {
        flex: 1;
    }
    
    .stats-grid {
        grid-template-columns: 1fr 1fr;
        gap: var(--spacing-3);
    }
    
    .stat-card {
        padding: var(--spacing-4);
    }
    
    .stat-card-value {
        font-size: var(--font-size-xl);
    }
    
    .card-header,
    .card-body {
        padding: var(--spacing-4);
    }
    
    .table th,
    .table td {
        padding: var(--spacing-3);
    }
}

/* Small Mobile */
@media (max-width: 480px) {
    .stats-grid {
        grid-template-columns: 1fr;
    }
    
    /* The row is the [icon][content] shape: a 40px square on the left, the
       figure and its label stacked beside it. It is only right for a card
       built that way. A card whose children ARE the figure, the label and a
       note -- Area Command's four tiles -- got three columns instead: the
       value collapsed to "7 /" over "18", the label broken across two lines,
       and the note crushed against the right edge (FPFM-67). Keyed to the
       content wrapper rather than to a page, so the next tile copied from
       here cannot inherit it. Without the wrapper the base .stat-card rule
       already stacks, which is also what a browser with no :has() falls back
       to -- the safe half of the choice. */
    .stat-card:has(> .stat-card-content) {
        flex-direction: row;
        align-items: center;
        gap: var(--spacing-4);
    }
    
    .stat-card-icon {
        margin-bottom: 0;
    }
    
    .stat-card-content {
        flex: 1;
    }
    
    h1 {
        font-size: var(--font-size-xl);
    }
}

/* Large Desktop */
@media (min-width: 1600px) {
    .main-content {
        padding: var(--spacing-8) var(--spacing-10);
    }
    
    .stats-grid {
        gap: var(--spacing-6);
    }
}

/* ============================================
   EMPTY STATES - IMPROVED
============================================ */
.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: var(--spacing-10) var(--spacing-6);
    text-align: center;
    color: var(--gray-400);
}

.empty-state svg {
    width: 64px;
    height: 64px;
    margin-bottom: var(--spacing-4);
    opacity: 0.5;
}

.empty-state-title {
    font-size: var(--font-size-lg);
    font-weight: 600;
    color: var(--gray-300);
    margin-bottom: var(--spacing-2);
}

.empty-state-text {
    font-size: var(--font-size-sm);
    max-width: 300px;
    margin-bottom: var(--spacing-4);
}

/* ============================================
   FILTERS BAR - IMPROVED
============================================ */
.filters-bar {
    display: flex;
    flex-wrap: wrap;
    gap: var(--spacing-3);
    padding: var(--spacing-4);
    background: var(--bg-surface);
    border-radius: var(--radius-xl);
    border: 1px solid var(--gray-700);
    margin-bottom: var(--spacing-6);
}

.filter-group {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-2);
    min-width: 150px;
    flex: 1;
}

.filter-label {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--gray-400);
}

@media (max-width: 768px) {
    .filters-bar {
        padding: var(--spacing-3);
    }
    
    .filter-group {
        min-width: calc(50% - var(--spacing-2));
    }
}

@media (max-width: 480px) {
    .filter-group {
        min-width: 100%;
    }
}

/* ============================================
   ALERT IMPROVEMENTS
============================================ */
.alert {
    display: flex;
    gap: var(--spacing-3);
    padding: var(--spacing-4);
    border-radius: var(--radius-lg);
    font-size: var(--font-size-sm);
}

.alert-icon {
    flex-shrink: 0;
    width: 20px;
    height: 20px;
}

.alert-info {
    background: rgba(59, 130, 246, 0.1);
    border: 1px solid rgba(59, 130, 246, 0.3);
    color: var(--info);
}

.alert-warning {
    background: rgba(245, 158, 11, 0.1);
    border: 1px solid rgba(245, 158, 11, 0.3);
    color: var(--warning);
}

.alert-success {
    background: rgba(16, 185, 129, 0.1);
    border: 1px solid rgba(16, 185, 129, 0.3);
    color: var(--success);
}

.alert-error {
    background: rgba(239, 68, 68, 0.1);
    border: 1px solid rgba(239, 68, 68, 0.3);
    color: var(--error);
}

/* View-only notice */
#viewOnlyNotice {
    margin-bottom: var(--spacing-6);
}

/* ============================================
   QUICK ACTIONS CARD
============================================ */
.quick-actions {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-3);
}

.quick-actions .btn {
    justify-content: flex-start;
}

/* ============================================
   JURISDICTION BADGE
============================================ */
.jurisdiction-badge {
    display: inline-flex;
    align-items: center;
    gap: var(--spacing-2);
    padding: var(--spacing-1) var(--spacing-3);
    background: rgba(220, 38, 38, 0.1);
    color: var(--primary);
    border-radius: var(--radius-full);
    font-size: 11px;
    font-weight: 600;
}

.jurisdiction-badge svg {
    width: 12px;
    height: 12px;
}

/* ============================================
   PROFESSIONAL NOTIFICATION MODALS
============================================ */
.notification-modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.6);
    backdrop-filter: blur(4px);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 10000;
    opacity: 0;
    visibility: hidden;
    transition: all 0.2s ease;
}

.notification-modal-overlay.active {
    opacity: 1;
    visibility: visible;
}

.notification-modal {
    background: var(--bg-surface);
    border-radius: var(--radius-xl);
    padding: var(--spacing-8);
    width: 90%;
    max-width: 400px;
    text-align: center;
    transform: scale(0.9) translateY(20px);
    transition: transform 0.2s ease;
    box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}

.notification-modal-overlay.active .notification-modal {
    transform: scale(1) translateY(0);
}

.notification-icon {
    margin-bottom: var(--spacing-4);
    display: flex;
    justify-content: center;
}

.notification-icon svg {
    width: 64px;
    height: 64px;
}

.notification-title {
    font-size: var(--font-size-xl);
    font-weight: 700;
    margin: 0 0 var(--spacing-3) 0;
}

.notification-message {
    font-size: var(--font-size-base);
    color: var(--gray-300);
    margin: 0 0 var(--spacing-6) 0;
    line-height: 1.6;
}

.notification-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: var(--spacing-3) var(--spacing-8);
    font-size: var(--font-size-base);
    font-weight: 600;
    color: white;
    border: none;
    border-radius: var(--radius-lg);
    cursor: pointer;
    transition: all 0.2s ease;
    min-width: 120px;
}

.notification-btn:hover {
    opacity: 0.9;
    transform: translateY(-1px);
}

.notification-btn:active {
    transform: translateY(0);
}

.notification-btn-cancel {
    background: var(--gray-600) !important;
    color: var(--gray-200) !important;
}

.notification-btn-cancel:hover {
    background: var(--gray-500) !important;
}

/* EXEMPT from the FPFM-80 modal action standard, deliberately and by name.
   showConfirm() and showPrompt() are yes/no pop-ups, not form modals: two
   short labels, centred, no scrolling body to push them apart. Side by side is
   the readable shape at that size and stacking them would make a one-question
   dialog as tall as an editing form. They keep the row at every width, and
   their dismiss keeps `.notification-btn-cancel` - a FILLED grey pill with
   `border: none`, which is a different design system from `.btn` and is
   already visually weighted, so the "Cancel is bordered" half of the standard
   does not reach it either.

   This exemption is written down rather than left as drift. Anything that IS a
   form modal belongs in `.modal-footer` and inherits the standard there. */
.notification-actions {
    display: flex;
    gap: var(--spacing-3);
    justify-content: center;
}

/* The text field showPrompt() puts between the message and the buttons.
   Nothing else in the notification modal takes typing, so this is the one
   place the app's form styling has to reach into it. Touch target is 44px
   tall on purpose: this is used on a phone to type a block reason. */
.notification-input {
    display: block;
    width: 100%;
    box-sizing: border-box;
    min-height: 44px;
    padding: var(--spacing-3);
    margin: 0 0 var(--spacing-6) 0;
    font-family: inherit;
    font-size: var(--font-size-base);
    color: var(--secondary);
    background: var(--bg-dark);
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-lg);
}

.notification-input::placeholder {
    color: var(--gray-500);
}

.notification-input:focus {
    outline: 2px solid var(--primary);
    outline-offset: 1px;
    border-color: var(--primary);
}

/* Submitted empty when a value is required. The colour is the only thing that
   changes, so nothing moves and the modal does not resize under the cursor. */
.notification-input--invalid {
    border-color: #EF4444;
}

/* Animation for success checkmark */
@keyframes notif-success-check {
    0% {
        stroke-dashoffset: 100;
    }
    100% {
        stroke-dashoffset: 0;
    }
}

.notification-icon svg path[d*="M8 12L11 15L16 9"] {
    stroke-dasharray: 100;
    animation: notif-success-check 0.5s ease forwards;
}

/* ============================================
   SUBSCRIPTION BANNERS
============================================ */
.sub-banner {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 10px 20px;
    border-radius: var(--radius);
    margin-bottom: 16px;
    font-size: 14px;
    gap: 12px;
    flex-wrap: wrap;
}
.sub-banner.info {
    background: rgba(59,130,246,0.08);
    border: 1px solid rgba(59,130,246,0.3);
    color: #1d4ed8;
}
.sub-banner.warning {
    background: rgba(245,158,11,0.08);
    border: 1px solid rgba(245,158,11,0.3);
    color: #92400e;
}
.sub-banner.expired {
    background: rgba(220,38,38,0.08);
    border: 1px solid rgba(220,38,38,0.3);
    color: #b91c1c;
}
.sub-banner-btn {
    background: var(--primary);
    color: white;
    padding: 6px 16px;
    border-radius: var(--radius);
    font-size: 13px;
    font-weight: 600;
    text-decoration: none;
    white-space: nowrap;
    transition: background 0.2s;
}
.sub-banner-btn:hover {
    background: var(--primary-hover);
    text-decoration: none;
}

/* ============================================
   INVITE SECTION IN SIDEBAR
============================================ */
/* No nested box — the button IS the element, so the label fits one line */
.sidebar-invite-section {
    margin: 8px 10px 0;
}
.sidebar-invite-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    width: 100%;
    padding: 9px 8px;
    background: var(--primary);
    color: white;
    border: none;
    border-radius: 8px;
    font-size: 12px;
    font-weight: 600;
    white-space: nowrap;
    cursor: pointer;
    transition: background 0.2s;
}
.sidebar-invite-btn span {
    overflow: hidden;
    text-overflow: ellipsis;
}
.sidebar-invite-btn:hover { background: var(--primary-hover); }
.sidebar-invite-btn svg { flex-shrink: 0; width: 15px; height: 15px; }

/* ============================================
   SIDEBAR DESKTOP COLLAPSE
============================================ */

/* Show toggle button on desktop */
@media (min-width: 768px) {
    .sidebar-toggle {
        display: flex;
    }
}

/* Collapsed sidebar — icons only */
.sidebar.sidebar-collapsed {
    width: 64px;
}

.sidebar.sidebar-collapsed .sidebar-logo span,
.sidebar.sidebar-collapsed .sidebar-link span,
.sidebar.sidebar-collapsed .sidebar-section-title,
.sidebar.sidebar-collapsed .sidebar-invite-btn span,
.sidebar.sidebar-collapsed .user-info,
.sidebar.sidebar-collapsed .logout-btn span,
.sidebar.sidebar-collapsed .role-switcher-container,
.sidebar.sidebar-collapsed .account-menu-toggle,
.sidebar.sidebar-collapsed .account-menu,
.sidebar.sidebar-collapsed .sidebar-version-btn {
    display: none;
}

.sidebar.sidebar-collapsed .sidebar-header {
    justify-content: center;
    /* Three values, not two. The shorthand here was `var(--spacing-4)
       var(--spacing-2)`, and because this rule is top-level -- not inside the
       @media above -- it outranks .sidebar-header on both specificity and
       order, so its 16px bottom silently replaced the 8px set there. The
       collapsed sidebar kept the old spacing while the expanded one tightened.
       The third value restores the match; the 8px sides are unchanged.

       Measured on the harness at 1440, brand block bottom to the first nav
       item's box: expanded 17px, collapsed 25px before this line and 17px
       after it. */
    padding: var(--spacing-4) var(--spacing-2) var(--spacing-2);
}

.sidebar.sidebar-collapsed .sidebar-logo {
    justify-content: center;
}

.sidebar.sidebar-collapsed .sidebar-logo svg {
    width: 28px;
    height: 28px;
}

.sidebar.sidebar-collapsed .sidebar-toggle {
    display: none;
}

.sidebar.sidebar-collapsed .sidebar-nav-container {
    padding: var(--spacing-2);
}

.sidebar.sidebar-collapsed .sidebar-link {
    justify-content: center;
    padding: var(--spacing-2);
    position: relative;
}

.sidebar.sidebar-collapsed .sidebar-link svg {
    width: 20px;
    height: 20px;
}

.sidebar.sidebar-collapsed .sidebar-section {
    margin-top: var(--spacing-3);
    padding-top: var(--spacing-3);
}

.sidebar.sidebar-collapsed .sidebar-user {
    padding: var(--spacing-3) var(--spacing-2);
}

.sidebar.sidebar-collapsed .sidebar-user .user-avatar {
    width: 36px;
    height: 36px;
    min-width: 36px;
    font-size: 13px;
    margin-bottom: var(--spacing-2);
}

.sidebar.sidebar-collapsed .sidebar-user .logout-btn {
    width: 36px;
    height: 36px;
    padding: 0;
    justify-content: center;
    border-radius: 50%;
}

.sidebar.sidebar-collapsed .sidebar-invite-section {
    margin: 4px;
    padding: 8px;
}

.sidebar.sidebar-collapsed .sidebar-invite-btn {
    justify-content: center;
    padding: 8px;
}

.sidebar.sidebar-collapsed .sidebar-version {
    display: none;
}

/* Tooltip on hover in collapsed state */
.sidebar.sidebar-collapsed .sidebar-link:hover::after {
    content: attr(data-tooltip);
    position: absolute;
    left: calc(100% + 8px);
    top: 50%;
    transform: translateY(-50%);
    background: var(--bg-surface);
    color: var(--secondary);
    padding: 6px 12px;
    border-radius: var(--radius-md);
    font-size: var(--font-size-xs);
    font-weight: 500;
    white-space: nowrap;
    z-index: 1100;
    box-shadow: var(--shadow-lg);
    border: 1px solid var(--gray-700);
    pointer-events: none;
}

/* Edge collapse handle — the tab that straddles the sidebar's right border.
   Fixed to the viewport so the sidebar's own scrolling cannot clip it, and
   moved by the same 0.3s the sidebar width uses so the two travel together. */
.sidebar-edge-toggle {
    display: none;
    position: fixed;
    top: 50%;
    left: 260px;
    transform: translate(-50%, -50%);
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    padding: 0;
    background: var(--bg-card);
    /* --gray-700 is #e5e7eb in the light theme, which against the white page on
       one side is an edge you cannot see: the handle read as half a circle
       hanging off the menu. --gray-600 separates it from both surfaces. */
    border: 1px solid var(--gray-600);
    border-radius: 50%;
    color: var(--secondary);
    cursor: pointer;
    z-index: 1001;
    box-shadow: var(--shadow-md);
    transition: left 0.3s ease, color var(--transition-fast), border-color var(--transition-fast);
}

.sidebar-edge-toggle:hover {
    color: var(--primary);
    border-color: var(--primary);
}

.sidebar-edge-toggle svg {
    transition: transform 0.3s ease;
}

/* Folded: the handle follows the narrower sidebar and the arrow turns round */
.sidebar-is-collapsed .sidebar-edge-toggle {
    left: 64px;
}

.sidebar-is-collapsed .sidebar-edge-toggle svg {
    transform: rotate(180deg);
}

/* Desktop only — on a phone the menu slides over the page instead of folding */
@media (min-width: 768px) {
    .sidebar-edge-toggle {
        display: flex;
    }
}

/* Main content adjusts when sidebar is collapsed */
.sidebar-is-collapsed .main-content {
    margin-left: 64px;
    width: calc(100% - 64px);
    transition: margin-left 0.3s ease, width 0.3s ease;
}

/* Smooth transitions for sidebar width and main-content margin */
.sidebar {
    transition: transform var(--transition-slow), width 0.3s ease;
}

.main-content {
    transition: margin-left 0.3s ease, width 0.3s ease, opacity 0.25s ease;
}

/* ============================================
   SIDEBAR VERSION FOOTER
============================================ */
.sidebar-version {
    padding: 8px 16px 12px;
    text-align: center;
    flex-shrink: 0;
}

.sidebar-version-btn {
    background: none;
    border: 1px solid var(--gray-700);
    color: var(--gray-500);
    font-size: 11px;
    font-weight: 500;
    padding: 4px 12px;
    border-radius: var(--radius-full);
    cursor: pointer;
    transition: all 0.2s ease;
    letter-spacing: 0.02em;
}

.sidebar-version-btn:hover {
    color: var(--gray-300);
    border-color: var(--gray-500);
    background: rgba(0, 0, 0, 0.03);
}

/* ============================================
   TRANSITION PROGRESS BAR
============================================ */
.transition-progress-bar {
    position: fixed;
    top: 0;
    left: 0;
    height: 2px;
    width: 0%;
    background: linear-gradient(90deg, var(--primary), #EF4444, var(--primary));
    z-index: 10001;
    transition: width 0.3s ease;
    opacity: 0;
    pointer-events: none;
}

.transition-progress-bar.active {
    opacity: 1;
}

/* ============================================
   CHANGELOG MODAL
============================================ */
.changelog-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.6);
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    z-index: 10002;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 20px;
    opacity: 0;
    transition: opacity 0.3s ease;
}

.changelog-overlay.active {
    opacity: 1;
}

.changelog-modal {
    background: var(--bg-surface);
    border: 1px solid var(--gray-700);
    border-radius: 16px;
    max-width: 520px;
    width: 100%;
    max-height: 70vh;
    display: flex;
    flex-direction: column;
    box-shadow: var(--shadow-xl);
    transform: translateY(20px);
    transition: transform 0.3s ease;
}

.changelog-overlay.active .changelog-modal {
    transform: translateY(0);
}

.changelog-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    padding: 24px 24px 16px;
    border-bottom: 1px solid var(--gray-700);
    flex-shrink: 0;
}

.changelog-title {
    font-size: var(--font-size-xl);
    font-weight: 700;
    color: var(--secondary);
    margin: 0;
}

.changelog-subtitle {
    font-size: var(--font-size-xs);
    color: var(--gray-500);
    margin: 4px 0 0;
}

.changelog-close {
    background: none;
    border: none;
    color: var(--gray-400);
    cursor: pointer;
    padding: 4px;
    border-radius: var(--radius-md);
    transition: all 0.15s ease;
}

.changelog-close:hover {
    color: var(--secondary);
    background: var(--gray-700);
}

.changelog-body {
    flex: 1;
    overflow-y: auto;
    padding: 20px 24px;
}

.changelog-entry {
    margin-bottom: 24px;
}

.changelog-entry:last-child {
    margin-bottom: 0;
}

.changelog-entry-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 8px;
}

.changelog-version-tag {
    font-size: 11px;
    font-weight: 600;
    background: var(--primary);
    color: white;
    padding: 2px 8px;
    border-radius: var(--radius-full);
}

.changelog-date {
    font-size: 12px;
    color: var(--gray-500);
}

.changelog-entry-title {
    font-size: var(--font-size-base);
    font-weight: 600;
    color: var(--secondary);
    margin: 0 0 12px;
}

.changelog-items {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.changelog-item {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    font-size: var(--font-size-sm);
    color: var(--gray-300);
    line-height: 1.5;
}

.changelog-badge {
    display: inline-block;
    font-size: 10px;
    font-weight: 700;
    color: white;
    padding: 1px 6px;
    border-radius: var(--radius-sm);
    text-transform: uppercase;
    letter-spacing: 0.03em;
    flex-shrink: 0;
    margin-top: 2px;
}

.changelog-footer {
    padding: 16px 24px;
    border-top: 1px solid var(--gray-700);
    flex-shrink: 0;
}

.changelog-got-it {
    width: 100%;
    padding: 10px;
    background: var(--primary);
    color: white;
    border: none;
    border-radius: var(--radius-lg);
    font-size: var(--font-size-sm);
    font-weight: 600;
    cursor: pointer;
    transition: background 0.15s ease;
}

.changelog-got-it:hover {
    background: var(--primary-hover);
}

@media (max-width: 480px) {
    .changelog-modal {
        max-height: 85vh;
        border-radius: 12px;
    }

    .changelog-header,
    .changelog-body,
    .changelog-footer {
        padding-left: 16px;
        padding-right: 16px;
    }
}

/* ============================================
   TITHES OF TITHES — Panel & Notification Styles
============================================ */

/* One row of facts, so the card is the same height whatever it has to say.

   This panel sits above Recent Transactions and My Feasts, and it used to be
   two columns with Remaining and Amount Paid spanning the full width on rows
   of their own. Measured: 62px for a month with no collections, 207px for an
   unpaid one, 282px when part of it had been paid. The card is filled from a
   fetch, so whichever of those it turned out to be arrived a third of a second
   after the page did — and pushed everything under it down by up to 220px.

   Reserving the tallest was not an answer: 282px of mostly empty card on every
   dashboard, to cover a case most feasts are not in. Laying the facts out in
   ONE auto-fitting row instead makes them all the same height — a cell more or
   a cell fewer changes what is in the row, not how tall it is — and the body's
   min-height covers the gap down to the one-line "no collections" case.
   Reserved on the BODY, not the card, so the header is unaffected. */
#tithesContent {
    min-height: 142px;
    /* Centred, so the slack a quiet month leaves in the reserved space reads
       as a card with one thing to say rather than as a card with a gap. */
    display: flex;
    flex-direction: column;
    justify-content: center;
}

/* A FIXED column count, not auto-fit: with auto-fit the number of rows still
   depends on how many cells there are, and renderPanel always emits four. */
.tithes-summary {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 16px;
    align-items: start;
}

/* The one-line states — "No collections yet this month." and the two failure
   messages settle() writes — are a lone <p> dropped into a grid built for four
   figures, so they landed in the FIRST COLUMN: a quarter of the card on a
   desktop, half of it on a phone, wrapping over three lines with the rest of a
   202px-tall card empty beside them. It read as a broken cell rather than as a
   card with one thing to say.

   Spanning every column and centring gives the sentence the whole width, which
   is what #tithesContent above is already doing vertically (FPFM-67).

   `> p` reaches only these states. The populated panel puts its text in
   .tithes-obligation / -status / -remaining / -paid, so its <p>s are
   grandchildren and are untouched. */
.tithes-summary > p {
    grid-column: 1 / -1;
    text-align: center;
}

/* The quiet-month state, wearing the app's standard .empty-state shape so it
   matches Recent Transactions beside it. Sized down, because the full-size one
   does not fit: .empty-state ships a 64px icon and spacing-10 padding, which
   comes to roughly 190px, and #tithesContent reserves 142px on a desktop. A
   min-height is a floor, not a cap, so the card would simply have grown on
   load -- the resize that reservation exists to prevent (FPFM-46).

   Icon + title at these sizes is about 109px, which clears 142px with room for
   the title to take a second line on a narrow card.

   Both selectors are doubled up (.empty-state.tithes-empty) so they outrank
   the base .empty-state rules regardless of where this block ends up in the
   file. */
.empty-state.tithes-empty {
    padding: var(--spacing-4) var(--spacing-4);
}

/* fill and opacity are BOTH corrections, not preferences.

   `.empty-state svg` forces `fill: var(--gray-600)` and `opacity: 0.5`. Those
   suit the filled icons the rest of the app uses -- Recent Transactions draws
   a <rect>, so a grey fill IS the icon. This one is a peso glyph drawn in
   strokes with `fill="none"` on the element, and a CSS fill beats a
   presentation attribute: the forced fill flooded the open path into a blob
   and the half opacity faded what was left of the strokes, so the icon read
   as a smudge or as nothing at all.

   Worst in light theme, where --gray-600 is #d1d5db against a #f7f7f8
   surface -- a pale fill on a near-white card.

   Restoring fill:none and full opacity gives the strokes back at
   currentColor, which .empty-state already sets to --gray-400. */
.empty-state.tithes-empty svg {
    width: 40px;
    height: 40px;
    margin-bottom: var(--spacing-2);
    fill: none;
    stroke: currentColor;
    opacity: 1;
}

.empty-state.tithes-empty .empty-state-title {
    margin-bottom: 0;
}

.tithes-obligation .text-2xl {
    font-size: 1.5rem;
    font-weight: 700;
    margin: 4px 0;
}

/* The basis line is the only prose in the card, and so the only thing left
   that can wrap: "10% of ₱250,000.00" is one line where "Minimum floor
   (10% = ₱500.00)" is two. Pinned at two lines, so which of them a feast is
   in changes the words and not the height. */
.tithes-obligation .text-xs {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow: hidden;
    /* line-height and height set together, so two lines is exactly the box and
       one line is exactly the box: a min-height would still let the taller
       variant win by the difference between the two. */
    line-height: 1.4;
    height: 2.8em;
}

/* Two columns on a phone rather than four across or one down: four facts in a
   single column is 200px of card, and four across is unreadable at 360px. */
@media (max-width: 700px) {
    #tithesContent {
        min-height: 202px;
    }
    .tithes-summary {
        grid-template-columns: repeat(2, 1fr);
    }
}

/* End-of-Month Reminder Modal Overlay */
.tithes-reminder-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.6);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 10000;
    padding: 20px;
    opacity: 0;
    transition: opacity 0.3s ease;
    pointer-events: none;
}

.tithes-reminder-overlay.active {
    opacity: 1;
    pointer-events: auto;
}

.tithes-reminder-modal {
    background: var(--bg-surface, #1e293b);
    border: 1px solid var(--gray-700, #374151);
    border-radius: 16px;
    padding: 0;
    max-width: 420px;
    width: 100%;
    transform: scale(0.95) translateY(10px);
    transition: transform 0.3s ease;
    overflow: hidden;
}

.tithes-reminder-overlay.active .tithes-reminder-modal {
    transform: scale(1) translateY(0);
}

.tithes-reminder-header {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 20px 24px 12px;
}

.tithes-reminder-header h3 {
    margin: 0;
    font-size: 18px;
    font-weight: 600;
    color: var(--secondary, #fff);
}

.tithes-reminder-body {
    padding: 8px 24px 20px;
}

.tithes-reminder-message {
    font-size: 14px;
    color: var(--gray-300, #d1d5db);
    line-height: 1.6;
    margin: 0 0 16px;
}

.tithes-reminder-days {
    display: flex;
    align-items: center;
    gap: 8px;
}

.tithes-days-badge {
    background: rgba(245, 158, 11, 0.15);
    color: #F59E0B;
    padding: 6px 14px;
    border-radius: 20px;
    font-size: 14px;
    font-weight: 600;
}

/* EXEMPT from the FPFM-80 stacking half of the standard, deliberately.
   This is the end-of-month reminder card, not a modal: two equal `flex: 1`
   buttons on one line inside a small card that is already competing with the
   page behind it. It keeps the row at every width. It is NOT exempt from the
   border half - its "Got It" is a real `.btn` and was switched btn-ghost ->
   btn-outline with the rest (js/tithes-calculator.js). Source order here is
   already confirm-then-dismiss, which is the standard's phone order, so
   nothing had to move. */
.tithes-reminder-actions {
    display: flex;
    gap: 8px;
    padding: 16px 24px;
    background: rgba(0, 0, 0, 0.15);
    border-top: 1px solid var(--gray-700, #374151);
}

.tithes-reminder-actions .btn {
    flex: 1;
}

/* Jurisdiction Badge */
.jurisdiction-badge {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 12px;
    background: rgba(220, 38, 38, 0.1);
    border: 1px solid rgba(220, 38, 38, 0.3);
    border-radius: 20px;
    font-size: 13px;
    font-weight: 500;
    color: var(--gray-300, #d1d5db);
}

.jurisdiction-badge svg {
    width: 14px;
    height: 14px;
}

/* Custom Category Form */
.custom-category-form {
    margin-bottom: var(--spacing-4, 16px);
}

/* ===== Builder Claim List (migrated-name onboarding) ===== */
.claim-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    max-height: 280px;
    overflow-y: auto;
    margin-top: 4px;
}
.claim-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 12px 14px;
    border: 1px solid var(--border, #E5E7EB);
    border-radius: 10px;
    background: var(--surface, #fff);
    cursor: pointer;
    transition: border-color .15s, background .15s, box-shadow .15s;
    text-align: left;
}
.claim-item:hover { border-color: var(--primary, #DC2626); }
.claim-item.selected {
    border-color: var(--primary, #DC2626);
    background: var(--primary-light, #FEE2E2);
    box-shadow: 0 0 0 1px var(--primary, #DC2626);
}
.claim-item__name { font-weight: 600; font-size: 14px; }
.claim-item__meta { font-size: 12px; color: var(--text-gray, #6B7280); margin-top: 2px; }
.claim-item__role {
    flex-shrink: 0;
    font-size: 11px;
    font-weight: 600;
    padding: 3px 8px;
    border-radius: 999px;
    background: var(--primary-light, #FEE2E2);
    color: var(--primary-dark, #991B1B);
    white-space: nowrap;
}
.claim-empty { font-size: 13px; color: var(--text-gray, #6B7280); padding: 12px 4px; text-align: center; }

/* ===== Mobile-first fixes (v1.000.008) — never hide buttons on phones ===== */
@media (max-width: 639px) {
  /* Card headers (e.g. transactions: tabs + Export) wrap instead of clipping */
  .card-header { flex-wrap: wrap; gap: var(--spacing-2, 8px); }
  .card-header > .flex { flex-wrap: wrap; }
}
@media (pointer: coarse) {
  /* Minimum 44x44 touch targets for icon + menu buttons */
  .btn-icon, .mobile-menu-toggle { min-width: 44px; min-height: 44px; }
}

/* Safety: the old Help&Support slide-in overlay must never block clicks */
#feastSupportPanel, .fs-overlay { display: none !important; }

/* Budget + Event modals now use the standard .modal-overlay > .modal pattern (no custom CSS needed). */

/* Safety: backdrops default hidden so a lingering instance can never swallow clicks (inline display:flex still opens them) */
.modal-backdrop { display: none; }
.modal-backdrop.active { display: flex; }

/* ============================================
   WALLET (US-003..006)
   Home = Money on Hand; green = Money In; red = Money Out;
   blue = Move / Send; amber = waiting to confirm.
============================================ */
.wallet-hero {
    text-align: center;
    padding: var(--spacing-8) var(--spacing-6);
}
.wallet-eyebrow {
    font-size: var(--font-size-sm);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--gray-400);
    margin-bottom: var(--spacing-2);
}
.wallet-hero-label {
    font-size: var(--font-size-base);
    color: var(--gray-400);
}
.wallet-hero-value {
    font-size: clamp(2.25rem, 9vw, 3.25rem);
    font-weight: 700;
    color: var(--secondary);
    line-height: 1.1;
    margin: var(--spacing-2) 0;
    word-break: break-word;
}
.wallet-hero-help {
    font-size: var(--font-size-sm);
    color: var(--gray-400);
}
.wallet-actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--spacing-3);
    margin-top: var(--spacing-6);
}
.wallet-actions .btn { flex: 0 1 auto; }
/* .wallet-btn deliberately carries NO size overrides — wallet buttons follow
   the system-standard .btn sizing like every other page. */
.wallet-btn {}
.wallet-blue { background: var(--info); color: #fff; }
.wallet-blue:hover:not(:disabled) { filter: brightness(0.94); }
.wallet-out { color: var(--error); border-color: var(--error); }
.wallet-out:hover:not(:disabled) { background: rgba(239, 68, 68, 0.08); color: var(--error); }
/* Tier-2 tiles carry BOTH labels in the markup and the stylesheet picks one, so
   the shortening is a phone-width concern only and desktop wording never moves.
   The short one is hidden everywhere by default; the phone block below swaps
   them. display:none contributes no layout, so desktop measures identically. */
.wallet-tile-label-short { display: none; }
/* CLS placeholder for the tier-1 pair (wallet.html). These two values are the
   inline ones it used to carry, so desktop reserves exactly what it always did. */
.wallet-skel-action { width: 140px; height: 42px; }
.wallet-pocket-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-3);
    padding: var(--spacing-4) 0;
    border-bottom: 1px solid var(--gray-700);
}
.wallet-pocket-row:last-child { border-bottom: none; }
.wallet-pocket-name {
    display: flex;
    align-items: center;
    gap: var(--spacing-2);
    font-weight: 500;
    color: var(--secondary);
}
.wallet-pocket-name svg { width: 18px; height: 18px; flex-shrink: 0; }
.wallet-pocket-amount {
    font-weight: 700;
    font-size: var(--font-size-lg);
    color: var(--secondary);
    white-space: nowrap;
}
.wallet-sent-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.wallet-sent-top { display: flex; align-items: center; gap: var(--spacing-2); font-weight: 600; color: var(--secondary); }
.wallet-sent-sub { font-size: var(--font-size-sm); color: var(--gray-400); }
.wallet-pending-banner {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    width: 100%;
    text-align: left;
    font: inherit;
    font-weight: 500;
    padding: var(--spacing-4);
    margin-bottom: var(--spacing-4);
    border-radius: var(--radius-lg);
    background: rgba(245, 158, 11, 0.12);
    border: 1px solid var(--warning);
    color: var(--warning);
    cursor: pointer;
}
.wallet-pending-banner svg { width: 22px; height: 22px; flex-shrink: 0; }
.wallet-preview {
    font-size: var(--font-size-base);
    color: var(--secondary);
    background: var(--bg-surface);
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-lg);
    padding: var(--spacing-3) var(--spacing-4);
    margin-top: var(--spacing-4);
}
.wallet-preview strong { color: var(--secondary); }
.wallet-going-to {
    font-size: var(--font-size-base);
    color: var(--gray-300);
    margin-top: var(--spacing-3);
}
.wallet-amount-input {
    font-size: var(--font-size-2xl);
    font-weight: 700;
    text-align: center;
}
.wallet-detail-card {
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-lg);
    padding: var(--spacing-4);
    margin-bottom: var(--spacing-4);
}
.wallet-detail-row { display: flex; justify-content: space-between; gap: var(--spacing-3); padding: var(--spacing-2) 0; }
.wallet-detail-row .label { color: var(--gray-400); }
.wallet-detail-row .value { font-weight: 600; color: var(--secondary); text-align: right; }
.wallet-detail-actions { display: flex; flex-wrap: wrap; gap: var(--spacing-3); margin-top: var(--spacing-3); }
.wallet-detail-actions .btn { flex: 0 1 auto; }
@media (max-width: 640px) {
    /* Stacked full-width on small phones so nothing clips (mobile-first rule),
       but at the standard .btn height — no oversizing. */
    .wallet-actions .btn { flex: 1 1 100%; }
    .wallet-detail-actions .btn { flex: 1 1 100%; }

    /* ---- Manage Pockets: per-pocket actions as ONE outlined row (FPFM-78) --
       Rename / Starting Money / Remove used to inherit the stacking rule
       directly above, so each pocket spent three full-width rows on bare
       .btn-ghost text -- no surface, no border, nothing reading as tappable,
       and two pockets alone filled the screen. They are now one row of equal
       outlined buttons per pocket.

       SCOPED TO .wallet-pocket-actions, NOT to .wallet-detail-actions. That
       container class has a second live call site -- the "A Share Has Arrived"
       modal, which carries a FILLED green "Yes, We Received It" confirm. A
       rule on .wallet-detail-actions .btn would strip that button's fill too,
       which is a different screen and not this ticket. The Manage Pockets
       builder in js/wallet.js now adds .wallet-pocket-actions beside the
       existing class; nothing else in the app uses it.

       (That sentence deliberately does not name the JS function. The gear
       guard in tests/wallet-button-tiers.test.js greps the RAW stylesheet for
       that identifier without stripping comments first, so naming it here
       fails a test about SELECTORS from inside a COMMENT. Logged, not fixed
       here -- it is FPFM-77's test and out of this ticket's scope.)

       Specificity: .wallet-pocket-actions .btn is (0,2,0), equal to the
       stacking rule above, and wins on source order. Two classes on purpose --
       the "Button Responsive" block re-declares .btn with min-height: 40px and
       white-space: nowrap at (0,1,0) but LATER in the file, so a single-class
       selector would lose both silently. Same trap noted at
       .wallet-actions-tiles.

       The border is var(--info) / var(--error), matching the outlined
       treatment shipped in FPFM-77, and deliberately not a grey: the dark
       remap redeclares --gray-600 and --gray-700 to values a hair off
       --bg-card (#1f2937), so a grey hairline on a card all but disappears
       there, while --info (#3B82F6) and --error (#EF4444) are NOT redeclared
       in the dark block and render identically in both themes. Photographed
       dark before shipping, not assumed. */
    .wallet-pocket-actions { gap: var(--spacing-2); flex-wrap: nowrap; }
    .wallet-pocket-actions .btn {
        flex: 1 1 0;
        min-width: 0;
        /* 46px explicitly, NOT via .btn-touch -- the duplicate .btn at equal
           specificity later in the file re-declares min-height: 40px and beats
           .btn-touch's 44px, so .btn-touch cannot be relied on for the floor.
           That duplicate is logged separately and is not touched here.

           A FIXED height, not a minimum, is what makes the rows line up:
           "Starting Money" wraps to two lines and its neighbours do not, so
           left to content the three-button pocket sat taller than the
           two-button one. 46px clears two wrapped lines
           (2 x 12px x 1.15 = 27.6px, plus 8px of padding) with room over, so
           every pocket card is the same height regardless of wrap or button
           count -- without touching the wording, which Renzo did not ask for. */
        height: 46px;
        padding: var(--spacing-1) var(--spacing-2);
        font-size: var(--font-size-xs);
        line-height: 1.15;
        /* Wrap INSIDE the button rather than forcing the row wider than the
           card. Needed at 360px, where three buttons leave ~100px each. */
        white-space: normal;
        text-align: center;
        background-color: transparent;
        color: var(--info);
        border: 1px solid var(--info);
        border-radius: var(--radius-md);
    }
    /* Remove is destructive and must never read like its two neighbours. */
    .wallet-pocket-actions .btn.wallet-act-remove {
        color: var(--error);
        border-color: var(--error);
    }
    .wallet-pocket-actions .btn:hover:not(:disabled) {
        background-color: var(--info-light);
        filter: none;
    }
    .wallet-pocket-actions .btn.wallet-act-remove:hover:not(:disabled) {
        background-color: var(--error-light);
    }

    /* ---- Wallet action hierarchy on phones (tiers 1 and 2) ----------------
       The rule directly above stacks every wallet button full-width, which
       gives a five-button wallet five identical bars and no hierarchy at all.
       The two rules below re-group them and are scoped to the two classes
       _render() now puts on its own containers, so nothing else that uses
       .wallet-actions -- and no other page -- is touched. They sit AFTER the
       stacking rule at equal specificity (0,2,0), so source order decides.

       The button set is not fixed: Move needs 2+ pockets, Our Share is hidden
       at provincial scope, Support is district/area/provincial only. Both
       tiers therefore size by `flex: 1 1 0` rather than by a column count, so
       the row is full and balanced at one, two or three children and cannot
       leave a half-empty grid track behind. Tier 3 (the gear) is in a
       card-header and is deliberately not addressed here. */

    /* Tier 1 -- Money In / Money Out, the daily pair, side by side. */
    .wallet-actions-primary .btn {
        flex: 1 1 0;
        min-width: 0;
        min-height: 48px;
        padding-left: var(--spacing-3);
        padding-right: var(--spacing-3);
    }

    /* Tier 2 -- the transfer actions, as SHORT OUTLINED rows (FPFM-77).
       They were icon-ABOVE-label tiles at 64px and filled blue, which gave the
       transfers more visual weight than the balance they sit under. They now
       keep their exact position in the flow and change only three things:
       icon BESIDE the label, roughly half the height, and an outline instead
       of a fill.

       46px, not 32px. "Roughly half of 64" is 32 and CLAUDE.md principle 4
       puts a hard floor at 44px, so the shrink stops at the floor plus a 2px
       margin rather than at the arithmetic. The floor wins; it is not a
       target to be optimised against.

       Every rule here is inside this phone media query and every selector is
       wallet-prefixed, so DESKTOP IS UNTOUCHED BY CONSTRUCTION -- this option
       was chosen precisely because it needs no markup change, and _render()
       still emits exactly what it emitted before. */
    /* Match the skeleton to the pair it stands in for: one row, 48px tall.
       Before this it reserved a single 42px row while the real buttons landed
       as two stacked rows (~93px), so the hero jumped on every load. */
    .wallet-skel-action { width: auto; height: 48px; flex: 1 1 0; }

    .wallet-actions-tiles {
        gap: var(--spacing-2);
        padding-top: var(--spacing-2);
    }
    .wallet-actions-tiles .btn {
        flex: 1 1 0;
        min-width: 0;
        flex-direction: row;
        gap: var(--spacing-2);
        min-height: 46px;
        padding: var(--spacing-2) var(--spacing-3);
        font-size: var(--font-size-sm);
        line-height: 1.2;
        text-align: center;
    }
    .wallet-actions-tiles .btn svg { flex-shrink: 0; width: 16px; height: 16px; }
    .wallet-actions-tiles .wallet-tile-label { display: none; }
    .wallet-actions-tiles .wallet-tile-label-short { display: inline; }

    /* Outlined blue instead of filled (FPFM-77). Scoped to the TILES, so the
       same .wallet-blue on the modal submit buttons keeps its fill -- those are
       the confirming action of a dialog and should still read as solid.

       The border is var(--info) and deliberately NOT a grey. --info is one of
       the few tokens the dark remap leaves alone (#3B82F6 in both themes),
       whereas --gray-800 is byte-identical to --bg-card (#1f2937) in dark, so
       a grey hairline on a card would be invisible there. Checked in dark
       theme before shipping, not assumed. */
    .wallet-actions-tiles .wallet-blue {
        background: transparent;
        color: var(--info);
        border: 1px solid var(--info);
    }
    .wallet-actions-tiles .wallet-blue:hover:not(:disabled) {
        background: var(--info-light);
        filter: none;
    }

    /* EXACTLY THREE tiles -- keep the SHIPPED icon-above-label layout (FPFM-77).
       Not a preference: icon-beside-label does not fit at three across.
       Measured at 360px by .evidence/f77-clip-probe.mjs, which compares the
       visible label's natural width against the button's content box:

         three across -> button 104px, content box 78px
           "Our Share"  needs 94.7px  -> headroom -16.7px   CLIPPED
           "Support"    needs 80.7px  -> headroom  -2.7px   CLIPPED
           "Move"       needs 63.8px  -> headroom +14.2px   fits

       CLAUDE.md principle 4 forbids clipping at 360px outright, so the row
       treatment stops where it stops fitting. One tile (328px) and two tiles
       (328px each, stacked) have 200px+ of headroom and keep the new look --
       and the two-tile case is the provincial view this ticket is about.

       NOTE the harness's own `clipped` flag (scrollWidth > clientWidth) reads
       FALSE here. The button does not scroll; the flex line compresses the
       label instead, so that signal cannot see it. Do not re-derive this from
       wallet-harness.mjs alone.

       Specificity, same construction as the two-tile rule below:
         .wallet-actions-tiles .btn                              (0,2,0)
         .wallet-actions-tiles .btn:first-child:nth-last-child(3)        (0,4,0)
         .wallet-actions-tiles .btn:first-child:nth-last-child(3) ~ .btn (0,5,0)
       so this wins on specificity before order, not because of it. The OUTLINE
       is deliberately NOT reverted -- it is a colour, it costs no width, and
       the three-tile row should not be the one filled-blue holdout. */
    .wallet-actions-tiles .btn:first-child:nth-last-child(3),
    .wallet-actions-tiles .btn:first-child:nth-last-child(3) ~ .btn {
        flex-direction: column;
        gap: var(--spacing-1);
        min-height: 64px;
        padding: var(--spacing-3) var(--spacing-1);
    }

    /* EXACTLY TWO tiles -- stack them, each full width.
       Reached on a provincial account, where Send Our Share is hidden and the
       set collapses to Move + Support. Under the `flex: 1 1 0` rule above that
       is two half-width tiles sharing a row; each one is now full width and
       they stack, so both read like the lone stretched tile the one-tile case
       already produces. One tile and three tiles are untouched -- the count is
       matched in the selector, not in JS, so _render() needs no new class.

       :first-child:nth-last-child(2) is "the first child that is also the
       second from the end", i.e. the first of exactly two; `~ .btn` takes its
       partner. Both win DETERMINISTICALLY, on specificity BEFORE order:
         .wallet-actions .btn                                    (0,2,0)
         .wallet-actions-tiles .btn                              (0,2,0)
         .wallet-actions-tiles .btn:first-child:nth-last-child(2)         (0,4,0)
         .wallet-actions-tiles .btn:first-child:nth-last-child(2) ~ .btn  (0,5,0)
       They also sit last in source order, so the two rules above lose twice
       over. The `.wallet-actions .btn { flex: 1 1 100% }` safety net is left
       exactly as it was and is deliberately NOT relied on here. */
    .wallet-actions-tiles .btn:first-child:nth-last-child(2),
    .wallet-actions-tiles .btn:first-child:nth-last-child(2) ~ .btn {
        flex: 1 1 100%;
    }

    /* The two `.wallet-modal .modal-footer` rules that stood here - a
       column-reverse and a width:100% - were CONVERGED into the single
       `.modal-footer` rule in the max-width:639px block by FPFM-80. Wallet
       modals were the only place in the app already putting the confirm on
       top; the standard is now that behaviour applied to every modal, so a
       wallet-only override would be a second rule answering a question the
       first one already answers. Rendering here is unchanged except at a
       viewport of exactly 640px, which this block used and the shared one does
       not - see the note at that rule. */
}

/* ============================================
   CASH FLOW (leader views + dashboard deltas)
   The SVG chart is styled inline in js/cashflow-chart.js (geometry must be);
   these rules back the class hooks so theme overrides have a target.
============================================ */
.cashflow-chart { width: 100%; }
.cashflow-empty { color: var(--gray-400); }
.cashflow-tt { pointer-events: none; }
.cashflow-cat-section { margin-top: var(--spacing-3); }
.cashflow-delta { font-variant-numeric: tabular-nums; }

/* ============================================
   LEADER HQ (command panels — js/leader-hq.js)
   Panels are plain .card blocks so they stack at 360px; tables scroll inside
   their own .table-responsive. Theme variables only (no hardcoded colors).
============================================ */
.leader-hq-card { margin-bottom: var(--spacing-4); }
.hq-approvals { display: inline-flex; align-items: baseline; gap: var(--spacing-2); text-decoration: none; color: inherit; }
.hq-approvals-count { font-size: var(--font-size-3xl); font-weight: 700; line-height: 1; }
.hq-activity { list-style: none; }
.hq-activity-item {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--spacing-2);
    padding: var(--spacing-3) var(--spacing-4);
    border-bottom: 1px solid var(--gray-700);
}
.hq-activity-item:last-child { border-bottom: none; }
.audit-feed-when { flex: 0 0 auto; }
.audit-feed-desc { flex: 1 1 100%; color: var(--gray-400); }

/* ============================================
   AREA COMMAND PORTAL — area-hq.html  (FFM-11)
   ============================================
   This page uses the app's OWN components: .page-header, .stats-grid /
   .stat-card, .card, .table and .badge. What follows is only the handful of
   things the app genuinely does not already have — the coverage bars, the
   team list and a few table alignment rules.

   Deliberately NOT here: a bespoke accent colour, a header band, custom stat
   tiles, custom chips, and coloured rails down the left edge of cards. Those
   were invented for this page, made it look unlike the rest of the product,
   and the left rail in particular is decoration masquerading as meaning —
   severity is carried by the value's colour instead.
   ============================================ */

/* Stat values on this page are set from JS and carry a severity colour via
   the app's text utilities; the note line sits under the label. */
.hq-tile-note { margin-top: var(--spacing-1); }
.hq-of { font-size: 0.6em; font-weight: 500; color: var(--gray-400); }
.hq-tile-wait { color: var(--gray-500); }

/* Four tiles two-up on a phone instead of four full-width bars, which cost
   most of the first screen before the roster came into view. The app's own
   .stats-grid drops to a single column below 480px, which is right for a card
   carrying a 40px icon and wrong for these: there is no icon, so half a phone
   is plenty. Still the app's .stat-card -- only the track count and the size
   of the figure change (FPFM-67).

   The clamp is doing real work. The Net tile has to hold a thirteen-character
   peso figure in half a 360px screen, and a currency amount must never be
   allowed to break across lines. Measured at 320, 360 and 390px. */
@media (max-width: 480px) {
    .hq-tiles.stats-grid { grid-template-columns: 1fr 1fr; gap: var(--spacing-3); }
    .hq-tiles .hq-tile-value { font-size: clamp(1rem, 4.6vw, var(--font-size-lg)); }
}

/* --- area chooser (no area resolved) --- */
.hq-chooser-hint {
    margin: 0 0 var(--spacing-5); color: var(--gray-300);
    line-height: 1.65; max-width: 60ch;
}
.hq-chooser-grid {
    display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
    gap: var(--spacing-4);
}
.hq-chooser-btn {
    display: flex; flex-direction: column; gap: var(--spacing-1);
    padding: var(--spacing-5) var(--spacing-6);
    background: var(--bg-surface); border: 1px solid var(--gray-700);
    border-radius: var(--radius-lg); text-decoration: none;
    transition: border-color .15s ease;
}
.hq-chooser-btn:hover, .hq-chooser-btn:focus-visible { border-color: var(--primary); }
.hq-chooser-code {
    font-size: var(--font-size-xs); font-weight: 700;
    letter-spacing: .12em; text-transform: uppercase; color: var(--primary);
}
.hq-chooser-name { font-size: var(--font-size-lg); font-weight: 600; color: var(--secondary); }

/* --- two-up layout; stacks on a phone ---
   align-items: start is load-bearing. Grid's default `stretch` made every card
   in a row as tall as the tallest one in it, so the short panels -- "Is my team
   ready" beside the long "What just happened" feed -- ended in a slab of empty
   surface below their last row. Each card now ends where its content ends. */
.hq-grid-2 {
    display: grid; grid-template-columns: 1fr 1fr;
    align-items: start;
    gap: var(--spacing-4); margin-bottom: var(--spacing-4);
}
@media (max-width: 900px) { .hq-grid-2 { grid-template-columns: 1fr; } }
.hq-grid-2 > .card { margin-bottom: 0; }

/* --- coverage: one block per feast, filled when it has reported --- */
.hq-cov { display: flex; flex-direction: column; gap: var(--spacing-4); }
.hq-cov-row {
    display: grid; grid-template-columns: 1fr auto;
    gap: var(--spacing-2) var(--spacing-4); align-items: baseline;
}
.hq-cov-name { font-size: var(--font-size-sm); }
.hq-cov-count {
    font-size: var(--font-size-sm); color: var(--gray-400);
    font-variant-numeric: tabular-nums; white-space: nowrap;
}
.hq-cov-bar { grid-column: 1 / -1; display: flex; gap: 4px; }
.hq-cov-bar span {
    flex: 1; height: 8px; border-radius: var(--radius-sm);
    background: var(--gray-700);
}
.hq-cov-bar span.on { background: var(--success); }
.hq-legend {
    display: flex; flex-wrap: wrap; gap: var(--spacing-2) var(--spacing-5);
    margin-top: var(--spacing-4); font-size: var(--font-size-sm); color: var(--gray-400);
}
.hq-sw {
    display: inline-block; width: 10px; height: 10px;
    border-radius: var(--radius-sm); margin-right: var(--spacing-2);
}
.hq-sw--on { background: var(--success); }
.hq-sw--off { background: var(--gray-700); }

/* --- tables ---
   Spacing and type size only; the component is still the app's .table. These
   two are read across a full row — feast, date, three figures and a state — and
   the shared default (11px uppercase headers, tighter cells) is tuned for
   denser listings elsewhere. Nudging padding and header size is a local
   refinement, NOT a second table component.

   This was set once in FFM-12, then lost when the portal stylesheet was
   rewritten in FFM-14, which put the cramped rows straight back. The test in
   tests/area-hq.test.js now pins it so that cannot happen a third time. */
.hq-table th,
.hq-table td {
    padding: var(--spacing-4) var(--spacing-5);
    vertical-align: middle;
}
.hq-table th { font-size: var(--font-size-sm); letter-spacing: .06em; }
.hq-table td { line-height: 1.55; }
.hq-table .hq-num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }

/* District separator highlight — its own tokens, defined for BOTH themes.
   The row previously drew on --bg-surface, which is #f7f7f8 on light but
   #1f2937 on dark: the same colour as --bg-card behind it. On dark the
   separator was therefore invisible, and the roster read as one long
   undifferentiated list. A shared token cannot fix that, because the two
   themes need the tint to move in opposite directions — lighter than the
   card on dark, darker than the card on light. Hence a dedicated pair.

   The two sides are NOT tuned to the same contrast ratio, deliberately. The
   first attempt gave light 1.16:1 against its white card and dark 1.22:1
   against its #1f2937 card — near-identical numbers, and dark read fine while
   light read as nothing at all. On a bright surface the eye is adapted to a
   high absolute luminance, so an equal ratio buys less separation than it does
   on a dark one. Light therefore takes the larger step (1.44:1) to land where
   dark sits at 1.33:1. Match the appearance, not the arithmetic. */
:root {
    --hq-group-bg: #d0d8e4;
    --hq-group-text: #2f3a4a;
    --hq-group-line: #b3bdcd;
}
:root[data-theme="dark"] {
    --hq-group-bg: #2b3d55;
    --hq-group-text: #dbe3ef;
    --hq-group-line: #44597a;
}

/* The district separator earns a little more room than a data row — it is the
   structure of the table, not another entry in it. */
.hq-table tr.hq-dgroup td {
    background: var(--hq-group-bg); color: var(--hq-group-text);
    font-size: var(--font-size-sm); font-weight: 700;
    letter-spacing: .08em; text-transform: uppercase;
    padding-top: var(--spacing-5); padding-bottom: var(--spacing-3);
    /* Tint and hairlines only — no coloured rail. This page had a bespoke
       accent stripped out of it deliberately, and tests/area-hq.test.js pins
       that decision. The band does the highlighting on its own. */
    border-top: 1px solid var(--hq-group-line);
    border-bottom: 1px solid var(--hq-group-line);
}
/* No line above the first group — there is nothing to separate it from. */
.hq-table tbody tr.hq-dgroup:first-child td { border-top: none; }

/* The band is a heading plus a count, emitted as two spans by js/area-hq.js.
   On a desktop they read as one line joined by a middot; on a phone the count
   drops to its own line (see the narrow-screen block below). The cell itself
   is deliberately NOT a flex container -- display:flex on a <td> takes it out
   of the table own layout. */
.hq-dgroup-count {
    text-transform: none; letter-spacing: normal; font-weight: 500;
    font-size: var(--font-size-xs); opacity: .8;
}
.hq-dgroup-name + .hq-dgroup-count::before {
    content: "\00b7"; margin: 0 var(--spacing-2); opacity: .65;
}
.hq-table tr.hq-dorm td:first-child { color: var(--gray-400); }
.hq-muted { color: var(--gray-500); }

/* --- narrow screens: the two tables stop being tables (FPFM-67) ---------
   The roster is six columns and compliance is five, and .table sets a global
   min-width of 600px under both. On a 360px phone that means the reader sees
   Feast and Last entry and has to drag sideways to reach In, Out, Net and the
   State badge -- the one column this screen exists to show. "No button, tab or
   action may be clipped, cut off or hidden at 360px" is a rule the page was
   breaking by construction, and no amount of tightening cell padding fixes a
   table that is wider than the screen. It has to stop being a table.

   Below 900px -- the width at which .hq-grid-2 already collapses, so the whole
   page goes single-column in one step -- every row becomes a record card laid
   out with grid. NOTHING is dropped: each cell that was a column is still on
   screen, carrying the column header it lost as its own label. Above 900px the
   desktop table is untouched.

   900px and not 640px because the sidebar takes 260px of the viewport from
   768px up: at 768-900px the content column is 444-576px, still narrower than
   the table 600px floor. A phone-only breakpoint would have left iPad portrait
   scrolling sideways.

   This REPLACES an @media (max-width: 600px) block that only nudged cell
   padding. tests/area-hq.test.js pinned that block as "phone breakpoint
   tightens the table deliberately"; the padding it protected is still here, on
   the card, and the assertion now pins the card layout instead. Tightening a
   table that does not fit was treating the symptom.

   Placement is BY CLASS -- js/area-hq.js emits hq-c-* on every cell it writes.
   Add a column to either table and place it here too, or it lands wherever the
   grid happens to have room. */
@media (max-width: 900px) {

    /* Undo the tabular box model. The .table min-width is what forces the
       sideways scroll, so it goes with it. */
    .hq-table { min-width: 0; }
    .hq-table thead { display: none; }
    .hq-table tbody,
    .hq-table tr { display: block; }
    .hq-table td { display: block; padding: 0; border-bottom: none; }

    /* Rows that are not records -- the empty state, "No feasts in this area
       yet", "Compliance unavailable" -- stay a plain padded block. */
    .hq-table tbody tr:not(.hq-frow):not(.hq-drow):not(.hq-dgroup) td {
        padding: var(--spacing-4);
    }

    /* District band: the structure of the list. The count drops under the name
       so neither is truncated, and the joining middot goes with it. */
    .hq-table tr.hq-dgroup td {
        padding: var(--spacing-4) var(--spacing-4) var(--spacing-3);
    }
    .hq-dgroup-name { display: block; }
    .hq-dgroup-count { display: block; margin-top: var(--spacing-1); }
    .hq-dgroup-name + .hq-dgroup-count::before { content: none; }

    /* ---- roster: one card per feast ----
       Name and state on the first line, last entry on the second, the three
       figures as a labelled three-up on the third. State rides up beside the
       name because a leader opens this screen to find out who has gone quiet. */
    .hq-table tr.hq-frow {
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: var(--spacing-3);
        padding: var(--spacing-4);
        border-bottom: 1px solid var(--gray-700);
    }
    .hq-table .hq-c-name {
        grid-column: 1 / 3; grid-row: 1;
        font-size: var(--font-size-base); font-weight: 600; line-height: 1.35;
    }
    .hq-table .hq-c-state { grid-column: 3; grid-row: 1; text-align: right; }
    .hq-table .hq-c-last {
        grid-column: 1 / -1; grid-row: 2;
        display: flex; flex-wrap: wrap; align-items: baseline;
        justify-content: space-between; gap: var(--spacing-2);
        font-size: var(--font-size-sm);
    }
    .hq-table .hq-c-fig { grid-row: 3; text-align: left; }

    /* ---- compliance: one card per district ----
       District, then what it owes -- the figure a leader came for -- then the
       three months as a labelled three-up. */
    .hq-table tr.hq-drow {
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: var(--spacing-3);
        padding: var(--spacing-4);
        border-bottom: 1px solid var(--gray-700);
    }
    .hq-table .hq-c-district {
        grid-column: 1 / -1; grid-row: 1;
        font-size: var(--font-size-base); font-weight: 600; line-height: 1.35;
    }
    .hq-table .hq-c-owed {
        grid-column: 1 / -1; grid-row: 2;
        display: flex; align-items: baseline; justify-content: space-between;
        gap: var(--spacing-2); text-align: left; font-weight: 600;
        padding: var(--spacing-2) var(--spacing-3);
        background: var(--bg-surface); border-radius: var(--radius-md);
    }
    .hq-table .hq-c-month {
        grid-row: 3; text-align: center;
        display: flex; flex-direction: column; gap: var(--spacing-1);
    }
    /* One line, always. A wrapped month label pushes its own badge a line
       lower than the two beside it and the three stop reading as a row. */
    .hq-table .hq-c-month::before { white-space: nowrap; }
    /* A third of a phone is narrower than "No collections", so the badge wraps
       inside its own pill rather than running under its neighbour. */
    .hq-table .hq-c-month .badge {
        display: flex; width: 100%; justify-content: center;
        padding-left: var(--spacing-2); padding-right: var(--spacing-2);
        white-space: normal; text-align: center; line-height: 1.3;
    }

    /* The column header each cell lost, restored onto the cell itself: above
       the figure where the cell is a three-up, beside it where it is a line. */
    .hq-table .hq-c-fig::before,
    .hq-table .hq-c-month::before,
    .hq-table .hq-c-last::before,
    .hq-table .hq-c-owed::before {
        content: attr(data-label);
        font-size: var(--font-size-xs); font-weight: 600;
        letter-spacing: .06em; text-transform: uppercase; color: var(--gray-400);
    }
    .hq-table .hq-c-fig::before { display: block; margin-bottom: var(--spacing-1); }
    .hq-table .hq-c-month::before { display: block; }

    /* A skeleton is a promise about how much room the answer needs, so the
       loading rows take the SHAPE of the cards that replace them -- three
       lines, not one -- instead of a single 45px strip that becomes a 140px
       card. It is a shape promise and not a height one: the row count is not
       known until the answer lands, so three skeletons never equal N cards.

       flex-basis is what re-proportions the lines. For a flex item a definite
       basis IS the main size and the inline width= in area-hq.html is ignored,
       so the markup needs no second set of widths for phones. */
    .hq-table tr.hq-skeleton-row > td { padding: 0; }
    .hq-table .hq-skeleton-row .skeleton-row {
        flex-wrap: wrap; row-gap: var(--spacing-5); padding: var(--spacing-4);
    }
    #hqRosterBody .hq-skeleton-row .skeleton-line:nth-child(1) { flex: 0 0 52%; }
    #hqRosterBody .hq-skeleton-row .skeleton-line:nth-child(2) { flex: 0 0 24%; }
    #hqRosterBody .hq-skeleton-row .skeleton-line:nth-child(3) { flex: 0 0 100%; }
    #hqRosterBody .hq-skeleton-row .skeleton-line:nth-child(n+4) { flex: 0 0 26%; }
    #hqComplianceBody .hq-skeleton-row .skeleton-line:nth-child(1) { flex: 0 0 46%; }
    #hqComplianceBody .hq-skeleton-row .skeleton-line:nth-child(2) { flex: 0 0 100%; }
    #hqComplianceBody .hq-skeleton-row .skeleton-line:nth-child(n+3) { flex: 0 0 26%; }

    /* ---- what just happened ----
       The clock led the row, so every entry opened with its least useful fact
       and the action, the person and the description wrapped underneath at
       whatever width was left over. The badge and the person lead now, the time
       sits at the trailing edge of the first line, and the description gets a
       line of its own. Scoped to this page feed -- the same markup is rendered
       by js/leader-hq.js on three other pages. */
    #hqActivity .hq-activity-item {
        display: grid; grid-template-columns: auto 1fr;
        gap: var(--spacing-1) var(--spacing-2); align-items: center;
    }
    #hqActivity .hq-activity-item > .badge { grid-column: 1; grid-row: 1; }
    #hqActivity .audit-feed-when { grid-column: 2; grid-row: 1; text-align: right; }
    #hqActivity .audit-feed-who { grid-column: 1 / -1; grid-row: 2; }
    #hqActivity .audit-feed-desc {
        grid-column: 1 / -1; grid-row: 3; font-size: var(--font-size-sm);
    }
}

/* --- team readiness --- */
.hq-team { display: flex; flex-direction: column; }
.hq-team-row {
    display: grid; grid-template-columns: 1fr auto;
    gap: var(--spacing-1) var(--spacing-4); align-items: center;
    padding: var(--spacing-3) 0; border-bottom: 1px solid var(--gray-700);
}
.hq-team-row:first-child { padding-top: 0; }
.hq-team-row:last-child { border-bottom: none; padding-bottom: 0; }
.hq-team-name { font-size: var(--font-size-sm); font-weight: 500; }
.hq-team-role { grid-column: 1; font-size: var(--font-size-xs); color: var(--gray-400); }

/* ============================================
   MY FEASTS — the money pair on a feast card (feasts.html, FPFM-67)
============================================
   This was `grid grid-cols-2`, and the app collapses .grid-cols-2 to a single
   column below 640px. That is right for a form and wrong for these two: each
   is a three-word label over a figure, and stacked they cost 144px of every
   card, so three feasts ran past a screen and a half before the last one
   appeared. Side by side they cost 64px and read as the comparison they are.

   Flex-wrap rather than a media query, because the width that matters is the
   CARD's and not the screen's -- the page puts cards two-up from 768px, where
   the sidebar has already taken 260px, so the card is NARROWER at 768px than
   on a 390px phone. Measured: container 222px at 320px wide, 262px at 360,
   292px at 390, 180px at 768 and 308px at 1024. A 7.5rem basis puts the pair
   side by side from 360px up, stacks it at 320px and again at 768px, and can
   never produce a third column the way auto-fit could.

   The figure drops to --font-size-sm so a thirteen-character peso total still
   fits the narrowest box the pair ever gets (125px at 360px wide). A currency
   amount must shrink or stack, never wrap and never be clipped. */
.feast-money {
    display: flex;
    flex-wrap: wrap;
    gap: var(--spacing-3);
}
.feast-money > div { flex: 1 1 7.5rem; padding: var(--spacing-3) var(--spacing-2); }
.feast-money .text-sm { font-size: var(--font-size-xs); }
.feast-money .font-semibold { font-size: var(--font-size-sm); }

/* ============================================
   CHART OF ACCOUNTS — categories.html
============================================
   Supersedes FPFM-2. That change aligned the three action buttons neatly
   (+ Sub left, Rename true-centre, Retire right) but left them on their own
   full-width line BELOW the name, which was the actual problem: every
   category and every subcategory cost two lines, so the Money Out tab
   (13 categories, 60 subcategories) ran to roughly 6,600px — about eight
   screens — and each button strip sat nearer the next item than its own.
   The .cat-row-actions rules and their --3 / --2 modifiers are gone with it.

   The blocker underneath both attempts: this list was rendered from inline
   style="" strings in js/categories.js, and an inline style outranks every
   media query, so the phone view could not be reached from CSS at all.
   Everything structural now lives here.

   Phones: name + icon-only actions on ONE line.
   >= 640px: the same buttons grow their text label back, so nothing about
   the desktop wording changes.

   Note these use var(--gray-700) for rules. The old inline markup asked for
   var(--border), which is not defined anywhere in this file, so it always
   fell through to a hardcoded #374151 — a near-black line in light theme. */

/* Card header: tabs and Add sit on one row, then stack full-width on phones. */
.coa-header { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-2); flex-wrap: wrap; }
.coa-tabs { display: flex; gap: var(--spacing-2); }

/* One category plus its subcategories. */
.coa-group { border-bottom: 1px solid var(--gray-700); }
.coa-group:last-child { border-bottom: none; }

/* A single line: name, badge, actions. Deliberately does NOT wrap. */
.coa-row {
    display: flex;
    align-items: center;
    gap: var(--spacing-2);
    min-height: 40px;
    padding: var(--spacing-1) 0;
}
.coa-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.coa-row-parent > .coa-name { font-weight: 600; }
.coa-badge { flex: 0 0 auto; font-size: var(--font-size-xs); color: var(--gray-400); }
.coa-badge-custom { color: var(--primary); }

/* Subcategories hang off a rule, so which parent they belong to is unambiguous. */
.coa-subs {
    margin: 0 0 var(--spacing-1) var(--spacing-2);
    padding-left: var(--spacing-3);
    border-left: 2px solid var(--gray-700);
}
.coa-row-sub > .coa-name { font-size: var(--font-size-sm); }

.coa-actions { flex: 0 0 auto; display: flex; align-items: center; gap: var(--spacing-1); }

/* Action button. Extends .btn-icon, which already carries the 44x44 minimum
   under @media (pointer: coarse) — do not restate it here. */
.coa-act { flex: 0 0 auto; }
.coa-act-danger,
.coa-act-danger:hover { color: var(--error); }
.coa-act-label { display: none; }

@media (min-width: 640px) {
    /* Desktop has the room, so give the icons their words back. */
    .coa-act {
        width: auto;
        padding: 0 var(--spacing-3);
        gap: var(--spacing-2);
        font-size: var(--font-size-sm);
    }
    .coa-act-label { display: inline; }
}

@media (max-width: 639px) {
    /* Tabs share the width evenly; Add Category gets its own full-width row
       instead of being the element that breaks the header. */
    .coa-header { flex-direction: column; align-items: stretch; }
    .coa-tabs > .btn { flex: 1 1 0; }
    .coa-add { width: 100%; }

    /* Three 44px buttons plus a badge leave a category name barely 100px on a
       390px screen. "Built-in" is the default state and the least useful thing
       competing for that width, so it stands down here. The red "Added" badge
       stays — that one is actually news. */
    .coa-badge:not(.coa-badge-custom) { display: none; }
}

/* ============================================
   BUDGET — budget.html
   ============================================
   #periodMonth / #feastSelect sit in a `.d-flex gap-2 align-center` wrapper,
   but `.d-flex` and `.align-center` are never actually defined anywhere in
   this file, so the wrapper isn't a flex container and `.gap-2`'s `gap`
   (which only applies inside flex/grid) has no effect -- the two controls
   currently rely on default inline-element flow with no real spacing.

   Scoped to mobile only (<=767px, this codebase's mobile/desktop split);
   desktop is untouched. Margin-right covers the case they stay on one
   line, margin-bottom covers the case they wrap to two -- either way
   there is now deliberate space between them on a phone.
   ============================================ */
@media (max-width: 767px) {
    #periodMonth { margin-right: var(--spacing-2); margin-bottom: var(--spacing-2); }
}

/* ============================================
   TRANSACTIONS — transactions.html
   ============================================
   Mobile only: Transaction History + Edit Requests + a full Export button
   don't comfortably fit one header row on a phone, so Export collapses
   into a kebab (.account-menu-toggle icon, reused from sidebar.js) next to
   Edit Requests. Desktop never renders the kebab and keeps the Export
   button exactly as it already was.

   .txn-kebab-menu only supplies the ONE thing that differs from the
   sidebar's .account-menu: it opens downward/right-anchored (this header
   sits near the top of the page, not the bottom like the sidebar), and it
   reuses .account-menu-item for its actual button styling.
   ============================================ */
.txn-kebab-wrap { position: relative; display: none; }
.txn-kebab-menu {
    position: absolute;
    top: calc(100% + 6px);
    right: 0;
    min-width: 160px;
    padding: 5px;
    background: var(--bg-card);
    border: 1px solid var(--gray-600);
    border-radius: 10px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
    z-index: 80;
}

@media (max-width: 767px) {
    .txn-kebab-wrap { display: inline-flex; }
    #txnExportGroup { display: none; }
}

/* Transaction History + Edit Requests + the kebab need to stay on one
   line on a phone rather than wrapping to two. #txnTabsRow's own ID
   beats the sitewide `.card-header > .flex { flex-wrap: wrap }` mobile
   rule (css/styles.css ~4230), so this row alone opts out of wrapping.
   flex-shrink:0 on each child stops the browser from squeezing button
   text illegible.

   Deliberately NOT overflow-x:auto (tried in an earlier pass, reverted
   here): setting overflow-x without overflow-y makes the browser compute
   overflow-y to auto too (CSS Overflow spec), which clipped both
   #editRequestsBadge (pokes out via top:-6px) AND -- worse -- the open
   .txn-kebab-menu dropdown, which extends below this row's own (short)
   box and was getting cut off by that same scroll container. Padding
   alone still gives every edge breathing room without introducing a
   clipping/scroll box at all. */
@media (max-width: 767px) {
    #txnTabsRow {
        flex-wrap: nowrap;
        padding: var(--spacing-2);
    }
    #txnTabsRow > * { flex-shrink: 0; }
}

/* ============================================
   TABLE PAGINATION (transactions list)
   ============================================
   Bar directly above a .table-container: "Showing 1-25 of 137", a rows-per-page
   select and Prev/Next. Sits outside the scrolling table container so it stays
   put while the table itself scrolls sideways on a phone. */
.table-pagination {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-3);
    padding: var(--spacing-3) var(--spacing-4);
    border-bottom: 1px solid var(--gray-700);
}

.table-pagination-info {
    margin: 0;
    font-size: var(--font-size-sm);
    color: var(--gray-400);
}

.table-pagination-controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--spacing-3);
}

.table-pagination-size {
    display: flex;
    align-items: center;
    gap: var(--spacing-2);
    margin: 0;
    font-size: var(--font-size-sm);
    color: var(--gray-400);
}

.table-pagination-size .form-select {
    width: auto;
    min-width: 72px;
    margin: 0;
}

.table-pagination-nav {
    display: flex;
    align-items: center;
    gap: var(--spacing-2);
}

.table-pagination-page {
    font-size: var(--font-size-sm);
    font-weight: 500;
    white-space: nowrap;
}

.table-pagination-nav .btn[disabled] {
    opacity: 0.4;
    cursor: not-allowed;
}

/* Phones: the bar stacks, and Prev/Next must stay tappable (>=44px). */
@media (max-width: 767px) {
    .table-pagination {
        justify-content: center;
        text-align: center;
    }

    .table-pagination-controls {
        width: 100%;
        justify-content: space-between;
    }

    .table-pagination-nav .btn {
        min-width: 44px;
        min-height: 44px;
    }
}

/* ============================================
   TRANSACTION HISTORY VERTICAL SCROLL
   ============================================
   The card itself is the bounded box: tabs and the pagination bar are pinned at
   the top of it, and only the table area underneath scrolls. Without this the
   card grows to whatever the page size is, and at 100 or All rows the pagination
   controls end up far above the screen with no way back short of scrolling the
   whole page.

   Scoped to #transactionsCard — .card and .table-container are shared by every
   page in the app, so bounding their height globally would clip unrelated views. */
#transactionsCard {
    display: flex;
    flex-direction: column;
    max-height: 78vh;
}

#transactionsCard > .card-header,
#transactionsCard > .table-pagination {
    flex: 0 0 auto;
}

#transactionsCard > .table-container {
    flex: 1 1 auto;
    overflow-y: auto;
    /* Required: a flex item defaults to min-height:auto and refuses to shrink
       below its content, which would push the scrollbar onto the page instead. */
    min-height: 0;
}

/* Column headings stay put while the rows move. Relies on the opaque
   .table th background (var(--bg-dark)) to cover the rows passing underneath. */
#transactionsCard .table thead th {
    position: sticky;
    top: 0;
    z-index: 2;
    /* border-collapse drops a sticky cell's own border while it floats — draw the
       header's bottom rule as an inset shadow instead so it never vanishes. */
    box-shadow: inset 0 -1px 0 var(--gray-700);
}

/* `.table th` is globally `white-space: nowrap`, which is fine while a column
   is as wide as its heading. With declared widths it is not: at 360px the
   heading is wider than its column, and a nowrap heading does not shrink or
   wrap — it prints straight over the next one ("DATE REQUESTETRANSACTION").
   These headings are two short words, so let them wrap onto a second line. */
#editRequestsPanel .table th {
    white-space: normal;
}

/* The Edit Requests Actions cell: View, Approve, Reject. The column is a fixed
   share of the table now (see the <colgroup> in transactions.html), and on a
   phone that share is narrower than the three buttons side by side, so they
   have to be able to wrap. The spacing is a margin on each button rather than
   the old margin-left on two of them: a left margin only reads correctly while
   the buttons are all on one line, and once they wrap the second row lost its
   gap. Nothing is clipped — the row grows instead. */
.edit-request-actions > .btn {
    margin: 2px;
    vertical-align: middle;
}

/* ============================================
   TITHER RECORDS VERTICAL SCROLL
   ============================================
   Same shell as #transactionsCard above: the card is the bounded box, the header
   and the pagination bar stay pinned, and only the rows underneath scroll.

   Extra step here: the rows sit inside .card-body.no-padding, so the body has to
   become a flex column too, or the table container has no height to fill. */
#tithersCard {
    display: flex;
    flex-direction: column;
    max-height: 78vh;
}

#tithersCard > .card-header,
#tithersCard > .table-pagination {
    flex: 0 0 auto;
}

#tithersCard > .card-body {
    display: flex;
    flex-direction: column;
    flex: 0 1 auto;
    /* Required: a flex item defaults to min-height:auto and refuses to shrink
       below its content, which would push the scrollbar onto the page instead. */
    min-height: 0;
}

/* Shrink-only (flex-grow 0) on purpose: a short list keeps the card short, and
   when the list is empty the table is hidden and the empty state sits right under
   the header instead of being pushed to the bottom of an 78vh box. */
#tithersCard > .card-body > .table-container {
    flex: 0 1 auto;
    overflow-y: auto;
    min-height: 0;
}

#tithersCard > .card-body > .empty-state {
    flex: 0 0 auto;
}

/* Column headings stay put while the rows move. Relies on the opaque
   .table th background (var(--bg-dark)) to cover the rows passing underneath. */
#tithersCard .table thead th {
    position: sticky;
    top: 0;
    z-index: 2;
    /* border-collapse drops a sticky cell's own border while it floats — draw the
       header's bottom rule as an inset shadow instead so it never vanishes. */
    box-shadow: inset 0 -1px 0 var(--gray-700);
}

/* ============================================
   AUDIT LOG VERTICAL SCROLL
   ============================================
   Same shell as #transactionsCard and #tithersCard above. This card has no
   header, so the pagination bar is pinned at the top of it and the rows scroll
   underneath. There is no footer: pressing Next on the last loaded page fetches
   the next batch from the server, which is what a log of this size needs — a
   button below a list that grows 50 rows a click walks further out of reach
   every time it is pressed.

   The mobile rules further up collapse #auditTable into stacked cards and hide
   the thead, so the sticky heading below only ever applies on wider screens. */
#auditCard {
    display: flex;
    flex-direction: column;
    max-height: 78vh;
}

#auditCard > .table-pagination,
#auditCard > .empty-state {
    flex: 0 0 auto;
}

/* Shrink-only (flex-grow 0): a short log keeps the card short instead of
   leaving a mostly empty 78vh box. */
#auditCard > .table-container {
    flex: 0 1 auto;
    overflow-y: auto;
    /* Required: a flex item defaults to min-height:auto and refuses to shrink
       below its content, which would push the scrollbar onto the page instead. */
    min-height: 0;
}

/* Column headings stay put while the rows move. Relies on the opaque
   .table th background (var(--bg-dark)) to cover the rows passing underneath. */
#auditCard .table thead th {
    position: sticky;
    top: 0;
    z-index: 2;
    /* border-collapse drops a sticky cell's own border while it floats — draw the
       header's bottom rule as an inset shadow instead so it never vanishes. */
    box-shadow: inset 0 -1px 0 var(--gray-700);
}

/* ============================================
   FEASTS IN DISTRICT VERTICAL SCROLL
   ============================================
   Same shell as the three cards above, with one difference: this table sits in
   a .table-responsive (the district/area/provincial views use that class) rather
   than a .table-container, so the scroll goes on that instead. The rows live
   inside .card-body.p-0, so the body has to become a flex column too or the
   table wrapper has no height to fill. */
#districtFeastsCard {
    display: flex;
    flex-direction: column;
    max-height: 78vh;
}

#districtFeastsCard > .card-header,
#districtFeastsCard > .table-pagination {
    flex: 0 0 auto;
}

#districtFeastsCard > .card-body {
    display: flex;
    flex-direction: column;
    flex: 0 1 auto;
    /* Required: a flex item defaults to min-height:auto and refuses to shrink
       below its content, which would push the scrollbar onto the page instead. */
    min-height: 0;
}

/* Shrink-only (flex-grow 0): a district with three feasts keeps the card three
   rows tall instead of stretching to a mostly empty 78vh box. */
#districtFeastsCard > .card-body > .table-responsive {
    flex: 0 1 auto;
    overflow-y: auto;
    min-height: 0;
}

/* Column headings stay put while the rows move. Relies on the opaque
   .table th background (var(--bg-dark)) to cover the rows passing underneath. */
#districtFeastsCard .table thead th {
    position: sticky;
    top: 0;
    z-index: 2;
    /* border-collapse drops a sticky cell's own border while it floats — draw the
       header's bottom rule as an inset shadow instead so it never vanishes. */
    box-shadow: inset 0 -1px 0 var(--gray-700);
}

/* ============================================
   MANAGE BUILDERS VERTICAL SCROLL
   ============================================
   Same shell as the cards above, applied to both tab panels. Here the rows sit
   directly in .card-body (which already carries an inline overflow-x:auto for
   the wide table), so the body IS the scroll box — no inner wrapper to hand the
   overflow to. Only overflow-y is added, leaving the inline overflow-x alone.

   #feastsPanel is display:none until its tab is picked, and switchTab() restores
   it with style.display = '' — an empty string, which falls back to the flex
   below rather than to block. */
#usersPanel,
#feastsPanel {
    display: flex;
    flex-direction: column;
    max-height: 78vh;
}

#usersPanel > .card-header,
#feastsPanel > .card-header,
#usersPanel > .table-pagination,
#feastsPanel > .table-pagination {
    flex: 0 0 auto;
}

/* Shrink-only (flex-grow 0): a jurisdiction with four users keeps the card four
   rows tall instead of stretching to a mostly empty 78vh box. */
#usersPanel > .card-body,
#feastsPanel > .card-body {
    flex: 0 1 auto;
    overflow-y: auto;
    /* Required: a flex item defaults to min-height:auto and refuses to shrink
       below its content, which would push the scrollbar onto the page instead. */
    min-height: 0;
    /* No padding, so the column headings sit flush under the pagination bar.
       .card-body's usual padding is INSIDE the scroll box, and a sticky heading
       pins to the scrollport edge — which means rows scroll through the padding
       band above the heading, visible and unclipped. The other four paginated
       cards avoid this by using a padding-less body (.no-padding / .p-0); this
       one is the only page whose body was padded. Cells keep their own padding
       from .table, so the rows are not cramped. */
    padding: 0;
}

/* The empty state loses the body's padding with it — give it its own. */
#usersPanel > .card-body > .empty-state,
#feastsPanel > .card-body > .empty-state {
    padding: var(--spacing-6);
}

/* Column headings stay put while the rows move. Relies on the opaque
   .table th background (var(--bg-dark)) to cover the rows passing underneath. */
#usersPanel .table thead th,
#feastsPanel .table thead th {
    position: sticky;
    top: 0;
    z-index: 2;
    /* border-collapse drops a sticky cell's own border while it floats — draw the
       header's bottom rule as an inset shadow instead so it never vanishes. */
    box-shadow: inset 0 -1px 0 var(--gray-700);
}

/* ============================================
   ALL USERS VERTICAL SCROLL
   ============================================
   Same shell as the five cards above. This table sits in a .table-responsive
   (which already carries overflow-x for the 6-column layout), so overflow-y is
   added there and the card-body has to become a flex column or the wrapper has
   no height to fill. */
#allUsersCard {
    display: flex;
    flex-direction: column;
    max-height: 78vh;
}

#allUsersCard > .card-header,
#allUsersCard > .table-pagination {
    flex: 0 0 auto;
}

#allUsersCard > .card-body {
    display: flex;
    flex-direction: column;
    flex: 0 1 auto;
    /* Required: a flex item defaults to min-height:auto and refuses to shrink
       below its content, which would push the scrollbar onto the page instead. */
    min-height: 0;
    /* No padding, so the column headings sit flush under the card header --
       .card-body's usual padding is INSIDE the scroll box, and a sticky heading
       pins to the scrollport edge, so rows would scroll through the padding band
       above the heading (same fix as #usersPanel, FPFM-21). Cells keep their own
       padding from .table. */
    padding: 0;
}

/* Shrink-only (flex-grow 0): a district with four users keeps the card four
   rows tall instead of stretching to a mostly empty 78vh box. */
#allUsersCard > .card-body > .table-responsive {
    flex: 0 1 auto;
    overflow-y: auto;
    min-height: 0;
}

/* Column headings stay put while the rows move. Relies on the opaque
   .table th background (var(--bg-dark)) to cover the rows passing underneath. */
#allUsersCard .table thead th {
    position: sticky;
    top: 0;
    z-index: 2;
    /* border-collapse drops a sticky cell's own border while it floats — draw the
       header's bottom rule as an inset shadow instead so it never vanishes. */
    box-shadow: inset 0 -1px 0 var(--gray-700);
}

/* ============================================
   ENVIRONMENT BANNER (staging only)
   ============================================
   Injected by js/env-banner.js when window.FEAST_ENV === 'staging'. Staging is
   a full clone of production, so the two sites are otherwise indistinguishable
   on screen — this bar is what stops someone entering a real transaction on
   the test site.

   Every rule below is scoped to .env-banner or body.env-staging, neither of
   which is ever emitted on production, so this block cannot affect the live
   site. */
:root {
    --env-banner-height: 30px;
}

.env-banner {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    height: var(--env-banner-height);
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    background-color: #F59E0B;
    color: #1F2937;
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
    /* Sits above modals so the warning never disappears, and is click-through
       so it can never swallow a tap on a control underneath. */
    z-index: 2147483000;
    pointer-events: none;
}

.env-banner__label {
    font-weight: 800;
}

.env-banner__detail {
    font-weight: 600;
    text-transform: none;
    letter-spacing: 0;
    opacity: 0.9;
}

/* Push the page down so the bar covers nothing. .sidebar and .mobile-header are
   position:fixed at top:0, so padding on body does not move them. */
body.env-staging {
    padding-top: var(--env-banner-height);
}

body.env-staging .sidebar {
    top: var(--env-banner-height);
    height: calc(100vh - var(--env-banner-height));
}

body.env-staging .mobile-header,
body.env-staging .transition-progress-bar {
    top: var(--env-banner-height);
}

body.env-staging .main-content {
    min-height: calc(100vh - var(--env-banner-height));
}

/* At 360px the full sentence wraps and doubles the bar height, which would eat
   screen. Drop the tail and keep the word that matters. */
@media (max-width: 480px) {
    :root {
        --env-banner-height: 26px;
    }

    .env-banner {
        font-size: 11px;
    }

    .env-banner__detail {
        display: none;
    }
}

/* ============================================
   SIDEBAR NOTIFICATIONS — bell, badge, dropdown

   These lived in a <style> block that sidebar.js appended to <head> at
   DOMContentLoaded. The markup, however, is written by renderSidebar()
   at parse time, from the CHROME FIRST block at the top of <body> — so
   for every navigation the dropdown existed for a few hundred
   milliseconds with no rules at all, and an unstyled block element is
   visible: "Notifications / Mark all read / No notifications" printed
   across the top of the page, then gone. That is the blink.

   Anything renderSidebar() writes must be styled by this file, which is
   in <head> and parsed before the first paint. Never move chrome styles
   back into a JS-injected <style>.

   Kept at the END of the file on purpose: .notification-icon,
   .notification-title and .notification-message are also used by the
   showNotification() modal higher up, and the injected block used to win
   by being appended last. Same order here, same rendering.

   Guarded by tests/navigation-blink.test.js.
============================================ */
.sidebar-notification-bell {
    position: relative;
    background: none;
    border: none;
    color: var(--gray-400);
    cursor: pointer;
    padding: 6px;
    border-radius: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.2s;
}
.sidebar-notification-bell:hover {
    background: var(--gray-700);
    color: var(--secondary);
}
.notification-badge {
    position: absolute;
    top: -2px;
    right: -2px;
    min-width: 18px;
    height: 18px;
    background: #ef4444;
    color: white;
    border-radius: 9px;
    font-size: 10px;
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0 4px;
}
.notification-dropdown {
    display: none;
    position: absolute;
    top: 52px;
    left: 8px;
    right: 8px;
    width: auto;
    max-height: 400px;
    background: var(--bg-card);
    border: 1px solid var(--gray-700);
    border-radius: 12px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
    z-index: 200;
    overflow: hidden;
}
.notification-dropdown.open {
    display: block;
}
.notification-dropdown-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 12px 16px;
    border-bottom: 1px solid var(--gray-700);
    font-size: 14px;
    font-weight: 600;
    color: var(--secondary);
}
.notification-dropdown-list {
    max-height: 340px;
    overflow-y: auto;
}
.notification-item {
    display: flex;
    gap: 10px;
    padding: 12px 16px;
    border-bottom: 1px solid var(--gray-700);
    text-decoration: none;
    color: inherit;
    transition: background 0.15s;
}
.notification-item:hover {
    background: rgba(0, 0, 0, 0.03);
}
.notification-item.unread {
    background: rgba(59, 130, 246, 0.08);
}
.notification-icon {
    font-size: 18px;
    flex-shrink: 0;
    margin-top: 2px;
}
.notification-title {
    font-size: 13px;
    font-weight: 600;
    color: var(--secondary);
    margin-bottom: 2px;
}
.notification-message {
    font-size: 12px;
    color: var(--gray-400);
    line-height: 1.4;
}
.notification-time {
    font-size: 11px;
    color: var(--gray-500);
    margin-top: 4px;
}
.notification-empty {
    padding: 24px 16px;
    text-align: center;
    color: var(--gray-500);
    font-size: 13px;
}
.sidebar-collapsed .notification-dropdown {
    left: 60px;
    right: auto;
    width: 320px;
}

/* ============================================
   CASH DENOMINATION BREAKDOWN (cash-in.html)
============================================ */
/* The toggle used to be a .btn-ghost: no surface of its own, so on the form's
   pale background it read as a line of body text with a chevron beside it,
   not as something you could open. It is a form field now -- the same surface,
   border, radius, padding and text as the Event select directly above it, so
   the row of things you can act on is unbroken down the form.

   The seven declarations that make that surface are NOT repeated here: this
   class is in the .form-input/.form-select group above, so the field and the
   toggle cannot drift apart. What is left below is only what a <button> needs
   that a field does not -- the flex row that puts the chevron on the right,
   and the join to the panel when it is open.

   Lives here rather than in a <style> after the markup, so the page never
   paints it unstyled and then moves it (see the FPFM-45/46 notes). */
.denom-toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-3);
    min-height: 44px; /* touch target */
    text-align: left;
    font-weight: 400;
    cursor: pointer;
}

.denom-toggle:hover {
    border-color: var(--gray-500);
}

/* A field turns its border red on focus. This one does not: red on this form
   means the breakdown does not match the amount entered, and the ring is the
   only thing a keyboard user has to see where they are -- it should not look
   like the error it sits three lines above. --secondary is the body text
   colour, so it reads on both themes. */
.denom-toggle:focus-visible {
    outline: 2px solid var(--secondary);
    outline-offset: 2px;
}

/* "(for bank deposit)" is why the panel exists, not a second label, so it
   takes the same grey the select uses for its own chevron. */
.denom-toggle .denom-toggle-hint {
    color: var(--gray-500);
}

/* Open: the toggle and the panel become one card. The toggle keeps its
   bottom border so the two are separated by a single line, not two. */
.denom-toggle[aria-expanded="true"] {
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;
}

/* Sized and coloured to match the arrow the .form-select paints into its own
   background (14px, #888888 = --gray-500), so the two sit at the same weight
   down the right edge of the form. */
.denom-chevron {
    flex-shrink: 0;
    width: 14px;
    height: 14px;
    color: var(--gray-500);
    transition: transform var(--transition-fast);
}

.denom-toggle[aria-expanded="true"] .denom-chevron {
    transform: rotate(180deg);
}

/* The panel hangs off the toggle, so it takes the toggle's edge colour --
   --gray-600, the field border. Two different greys meeting at the join read
   as two stacked boxes rather than one opened section. */
.denom-panel {
    padding: var(--spacing-4);
    background-color: var(--gray-800);
    border: 1px solid var(--gray-600);
    border-top: none;
    border-bottom-left-radius: var(--radius-lg);
    border-bottom-right-radius: var(--radius-lg);
}

.denom-panel-intro {
    margin-bottom: var(--spacing-3);
    font-size: 12px;
    color: var(--gray-400);
}

.denom-panel-rule {
    margin: var(--spacing-3) 0;
    border: none;
    border-top: 1px solid var(--gray-700);
}

.denom-mismatch {
    margin-top: var(--spacing-2);
    font-size: 12px;
    color: var(--error);
}

/* ============================================
   ENTRY LINES — the repeating half of a money form (FPFM-58)
   ============================================
   A liquidation is one entry with a line per expense; a deposit is one entry
   with a line per tither. js/entry-lines.js builds these rows.

   Both directions have the same shape -- a number, four fields, a remove
   button -- so one grid serves Cash In and Cash Out. The AMOUNT is always the
   last field, which is what lets the column widths be shared.

   These rules live here, in the stylesheet, and not in a <style> injected by
   the module. A component styled by its own script is unstyled for the frames
   between the markup landing and the script running, which is the mistake the
   sidebar notification dropdown made (see CLAUDE.md). */

.entry-lines-head,
.entry-line {
    display: grid;
    grid-template-columns: 28px 1.1fr 1.2fr 1fr 150px 44px;
    gap: var(--spacing-2);
    align-items: center;
}

.entry-lines-head {
    padding-bottom: var(--spacing-2);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--gray-400);
}

.entry-lines-head .num,
.entry-lines-head .kill { text-align: center; }
.entry-lines-head .amt { text-align: right; }

.entry-line { padding: var(--spacing-2) 0; }
.entry-line + .entry-line { border-top: 1px solid var(--gray-700); }

.entry-line__num {
    text-align: center;
    font-size: 12.5px;
    font-weight: 600;
    color: var(--gray-400);
    font-variant-numeric: tabular-nums;
}

/* The controls are the app's own .form-input / .form-select, so they already
   carry the 44px minimum and the focus ring. Only the density changes. */
.entry-line .form-input,
.entry-line .form-select {
    padding: var(--spacing-2) var(--spacing-3);
    font-size: 14px;
}

/* The per-field labels exist for the phone card only (FPFM-71). On a desktop
   row the .entry-lines-head above names every column, so a second label under
   it would be noise -- and, more mechanically, a visible label here would be a
   grid item and would shift every track by one. display:none takes it out of
   the grid entirely, which is what keeps the desktop row byte-for-byte what it
   was before the redesign. */
.entry-line__label { display: none; }

.entry-line__amount-wrap { position: relative; }

.entry-line__peso {
    position: absolute;
    left: var(--spacing-3);
    top: 50%;
    transform: translateY(-50%);
    font-size: 13px;
    color: var(--gray-400);
    pointer-events: none;
}

.entry-line__amount {
    text-align: right;
    padding-left: calc(var(--spacing-3) + 12px) !important;
    font-variant-numeric: tabular-nums;
    font-weight: 600;
}

/* Hide the spinner arrows: they sit exactly where the peso amount ends and
   invite a click that changes money by one peso. */
.entry-line__amount::-webkit-outer-spin-button,
.entry-line__amount::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.entry-line__amount { -moz-appearance: textfield; appearance: textfield; }

.entry-line__remove {
    width: 44px;
    height: 44px;
    display: grid;
    place-items: center;
    margin: 0 auto;
    border: 1px solid transparent;
    border-radius: var(--radius-lg);
    background: transparent;
    color: var(--gray-400);
    font-size: 20px;
    line-height: 1;
    cursor: pointer;
    font-family: inherit;
}

.entry-line__remove:hover {
    background: var(--primary-light);
    color: var(--primary);
    border-color: var(--gray-700);
}

.entry-line__remove:focus-visible {
    outline: 2px solid var(--primary);
    outline-offset: 1px;
}

/* Running total */

.entry-lines-total {
    border-top: 1px solid var(--gray-700);
    padding-top: var(--spacing-3);
    margin-top: var(--spacing-3);
    display: flex;
    justify-content: flex-end;
}

.entry-lines-total__inner {
    width: min(340px, 100%);
    display: flex;
    flex-direction: column;
    gap: var(--spacing-1);
}

.entry-lines-total__row {
    display: flex;
    justify-content: space-between;
    gap: var(--spacing-4);
    font-size: 13.5px;
    color: var(--gray-300);
}

.entry-lines-total__row b {
    font-variant-numeric: tabular-nums;
    color: var(--secondary);
    font-weight: 600;
}

.entry-lines-total__row--grand {
    padding-top: var(--spacing-2);
    border-top: 1px solid var(--gray-700);
    font-size: 15px;
    font-weight: 600;
    color: var(--secondary);
}

.entry-lines-total__row--grand b { font-size: 17px; }

.entry-lines-balance {
    display: flex;
    align-items: center;
    gap: var(--spacing-2);
    font-size: 12.5px;
    font-weight: 600;
    padding: var(--spacing-2) var(--spacing-3);
    border-radius: var(--radius-lg);
    background: var(--success-light);
    color: var(--success);
}

.entry-lines-balance--off {
    background: var(--warning-light);
    color: var(--warning);
}

/* ---- Phone: six columns cannot survive 360px ----
   Each line becomes its own card. NOTHING is dropped -- every field that
   exists on a desktop row exists here, which is non-negotiable in this
   product. The heading row goes because each control carries its own
   aria-label and, since FPFM-71, its own visible label.

   FPFM-71 turned the card from the layout that SURVIVED a phone into the one
   that was designed for it:

     - the line is a FLEX COLUMN, not a two-track grid. Ordering the amount to
       the top is the reason. The DOM order is fixed by COLUMNS in
       entry-lines.js (category first, because it decides whether a tither is
       even a sensible question), and that order is right for a desktop row and
       wrong for a thumb -- on a phone the number is what she came to type. A
       flex `order` moves it without moving the markup, so both directions and
       both readings keep one source of truth for the field list.
     - the amount is the largest type on the screen and sits in a box with a
       2px border it cannot be mistaken for a dropdown. Its own font-size is
       well over 16px, which is also what stops iOS zooming the page on focus.
     - every other field is stacked under a readable label. The desktop column
       headings are gone at this width, so an unlabelled <select> reading
       "Regular Tithe" answers a question nobody can see.

   The desktop grid above is untouched. Everything here is inside the media
   query, and the labels are display:none outside it, so a wide row has the
   same elements in the same tracks it had before this ticket. */

@media (max-width: 780px) {
    .entry-lines-head { display: none; }

    .entry-line {
        display: flex;
        flex-direction: column;
        /* The desktop rule sets `align-items: center` for the grid row. In a
           flex COLUMN that is the cross axis, so it shrank every field to its
           own text width and centred it -- labels floating in mid-card and an
           amount box 192px wide inside a 358px one. Restate it. */
        align-items: stretch;
        position: relative;
        gap: 0;
        padding: 0;
        border: 1px solid var(--gray-700);
        border-radius: var(--radius-xl);
        background: var(--bg-card);
        box-shadow: var(--shadow-sm);
        overflow: hidden;
    }

    .entry-line + .entry-line {
        margin-top: var(--spacing-4);
        border-top: 1px solid var(--gray-700);
    }

    /* The card's header strip. It is the line number and the remove control,
       and nothing else -- a card that opens with a field is a card whose first
       field can be typed into by accident while scrolling. */
    .entry-line__num {
        order: -3;
        text-align: left;
        padding: 15px var(--spacing-4);
        background: var(--bg-surface);
        border-bottom: 1px solid var(--gray-700);
        font-size: 11px;
        font-weight: 600;
        letter-spacing: 0.08em;
        text-transform: uppercase;
    }

    .entry-line__num::before { content: 'Line '; }

    .entry-line__remove {
        position: absolute;
        top: 1px;
        right: var(--spacing-1);
        margin: 0;
    }

    .entry-line__label {
        display: block;
        margin: 0 var(--spacing-4) var(--spacing-2);
        font-size: var(--font-size-sm);
        font-weight: 500;
        color: var(--gray-300);
    }

    .entry-line__cell {
        margin: 0 var(--spacing-4) var(--spacing-4);
    }

    /* `width: auto`, not `100%` (FPFM-72). The control IS the cell here --
       entry-lines.js puts `entry-line__cell` on the <select>/<input> itself,
       not on a wrapper -- so the 16px side margins above land on the same box.
       `100%` resolves against the card's CONTENT width and the margins are
       then added OUTSIDE it, making a 356px control in a 356px card: measured
       at 390px, Category and Subcategory ran to x=389 against the card's inner
       edge at 373, and `overflow: hidden` on the card cut them off. The AMOUNT
       never showed it because its cell is the `entry-line__amount-wrap` div,
       which this rule does not match. `auto` lets the flex column's
       `align-items: stretch` size the MARGIN box instead, which is the same
       324px the amount box already gets.

       CORRECTION (FPFM-73): `auto` fixed the clipping and bought a worse bug.
       `auto` on a <select> means "as wide as your longest OPTION", and the
       Tither box's options are real people's names. That width is INTRINSIC,
       so it propagates out of the control, out of the line card, out of the
       form, and up to the `.entry-form-card` grid item -- which has the
       default `min-width: auto` and so cannot be squeezed below it. The
       document then ends up wider than `width=device-width`, and a phone
       answers that by widening the layout viewport: measured on a 390px
       device with one 61-character tither name, `document.scrollWidth` went
       390 -> 540 the instant Category was set to Tithes, and EVERY box on the
       page -- the line card, its header, the deposit-slip label -- ran off the
       right of the screen. Bisected to a single element: hiding
       `#entry-line-N-tither_id` alone took the document back to 390.

       `min-width: 0` and `max-width: 100%` do not cure it. Neither reduces an
       intrinsic contribution -- a percentage max-width resolves against a
       containing block that is itself already inflated by that contribution,
       and the flex automatic minimum is not what is doing the inflating.

       The only thing that works is a width that is NOT derived from content,
       which is what `100%` was doing right before FPFM-72 touched it. Its one
       real fault was arithmetic: the 16px side margins landed OUTSIDE the
       100%, making a 356px control in a 356px card. So subtract them. A
       percentage contributes nothing to intrinsic width, and the margin box
       now comes to exactly 100% of the card -- the same 324px at 390px that
       the amount box gets, with no clipping and no blow-out. Both halves of
       the history are load-bearing; do not simplify this back to either
       `100%` or `auto`.

       Guarded by tests/entry-line-intrinsic-width.test.js. */
    .entry-line .form-input,
    .entry-line .form-select {
        width: calc(100% - var(--spacing-4) * 2);
        padding: var(--spacing-3) var(--spacing-4);
        font-size: var(--font-size-base);
    }

    /* THE AMOUNT — the whole point of the redesign. Lifted out of DOM order to
       the top of the card and given the largest type on the screen. */
    .entry-line__label[data-key="amount"] {
        order: -2;
        margin-top: var(--spacing-4);
        font-size: var(--font-size-xs);
        font-weight: 600;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: var(--gray-400);
    }

    .entry-line__amount-wrap {
        order: -1;
        position: static;
        display: flex;
        align-items: center;
        gap: var(--spacing-2);
        margin: 0 var(--spacing-4) var(--spacing-5);
        padding: var(--spacing-3) var(--spacing-4);
        background: var(--bg-card);
        border: 2px solid var(--gray-600);
        border-radius: var(--radius-lg);
        transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
    }

    .entry-line__amount-wrap:focus-within {
        border-color: var(--primary);
        box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.14);
    }

    /* The peso sign is absolutely positioned inside the desktop field. Here the
       wrap IS the field, so the sign is an ordinary flex child beside it. */
    .entry-line__peso {
        position: static;
        transform: none;
        font-size: 1.75rem;
        font-weight: 600;
        line-height: 1;
        color: var(--gray-500);
    }

    /* Two classes deep to outrank .entry-line .form-input above, and
       `padding: 0 !important` because the desktop rule reserves room for the
       absolutely-positioned peso with an !important of its own. */
    .entry-line .form-input.entry-line__amount {
        flex: 1;
        min-width: 0;
        width: 100%;
        height: auto;
        min-height: 44px;
        padding: 0 !important;
        border: none;
        background: transparent;
        text-align: left;
        font-size: 2.375rem;
        font-weight: 700;
        line-height: 1.15;
        letter-spacing: -0.02em;
    }

    /* The ring belongs to the box, not to the borderless input inside it. */
    .entry-line .form-input.entry-line__amount:focus {
        outline: none;
        border: none;
        box-shadow: none;
    }

    /* One column here, so nothing can fall out of line: take the row back
       rather than leaving a 44px hole in the middle of the card. The label
       goes with it -- a label with no field under it is worse than neither. */
    .entry-line--no-tither .entry-line__label[data-key="tither_id"] {
        display: none;
    }

    .entry-line--no-tither .entry-line__people {
        display: none;
    }
}

/* Multi-line entries in the Transactions list (FPFM-58).
   A liquidation shows as ONE row; its lines sit beneath it, hidden until the
   chevron is pressed. The lines are rendered with the parent rather than
   fetched on expand -- they are already in memory, and a spinner for data we
   are holding would be a lie. */

.entry-line-row > td {
    background: var(--bg-surface);
    font-size: 13px;
}

.entry-line-row > td:nth-child(4) {
    /* The category cell carries the indent, so the line reads as belonging to
       the row above rather than as a transaction in its own right. */
    padding-left: var(--spacing-6);
}

.entry-toggle svg { transition: transform var(--transition-fast); }
.entry-toggle.is-open svg { transform: rotate(180deg); }

@media (prefers-reduced-motion: reduce) {
    .entry-toggle svg { transition: none; }
}

/* A tither belongs to a tithe or an offering, never to a Fund Balance or an
   Interest Income (FPFM-58), and on those the field does not appear at all.

   HOW it disappears differs by width, and both halves are load-bearing.

   Wide: the line is a six-column grid. display:none would pull Amount left into
   the empty slot, so a row with a giver and a row without one would have their
   Amount boxes in different places -- exactly the column drift FPFM-46 spent a
   ticket removing. The cell is made invisible and keeps its slot.

   Phone (the block below, at 780px): the cells are already stacked one per row,
   so there is no column left to misalign. The cell is removed outright and the
   card is simply shorter.

   js/entry-lines.js also disables it (out of the tab order, out of the submit),
   sets aria-hidden (out of a screen reader) and CLEARS it, so a person picked
   before the category changed can never reach the insert. */

.entry-line--no-tither .entry-line__people {
    visibility: hidden;
}

/* ...and when NO line on the form needs a giver, the column itself goes --
   heading included. Hiding only the cells left an empty slot between
   Subcategory and Amount, headed "TITHER", asking a question of nobody.

   Whole column or no column. Collapsing it per row instead would leave the
   rows out of line with each other, which is the drift FPFM-46 removed.

   MIN-WIDTH, and it is load-bearing (FPFM-70). A column count is a statement
   about the DESKTOP grid; on a phone the line is a card with no columns to
   collapse. Unguarded, this rule sat after the 780px block and outweighed it
   (0,2,0 against 0,1,0), so it put the five-column desktop track list back on
   a 390px screen. The Amount cell is pinned to `grid-column: 1 / 2` there, and
   track 1 is the 28px line-number column -- so the money box rendered 38px
   wide with 24px of peso padding inside it: nought pixels of content area.
   Both halves of FPFM-70 came from that one line. The first row looked wrong,
   and the field could be tapped and typed into but showed nothing back, which
   from the user's seat is a field that does not work. */

@media (min-width: 781px) {
    .entry-lines--no-tither-col.entry-lines-head,
    .entry-lines--no-tither-col .entry-line {
        grid-template-columns: 28px 1.15fr 1.25fr 150px 44px;
    }
}

.entry-lines--no-tither-col .entry-lines-head__tither,
.entry-lines--no-tither-col .entry-line__label[data-key="tither_id"],
.entry-lines--no-tither-col .entry-line__people {
    display: none;
}

/* The empty-state bar is display:flex, which beats the browser's own
   [hidden] { display: none } and left an empty green strip under the slip
   check on every load. An element that sets display must restate hidden. */
.entry-lines-balance[hidden] {
    display: none;
}

/* The lines card sits between ordinary .form-group rows, which carry a bottom
   margin and no top one. Given only margin-top, the card's bottom border ended
   up flush against the next label -- "Event (optional)" read as if it belonged
   inside the card rather than after it.

   A class rather than an inline style, so the two pages cannot drift apart and
   the spacing is stated once. */

.entry-lines-card {
    margin: var(--spacing-4) 0;
}

/* ---- The pinned action bar (FPFM-71) ----
   PHONE ONLY. On a desktop the total and the Save button are both on screen at
   once; on a phone the total sits at the bottom of a card that is now three
   times taller than it was, and Save is below that again. The figure she is
   checking and the button she presses about it ended up on two different
   screens, so she scrolled up to read the total, scrolled down to save, and had
   to trust her memory in between.

   The bar is the existing form footer, fixed to the viewport, with a total
   mirror added in front of the buttons. The mirror is display:none above 780px
   and the footer keeps every declaration it had, so a wide screen renders
   exactly what it rendered before this ticket.

   NOTHING IS HIDDEN: Cancel travels with Save rather than being dropped for
   room, per the mobile rule in CLAUDE.md.

   The total is not a second copy that could drift. There is ONE writer --
   the onChange in cash-in.js / cash-out.js -- and it writes every element
   carrying [data-entry-total], of which the in-card figure is one and this is
   the other. */

.entry-lines-actions__total { display: none; }

@media (max-width: 780px) {
    /* The page has to end above the bar. 180px was a guess measured off one
       screenshot at one text size, and the bar is not a fixed height: it wraps
       (`flex-wrap`, the total at `flex: 0 0 100%`, buttons at `min-height:
       52px`) and it adds `env(safe-area-inset-bottom)`. At a larger OS text
       size, or when Cancel and Record wrap onto separate rows, the real bar
       exceeds 180px and the last field hides behind it.

       So the gap is now the bar's OWN measured height. `--pinned-bar-h` is
       written by entry-lines.js from a ResizeObserver on the bar, so it tracks
       wrapping and text-size changes as they happen. The 180px here is only
       the fallback for the first frame, before the observer has run once. */
    .has-pinned-actions {
        padding-bottom: calc(var(--pinned-bar-h, 180px) + var(--spacing-4));
    }

    /* ---- NO ENCLOSING CARD: the page is plain (FPFM-71, third pass) ----
       Two audits failed on the same page. What shipped was the OLD nested
       layout with one layer removed: the lines wrapper kept `width: 100vw`
       with `margin-inline: calc(50% - 50vw)` to break OUT of the form card
       it was still inside, so on a phone a viewport-wide band tore through
       that card's side borders and the card resumed below it. It read as a
       rendering fault because it was one -- the approved frame has no
       enclosing card at all.

       So at this width the wrappers STOP BEING CARDS instead of being
       escaped from. With no chrome left to escape, the breakout is
       unnecessary and is deleted -- nothing on the page is wider than the
       page, which is also what keeps a classic scrollbar from minting a
       horizontal one (a viewport unit includes the scrollbar gutter; the
       content box does not).

       The entry-line cards are now the only cards here: inset by the
       .main-content gutter, corners on, exactly the approved frame. The
       amount box still computes to the same ~324px of 390 the full-bleed
       pass measured, because the gutter the card gains back equals the
       padding it no longer loses.

       Scoped to `.has-pinned-actions`, which exists only on cash-in.html and
       cash-out.html -- no other page loses a card to this. */
    .has-pinned-actions .entry-form-card {
        border: 0;
        border-radius: 0;
        background: transparent;
        /* The 639px block gives every .card 16px of its OWN padding, on top
           of what .card-body carries -- the level the first repair's
           measurements called ".card.md:col-span-2 | 16px" without naming
           the rule. De-carding means this goes too. */
        padding: 0;
        overflow: visible;
    }

    /* Its heading said "Cash In Entry" directly under a page header already
       saying "Record Cash In". On the plain page that is the same sentence
       twice; the mock carries it once. */
    .has-pinned-actions .entry-form-card > .card-header {
        display: none;
    }

    /* The page gutter is .main-content's own 16px. A second inset on top of
       it is the old nested look. The vertical rhythm stays. */
    .has-pinned-actions .entry-form-card > form > .card-body {
        padding-left: 0;
        padding-right: 0;
    }

    .has-pinned-actions .entry-lines-card {
        border: 0;
        border-radius: 0;
        background: transparent;
        box-shadow: none;
        padding: 0;
        overflow: visible;
    }

    /* "Who this came from" becomes the mock's plain section label: the words
       and the line count on one baseline, no strip behind them, no rule
       under them. */
    .has-pinned-actions .entry-lines-card > .card-header {
        padding: 0 0 var(--spacing-3);
        background: transparent;
        border-bottom: 0;
    }

    .has-pinned-actions .entry-lines-card > .card-body {
        padding: 0;
    }

    /* The amount box keeps the ~16px a side inside its line card. That is
       the approved frame: "nearly edge to edge", not literally against the
       glass -- a bordered field flush to the screen edge has nowhere to show
       its focus ring. Stated here so a later tidy-up does not read the
       remaining gutter as something this ticket missed. */

    /* ---- The figure appears ONCE (FPFM-71 repair) ----
       The in-card total and the pinned total are both on screen on a phone;
       the approved mock shows the figure once. The pinned one is the one she
       reads -- it is beside the button she presses about it and it cannot
       scroll away -- so the in-card copy is hidden HERE, in CSS, on the phone
       only. It stays in the DOM and stays a [data-entry-total] sink, so there
       is still exactly one writer feeding both and the desktop is untouched.
       Mirror image of `.entry-lines-actions__total { display: none }` above:
       at every width, exactly one of the two is visible. */
    .entry-lines-total__row--grand { display: none; }

    /* Hiding the grand row must not leave its box behind. On cash-out.html
       that box holds ONLY the grand row, so it kept a border-top and padding
       around nothing: a full-width hairline over ~28px of dead space. The
       node stays -- it is one of the two [data-entry-total] sinks the
       invariant test counts -- so the CSS collapses what it draws instead.
       On cash-in.html the slip check and balance share the box and carry
       their own spacing, so they lose nothing here. */
    .has-pinned-actions .entry-lines-total {
        border-top: 0;
        padding-top: 0;
    }

    /* ---- "+ Add line" is a control, not a footnote (FPFM-71, third pass) ----
       The approved frame shows a full-width dashed button; what shipped was
       the desktop's ghost link. Phone only: on a desktop the ghost beside
       the heading row is the design, so this class is styled nowhere above
       this block. */
    .has-pinned-actions .entry-lines-add {
        width: 100%;
        min-height: 52px;
        padding: var(--spacing-4);
        background: transparent;
        border: 1.5px dashed var(--gray-600);
        border-radius: var(--radius-xl);
        font-size: var(--font-size-base);
        font-weight: 600;
        color: var(--primary);
    }

    /* `.flex.gap-3:not(.flex-wrap)` at 639px stacks every gap-3 flex row, and
       three classes outweigh one -- so the bar came out as three full-width
       rows and stood 220px tall. The bar wants a WRAPPING ROW: the total takes
       a line of its own, the two buttons share the next. Matching weight and
       later in the file, so it wins without !important. */
    .flex.gap-3.entry-lines-actions {
        flex-direction: row;
    }

    .entry-lines-actions {
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        z-index: 90;
        /* flex-WRAP rather than a wrapper element around the two buttons: a
           wrapper would be a new child of the footer on the desktop too, and
           this ticket may not move a pixel there. The total takes a full row
           of its own; the buttons share the next one. */
        display: flex;
        flex-wrap: wrap;
        align-items: stretch;
        justify-content: flex-start;
        gap: var(--spacing-3);
        margin: 0;
        padding: var(--spacing-4);
        padding-bottom: max(var(--spacing-4), env(safe-area-inset-bottom));
        background: var(--bg-card);
        border-top: 1px solid var(--gray-700);
        border-radius: 0;
        box-shadow: 0 -6px 20px -8px rgba(0, 0, 0, 0.16);
    }

    .entry-lines-actions__total {
        flex: 0 0 100%;
        display: flex;
        align-items: baseline;
        justify-content: space-between;
        gap: var(--spacing-3);
    }

    .entry-lines-actions__total .k {
        font-size: var(--font-size-sm);
        font-weight: 500;
        color: var(--gray-300);
    }

    .entry-lines-actions__total .k em {
        display: block;
        margin-top: 2px;
        font-style: normal;
        font-size: var(--font-size-xs);
        color: var(--gray-500);
    }

    .entry-lines-actions__total .v {
        font-size: 1.75rem;
        font-weight: 700;
        letter-spacing: -0.02em;
        font-variant-numeric: tabular-nums;
        color: var(--secondary);
    }

    /* Save is the action; Cancel keeps a full touch target beside it rather
       than being dropped for room. */
    .entry-lines-actions .btn {
        min-height: 52px;
    }

    .entry-lines-actions .btn[type="submit"] {
        flex: 1;
    }
}

/* Receipt preview (FPFM-58).
   The image and its Remove button were siblings with nothing arranging them,
   so an inline-block <img> put the button on its own baseline, floating beside
   the bottom-right corner of the photo and reading as stray text rather than a
   control. Stack them and left-align.

   .hidden carries !important, so display:flex here cannot reveal a preview the
   JS has hidden -- checked, because an element that sets display is exactly how
   the empty balance bar leaked through its hidden attribute. */

.receipt-preview {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--spacing-2);
}

.receipt-preview img {
    display: block;
    /* max-width matters more than max-height: a wide photo from a phone camera
       would otherwise run past the form on a 360px screen. */
    max-width: 100%;
    max-height: 200px;
    border-radius: var(--radius-lg);
    border: 1px solid var(--gray-700);
}

/* ============================================
   RECEIPT VIEWER (FPFM-58)
   ============================================
   A receipt is checked ALONGSIDE the entry it belongs to -- right amount, right
   date, right supplier -- so it opens over the page rather than in a new tab,
   which put the question and the answer in different windows. On a phone a new
   tab is a different screen entirely.

   Rules live here and not in a <style> the script injects: an overlay styled by
   its own script is an unstyled block of markup for the frames in between, and
   this one covers the whole screen. */

.receipt-lightbox {
    position: fixed;
    inset: 0;
    z-index: 10000;
    background: rgba(0, 0, 0, 0.88);
    display: flex;
    flex-direction: column;
}

/* The page behind must not scroll under the overlay. */
body.receipt-lightbox-open { overflow: hidden; }

.receipt-lightbox__bar {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-4);
    padding: var(--spacing-3) var(--spacing-4);
}

.receipt-lightbox__open {
    color: #fff;
    font-size: 13px;
    text-decoration: underline;
    opacity: 0.85;
}
.receipt-lightbox__open:hover { opacity: 1; }

.receipt-lightbox__close {
    /* 44px, like every other control in this product: it is the one button on
       the screen and it is reached with a thumb. */
    width: 44px;
    height: 44px;
    display: grid;
    place-items: center;
    border: 0;
    border-radius: var(--radius-lg);
    background: rgba(255, 255, 255, 0.12);
    color: #fff;
    font-size: 26px;
    line-height: 1;
    cursor: pointer;
    font-family: inherit;
}
.receipt-lightbox__close:hover { background: rgba(255, 255, 255, 0.22); }
.receipt-lightbox__close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }

.receipt-lightbox__stage {
    flex: 1 1 auto;
    min-height: 0;
    display: grid;
    place-items: center;
    padding: 0 var(--spacing-4) var(--spacing-4);
    /* The backdrop closes and the image does not, so the stage has to be the
       clickable part around it. */
    cursor: zoom-out;
}

.receipt-lightbox__img {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    border-radius: var(--radius-lg);
    cursor: default;
}

.receipt-lightbox__failed {
    color: #fff;
    font-size: 14px;
    text-align: center;
    max-width: 40ch;
}

/* The trigger in a table cell reads as the link it replaced, not as a button
   with a box around it. */
.receipt-link {
    padding: 0;
    border: 0;
    background: none;
    color: var(--primary);
    font: inherit;
    cursor: pointer;
    text-decoration: none;
}
.receipt-link:hover { text-decoration: underline; }
.receipt-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 2px; }

/* ============================================
   RECENT TRANSACTIONS LIST — dashboard.html (FPFM-67)
   ============================================
   This was a <table class="table"> inside a .table-container. The global
   `.table { min-width: 600px }` therefore forced it to 600px, and on a 360px
   phone the amount — the one number anyone opens this card to see — sat off
   the right edge behind a sideways scroll, on the first screen after sign-in.

   It is not a table. There is no <thead>, the three columns are unlabelled,
   and nobody compares row to row: it is a feed. So it is a list now, and a
   screen reader announces it as one.

   The geometry is copied from .skeleton-row deliberately, not approximately:
   same flex row, same 32px leading circle, same two stacked lines, same
   spacing-3 gap and spacing-3/spacing-4 padding. The skeleton is a promise
   about how much room the answer needs (FPFM-46) and this is the answer
   keeping it, so the card does not resize when the data lands.

   Wrapping, not truncating: a long feast name takes a second line rather
   than an ellipsis. Nothing on a phone may be hidden, and a half-named feast
   is hidden information. */

.txn-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

.txn-row {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    padding: var(--spacing-3) var(--spacing-4);
}

.txn-row + .txn-row {
    border-top: 1px solid var(--gray-700);
}

/* Rides on .stat-card-icon for the green/red tint, and puts back the three
   things that class sets for a stat card and not for a list row. */
.txn-row__icon {
    width: 32px;
    height: 32px;
    flex-shrink: 0;
    margin-bottom: 0;
}

.txn-row__icon svg {
    width: 16px;
    height: 16px;
    stroke: currentColor;
    fill: none;
    stroke-width: 2;
}

/* min-width: 0 is load-bearing. A flex item defaults to min-width:auto and
   refuses to shrink below its content, so without it a long feast name
   pushes the amount off the row instead of wrapping. */
.txn-row__body {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.txn-row__title {
    margin: 0;
    font-size: var(--font-size-sm);
    font-weight: 500;
    line-height: 1.3;
    color: var(--secondary);
    overflow-wrap: anywhere;
}

.txn-row__meta {
    margin: 0;
    font-size: 12px;
    line-height: 1.3;
    color: var(--gray-400);
    overflow-wrap: anywhere;
}

/* Never shrinks and never wraps: the amount is why the card exists. */
.txn-row__amount {
    flex: 0 0 auto;
    align-self: center;
    font-size: var(--font-size-sm);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* ============================================
   BUDGET PAGE — PHONE (budget.html only)  FPFM-79
   ============================================
   Three separate problems, kept separate. Everything that changes an existing
   screen lives inside @media (max-width: 767px), so desktop is unchanged by
   construction — except the empty states at the bottom of this block, which
   are deliberately shared with desktop and are the one intended desktop diff.

   767, not 768 — the sidebar split earlier in this file is 767/768 and a 768
   bound here would overlap it by a pixel (FPFM-67).

   budget.html carries NO inline <style> block, unlike 15 other pages, so this
   is the correct home for its rules. */

/* The phone list is a phone thing. Above the breakpoint it does not exist.
   Turned back on below by #budgetPhoneList, an ID — so the cascade is decided
   by specificity rather than by which rule happens to be written last. */
.bdg-list {
    display: none;
    list-style: none;
    margin: 0;
    padding: 0;
}

/* Destructive, and off on desktop where the table's own trash button lives.
   `display: none` here rather than relying on the element's `hidden`
   attribute: .btn sets `display: inline-flex`, and an author rule beats the
   UA sheet's `[hidden] { display: none }`, so `hidden` alone does NOT hide a
   .btn. The phone rule below re-reads the attribute to honour it. */
.bdg-modal-delete {
    display: none;
}

@media (max-width: 767px) {

    /* --- PROBLEM 1: Budget Period control widths ---------------------
       #periodMonth and #feastSelect both carry an inline `width: auto`, and
       the select an inline `min-width: 160px`. An inline style beats any
       selector, so the only thing a stylesheet can do is !important — used
       here for exactly that reason and nowhere else in this block. Deleting
       the inline attributes was the alternative and was rejected: they ARE
       the desktop layout (two shrink-to-fit controls on one line beside the
       heading), and removing them would inherit .form-control's base
       `width: 100%` on desktop too.

       Addressed by id, not by the `.d-flex.gap-2.align-center` utilities the
       div also carries — those serve 16 other elements across the app. */
    #budgetPeriodControls {
        flex-direction: column;
        align-items: stretch;
        width: 100%;
        gap: var(--spacing-2);
    }

    #budgetPeriodControls > #periodMonth,
    #budgetPeriodControls > #feastSelect {
        width: 100% !important;
        min-width: 0 !important;
        min-height: 48px;
        box-sizing: border-box;
    }

    /* --- PROBLEM 2: Budget by Category on a phone --------------------
       The table is six columns inside .table-responsive, so on a phone it is
       a sideways-scrolling strip. It is swapped for a list, not restyled —
       .table-responsive appears 95 times across 21 pages, so the hide is
       scoped to this one table's id. */
    #budgetCatTable {
        display: none;
    }

    #budgetPhoneList {
        display: block;
    }

    /* Same vocabulary as .txn-row on the dashboard, which is this app's
       shipped phone list: same spacing-4 side padding, same gray-700 hairline
       between items, same tabular figures, same overflow-wrap. Tighter
       vertically, because a budget row is two lines and a txn-row is one. */
    .bdg-item {
        display: flex;
        flex-wrap: wrap;
        align-items: baseline;
        gap: var(--spacing-1) var(--spacing-2);
        padding: var(--spacing-2) var(--spacing-4) var(--spacing-3);
        min-height: 44px;
    }

    .bdg-item + .bdg-item {
        border-top: 1px solid var(--gray-700);
    }

    /* --- The row's own actions (FPFM-86) -----------------------------
       This REPLACES `.bdg-item__tap`, a transparent button stretched inset:0
       over the whole item. That overlay is gone and is not coming back:
       CLAUDE.md section 8 now forbids an invisible whole-row tap target
       outright, because a control the reader cannot see cannot be labelled
       and cannot be avoided. `position: relative` came off `.bdg-item` in
       the same breath — nothing is absolutely positioned inside it now.

       With Edit visible and named, Delete can sit beside it rather than
       hiding in the edit modal: the trap FPFM-79 was avoiding was a
       destructive control inside a tap zone whose whole point was Edit, and
       there is no tap zone any more. Same arrangement attendance already
       ships (.atn-card__actions), deliberately, so the two pages read alike. */
    .bdg-item__actions {
        flex: 1 1 100%;
        display: flex;
        gap: var(--spacing-2);
        margin-top: var(--spacing-2);
    }

    /* 44px spelled out, NOT delegated to .btn-touch: the duplicate `.btn`
       later in this file re-declares min-height:40px at equal specificity and
       wins on source order, so .btn-touch's 44px never lands. That duplicate
       is logged separately and is deliberately not touched here.
       Both actions are outlined so neither is the loudest thing in a list. */
    .bdg-item__btn {
        flex: 1 1 0;
        min-width: 0;
        min-height: 44px;
        background-color: transparent;
        color: var(--secondary);
        border: 1px solid var(--gray-700);
        border-radius: var(--radius-md);
    }

    /* Destructive: outlined red, never filled, and second so it is furthest
       from where the thumb lands on Edit. A modifier rather than the shared
       .btn-danger-outline, and that is a cascade decision rather than a
       stylistic one: .btn-danger-outline is a base rule far above this block,
       so .bdg-item__btn's own `color` and `border` — equal specificity, later
       in source — would defeat it. Written after .bdg-item__btn, this wins.
       Same two tokens either way: --error and --error-light are the pair that
       render safely in both themes. */
    .bdg-item__btn--del {
        color: var(--error);
        border-color: var(--error);
    }

    .bdg-item__btn--del:hover:not(:disabled) {
        background-color: var(--error-light);
    }

    /* min-width: 0 is load-bearing — a flex item defaults to min-width:auto
       and refuses to shrink below its content, so without it a long category
       name pushes the variance off the row instead of wrapping. */
    .bdg-item__name {
        flex: 1 1 auto;
        min-width: 0;
        font-size: var(--font-size-sm);
        font-weight: 500;
        line-height: 1.3;
        color: var(--secondary);
        overflow-wrap: anywhere;
    }

    .bdg-item__var {
        flex: 0 0 auto;
        font-size: var(--font-size-sm);
        font-weight: 600;
        font-variant-numeric: tabular-nums;
        white-space: nowrap;
    }

    /* Line two. flex-basis 100% forces the wrap after name + variance, so the
       row is exactly two lines whatever the figures are. */
    .bdg-item__meter {
        flex: 1 1 100%;
        display: flex;
        align-items: center;
        gap: var(--spacing-2);
        min-width: 0;
    }

    /* Budget and actual sit at each end of the bar, in source order, and the
       word Budget/Actual is deliberately not printed: at 390px the two
       figures either side of a bar are unambiguous, and the labels are what
       would stop this row being compact. */
    .bdg-item__budget,
    .bdg-item__actual {
        flex: 0 0 auto;
        font-size: 12px;
        line-height: 1.3;
        color: var(--gray-400);
        font-variant-numeric: tabular-nums;
        white-space: nowrap;
    }

    /* A tint of --info, not a grey. --gray-600 and --gray-700 are both
       redeclared in the dark block and --gray-800 IS the card background
       there, so a grey track is invisible in dark theme on exactly the screen
       this exists for. --info and --error are not redeclared in dark. */
    .bdg-item__track {
        /* display:block is load-bearing. Both the track and its fill are
           <span>, and a percentage height on a child of an INLINE box
           resolves against nothing — which renders every bar as an empty grey
           track with no fill at all. */
        display: block;
        flex: 1 1 auto;
        min-width: 0;
        height: 4px;
        border-radius: 999px;
        background: rgba(148, 163, 184, 0.28);
        overflow: hidden;
    }

    .bdg-item__fill {
        display: block;
        height: 100%;
        border-radius: 999px;
    }

    /* --- The phone's route to deleting a budget ----------------------
       Re-reads [hidden] so the JS toggle still governs: this only paints when
       a budget is actually being edited. Outlined red, never filled, and
       pushed to the far end of the footer by margin-right:auto so it is not a
       thumb's width from Save. The treatment is the one FPFM-78 shipped for
       Remove in Manage Pockets — --error on transparent, an explicit height
       rather than .btn-touch, and --error/--error-light are two tokens that
       render safely in both themes. */
    .bdg-modal-delete:not([hidden]) {
        display: inline-flex;
        margin-right: auto;
        height: 44px;
        padding: var(--spacing-2) var(--spacing-4);
        background-color: transparent;
        color: var(--error);
        border: 1px solid var(--error);
        border-radius: var(--radius-md);
    }

    .bdg-modal-delete:not([hidden]):hover:not(:disabled) {
        background-color: var(--error-light);
        filter: none;
    }

}

/* --- PROBLEM 3: empty states ----------------------------------------
   Reuses the app's shipped .empty-state / .empty-state-title /
   .empty-state-text, so this is one new class and it is for the action.

   OUTSIDE the media query on purpose: the empty state renders on desktop too,
   and this is the bounded, intended desktop change for FPFM-79. A button that
   only has a height on a phone is the defect this is avoiding. The height is
   declared here and does not come from .btn-touch, for the duplicate-.btn
   reason given above. */
.empty-state-action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    min-width: 44px;
    padding: 0 var(--spacing-5);
}

/* --- FPFM-83: the empty state action is a PHONE action ---------------------
   #sessionsEmpty holds a second Record Session button, directly under the one
   in the page header. On a desktop that is pure redundancy: the header button
   is in view the whole time the empty state is. On a phone it is not -- once
   the empty state is scrolled into view at 390px the header button's top is
   ABOVE the viewport (measured at -312 in
   docs/evidence/FPFM-83-width-measurements.txt, and separately at -303 in
   docs/evidence/FPFM-83-phone-measurement.txt, which scrolls differently), so
   the empty-state button is the only action the user can see. The exact figure
   moves with how far you scroll; that it is off the top does not.

   So it is hidden above the breakpoint and left alone below it. Presentation
   only -- the button stays in attendance.html, keeps its `edit-only` gate, and
   nothing about the phone rendering changes.

   768 because that is the pair this page already splits on: the FPFM-81 phone
   list and the FPFM-82 filter sheet both live under @media (max-width: 767px),
   so the button now appears exactly where the phone layout does and nowhere
   else.

   Scoped by the id, NOT by .empty-state-action alone. js/budget.js writes that
   same class onto its own "Choose a feast" button, and on budget.html that
   button is the only action on the screen -- a class-wide rule would delete it
   at desktop width. #sessionsEmpty exists once, in attendance.html, so budget
   is out of reach by construction rather than by luck.
   (docs/evidence/FPFM-83-budget-determination.txt measures both pages.) */
@media (min-width: 768px) {
    #sessionsEmpty .empty-state-action {
        display: none;
    }
}

/* ==========================================================================
   ATTENDANCE PAGE — PHONE (attendance.html only)  FPFM-81
   ==========================================================================
   attendance.html carries NO inline <style> block — checked, it is not one of
   the 15 pages that do — so this file is the correct home for its rules.

   Every selector below is new: `atn-` and `attendanceFilters` returned zero
   matches across css/, *.html and js/ before this block was written. The one
   class reused rather than redeclared is `.empty-state-action`, which FPFM-79
   already shipped further up this file. */

/* The phone list is a phone thing. Above the breakpoint it does not exist.
   Turned back on below by #sessionsPhoneList, an ID — so the cascade is
   decided by specificity rather than by which rule is written last. */
.atn-list {
    display: none;
    list-style: none;
    margin: 0;
    padding: 0;
}

@media (max-width: 767px) {

    /* --- PART 1: the four filter controls — GONE, and deliberately ---------
       FPFM-82 laid the filter row out here by id: one column, a 12px gap,
       48px on every field, a border on Clear. FPFM-94 moved all of it into
       the shared `.fsheet__*` block at the end of this file, which does the
       same four things for all four pages that use the sheet — and left this
       copy behind.

       That leftover was a live defect, not dead weight. `#attendanceFilters`
       is (1,0,0); the shared `.fsheet__cb:checked ~ .fsheet__panel
       .fsheet__sheet .grid` is (0,4,0). Both sit inside `max-width: 767px`,
       so the id won on specificity and no source ordering could have
       rescued it: attendance's fields sat 12px apart while the other three
       sat 16px. The remaining three declarations were inert rather than
       wrong — the shared rule turns the container into a flex column, so
       `grid-template-columns` had nothing to act on; `.form-group` is a
       block, so `align-items` had nothing to act on; and there is no `.btn`
       inside this div any more at all, because Clear now renders in
       `.fsheet__actions` outside it.

       NOTHING ABOVE 767px IS AFFECTED: every one of the four declarations
       was inside this media query, so above the breakpoint they had never
       applied. Width, box-sizing and the 48px floor are all still declared,
       once, by `.fsheet__sheet .form-input, .fsheet__sheet .form-select`.

       The lesson worth keeping: when a per-page block is extracted into a
       shared one, an ID selector left behind in the SAME media band is not
       redundant, it is an override. Measured by tools/fpfm94-measure.mjs,
       which now reads the inter-field gap out of Chrome.

       --- PART 2: the ten-column table becomes a list ----------------------
       Measured before: the table is 600px of content in a 324px box, and four
       of the ten columns — First Timers, Love Offering, Topic/Speaker and
       Actions — are off-screen entirely. Swapped for a list, not restyled.
       Scoped to this table's id: `.table-stable` and `.table-responsive` are
       shared across the app. */
    #sessionsTable {
        display: none;
    }

    #sessionsPhoneList {
        display: block;
    }

    .atn-card + .atn-card {
        border-top: 1px solid var(--gray-700);
    }

    /* --- PART 3: one card per session -------------------------------------
       Keeps every column the desktop table has, at the cost of height: about
       three sessions per screen. A compact two-line row was previewed beside
       it and NOT chosen -- it could carry neither Servants, Kids nor
       Topic/Speaker at 390px. Nothing of it survives in this file. */
    .atn-card {
        position: relative;
        padding: var(--spacing-3) var(--spacing-4);
    }

    .atn-card__head {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: var(--spacing-2);
        margin-bottom: var(--spacing-1);
    }

    .atn-card__date {
        min-width: 0;
        font-size: var(--font-size-sm);
        font-weight: 600;
        line-height: 1.3;
        color: var(--secondary);
    }

    .atn-card__feast {
        margin: 0 0 var(--spacing-2);
        font-size: 12px;
        line-height: 1.3;
        color: var(--gray-400);
        overflow-wrap: anywhere;
    }

    /* Two columns rather than a wrapping row: five figures with labels of very
       different lengths do not form a readable ragged row, and a fixed grid
       also means a card is the same height whatever the numbers are. */
    .atn-card__figs {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: var(--spacing-1) var(--spacing-3);
        margin-bottom: var(--spacing-2);
    }

    .atn-card__fig {
        display: flex;
        align-items: baseline;
        justify-content: space-between;
        gap: var(--spacing-2);
        min-width: 0;
    }

    .atn-card__fig-k {
        font-size: 11px;
        line-height: 1.4;
        color: var(--gray-400);
        white-space: nowrap;
    }

    .atn-card__fig-v {
        font-size: 12px;
        font-weight: 600;
        line-height: 1.4;
        color: var(--secondary);
        font-variant-numeric: tabular-nums;
        white-space: nowrap;
    }

    .atn-card__topic {
        margin: 0 0 var(--spacing-3);
        font-size: 12px;
        line-height: 1.4;
        color: var(--gray-400);
        overflow-wrap: anywhere;
    }

    .atn-card__actions {
        display: flex;
        gap: var(--spacing-2);
    }

    /* Explicit height again, not .btn-touch. Both actions are outlined rather
       than filled so neither is the loudest thing in a list of them. */
    .atn-card__btn {
        flex: 1 1 0;
        min-height: 44px;
        background-color: transparent;
        color: var(--secondary);
        border: 1px solid var(--gray-700);
        border-radius: var(--radius-md);
    }

    /* Destructive: outlined red, never filled, and second so it is furthest
       from the thumb's resting position after Edit. --error and --error-light
       are the two tokens that render safely in both themes. */
    .atn-card__btn--del {
        color: var(--error);
        border-color: var(--error);
    }

    .atn-card__btn--del:hover:not(:disabled) {
        background-color: var(--error-light);
        filter: none;
    }

}

/* ============================================================
   FPFM-99 — THE MOBILE SHELL, STAGE 1. A PLACE YOU VISIT.
   ============================================================

   Every selector in this block begins `mshl-` and every one of them was
   grep-proven absent from the repo before it was written. That is not
   housekeeping: a preview patch built against this app once reused a class
   name that an unrelated modal already owned, and it would have broken a
   button on every phone in production. A collision here is a build failure.

   NOTHING BELOW LEAKS, AND NOW THERE IS NOTHING THAT COULD. Every selector
   here exists only on mobile.html. FPFM-98’s `.mshl-notice` was the single
   exception — the one rule that could paint on an ordinary page — and it is
   gone with the flag that was its only reason to exist.

   NO ONE-EDGE COLOUR STRIPES (FPFM-95). Severity is carried by the word in
   `.mshl-sev`, never by a coloured border on one side of a card.

   44px TAP TARGETS ARE DECLARED, NEVER INHERITED. css/styles.css declares a
   bare `.btn` four times at equal specificity and source order alone decides
   which wins, so a desktop browser narrowed to phone width silently gets 40px
   where a real phone gets 44px. Nothing here leans on that cascade.
   ============================================================ */

.mshl-body {
    /* The shell owns the viewport. The app's sidebar and mobile header still
       BOOT on this page — same chrome, same order — they are simply not shown,
       so this is a skin over the app rather than a second application. */
    overflow: hidden;
}

.mshl-body .sidebar,
.mshl-body .mobile-header,
.mshl-body .sidebar-overlay,
.mshl-body .sidebar-edge-toggle {
    display: none !important;
}

/* The shell's render target is the page's own <main class="main-content"
   id="mainContent">, kept to that exact spelling because
   tests/navigation-blink.test.js requires every app page to carry it — the
   rel=expect contract in <head> points at it, and a rel=expect aimed at
   nothing releases at the END of parse rather than before it. So the shell is
   selected through the body class instead of by adding one to the element. */
.mshl-body .main-content {
    position: fixed;
    inset: 0;
    /* The app keeps its own fixed bars above everything (View As; the staging
       banner). reserveAppBars() in js/mobile-shell.js measures them and writes
       this, so the shell starts BELOW them instead of underneath them. */
    top: var(--mshl-app-top, 0px);
    /* min-height: 100vh comes from the app's own .main-content and OVER-CONSTRAINS
       this box: with top, bottom and a height all set, the browser drops the
       bottom. Measured, not reasoned about -- the scroll region computed a
       bottom of 884 in an 844px viewport, so its last 40px, and with it the
       last card, sat under the tab bar. Both are reset here so top and bottom
       govern the height. */
    height: auto;
    min-height: 0;
    margin-left: 0;
    padding: 0;
    margin: 0;
    max-width: none;
    background: var(--bg-dark);
    display: flex;
    flex-direction: column;
    /* A flex item's automatic minimum is min-content, which lets one wide
       child blow the whole document open at phone width. */
    min-width: 0;
}

.mshl-scroll {
    flex: 1 1 auto;
    min-height: 0;
    min-width: 0;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    padding: var(--spacing-4);
    /* The tab bar's declared 64px plus the home bar, so the last card is
       never trapped under the bar. */
    padding-bottom: calc(64px + var(--spacing-6) + env(safe-area-inset-bottom, 0px));
    display: flex;
    flex-direction: column;
    gap: var(--spacing-4);
}

/* EVERY CHILD OF THE SCROLL REGION REFUSES TO SHRINK.
   .mshl-scroll is a column flex container, and a flex item's default is
   flex-shrink: 1 — so with more content than fits, the browser CRUSHES the
   children rather than scrolling them. Measured, not guessed: the More index's
   first group computed 4.67px tall while its own first row inside it computed
   64px, and  on the group then clipped all 31 rows out of
   sight. The screen rendered as seven empty outlines. It was the duplicate-hash
   check in tools/fpfm98-shots.mjs that surfaced it — two different More
   captures came out byte-identical because there was nothing to scroll. */
.mshl-scroll > * { flex: 0 0 auto; }

.mshl-loading {
    color: var(--gray-500);
    font-size: var(--font-size-sm);
    padding: var(--spacing-6) 0;
}

/* ---------- the simulated-persona strip ----------
   NOT part of the design. It exists so no capture of this build can be
   mistaken for a real officer's screen. Drawn only while the app's own
   View As is active, so a real user never sees it. */
.mshl-sim {
    flex: 0 0 auto;
    background: var(--warning);
    color: #111827;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: uppercase;
    padding: 6px var(--spacing-4);
    padding-top: calc(6px + env(safe-area-inset-top, 0px));
    text-align: center;
}

/* ---------- top chrome ---------- */

.mshl-top {
    flex: 0 0 auto;
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--spacing-3);
    padding: var(--spacing-4);
    padding-top: calc(var(--spacing-4) + env(safe-area-inset-top, 0px));
    border-bottom: 1px solid var(--gray-200);
    background: var(--bg-card);
}

.mshl-body.mshl-simulated .mshl-top { padding-top: var(--spacing-4); }

.mshl-top__id { min-width: 0; }

.mshl-top__sub,
.mshl-top__role {
    margin: 0;
    font-size: var(--font-size-xs);
    color: var(--gray-500);
}

.mshl-top__scope {
    margin: 2px 0;
    font-size: var(--font-size-xl);
    font-weight: 700;
    line-height: 1.2;
    color: var(--secondary);
    overflow-wrap: anywhere;
}

.mshl-top__btn {
    position: relative;
    flex: 0 0 auto;
    width: 44px;
    height: 44px;
    min-width: 44px;
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-lg);
    background: var(--bg-card);
    color: var(--secondary);
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

.mshl-top__badge {
    position: absolute;
    top: -6px;
    right: -6px;
    min-width: 20px;
    height: 20px;
    padding: 0 5px;
    border-radius: 10px;
    background: var(--primary);
    color: #fff;
    font-size: 11px;
    font-weight: 700;
    line-height: 20px;
    text-align: center;
}

/* A count that could not be read is a dash, not a zero. It must not look
   like an alert, because "unknown" is not "nothing waiting". */
.mshl-top__badge--unknown {
    background: var(--gray-200);
    color: var(--gray-400);
}

.mshl-i { width: 20px; height: 20px; }

/* ---------- hero ---------- */

.mshl-hero {
    background: var(--bg-card);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-xl);
    padding: var(--spacing-5);
}

.mshl-hero__label {
    margin: 0;
    font-size: var(--font-size-xs);
    font-weight: 600;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--gray-500);
}

.mshl-hero__amount {
    margin: var(--spacing-2) 0 var(--spacing-1);
    font-size: var(--font-size-3xl);
    font-weight: 800;
    line-height: 1.1;
    color: var(--secondary);
    overflow-wrap: anywhere;
}

.mshl-hero__amount.is-neg { color: var(--error); }
.mshl-hero__amount--unknown { color: var(--gray-400); }

.mshl-hero__sub {
    margin: 0;
    font-size: var(--font-size-sm);
    color: var(--gray-400);
    line-height: 1.45;
}

.mshl-hero__split {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
    gap: var(--spacing-3);
    margin: var(--spacing-4) 0 0;
}

.mshl-hero__pocket { min-width: 0; }

.mshl-hero__pocket dt {
    font-size: var(--font-size-xs);
    color: var(--gray-500);
    overflow-wrap: anywhere;
}

.mshl-hero__pocket dd {
    margin: 2px 0 0;
    font-size: var(--font-size-base);
    font-weight: 700;
    color: var(--secondary);
    overflow-wrap: anywhere;
}

/* ---------- the head's opening note (no balance, deliberately) ---------- */

.mshl-headnote {
    background: var(--bg-card);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-xl);
    padding: var(--spacing-5);
}

.mshl-headnote__t {
    margin: 0 0 var(--spacing-2);
    font-size: var(--font-size-lg);
    font-weight: 700;
    color: var(--secondary);
}

.mshl-headnote__p {
    margin: 0;
    font-size: var(--font-size-sm);
    color: var(--gray-400);
    line-height: 1.5;
}

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

.mshl-card {
    background: var(--bg-card);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-xl);
    padding: var(--spacing-4);
    min-width: 0;
}

.mshl-card__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-2);
    margin-bottom: var(--spacing-3);
}

.mshl-card__title {
    margin: 0;
    font-size: var(--font-size-base);
    font-weight: 700;
    color: var(--secondary);
}

.mshl-card__link {
    display: inline-flex;
    align-items: center;
    gap: 2px;
    min-height: 44px;
    padding: 0 var(--spacing-2);
    border: 0;
    background: none;
    color: var(--primary);
    font-size: var(--font-size-sm);
    font-weight: 600;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

.mshl-card__link .mshl-i { width: 14px; height: 14px; }

.mshl-card__big {
    margin: 0 0 2px;
    font-size: var(--font-size-2xl);
    font-weight: 800;
    color: var(--secondary);
}

.mshl-card__note {
    margin: 0;
    font-size: var(--font-size-sm);
    color: var(--gray-400);
    line-height: 1.5;
}

/* TWO NOTES IN ONE CARD NEED A GAP BETWEEN THEM.                     FPFM-108
   `margin: 0` above is right for the single-note card, which is every card the
   shell drew until this ticket. FPFM-108 adds two that carry a second
   paragraph -- the 'recording is done on the parent's own page' card and the
   failed-tree-read card -- and without this they run together into one block of
   text with no paragraph break, which is exactly the case where the break is
   carrying the meaning: the first paragraph says why the control is absent and
   the second says what to do instead.

   An adjacent-sibling rule rather than a margin on every note, so the
   single-note cards keep the spacing they were designed with and nothing that
   already ships moves. `.mshl-ovw .mshl-card__note` sets its own margin-top
   and is more specific, so it still wins where it applies. */
.mshl-card__note + .mshl-card__note {
    margin-top: var(--spacing-3);
}

.mshl-pair {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--spacing-3);
    margin-bottom: var(--spacing-3);
}

.mshl-pair__half { min-width: 0; }

.mshl-pair__label {
    margin: 0;
    font-size: var(--font-size-xs);
    color: var(--gray-500);
}

.mshl-pair__v {
    margin: 2px 0 0;
    font-size: var(--font-size-lg);
    font-weight: 700;
    overflow-wrap: anywhere;
}

.mshl-pair__v.is-in { color: var(--success); }
.mshl-pair__v.is-out { color: var(--error); }

/* ---------- exceptions (head) ---------- */

.mshl-alerts {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: var(--spacing-2);
}

.mshl-alert {
    width: 100%;
    display: grid;
    grid-template-columns: auto 1fr auto;
    align-items: center;
    gap: var(--spacing-3);
    min-height: 56px;
    padding: var(--spacing-3);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-lg);
    background: var(--bg-surface);
    text-align: left;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

.mshl-alert__n {
    font-size: var(--font-size-2xl);
    font-weight: 800;
    color: var(--secondary);
    min-width: 1.5ch;
    text-align: center;
}

.mshl-alert__body { min-width: 0; display: block; }

.mshl-alert__t {
    display: block;
    font-size: var(--font-size-sm);
    font-weight: 600;
    color: var(--secondary);
    line-height: 1.35;
}

.mshl-alert__s { display: block; margin-top: 4px; }

.mshl-alert__chev { color: var(--gray-500); display: inline-flex; }
.mshl-alert__chev .mshl-i { width: 16px; height: 16px; }

/* Severity is a WORD in a chip, not a coloured edge. FPFM-95. */
.mshl-sev {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 999px;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: .02em;
}

.mshl-sev--bad { background: var(--error-light); color: #991B1B; }
.mshl-sev--warn { background: var(--warning-light); color: #92400E; }
.mshl-sev--info { background: var(--info-light); color: #1E40AF; }

/* ---------- honest stubs ---------- */

.mshl-stub {
    background: var(--bg-card);
    border: 1px dashed var(--gray-300);
    border-radius: var(--radius-xl);
    padding: var(--spacing-5);
}

.mshl-stub__flag {
    margin: 0 0 var(--spacing-2);
    display: inline-block;
    padding: 3px 10px;
    border-radius: 999px;
    background: var(--gray-100);
    color: var(--gray-400);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: .06em;
    text-transform: uppercase;
}

.mshl-stub__t {
    margin: 0 0 var(--spacing-2);
    font-size: var(--font-size-xl);
    font-weight: 700;
    color: var(--secondary);
}

.mshl-stub__p {
    margin: 0 0 var(--spacing-3);
    font-size: var(--font-size-sm);
    color: var(--gray-400);
    line-height: 1.55;
}

.mshl-stub__p--strong { color: var(--secondary); font-weight: 500; }

.mshl-stub__links {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-2);
}

.mshl-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: 44px;
    padding: 0 var(--spacing-4);
    border-radius: var(--radius-lg);
    font-size: var(--font-size-sm);
    font-weight: 600;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

.mshl-btn--outline {
    border: 1px solid var(--gray-300);
    background: var(--bg-card);
    color: var(--secondary);
}

/* ---------- More: the index of every page ---------- */

.mshl-grp {
    list-style: none;
    margin: 0;
    padding: 0;
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-xl);
    overflow: hidden;
    background: var(--bg-card);
}

.mshl-grp--exit { border-color: var(--primary); }

.mshl-grp__t {
    margin: var(--spacing-3) 0 var(--spacing-2);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: .07em;
    text-transform: uppercase;
    color: var(--gray-500);
}

.mshl-grp li + li .mshl-row { border-top: 1px solid var(--gray-200); }

.mshl-row {
    width: 100%;
    display: grid;
    grid-template-columns: 1fr auto auto;
    align-items: center;
    gap: var(--spacing-2);
    min-height: 52px;
    padding: var(--spacing-3) var(--spacing-4);
    border: 0;
    background: none;
    text-align: left;
    color: var(--secondary);
    font-size: var(--font-size-sm);
    font-weight: 500;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

.mshl-row__t { min-width: 0; overflow-wrap: anywhere; }

.mshl-row__chev { color: var(--gray-500); display: inline-flex; }
.mshl-row__chev .mshl-i { width: 16px; height: 16px; }

/* Out of scope is SHOWN, greyed, and says why in words. Hiding it teaches the
   reader the app is smaller than it is; this teaches them who to ask. */
.mshl-row.is-locked {
    color: var(--gray-500);
    cursor: not-allowed;
}

.mshl-row__lock {
    font-size: 11px;
    font-weight: 600;
    color: var(--gray-500);
    white-space: nowrap;
}

.mshl-row--exit {
    grid-template-columns: auto 1fr;
    grid-template-rows: auto auto;
    row-gap: 2px;
    color: var(--primary);
    font-weight: 700;
    min-height: 64px;
}

.mshl-row--exit .mshl-row__ico { grid-row: 1 / span 2; display: inline-flex; }
.mshl-row--exit .mshl-row__t { grid-column: 2; }

.mshl-row__sub {
    grid-column: 2;
    font-size: var(--font-size-xs);
    font-weight: 400;
    color: var(--gray-400);
}

.mshl-foot {
    margin: var(--spacing-4) 0 0;
    font-size: var(--font-size-xs);
    color: var(--gray-500);
    line-height: 1.5;
}

/* ---------- tab bar ---------- */

.mshl-tabs {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 1100;
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    height: 64px;
    padding-bottom: env(safe-area-inset-bottom, 0px);
    box-sizing: content-box;
    border-top: 1px solid var(--gray-200);
    background: var(--bg-card);
}

.mshl-tab {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 3px;
    min-width: 0;
    min-height: 44px;
    height: 100%;
    border: 0;
    background: none;
    color: var(--gray-500);
    font-family: inherit;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

.mshl-tab.is-on { color: var(--primary); }

.mshl-tab__label {
    font-size: 11px;
    font-weight: 600;
    line-height: 1;
}

/* The centre slot: Record for an officer, Review for a head. One disc, and
   deliberately no badge on it — the approvals count has three homes already
   and a number on a write button reads as a queue you are about to add to. */
.mshl-tab--centre .mshl-tab__disc {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    border-radius: 999px;
    background: var(--primary);
    color: #fff;
    margin-top: -14px;
    box-shadow: var(--shadow-md);
}

.mshl-tab--centre .mshl-i { width: 22px; height: 22px; }

/* ============================================================
   FPFM-99 — THE HOME SCREENS, BUILT TO THE APPROVED PICTURE.
   ============================================================

   STILL INSIDE THE OPT-IN PATH, AND STILL INSIDE `mshl-`. Every selector below
   begins `mshl-`, every one of them was grep-proven absent from the repo before
   it was written, and every rule that is not a new selector is scoped by
   `.mshl-top--hero` or `.mshl-body` — so nothing here can reach a page a
   non-opted-in user ever loads. Nobody who did not choose this sees a pixel of
   it.

   WHAT THIS BLOCK IS FOR. FPFM-98 shipped a home screen thinner than the one
   Renzo approved as an image: a white card where the design has a full-bleed
   red block, no quick actions at all, "This month" over a named month, and no
   net, no tithes and no recency. The rules below are that picture, and where a
   value is not the picture's it is because the picture could not be honestly
   built at that point and the report says which.

   THE RED RUNS FROM THE STATUS BAR TO THE BOTTOM OF THE POCKET TILES. It is
   drawn by TWO elements, not one — `.mshl-top--hero` (a flex sibling, outside
   the scroll region) and `.mshl-hero` (the scroll region's first child) — and
   they meet only because the hero's negative top margin cancels the scroll
   region's padding exactly. That join is the thing most likely to break
   silently on a token change, so it is MEASURED rather than trusted:
   tools/fpfm99-measure.mjs asserts the two boxes share an edge to the pixel and
   that both compute the same background colour.

   NO ONE-EDGE COLOUR STRIPES (FPFM-95). The sub-cards below carry a full
   border on all four sides and their meaning in a label, never a coloured edge.
   ============================================================ */

/* The picture's page is light grey behind white cards; `--bg-dark` is #ffffff
   in this theme, so the shipped shell drew white cards on a white page and the
   card edges were doing all the work. */
.mshl-body .main-content { background: var(--bg-surface); }

/* ---------- the red top block ---------- */

.mshl-top--hero {
    background: var(--primary);
    border-bottom: 0;
    color: #fff;
    align-items: flex-start;
    padding-bottom: var(--spacing-2);
}

.mshl-top--hero .mshl-top__scope,
.mshl-top--hero .mshl-top__sub { color: #fff; }

/* The region line and the funds caption are the same weight of statement, so
   they are the same treatment: white at reduced opacity rather than a grey,
   which on red reads as a disabled control. */
.mshl-top--hero .mshl-top__sub { opacity: .85; }

/* The role is a PILL on the red, not grey text under the title. It is a fact
   about the reader, not a caption on the feast name above it, and at 85%
   opacity under a bold white heading it read as a subtitle of the heading. */
.mshl-top__pill {
    display: inline-block;
    margin: var(--spacing-2) 0 0;
    padding: 3px 10px;
    border-radius: 999px;
    background: rgba(255, 255, 255, .18);
    color: #fff;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: .02em;
}

.mshl-top__actions {
    display: flex;
    flex: 0 0 auto;
    gap: var(--spacing-2);
}

/* Circular, translucent, on the red. 44px is DECLARED here and not inherited —
   the same reason the block above this one gives: css/styles.css declares a
   bare `.btn` four times at equal specificity and source order decides. */
.mshl-top--hero .mshl-top__btn {
    width: 44px;
    height: 44px;
    min-width: 44px;
    min-height: 44px;
    border: 0;
    border-radius: 999px;
    background: rgba(255, 255, 255, .16);
    color: #fff;
}

/* THE TWO TOP-BAR BUTTONS ARE NAMED, and both names are declared here rather
   than left as bare hooks for the tests. Until this ticket the bell was the
   only button in that bar, so `.mshl-top__btn` identified it; it now describes
   a shape that two different controls share. The bell needs the badge's
   positioning context and nothing else; the account button needs nothing
   beyond the shape — it is declared all the same, because a class the shell
   emits and the stylesheet does not know about is exactly what section 8 of
   tests/mobile-shell-structure.test.js exists to catch, and an undeclared hook
   is one rename away from being silently dead. */
.mshl-top__btn--bell { position: relative; }
.mshl-top__btn--account { position: relative; }

/* Amber on red, with a ring in the block's own colour so the badge reads as
   sitting ON the button rather than punched through it. Red-on-red would be
   invisible, which is what the shipped `--primary` badge computes to here. */
.mshl-top--hero .mshl-top__badge {
    top: -2px;
    right: -2px;
    background: #FBBF24;
    color: #4A2C00;
    border: 2px solid var(--primary);
    line-height: 16px;
}

.mshl-top--hero .mshl-top__badge--unknown {
    background: rgba(255, 255, 255, .3);
    color: #fff;
}

/* ---------- the hero, full-bleed ---------- */

/* FULL-BLEED, AND THE FOUR OFFSETS ARE NOT INTERCHANGEABLE.
   `.mshl-scroll` pads all four sides by --spacing-4 and lays its children out
   in a column with a --spacing-4 gap. To reach the viewport edges the hero
   cancels the horizontal padding with a negative margin and re-adds it as its
   own padding, so the TEXT keeps the same gutter every card has while the
   BACKGROUND runs edge to edge. The negative top margin cancels the top
   padding and nothing else — that is what puts this box's top edge exactly on
   the red bar's bottom edge. */
.mshl-hero {
    background: var(--primary);
    border: 0;
    border-radius: 0;
    color: #fff;
    margin: calc(var(--spacing-4) * -1) calc(var(--spacing-4) * -1) 0;
    padding: 0 var(--spacing-4) var(--spacing-8);
}

.mshl-hero__label { color: #fff; opacity: .8; }

.mshl-hero__amount {
    color: #fff;
    font-size: 40px;
    letter-spacing: -.03em;
    margin: var(--spacing-1) 0 0;
}

/* A negative balance on red cannot be red. It is a pale tint of the block's
   own colour, and the minus sign in front of the figure is what actually
   carries the meaning — FPFM-95, meaning goes in the glyph, not the hue. */
.mshl-hero__amount.is-neg { color: #FFD7D7; }
.mshl-hero__amount--unknown { color: rgba(255, 255, 255, .65); }

.mshl-hero__sub {
    color: #fff;
    opacity: .85;
    margin-top: var(--spacing-2);
}

/* The pocket tiles are TRANSLUCENT TILES ON THE RED, not a white card. Two
   columns fixed rather than auto-fit: the picture has two, and auto-fit at
   minmax(120px) drops to one column at 390 once a pocket name wraps, which
   turns the pair into a stack halfway down a red block. */
.mshl-hero__split {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--spacing-2);
    margin-top: var(--spacing-4);
}

.mshl-hero__pocket {
    background: rgba(255, 255, 255, .14);
    border-radius: var(--radius-xl);
    padding: var(--spacing-2) var(--spacing-3);
}

.mshl-hero__pocket dt { color: #fff; opacity: .82; }
.mshl-hero__pocket dd { color: #fff; }

/* The head's opening note sits on the same red, for the same reason. */
.mshl-headnote {
    background: var(--primary);
    border: 0;
    border-radius: 0;
    color: #fff;
    margin: calc(var(--spacing-4) * -1) calc(var(--spacing-4) * -1) 0;
    padding: 0 var(--spacing-4) var(--spacing-8);
}

.mshl-headnote__t { color: #fff; }
.mshl-headnote__p { color: #fff; opacity: .85; }

/* ---------- quick actions: the card that straddles the red edge ---------- */

/* THE NEGATIVE MARGIN IS THE DESIGN, not a nudge. The card is meant to sit
   ACROSS the hero's lower edge — part of it on the red, part on the page — and
   that overlap is what ties the two halves of the screen together. The hero
   pays for it with --spacing-8 of bottom padding so the tiles above are never
   the thing being covered.

   `position: relative` and the z-index are load-bearing: without them the card
   is painted before the hero's background in the same stacking context and the
   red draws straight over the top third of it. */
.mshl-qa {
    position: relative;
    z-index: 1;
    display: grid;
    gap: var(--spacing-1);
    margin-top: calc(var(--spacing-6) * -1);
    padding: var(--spacing-3) var(--spacing-1);
    border-radius: var(--radius-2xl);
    background: var(--bg-card);
    box-shadow: var(--shadow-md);
}

/* The column count follows how many items SURVIVED their gate. A four-column
   grid holding three items leaves a hole where the refused one was, which
   reads as a control that failed to render rather than one the reader may not
   use. */
.mshl-qa--1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.mshl-qa--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.mshl-qa--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.mshl-qa--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }

.mshl-qa__item {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-start;
    gap: var(--spacing-2);
    min-width: 0;
    min-height: 68px;
    padding: var(--spacing-1) 2px;
    border: 0;
    background: none;
    font-family: inherit;
    text-align: center;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

.mshl-qa__ico {
    display: grid;
    place-items: center;
    width: 42px;
    height: 42px;
    border-radius: 14px;
    background: var(--primary-light);
    color: var(--primary-dark);
}

.mshl-qa__ico .mshl-i { width: 21px; height: 21px; }

/* A TINT IS NOT THE MEANING. Each square is tinted to the kind of thing it
   does, and every one of them is captioned in words directly beneath — remove
   the colour and the rail still reads. That is the FPFM-95 test, applied to a
   fill rather than to an edge. */
.mshl-qa__ico.is-in { background: var(--success-light); color: #047857; }
.mshl-qa__ico.is-out { background: var(--error-light); color: #B91C1C; }
.mshl-qa__ico.is-neutral { background: var(--info-light); color: #4338CA; }
.mshl-qa__ico.is-amber { background: var(--warning-light); color: #92400E; }

.mshl-qa__label {
    font-size: 11px;
    font-weight: 600;
    line-height: 1.2;
    color: var(--secondary);
    overflow-wrap: anywhere;
}

/* ---------- the month card's sub-cards ---------- */

/* Money In and Money Out shipped as two columns of bare text. They are bounded
   boxes now, each carrying the fact the reader asks for next — how many
   entries, how recent — and Net is a third, full width, below them. The border
   is on all four edges. */
.mshl-sub {
    min-width: 0;
    padding: var(--spacing-2) var(--spacing-3);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-xl);
    background: var(--bg-card);
}

.mshl-sub--wide { margin-top: var(--spacing-3); }

.mshl-sub__label {
    margin: 0;
    font-size: 11px;
    font-weight: 600;
    color: var(--gray-500);
}

.mshl-sub__v {
    margin: 3px 0 0;
    font-size: var(--font-size-lg);
    font-weight: 800;
    letter-spacing: -.02em;
    overflow-wrap: anywhere;
}

.mshl-sub__v.is-in { color: var(--success); }
.mshl-sub__v.is-out { color: var(--error); }

.mshl-sub__cap {
    margin: 2px 0 0;
    font-size: 11px;
    font-weight: 500;
    color: var(--gray-500);
    line-height: 1.45;
}

/* `.mshl-pair` is now the two-up track that holds the sub-cards. Its own
   bottom margin goes: Net's `--wide` rule owns the gap between the row and the
   card below it, and two margins meeting there put uneven space in the middle
   of one card. */
.mshl-pair { margin-bottom: 0; }

/* ---------- the obligation meter ---------- */

/* A SHARE OF WHAT IS OWED, NOT A PROGRESS TOY. The bar is never the only
   statement: the sentence beneath it gives both halves in pesos, so a reader
   who cannot judge a bar's length — or cannot see it — loses nothing. */
.mshl-meter {
    height: 8px;
    margin: var(--spacing-2) 0 var(--spacing-2);
    border-radius: 999px;
    background: var(--gray-200);
    overflow: hidden;
}

.mshl-meter i {
    display: block;
    height: 100%;
    border-radius: 999px;
    background: var(--warning);
}

.mshl-meter i.is-done { background: var(--success); }

/* ---------- the badge on the More tab ---------- */

/* Positioned against the tab, which needs the tab to be a containing block. It
   is deliberately NOT on the centre disc — a number on a write button reads as
   a queue you are about to add to. */
.mshl-tab { position: relative; }

.mshl-tab__badge {
    position: absolute;
    top: 6px;
    left: 50%;
    margin-left: 4px;
    min-width: 17px;
    height: 17px;
    padding: 0 4px;
    border-radius: 999px;
    background: var(--primary);
    color: #fff;
    font-size: 10px;
    font-weight: 800;
    line-height: 17px;
    text-align: center;
}

/* ---------- the Records index rows   FPFM-100 ---------- */

/* WHAT THIS REPLACES, AND WHY IT HAD TO GO.
   There used to be a `.mshl-stub__built` block and a filled `.mshl-btn--go`
   here, so that Records' one finished screen could sit on the Records STUB.
   The comment that lived on this line asked the reader to understand that
   filled-versus-outline carried "this one is built". They had not read it. In
   docs/evidence/FPFM-100/shell-records-390.png the filled red button was the
   strongest thing on the screen, inside the dashed border that means "not
   built", above the "Not built yet" flag and above the screen's own heading,
   and the screen read as though it were called Reports.

   A distinction a CSS comment has to explain is not a distinction the reader
   can see. So the built thing left the not-built container entirely: Records
   is now the proposal's Stage 3 INDEX, and the rows below are the shell's own
   .mshl-row primitive — the same one the More index uses — arranged to carry
   an icon, a name and the line that answers what the row does.

   NOTHING NEW IS DECLARED. Every property here is layout: it says where the
   four parts of a row sit, and it sets no colour, type scale or border of its
   own. The row's appearance is still .mshl-row's. */
.mshl-row--rec {
    grid-template-columns: auto 1fr auto;
    grid-template-rows: auto auto;
    row-gap: 2px;
    min-height: 64px;
}

.mshl-row--rec .mshl-row__ico {
    grid-row: 1 / span 2;
    display: inline-flex;
    color: var(--gray-500);
}

.mshl-row--rec .mshl-row__t {
    grid-column: 2;
    font-weight: 700;
}

.mshl-row--rec .mshl-row__chev { grid-row: 1 / span 2; grid-column: 3; }

/* ---------- a pushed screen's back arrow   FPFM-100 ---------- */

/* No new box: it is .mshl-top__btn, which is already the 44px tappable square
   the bell and the account button use. The only thing this rule says is that
   the arrow sits at the START of the bar, before the title, rather than in the
   actions cluster at the end — because a back control that is not the first
   thing on the row is a back control the thumb has to look for. */
.mshl-top__btn--back { align-self: flex-start; margin-right: var(--spacing-1); }

/* ==========================================================================
   THE SHELL'S REPORTS SCREEN — FIT ONLY, NEVER DESIGN   FPFM-100
   ==========================================================================
   The design is css/reports-index.css, which BOTH reports.html and mobile.html
   link. One copy. Nothing in this block restates it, and nothing here changes
   how a card, a figure or the period bar looks.

   What IS here is the seam between a design built for a PAGE and a host that
   is a SHELL, and every rule below is answerable to a measurement in
   docs/evidence/FPFM-100/shell-fit.txt rather than to an opinion. A rule that
   cannot name the measurement that forced it does not belong in this block.

   THERE IS EXACTLY ONE RULE, and that is a finding rather than a convenience.
   The proposal predicted one fit change; the measurement agrees, and the run
   that would have shown a second one is the same run that shows this one.
   ========================================================================== */

/* THE PAGE HEADER, AND ONLY THE PAGE HEADER.

   MEASURED, section M3: on mobile.html#reports at 390 there were TWO visible
   <h1> elements and both read "Reports" — the shell's own bar at y=18, 20px/700,
   and the index template's page header at y=93, 24px/700. The template carries
   it because reports.html has no bar of its own to name the screen; the shell
   does, so on this surface it is the same word twice, 75px apart, in two
   different sizes.

   It costs more than the duplication. .page-header measured 57.58px tall with a
   24px bottom margin — 81.58px of an 844px screen, spent restating the title
   directly under the title, before the reader reaches the period control.

   display:none rather than deleting it from the shared template, because the
   template is shared: reports.html needs the header and is the surface this
   design was approved on. A fit rule on the host is the difference between
   adapting a shared design and forking it.

   The <p> subtitle goes with it. It is inside .page-header, so this is not a
   separate decision — it is stated because "drop the page header" and "drop the
   h1" are not the same instruction, and the header is what actually goes. */
.mshl-body #reportsRoot .page-header {
    display: none;
}

/* ==========================================================================
   THE SHELL'S MONEY TAB AND ITS TRANSACTIONS SCREEN          FPFM-101
   ==========================================================================
   Two things live here and they are different in kind:

     1. THE SEGMENTED CONTROL, which is a real addition to the shell. Stage 1
        had two levels — a tab root and a pushed route with a back arrow — and
        Money is neither: three peer surfaces a reader swaps between and never
        returns from. The proposal settles that this is a segmented strip and
        why (proposal/mobile/shell.js, STAGE 2); this declares how it looks, in
        the shell's own idiom, and nothing about it is copied from a page.

     2. THE TRANSACTIONS LIST, which is this shell's own composition. It is NOT
        transactions.html's phone layout and does not reuse a rule from it. That
        layout is a CSS re-lay of the desktop <tr> — same eight cells, addressed
        by nth-child — and the design here is a different one: month headers, a
        marks line, an In/Out/Net strip. There was no shared design to extract,
        so nothing was extracted; what the two surfaces share is the three RULES
        in js/txn-filter.js. Stated because "extract the phone card layout" was
        the expected answer and this is not it.

   ONE FIT RULE ON THE SHARED FILTER SHEET, and it is the only thing this block
   says about `.fsheet__*`. Same shape and same standard as the Reports fit rule
   above: it names the measurement that forced it, and it changes where the
   control sits rather than what it looks like.

   NO ONE-EDGE COLOUR STRIPES. FPFM-95. `is-in` and `is-out` set a text colour
   and a disc tint, and every one of them sits beside a word or a sign that says
   the same thing: the amount carries an explicit + or −, the receipt mark
   carries "Receipt" / "No receipt", and a negative Net is captioned in a
   sentence beneath the strip.
   ========================================================================== */

/* ---------- the segmented control ---------- */

/* Sits between the top bar and the scroll region, outside it, so it survives a
   long list — the proposal's own requirement ("sticky, so it survives a long
   scroll through 187 rows"). It is not `position: sticky`: `.mshl-body
   .main-content` is a fixed column flex box and `.mshl-scroll` is the only
   scrolling child, so a sibling above that child is already pinned. Sticky
   would be a second mechanism for a thing already true. */
.mshl-segs {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    flex: 0 0 auto;
    min-width: 0;
    background: var(--bg-card);
    border-bottom: 1px solid var(--gray-700);
}

.mshl-seg {
    min-height: 44px;
    min-width: 0;
    padding: var(--spacing-3) var(--spacing-2);
    background: transparent;
    border: 0;
    /* The current segment is marked by a 2px underline on its BOTTOM edge and
       by weight and colour together. That is not the FPFM-95 case: the rule is
       about a coloured edge carrying MEANING that is nowhere in words, and this
       edge carries selection, which `aria-selected` states outright and which
       the reader also performed. It is a tab underline, not a severity. */
    border-bottom: 2px solid transparent;
    color: var(--gray-400);
    font-size: var(--font-size-base);
    font-weight: 600;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

.mshl-seg.is-on {
    color: var(--primary);
    border-bottom-color: var(--primary);
}

/* A SCOPE ABOVE THE READER — DRAWN, AND DISABLED.                    FPFM-108
   The proposal keeps an unreachable scope on the strip rather than removing it
   ("an area head who cannot see it should know it exists and is above them",
   capture 81, where Provincial is greyed beside a live Area tab).

   FPFM-95: the grey is not what carries the meaning. The button is `disabled`,
   so it is unreachable by pointer, keyboard and assistive technology alike, and
   it carries an aria-label and a title that say "above your scope" in words.
   The colour is the visual echo of a state three other channels already state.

   --gray-500 rather than --gray-400: the resting segment is already --gray-400,
   and a disabled tab that measures the same as an idle one is not a state. Both
   arms are read at their computed values in
   docs/evidence/FPFM-108/layout-measurements.txt. */
.mshl-seg:disabled {
    color: var(--gray-500);
    cursor: not-allowed;
}

/* ---------- the bar above the list ---------- */

.mshl-txbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-3);
    flex: 0 0 auto;
    min-width: 0;
    padding: var(--spacing-3) var(--spacing-4);
}

/* THE ONE FIT RULE ON THE SHARED SHEET.

   MEASURED at 390 before it existed: `.fsheet__open` is declared `width: 100%`
   with `margin-bottom: var(--spacing-5)`, which is right on the four PAGES that
   use it — there the button is a full-width band above a list. In this bar it
   made the opener 358px wide and pushed "40 of 40" onto a second line 20px
   below it, so a two-item row became two stacked blocks 68px tall.

   It changes the BOX and nothing else: the border, the radius, the background,
   the funnel, the badge and the 48px tap target are all the component's own and
   are untouched. Scoped through `.mshl-body` so no page can be reached by it. */
.mshl-body .mshl-txbar .fsheet__open {
    width: auto;
    margin-bottom: 0;
    flex: 0 0 auto;
}

/* The `.fsheet` root is a plain wrapper with no box of its own; it is here so
   the opener can be a flex item of the bar. */
.mshl-body .mshl-txbar .fsheet {
    display: flex;
    min-width: 0;
}

.mshl-txcount {
    min-width: 0;
    color: var(--gray-400);
    font-size: var(--font-size-sm);
    font-weight: 500;
    text-align: right;
    overflow-wrap: anywhere;
}

/* ---------- the In / Out / Net strip ---------- */

/* Recomputed from whatever the filters selected — see txBody(). Three equal
   columns so the three figures line up whatever their length, and `min-width: 0`
   on each because a peso figure with no spaces in it is one long word. */
.mshl-txsum {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--spacing-2);
    margin: 0;
    padding: var(--spacing-4);
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-lg);
    background: var(--bg-card);
}

.mshl-txsum > div {
    min-width: 0;
}

.mshl-txsum dt {
    margin: 0 0 2px;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

/* A PESO FIGURE IS NEVER BROKEN MID-NUMBER. FPFM-101 audit finding A1's
   knock-on, found by looking at the capture rather than by a test.

   This carried `overflow-wrap: anywhere`, which is the right answer for a long
   word and the wrong one for money: at 390px the head's August figure came out
   as "₱648,382.2" with a lone "9" on the line beneath it. Nothing was
   truncated and the number was still correct, and it still read as a defect,
   because a reader scanning three columns sees the digit count before they
   read the digits.

   It only started happening now because the screen opens on ALL DATES: one
   month's totals were five and six figures, a whole history's are seven, and
   the column is a third of a 390px card. The rule was not wrong before; the
   numbers grew past it.

   So the figure stays on ONE line and the TYPE shrinks to fit instead. The
   clamp lands at ~14px on a 390px phone and ~11.5px on a 320px one, and caps at
   the base size on anything wide — measured in a real browser, not derived from
   a character count: tools/fpfm101-measure.mjs asserts scrollWidth <= clientWidth
   for all three figures. Tabular figures because three stacked columns of
   proportional digits do not line up. */
.mshl-txsum dd {
    margin: 0;
    font-size: clamp(0.8125rem, 3.6vw, var(--font-size-base));
    font-weight: 700;
    overflow-wrap: normal;
    word-break: keep-all;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.mshl-txsum dd.is-in { color: var(--success); }
.mshl-txsum dd.is-out { color: var(--error); }

/* ---------- the month header ---------- */

/* The landmark. It is an <h2> because it is a heading and a screen-reader user
   navigating by heading is exactly the reader this list was designed for. */
.mshl-mhead {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--spacing-3);
    margin: var(--spacing-2) 0 0;
    min-width: 0;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

.mshl-mhead b {
    flex: 0 0 auto;
    font-weight: 600;
    text-transform: none;
    letter-spacing: 0;
}

/* ---------- the list ---------- */

.mshl-txlist {
    list-style: none;
    margin: 0;
    padding: 0;
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-lg);
    background: var(--bg-card);
    overflow: hidden;
}

/* NOT A BUTTON, and that is the decision rather than an oversight — see
   js/mobile-shell.js txEntryRow(). There is no in-shell detail screen to open
   and no deep link to a single entry anywhere in the app, so a row that looked
   tappable would be the fifth dead control this shell has had to remove.
   Nothing here draws a chevron, a hover tint or a pointer cursor. */
.mshl-txrow {
    display: grid;
    grid-template-columns: 36px minmax(0, 1fr) auto;
    align-items: start;
    gap: var(--spacing-3);
    min-width: 0;
    padding: var(--spacing-3) var(--spacing-4);
    border-bottom: 1px solid var(--gray-800);
}

.mshl-txlist > .mshl-txrow:last-child {
    border-bottom: 0;
}

.mshl-txrow__ico {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    border-radius: 999px;
}

.mshl-txrow__ico.is-in {
    background: rgba(16, 185, 129, 0.16);
    color: var(--success);
}

.mshl-txrow__ico.is-out {
    background: rgba(239, 68, 68, 0.16);
    color: var(--error);
}

.mshl-txrow__main {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}

/* ONE LINE, THEN AN ELLIPSIS — BOTH OF THEM, AND IT IS THE PROPOSAL'S RULE
   RATHER THAN A PREFERENCE.

   "ONE ROW = ONE LINE OF TEXT AND ONE FIGURE. The desktop table has six columns.
   Six columns on a phone is a spreadsheet with the sides cut off."
   (proposal/mobile/shell.js, TRANSACTIONS — the workhorse.) Its own captures 20
   and 30 show it: "Budget food during Liv…", "Food & Hospitality · Fellows…".

   THIS WAS BUILT WRAPPING FIRST AND THE CAPTURE IS WHAT SETTLED IT. Measured at
   390 with the title clamped to two lines and the sub-line free to wrap: a head's
   rows came out 165.34, 190.94 and 168.55px tall — "Tithes & Remittance · Tithes
   to Provincial · Feast Zamboanga Sibugay" over four lines — so two entries
   filled the screen and the month header scrolled away from the rows it labels.
   The landmarks are the whole point of grouping by month, and a row that grows
   without bound is what destroys them. Figures in
   docs/evidence/FPFM-101/shell-measurements.txt.

   The desktop table truncates its own Description column at 200px for the same
   reason. Nothing is LOST by this: the description is also the row's
   accessible-name text and is read in full by a screen reader. */
.mshl-txrow__t {
    min-width: 0;
    color: var(--secondary);
    font-size: var(--font-size-base);
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.mshl-txrow__s {
    min-width: 0;
    color: var(--gray-400);
    font-size: var(--font-size-sm);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.mshl-txrow__marks {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--spacing-2);
    min-width: 0;
    margin-top: 2px;
}

/* A mark is a glyph AND its word. FPFM-95: the silhouette reinforces, it never
   carries the meaning by itself — "no receipt" is a fact somebody may be asked
   about, and an absent mark and a mark nobody noticed look identical. */
.mshl-mk {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    min-width: 0;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    font-weight: 500;
}

.mshl-mk.is-yes { color: var(--success); }
.mshl-mk.is-no { color: var(--gray-500); }
.mshl-mk.is-batch { color: var(--info); }
.mshl-mk--date { color: var(--gray-400); }

.mshl-mk .mshl-i {
    width: 14px;
    height: 14px;
}

.mshl-txrow__amt {
    flex: 0 0 auto;
    font-size: var(--font-size-base);
    font-weight: 700;
    white-space: nowrap;
}

.mshl-txrow__amt.is-in { color: var(--success); }
.mshl-txrow__amt.is-out { color: var(--error); }
/* A voided entry: the word says it, the strike and the grey reinforce it. */
.mshl-txrow__amt.is-void { color: var(--gray-400); }
.mshl-mk.is-void { color: var(--gray-300); }


/* ==========================================================================
   THE SHELL'S WALLET SEGMENT                                       FPFM-103
   ==========================================================================
   The Money tab's remaining segment. Every rule below is LAYOUT or a colour
   that had nowhere else to come from, and each one names the thing that forced
   it. Nothing here restates a box the shell already has: the action rows, the
   pocket rows and the feast rows are all `.mshl-row--rec`, the primitive the
   Records index already uses, because they are the same shape — an icon, a
   name, the line that says what it is, and one thing at the end.

   NO ONE-EDGE COLOUR STRIPES (FPFM-95). The negative card below is a full
   tinted box with the word "Below zero" as its heading; the severity is in the
   words and in the minus sign, never in an edge.
   ========================================================================== */

/* The home hero pads its bottom by --spacing-8 so the quick-actions card can
   straddle its lower edge. Nothing straddles this one — the actions are a card
   of their own below it, which is the proposal's whole point about the wallet
   — so the red block ends where its text does. The proposal calls this
   `hero--tight` and this is the same idea under the shell's own prefix. */
.mshl-hero--tight { padding-bottom: var(--spacing-5); }

/* A ROW LIST INSIDE A CARD. `.mshl-grp` is a bordered, rounded box in its own
   right, which is correct on the More and Records screens where it IS the
   card. Inside one it would be a second border 16px in from the first, so it
   drops its box and cancels the card's horizontal padding so the rows reach
   the card's edges — the same negative-margin trick `.mshl-hero` already uses
   against `.mshl-scroll`, and for the same reason. */
.mshl-card > .mshl-grp {
    border: 0;
    border-radius: 0;
    background: none;
    margin: var(--spacing-3) calc(var(--spacing-4) * -1) 0;
}

.mshl-card > .mshl-grp > li + li { border-top: 1px solid var(--gray-200); }

/* THE FIFTH TINT. Four existed — in, out, neutral, amber — and the wallet has
   five actions that must not repeat a treatment, because "four buttons wearing
   two marks" is the defect this segment exists to fix and repeating a colour
   would half-undo it in another dimension.

   Violet is the proposal's choice for Send Support. There is no violet token in
   this file, so the two values are written here and nowhere else, with a dark
   arm matching the pattern the other four use (a translucent tint of the same
   hue rather than a second flat colour). Both arms are contrast-measured
   against their COMPOSITED background in
   docs/evidence/FPFM-103/layout-measurements.txt, not against the page. */
.mshl-qa__ico.is-purple { background: #EDE9FE; color: #5B21B6; }
[data-theme="dark"] .mshl-qa__ico.is-purple {
    background: rgba(139, 92, 246, 0.18);
    color: #C4B5FD;
}

/* A ROW THAT ENDS IN A FIGURE RATHER THAN A CHEVRON. Same three columns
   `.mshl-row--rec` already declares; the amount simply takes the slot the
   chevron takes on a row that leads somewhere.

   IT IS A DIV, NOT A BUTTON, on purpose: a pocket and a feast wallet have no
   screen behind them in this shell, and `.mshl-row` sets `cursor: pointer` for
   the rows that do. A pointer on something that does not respond is a promise
   the shell cannot keep. */
.mshl-row--pock { cursor: default; }

/* THE HEAD FEAST ROW'S STATUS DOT — the proposal's `.f-dot`, restored.
   FPFM-103 audit, finding 6. Same 9px circle, same three states, and a fourth
   for a month this device could not read. It rides the `.mshl-row__ico` slot so
   the row's grid placement is the one already declared above rather than a
   second copy of it; `display` and `color` come from that rule and neither
   matters to a filled circle.

   IT IS DECORATION OVER TEXT THAT ALREADY SAYS THIS, and is aria-hidden in the
   markup for that reason — so 1.4.11's 3:1 is not what makes it legible, the
   sentence beside it is. It is still given the darkest hue of each pair that
   this stylesheet already uses, rather than the raw --success / --warning /
   --error, because those measure ~2.4:1 against --bg-surface in the light theme
   and a mark nobody can find is not worth the bytes. #047857, #92400e and
   #b91c1c are one set already in this file — .alert-success / .alert-warning /
   .alert-danger — so this is the app's own answer rather than a new one. */
.mshl-row__dot {
    width: 9px;
    height: 9px;
    flex: 0 0 9px;
    align-self: center;
    border-radius: 999px;
    background: #047857;
}

.mshl-row__dot.is-stale { background: #92400e; }
.mshl-row__dot.is-neg { background: #b91c1c; }
.mshl-row__dot.is-unknown { background: var(--gray-400); }

/* In dark the surface is #1f2937 and the relationship inverts: the dark hexes
   above fall towards the background and the bright tokens rise off it. This is
   the same failure the action tints were MEASURED to have on this screen (see
   layout-measurements.txt, "tint is-in computes 1.98:1 in this theme") — a
   fixed light-theme foreground over a surface that flips. It is not repeated
   here. */
[data-theme="dark"] .mshl-row__dot { background: var(--success); }
[data-theme="dark"] .mshl-row__dot.is-stale { background: var(--warning); }
[data-theme="dark"] .mshl-row__dot.is-neg { background: var(--error); }

.mshl-row__amt {
    grid-row: 1 / span 2;
    grid-column: 3;
    align-self: center;
    font-weight: 700;
    /* A peso figure is one long word with no break opportunity in it; three of
       them stacked in a column only line up with tabular digits. Both rules are
       the ones `.mshl-txsum dd` already carries, for the same measurement. */
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.mshl-row__amt.is-neg { color: var(--error); }

/* THE ONE WALLET THAT NEEDS A PERSON. A tinted card rather than a row in the
   list, because the proposal pulls it out and shows it first — "it is the only
   row that needs a person, not a formula" — and a card the reader meets before
   the list is what makes that ordering visible. */
.mshl-card--neg {
    background: var(--error-light);
    border-color: var(--error);
}

.mshl-card--neg .mshl-card__title { color: var(--error); }
.mshl-card--neg > .mshl-grp > li + li { border-top-color: var(--error); }

/* THE CLOSING NOTE, on both roles' screens. An icon column and a block of
   prose; no severity colour, because it is not a warning — it is the sentence
   that explains why the big number above has no button beside it. */
.mshl-note {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--spacing-3);
    align-items: start;
    min-width: 0;
    padding: var(--spacing-4);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-xl);
    background: var(--bg-surface);
}

/* --gray-400 and NOT --gray-500 on both of these, and it is a measurement
   rather than a preference. --gray-500 is #888888, which against this card's
   --bg-surface (#f7f7f8) computes 3.31:1 — under 4.5 on 12px prose. Measured in
   a real browser against the COMPOSITED background before it was changed:
   docs/evidence/FPFM-103/layout-measurements.txt, "the closing note's prose".
   --gray-400 (#666666) is what .mshl-card__note already uses for the same job
   at the same size, so this is the shell's own answer rather than a new one. */
.mshl-note__ico { display: inline-flex; color: var(--gray-400); }

.mshl-note__t {
    margin: 0 0 var(--spacing-1);
    font-size: var(--font-size-sm);
    font-weight: 700;
    color: var(--secondary);
}

.mshl-note__p {
    margin: 0;
    font-size: var(--font-size-xs);
    line-height: 1.5;
    color: var(--gray-400);
}

/* `code` inside the note is the table's real name and is meant to be read as
   one. The app declares no inline-code treatment anywhere else, so it gets the
   minimum that makes it legible rather than a new component. */
.mshl-note__p code {
    padding: 1px 4px;
    border-radius: var(--radius-sm);
    background: var(--gray-200);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 0.92em;
    /* min-width:0 on the grid item above is what stops this from widening the
       document; a monospace token has no break opportunity in it either. */
    overflow-wrap: anywhere;
}
/* ==========================================================================
   FPFM-104 — THE RECORDS TAB'S FIRST TWO BUILT SCREENS
   ==========================================================================
   Tithers and Attendance. Ten new selectors, all `mshl-` prefixed, all inside
   this block so the bound tests/mobile-shell-structure.test.js reads still
   holds. Nothing existing is redeclared.

   WHAT IS DELIBERATELY NOT HERE. There is no `.tth-` rule of any kind. The
   tither card's appearance is declared ONCE, in the FPFM-87 block immediately
   below, by CLASS rather than by page — so the shell's list takes the identical
   bytes tithers.html takes, which is what "reuse the card unchanged" has to mean
   in a stylesheet. The single thing said about it here is that the list is
   VISIBLE, because `.tth-list` ships `display: none` and tithers.html turns it
   back on through its own id.

   The `.fsheet__*` component is likewise untouched: the Tithers sheet is the
   sixth USE of FPFM-94's one component, and the shell declares nothing about it
   beyond the fit rule the Transactions screen already added.
   ========================================================================== */

/* ---------- the bar above the roster ---------- */

/* `.mshl-txbar` is the row; this modifier only changes how the two children
   share it. The search field is the one that should absorb the slack — the
   Filters button is a fixed-width control and stretching it would put a lot of
   empty box around one word. */
.mshl-tthbar > .mshl-find { flex: 1 1 auto; }
.mshl-tthbar > .fsheet { flex: 0 0 auto; }

/* The search box. A field with a mark inside it, not a field with a button
   beside it: the glyph is decoration and the input carries its own placeholder
   and aria-label, so nothing here depends on the magnifier being recognised. */
.mshl-find {
    display: flex;
    align-items: center;
    gap: var(--spacing-2);
    min-width: 0;
    padding: 0 var(--spacing-3);
    /* 44 is the project's touch floor and it is declared here rather than
       inherited from `.form-input`, whose height is 40 outside
       `@media (pointer: coarse)`. Spelling it means the cascade question does
       not arise — the same reasoning the FPFM-87 block gives below. */
    min-height: 44px;
    border: 1px solid var(--gray-300);
    border-radius: var(--radius-lg);
    background: var(--bg-card);
    color: var(--gray-400);
}

.mshl-find__in {
    flex: 1 1 auto;
    /* min-width:0 is load-bearing: an <input> has an intrinsic default size and
       a flex item will not shrink below it, so without this a long placeholder
       pushes the Filters button off the row. */
    min-width: 0;
    border: 0;
    background: transparent;
    padding: 0;
    font-size: var(--font-size-sm);
    color: var(--secondary);
}

.mshl-find__in:focus { outline: none; }
.mshl-find:focus-within { border-color: var(--primary); }

/* The clear affordance a `type="search"` field draws in Chromium sits on the
   right edge and is not a 44px target. It is left as the browser draws it —
   the screen's own "Clear the search and filters" button is the accessible way
   out — but its colour is inherited so it does not render as a black mark on a
   grey field. */
.mshl-find__in::-webkit-search-cancel-button { filter: grayscale(1) opacity(0.55); }

/* ---------- the roster ---------- */

/* `.tth-list` ships display:none and tithers.html re-enables it through
   `#tithersPhoneCards`. This is the shell's equivalent id, and it is an id for
   the same reason theirs is: the cascade is then decided by specificity rather
   than by which rule happens to be written later in the file.

   The box around it is the shell's own, matching `.mshl-txsum` above so the
   strip and the list below it read as one surface. The CARDS inside are the
   FPFM-87 card and nothing here touches them.

   THIS RULE IS UNMEDIATED, AND IT WAS BRIEFLY NOT. Two wrong attempts are
   recorded here because both are instructive and the second is the one that
   looked right.

   ATTEMPT 1 — scope the whole rule to `@media (max-width: 767px)`. That takes
   `display: block` with it, and `display: block` is not cosmetic: `.tth-list`
   ships `display: none` unmediated, so this line is the ENABLE SWITCH for the
   whole roster. CAPTURED at 1440: the box went and so did all three tithers,
   while the summary strip above still read "SHOWING 3 of 3" and the note below
   still said "Every figure on the cards above is a real zero" — prose
   describing rows that were no longer on the screen.

   ATTEMPT 2 — split the rule, leaving `display: block` at every width and
   scoping only the box (margin, padding, list-style, border, radius,
   background, overflow) to the phone. That is what shipped for one revision.
   It was reasoned from a true premise — above 768 this drew a bordered,
   card-coloured box around cards that had NO appearance rules at all, because
   every `.tth-card*` declaration was itself stuck under
   `@media (max-width: 767px)` in the FPFM-87 block — and a frame around
   unstyled run-together text does look worse than no frame.

   IT WAS THE WRONG HALF OF THE PAIR. The defect was never the box; it was the
   children having no rules. FPFM-104 lifted the `.tth-card*` declarations out
   of that media query (see the banner in the FPFM-87 block), so the cards are
   now styled at every width — and a roster of styled cards sitting in the one
   surface on the screen with no frame, directly under a `.mshl-txsum` strip
   that has one, reads as an unfinished panel. So the box comes back at every
   width, and this is now ONE rule rather than two.

   The general form, and it is the same ruling that governs the FPFM-87 block:
   a declaration is width-scoped when it decides WHETHER something is shown,
   and not when it decides what it LOOKS LIKE. Nothing here decides whether:
   `display: block` is the enable switch and it must apply wherever the shell is
   reachable, which is everywhere — js/sidebar.js mobileShellOpen() navigates to
   mobile.html from the account menu with no viewport check, deliberately. The
   rest is the box, and a box is appearance.

   IT IS DECLARED HERE RATHER THAN BESIDE THE `.tth-card*` RULES. The FPFM-87
   block is headed "tithers.html only" and this container is the shell's;
   tests/mobile-shell-records.test.js section 13 pins every selector this ticket
   added to the shell's own block, and moving it there was tried and that guard
   caught it, correctly. */
#mshlTithersCards {
    display: block;
    margin: 0;
    padding: 0;
    list-style: none;
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-lg);
    background: var(--bg-card);
    overflow: hidden;
}

/* ---------- the honest empty state ---------- */

/* NOT `.empty-state`. That is the app's page-level empty state, declared four
   times across this file at two different sizes with a 64px icon and
   spacing-10 padding, and it is sized for a card body on a desktop table. This
   is the shell's, at the shell's scale, and it is one block rather than an
   override of somebody else's four. */
.mshl-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    gap: var(--spacing-2);
    padding: var(--spacing-8) var(--spacing-5);
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-xl);
    background: var(--bg-card);
}

.mshl-empty__ico {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 56px;
    height: 56px;
    margin-bottom: var(--spacing-1);
    border-radius: 50%;
    background: var(--gray-200);
    color: var(--gray-400);
}

.mshl-empty__ico .mshl-i { width: 26px; height: 26px; }

.mshl-empty__t {
    margin: 0;
    font-size: var(--font-size-base);
    font-weight: 700;
    color: var(--secondary);
}

/* --gray-400 (#666666) and not --gray-500 (#888888), and the figure is this
   surface's OWN rather than the one carried over from the note block above.
   Measured in a real browser against the COMPOSITED background, M4 of
   docs/evidence/FPFM-104/measurements.txt:

     --gray-400 on .mshl-empty  5.74:1
     --gray-500 on .mshl-empty  3.54:1   <- under 4.5 on 14px prose

   The neighbouring block quotes 3.31:1 for --gray-500, which is correct for ITS
   composite and would have been the wrong number here. The harness applies the
   rejected token for the length of one reading and puts it straight back, so
   the comment cannot drift from the measurement without the run going red. */
.mshl-empty__s {
    margin: 0;
    max-width: 34ch;
    font-size: var(--font-size-sm);
    line-height: 1.55;
    color: var(--gray-400);
}

/* ---------- the note that is a warning ---------- */

/* `.mshl-note` is the neutral block. This modifier is for the one that names a
   DEFECT, and it says so with a border and a tint rather than with colour on the
   text — FPFM-95: a colour may reinforce a meaning and may never be the only
   thing carrying it. The heading already reads "has no empty state", which is
   what a reader who cannot see the tint has. */
.mshl-note--bad {
    border-color: var(--error);
    background: var(--error-light);
}

/* ---------- the sticky primary ---------- */

/* Attendance is the first screen in this shell with a sticky action, and it sits
   OUTSIDE `.mshl-scroll` so it does not travel with the content. `.mshl-app` is
   the fixed column both live in.

   The scroll gets `--cta` when this is present, so the last note is not trapped
   behind it. The figure is not a constant: the bar's own height is whatever two
   lines of note plus a 44px button come to, so the padding is expressed against
   the same tokens rather than as a number that would go stale the first time the
   sentence rewraps. Measured at 390 in
   docs/evidence/FPFM-104/measurements.txt. */
.mshl-sticky {
    flex: 0 0 auto;
    padding: var(--spacing-3) var(--spacing-4)
             calc(var(--spacing-3) + env(safe-area-inset-bottom, 0px));
    border-top: 1px solid var(--gray-700);
    background: var(--bg-card);
    /* Above the tab bar, which is the shell's own fixed element. */
    margin-bottom: 64px;
}

.mshl-scroll--cta {
    padding-bottom: calc(64px + var(--spacing-6) + 96px + env(safe-area-inset-bottom, 0px));
}

.mshl-sticky__note {
    margin: var(--spacing-2) 0 0;
    font-size: var(--font-size-xs);
    line-height: 1.45;
    color: var(--gray-400);
    text-align: center;
}

/* The primary. `.mshl-btn` already carries the box, the 44px floor and the
   weight; this is the fill, and it is the app's own --primary rather than a
   third red. */
.mshl-btn--primary {
    border: 1px solid var(--primary);
    background: var(--primary);
    color: #fff;
}

/* ==========================================================================
   FPFM-105 — BUDGET (a Money segment) AND EVENTS (a Records screen)
   ==========================================================================
   Every selector below is new and `mshl-` prefixed, and every one of them sits
   in this block so the bound read in tests/mobile-shell-structure.test.js still
   holds. Nothing existing is redeclared.

   WHAT IS DELIBERATELY REUSED RATHER THAN REDECLARED, because the shortest way
   to describe this block is by what is NOT in it:

     .mshl-card / __title / __note   both screens' container and prose
     .mshl-meter + .mshl-meter i     the MEASURABLE budget row's bar. It is the
                                     obligation meter, unchanged: a share of a
                                     known total, with the figures in words
                                     beside it.
     .mshl-txsum                     the Events header strip AND the per-event
                                     Income/Expense/Net row. It already solves
                                     this exact problem — three peso figures in
                                     130px each, without breaking a number
                                     across two lines — and the clamp that does
                                     it was measured, not guessed
                                     (tools/fpfm101-measure.mjs). A second
                                     three-column money strip would be a second
                                     answer to a settled question.
     .mshl-empty / .mshl-note        the empty states and the two defect notes
     .mshl-mk.is-yes / .is-no        the event's ACTIVE / ARCHIVED pill
     .mshl-sticky / .mshl-btn--*     the officer's "Set the first budget" bar
     .mshl-foot                      both closing sentences

   So what is new is only the four things the shell had no shape for: a budget
   row's two-column head, the hatched NOT-MEASURABLE track, the unbudgeted list,
   and the event card.
   ========================================================================== */

/* ---------- a budget row ---------- */

/* `.mshl-card` already gives the box, the padding and the border. This only
   arranges the two things that sit on the top line, and the gap below them. */
.mshl-bud > * + * { margin-top: var(--spacing-2); }

.mshl-bud__head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--spacing-3);
}

/* min-width: 0 on the identity column, not on the amount. A long category name
   wraps; a peso figure must not, and giving BOTH a shrink floor let the amount
   be the thing that gave way at 320px. */
.mshl-bud__id {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.mshl-bud__t {
    font-size: var(--font-size-base);
    font-weight: 700;
    line-height: 1.3;
}

.mshl-bud__s {
    margin-top: 2px;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    line-height: 1.4;
}

/* The allocated amount and its label, right-aligned as one unit. `flex: 0 0
   auto` is what stops the figure wrapping when the name beside it is long. */
.mshl-bud__alloc {
    display: flex;
    flex: 0 0 auto;
    flex-direction: column;
    align-items: flex-end;
    text-align: right;
}

.mshl-bud__alloc b {
    font-size: var(--font-size-lg);
    font-weight: 700;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.mshl-bud__alloc span {
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    font-weight: 700;
    letter-spacing: 0.04em;
}

.mshl-bud__read {
    margin: 0;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    line-height: 1.5;
}

/* The stored category strings, shown AS STORED. Same treatment `.mshl-note__p
   code` already gives a table name, declared again here rather than by grouping
   the two selectors because that rule sits 250 lines up inside the FPFM-104
   block and reaching into it to add a comma is how a block stops being one
   ticket's. */
.mshl-bud__read code,
.mshl-card__note code {
    padding: 1px 4px;
    border-radius: var(--radius-sm);
    background: var(--gray-200);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 0.92em;
    overflow-wrap: anywhere;
}

/* ---------- the NOT MEASURABLE track ---------- */

/* THE HATCH IS DECORATION. THE WORDS ARE THE MESSAGE.

   FPFM-95's rule, and the reason this is a <p> with text in it rather than an
   empty bar with a caption somewhere else: a reader with no colour, no images
   and no CSS still gets "NOT MEASURABLE". The stripes and the amber only make
   it findable in a scroll of otherwise-solid rows.

   IT IS NOT A `.mshl-meter`, DELIBERATELY. A meter is a share of something. The
   entire point of this row is that there is no share to draw, so borrowing the
   shape that means "this much of that" would say the opposite of the sentence
   underneath it.

   THE FOREGROUND IS A THEME-AWARE PAIR, and that is the one thing in this block
   that is not a layout decision. `--warning` over `--warning-light` measures
   about 1.9:1 in the light theme — the exact defect FPFM-103 measured on the
   wallet tints and declined to ship a fourth instance of. So light gets #92400e
   and dark gets var(--warning), which is the pair `.mshl-row__dot.is-stale`
   already uses and which .alert-warning established before it. Both are
   measured in a real browser by tools/fpfm105-measure.mjs, in both themes,
   against the COMPOSITED background — `--warning-light` is translucent in dark,
   so reading it as opaque would score a colour that is never on screen. */
.mshl-hatch {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--spacing-2);
    min-height: 34px;
    margin: 0;
    padding: var(--spacing-2) var(--spacing-3);
    border: 1px solid #92400e;
    border-radius: var(--radius-md);
    color: #92400e;
    background-color: var(--warning-light);
    background-image: repeating-linear-gradient(
        45deg,
        rgba(146, 64, 14, 0.16) 0,
        rgba(146, 64, 14, 0.16) 6px,
        transparent 6px,
        transparent 12px
    );
}

.mshl-hatch__q {
    font-weight: 700;
    opacity: 0.75;
}

.mshl-hatch__t {
    font-size: var(--font-size-sm);
    font-weight: 700;
    letter-spacing: 0.06em;
}

[data-theme="dark"] .mshl-hatch {
    border-color: var(--warning);
    color: var(--warning);
    background-image: repeating-linear-gradient(
        45deg,
        rgba(245, 158, 11, 0.22) 0,
        rgba(245, 158, 11, 0.22) 6px,
        transparent 6px,
        transparent 12px
    );
}

/* The card around an unmeasurable row is tinted the same amber, at a fraction
   of the strength, so a scroll of four of them reads as one condition rather
   than four separate alarms. The BORDER is what carries it — not a coloured
   edge on one side, which FPFM-95 forbids and section 5 of
   tests/mobile-shell-structure.test.js checks for. */
.mshl-bud--unmeasured {
    border-color: #92400e;
}

[data-theme="dark"] .mshl-bud--unmeasured {
    border-color: var(--warning);
}

/* ---------- spending no budget covers ---------- */

/* Three columns: the category and its entry count, an illustrative bar, and the
   real amount. The bar is `1fr` so it absorbs the width the other two do not
   need, and both text columns are `auto` with a min-width floor of 0 on the
   first — a long category name wraps to two lines inside its own column
   instead of pushing the amount off the card. */
.mshl-ubl {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-3);
    margin: var(--spacing-3) 0 0;
    padding: 0;
    list-style: none;
}

.mshl-ubl__row {
    display: grid;
    grid-template-columns: minmax(0, 8.5rem) 1fr auto;
    align-items: center;
    gap: var(--spacing-3);
}

.mshl-ubl__id {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.mshl-ubl__id b {
    font-size: var(--font-size-sm);
    font-weight: 700;
    line-height: 1.25;
}

.mshl-ubl__id span {
    margin-top: 1px;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
}

/* NEUTRAL, not amber and not green. These rows are not a problem and not an
   achievement — they are spending with nothing to compare it to — and every
   coloured bar in this app means one or the other. */
.mshl-ubl__bar {
    display: block;
    height: 8px;
    border-radius: 999px;
    background: var(--gray-200);
    overflow: hidden;
}

.mshl-ubl__bar i {
    display: block;
    height: 100%;
    border-radius: 999px;
    background: var(--gray-500);
}

.mshl-ubl__amt {
    font-size: var(--font-size-sm);
    font-weight: 700;
    white-space: nowrap;
    text-align: right;
    font-variant-numeric: tabular-nums;
}

/* BELOW 360px THE BAR IS DROPPED, and it is dropped rather than squeezed.

   MEASURED, not guessed. tools/fpfm105-measure.mjs section 3 failed the run on
   this: at a 320px viewport the card's inner width is 254px, the name column
   takes 136, the amount 81 and the two gaps 24 -- leaving the bar 13.1px. The
   same bar measures 83.1px at 390 and is fine.

   A 13px bar is not a small bar. It is a smudge that still reads as a
   comparison, so every row would look about the same length -- the opposite of
   what the bar is for, and a visual claim the screen cannot support at that
   width.

   Dropping it costs a reader nothing: the bar is aria-hidden DECORATION, the
   exact peso figure is printed beside it, and the card says in words that the
   bars are a share of the largest rather than of a budget. The figures are the
   statement; the bar was only ever the illustration.

   Shrinking the name column instead was rejected -- at 320 that forces
   "Ministry & Personnel" onto three lines to make room for an ornament. */
@media (max-width: 360px) {
    .mshl-ubl__row { grid-template-columns: minmax(0, 1fr) auto; }
    .mshl-ubl__bar { display: none; }
}

/* ---------- an event card ---------- */

.mshl-evc > * + * { margin-top: var(--spacing-2); }

.mshl-evc__head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--spacing-3);
}

.mshl-evc__t {
    min-width: 0;
    font-size: var(--font-size-base);
    font-weight: 700;
    line-height: 1.3;
}

/* The pill around the ACTIVE / ARCHIVED word. `.mshl-mk` supplies the colour
   and the size; this is only the box, and `flex: 0 0 auto` is what keeps it on
   the first line beside a long event name. */
.mshl-evc__head .mshl-mk {
    flex: 0 0 auto;
    padding: 2px 8px;
    border-radius: 999px;
    background: var(--gray-100);
    font-size: var(--font-size-xs);
    font-weight: 700;
    letter-spacing: 0.04em;
}

.mshl-evc__when {
    margin: 0;
    font-size: var(--font-size-sm);
    font-weight: 600;
    line-height: 1.4;
}

.mshl-evc__where,
.mshl-evc__desc,
.mshl-evc__n {
    margin: 0;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    line-height: 1.5;
}

/* The Income / Expense / Net row INSIDE a card. `.mshl-txsum` is a bordered box
   in its own right because on the Transactions screen it stands alone on the
   scroll surface; inside a card that border would be a box in a box. Only the
   frame is removed — the grid, the clamp on the figures and the tabular digits
   are the ones that were measured, and they stay. */
.mshl-txsum--flat {
    padding: var(--spacing-3) 0 0;
    border: none;
    border-top: 1px solid var(--gray-200);
    border-radius: 0;
    background: none;
}

/* An event with nothing tagged to it. Set apart from the prose above by a rule
   rather than by a tint: it is not a warning, it is the absence of a figure,
   and the sentence says so. */
.mshl-evc__none {
    margin: 0;
    padding-top: var(--spacing-3);
    border-top: 1px dashed var(--gray-200);
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    line-height: 1.5;
}

/* --- FPFM-106 — THE OVERSIGHT SCREENS ---------------------------------------

   Four screens: the approvals queue, one edit request, the hierarchy and the
   audit log. Every selector below begins `mshl-` and lives only on mobile.html.

   WHAT IS REUSED RATHER THAN REDECLARED, because the cheapest block is the one
   that is not written:

     .mshl-card / __head / __title / __note / __big / __link   every panel here
     .mshl-txsum          the three-up count strip on the queue and the tree,
                          unchanged — it already solves "three figures across
                          390px without breaking one across two lines"
     .mshl-grp / .mshl-row / __ico / __t / __sub / __lock / __chev
                          the out-of-scope rows and the two Oversight index rows
     .mshl-mk             the status pill's shape. Only its three TONES are new
                          below, declared the way `--date` already is
     .mshl-btn / --outline / --primary,  .mshl-foot,  .mshl-loading
     .fsheet__*           FPFM-94's shared component, in its SIXTH use. Not one
                          `.fsheet` rule is declared here or anywhere for it

   THE ONE-EDGE STRIPE IS NOT HERE AND IS NOT COMING (FPFM-95). Every state on
   these four screens — pending, approved, rejected, "your branch", "not your
   scope", and all seven verdicts — is carried by a WORD. Colour reinforces and
   never carries. The greyed branch is the only place tone does real work, and
   it is paired with the words "not your scope" in the same row.

   `mshl-aud` and NOT `mshl-al`: `.mshl-alert*` already exists eight lines' worth
   above and a two-letter prefix of a live class is a grep that lies to the next
   reader. Class names are whole tokens so it would have worked; it would just
   have been unreadable.

   ---- THE GREY SCALE IS NOT A RAMP, AND THIS BLOCK GOT IT WRONG ONCE --------
   Written down here because it cost a screenshot and it will cost the next
   person one too. This file's greys are remapped per theme and they are NOT
   monotonic — they are three bands with different jobs:

       --gray-50 .. --gray-200    SURFACES and borders   (light: near-white)
       --gray-300 .. --gray-500   TEXT                   (light: #555/#666/#888)
       --gray-600 .. --gray-900   SURFACES again         (light: #d1d5db..#f7f7f8)

   So `--gray-900` is NOT "the darkest text". In the light theme it is #f7f7f8,
   which is very nearly the page. The first draft of this block used
   `--gray-900` for the request sentence, the verdict heading, the before/after
   figures, the branch names and the audit module names, and every one of them
   rendered as near-white on white — a screen that passed every DOM assertion in
   tests/mobile-shell-oversight.test.js and was unreadable in the capture.

   Primary text in this shell is `var(--secondary)`, which is what
   `.mshl-card__title` above already uses and which inverts correctly with the
   theme. Secondary text is `--gray-400`, which is what `.mshl-card__note` uses.
   Nothing below sets a text colour outside the 300-500 band or `--secondary`,
   and tools/fpfm106-measure.mjs reads the CONTRAST of each of them out of a
   real browser rather than trusting this paragraph.
   ------------------------------------------------------------------------- */

/* "NOT YOUR SCOPE" IS RAISED ON THESE TWO SCREENS, AND THE SHIPPED RULE IS LEFT
   ALONE.

   `.mshl-row__lock` was declared by FPFM-98 with `color: var(--gray-500)`, which
   tools/fpfm106-measure.mjs reads at 3.31:1 — below the 4.5:1 floor. On the More
   index that is a label on a disabled row. On these two screens it is the label
   that carries Renzo's whole ruling: out-of-scope rows are DRAWN and LABELLED
   rather than hidden, and a label nobody can read is a row that was hidden after
   all.

   So the colour is raised HERE ONLY, by two selectors that cannot reach the More
   index. The shipped rule is untouched, so More stays byte-identical under the
   pixel gate, and the shipped shortfall is reported as a finding rather than
   repaired by a ticket that was told not to.

   `.mshl-row--ovout` carries nothing else. It is a hook, and js/mobile-shell.js
   says so where it is written. */
.mshl-hier .mshl-row__lock,
.mshl-row--ovout .mshl-row__lock {
    color: var(--gray-400);
}

/* The three status tones. `.mshl-mk` above supplies the shape, the weight and
   the letter-spacing; these supply colour only, exactly as `--date` does. */
.mshl-mk--ok { color: var(--success); }
.mshl-mk--warn { color: var(--warning); }
.mshl-mk--bad { color: var(--error); }

/* A glyph beside a card title, for the two "you cannot decide this" panels.
   `.mshl-card__head` is already `display:flex; align-items:center` with a gap,
   so this only has to stop the SVG from being stretched by `align-items` and
   size it to the heading beside it. */
.mshl-card__ico {
    display: inline-flex;
    flex: 0 0 auto;
    color: var(--gray-400);
}

.mshl-card__ico .mshl-i { width: 18px; height: 18px; }

/* ---- one edit request, as a card ---- */

.mshl-erq {
    background: var(--bg-card);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-lg);
    padding: var(--spacing-4);
    margin-bottom: var(--spacing-3);
}

.mshl-erq__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-2);
    margin-bottom: var(--spacing-2);
}

.mshl-erq__when {
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    white-space: nowrap;
}

/* THE SENTENCE THE OFFICER TYPED, AND IT IS NEVER CLAMPED. The desktop table
   gives it two lines and an ellipsis; on a phone the sentence IS the request and
   a head who cannot read it cannot decide. No line-clamp, no max-height, no
   overflow — the card grows. `overflow-wrap` because these are typed by hand and
   one of them in production is a bare number long enough to push the card wide. */
.mshl-erq__ask {
    margin: 0;
    color: var(--secondary);
    font-size: var(--font-size-base);
    font-weight: 600;
    line-height: 1.45;
    overflow-wrap: anywhere;
}

.mshl-erq__why {
    margin: var(--spacing-2) 0 0;
    color: var(--gray-400);
    font-size: var(--font-size-sm);
    font-style: italic;
    line-height: 1.5;
    overflow-wrap: anywhere;
}

/* Asked by / Feast / Approving. Three columns on the card, and each cell is a
   caption over a value — the same shape `.mshl-txsum` uses, at list weight
   rather than figure weight, because none of these three is a number. */
.mshl-erq__meta {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--spacing-3);
    margin: var(--spacing-3) 0 0;
    padding-top: var(--spacing-3);
    border-top: 1px solid var(--gray-200);
}

.mshl-erq__meta--stack {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--spacing-2);
    border-top: 0;
    padding-top: 0;
}

.mshl-erq__meta > div { min-width: 0; }

.mshl-erq__meta dt {
    color: var(--gray-400);
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}

.mshl-erq__meta dd {
    margin: 2px 0 0;
    color: var(--secondary);
    font-size: var(--font-size-sm);
    font-weight: 500;
    line-height: 1.4;
    overflow-wrap: anywhere;
}

/* Below 340px three columns put one word per line. The stacked form is the same
   information at the same weight, which is why there is no second breakpoint
   for the `--stack` modifier: it is already this. */
@media (max-width: 339px) {
    .mshl-erq__meta { grid-template-columns: minmax(0, 1fr); }
}

.mshl-erq .mshl-btn {
    width: 100%;
    margin-top: var(--spacing-3);
}

/* ---- what approving would do ---- */

/* BORDERED ON ALL FOUR SIDES, deliberately (FPFM-95). This panel carries the
   single most consequential sentence on these screens — whether a decision
   changes a financial record — and the temptation to give it a coloured left
   edge is exactly the one that rule exists to refuse. Its heading says which of
   the seven verdicts it is. */
.mshl-ovw {
    background: var(--gray-50);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-lg);
    padding: var(--spacing-4);
    margin-bottom: var(--spacing-3);
}

.mshl-ovw__t {
    margin: 0 0 var(--spacing-2);
    color: var(--secondary);
    font-size: var(--font-size-sm);
    font-weight: 700;
    letter-spacing: 0.02em;
    text-transform: uppercase;
}

.mshl-ovw .mshl-card__note { margin-top: var(--spacing-2); }

/* THE BEFORE AND AFTER. A two-row grid rather than one line with an arrow in
   the middle: at 390px an amount like ₱167,231.75 on each side of an arrow is
   34 characters, which wraps mid-figure. Caption, value, caption, value — the
   arrow sits in its own cell between the two rows and reads down. */
.mshl-ovw__ba {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 2px;
    margin: var(--spacing-3) 0 0;
    padding: var(--spacing-3);
    background: var(--bg-card);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-md);
}

.mshl-ovw__k {
    color: var(--gray-400);
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}

.mshl-ovw__ba b {
    color: var(--secondary);
    font-size: var(--font-size-base);
    font-weight: 700;
    line-height: 1.35;
    overflow-wrap: anywhere;
}

.mshl-ovw__arr {
    margin: var(--spacing-1) 0;
    color: var(--gray-400);
    font-size: var(--font-size-base);
    line-height: 1;
}

/* A comparison shown for a request that will NOT be applied. Same block, muted,
   and the caption above it reads "Asked for" rather than "After approval" — the
   words do the work, this only stops the pair looking like a promise. */
.mshl-ovw__ba--off { opacity: 0.72; }

/* ---- the hierarchy ---- */

.mshl-hier {
    background: var(--bg-card);
    border: 1px solid var(--gray-200);
    border-radius: var(--radius-lg);
    margin-bottom: var(--spacing-3);
    overflow: hidden;
}

/* THE 44px FLOOR IS SPELLED OUT, not delegated to `.btn-touch` or `.btn`.
   css/styles.css declares a bare `.btn {}` four times and the last is inside
   `@media (pointer: coarse)`, so the cascade yields 44 in an emulator and 40 in
   a resized desktop window — the same reason the FPFM-87 block spells its
   heights out. This is the tap target for a whole branch. */
.mshl-hier__head {
    display: flex;
    align-items: center;
    gap: var(--spacing-2);
    width: 100%;
    min-height: 56px;
    padding: var(--spacing-3) var(--spacing-4);
    background: none;
    border: 0;
    text-align: left;
    cursor: pointer;
}

.mshl-hier__id {
    flex: 1 1 auto;
    min-width: 0;
}

.mshl-hier__id b {
    display: block;
    color: var(--secondary);
    font-size: var(--font-size-base);
    font-weight: 700;
    line-height: 1.3;
    overflow-wrap: anywhere;
}

.mshl-hier__id small {
    display: block;
    margin-top: 2px;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    line-height: 1.4;
}

.mshl-hier__chev {
    display: inline-flex;
    flex: 0 0 auto;
    color: var(--gray-400);
    transition: transform 0.15s ease;
}

.mshl-hier__chev.is-open { transform: rotate(180deg); }

@media (prefers-reduced-motion: reduce) {
    .mshl-hier__chev { transition: none; }
}

.mshl-hier__body {
    padding: 0 var(--spacing-4) var(--spacing-4);
    border-top: 1px solid var(--gray-100);
}

.mshl-hier__d { padding-top: var(--spacing-3); }

.mshl-hier__dn {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--spacing-2);
    margin: 0 0 var(--spacing-1);
    color: var(--gray-400);
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}

.mshl-hier__dn span {
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    font-weight: 600;
    letter-spacing: 0;
}

/* The feasts under a district. A single left rail joins them to the district
   heading above — one line that actually reaches, which is the correction
   hierarchy.html's own comment records making to its connector bus. */
.mshl-hier__f {
    margin: 0;
    padding: 0 0 0 var(--spacing-3);
    border-left: 2px solid var(--gray-200);
    list-style: none;
}

.mshl-hier__f li {
    padding: var(--spacing-1) 0;
    color: var(--secondary);
    font-size: var(--font-size-sm);
    line-height: 1.45;
    overflow-wrap: anywhere;
}

.mshl-hier__f li.is-mine b {
    color: var(--primary);
    font-weight: 700;
}

.mshl-hier__none {
    margin: 0;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    line-height: 1.5;
}

/* AN OUT-OF-SCOPE BRANCH IS MUTED BY ITS SURFACE, NEVER BY OPACITY ON ITS TEXT.

   This rule was `opacity: 0.6` on the whole branch and tools/fpfm106-measure.mjs
   refused it: the branch's own sub-line and its "not your scope" label measured
   1.99:1 against a floor of 4.5. Opacity multiplies EVERY layer including the
   glyphs, so a 0.6 card is a 0.6 sentence — and the entire argument for drawing
   an out-of-scope branch rather than hiding it is that the reader can tell what
   the branch IS and that it exists. A name at 1.99:1 is hidden with extra steps,
   which is the ruling defeated by the styling meant to implement it.

   So the SURFACE carries the muting and the text does not: a recessed fill and a
   dashed border say "shut", and every word inside stays at full strength. The
   words still do the work (FPFM-95) — `.mshl-row__lock` prints "not your scope"
   in the header of every one of these, so the tone only reinforces a statement
   already on screen.

   Re-measured after the change in section 2 of the measurement run, which is
   also the only reason this is known rather than believed. */
.mshl-hier.is-off {
    background: var(--bg-surface);
    border-style: dashed;
}

.mshl-hier__f li.is-off { color: var(--gray-400); }

/* ---- the audit log ---- */

.mshl-aud {
    margin: 0;
    padding: 0;
    list-style: none;
}

.mshl-aud__row {
    display: flex;
    align-items: flex-start;
    gap: var(--spacing-3);
    padding: var(--spacing-3) 0;
    border-bottom: 1px solid var(--gray-100);
}

.mshl-aud__row:last-child { border-bottom: 0; }

/* The action, as a word in a pill. A fixed column so twenty rows read as a
   column of verbs rather than as ragged text — 84px fits "Payment Failed" at
   this size, which is the longest label js/audit-log.js declares. */
.mshl-aud__act {
    flex: 0 0 84px;
    color: var(--gray-400);
    text-align: left;
}

.mshl-aud__main {
    flex: 1 1 auto;
    min-width: 0;
}

.mshl-aud__main b {
    display: block;
    color: var(--secondary);
    font-size: var(--font-size-sm);
    font-weight: 600;
    line-height: 1.35;
}

.mshl-aud__main small {
    display: block;
    margin-top: 2px;
    color: var(--gray-400);
    font-size: var(--font-size-xs);
    line-height: 1.4;
    overflow-wrap: anywhere;
}

/* ==========================================================================
   THE WRITE SCREENS — the Record sheet, Cash In and Cash Out   FPFM-102
   ==========================================================================
   Still inside the `mshl-` block above, and every selector below begins
   `mshl-`. Each of `mshl-rec`, `mshl-sheet`, `mshl-entry`, `mshl-line`,
   `mshl-prog`, `mshl-drop`, `mshl-rev` and `mshl-done` returned ZERO matches
   across css/, js/ and all 23 pages before this block was written, so nothing
   here redeclares an existing rule and nothing is declared twice.

   NO NEW BREAKPOINT AND NO NEW FIGURE. Spacing, radius, colour and type all
   come from the same custom properties the shell already uses; the literal
   pixel values are the 44px touch target this file already spells out for the
   reason given at the top of the mshl- block (a bare `.btn` is declared four
   times, the last inside `@media (pointer: coarse)`, so the cascade yields 44
   in an emulator and 40 in a resized window — spelling it means the question
   does not arise), and the 64px tab-bar height that .mshl-scroll already
   reserves.

   REACHED ONLY FROM mobile.html. Nothing on any desktop page carries an
   `mshl-` class; the assertion that proves it is section 16 of
   tests/mobile-shell-structure.test.js.
   ========================================================================== */

/* ---------- the Record sheet (proposal capture 08) ---------- */

/* The sheet sits over the reader's own home screen, which is rendered
   underneath it in full — homeOfficer(), the same call route `home` makes.
   The scrim is what makes it read as a layer rather than as a card at the
   bottom of a long page. `position: fixed` and not absolute: .mshl-scroll is
   the scrolling box, so an absolutely-positioned scrim would scroll with the
   figures it is meant to be covering. */
.mshl-sheet__scrim {
    position: fixed;
    inset: var(--mshl-app-top, 0px) 0 0 0;
    background: rgba(0, 0, 0, 0.45);
    z-index: 5;
}

.mshl-sheet {
    position: fixed;
    left: 0;
    right: 0;
    bottom: calc(64px + env(safe-area-inset-bottom, 0px));
    z-index: 6;
    background: var(--bg-card, #fff);
    border-radius: var(--radius-xl, 20px) var(--radius-xl, 20px) 0 0;
    padding: var(--spacing-3) var(--spacing-4) var(--spacing-5);
    /* The sheet is a list and the list can outgrow the phone. It scrolls
       itself rather than pushing its own foot off the screen. */
    max-height: 72vh;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.25);
}

.mshl-sheet__grab {
    display: block;
    width: 40px;
    height: 4px;
    margin: 0 auto var(--spacing-3);
    border-radius: 2px;
    background: var(--gray-300, #d1d5db);
}

.mshl-sheet__t {
    margin: 0 0 var(--spacing-1);
    font-size: var(--font-size-xl, 20px);
    font-weight: 700;
    color: var(--text-primary);
}

.mshl-sheet__p {
    margin: 0 0 var(--spacing-4);
    font-size: var(--font-size-sm);
    color: var(--gray-500);
    line-height: 1.45;
}

.mshl-rec__list { list-style: none; margin: 0; padding: 0; }
.mshl-rec__li { margin-bottom: var(--spacing-2); }

.mshl-rec__row {
    display: flex;
    align-items: center;
    gap: var(--spacing-3);
    width: 100%;
    min-height: 44px;
    padding: var(--spacing-3);
    text-align: left;
    background: transparent;
    border: 1px solid var(--gray-200, #e5e7eb);
    border-radius: var(--radius-lg, 12px);
    cursor: pointer;
    color: inherit;
}

.mshl-rec__ic {
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    width: 40px;
    height: 40px;
    border-radius: var(--radius-md, 10px);
}
.mshl-rec__ic--in    { background: rgba(16, 185, 129, 0.15); color: var(--success); }
.mshl-rec__ic--out   { background: rgba(220, 38, 38, 0.12);  color: var(--primary); }
.mshl-rec__ic--blue  { background: rgba(59, 130, 246, 0.14); color: #3B82F6; }
.mshl-rec__ic--amber { background: rgba(245, 158, 11, 0.16); color: #B45309; }
.mshl-rec__ic--pink  { background: rgba(236, 72, 153, 0.14); color: #DB2777; }

/* min-width: 0 on the text cell, not only on the row. A flex item's automatic
   minimum is min-content, so a long subtitle refuses to wrap and pushes the
   chevron off the right edge instead. */
.mshl-rec__txt { flex: 1 1 auto; min-width: 0; }
.mshl-rec__t {
    display: block;
    font-weight: 600;
    color: var(--text-primary);
}
.mshl-rec__s {
    display: block;
    font-size: var(--font-size-sm);
    color: var(--gray-500);
    line-height: 1.35;
}
.mshl-rec__chev { flex: 0 0 auto; color: var(--gray-400); }
.mshl-rec__none {
    margin: 0;
    font-size: var(--font-size-sm);
    color: var(--gray-500);
    line-height: 1.5;
}

/* ---------- the flow's chrome ---------- */

/* The entry screens keep the white bar every non-home screen wears. The hero
   red is a HOME treatment (see topbar()) and a red bar over a form the reader
   is filling in would say "this screen is the summary of your feast", which is
   the one thing it is not. */
.mshl-top--entry { justify-content: flex-start; }

.mshl-prog {
    display: flex;
    gap: var(--spacing-2);
}
.mshl-prog__s {
    flex: 1 1 0;
    min-width: 0;
    height: 4px;
    border-radius: 2px;
    background: var(--gray-200, #e5e7eb);
}
.mshl-prog__s.is-on { background: var(--primary, #DC2626); }
.mshl-prog__c {
    margin: 0;
    font-size: var(--font-size-sm);
    color: var(--gray-500);
}

.mshl-entry { display: flex; flex-direction: column; gap: var(--spacing-2); }
.mshl-entry > * { flex: 0 0 auto; }

.mshl-entry__lb {
    margin: var(--spacing-2) 0 0;
    font-weight: 600;
    font-size: var(--font-size-sm);
    color: var(--text-primary);
}
.mshl-entry__lb i { color: var(--primary, #DC2626); font-style: normal; }

.mshl-entry__in,
.mshl-entry__sel {
    width: 100%;
    min-width: 0;
    min-height: 44px;
    padding: 0 var(--spacing-3);
    font-size: 16px; /* below 16 iOS zooms the whole page on focus */
    font-family: inherit;
    color: var(--text-primary);
    background: var(--bg-card, #fff);
    border: 1px solid var(--gray-300, #d1d5db);
    border-radius: var(--radius-md, 10px);
    -webkit-appearance: none;
    appearance: none;
}
.mshl-entry__ta { min-height: 68px; padding: var(--spacing-2) var(--spacing-3); }
/* THE AMOUNT IS THE FIGURE THE LINE IS ABOUT, and on a card of four identical
   boxes it has to be findable without reading the label. Bold and tabular, so
   a column of them lines up on the decimal point when the reader scrolls the
   lines — the one place on this screen where comparing two numbers by eye is
   the actual task. */
.mshl-entry__amt {
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}

.mshl-entry__locked {
    margin: 0;
    min-height: 44px;
    display: flex;
    align-items: center;
    padding: 0 var(--spacing-3);
    font-weight: 600;
    color: var(--text-primary);
    background: var(--gray-100, #f3f4f6);
    border: 1px solid var(--gray-200, #e5e7eb);
    border-radius: var(--radius-md, 10px);
}

.mshl-entry__hint,
.mshl-entry__wait {
    margin: 0;
    font-size: var(--font-size-sm);
    color: var(--gray-500);
    line-height: 1.4;
}

.mshl-entry__warn {
    margin: 0;
    font-size: var(--font-size-sm);
    color: #B45309;
    line-height: 1.45;
}

/* THE ERROR RED IS DARKENED FOR TEXT, AND IT WAS MEASURED RATHER THAN JUDGED.
   `--error` is #EF4444 in the light theme, which computes to 3.51:1 against
   this shell's page background — under the 4.5:1 body-text threshold, on the
   most important sentence on the screen. tools/fpfm102-measure.mjs claim 8
   found it; I did not see it on the capture, which is exactly why a contrast
   claim is a computed value and not an opinion.

   #B91C1C is the same hue two steps down the ramp and clears the threshold at
   6.04:1 — the figure a browser actually measured (measurements.txt claim 8)
   and the one tools/fpfm102-contrast-before.mjs recomputes. This comment said
   5.9:1, which was a number nothing produced. The token is left alone where it
   carries a BORDER — a border is not
   text and is not held to a text threshold — so `.mshl-line.is-bad` and the
   Delete outline still use it. */
.mshl-entry__err {
    margin: 0;
    font-size: var(--font-size-sm);
    font-weight: 600;
    color: #B91C1C;
    line-height: 1.45;
}

/* ---------- a line ---------- */

.mshl-line {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-2);
    padding: var(--spacing-3);
    border: 1px solid var(--gray-200, #e5e7eb);
    border-radius: var(--radius-lg, 12px);
}
/* A LINE AT FAULT IS OUTLINED, NOT TINTED ON ONE EDGE. The mshl- block's own
   rule at the top of it: severity is carried by the whole control, never by a
   coloured border on one side of a card. */
.mshl-line.is-bad { border-color: var(--error, #DC2626); }

.mshl-line__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-2);
}
.mshl-line__n {
    font-size: var(--font-size-sm);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--gray-500);
}
.mshl-line__del {
    min-height: 44px;
    padding: 0 var(--spacing-3);
    font-weight: 600;
    /* Text: the darkened red (see .mshl-entry__err). Border: the token, because
       a border is not held to a text threshold. */
    color: #B91C1C;
    background: transparent;
    border: 1px solid var(--error, #DC2626);
    border-radius: var(--radius-md, 10px);
    cursor: pointer;
}
.mshl-line__err {
    margin: 0;
    font-size: var(--font-size-sm);
    font-weight: 600;
    color: #B91C1C;
}

.mshl-entry__add {
    min-height: 44px;
    font-weight: 600;
    color: var(--primary, #DC2626);
    background: transparent;
    border: 1px dashed var(--primary, #DC2626);
    border-radius: var(--radius-lg, 12px);
    cursor: pointer;
}

.mshl-entry__total {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-3);
    padding: var(--spacing-3);
    border-radius: var(--radius-lg, 12px);
    /* A LITERAL, AND `--gray-900` IS THE REASON. That token is #f7f7f8 in the
       light theme and #111827 in the dark one — it names a position in a ramp
       that INVERTS, so it is a text colour and never a surface. Used here it
       painted white text on a near-white block and the running total of a
       deposit was invisible in light mode. Caught by looking at the capture,
       and now caught by a number: tools/fpfm102-measure.mjs claim 8 computes
       the contrast ratio of this block and of the review hero in a real
       browser, so the next person to reach for a ramp token gets a failing
       figure rather than a screenshot nobody re-read.

       #111827 is the same value --gray-900 holds in dark mode, so this block
       looks identical there and correct here. */
    background: #111827;
    color: #fff;
}
.mshl-entry__total span { font-size: var(--font-size-sm); opacity: 0.85; min-width: 0; }
.mshl-entry__total b { font-size: var(--font-size-xl, 20px); }

/* ---------- step 2 ---------- */

.mshl-entry__must {
    padding: var(--spacing-3);
    border: 1px solid #F59E0B;
    border-radius: var(--radius-lg, 12px);
    background: rgba(245, 158, 11, 0.12);
    color: #92400E;
    font-size: var(--font-size-sm);
    line-height: 1.5;
}

.mshl-drop {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--spacing-1);
    padding: var(--spacing-6) var(--spacing-4);
    text-align: center;
    border: 1px dashed var(--gray-300, #d1d5db);
    border-radius: var(--radius-lg, 12px);
    background: var(--bg-card, #fff);
}
.mshl-drop.is-on {
    border-style: solid;
    border-color: var(--success, #10B981);
    background: rgba(16, 185, 129, 0.10);
}
.mshl-drop__ic {
    display: grid;
    place-items: center;
    width: 56px;
    height: 56px;
    border-radius: var(--radius-lg, 12px);
    background: rgba(245, 158, 11, 0.16);
    color: #B45309;
}
.mshl-drop__t { margin: var(--spacing-2) 0 0; font-weight: 700; color: var(--text-primary); }
.mshl-drop__n { margin: 0; font-weight: 600; color: var(--text-primary); word-break: break-all; }
.mshl-drop__s { margin: 0; font-size: var(--font-size-sm); color: var(--gray-500); }

/* A <label> wrapping the file input, because a styled <input type=file> cannot
   be made to look like a button reliably across browsers and a separate button
   that calls .click() breaks when the page is re-rendered under it — which this
   shell does on every state change. */
.mshl-entry__file {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    font-weight: 600;
    color: var(--text-primary);
    background: var(--bg-card, #fff);
    border: 1px solid var(--gray-300, #d1d5db);
    border-radius: var(--radius-md, 10px);
    cursor: pointer;
}
.mshl-entry__file input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.mshl-entry__file--off { color: #B91C1C; }

.mshl-entry__later {
    padding: var(--spacing-3);
    border: 1px solid var(--gray-200, #e5e7eb);
    border-radius: var(--radius-lg, 12px);
}
.mshl-entry__later-t { margin: 0 0 var(--spacing-1); font-weight: 700; color: var(--text-primary); }
.mshl-entry__later-p { margin: 0; font-size: var(--font-size-sm); color: var(--gray-500); line-height: 1.45; }

/* ---------- step 3 and done ---------- */

.mshl-rev__hero {
    padding: var(--spacing-4);
    border-radius: var(--radius-lg, 12px);
    color: #fff;
}
.mshl-rev__hero--in  { background: var(--primary, #DC2626); }
/* A literal, for the reason spelled out on .mshl-entry__total above: --gray-900
   inverts between the themes and cannot carry a surface. */
.mshl-rev__hero--out { background: #111827; }
.mshl-rev__k {
    margin: 0;
    font-size: var(--font-size-sm);
    font-weight: 700;
    letter-spacing: 0.06em;
    opacity: 0.85;
}
.mshl-rev__v { margin: var(--spacing-1) 0 0; font-size: 34px; font-weight: 800; line-height: 1.1; }
.mshl-rev__s { margin: var(--spacing-1) 0 0; font-size: var(--font-size-sm); opacity: 0.9; }

.mshl-rev {
    border: 1px solid var(--gray-200, #e5e7eb);
    border-radius: var(--radius-lg, 12px);
    padding: 0 var(--spacing-3);
    background: var(--bg-card, #fff);
}
.mshl-rev__r {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--spacing-3);
    padding: var(--spacing-3) 0;
    border-bottom: 1px solid var(--gray-100, #f3f4f6);
}
.mshl-rev__r:last-child { border-bottom: 0; }
.mshl-rev__r span { flex: 0 0 auto; font-size: var(--font-size-sm); color: var(--gray-500); }
/* The VALUE is the one that wraps. It is the longer of the two and it is the
   one that can be a five-word category plus a person's full name; letting the
   label wrap instead would leave "Line" over "1". */
.mshl-rev__r b { flex: 1 1 auto; min-width: 0; text-align: right; color: var(--text-primary); }

.mshl-rev__warn {
    padding: var(--spacing-3);
    border: 1px solid #3B82F6;
    border-radius: var(--radius-lg, 12px);
    background: rgba(59, 130, 246, 0.10);
    color: #1E3A8A;
    font-size: var(--font-size-sm);
    line-height: 1.5;
}

/* "What happens next" is the SAME note as the pre-commit warning, deliberately
   — same blue, same weight — because it is the same fact said at the two moments
   it matters: before, so she can stop; after, so she knows what she is now
   holding. The modifier exists only to widen the line spacing on a block that
   is read rather than skimmed, and to be the hook this screen is measured by. */
.mshl-rev__warn--next { line-height: 1.6; }

.mshl-done { text-align: center; }
.mshl-done__tick {
    display: grid;
    place-items: center;
    width: 72px;
    height: 72px;
    margin: var(--spacing-4) auto var(--spacing-3);
    border-radius: 50%;
    background: rgba(16, 185, 129, 0.16);
    color: var(--success, #10B981);
}
.mshl-done__t { margin: 0; font-size: var(--font-size-xl, 20px); font-weight: 800; color: var(--text-primary); }
.mshl-done__p { margin: var(--spacing-2) 0 0; font-size: var(--font-size-sm); color: var(--gray-500); line-height: 1.5; }
.mshl-done__v { margin: var(--spacing-4) 0 0; font-size: 34px; font-weight: 800; color: var(--text-primary); }
.mshl-done__s { margin: var(--spacing-1) 0 var(--spacing-4); font-size: var(--font-size-sm); color: var(--gray-500); }
.mshl-done .mshl-rev,
.mshl-done .mshl-rev__warn { text-align: left; margin-bottom: var(--spacing-3); }

/* ---------- the pinned action ----------
   FIXED, AND ABOVE THE TAB BAR RATHER THAN BEHIND IT. The proposal's captures
   09 through 16 all show the primary action sitting on top of the tab bar with
   the centre button overlapping it, and that is what this reproduces: the tab
   bar stays reachable for the whole flow, which is the shell's own rule for a
   pushed screen. */
.mshl-entry__foot {
    position: fixed;
    left: 0;
    right: 0;
    bottom: calc(64px + env(safe-area-inset-bottom, 0px));
    z-index: 4;
    display: flex;
    flex-direction: column;
    gap: var(--spacing-2);
    padding: var(--spacing-3) var(--spacing-4);
    background: var(--bg-card, #fff);
    border-top: 1px solid var(--gray-200, #e5e7eb);
}
.mshl-entry__footnote {
    margin: 0;
    font-size: var(--font-size-sm);
    color: var(--gray-500);
    text-align: center;
}
.mshl-entry__go {
    min-height: 52px;
    font-size: 16px;
    font-weight: 700;
    color: #fff;
    background: var(--primary, #DC2626);
    border: 0;
    border-radius: var(--radius-lg, 12px);
    cursor: pointer;
}
/* DISABLED IS A REAL STATE, not a hint. Step 2's primary action is genuinely
   unavailable until a photo is attached — the element carries `disabled` and
   aria-disabled, so a screen reader says so; this only makes it look like what
   it is. */
.mshl-entry__go.is-off,
.mshl-entry__go[disabled] {
    background: var(--gray-200, #e5e7eb);
    color: var(--gray-400, #9ca3af);
    cursor: not-allowed;
}
.mshl-entry__go--ghost {
    color: var(--text-primary);
    background: var(--bg-card, #fff);
    border: 1px solid var(--gray-300, #d1d5db);
}

/* The scroll region has to clear the pinned bar as well as the tab bar, or the
   last field sits behind the button that acts on it.

   THE FIGURE IS MEASURED, NOT WRITTEN, and this app has already learned that
   lesson once. js/entry-lines.js trackPinnedBar() carries the note in full:
   `.has-pinned-actions` used `padding-bottom: 180px`, "a number read off one
   screenshot, at one text size, with the buttons on one row" — raise the OS
   text size and the real bar goes past it, at which point the button covers the
   field above it and nothing in CSS notices.

   The same thing is true here and it was measured rather than assumed: a first
   version of this rule reserved a written 168px for a bar that
   tools/fpfm102-measure.mjs then measured at 77px, leaving 155px of dead space
   under the last field on every step. So a ResizeObserver in js/mobile-shell.js
   writes the bar's real height to `--mshl-foot-h`, and this consumes it. The
   fallback is 168px — the old written number, kept as the degraded case for a
   browser with no ResizeObserver, exactly as entry-lines.js keeps its 180px.

   Scoped with :has() to the screens that HAVE such a bar, so every other screen
   in the shell keeps its own footer gap untouched. */
.mshl-body:has(.mshl-entry__foot) .mshl-scroll {
    padding-bottom: calc(64px + var(--mshl-foot-h, 168px) + var(--spacing-4) +
        env(safe-area-inset-bottom, 0px));
}

/* ==========================================================================
   TITHERS PAGE - PHONE (tithers.html only)   FPFM-87
   ==========================================================================
   tithers.html carries NO inline <style> block - checked, it is not one of the
   15 pages that do - so this file is the correct home for its rules. It also
   uses `.table-container` rather than `.table-responsive`, so it never carried
   the FPFM-84 dead-grey defect and nothing here addresses one.

   Every selector below is new: `tth-`, `titherFilters`, `titherFilterCount`,
   `titherFiltersToggle` and `tithersPhoneCards` each returned zero matches
   across css/, *.html and js/ before this block was written. Nothing existing
   is redeclared, and no selector here is declared twice.

   `.filters-bar` is reused rather than redeclared, and it is safe to scope
   against: grepped across css/, js/ and all 22 pages, it has exactly one call
   site in markup and that is tithers.html. `.empty-state`, the modal footers
   and the summary tiles are untouched.

   PLACED ABOVE the FPFM-82 banner below, deliberately, and the reason has
   OUTLIVED THE TEST THAT GAVE IT. It was tests/attendance-filter-sheet.test.js
   that bounded that block as "its banner to END OF FILE"; FPFM-94 deleted
   that file along with the per-page badge function it drove, and its executed
   assertions live in tests/filter-sheet-badge.test.js now. The placement
   still stands on its own: the FPFM-82 banner claims everything after it, so
   a block appended below it reads as part of a section it has nothing to do
   with. Do not move this below that banner.

   767, matching the phone breakpoint the budget and attendance blocks already
   use on this file, so a third figure is not introduced.

   Heights are spelled out rather than delegated to `.btn-touch` or to `.btn`.
   css/styles.css declares a bare `.btn {}` four times and the last of them is
   inside `@media (pointer: coarse)`, so the cascade yields 44px in an emulator
   and 40px in a resized desktop window. Spelling the figure out means the
   question does not arise. Those duplicates are logged elsewhere and are not
   touched here. */

/* The phone list is a phone thing. Above the breakpoint it does not exist.
   Turned back on below by an id, so the cascade is decided by specificity
   rather than by which rule happens to be written last. */
.tth-list {
    display: none;
    list-style: none;
    margin: 0;
    padding: 0;
}

/* THE FILTER SHEET IS NOT HERE ANY MORE. FPFM-94 extracted all four
   hand-copies into one `.fsheet__*` component at the END of this file.
   tithers.html is a `.fsheet--search` page. Do not re-add a `tth-filters__*`
   rule here; tests/filter-sheet-shared.test.js fails the build if you do. */

/* --- ONE CARD PER TITHER, AT EVERY WIDTH -------------------------------------
   FPFM-104. These rules were inside the `@media (max-width: 767px)` block below
   until this ticket. They are OUT of it now, and the reason is a split by JOB
   rather than by property name.

   THE TWO JOBS. The rules in this section do two separate things:

     (a) decide WHETHER the phone list is shown at all, and
     (b) decide WHAT THE CARD LOOKS LIKE once it is.

   Only (a) is width-dependent. Everything below is (b), and every declaration
   in (a) is still inside the media query where it was:

       .tth-list { display: none }        top level, unmediated   — the default
       #tithersTable { display: none }    inside @media <=767px   — hide the table
       #tithersPhoneCards { display: block }  inside @media <=767px — THE ENABLE SWITCH

   WHY IT HAD TO MOVE. js/tither-card.js is now the one generator and the mobile
   shell renders through it, into `#mshlTithersCards`. The shell is reachable at
   EVERY width — js/sidebar.js mobileShellOpen() navigates from the account menu
   with no viewport check, deliberately — so at 1440 the shell drew a roster whose
   every appearance rule was switched off, and the figures ran together:
   "Total₱0.00", "Times0", "LastNever". Scoping the shell's own container cannot
   reach these: they style the container's CHILDREN and the media query is on the
   declarations themselves, not on an ancestor.

   WHY IT IS SAFE FOR tithers.html, PROVEN AND NOT ASSUMED. Above 767px that page
   shows the table and `#tithersPhoneCards` never gets its `display: block`, so
   the <ul> computes `display: none` and every `.tth-card` inside it generates no
   box at all. Measured on the live page, both halves, in
   docs/evidence/FPFM-104/premise.txt: 3 cards in the DOM at 1440, 0 visible,
   every one hidden by `UL#tithersPhoneCards display:none`, against a 390 control
   arm on the same page and persona where all 3 measure 324x76. So these
   declarations are unreachable on tithers.html above the breakpoint whether they
   are inside a media query or not, and lifting them changes that page nowhere.

   THE `display` DECLARATIONS BELOW ARE NOT ENABLE SWITCHES. `.tth-card__head`,
   `__figs`, `__fig` and `__actions` set flex and grid on elements whose default
   is already visible, and no rule anywhere in this file gives a `.tth-card*`
   selector `display: none`. They are layout, which is job (b). The one
   declaration that IS an enable switch — `#tithersPhoneCards { display: block }`
   — stayed put, and the shell's equivalent `#mshlTithersCards { display: block }`
   is likewise declared unmediated in the shell's own block above.

   ONE COPY, BOTH HOSTS. Nothing here is duplicated into the shell block; the
   shell adds no `.tth-` rule of any kind, which tests/tither-card-shared.test.js
   re-derives.

   Heights are still spelled out rather than delegated to `.btn-touch` or `.btn`,
   for the reason the banner above gives.

   Attendance's `.atn-card` vocabulary, so the two phone lists in this app read as
   one design. Keeps every column the desktop table has — name, nickname, feast,
   total, times, last tithe, status and both actions — at the cost of height:
   about three tithers per screen.

   A compact two-line row was previewed beside this and NOT chosen: it could carry
   neither Feast nor Times Given at 390px, and its View action was an invisible
   button stretched over the whole row, which FPFM-86 bans. Nothing of it survives
   in this file. */
.tth-card {
    position: relative;
    padding: var(--spacing-3) var(--spacing-4);
}

.tth-card + .tth-card {
    border-top: 1px solid var(--gray-700);
}

.tth-card__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-2);
    margin-bottom: var(--spacing-1);
}

/* min-width: 0 is load-bearing - a flex item defaults to min-width:auto and
   refuses to shrink below its content, so without it a long name pushes the
   status badge off the card instead of wrapping. */
.tth-card__name {
    min-width: 0;
    font-size: var(--font-size-sm);
    font-weight: 600;
    line-height: 1.3;
    color: var(--secondary);
    overflow-wrap: anywhere;
}

.tth-card__feast {
    margin: 0 0 var(--spacing-2);
    font-size: 12px;
    line-height: 1.3;
    color: var(--gray-400);
    overflow-wrap: anywhere;
}

/* Two columns rather than a wrapping row: three figures with labels of very
   different lengths do not form a readable ragged row, and a fixed grid also
   means a card is the same height whatever the numbers are.

   This is also the rule whose absence produced the 1440 defect. Without the
   grid the three `.tth-card__fig` divs stack, and without the flex below each
   key and value become adjacent inline spans with no separation — which is
   literally where "Total₱0.00" came from. */
.tth-card__figs {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--spacing-1) var(--spacing-3);
    margin-bottom: var(--spacing-3);
}

/* `justify-content: space-between` is what puts air between the key and the
   value. The markup writes them as two <span>s with no whitespace between the
   tags — deliberately, so the generator emits no stray text nodes — so this
   declaration is the ONLY thing separating "Total" from "₱0.00". */
.tth-card__fig {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--spacing-2);
    min-width: 0;
}

.tth-card__fig-k {
    font-size: 11px;
    line-height: 1.4;
    color: var(--gray-400);
    white-space: nowrap;
}

.tth-card__fig-v {
    font-size: 12px;
    font-weight: 600;
    line-height: 1.4;
    color: var(--secondary);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* The money keeps the green the desktop table gives it - it is the one
   figure anyone opens this page to read. --success is not redeclared in the
   dark block, so it is safe in both themes. */
.tth-card__fig-v--money {
    color: var(--success);
}

/* FPFM-86: the actions sit at the BOTTOM of the card, full width, side by
   side, as visible buttons carrying the WORD. */
.tth-card__actions {
    display: flex;
    gap: var(--spacing-2);
}

/* Explicit 44px, not `.btn-touch` and not `.btn`. Both actions are outlined
   rather than filled so neither is the loudest thing in a list of them.
   Neither is destructive: this page has no per-record delete, on the phone
   or on the desktop, so FPFM-86's outlined-red clause has nothing to apply
   to here. */
.tth-card__btn {
    flex: 1 1 0;
    min-height: 44px;
    background-color: transparent;
    color: var(--secondary);
    border: 1px solid var(--gray-700);
    border-radius: var(--radius-md);
}

@media (max-width: 767px) {

    /* --- THE LIST ---------------------------------------------------------
       Measured before at 390: the table is 600px of content in a 324px box and
       four of the seven columns - Times Given, Last Tithe, Status and Actions -
       are off screen entirely (docs/evidence figures in
       evidence/FPFM-87/pagination-and-list-measurements.txt). Swapped for a
       list, not restyled; scoped to this table's own id, because
       `.table-stable` and `.table-container` are shared across the app. */
    #tithersTable {
        display: none;
    }

    #tithersPhoneCards {
        display: block;
    }

    /* The card is a bounded scroll shell (#tithersCard, further up this file):
       the header and pagination stay pinned and only the rows move. The phone
       list replaces the table INSIDE that shell, so it inherits the same
       shrink-only, scroll-if-needed treatment. The 78vh cap is untouched. */
    #tithersCard > .card-body > .tth-list {
        flex: 0 1 auto;
        overflow-y: auto;
        min-height: 0;
    }

    /* THE CARD'S OWN RULES ARE NOT HERE ANY MORE — FPFM-104 lifted every
       `.tth-card*` declaration out of this media query and up above it, because
       they are appearance and appearance is not width-dependent. What stayed in
       here is the pair that decides WHETHER the list is shown at all:
       `#tithersTable { display: none }` and `#tithersPhoneCards { display:
       block }`. The long form of the split is in the banner above. Do not move
       the card rules back in: the mobile shell hosts the same card at every
       width and they would go dark again above 768. */

    /* --- THE PAGINATION BAR ------------------------------------------------
       MEASURED, not relayed. Renzo: "The position of pagination is not properly
       set. It is not center." At 390px, before this rule
       (evidence/FPFM-87/pagination-and-list-measurements.txt):

         bar          x 33..357   centre 195   - centred on the 390px viewport
         info line    x 135..255  centre 195   - centred, dx 0
         controls     x 49..341   centre 195   - centred, dx 0, but 100px TALL
         Rows select  x 49..161   centre 105   - dx -90
         Prev/Next    x 49..230   centre 139   - dx -56

       So the BAR is centred and its contents are not. `.table-pagination-
       controls` is `width: 100%` + `justify-content: space-between` + `flex-
       wrap: wrap`, and at 390px it wraps onto two lines - 44 + 12 + 44 = the
       100px measured. space-between on a line holding ONE item is flex-start,
       so both wrapped lines go hard left while the bar around them stays
       centred. That is the defect, and it is horizontal.

       The VERTICAL placement is NOT the defect and is not touched. The bar sits
       above the rows deliberately, so Prev/Next stay reachable without
       scrolling back through the list, and tests/tithers-pagination.test.js
       pins it there.

       Scoped to #titherPagination. `.table-pagination` is shared by seven
       pages - audit-log, district-view, manage-builders (twice), tithers,
       transactions and user-approvals - and six of them are out of scope for
       this ticket. The same wrap happens on all seven; fixing it globally is
       its own ticket, noted rather than smuggled in here. */
    #titherPagination .table-pagination-controls {
        justify-content: center;
    }

}

/* ==========================================================================
   THE FILTER SHEET — ONE SHARED COMPONENT   FPFM-94
   ==========================================================================
   `.fsheet__*`. Four pages use it: transactions, attendance, tithers and
   audit-log. It is the ONLY declaration of the mechanism in this repository.

   WHAT THIS REPLACES. Four hand-copies, taken one per ticket:
       FPFM-75  transactions.html  inline <style>  .txn-filters__*   @639px
       FPFM-82  css/styles.css                     .attn-filters__*  @767px
       FPFM-87  css/styles.css                     .tth-filters__*   @767px
       FPFM-93  css/styles.css                     .aud-filters__*   @768px
   Each was declared as a copy in its own banner and each said the extraction
   was a ticket of its own. This is that ticket, and all four are gone: nothing
   in css/, *.html or js/ matches `[a-z]*-filters__` any more, which
   tests/filter-sheet-shared.test.js re-derives rather than trusts.

   MAKING FOUR COPIES AGREE BY EDITING FOUR COPIES IS HOW THEY DRIFTED. The
   point of one block is not tidiness, it is that the next person cannot
   change one page's sheet without changing all four. If you are about to add
   a fifth prefix, you are re-opening the loop this ticket closed.

   THE MARKUP CONTRACT. Every page writes exactly this, and only the fields in
   the middle differ:

     <div class="fsheet fsheet--plain|--search" id="xFilters">
       <input type="checkbox" class="fsheet__cb" id="xToggle" aria-label="Show filters">
       <div class="card mb-6|filters-bar fsheet__panel">
         ...a page search, on --search only, which never enters the sheet...
         <div class="fsheet__sheet">
           <div class="fsheet__head">
             <span>Filters</span>
             <label class="fsheet__done" for="xToggle">Done</label>
           </div>
           ...this page's own fields...
           <div class="fsheet__actions">
             <button class="fsheet__clear" type="button">Clear</button>
           </div>
         </div>
       </div>
       <label class="fsheet__open" for="xToggle" id="xOpen" aria-label="Filters, none applied">
         <svg …/><span>Filters</span><span class="fsheet__count" aria-hidden="true" hidden></span>
       </label>
       <label class="fsheet__scrim" for="xToggle" aria-hidden="true"></label>
     </div>

   TWO MODIFIERS AND NO MORE, because there are exactly two shapes and the
   difference between them is a fact about the PAGE, not about the sheet:
     .fsheet--search  a free-text search stays on the page at every width, so
                      the panel keeps its box and only the rest collapses.
                      (tithers, audit-log — Renzo: "The search must remain.
                      Remove it in the filters.")
     .fsheet--plain   there is no page search, so on a phone the panel's own
                      card box has nothing left to hold and is removed.
   Both are declared here, once each. A page picks one; it does not write CSS.

   THE FIVE FIGURES THIS TICKET DECIDED, and why each is the one it is —
   chosen against the app rather than by counting copies, since three copies
   agreeing may be three copies of one mistake:

   1. BREAKPOINT 767px. Not the most common of the four by accident: it is the
      app's own phone band. js/responsive-nav.js switches at `innerWidth < 768`
      — so the app calls <=767 a phone — and css/styles.css uses `max-width:
      767px` fifteen times against four for 639 and two for 768. 639 left a
      640–767 band where the filter card was still the cramped two-column grid
      and not yet a sheet; 768 was off by one from the JS, so at exactly 768px
      the nav said desktop and the sheet said phone.
   2. MAX-HEIGHT 78vh. Already this file's bottom-panel cap in five unrelated
      places (the modal bodies around line 6100) as well as in three of the
      four copies. 80vh existed once, in transactions.html, and was FPFM-75's
      first draft.
   3. BOTTOM PADDING calc(var(--spacing-4) + env(safe-area-inset-bottom, 0px)).
      THE D1 DEFECT, AND ITS CAUSE, MEASURED. Every copy wrote
      `padding: var(--spacing-4)` and then `padding-bottom:
      env(safe-area-inset-bottom, 0px)` on the line below. That is not a
      floor, it is a REPLACEMENT: on any browser reporting no inset — which is
      every one that is not an iPhone with a home bar, including the emulator
      every screenshot in this repo is taken in — env() resolves to its 0px
      fallback and the padding the line above it set is thrown away. Measured
      before this ticket: computed `padding-bottom: 0px` on ALL FOUR sheets,
      and the last control sitting flush on the viewport floor on the two
      whose panel had no other padding to hide it. `calc()` is the fix because
      it ADDS the inset to the padding instead of replacing it.
   4. CLEAR ON ALL FOUR. Only attendance had one. With the sheet shut the count
      badge is the only thing on screen that says filters are on, and undoing
      them otherwise means opening the sheet and resetting each control by
      hand. Every page already declares its own cleared state — attendance's
      computed current month, TITHER_FILTER_CLEARED, audit-log's state.cleared
      — so the data a Clear needs existed on all four already; the button was
      the missing half. js/filter-sheet.js drives it from the SAME declaration
      that drives the badge, so Clear always zeroes the badge by construction.
   5. THE OPENER ANNOUNCES "Filters, none applied" / "Filters, N applied: …".
      Attendance's wording. "Filters 0" announces a bare number with no noun,
      and "Filters" alone says nothing about state at all. The count badge is
      `aria-hidden` so the figure is not read twice — the badge is the visual
      channel, the aria-label is the spoken one, and js/filter-sheet.js writes
      both from one count so they cannot disagree.

   NO SCRIPT IS IN THE INTERACTION, still. One visually-hidden checkbox holds
   the open/shut state and three <label for> elements toggle it — the opener,
   the scrim and Done. js/filter-sheet.js only ever reads control values to
   write the badge and the label, and resets them on Clear; the sheet opens
   and shuts with JS disabled.

   Heights are spelled out rather than delegated to `.btn` or `.btn-touch`:
   this file declares a bare `.btn {}` four times and the last is inside
   `@media (pointer: coarse)`, so the cascade yields 44px in an emulator and
   40px in a resized window. Those duplicates are a known open defect and are
   not touched here.
   ========================================================================== */

/* At every width above the breakpoint the added elements are simply not there,
   so the desktop cascade sees what it saw before any of this existed. */
.fsheet__open,
.fsheet__scrim,
.fsheet__head {
    display: none;
}

/* CLEAR IS THE ONE PIECE THAT IS NOT PHONE-ONLY, and that is a decision rather
   than an oversight. attendance.html already carried a desktop Clear — the
   last cell of its filter grid — and making the sheet uniform must not be an
   excuse to delete a control off a page. So Clear renders at every width on
   all four, which is also the honest reading of "Clear on all four or none":
   on all four, everywhere, and driven by the same cleared-state the badge is.
   The opener, the scrim, Done and the fixed panel remain phone-only. */
.fsheet__actions {
    display: flex;
    justify-content: flex-end;
}

.fsheet__clear {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    min-width: 44px;
    padding: var(--spacing-2) var(--spacing-4);
    border-radius: var(--radius-md);
    /* Clear is secondary and slightly destructive, so it is outlined rather
       than filled — two solid buttons in one sheet would compete, and Clear is
       not the one that should win. */
    background: transparent;
    color: var(--secondary);
    border: 1px solid var(--gray-700);
    font-size: var(--font-size-sm);
    font-weight: 600;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}

/* Visually hidden, NOT display:none — a display:none checkbox cannot be
   reached by the keyboard and its three labels are then mouse-only. Clipped,
   it stays focusable and Space still opens and shuts the sheet.

   `position: absolute`, unchanged from all four copies, AND THAT IS A RESULT
   RATHER THAN AN OMISSION. The brief for this ticket carried a survey saying
   two of the four pages shifted ~240px when the sheet opened, and asked for
   them converged on held. Measured on the base revision, three ways — opened
   at scroll-top, opened from the bottom of the page, and the checkbox given
   focus() from the bottom of the page, which is the only mechanism that could
   do it — NONE of the four moved by a single pixel. The figure in the survey
   was reproduced once and turned out to be the measuring harness:
   Playwright's page.click() scrolls its target into view first, and the
   opener sits near the top of the document, so from a scrolled position that
   scroll IS the whole offset. The harness dispatches a DOM click now.
   So nothing was changed here, because nothing was wrong here. The focus
   probe stays in tools/fpfm94-measure.mjs as a standing check that it
   remains 0 on all four. */
.fsheet__cb {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    border: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
}

/* The wrapper generates no box above the breakpoint, which is the whole reason
   the desktop is untouched on all four pages: the page's own fields stay
   direct children of the card grid or the filters bar, in their original
   order, with their original flex and grid behaviour. A real wrapper div would
   have re-flowed every one of them. Declared exactly once, at top level. */
.fsheet__sheet {
    display: contents;
}

@media (max-width: 767px) {

    /* --- WHERE THE PANEL GOES, WHICH IS THE ONLY PER-PAGE QUESTION ------- */

    /* A search stayed on the page, so the panel keeps its box and holds it.
       Only the gap under it tightens, so the search and the Filters button
       below it read as one group rather than as two cards. */
    .fsheet--search .fsheet__panel {
        margin-bottom: var(--spacing-3);
    }

    /* No page search: the panel's card box has nothing left to hold, so it is
       removed rather than hidden. `display: contents` and not `display: none`
       — none would take the sheet inside it down as well, and hiding the
       panel while re-showing a descendant is what made these two copies a
       different shape from the other two in the first place. The card-body
       goes with it for the same reason: it is a box between the panel and the
       sheet, and only --plain pages have one. */
    .fsheet--plain .fsheet__panel,
    .fsheet--plain .fsheet__panel > .card-body {
        display: contents;
    }

    /* --- THE OPENER ------------------------------------------------------ */

    .fsheet__open {
        display: flex;
        align-items: center;
        justify-content: center;
        gap: var(--spacing-2);
        width: 100%;
        min-height: 48px;
        margin-bottom: var(--spacing-5);
        padding: var(--spacing-3) var(--spacing-4);
        border: 1px solid var(--gray-700);
        border-radius: var(--radius-lg);
        background: var(--bg-card);
        color: var(--secondary);
        font-size: var(--font-size-base);
        font-weight: 500;
        cursor: pointer;
        -webkit-tap-highlight-color: transparent;
    }

    /* The count badge is DECORATION. js/filter-sheet.js sets `hidden` when
       nothing departs from the cleared state, so the button reads plainly
       "Filters" until there is something to say — which is why this is
       :not([hidden]) rather than an inline style. It is aria-hidden in the
       markup: the same figure is already in the opener's aria-label, in
       words, and a screen reader must not read it twice. --info is one of the
       tokens that renders safely in both themes; --gray-800 could not be used
       here, it IS the dark card. */
    .fsheet__count:not([hidden]) {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 20px;
        height: 20px;
        padding: 0 6px;
        border-radius: 999px;
        background: var(--info);
        color: #fff;
        font-size: var(--font-size-xs);
        font-weight: 700;
        line-height: 1;
    }

    /* The checkbox is the focus target, so the ring is drawn on the thing the
       user can actually see. */
    .fsheet__cb:focus-visible ~ .fsheet__open {
        outline: 2px solid var(--primary);
        outline-offset: 2px;
    }

    /* --- THE SCRIM ------------------------------------------------------- */

    /* 1200 clears the mobile header and the sidebar overlay, both z-index 999,
       and stays far below the modal layer at 10000 — a filter sheet must never
       sit over a dialog. The figures are FPFM-75's and so is the reasoning. */
    .fsheet__cb:checked ~ .fsheet__scrim {
        display: block;
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(0, 0, 0, 0.5);
        z-index: 1200;
    }

    /* --- THE SHEET ------------------------------------------------------- */

    /* Shut. (0,2,0), so the checked rule below at (0,3,0) wins on specificity
       and not on source order. */
    .fsheet__panel .fsheet__sheet {
        display: none;
    }

    /* Open. A single column, so field spacing is one figure rather than one
       per page: `gap` between every field, `padding` around all of them, and
       nothing carrying a margin of its own.

       See figures 2 and 3 in the banner for max-height, and for why
       padding-bottom is a calc() and not a bare env(). */
    .fsheet__cb:checked ~ .fsheet__panel .fsheet__sheet {
        display: flex;
        flex-direction: column;
        gap: var(--spacing-4);
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        z-index: 1201;
        margin: 0;
        max-height: 78vh;
        overflow-y: auto;
        overscroll-behavior-y: contain;
        background: var(--bg-surface);
        border-radius: var(--radius-xl) var(--radius-xl) 0 0;
        padding: var(--spacing-4);
        padding-bottom: calc(var(--spacing-4) + env(safe-area-inset-bottom, 0px));
        box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.25);
    }

    .fsheet__cb:checked ~ .fsheet__panel .fsheet__head {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: var(--spacing-3);
        padding-bottom: var(--spacing-3);
        border-bottom: 1px solid var(--gray-700);
        font-size: var(--font-size-base);
        font-weight: 600;
    }

    /* A field set the page wrote as a grid is one column in here. (0,4,0), so
       it beats `@media (max-width: 767px) .grid.grid-cols-1.md\:grid-cols-4`
       at (0,3,0) on specificity rather than on source order. Two columns gives
       each control ~137px, which is where a real feast name gets cut; the
       sheet is the only thing on the screen, so each field gets a full row.
       Flex rather than a grid template because the sheet is already a column
       and one spacing mechanism is better than two. */
    .fsheet__cb:checked ~ .fsheet__panel .fsheet__sheet .grid {
        display: flex;
        flex-direction: column;
        gap: var(--spacing-4);
    }

    /* Every control in the sheet is a tap target and every one is full width.
       48px matches the opener. `box-sizing` and the explicit width are what a
       date input needs, or its native calendar button pushes it past its
       column — that is what clipped audit-log's "To" field before FPFM-93. */
    .fsheet__sheet .form-input,
    .fsheet__sheet .form-select {
        min-height: 48px;
        width: 100%;
        box-sizing: border-box;
        font-size: var(--font-size-base); /* under 16px and iOS zooms on focus */
    }

    /* A page writes its fields in whatever wrapper its desktop layout needed —
       `.form-group`, `.filter-group` — and those carry margins and flex values
       that would sit on top of the sheet's own gap. The gap is the one figure. */
    .fsheet__sheet .form-group,
    .fsheet__sheet .filter-group {
        margin: 0;
        flex: 0 0 auto;
        min-width: 0;
    }

    /* --- DONE AND CLEAR -------------------------------------------------- */

    /* Done is phone-only — there is no sheet to dismiss at any other width —
       and it is the primary, because it is what the reader is looking for.
       Clear is styled at top level, above: it renders at every width.

       44x44 is the floor and it is measured rather than assumed. Built from
       padding alone this came to 66x38.4 — under the project's own mobile rule,
       on the one control that dismisses the sheet. The min-height/min-width
       pair is what guarantees it at any text size; inline-flex is what keeps
       the label centred once the box is taller than the text. */
    .fsheet__done {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-height: 44px;
        min-width: 44px;
        padding: var(--spacing-2) var(--spacing-4);
        border-radius: var(--radius-md);
        background: var(--primary);
        color: #fff;
        border: 1px solid var(--primary);
        font-size: var(--font-size-sm);
        font-weight: 600;
        cursor: pointer;
        -webkit-tap-highlight-color: transparent;
    }
}


/* ============================================
   OWN-ACCOUNT NOTE (District View, Area View) -- FPFM-110
   The line under a district's or area's own three tiles. It says in words
   why they read P0.00 when that level has recorded nothing of its own. It is
   in the card from the first paint at one line's height and is empty until
   there is something to say, so the words arriving never push the page down
   (FPFM-46: a region revealed by its own loader is a layout shift).
   1.6em is one line: the body line-height is 1.6 and this is set in its own
   font size.
============================================ */
.account-scope-note {
    min-height: 1.6em;
    margin-top: var(--spacing-3);
}
/* The tile grid above it carries .stats-grid's own 24px bottom margin, which
   left the line floating 36px under the figures it explains. Where :has() is
   not supported the gap is simply looser; nothing else changes. */
.stats-grid:has(+ .account-scope-note) {
    margin-bottom: 0;
}
/* The heading block beside the Cash In / Cash Out buttons now carries a caption,
   so it is wide enough to squeeze them: at 768px the two buttons were pushed
   into a ragged stack. The buttons keep their own width and the caption wraps
   instead; below 640px the header already wraps them onto their own row. */
.account-scope-head {
    gap: var(--spacing-4);
}
.account-scope-head > .d-flex {
    flex-shrink: 0;
}
