/* AUTO-GENERATED — copied from web/styles.css. Do not edit by hand.
 * Regenerated by scripts/sync-app-styles.js. Edit web/styles.css instead.
 */
/* ⚠️ NO TAP FLASH, ANYWHERE (owner, 2026-08-12). The grey box WebKit paints on
   every tap is the single clearest tell that a screen is a web page rather than
   an app - it fired on buttons, account rows, pocket cards, chart slices, the
   lot. Declared once on every element (and ::before/::after, which inherit
   nothing) instead of being chased surface by surface, which is how four
   separate copies of this rule ended up in this file. Focus rings are NOT
   touched: keyboard users still need them. */
*, *::before, *::after { -webkit-tap-highlight-color: transparent; }

:root {
  color-scheme: dark;
  --bg: #0f0f13; --surface: #25252f; --surface2: #2e2e3a;
  /* Lighter purple, app-wide — more legible as text on dark surfaces.
     --accent-dim is the hover/press shade (a muted purple). */
  --border: #3a3a4c; --accent: #ada1ff; --accent-dim: #7c71c9;
  --accent-glow: rgba(173,161,255,0.15);
  --green: #4ade80; --red: #f87171; --yellow: #facc15;
  /* ── POCKET TYPE COLOURS — one definition each ──────────────────────────
     These moved seven times between 2026-08-13 and 2026-08-15, each time
     across five or six surfaces (the pocket card's badge and border, the
     pocket-and-amount lists' badge, and POCKET_TYPE_META's hex for the
     Pockets-tab group headers). They are tokens now so the next move is one
     line, and so the surfaces cannot drift apart between moves.
     ⚠️ POCKET_TYPE_META in app.js MUST mirror the base hexes. It can't read a
     custom property — it builds the header tint by appending an alpha suffix
     to a literal.
     ⚠️ The -12 / -45 pairs are the app's badge recipe: colour on the text, 12%
     behind it, 45% on the border. Keep them in step with the base hex by hand;
     CSS can't derive them without color-mix, which this sheet doesn't use.

     SETTLED 2026-08-15, after seven attempts. Two of the three are semantic and
     one is the brand:
       • Credit Card GOLD — the owner settled on it early and never moved it.
       • Savings Goal ORANGE — wants "growth, in progress". He reached for
         yellow (the middle of a traffic light) and yellow turned out to be the
         one hue unavailable: at #e3cd6b it sat one row from the card gold and
         the two read as shades of each other, which he called the same day it
         shipped. Orange is the nearest hue that keeps the reading and clears
         the gold.
       • Spending Fund BRAND PURPLE — "to tie it all together". It was teal for
         a day on the semantic argument ("this is safe to spend from"), and the
         brand read better once the other two were settled: with gold and orange
         both warm, the accent is what makes the set look like this app rather
         than three unrelated markers.
     ⚠️ Purple is ALSO the accent and the premium pocket's outline. That was the
     original reason Spending Fund left it, and it is a real overlap — accepted
     deliberately, since a Spending Fund badge and a premium card border never
     appear as the same kind of mark in the same place.
     ⚠️ GREEN IS DELIBERATELY UNUSED HERE, and has been rejected twice. It is
     the Good to Go signal, so a green type marker reads as a health state
     rather than a type — which is why a green Savings Goal badge (#4def5a,
     2026-08-14) never sat right and was pulled. Red is `behind`, and purple is
     the accent AND the premium pocket, which is what took Spending Fund off it.
     ⚠️ SAVINGS AND CARD ARE THE TWO TO CHECK whenever either moves — they are
     the closest pair on the wheel, they sit adjacent in the same list, and the
     first attempt (a pale yellow against the gold) failed exactly there. Any
     future savings colour has to be judged in a row directly above a Credit
     Card row, at both badge sizes, not on its own. */
  --type-fund:    #ada1ff; --type-fund-12:    rgba(173,161,255,0.12); --type-fund-45:    rgba(173,161,255,0.45);
  --type-savings: #fb923c; --type-savings-12: rgba(251,146,60,0.12);  --type-savings-45: rgba(251,146,60,0.45);
  --type-card:    #facc15; --type-card-12:    rgba(250,204,21,0.12);  --type-card-45:    rgba(250,204,21,0.45);
  --text: #e8e8f0; --muted: #8888aa;
  --radius: 14px;
  /* THE app backdrop — one definition, used by the app (body::before) and by
     every full-screen overlay that has to look like the app behind it:
     onboarding, the consent gate, the connect/setup overlays, and the wizard's
     skinned modal pages. Those four used to carry their own
     `radial + linear-gradient(160deg, …)` wash, so onboarding sat on a
     different backdrop than the app it was setting up, and its 20deg tilt made
     the bottom of the screen non-uniform across x. Vertical and shared: any
     screen fades to the same tone at the same height. */
  --app-backdrop: linear-gradient(180deg, #262343 0%, #0a0a0d 100%);
  /* The tone --app-backdrop reaches at the BOTTOM of the viewport. Pinned
     action bars fill with this, so their backing matches the page behind them.
     Keep the two in step. */
  --app-backdrop-bottom: #0a0a0d;
  /* Visible nav height + the bottom safe-area gutter (the Android nav bar /
     iOS home-indicator strip). Because `--nav-h` is the single source of
     truth for every "sits above the nav" calc throughout the app (page
     bottom padding, toast, install banner, cat-reorder-bar,
     tx-select-bar), folding the inset in here means every one of those
     gets pushed up by the gutter automatically. The bottom-nav itself
     uses this as its outer height and adds matching padding-bottom so
     its icons stay above the gutter. */
  --nav-h: calc(68px + env(safe-area-inset-bottom));
  /* Height of the offline banner under the header — 0 while online, so every
     `calc(... + var(--offline-h))` below is a no-op unless the banner is up. */
  --offline-h: 0px;
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* overflow-x: hidden contains the tab-transition pager, which parks the
   incoming page off-screen at translateX(+viewport) before sliding it in —
   without this the off-screen page briefly widens the document and flashes a
   horizontal scrollbar on tab switches. (y stays scrollable.) */
html { height: 100%; scrollbar-gutter: stable; background-color: var(--bg); overflow-x: hidden; }

/* ── Global scrollbars ─────────────────────────────────────────────────────
   Thin, dark-mode-friendly scrollbars that feel like overlay: the thumb is
   transparent by default and fades in when the scrollable container is
   hovered, then brightens further on direct thumb hover or drag. WebKit/
   Chromium (Chrome, Edge, Safari) reserves the 8px track only when the
   element actually overflows — no always-reserved gutter on inner scrollers.
   Firefox approximates via scrollbar-width + scrollbar-color. This applies
   app-wide so any new scrollable element picks it up automatically. */
* {
  scrollbar-width: thin;
  scrollbar-color: transparent transparent;
}
@media (hover: hover) {*:hover, *:focus-within {
  scrollbar-color: rgba(255,255,255,0.22) transparent;
}}
::-webkit-scrollbar {
  width: 8px;
  height: 8px;
  background: transparent;
}
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background-color: transparent;
  border-radius: 8px;
  border: 2px solid transparent;
  background-clip: padding-box;
  transition: background-color 0.25s ease;
}
@media (hover: hover) {*:hover::-webkit-scrollbar-thumb,
*:focus-within::-webkit-scrollbar-thumb {
  background-color: rgba(255,255,255,0.22);
  background-clip: padding-box;
}}
@media (hover: hover) {::-webkit-scrollbar-thumb:hover,
::-webkit-scrollbar-thumb:active {
  background-color: rgba(255,255,255,0.45);
  background-clip: padding-box;
}}
::-webkit-scrollbar-corner { background: transparent; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  /* Transparent: the backdrop lives on the fixed ::before layer below and the
     --bg base is on <html>. The wash must NOT scroll — a scrolling backdrop
     differs by scroll position, so switching tabs while scrolled made it
     visibly jump. Fixed layers stay put and look identical on every page and
     at every scroll position. */
  background: transparent;
  color: var(--text);
  min-height: 100vh;
  min-height: 100dvh;
  /* Belt-and-suspenders with html's overflow-x: any element wider than the
     viewport (e.g. the pager's off-screen incoming page) is clipped rather
     than revealing a horizontal scrollbar. */
  overflow-x: clip;
}
/* Viewport-fixed purple wash. position:fixed → constant on scroll and across
   pages (no page-length dependence, no jump on tab switch). The fade spans
   the FULL VIEWPORT height: because this layer is inset:0 (exactly the
   viewport), a 0% → 100% gradient reaches the dark bottom tone at the bottom
   of the screen at ANY viewport height — short screens aren't cut off early,
   tall screens don't hit black before the bottom. Opaque 2-stop from a
   purple top to a near-black bottom (no middle stop = no seam line). */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: var(--app-backdrop);
}
h1 { font-size: 22px; font-weight: 700; }
h2 { font-size: 17px; font-weight: 600; }
p  { line-height: 1.6; }

/* ── Pages ── */
/* ⚠️ `body.is-offline` sets the ONE number the banner and every page offset
   share. Same method the header clearance already uses — a set constant, not a
   measurement — with one term added rather than a second system invented. */
body.is-offline { --offline-h: 26px; }
.page { display: none; padding: 0 16px calc(var(--nav-h) + 28px); padding-top: calc(84px + env(safe-area-inset-top) + var(--offline-h)); max-width: 520px; margin: 0 auto; width: 100%; }
/* Pockets page sits below #app-gtg-wrapper, which already owns the 70px
   header-clear padding — zero out here so the title sits ~20px below the
   GTG hero, matching the Transactions page's hero→title spacing. */
#page-pockets { padding-top: 0; }
#page-pockets { padding-top: 0; }
.page.active { display: block; }
/* ── Splash (shown while auth resolves) ── */
/* Fixed, opaque, full-viewport OVERLAY — floats over the (possibly
   half-rendered) auth/app content beneath it during boot instead of stacking
   above it in document flow. Without position:fixed the splash was a normal
   in-flow block with min-height:100vh, so its 100vh of height sat ON TOP of
   the login/app content and you could scroll straight past it to a partly
   loaded page (and see the logo twice). z-index sits below #toast (100000) so
   a boot-time toast still shows, but above every modal/overlay. */
#app-splash { position: fixed; inset: 0; z-index: 99999; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; min-height: 100vh; min-height: 100dvh; padding: 32px 16px; }
#app-splash[style*="display: none"] { display: none !important; }

/* min-height uses 100dvh (dynamic viewport height) so mobile Chrome/Safari
 * exclude the address bar from the height calculation — using 100vh would
 * force the page to be taller than the visible area while the bar is up
 * and force a pointless scroll. 100vh fallback for older browsers. */
.auth-page { display: none; min-height: 100vh; min-height: 100dvh; align-items: center; justify-content: center; padding: 32px 16px; flex-direction: column; gap: 24px; }
.auth-page.active { display: flex; }

/* ── Fixed top header bar ──
   Visually mirrors the marketing site's <.top-nav>: translucent dark
   background with a backdrop-filter blur, hairline border, and the
   same 16px/28px padding so the chrome reads identically across the
   site → app boundary. */
#app-header {
  position: fixed; top: 0; left: 0; right: 0; z-index: 100;
  background: rgba(15, 15, 19, 0.72);
  backdrop-filter: blur(14px) saturate(160%);
  -webkit-backdrop-filter: blur(14px) saturate(160%);
  border-bottom: 1px solid rgba(255, 255, 255, 0.04);
  /* The top inset accounts for the status bar / camera notch under
     `viewport-fit=cover` — without it the header content slides under
     the status bar. */
  padding: calc(16px + env(safe-area-inset-top)) 28px 16px;
  /* Site nav height is set by the buttons/burger (~40px content) inside
     it; the app header only has ~22px of text content. Pad the floor
     up so the chrome lands at the same height as the site. */
  min-height: 72px;
  /* Flex with align-items:center vertically centers the inner row inside
     the min-height-padded box — without this the brand/GTG sit at the
     top of the padding area instead of the optical center. */
  display: flex;
  align-items: center;
}
/* Per Gemini's suggestion: keep the in-app header dark even when the OS is
   in light mode, so the app's chrome matches the forced-dark system bars.
   Targets `#app-header` (our header is a div with that id, not a <header>
   element). The app is dark-only today, so this is mostly belt-and-braces
   until real light mode lands — but it guarantees the header doesn't pick
   up any UA light-mode default. */
@media (prefers-color-scheme: light) {
  #app-header {
    background-color: #000000;
    color: #ffffff;
  }
}
/* A control the network has taken away, not one this screen switched off.
   ⚠️ Pairs with the `disabled` attribute in _applyOfflineLocks — this half is
   the look and the pointer, that half is keyboard activation and what a screen
   reader announces. Neither alone is the disable. */
.is-offline-locked { opacity: 0.45; pointer-events: none; }

/* ── Offline banner ────────────────────────────────────────────────────────
   One word, ABOVE the header, for as long as there is no network.
   ⚠️ ITS HEIGHT IS `--offline-h`, THE SAME VARIABLE THE PAGES OFFSET BY. The
   app clears the fixed header with a SET constant — `calc(84px +
   env(safe-area-inset-top))` on `.page` and `#app-gtg-wrapper` — not a measured
   one, so this follows the same method and adds one more term to that calc.
   Declaring the banner's height FROM that variable is what makes the two agree
   by construction rather than by someone remembering to update both. */
#app-offline-banner {
  display: none;
  /* Owns the very top of the screen; the header is pushed down below it. */
  position: fixed; top: 0; left: 0; right: 0;
  /* ⚠️ z-index above the header's 100, or the header's own backdrop-filter
     layer paints over it. */
  z-index: 101;
  /* ⚠️ THE NOTCH IS THE BANNER'S TO CLEAR NOW, since it is the topmost thing on
     screen. `--offline-h` stays the banner's own CONTENT height, so the inset
     is added here AND to the header's `top` below — never folded into the
     variable, which is what keeps `.page`'s existing `+ env(...)` term from
     counting it twice. Under the global `box-sizing: border-box` the padding
     is inside the height, hence the calc. */
  height: calc(var(--offline-h) + env(safe-area-inset-top));
  padding-top: env(safe-area-inset-top);
  align-items: center; justify-content: center;
  background: var(--surface2);
  border-bottom: 1px solid var(--border);
  font-size: 12px; font-weight: 700; letter-spacing: 1.4px; text-transform: uppercase;
  color: var(--muted); line-height: 1;
}
body.is-offline #app-offline-banner { display: flex; }
/* ⚠️ The ONLY thing that changes about the header: where it starts. Its size,
   padding, fonts and inner layout are untouched — a previous attempt shrank the
   wordmark and the Good to Go amount to fit a strip INSIDE it, and that was
   reverted for looking bad (owner, 2026-08-29). */
body.is-offline #app-header { top: calc(var(--offline-h) + env(safe-area-inset-top)); }

.app-header-inner {
  flex: 1;
  max-width: 520px; margin: 0 auto; position: relative;
  display: flex; align-items: center; justify-content: center;
}
/* `#app-header` prefix bumps specificity above .logo-text (defined later
   in the file at 26px), the same way the site uses `.top-nav .logo-text`
   to override the base. Without the prefix, the brand silently renders
   at .logo-text's 26px instead of the 22px we want. */
#app-header .app-header-brand {
  display: inline-flex;
  align-items: center;
  font-size: 22px;
  white-space: nowrap;
}
/* margin-right (not flex gap) so the spacing only sits between the icon
   and the wordmark — gap would also wedge 10px between the "Pockets"
   text node and the <span>by Hoopy</span>, doubling the visible space. */
#app-header .app-header-brand .app-icon { width: 28px; height: 28px; vertical-align: 0; margin-right: 10px; }
.header-gtg-group {
  position: absolute; right: 0; top: 50%;
  transform: translateY(-50%);
  display: flex; align-items: baseline; gap: 8px;
  white-space: nowrap; opacity: 0;
}
.header-gtg-label {
  font-size: 11px; text-transform: uppercase; letter-spacing: 2px; color: #8888aa;
}
.header-gtg-amount {
  font-size: 22px; font-weight: 800; letter-spacing: -1px; color: var(--green);
}
.header-gtg-amount.negative { color: var(--red); }
/* ⚠️ THE HEADER GTG SHOWS A SPINNER, NOT A BLANK, while its inputs load
   (owner, 2026-09-01). It used to render '' on the reasoning that a spinner in
   tiny scrolled-to chrome would read as the whole header loading — that call is
   reversed: a blank slot next to a "Good to Go" label reads as a broken or
   zeroed number, where a spinner says the figure is on its way.
   ⚠️ Sized off the amount's own 22px so it occupies about the height of the
   digits it replaces, and inherits the row's alignment rather than being
   centred in a box of its own. */
.header-gtg-spinner {
  display: inline-block; width: 14px; height: 14px;
  border: 2px solid var(--border); border-top-color: var(--accent);
  border-radius: 50%; animation: spin 0.65s linear infinite;
  vertical-align: middle;
}

/* ── GTG Hero ── */
#app-gtg-wrapper { display: none; max-width: 520px; margin: 0 auto; padding: calc(84px + env(safe-area-inset-top) + var(--offline-h)) 16px 20px; }
#app-gtg-wrapper.visible { display: block; }
.gtg-hero {
  position: relative;
  /* "Twin purple" (P1): a rich, saturated deep-purple base (diagonal) with a
     bright brand-purple glow in BOTH opposite corners (upper-left +
     lower-right), deeper through the middle — vibrant and all-brand-purple,
     no gold. CSS gradients (not a baked image) so it paints with the first
     CSS pass — no image-fetch delay — and the saturated colors keep banding
     minimal. In sync with .gtg-spend-hero-wrap. */
  background:
    radial-gradient(circle at top left, rgba(195, 183, 255, 0.42) 0%, rgba(195, 183, 255, 0.18) 40%, rgba(195, 183, 255, 0.05) 62%, transparent 85%),
    radial-gradient(circle at bottom right, rgba(195, 183, 255, 0.42) 0%, rgba(195, 183, 255, 0.18) 40%, rgba(195, 183, 255, 0.05) 62%, transparent 85%),
    linear-gradient(135deg, #2c2270 0%, #171142 100%);
  border: 1px solid #4a4470;
  border-radius: var(--radius);
  padding: 36px 24px 28px;
  text-align: center;
  box-shadow: 0 10px 32px -20px rgba(0, 0, 0, 0.55);
}

/* ── Loading skeletons ── */
/* Sit in place of the Good to Go amount + the pockets list while the
 * initial queries are in flight. The hero outline is preserved so the
 * page doesn't reflow when real data arrives. */
.loading-spinner {
  width: 32px; height: 32px;
  border: 3px solid rgba(255, 255, 255, 0.10);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.gtg-hero-loading .gtg-amount-skeleton {
  display: flex; align-items: center; justify-content: center;
  height: 64px;
  padding-top: 5px; padding-bottom: 18px;
}
.pockets-loading {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: 48px 16px; gap: 14px; color: var(--muted); font-size: 14px;
}
.app-icon { width: 1.2em; height: 1.2em; vertical-align: -0.15em; display: inline-block; }
.gtg-label { font-size: 20px; color: var(--text); margin: 0; }
.gtg-amount { font-size: 48px; font-weight: 800; letter-spacing: -2px; color: var(--green); line-height: 1; font-variant-numeric: tabular-nums; padding-top: 5px; padding-bottom: 18px; }
.gtg-amount.negative { color: var(--red); }
/* Info button parks in the bottom-right corner of the hero so it doesn't
 * shove the centered label off-center. position:absolute means the rest of
 * the hero's content lays out as if the button weren't there. */
/* GTG hero "?" — uses the standard .info-q look; this only pins it to the
   hero's bottom-right corner (more specific than .info-q so it wins). */
.gtg-hero .gtg-info-btn { position: absolute; bottom: 6px; right: 6px; margin: 0; }
.gtg-balance-age { font-size: 11px; color: var(--muted); margin-top: 12px; opacity: 0.5; letter-spacing: 0.3px; }

/* Single-line Stashed / Bank summary that sits above the Catch Up /
   Fund Now buttons. Intentionally not wrapped in a card — it reads
   as a supporting caption to the GTG hero, not a separate UI block. */
.pockets-summary-stashed {
  font-size: 13px;
  color: var(--muted);
  text-align: center;
  margin: 0 0 10px;
}
.pockets-summary-stashed span { color: var(--text); font-weight: 600; }

/* Catch Up / Fund Now action row groups directly under the hero. The
   paycheck pill itself now lives inside the hero (see .gtg-paycheck-btn
   below); only the action-row pair is rendered into the slot. */
.pockets-actions-group { display: flex; flex-direction: column; gap: 8px; }
.pockets-actions-group .pockets-action-row { margin-bottom: 0; }
#pockets-paycheck-line { display: none; }
#pockets-paycheck-slot:empty { display: none; }

/* ── Cards ── */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 12px; }
.card-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 10px; }
.card-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }

/* ── Inputs ── */
.input-group { display: flex; flex-direction: column; gap: 10px; }
input[type="email"], input[type="password"], input[type="text"],
input[type="number"], input[type="date"], textarea {
  width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px;
  padding: 12px 14px; color: var(--text); font-size: 15px; outline: none;
  caret-color: var(--text);
  transition: border-color 0.2s, box-shadow 0.2s; font-family: inherit;
}
input.input-error, textarea.input-error {
  border-color: var(--red);
  box-shadow: 0 0 0 1px var(--red);
}
/* Pattern no longer matches the transaction it was built from — a soft red
   warning (distinct from a hard validation error, but same alarm color). */
input.input-warn, textarea.input-warn {
  border-color: var(--red);
  box-shadow: 0 0 0 1px var(--red);
}
select {
  width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px;
  padding: 11px 36px 11px 14px; color: var(--text); font-size: 15px; outline: none;
  transition: border-color 0.2s, box-shadow 0.2s; font-family: inherit;
  -webkit-appearance: none; appearance: none; cursor: pointer;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 12px center;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
input::placeholder { color: var(--muted); }
select option { background: var(--surface2); }
/* Override browser autofill white-box */
@media (hover: hover) {input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus {
  -webkit-box-shadow: 0 0 0 1000px var(--surface2) inset !important;
  -webkit-text-fill-color: var(--text) !important;
  caret-color: var(--text) !important;
  border: 1px solid var(--border) !important;
  border-radius: 12px !important;
  background-clip: content-box !important;
  transition: background-color 5000s ease-in-out 0s;
}}
/* Dark calendar picker — color-scheme forces the browser's native widget into dark mode */
input[type="date"] { color-scheme: dark; }
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(0.7) sepia(0.2) saturate(0.6) hue-rotate(220deg) brightness(1.2); cursor: pointer; border-radius: 4px; }
/* Remove number input spinners */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type="number"] { -moz-appearance: textfield; appearance: textfield; }

/* ── Buttons ── */
button { cursor: pointer; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; padding: 12px 18px; transition: opacity 0.15s, transform 0.1s, background 0.15s; font-family: inherit; }
button:active { transform: scale(0.97); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.btn-primary { background: var(--accent); color: #fff; width: 100%; }
@media (hover: hover) {.btn-primary:hover:not(:disabled) { background: var(--accent-dim); }}
/* Gold CTA — the premium "Get Premium" action. Gold from the credit-card
   pocket border (var(--yellow)); dark text for contrast. */
.btn-gold { background: var(--yellow); color: #1c1600; width: 100%; font-weight: 700; }
@media (hover: hover) {.btn-gold:hover:not(:disabled) { background: #eab308; }}
.btn-gold:disabled { opacity: 0.6; }
.btn-secondary { background: var(--surface2); border: 1px solid var(--border); color: var(--text); }
@media (hover: hover) {.btn-secondary:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }}
.btn-ghost { background: transparent; color: var(--muted); font-size: 13px; padding: 6px 10px; }
@media (hover: hover) {.btn-ghost:hover { color: var(--text); }}
/* File picker. The native control renders browser chrome — a grey "Choose File"
   button plus a filename string — and neither can be restyled to match the app
   (::file-selector-button reaches the button, nothing reaches the text). The
   string is dead weight anyway: picking a file advances straight to the next
   step, so it only ever reads "No file chosen". So the input is visually hidden
   (NOT display:none — it must stay focusable) and its own <label> is the button.
   The label carries the sizing from the base `button` rule explicitly, since
   that's an element selector a <label> never matches. */
.file-input {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.file-input-btn {
  display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer; font-family: inherit; font-size: 15px; font-weight: 600;
  padding: 12px 18px; border-radius: 10px;
  background: var(--surface2); border: 1px solid var(--border); color: var(--text);
  transition: border-color 0.15s, color 0.15s;
}
@media (hover: hover) {.file-input-btn:hover { border-color: var(--accent); color: var(--accent); }}
.file-input-btn:active { transform: scale(0.97); }
/* The hidden input still takes keyboard focus and Space/Enter still opens the
   picker — mirror its focus ring onto the visible button so that's not silent. */
.file-input:focus-visible + .file-input-btn { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Segmented two-option toggle (e.g. add-pending balance mode). */
.seg-toggle { display: flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface2); }
.seg-toggle .seg-opt { flex: 1 1 0; background: transparent; border: none; color: var(--muted); font-size: 13px; font-weight: 500; padding: 9px 10px; cursor: pointer; border-radius: 0; }
.seg-toggle .seg-opt.active { background: var(--accent); color: #fff; }
/* Transaction type: one of three, each with a plain-language sub-line under the
   bank's word. Dividers between the options rather than around them, so the
   control reads as one field with three parts. */
/* justify-content: center is what vertically centres "All", which has no
   sub-line row to balance the other two - without it it sat at the top against
   their first line. */
.tx-type-toggle .seg-opt {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 2px; padding: 8px 6px; line-height: 1.25;
  transition: background-color .12s, color .12s;
}
.tx-type-toggle .seg-opt + .seg-opt { border-left: 1px solid var(--border); }
.tx-type-toggle .seg-opt-main { font-size: 15px; font-weight: 600; }
/* Inherits colour so it flips with the selected state along with the label
   above it; the opacity is what keeps it secondary in both states. */
.tx-type-toggle .seg-opt-sub { font-size: 11px; font-weight: 400; opacity: .72; }
@media (hover: hover) {.seg-toggle .seg-opt:not(.active):hover { color: var(--text); }}
/* ── Pocket Type ──────────────────────────────────────────────────────────
   Standard | Spending Fund | Savings Goal, built on the same .seg-toggle the
   Transactions type filter uses (owner, 2026-08-14: "a 3 way selection like
   the filter for All | Debit | Credit").
   ⚠️ This REPLACES the Spending Fund toggle rather than joining it, which is
   the whole reason a third type costs nothing: a Standard pocket now shows
   FEWER controls than before, because the switched-off toggle is gone rather
   than sitting there inviting "what is a spending fund?". The DAS-budget
   failure being avoided is three funding METHODS offered as peers with nothing
   to tell them apart; three TYPES, each with one sentence and each surfacing
   only its own options, is a different shape.
   ⚠️ NO SUB-TEXT HERE, unlike the tx filter (owner, 2026-08-14). A label plus a
   caption is two lines fighting over a third of a phone's width, and the three
   captions were different lengths — "Boost from Spending Fund" wrapped where
   "Fund by due date" didn't — so the row was ragged at every size and looked
   worst at the sizes most people have. The names carry themselves; the "?" on
   the label opens the real explanation (openPocketTypeHelp).
   ⚠️ The NAMES can still wrap on a narrow phone or at a large system text size,
   and that has to look deliberate rather than broken: centred both ways, so a
   two-line option stays balanced against its one-line neighbours instead of
   hanging below them. */
.pocket-type-toggle .seg-opt {
  display: flex; align-items: center; justify-content: center;
  min-height: 42px; padding: 9px 5px; line-height: 1.22; text-align: center;
  font-size: 14px; font-weight: 600;
}
/* ⚠️ 5px of side padding, not the 7px the rest of the app's segmented controls
   use, and it is not arbitrary: at 390px — the commonest phone width — a
   button is 116px and "Spending Fund" measures 101.9px, so 7px of padding left
   exactly 102.0px of room and the label wrapped on a rounding error. Sitting
   that close to the boundary also means it would flip between one and two lines
   between devices, which is worse than either. 5px buys ~6px of headroom there.
   Narrower phones still wrap, and are meant to — that is what the centring is
   for. */
.pocket-type-toggle .seg-opt + .seg-opt { border-left: 1px solid var(--border); }
/* A read-only stand-in where a control would be, matching the height and inset
   of the segmented controls it replaces so the form doesn't jump. */
.pm-static-note {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 10px 12px;
}
/* A field-group row that opens a picker: the label above is the field's name,
   this is its value plus a chevron. Same surface as an input so it reads as
   part of the form rather than as a settings row that wandered in. */
.pm-picker-row {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  width: 100%; text-align: left; background: var(--surface2);
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 10px 12px; color: var(--text); cursor: pointer;
}
@media (hover: hover) { .pm-picker-row:hover { border-color: var(--accent); } }
.pm-picker-row > span { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pm-picker-title { font-size: 14px; font-weight: 500; }
.pm-picker-sub { font-size: 12px; color: var(--muted); }
.pm-picker-row svg { flex: 0 0 auto; color: var(--muted); }

/* ── Pocket types explainer ───────────────────────────────────────────────
   One row per type: a name, a sentence saying what it is FOR, and a caret that
   expands to the mechanics. Short by default so nothing has to be read to get
   past it. */
.pt-help-row { border-bottom: 1px solid var(--border); }
.pt-help-row:last-child { border-bottom: none; }
.pt-help-head {
  display: flex; align-items: flex-start; gap: 12px; width: 100%;
  background: transparent; border: none; padding: 14px 0; text-align: left;
  color: var(--text); cursor: pointer;
}
.pt-help-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.pt-help-name { font-size: 16px; font-weight: 600; }
.pt-help-short { font-size: 13px; color: var(--muted); line-height: 1.45; font-weight: 400; }
.pt-help-caret { flex: 0 0 auto; color: var(--muted); margin-top: 3px; transition: transform .15s; }
.pt-help-row.open .pt-help-caret { transform: rotate(180deg); }
.pt-help-long { display: none; padding: 0 0 16px; }
.pt-help-row.open .pt-help-long { display: block; }
.pt-help-long p { font-size: 13.5px; line-height: 1.55; color: var(--text); margin: 0 0 12px; }
.pt-help-long p:last-child { margin-bottom: 0; }
.btn-icon { background: transparent; color: var(--muted); padding: 6px; font-size: 16px; }
@media (hover: hover) {.btn-icon:hover { color: var(--text); }}
/* ⚠️ The transparent border and the matched padding are load-bearing, not
   decoration. These two share a `flex: 1 1 0` row, and a zero flex-basis adds
   border+padding ON TOP of each button's equal share - so any difference in
   either lands directly in the rendered width. Measured: 16px vs 14px padding
   made them 165/167, and giving only one a border made it worse at 164/168.
   Identical box extras are what makes a 50/50 row actually 50/50. */
/* Catch Up / Top Up, in the pocket editor's HEADER beside the ⋮ (owner,
   2026-08-14). Compact, because it shares a row with the title and two icon
   buttons and it carries an AMOUNT — "Catch Up $1,234.56" is the whole point of
   showing it, so the label can't be shortened and the box has to give instead.
   `nowrap` keeps it one line and lets the h2 take the squeeze; the h2 is "Edit
   Pocket", so there is room. */
.pm-header-action {
  background: var(--surface2); color: var(--text); font-size: 12px;
  padding: 6px 10px; border-radius: 8px; font-weight: 600;
  border: 1px solid var(--border); white-space: nowrap;
  flex: 0 0 auto; margin-left: 8px;
}
/* ⚠️ The TITLE is what gives way when the row runs out of width, and it needs
   to be told how: an h2 in a flex row will not shrink below its content
   without min-width:0, so it overflowed its box and the text ran UNDER the
   button — measured at 320px, where the h2's box was 59px against ~95px of
   text. Truncating the title is the right sacrifice; it reads "Edit Pocket",
   which the user already knows, while the button carries an AMOUNT that is the
   entire reason it is shown. */
#pocket-modal-overlay .modal-header h2 {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
@media (hover: hover) {.pm-header-action:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }}
/* The row above Pocketed/Need. `flex: 1` on every visible child IS the sizing
   rule the owner asked for and needs no JS: whichever buttons are showing split
   the width, so + Funds alone is full-width and a Catch Up beside it makes both
   50%. Catch Up and Top Up are mutually exclusive, so it never goes past two.
   min-width: 0 lets a long label ("Catch Up $1,234.56") shrink rather than push
   the row wider than the modal. */
/* Its own row at the top of the modal body, above the form. .modal-body is block
   layout (no gap), so the separation is this margin - matched to the Recommended
   Pockets button that sits in the same slot. */
/* ⚠️ NO bottom margin. `.modal-body` is a flex column with a 10px gap and every
   other block in this form sits on exactly that; a 14px margin here made the
   funds row 24px clear of what follows, which read as a hole rather than as
   separation — most obviously above the Link Recommended Transactions button,
   another row of buttons (owner, 2026-08-14). Measured: 24 against the form's
   10. An earlier comment in index.html claimed the form's rhythm WAS 24px; it
   is not, and that is what left this gap standing.
   Since + Funds moved into the Pocketed field's overflow (owner, 2026-08-14)
   this row holds at most ONE button — Catch Up and Top Up are mutually
   exclusive — so `flex: 1 1 0` simply fills it. The transparent border that
   used to sit on `.btn-add-funds` to make the two boxes identical went with it;
   there is no second box to match any more. */
.pm-funds-row { display: flex; gap: 8px; }
.pm-funds-row > button { flex: 1 1 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.modal-box-sm { max-width: 340px; }
.btn-sm { background: var(--surface2); border: 1px solid var(--border); color: var(--muted); padding: 13px 13px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s; }
@media (hover: hover) {.btn-sm:hover { border-color: var(--accent); color: var(--accent); }}
.btn-sm.active { background: var(--accent-glow); border-color: var(--accent); color: var(--accent); }
/* Tx filter button — accent color when one or more filters are active so
   it's visible even with the filter panel collapsed. Toggled by
   renderTransactions via _hasActiveTxFilters. */
/* Filters ON reads as a SOLID chip rather than an outline: the whole point is
   telling you at a glance that the list you're looking at is incomplete, and an
   outline said that too quietly next to the un-filtered button. Dark ink because
   --accent is a light lavender. The hover rule below keeps the fill (the generic
   .btn-sm:hover would paint accent text onto the accent fill). */
.btn-sm.has-active-filters { background: var(--accent); border-color: var(--accent); color: var(--bg); }
@media (hover: hover) {
  .btn-sm.has-active-filters:hover { background: var(--accent-dim); border-color: var(--accent-dim); color: var(--bg); }
}

/* Pocket link-recs modal stacks ON TOP of the pocket edit modal (both
   share .modal-overlay's z-index: 300, and DOM order would otherwise
   put the pocket modal on top). The link-recs modal is only ever
   opened from inside the pocket modal, so always layering above is
   safe. Escape/back stacking already handled by data-open-seq. */
#pocket-link-recs-overlay { z-index: 310; }

/* Suggestion-style review modals (Recommended Pockets, per-pocket
   Link Transactions) go full-screen ONLY on mobile — same breakpoint
   as fullscreen-mobile. On desktop they stay floating cards because
   a giant viewport-filling pop-up over a scrollable page feels heavy
   and unprofessional. The body-scroll-lock CSS rule (see
   .modal-overlay.open :has() rule above) handles the page-drift
   feeling on desktop without resorting to full-screen. */
@media (max-width: 600px) {
  #pocket-suggestions-overlay,
  #pocket-link-recs-overlay {
    padding: 0;
    align-items: stretch;
    justify-content: stretch;
  }
  #pocket-suggestions-overlay > .modal-box,
  #pocket-link-recs-overlay > .modal-box {
    width: 100vw !important;
    max-width: none !important;
    height: 100dvh;
    max-height: none;
    border-radius: 0;
    border: none;
    display: flex;
    flex-direction: column;
  }
  #pocket-suggestions-overlay > .modal-box > .modal-body,
  #pocket-link-recs-overlay > .modal-box > .modal-body {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
  }
}

/* Stacked-modal hygiene: when a modal opens on top of another, the
   back modal's frame would otherwise peek out around the edges of
   the front one — looks cluttered, especially on desktop where both
   are floating cards of different sizes. Solution: hide the back
   overlay entirely (visibility:hidden preserves its DOM state and
   form values; restored when the front modal closes). The front
   modal's own dim layer takes over as the only visible backdrop, so
   it reads as a single clean dialog instead of layered rectangles. */
.modal-overlay.modal-covered { visibility: hidden; }

/* ── Alerts ── */
.alert { border-radius: 10px; padding: 12px 14px; font-size: 14px; line-height: 1.5; display: none; margin-top: 4px; }
.alert.show { display: block; }
.alert-error   { background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.4); color: var(--red); }
.alert-success { background: rgba(74,222,128,0.1);  border: 1px solid rgba(74,222,128,0.4);  color: var(--green); }
.alert-info    { background: rgba(173, 161, 255,0.1); border: 1px solid rgba(173, 161, 255,0.4); color: var(--accent); }
.alert-warning { background: rgba(234,179,8,0.10);   border: 1px solid rgba(234,179,8,0.45);  color: #facc15; }

/* ── Auth ── */
.auth-mark { width: 72px; height: 72px; display: block; margin-bottom: -12px; }
.auth-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 28px; width: 100%; max-width: 380px; display: flex; flex-direction: column; gap: 12px; }

/* ── Password field with reveal toggle ── */
.pw-wrap { position: relative; }
.pw-wrap input { padding-right: 46px !important; }
.pw-wrap input::-ms-reveal { display: none; }
.pw-eye-btn {
  position: absolute; right: 0; top: 0; bottom: 0; width: 44px;
  background: transparent; border: none; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  color: var(--muted); transition: color 0.15s;
}
@media (hover: hover) {.pw-eye-btn:hover { color: var(--text); }}
.pw-match-hint { font-size: 12px; margin: -4px 0 0; padding: 0 2px; min-height: 14px; }
.pw-match-hint.ok  { color: var(--green); }
.pw-match-hint.err { color: var(--red); }

.logo-text { font-size: 26px; font-weight: 800; letter-spacing: -0.5px; }
.logo-text span { color: var(--accent); }
/* The wordmark wrapper must stay the normal text color — only the inner
   "by Hoopy" span is accent (it still matches .logo-text span above). */
.logo-text .brand-wordmark { color: inherit; }
/* Fades out (driven by the header-progress JS) only when the Good to Go amount
   is on screen AND would collide with it — otherwise it stays fully visible,
   including on the Pockets tab at scroll-top. Keeps its layout space at opacity
   0 so the logo icon never shifts. */
#app-header .brand-wordmark { transition: opacity 0.18s ease; }
/* Add / Remove direction toggle at the top of the Add Funds modal. */
.af-dir-toggle { display: flex; gap: 4px; background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 4px; margin-bottom: 4px; }
.af-dir-btn { flex: 1; padding: 9px 0; border: none; background: transparent; color: var(--muted); font-size: 14px; font-weight: 600; border-radius: 7px; cursor: pointer; transition: background .15s ease, color .15s ease; }
@media (hover: hover) {.af-dir-btn:hover:not(.active) { color: var(--text); }}
.af-dir-btn.active { background: var(--accent); color: #fff; }
/* Available-balance hint in the Add Funds modal. */
.af-available { font-size: 13px; color: var(--muted); margin-top: 6px; }
.af-available strong { color: var(--text); font-variant-numeric: tabular-nums; }
.af-available strong.af-available-neg { color: var(--red); }
.auth-subtitle { font-size: 14px; color: var(--muted); text-align: center; margin-top: 4px; }
.auth-link { text-align: center; font-size: 14px; color: var(--muted); }
.auth-link a { color: var(--accent); text-decoration: none; font-weight: 500; }
.auth-consent { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: var(--muted); text-align: left; line-height: 1.4; margin: 2px 0; cursor: pointer; }
.auth-consent input { margin-top: 2px; flex: 0 0 auto; }
.auth-consent a { color: var(--accent); text-decoration: none; font-weight: 500; }
.auth-consent.input-error { color: var(--red); }
/* Fine print under the auth card's actions — availability/limitation notices
   (e.g. the Plaid country restriction on Create account). Deliberately quieter
   than .alert-warning: it's a fact to read before signing up, not a warning. */

/* Segmented control — a small two-or-three-way choice that reads as one
   control (direction: money out/in; status: pending/settled). Used where a
   dropdown would be heavy for two mutually exclusive options. */
.seg-ctl {
  display: flex; gap: 4px; padding: 4px;
  background: var(--surface2); border: 1px solid var(--border); border-radius: 10px;
}
.seg-ctl .seg-btn {
  flex: 1; padding: 9px 10px; border: none; border-radius: 7px;
  background: transparent; color: var(--muted);
  font-size: 14px; font-weight: 500; cursor: pointer;
  transition: background .15s ease, color .15s ease;
}
@media (hover: hover) {.seg-ctl .seg-btn:hover { color: var(--text); }}
.seg-ctl .seg-btn.active {
  background: var(--accent); color: var(--text); font-weight: 600;
  box-shadow: 0 1px 2px rgba(0,0,0,0.25);
}
/* Money-in tints green so the direction is readable at a glance — the amount
   it produces is a credit, and credits are the ones worth double-checking. */
.seg-ctl .seg-btn.active[data-v="in"] { color: var(--green); }
/* Two-line variant: the label with the amount THAT choice would produce
   underneath it, so a user compares outcomes instead of parsing a description
   (spending funds: full Need vs. top-up-to-Need). The sub-line stays quiet on
   the inactive side and brightens on the selected one. */
.seg-ctl .seg-btn.seg-btn-stacked {
  display: flex; flex-direction: column; align-items: center; gap: 1px;
  line-height: 1.25; padding: 7px 10px;
}
.seg-ctl .seg-btn .seg-btn-sub {
  font-size: 12px; font-weight: 500; color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.seg-ctl .seg-btn.active .seg-btn-sub { color: var(--text); }
/* Empty amount (no Need typed yet) collapses so both buttons stay one line. */
.seg-ctl .seg-btn .seg-btn-sub:empty { display: none; }

/* SLIDING variant (`.seg-slide`) — the selection is one pill that MOVES between
   the two options instead of appearing under whichever was tapped (owner,
   2026-08-15, for Funding Method). Two options that produce different amounts
   from the same money are a genuine either/or, and a pill travelling between
   them says that; a background switching on and off says only "this one is
   pressed". It also lands on the SIDE it moved to, which is the readable part
   at a glance.
   Purple — the app's accent, and the Spending Fund type colour, which is the
   only type this control appears on.
   ⚠️ Opt-in per control. The only other `.seg-ctl` is Direction (money
   out/in), which keeps the plain highlight because it TINTS its active side
   green — a purple pill would fight the one thing that control says at a
   glance. (Pocket Type and Transaction Type are `.seg-toggle`, a different
   family, and unaffected either way.)
   ⚠️ TWO OPTIONS ONLY — the travel is hardcoded as one pill-width plus the gap.
   A third would need the offset driven off the index, not a single class. */
.seg-ctl.seg-slide { position: relative; }
.seg-ctl.seg-slide::before {
  content: ''; position: absolute; z-index: 0;
  top: 4px; bottom: 4px; left: 4px;
  /* Two buttons inside 4px padding either side and a 4px gap between. */
  width: calc((100% - 12px) / 2);
  background: var(--type-fund-12);
  border: 1px solid var(--type-fund-45);
  border-radius: 7px;
  transition: transform .18s ease;
}
/* The pill's own width plus the 4px gap — expressed against the pill, so it
   stays right whatever the control's width.
   ⚠️ A transform percentage resolves against the BORDER box, which is the same
   as the declared `width` only because `box-sizing: border-box` is set on
   everything. Without it the 1px border either side would make the pill 2px
   wider than the button and the travel 2px short. */
.seg-ctl.seg-slide.seg-slide-1::before { transform: translateX(calc(100% + 4px)); }
/* ⚠️ The buttons must sit ABOVE the pill or it paints over their text. */
.seg-ctl.seg-slide .seg-btn { position: relative; z-index: 1; }
/* The pill IS the background now, so the button must not draw its own — that
   opaque `--surface` would hide the pill underneath it. */
.seg-ctl.seg-slide .seg-btn.active {
  background: transparent; box-shadow: none; color: var(--type-fund);
}
.seg-ctl.seg-slide .seg-btn.active .seg-btn-sub { color: var(--type-fund); }
@media (prefers-reduced-motion: reduce) {
  .seg-ctl.seg-slide::before { transition: none; }
}

/* Manual accounts carry a quiet badge everywhere they're listed, so a
   user-maintained account is never mistaken for a live bank feed. Rendered
   next to the INSTITUTION name (and in account pickers), not the account
   name — "Manual" answers the same question that line does: where the data
   comes from. */
.badge.manual-acct {
  background: rgba(148,163,184,0.16); color: #cbd5e1;
  border: 1px solid rgba(148,163,184,0.30);
  vertical-align: baseline;
}
/* NOTE: the accounts list no longer renders this badge — it uses
   .account-manual-badge on the NAME line, sharing the Disabled badge's slot.
   .badge.manual-acct remains for the account pickers and the Connections list,
   where it trails a name inline. */
/* In an account picker the badge trails the name on one line. */
.cs-opt .badge.manual-acct,
.cs-btn .badge.manual-acct { font-size: 10px; padding: 1px 6px; margin-left: 6px; }

/* Long account names ("Costco Anywhere Visa® Card by Citi") already risked
   pushing the chevron out of a narrow dropdown — the trigger's label had no
   truncation at all, and adding a badge makes that worse. Truncate it.
   Deliberately NOT flex: .cs-btn > span and .cs-opt are shared by every
   dropdown in the app, and switching their layout mode to chase this one case
   would be a far wider change than the problem warrants. Plain block
   truncation fixes the overflow for every dropdown and needs no per-option
   markup. Consequence, accepted: on a very narrow trigger a long name can
   ellipsise the badge away — the menu row below is full width and still shows
   it, which is where the user is actually comparing accounts. */
.cs-btn > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Menu rows are full width, so the name only needs to not wrap mid-badge. */
.cs-opt .cs-name { white-space: nowrap; }

/* Destructive ghost button — same weight as .btn-ghost so it doesn't shout,
   but unmistakably the delete. Used in the transaction multi-select bar. */
.btn-ghost.btn-danger-ghost { color: var(--red); }
@media (hover: hover) {.btn-ghost.btn-danger-ghost:hover { background: rgba(239,68,68,0.12); }}
.auth-divider { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.btn-google { display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%; background: var(--surface2); border: 1px solid var(--border); color: var(--text); }
@media (hover: hover) {.btn-google:hover:not(:disabled) { border-color: var(--accent); }}
.btn-google svg { flex-shrink: 0; }

/* ── Bottom Nav ── */
#bottom-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; height: var(--nav-h); padding-bottom: env(safe-area-inset-bottom); background: rgba(26,26,34,0.95); backdrop-filter: blur(12px); border-top: 1px solid var(--border); z-index: 100; }
#bottom-nav.visible { display: flex; }
.nav-items { display: flex; width: 100%; max-width: 520px; margin: 0 auto; }
.nav-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; cursor: pointer; background: transparent; border: none; color: var(--muted); font-size: 10px; font-weight: 500; padding: 8px 0; transition: color 0.15s; border-radius: 0; }
/* Height is uniform across all three icons so the column-flex layout in
   .nav-item lines the labels up at the same y. Width drives the optical
   size: Pockets stays at the smaller width (its line-art reads heavier
   at the same width than the others' fills/whitespace). Sizes scaled
   down ~15% from the previous 30/36 px to match the slimmer nav. */
.nav-item svg { width: 26px; height: 30px; stroke-width: 1.8; }
/* NO per-icon width bumps any more (2026-08-14). Transactions and Profile each
   used to carry `width: 30px`, and both existed for the same reason: the glyph
   there was a Noun Project fill with the download's ~10% padding still in its
   viewBox, so it rendered small and the width was compensating for a box that
   was mostly empty. The padding is now tightened out of the viewBox at the use
   site, so the drawing fills its box and the compensation became a 15%
   oversize instead — measured 30px of ink against the Pockets wallet's 23.8.
   Fix the viewBox, not the width; a bump here is the tell that one was
   shipped untightened.
   `#nav-accounts svg { stroke-width: 1.3 }` went with them — it was tuning the
   owner's traced line-art bust, and every glyph in that slot since is a fill,
   where stroke-width does nothing at all. */
/* Easter egg: five quick taps on Profile swap the bust for the Guide (see
   _initDontPanicEgg). Both glyphs are in the markup and this picks one, so the
   swap costs no path data in JS and nothing has to be re-measured. */
#nav-accounts .nav-egg { display: none; }
#nav-accounts.egg-dont-panic .nav-icon { display: none; }
#nav-accounts.egg-dont-panic .nav-egg { display: block; }
.nav-item.active { color: var(--accent); }
.nav-item:active { transform: none; }

/* ── Page header ── */
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; padding-top: 2px; }
.page-header h1 { font-size: 22px; }

/* ── Connect banner ── */
.connect-banner { background: linear-gradient(135deg, #1e1b40, #271e42); border: 1px solid #3d3070; border-radius: var(--radius); padding: 24px; margin-bottom: 12px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.connect-banner p { font-size: 14px; color: var(--muted); line-height: 1.6; }
.connect-banner h2 { font-size: 17px; }

/* ── Account rows ── */
.account-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--border); }
.account-row:last-child { border-bottom: none; }
.account-name { font-size: 15px; font-weight: 500; }
.account-sub  { font-size: 12px; color: var(--muted); margin-top: 2px; }
.account-updated { font-size: 11px; color: var(--muted); opacity: 0.75; margin-top: 2px; }
.account-balance { font-size: 17px; font-weight: 700; color: var(--text); }
.account-balance.muted { color: var(--muted); }
.account-debt { font-size: 12px; color: var(--red); font-weight: 600; margin-top: 2px; text-align: right; }
/* Loans only have an outstanding balance (no "available"), so the debt
   line takes the primary slot. Same red as the secondary debt line on
   credit cards but sized like the regular balance. */
.account-debt.account-debt-primary { font-size: 17px; margin-top: 0; font-weight: 700; }
.account-debt.account-debt-primary.muted { color: var(--muted); }
/* A NEGATIVE liability balance is a credit on the account, not a debt — see
   _acctOwedParts. Same size and weight as .account-debt so the row keeps its
   rhythm; green rather than red, because it is money in your favour. It also
   has to carry .account-debt-primary's overrides, which key on that class. */
.account-credit { font-size: 12px; color: var(--green); font-weight: 600; margin-top: 2px; text-align: right; }
.account-credit.account-debt-primary { font-size: 17px; margin-top: 0; font-weight: 700; }
.account-credit.account-debt-primary.muted { color: var(--muted); }

/* "$X available" credit line under a card's owed headline. Muted, not red —
   it's headroom, not debt. Only shown when the bank reports enough to derive it. */
.account-sub-credit { font-size: 12px; color: var(--muted); margin-top: 2px; text-align: right; }

/* ── Transactions ── */
.tx-row { display: flex; align-items: center; justify-content: space-between; padding: 13px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.15s; }
.tx-row:last-child { margin-bottom: 0; }
@media (hover: hover) { .tx-row:hover { border-color: var(--accent); } }
.tx-merchant { font-size: 15px; font-weight: 500; }
.tx-meta { font-size: 12px; color: var(--muted); margin-top: 3px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.tx-amount { font-size: 16px; font-weight: 700; text-align: right; white-space: nowrap; }
.tx-amount.debit  { color: var(--text); }
.tx-amount.credit { color: var(--green); }
/* Subline amount: drops to date/bank text size while keeping bold + the
   credit-vs-debit color rules above. Used in linked-tx and suggestion
   rows where the amount sits in a "Date · Bank · Amount" inline meta
   line rather than as a right-rail figure. */
.tx-amount.tx-amount-sm { font-size: 12px; }

/* ── Badges ── */
.badge { display: inline-flex; align-items: center; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 99px; background: var(--surface2); color: var(--muted); border: 1px solid var(--border); }
.badge.pending  { background: rgba(250,204,21,0.1);  color: var(--yellow); border-color: rgba(250,204,21,0.3); }
.badge.linked   { background: rgba(173, 161, 255,0.1); color: var(--accent); border-color: rgba(173, 161, 255,0.3); }
.badge.verified { background: rgba(74,222,128,0.1);  color: var(--green);  border-color: rgba(74,222,128,0.3); }
.badge.manual   { background: rgba(56,189,248,0.12); color: #38bdf8; border-color: rgba(56,189,248,0.4); }
.badge.badge-warn { background: rgba(248,113,113,0.12); color: var(--red); border-color: rgba(248,113,113,0.4); }
/* Balance card sits inside #tx-modal-detail (not a direct .modal-body flex
   child), so it needs its own top gap to match the modal-body 10px rhythm. */
.tx-detail-manual-actions { margin-top: 10px; }
/* Locked "Update balance?" row: not tappable, and only the toggle dims (the
   explanatory text stays fully readable — unlike menu-toggle-disabled, which
   fades the whole row). */
.tx-balance-locked { cursor: default; }
@media (hover: hover) {.tx-balance-locked:hover { background: transparent; }}
.tx-balance-locked .toggle-pill { opacity: 0.45; }
/* Bottom action row: Cancel (left) + Save (right), even halves. With Cancel
   absent, Save fills the width. Delete moved into the header overflow on
   2026-08-13; the injected Cancel took its slot.
   ⚠️ The old note here said a .modal-body flex gap spaced it from the button
   above. It is not in the body any more, so there is no gap to lean on — the
   separation is the body clipping at this bar's top edge. */
/* ⚠️ Its own side padding, now that it is OUTSIDE .modal-body and no longer
   inherits any. Matches .pm-actions-bar, so the two pinned bars in this app sit
   on the same margins. */
.tx-save-row { display: flex; gap: 10px; flex: 0 0 auto;
  padding: 0 20px calc(24px + env(safe-area-inset-bottom)); }
.tx-save-row button { flex: 1; font-size: 14px; }
.btn-outline-danger { background: transparent; border: 1px solid var(--red); color: var(--red); }
@media (hover: hover) {.btn-outline-danger:hover:not(:disabled) { background: rgba(248,113,113,0.10); }}
.reconcile-row { cursor: pointer; }
.reconcile-empty { color: var(--muted); font-size: 13px; padding: 16px 4px; text-align: center; }
/* Auto-link / advance badges. Tinted differently from the linked / verified
   states above so they're spottable while we tune the matcher. */
.badge.badge-auto-linked { background: rgba(173, 161, 255,0.12); color: var(--accent); border-color: rgba(173, 161, 255,0.35); }
.badge.badge-advanced    { background: rgba(74,222,128,0.12);  color: var(--green);  border-color: rgba(74,222,128,0.35); }

/* Small inline indicator next to a linked-tx row's merchant name when
   that link advanced the pocket's due date. Purposely subtle — at a
   glance, the user can scan a list and see which row was the cycle's
   drain without it competing with the merchant text. */
.tx-linked-advanced { display: inline-block; margin-left: 6px; color: var(--green); font-size: 12px; font-weight: 700; }

/* ── Multi-select filter ── */
.filter-row { display: flex; gap: 10px; margin-bottom: 12px; }
.multi-select { position: relative; flex: 1; }
.multi-select-btn {
  width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px;
  padding: 10px 36px 10px 14px; font-size: 13px; color: var(--text); cursor: pointer;
  text-align: left; font-family: inherit; font-weight: 500; transition: border-color 0.2s;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 12px center;
}
@media (hover: hover) {.multi-select-btn:hover { border-color: var(--accent); }}
.multi-select-menu { display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; z-index: 50; overflow: hidden; box-shadow: 0 8px 24px rgba(0,0,0,0.4); min-width: 180px; }
.multi-select-menu.open { display: block; }
.multi-select-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; font-size: 14px; transition: background 0.1s; }
@media (hover: hover) {.multi-select-item:hover { background: var(--surface2); }}
.multi-select-item input[type="checkbox"] { accent-color: var(--accent); width: 16px; height: 16px; flex-shrink: 0; -webkit-appearance: auto; appearance: auto; }
.multi-select-divider { height: 1px; background: var(--border); margin: 2px 0; }

/* ── Transaction toolbar (search + filter + sort) ── */
.tx-toolbar { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.tx-search-row { display: flex; align-items: center; gap: 8px; }
.tx-search-wrap { flex: 1; position: relative; }
.tx-search-wrap svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
.tx-search-input { padding-left: 36px !important; font-size: 14px !important; }
.tx-filter-row { display: flex; gap: 8px; flex-wrap: wrap; }
.tx-filter-chip {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;
  background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
  cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
@media (hover: hover) {.tx-filter-chip:hover { border-color: var(--accent); color: var(--accent); }}
.tx-filter-chip.active { background: var(--accent-glow); border-color: var(--accent); color: var(--accent); }
.tx-filter-chip .chip-clear { font-size: 14px; color: var(--accent); margin-left: 2px; }
/* Filter panel (dropdown under toolbar) */
.tx-filter-panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px; display: none; flex-direction: column; gap: 12px; }
/* Two-column filter row (Account | Account Type, Transaction Type | Category). */
.tx-filter-row { display: flex; gap: 10px; }
.tx-filter-row .tx-filter-section { min-width: 0; }
.tx-filter-heading {
  font-size: 16px;
  font-weight: 700;
  color: var(--text);
  letter-spacing: -0.2px;
  padding-bottom: 6px;
  border-bottom: 1px solid var(--border);
  margin-bottom: 2px;
}
/* A panel with a pinned action bar. Same shape the wizard and the pocket editor
   use, and the same payoff: the body clips at the buttons' top edge, so content
   never passes behind them and the bar needs no backing and no fade (owner,
   2026-08-02). It used to trail the last filter down a tall screen, which put
   Clear and Done wherever the form happened to end.
   ⚠️ Written as `:has(> .tx-filter-actions)` rather than per panel, so the
   Transactions filter, the Pockets filter and Chart options are one rule — a
   new panel gets the behaviour by carrying the bar. The transaction editor
   joined on the same terms (owner, 2026-08-21) rather than growing a second
   copy of the shape scoped to its own id. */
.modal-overlay > .modal-box:has(> .tx-filter-actions),
.modal-overlay > .modal-box:has(> .tx-save-row) {
  display: flex; flex-direction: column; overflow: hidden;
}
.modal-overlay > .modal-box:has(> .tx-filter-actions) > .modal-body,
.modal-overlay > .modal-box:has(> .tx-save-row) > .modal-body {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
}
.tx-filter-actions {
  display: flex;
  flex-shrink: 0;
  gap: 10px;
  border-top: 1px solid var(--border);
  padding: 12px 20px calc(12px + env(safe-area-inset-bottom, 0px));
}
/* A zero basis, so whichever buttons are showing split the row exactly: Done
   alone fills it, Done beside Clear takes half. ⚠️ That only holds while both
   carry identical box extras — a zero basis adds border and padding ON TOP of
   the equal share, so any difference lands straight in the rendered width (the
   trap .pm-funds-row documents). Both are .btn-* with the same padding here. */
.tx-filter-action-btn {
  flex: 1 1 0;
  min-width: 0;
  padding: 10px 16px;
  font-size: 14px;
}
/* ⚠️ Measured at 166/164 before this: .btn-secondary carries a 1px border and
   .btn-primary does not, and on a zero flex-basis each button's border and
   padding are added ON TOP of its equal share — so the difference lands
   straight in the rendered width, as a half that isn't. The same trap
   .pm-funds-row documents. A transparent border on the PRIMARY squares the box
   extras (and the heights) without touching Clear's real outline — putting it
   on `.tx-filter-action-btn` instead would win over .btn-secondary's border by
   source order and quietly erase it. */
.tx-filter-action-btn.btn-primary { border: 1px solid transparent; }
.tx-filter-panel.open { display: flex; }
.tx-filter-section label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; display: block; margin-bottom: 6px; }
.tx-filter-range { display: flex; align-items: stretch; gap: 6px; }
.tx-filter-range input { flex: 1; font-size: 13px !important; padding: 8px 10px !important; }
.tx-filter-range span { color: var(--muted); font-size: 12px; display: flex; align-items: center; }
/* Per-row Clear button at the end of a range (amount / date). Square, matches
   the field height, muted until hovered. */
.tx-range-clear {
  flex-shrink: 0; display: flex; align-items: center; justify-content: center;
  width: 42px; padding: 0; background: var(--surface2);
  border: 1px solid var(--border); border-radius: 12px;
  color: var(--muted); cursor: pointer; transition: color 0.15s, border-color 0.15s;
}
@media (hover: hover) {.tx-range-clear:hover { color: var(--accent); border-color: var(--accent); }}
/* Explanatory helper line under a filter control (e.g. the amount-range
   absolute-value note). */
.tx-filter-hint { margin: 8px 0 0; font-size: 11.5px; line-height: 1.5; color: var(--muted); }
.tx-filter-hint b { color: var(--text); font-weight: 600; }

/* ── Uniform filter-field height ──────────────────────────────────
   Dropdowns, amount inputs, and date boxes across BOTH filter panels share
   one height so a date-range box is exactly as tall as a dropdown or a text
   entry. The date cal-button stretches to match via .dp-field-wrap's
   align-items: stretch. */
#tx-filter-panel .cs-btn,
#pocket-filter-panel .cs-btn,
#tx-filter-panel .tx-filter-range input,
#pocket-filter-panel .tx-filter-range input,
#tx-filter-panel .dp-field-wrap .dp-input,
#pocket-filter-panel .dp-field-wrap .dp-input,
#tx-filter-panel .tx-range-clear,
#pocket-filter-panel .tx-range-clear {
  min-height: 42px;
  box-sizing: border-box;
}
#tx-filter-panel .tx-filter-range input,
#pocket-filter-panel .tx-filter-range input,
#tx-filter-panel .dp-field-wrap .dp-input,
#pocket-filter-panel .dp-field-wrap .dp-input {
  font-size: 14px !important;
  padding: 9px 12px !important;
}
/* The empty / notice state for ANY list — transactions, Pockets, accounts.
   ⚠️ RENAMED off `tx-empty-*` (2026-08-29) when the offline notice started
   using it on the Pockets and Accounts lists too: a `tx-` class on the Pockets
   page reads as a transactions element that has wandered in. No aliases were
   left behind — every use site is in app.js and all of them moved. */
.list-empty-state {
  text-align: center;
  padding: 40px 16px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}
/* A one-line notice above a list that is showing saved data. Deliberately quiet
   — the banner at the top of the screen already says Offline; this only says
   why THESE rows are the ones on screen. */
.list-offline-note {
  font-size: 12px; color: var(--muted); line-height: 1.4;
  padding: 8px 10px; margin-bottom: 8px;
  background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius);
}
.list-empty-title { font-size: 16px; font-weight: 600; color: var(--text); margin: 0; }
.list-empty-sub { font-size: 13px; color: var(--muted); margin: 0 0 10px; max-width: 280px; line-height: 1.5; }
.list-empty-clear { padding: 8px 16px; font-size: 13px; }
.tx-section-header { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; padding: 10px 0 6px; margin-top: 4px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
.tx-section-header:first-child { padding-top: 0; }
/* Import result breakdown — the "why" lines under the headline. */
.imp-result-details {
  list-style: disc; margin: 10px 0 14px; padding-left: 20px;
  font-size: 13px; color: var(--muted); line-height: 1.6;
}

/* Transaction group headers reuse .pocket-group-header (see _txGroupHeaderHtml)
   so the two lists read as one app — including the collapse caret and the tap
   target. A collapsed group renders no body at all rather than hiding one, so
   there's no display:none counterpart here. */
.tx-group-body { display: contents; }

/* ── Sort controls ── */
/* flex-wrap + a min-width floor: the two columns sit side by side while they
   fit, and stack full-width once the viewport (or text zoom) gets too skinny —
   instead of squeezing the "Due Date" dropdown onto two lines. */
.sort-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; margin-bottom: 12px; }
.sort-col { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 158px; }
.sort-col:last-child { flex: 1; }
.sort-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
.sort-row select { width: 100%; font-size: 13px; padding: 10px 36px 10px 12px; }
.sort-dir-btn { background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; display: flex; align-items: center; justify-content: center; color: var(--text); min-width: 42px; flex-shrink: 0; align-self: flex-end; }
@media (hover: hover) {.sort-dir-btn:hover { border-color: var(--accent); color: var(--accent); }}

/* ── Pockets ── */
.pocket-row { padding: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; cursor: pointer; margin-bottom: 8px; transition: border-color 0.15s; }
.pocket-row:last-child { margin-bottom: 0; }
@media (hover: hover) { .pocket-row:hover { border-color: var(--accent); } }
.pocket-row.paused { opacity: 0.5; filter: grayscale(0.5); }
.pocket-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; gap: 8px; }
.pocket-name { font-size: 15px; font-weight: 600; flex: 1; }
.pocket-amounts { font-size: 13px; color: var(--muted); white-space: nowrap; }
.pocket-amounts strong { color: var(--text); }
.pocket-meta { font-size: 12px; color: var(--muted); margin-top: 6px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.progress-track { height: 6px; background: var(--surface2); border-radius: 99px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.4s ease; }
.progress-fill.full { background: var(--green); }
.progress-fill.over { background: var(--yellow); }
.due-soon { color: var(--yellow); }
.due-next-paycheck { color: #60a5fa; }
.past-due { color: var(--red); }

/* ── Overflow menus ── */
.bank-menu-wrap { position: relative; }
.bank-menu-btn { background: transparent; color: var(--muted); padding: 8px 10px; font-size: 22px; line-height: 1; }
@media (hover: hover) {.bank-menu-btn:hover { color: var(--text); }}
/* Grow to fit the longest item (e.g. "Refresh Account" + Premium badge + "?")
   instead of wrapping, with a 240px floor and a viewport-aware cap so it never
   runs off a narrow screen. */
.bank-menu { display: none; position: absolute; right: 0; top: 100%; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; min-width: 240px; width: max-content; max-width: calc(100vw - 24px); z-index: 50; overflow: hidden; box-shadow: 0 8px 24px rgba(0,0,0,0.4); }
.bank-menu.open { display: block; }
/* Drop-up: set by _menuFlipIfClipped when the menu would open past the bottom
   of the screen and there is room above. Same anchor edge, opposite side. */
.bank-menu.drop-up { top: auto; bottom: 100%; }
.bank-menu button { display: block; width: 100%; text-align: left; border-radius: 0; padding: 12px 16px; font-size: 14px; font-weight: 500; background: transparent; color: var(--text); }
@media (hover: hover) {.bank-menu button:hover { background: var(--surface2); }}
.bank-menu button.danger { color: var(--red); }
/* ⚠️ THIS RULE DID NOT EXIST UNTIL 2026-08-26, while five menus had already
   been emitting `<div class="menu-divider">` — so every one of them rendered a
   zero-height empty div and the separation this file claims for Account
   Activity's menus was never on screen. An unstyled class is silent; a missing
   divider looks like a design choice. */
.bank-menu .menu-divider { height: 1px; background: var(--border); margin: 4px 0; }

/* GTG toggle pill in menu */
.menu-toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; cursor: pointer; gap: 12px; }
@media (hover: hover) {.menu-toggle-row:hover { background: var(--surface2); }}
.menu-toggle-row span { font-size: 14px; font-weight: 500; color: var(--text); }
/* Right-side cluster (badge + pill) so the badge sits next to the
   pill instead of being center-spread by the row's space-between. */
.menu-toggle-row .menu-toggle-end { display: flex; align-items: center; gap: 8px; }
/* ⚠️ A toggle row is a LABEL AND A PILL — no sub-line, ever (owner,
   2026-08-12). Credit Card Integration briefly carried one explaining who the
   setting is for; the explanation belongs behind the row's "?" like every other
   one. Don't re-add the wrapper: a second line here is the thing being ruled
   out, not a styling problem to solve. */
/* Inside a toggle row, ".menu-toggle-row span" (text colour, 14px, weight 500)
   would override the "?" info button — restore the muted .refresh-info-q look so
   it matches the one on Refresh Account. */
.menu-toggle-row .refresh-info-q, .menu-toggle-row .info-q { color: var(--muted); font-size: 12px; font-weight: 400; }
@media (hover: hover) {.menu-toggle-row .refresh-info-q:hover, .menu-toggle-row .info-q:hover { color: var(--text); }}
/* Premium-gated toggle row: dims via TEXT COLOUR (not a blanket opacity) so
   its Premium badge stays legible — a parent opacity would fade the badge with
   everything else and a child can't undo it. Matches the colour-based dimming
   of .menu-item-gated / .menu-toggle-disabled so every dimmed row in the menu
   reads uniformly. pointer-events stay live (click launches the upsell) and
   hover restores full text colour, signalling clickability. The toggle pill
   gets its own mild dim so it reads as inactive. Badge font-size is locked to
   10px because .menu-toggle-row span (font-size:14px) outranks .premium-badge. */
.menu-toggle-gated { cursor: pointer; transition: color 0.15s; }
.menu-toggle-gated span { color: var(--muted); }
@media (hover: hover) {.menu-toggle-gated:hover span { color: var(--text); }}
.menu-toggle-gated .toggle-pill { opacity: 0.5; }
.menu-toggle-gated .premium-badge { font-size: 10px; font-weight: 600; }

/* Push notification sub-categories. Used inside the Notifications modal —
   no indent (every row is at the same hierarchy), no dividers (the rows
   sit visually as a flat list, separator lines added noise). Matches the
   master toggle row's 12px 16px padding. */
.push-subcat-row {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 12px 16px;
  cursor: pointer;
}
.push-subcat-list { padding-bottom: 8px; }
/* Divider under the master "Push notifications" toggle, separating it from the
   per-category list. Scoped to the notifications modal so other .menu-toggle-row
   uses are unaffected. */
#notifications-modal-body .menu-toggle-row { border-bottom: 1px solid var(--border); }

.profile-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.profile-row:last-of-type { border-bottom: none; }
.profile-row-text { flex: 1; min-width: 0; }
.profile-row-label { font-size: 12px; color: var(--muted); margin-bottom: 2px; }
.profile-row-value { font-size: 15px; color: var(--text); word-break: break-word; }

/* Flat full-width tile rows — the Manage Categories look, reused for the
   User Profile and Connections modals. The list bleeds to the modal edges
   (cancels the .modal-body 20px side padding); each row is a flat surface
   tile with a hairline separator. Mirrors .mc-row's metrics exactly. */
.flat-list { margin-left: -20px; margin-right: -20px; }
.flat-row { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.06); background: var(--surface); }
.flat-row:last-child { border-bottom: none; }
.flat-row-text { flex: 1 1 auto; min-width: 0; }
.flat-row-label { font-size: 12px; color: var(--muted); margin-top: 2px; }
.flat-row-value { font-size: 15px; color: var(--text); word-break: break-word; }
/* Manage Rules list rows — same flat tile treatment (the row's inner layout
   is built inline in renderMerchantRulesList). */
.rule-row { padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.06); background: var(--surface); cursor: pointer; }
.rule-row:last-child { border-bottom: none; }
@media (hover: hover) {.rule-row:hover { background: var(--surface2); }}
/* Disabled toggle row: colour-dim the label (keeps any badge readable) and
   dim only the pill so the row reads inactive without a blanket opacity. */
.menu-toggle-disabled { cursor: default; pointer-events: none; }
.menu-toggle-disabled span { color: var(--muted) !important; }
@media (hover: hover) {.menu-toggle-disabled:hover { background: transparent; }}
.menu-toggle-disabled .toggle-pill { opacity: 0.5; }
.toggle-pill { width: 38px; height: 22px; border-radius: 99px; background: var(--border); position: relative; transition: background 0.2s; flex-shrink: 0; }
.toggle-pill::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left 0.2s; }
.toggle-pill.on { background: var(--accent); }
.toggle-pill.on::after { left: 19px; }
.account-balance.gtg-on { color: var(--green); }
/* Negative reads red ONLY on a Good-to-Go account, where the balance feeds
   the forecast and the sign is a verdict. A GTG-off account stays white
   whatever the sign. _accountBalanceClass emits `negative` INSTEAD of
   `gtg-on` (never both), so there's no specificity race — the compound
   selector is listed anyway so red still wins if they ever coincide. */
.account-balance.negative,
.account-balance.gtg-on.negative { color: var(--red); }

/* ── Modals ── */
/* (Per-modal ID rules used to duplicate .modal-overlay and .modal-box-style
   rules, but their higher specificity fought the fullscreen-mobile media
   query. Class-based rules below are the source of truth.) */
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.modal-header h2 { font-size: 18px; }
.modal-close { background: transparent; color: var(--muted); font-size: 22px; padding: 4px 8px; }
@media (hover: hover) {.modal-close:hover { color: var(--text); }}
.field-label { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.field-group { display: flex; flex-direction: column; gap: 4px; }
.modal-actions { display: flex; gap: 10px; margin-top: 8px; }
.modal-actions button { flex: 1; }
/* Save buttons in modals: extra top margin = field-label line-height so there's
   breathing room. The pocket editor's row is NOT here any more — it left the
   body to be pinned (see .pm-actions-bar), and a top margin on a pinned bar is
   just a gap above the buttons. */
.modal-body .input-group > .btn-primary,
.modal-body > .btn-primary,
#page-accounts .card .btn-primary { margin-top: 16px; }

/* Modal linked transactions */
.modal-txs { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 12px; }
.modal-tx-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 8px; }
.modal-tx-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border); }
.modal-tx-row:last-child { border-bottom: none; }
.modal-tx-merchant { font-size: 14px; font-weight: 500; }
.modal-tx-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
.modal-tx-amount { font-size: 14px; font-weight: 700; white-space: nowrap; }

/* ── Summary row ── */
.summary-row { display: flex; gap: 20px; flex-wrap: wrap; }
.summary-item { font-size: 13px; color: var(--muted); }
.summary-item span { color: var(--text); font-weight: 600; display: block; font-size: 15px; }

/* ── Empty / loading ── */
.empty { text-align: center; padding: 40px 16px; color: var(--muted); }
.empty-icon { font-size: 36px; margin-bottom: 10px; }
.empty p { font-size: 14px; line-height: 1.6; }
.loading-state { text-align: center; padding: 40px 16px; color: var(--muted); font-size: 14px; }

/* ── Spinner ── */
.spinner { display: inline-block; width: 15px; height: 15px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.65s linear infinite; vertical-align: middle; margin-right: 5px; }
@keyframes spin { to { transform: rotate(360deg); } }

/* ── Toast ── */
/* Toast lives ABOVE every modal/overlay/sheet in the app. Highest z-index
   ceiling currently in the stylesheet is 9999 (.freq-pop) and 9000-9001
   (welcome flow overlay); 100000 keeps us comfortably clear of all of
   them without flirting with browser max-z weirdness. */
#toast { position: fixed; bottom: calc(var(--nav-h) + 12px); left: 50%; transform: translateX(-50%) translateY(20px); background: var(--surface2); border: 1px solid var(--border); color: var(--text); padding: 12px 18px; border-radius: 10px; font-size: 14px; transition: opacity 0.25s, transform 0.25s; z-index: 100000; white-space: nowrap; opacity: 0; pointer-events: none; max-width: min(96vw, 680px); overflow: hidden; text-overflow: ellipsis; }
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* Long messages (friendly bank errors, reconnect prompts) wrap to multiple
   lines instead of truncating to a single ellipsized line. Both forms may run
   nearly full-screen wide (capped on desktop). */
#toast.wrap { white-space: normal; overflow: visible; text-overflow: clip; text-align: left; line-height: 1.4; width: min(96vw, 680px); max-width: min(96vw, 680px); }
#toast.success { border-color: rgba(74,222,128,0.5);  color: var(--green); }
#toast.error   { border-color: rgba(248,113,113,0.5); color: var(--red); }
#toast.info    { border-color: var(--border); color: var(--muted); }

/* ── Install banner ── */
/* Pinned above the bottom nav so it doesn't cover tappable controls. Sized
   to look like an in-app suggestion, not a takeover. The [hidden] attribute
   is still honored so the JS toggle is the single source of truth. */
.install-banner { position: fixed; bottom: calc(var(--nav-h, 0px) + 12px); left: 12px; right: 12px; max-width: 460px; margin: 0 auto; display: flex; align-items: center; gap: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 12px 14px; box-shadow: 0 12px 32px rgba(0,0,0,0.45); z-index: 250; }
.install-banner[hidden] { display: none; }
.install-banner-icon { width: 40px; height: 40px; border-radius: 10px; background: #000; display: grid; place-items: center; flex-shrink: 0; }
.install-banner-icon img { width: 40px; height: 40px; border-radius: 10px; }
.install-banner-text { flex: 1; min-width: 0; }
.install-banner-title { font-size: 14px; font-weight: 700; color: var(--text); }
.install-banner-sub { font-size: 12px; color: var(--muted); margin-top: 2px; line-height: 1.35; }
.install-banner-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.install-banner-btn-primary { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 8px 14px; font-size: 13px; font-weight: 600; cursor: pointer; }
@media (hover: hover) {.install-banner-btn-primary:hover { background: var(--accent-dim); }}
.install-banner-btn-primary[hidden] { display: none; }
.install-banner-btn-close { background: transparent; border: none; color: var(--muted); width: 28px; height: 28px; border-radius: 8px; cursor: pointer; font-size: 14px; }
@media (hover: hover) {.install-banner-btn-close:hover { background: var(--surface2); color: var(--text); }}

/* ── Misc ── */
a { color: var(--accent); text-decoration: none; }

/* ── Page title row ── */
.page-title-row { display: flex; align-items: center; justify-content: space-between; padding-top: 4px; margin-bottom: 14px; gap: 8px; flex-wrap: wrap; }
/* Sits above the title on Profile, so it inherits the title row's own top
   padding and bottom gap rather than inventing a third value. */
#premium-profile-section:not(:empty) { padding-top: 4px; margin-bottom: 14px; }
.page-title-row h1 { font-size: 22px; }
/* The Profile page's overflow lives in its title row. `position: relative` on
   the wrap is what the absolutely-positioned .bank-menu anchors to; without it
   the menu would hang off the page. */
#profile-overflow-wrap { position: relative; flex-shrink: 0; margin-left: auto; }
/* Identity block: your login, in ONE card — name, email and Change Password
   (owner, 2026-08-12). It reuses .flat-row for the rows but NOT .flat-list,
   whose negative margins exist to bleed rows to a modal body's padding; this is
   a contained card on a page. `overflow: hidden` is what clips the rows'
   backgrounds to the rounded corners. */
/* ⚠️ 8px below, not 4 (owner, 2026-08-13). #premium-profile-section used to sit
   between this card and Manage Premium carrying a margin of its own — but it is
   display:none for a premium user, so for exactly the people who see Manage
   Premium the gap collapsed to this margin alone and the two blocks nearly
   touched. 8 is the rhythm between consecutive rows INSIDE a group, which is
   what these are now that a "User Profile" label sits over both. (18 was tried
   first — that is the gap that separates groups, and it read as too much.) */
.profile-identity {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); overflow: hidden; margin-bottom: 8px;
}
.profile-identity > .flat-row { padding: 14px 16px; }
/* Change Password is a ROW here, not a button beside the card. Reset the
   global button chrome so it sits flush with the two above it. */
.profile-identity-action {
  width: 100%; border: 0; border-top: 1px solid rgba(255, 255, 255, 0.06);
  color: var(--text); text-align: left; font: inherit; cursor: pointer;
}
.profile-identity-action:active { transform: none; }
.profile-identity-action > svg { color: var(--muted); flex-shrink: 0; }
@media (hover: hover) { .profile-identity-action:hover { background: var(--surface2); } }
/* Empty until a save reports back — see _populateUserProfile. */
.profile-identity > .alert:empty { display: none; }

/* ── Send Feedback ─────────────────────────────────────────────────────────
   Ordinary form rows; only the bits with no existing equivalent are styled. */
/* The dev-only send failure carries the provider's raw message on its own
   line, so this alert honours the newline the client puts in. */
#fb-err { white-space: pre-wrap; word-break: break-word; }
#feedback-modal-overlay .modal-box { overflow: hidden; }
#feedback-modal-overlay > .modal-box > .modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.fb-intro { font-size: 14px; line-height: 1.55; color: var(--muted); margin: 0; }
/* The beta line and its "?" read as one sentence with a question mark after it,
   so they sit on one baseline rather than as a row with a control at the end. */
.fb-beta-note {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  font-size: 13px; line-height: 1.5; color: var(--muted); margin: -2px 0 2px;
}
/* The count lives IN the label, right-aligned, so it can't shift the field. */
.field-label:has(> .fb-count) { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.fb-count { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.fb-count.at-limit { color: var(--red); }
.fb-textarea {
  width: 100%; min-height: 140px; resize: vertical;
  padding: 12px 14px; background: var(--surface2); border: 1px solid var(--border);
  border-radius: 12px; color: var(--text); font-size: 15px; line-height: 1.5;
  font-family: inherit;
}
.fb-textarea:focus { outline: none; border-color: var(--accent); }
.fb-add-files { width: 100%; }
.fb-thumbs { display: flex; flex-wrap: wrap; gap: 8px; }
.fb-thumbs:empty { display: none; }
.fb-thumb { position: relative; width: 72px; height: 72px; border-radius: 10px; overflow: hidden; border: 1px solid var(--border); }
.fb-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Sits ON the thumbnail, so a wall of images stays a wall of images rather
   than doubling in height to make room for a row of remove buttons. */
.fb-thumb-x {
  position: absolute; top: 2px; right: 2px; width: 22px; height: 22px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%; background: rgba(0,0,0,0.62); color: #fff;
  font-size: 12px; line-height: 1; font-weight: 600;
}

/* ── Tutorial cards ────────────────────────────────────────────────────────
   Stacked and scrolling, each taking the height its own text needs (owner,
   2026-08-12). The onboarding deck's fixed-height stage exists to fill time
   while a sync runs; here the reader is in charge, so nothing is clipped and
   nothing is padded out to match its neighbours. */
.tut-card {
  display: flex; gap: 12px; align-items: flex-start;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 14px 16px; margin-bottom: 10px;
}
/* The number is the only reason these read as a sequence rather than a list of
   unrelated notes — the deck had dots for that. Fixed box so the text columns
   line up down the stack however many digits it grows to. */
.tut-card-num {
  flex: none; width: 24px; height: 24px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: var(--accent); color: #fff;
  font-size: 12px; font-weight: 700; margin-top: 1px;
}
.tut-card-text { min-width: 0; }
.tut-card-title { font-size: 15px; font-weight: 700; color: var(--text); line-height: 1.3; }
.tut-card-body { font-size: 13.5px; color: var(--muted); line-height: 1.5; margin-top: 4px; }

.pockets-header-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.pocket-header-btn { width: auto; padding: 8px 14px; font-size: 13px; white-space: nowrap; }
.pocket-header-btn-primary { padding: 8px 16px; font-size: 14px; }

/* Notification badge appended to "+ Add Pocket" and inside "Recommended
   Pockets" — a small pill that surfaces the count of pending suggestions.
   Hidden via inline style="display:none" when empty; the JS layer flips
   it on/off as suggestions populate. */
.suggestion-badge {
  display: inline-flex; align-items: center; justify-content: center;
  margin-left: 8px;
  min-width: 18px; height: 18px;
  padding: 0 6px;
  border-radius: 99px;
  background: rgba(255, 255, 255, 0.22);
  color: #fff;
  font-size: 11px; font-weight: 700;
  line-height: 1;
}
/* The badge inside the modal's recommended button rides on a darker
   surface, so use a contrasting color for legibility. Same for the
   header's secondary Recommended button. */
#pm-recommended-btn .suggestion-badge,
#pockets-recommended-btn .suggestion-badge {
  background: var(--accent);
  color: #fff;
}

/* Pocket-suggestions modal section header — shown above each lifecycle
   bucket (Active / Recent / Inactive). The body uses the same flat
   surface as the .modal-body, so the header sits on top of an
   accent-tinted strip rather than inside a card. */
.ps-section { margin-bottom: 18px; }
.ps-section:last-child { margin-bottom: 0; }
.ps-section-header { padding: 4px 4px 8px; }
.ps-section-title {
  font-size: 13px; font-weight: 700; color: var(--text);
  text-transform: uppercase; letter-spacing: 0.5px;
}
.ps-section-count {
  display: inline-block; margin-left: 6px;
  font-size: 11px; font-weight: 500; color: var(--muted);
  letter-spacing: normal; text-transform: none;
}
.ps-section-desc { font-size: 11px; color: var(--muted); margin-top: 2px; line-height: 1.4; }
.ps-section-defaults .ps-section-title { color: var(--accent); }
.ps-section-active .ps-section-title { color: var(--green); }
.ps-section-recent .ps-section-title { color: var(--yellow); }
.ps-section-inactive .ps-section-title { color: var(--muted); }

/* Pocket-suggestions modal rows — a card-shaped strip per detected
   recurring expense. */
.ps-row {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px;
  padding: 10px 12px;
  background: var(--surface2);
  border: 1px solid var(--border);
  border-radius: 12px;
  margin-bottom: 8px;
}
.ps-row-info { flex: 1; min-width: 0; }
.ps-row-tap { cursor: pointer; }
@media (hover: hover) {.ps-row-tap:hover .ps-row-name { color: var(--accent); }}
/* Accept-in-place: a full-row purple fill sweeps in from the left with a
   centered check. The row stays put (no reflow) so rapid taps down the column
   never shift rows; pointer-events:none makes a repeat tap a no-op. */
.ps-row-accepted { position: relative; overflow: hidden; pointer-events: none; }
.ps-row-accepted::before {
  content: ''; position: absolute; inset: 0; z-index: 1;
  background: var(--accent); transform: translateX(-101%);
  animation: ps-accept-sweep 0.26s ease forwards;
}
.ps-row-accepted::after {
  content: '✓ Accepted'; position: absolute; inset: 0; z-index: 2;
  display: flex; align-items: center; justify-content: center;
  color: #fff; font-weight: 700; font-size: 15px;
  opacity: 0; animation: ps-accept-label 0.18s ease 0.16s forwards;
}
@keyframes ps-accept-sweep { to { transform: translateX(0); } }
@keyframes ps-accept-label  { to { opacity: 1; } }
/* When tapping stops, accepted rows fade/slide out, then a re-render reflows. */
.ps-row-leaving { opacity: 0; transform: translateX(-14px); transition: opacity 0.2s ease, transform 0.2s ease; }
.ps-row-name { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 2px; }
.ps-row-meta { font-size: 12px; color: var(--muted); margin-bottom: 2px; }
.ps-row-meta .ps-amount { color: var(--green); font-weight: 600; font-variant-numeric: tabular-nums; }
/* Recency tinting on "last seen" date inside suggestion-list rows.
   Fresh = under one cycle since the last observed tx (still active).
   Stale = 1–2 cycles (recently lapsed but worth flagging).
   Old   = over 2 cycles (probably abandoned; surfaced anyway since
   they're useful for retro-linking historical txs). */
.ps-last-seen { font-weight: 500; }
.ps-last-seen.ps-recency-fresh { color: var(--green); }
.ps-last-seen.ps-recency-stale { color: #facc15; }
.ps-last-seen.ps-recency-old   { color: var(--muted); }

/* Archived pockets section — collapsed <details> at the bottom of the
   pockets list. Cards inside dim slightly so the user reads them as
   "retired" rather than active. */
.pockets-archived-section { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 8px; }
.pockets-archived-section > summary {
  cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px;
  padding: 8px 4px; font-size: 13px; color: var(--muted); user-select: none;
}
.pockets-archived-section > summary::-webkit-details-marker { display: none; }
.pockets-archived-section > summary .card-collapse-chevron {
  width: 12px; height: 12px; color: var(--muted); transition: transform 0.15s; margin-left: auto;
}
.pockets-archived-section[open] > summary .card-collapse-chevron { transform: rotate(90deg); }
.pockets-archived-label { font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; font-size: 11px; }
.pockets-archived-body { padding-top: 8px; }
/* ⚠️ THE DIM IS ON THE CARD'S BODY, NEVER ON A CONTAINER (owner-reported
   2026-08-26: "the overflow menu shouldn't be transparent"). `opacity` on
   .pockets-archived-body made the whole subtree ONE composited group at 0.7 —
   including any overflow menu opened inside it — so the sort row showed
   straight through an open menu, and the container's new stacking context also
   trapped the card's own `z-index: 60`.
   The ⋮ is a SIBLING of .pocket-card-body (the card is a flex row: body +
   button gutter), so dimming the body leaves the menu out of the group
   entirely. This is exactly what .pocket-card.paused already does — it dims its
   named text parts rather than the card — and the archived section, added
   later, broke the pattern. Any future "dim a card" wants the same shape. */
.pockets-archived-body .pocket-card-body { opacity: 0.7; }
.pockets-archived-body .pocket-card { background: var(--surface2); }

/* Inline archived-pocket-name match prompt inside the New Pocket modal.
   Shows up under the name field when the user types a name that matches
   an archived pocket; offers one-click Restore. */
.pm-archived-match {
  display: flex; align-items: center; gap: 10px;
  margin-top: 8px; padding: 8px 12px;
  background: var(--surface2); border: 1px solid var(--border);
  border-radius: 8px; font-size: 12px; color: var(--muted);
}
.pm-archived-match .btn-link {
  background: transparent; border: none; padding: 0;
  color: var(--accent); font-weight: 600; font-size: 12px;
  cursor: pointer; text-decoration: underline; margin-left: auto;
}
@media (hover: hover) {.pm-archived-match .btn-link:hover { color: var(--accent-dim, var(--accent)); }}
.ps-row-sample { font-size: 11px; color: var(--muted); font-style: italic; opacity: 0.75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps-row-actions { display: flex; gap: 6px; flex-shrink: 0; }
.ps-row-actions .btn-primary,
.ps-row-actions .btn-secondary { width: auto; padding: 6px 12px; font-size: 12px; }

/* Action pills sit inside the GTG hero below the paycheck button. Natural
   width — they hug their label/amount — centered as a pair. The GTG number is
   the focus of this page, so these buttons stay quiet: subtle secondary
   surface, with the amount rendered as small muted detail text. */
/* flex-wrap: the buttons are nowrap, so each keeps its full label+amount width;
   when both no longer fit on one line (skinny viewport / text zoom) the second
   wraps and each flex:1 button grows to full width — a clean vertical stack. */
.pockets-action-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.pocket-action-btn { flex: 1; display: inline-flex; align-items: baseline; justify-content: center; gap: 6px; padding: 10px 14px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--border); color: var(--text); cursor: pointer; transition: border-color 0.15s, color 0.15s; font: inherit; white-space: nowrap; line-height: 1.2; }
@media (hover: hover) {.pocket-action-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }}
@media (hover: hover) {.pocket-action-btn:hover:not(:disabled) .pocket-action-amount { color: var(--accent); }}
.pocket-action-btn:disabled { cursor: not-allowed; color: var(--muted); opacity: 0.75; }
.pocket-action-btn.is-muted { color: var(--muted); border-color: var(--border); }
.pocket-action-btn.is-muted .pocket-action-amount { color: var(--muted); }
/* Amount turns red when the action would push GTG negative — label stays
   the same so the action remains visually clickable. */
.pocket-action-btn.over-budget .pocket-action-amount { color: var(--red); }
@media (hover: hover) {.pocket-action-btn.over-budget:hover .pocket-action-amount { color: var(--red); }}
.pocket-action-label { font-size: 14px; font-weight: 600; }
.pocket-action-amount { font-size: 12px; font-weight: 500; color: var(--muted); }
.pocket-action-btn:disabled .pocket-action-amount { display: none; }

/* ── Soft select wrapper (select + custom arrow overlay) ── */
.soft-select-wrap { position: relative; }
.soft-select-wrap select { padding-right: 36px; }
.soft-select-arrow { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--muted); display: flex; align-items: center; }

/* ── Pocket cards ── */
/* The press-in is the card's own acknowledgement (owner, 2026-08-12): the
   global tap highlight is off app-wide, so without this a tap that opens a
   modal has nothing between the finger going down and the screen changing.
   Scale rather than a background change — it reads on a card of any colour,
   including the paused and credit variants. */
.pocket-card {
  background: #2a2a36; border: 1px solid var(--border); border-radius: 12px;
  padding: 14px 14px 12px; margin-bottom: 10px; cursor: pointer;
  transition: border-color 0.15s, transform 0.12s ease-out;
}
.pocket-card:active { transform: scale(0.982); }
/* ⚠️ ...but NOT when the press is in the overflow gutter (owner, 2026-08-13:
   "while depressing the button for the overflow menu, the overflow menu is
   transparent. Once you let go it shows up correctly").
   The press-in acknowledges a tap that opens the pocket modal; a tap on the ⋮
   stops propagation and opens no modal, so there is nothing to acknowledge —
   and scaling an ancestor of an open menu makes the browser re-rasterize the
   whole card as a composited layer with the menu inside it, which is what he
   was seeing paint through. :active matches ancestors of the pressed element,
   so this covers the ⋮ and every item in the menu it opens.
   The second rule is the same fix the group header already carries at
   .pocket-group-header:has(.bank-menu.open): while a menu is open the card is
   lifted above its neighbours outright, so an item hanging over the next card
   takes its own clicks rather than that card's. */
.pocket-card:has(.pocket-card-menu:active) { transform: none; }
.pocket-card:has(.bank-menu.open) { position: relative; z-index: 60; transform: none; }
/* The card's two parts: everything in a column, the ⋮ in a gutter beside it.
   `min-width: 0` so a long pocket name shrinks the body rather than pushing the
   button off the card. */
.pocket-card { display: flex; align-items: stretch; gap: 8px; }
.pocket-card-body { flex: 1 1 auto; min-width: 0; }
@media (hover: hover) { .pocket-card:hover { border-color: var(--accent); } }
.pocket-card.paused { background: #15151e; border-color: #222234; }
@media (hover: hover) { .pocket-card.paused:hover { border-color: var(--accent); } }
.pocket-card.paused .pocket-name,
.pocket-card.paused .pocket-amounts,
.pocket-card.paused .pocket-meta,
.pocket-card.paused .badge-spending-fund { opacity: 0.65; }
.pocket-card-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
/* ⚠️ The ⋮ is centred on the WHOLE CARD, not on the name row (owner,
   2026-08-12). That's why the card is a flex ROW with a body column beside the
   button: `align-self: center` against the row centres it on the card's full
   height. Inside the name row it could only ever be level with the name. */
.pocket-card-menu { flex-shrink: 0; align-self: center; margin-left: 2px; }
.pocket-card-menu .bank-menu-btn { padding: 0 4px; line-height: 1; }
/* The amounts take the free space so they stay right-aligned within the body
   rather than each dodging the button by a different amount. */
.pocket-card-row .pocket-amounts { margin-left: auto; }
.pocket-name-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex: 1; min-width: 0; }
.pocket-name { font-size: 15px; font-weight: 600; color: var(--text); }
.pocket-amounts { font-size: 16px; font-weight: 700; white-space: nowrap; flex-shrink: 0; }
.pocket-stashed { color: var(--text); }
.pocket-sep { color: var(--muted); font-weight: 400; }
.pocket-goal { color: var(--muted); font-weight: 500; }
.pocket-category { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; border-radius: 99px; padding: 2px 8px; border: 1px solid; flex-shrink: 0; margin-bottom: 5px; }
/* Tags row — category pill on the left, status badges on the right.
   Space-between pushes them to the edges of the card so the row reads
   as "what this pocket IS" (category) and "what STATE it's in"
   (badges) at a glance. Wraps if a long category name + many badges
   don't fit. */
.pocket-tags-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  flex-wrap: wrap;
  margin-top: 6px;
}
.pocket-badges {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}
.pocket-progress-wrap { height: 10px; background: #35354a; border-radius: 99px; overflow: hidden; margin-top: 8px; }
.pocket-progress-bar { height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.4s ease; min-width: 0; }
.pocket-progress-bar.full   { background: var(--green); }
.pocket-progress-bar.over   { background: var(--green); }
.pocket-progress-bar.behind { background: var(--red); }
/* ⚠️ WRAPS, and its two halves stack LEFT-aligned when they do (owner,
   2026-08-12). It was `nowrap` + `overflow: hidden`, so on a narrow card the
   funding line was simply cut off. space-between (not a margin-left: auto on
   the funding line) is what makes the wrapped line start at the left edge
   instead of staying jammed against the right. */
.pocket-meta {
  font-size: 12px; color: var(--muted); display: flex; gap: 2px 10px;
  flex-wrap: wrap; align-items: center; justify-content: space-between;
  margin-top: 4px;
}
.pocket-meta-left { display: flex; gap: 10px; align-items: center; min-width: 0; }
/* Pocket-edit modal: the spending figures under the Pocketed/Need row, in their
   own outlined block rather than loose centred text. One row per stat (last
   completed pay period first, then the windowed average), label left and amount
   right so the amounts line up in a column you can read down. Either row can be
   absent - each stat stands alone - so nothing here assumes two.
   Same outline as the Spending Fund card directly below it (`.card`: --surface,
   1px --border, --radius) so the two blocks in this form read as one family.
   Padding matches that card's inner .menu-toggle-row rather than .card's own
   16px, since this is a compact stat pair, not a titled section. */
.pm-spend-avg {
  font-size: 12px; color: var(--muted);
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 12px 16px; margin: -2px 0 4px;
}
.pm-spend-avg-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.pm-spend-avg-row + .pm-spend-avg-row { margin-top: 6px; }
.pm-spend-avg-label { min-width: 0; }
.pm-spend-avg-amt { color: var(--accent); font-weight: 700; white-space: nowrap; }
/* The block is a row: the stat lines in a column, then the overflow trigger in
   its own gutter. Keeping the ⋮ OUT of the stat column is what lets the amounts
   stay right-aligned to each other instead of dodging it. */
.pm-spend-avg { display: flex; align-items: center; gap: 6px; }
.pm-spend-avg-body { flex: 1 1 auto; min-width: 0; position: relative; }
.pm-spend-menu-wrap { flex: 0 0 auto; }
.pm-spend-menu-wrap .bank-menu-btn { padding: 2px 4px; font-size: 20px; }
/* ⚠️ Opens LEFT, unlike the range menu beside it. The trigger sits at the block's
   RIGHT edge, so a left-anchored menu grows off the screen; right-anchored it
   grows back across the block, which is where the room is. Sized to content
   rather than the .bank-menu 240px floor. */
.pm-spend-menu { right: 0; left: auto; min-width: 0; width: max-content; max-width: calc(100vw - 48px); }
/* The timeframe control. Dotted underline as a BACKGROUND gradient, never
   text-decoration - see the note on .mg-sum-mode: a decoration paints outside
   the content box, and this line re-renders in place every time the range
   changes, which is exactly where that leaves a ghost behind. */
.pm-spend-range {
  color: var(--text); cursor: pointer; white-space: nowrap;
  background-image: linear-gradient(to right, var(--muted) 0 2px, transparent 2px 4px);
  background-size: 4px 1px; background-repeat: repeat-x; background-position: 0 100%;
  padding-bottom: 2px;
}
@media (hover: hover) {.pm-spend-range:hover { color: var(--accent); }}
/* ⚠️ Both menus in this block open LEFT-ALIGNED and size to their content. The
   block is ~340px inside the modal and the default .bank-menu is right-anchored
   at a 240px floor, which pushed its left edge off the screen - the same trap
   the Pocketed-field menu hit. */
.pm-range-menu { left: 0; right: auto; min-width: 0; width: max-content; max-width: calc(100vw - 48px); }
.pm-range-menu .menu-item.is-on { color: var(--accent) !important; }

/* Free-tier teaser in place of the spending-insights figures. Centred, because
   it's one line of invitation rather than a label/amount pair. */
/* ⚠️ It sits in the AMOUNT slot now, not centred across the block (owner,
   2026-08-12), so it takes that slot's alignment — a full-width centred badge
   would push the label off its own row. */
.pm-spend-avg-lock { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }

/* Pocket modal: "Loading more…" sentinel under the linked-tx list. */
.pm-linked-more { font-size: 12px; color: var(--muted); text-align: center; padding: 10px 0 4px; }

/* Pocket modal: the "Linked Transactions" and "Recommended Transactions"
   buttons, stacked. Each has a left-aligned title + description, a count
   badge spanning both rows, and a right-hand caret into its list. */
.pm-linked-actions { display: flex; flex-direction: column; gap: 8px; align-items: stretch; }
.pm-linked-btn {
  width: 100%; display: flex; align-items: center; gap: 10px; text-align: left;
  background: var(--surface2); border: 1px solid var(--border); color: var(--text);
  border-radius: 12px; padding: 12px 14px; cursor: pointer;
}
@media (hover: hover) {.pm-linked-btn:hover { border-color: var(--accent); }}
.pm-linked-btn-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.pm-linked-btn-title { font-size: 14px; font-weight: 600; }
.pm-linked-btn-desc { font-size: 12px; font-weight: 400; color: var(--muted); }
.pm-linked-btn-caret { color: var(--muted); flex-shrink: 0; }
.pm-linked-btn .pm-linked-btn-badge { margin-left: 0; flex-shrink: 0; }

/* Reset Account (debug) modal rows. */
.admin-reset-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); }
.admin-reset-row:last-child { border-bottom: 0; }
.admin-reset-info { min-width: 0; flex: 1; }
.admin-reset-name { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.admin-reset-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.admin-reset-counts { font-size: 11px; color: var(--muted); margin-top: 2px; }
.admin-reset-btn { flex-shrink: 0; background: transparent; color: var(--red); border: 1px solid var(--red); border-radius: 8px; padding: 6px 14px; font-size: 13px; font-weight: 600; cursor: pointer; }
@media (hover: hover) {.admin-reset-btn:hover:not(:disabled) { background: var(--red); color: #fff; }}
.admin-reset-btn:disabled { opacity: 0.6; cursor: default; }

/* Accounts (dev-tools) modal: totals header, sort control + per-row ⋮ menu. */
/* Account kind — exactly one per account, always shown on the user sheet.
   ⚠️ Legacy and Beta share a treatment on purpose: both are free grants, and
   the distinction that matters at a glance is paying / not / me. */
.badge-kind { font-weight: 600; letter-spacing: .02em; }
.badge-kind-premium { background: #facc1520; color: #facc15; border-color: #facc1555; }
.badge-kind-basic   { background: var(--surface2); color: var(--muted); }
.badge-kind-legacy,
.badge-kind-beta    { background: #ada1ff20; color: #ada1ff; border-color: #ada1ff55; }
.badge-kind-admin   { background: #4def5a20; color: #4def5a; border-color: #4def5a55; }

/* Waitlist capacity — the early warning on the cap. Sits above the roster
   totals because it is the one figure that decides whether new people get in.
   ⚠️ It only takes a colour when there is something to say: neutral under 75%,
   amber approaching, red once a queue actually exists. A figure that is always
   coloured says nothing by changing. */
.admin-waitlist-panel { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; margin: 12px 0 0;
  background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); }
.admin-waitlist-panel.is-near { border-color: #facc1566; background: #facc150f; }
.admin-waitlist-panel.is-full { border-color: #f8717166; background: #f871710f; }
.admin-wl-main { font-size: 15px; font-weight: 600; color: var(--text); }
.admin-waitlist-panel.is-near .admin-wl-main { color: #facc15; }
.admin-waitlist-panel.is-full .admin-wl-main { color: #f87171; }
.admin-wl-sub { font-size: 12px; color: var(--muted); }
.admin-accounts-totals { display: flex; flex-wrap: wrap; gap: 10px 20px; padding: 12px 0; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--muted); }
/* The denominator is DIMMER and smaller than the figure: "4 of 8" is one
   reading, and setting both at full weight makes it two competing numbers. */
.admin-total-of { font-size: 0.72em; font-weight: 500; color: var(--muted); }
.admin-total-num { font-size: 16px; font-weight: 700; color: var(--text); }
.admin-accounts-controls { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 0; border-bottom: 1px solid var(--border); }
.admin-sort-group { display: flex; align-items: center; gap: 6px; }
.admin-sort-label { font-size: 12px; color: var(--muted); font-weight: 600; }
.admin-sort-select { background: var(--surface2, var(--surface)); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 13px; font-weight: 500; cursor: pointer; }
/* Roster filter — matches the sort select's box so the two read as one row of
   controls. Lights up while any filter is on, because a filter left from an
   earlier question is the thing you forget is there. */
.admin-filter-btn {
  background: var(--surface2, var(--surface)); color: var(--text);
  border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px;
  font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap;
}
.admin-filter-btn.is-on { color: var(--accent); border-color: var(--accent); }
@media (hover: hover) {.admin-filter-btn:hover { border-color: var(--accent); }}
/* ⚠️ Selected state is NOT inside @media (hover: hover) — selecting is not
   hovering, and this menu is multi-select, so the ticks are the only thing
   saying what is on. */
.admin-filter-wrap .menu-item.is-on { color: var(--accent); font-weight: 600; }
.admin-filter-wrap .menu-item.is-on::after { content: ' ✓'; }
/* The note wraps rather than truncating: it lists every active filter by name,
   and the whole point is knowing WHICH are on. Clear never wraps away from it. */
.atl-filter-note {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
  flex-wrap: wrap; font-size: 12px; color: var(--muted); padding: 6px 0 2px;
}
.atl-filter-note strong { color: var(--text); font-weight: 700; }
.atl-filter-clear {
  flex: 0 0 auto; background: none; border: 0; padding: 0;
  color: var(--accent); font-size: 12px; font-weight: 600; cursor: pointer;
  /* A background gradient, never text-decoration — a decoration paints outside
     the content box, and this row re-renders in place on every filter change,
     which is exactly where that leaves a ghost underline. Same rule as
     .mg-sum-mode. */
  background-image: linear-gradient(var(--accent), var(--accent));
  background-size: 100% 1px; background-repeat: no-repeat; background-position: 0 100%;
}
@media (hover: hover) {.atl-filter-clear:hover { color: var(--text); }}
.admin-sort-group .sort-dir-btn { align-self: center; }

/* ── Bulk outreach email: the recipient picker (dev) ──────────────────
   A tick, a person, and the reason you picked them — the same last-active line
   the lane carries, so the roster filter that produced this list is still
   legible while you write the letter. */
.atl-bulk-bar {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
  padding: 4px 0 8px; font-size: 12px; color: var(--muted);
  border-bottom: 1px solid var(--border);
}
.atl-bulk-count strong { color: var(--text); font-size: 15px; font-weight: 700; }
.atl-bulk-bar .atl-filter-clear + .atl-filter-clear { margin-left: 12px; }
/* The no-name preview under the Message field. Quiet, but the rendered line is
   the thing being checked, so IT is the part that reads as text. */
.atl-bulk-preview:empty { display: none; }
.atl-bulk-preview strong { color: var(--text); font-weight: 600; }
.atl-bulk-row {
  display: flex; align-items: flex-start; gap: 10px;
  padding: 9px 0; border-bottom: 1px solid var(--border);
}
.atl-bulk-row:last-child { border-bottom: 0; }
/* ⚠️ Dimmed, never hidden. Someone who opted out or has no address on file is
   the answer to "why didn't they get it" — dropping them from the list turns
   that into a mystery, and the badge beside the name says which it is. */
.atl-bulk-row.is-blocked { opacity: 0.5; }
.atl-bulk-tick {
  flex: 0 0 auto; width: 22px; height: 22px; margin-top: 1px;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface2); color: var(--bg);
  border: 1px solid var(--border); border-radius: 6px;
  font-size: 13px; font-weight: 800; line-height: 1; cursor: pointer;
}
/* ⚠️ NOT inside @media (hover: hover) — selecting is not hovering, and on a
   phone the tick is the only thing saying who is about to be mailed. */
.atl-bulk-tick.is-on { background: var(--accent); border-color: var(--accent); color: #fff; }
.atl-bulk-tick:disabled { cursor: default; opacity: 0.6; }
@media (hover: hover) {.atl-bulk-tick:not(:disabled):hover { border-color: var(--accent); }}
.atl-bulk-who { min-width: 0; flex: 1; }
.atl-bulk-name {
  font-size: 13.5px; font-weight: 600; color: var(--text);
  display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
}
/* The ADDRESS wraps rather than clipping — it is the fact being confirmed
   before a real stranger is mailed, and a truncated one confirms nothing. */
.atl-bulk-email { font-size: 12px; color: var(--text); overflow-wrap: break-word; }
.atl-bulk-meta { font-size: 11px; color: var(--muted); margin-top: 1px; }

/* ── Activity Timeline (dev) ──────────────────────────────────────────
   One lane per user, one mark per VISIT. Identity is carried by the LANE,
   not by mark colour — every mark is the same accent, so nothing here
   impersonates a status colour and no legend is needed. Percent-based
   positioning (not SVG) so it reflows on resize with no re-render. */
/* ⚠️ THE GRID IS THE ONLY SCROLLER, IN BOTH DIRECTIONS (owner, 2026-08-17:
   "ensure the horizontal scroll is never below the fold"). It used to scroll
   sideways while the PAGE scrolled down, so on a long roster the horizontal
   scrollbar sat at the foot of every lane — below the fold, i.e. you scrolled
   the page down past the whole chart to reach the control that moves the chart
   sideways. Giving this box the leftover height puts that scrollbar on the
   viewport's bottom edge at every roster length.
   `.modal-scroll-contained` on the box (index.html) is what gives it a definite
   height to fill; the `#id >` scope out-specifies `.modal-body > *
   { flex-shrink: 0 }`, which would otherwise refuse to shrink it and there
   would be nothing to scroll. */
.atl-scroll { overflow: auto; margin: 0 -4px; }
#activity-timeline-modal-body > .atl-scroll { flex: 1 1 auto; min-height: 0; }
.atl-grid { min-width: 560px; }
.atl-row { display: grid; grid-template-columns: 150px 1fr; border-bottom: 1px solid var(--border); }
.atl-row:last-child { border-bottom: 0; }
@media (hover: hover) {.atl-row:hover .atl-track { background: rgba(255,255,255,0.04); }}
/* Frozen name column + date axis — a consequence of the box above scrolling in
   both directions rather than a flourish: without them, scrolling right leaves
   anonymous lanes and scrolling down leaves undated marks. Each needs its OWN
   --surface background or the lanes travel through it; the corner cell is both
   at once, so it takes the top z-index. */
.atl-lab {
  position: sticky; left: 0; z-index: 2; background: var(--surface);
  padding: 8px 10px 8px 2px; display: flex; align-items: center; gap: 6px; min-width: 0;
}
/* The label is a real <button> — tapping a lane opens that user's detail. It
   is NOT the whole .atl-lab, because the hide ✕ is a sibling and a button
   inside a button breaks the inner one's clicks. */
.atl-lab-text {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; justify-content: center; gap: 2px;
  background: none; border: 0; padding: 0; margin: 0;
  text-align: left; font: inherit; color: inherit; cursor: pointer;
  border-radius: 5px;
}
@media (hover: hover) {.atl-lab-text:hover .atl-name { color: var(--accent); }}
.atl-lab-text:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.atl-grid > .atl-row:first-child { position: sticky; top: 0; z-index: 3; background: var(--surface); }
.atl-grid > .atl-row:first-child .atl-lab { z-index: 4; }
/* ⚠️ `display: block` explicitly — text-overflow applies to block containers,
   not inline boxes. It only ellipsises today because the flex parent blockifies
   it, which would stop silently the moment that parent changed. */
.atl-name {
  display: block; font-size: 12.5px; font-weight: 600; color: var(--text);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.atl-meta { font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums;
  display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.atl-meta .badge { font-size: 8.5px; padding: 1px 4px; }
.atl-track { position: relative; min-height: 34px; }
.atl-day { position: absolute; top: 0; bottom: 0; border-left: 1px solid var(--border); opacity: 0.35; }
.atl-day-wk { opacity: 0.9; }
/* Rounded data-ends, min-width so a single tap stays visible. */
.atl-sess {
  position: absolute; top: 50%; transform: translateY(-50%);
  height: 10px; min-width: 5px; border-radius: 4px;
  background: var(--accent); border: 0; padding: 0; display: block; cursor: pointer;
}
@media (hover: hover) {.atl-sess:hover, .atl-sess:focus-visible { background: #fff; outline: 2px solid var(--accent); outline-offset: 1px; }}
.atl-sess-lg { height: 14px; }
/* A visit’s detail follows the pointer instead of a toast in the corner.
   ⚠️ pointer-events: none is load-bearing — it sits directly over the lane, so
   without it the tooltip takes the pointerout for the mark it describes and the
   pair flickers. ⚠️ It is on <body>, above the timeline’s own inline z-index
   (300 + stack depth) and below dialogs (10000) so a confirm still covers it. */
.atl-tip {
  position: fixed; z-index: 9500; pointer-events: none;
  display: none; max-width: min(360px, calc(100vw - 12px));
  padding: 7px 10px; border-radius: 8px;
  background: var(--surface); border: 1px solid var(--border);
  color: var(--text); font-size: 12px; line-height: 1.35;
  box-shadow: 0 6px 20px rgba(0,0,0,0.45);
}
.atl-tip.open { display: block; }
/* A renamed Pocket puts USER TEXT where the row otherwise shows a number or a
   date, so it has to wrap and be allowed to end. The money styling assumes a
   short fixed-width string and would let a long name push the row wide. */
.ph-name { max-width: 55%; text-align: right; white-space: normal;
  overflow-wrap: break-word; font-variant-numeric: normal; }
/* Signup notch sits at the lane's FOOT — centred it collided with the first
   visit, which usually starts seconds after signup. */
.atl-signup {
  position: absolute; bottom: 2px; width: 6px; height: 6px; margin-left: -3px;
  transform: rotate(45deg); border: 1.5px solid var(--muted);
}
.atl-before { position: absolute; left: 1px; bottom: 0; font-size: 10px; color: var(--muted); }
.atl-empty {
  position: absolute; left: 8px; top: 50%; transform: translateY(-50%);
  font-size: 11px; color: var(--muted); font-style: italic;
}
.atl-axis { position: relative; height: 22px; }
.atl-axis .atl-day span {
  position: absolute; top: 5px; left: 5px; white-space: nowrap;
  font-size: 10px; color: var(--muted);
}
/* Per-lane hide + the restore bar. Icon-only ✕ is a functional control (the
   modal's own dismiss is the same glyph), so the no-decorative-symbols rule
   isn't in play — but the restore CHIPS are text buttons and deliberately carry
   no symbol beside the name for exactly that reason. */
.atl-hide {
  flex: 0 0 auto; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 5px;
  background: none; color: var(--muted); font-size: 11px; line-height: 1;
  cursor: pointer; opacity: 0.5;
}
@media (hover: hover) {
  .atl-hide:hover { opacity: 1; color: var(--text); background: rgba(255,255,255,0.08); }
}
.atl-hide:focus-visible { opacity: 1; color: var(--text); }
.atl-hidden-bar {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  padding-top: 2px; font-size: 11.5px;
}
.atl-hidden-lab { color: var(--muted); }
.atl-chip {
  font-size: 11.5px; color: var(--text); background: var(--surface2);
  border: 1px solid var(--border); border-radius: 999px; padding: 3px 9px; cursor: pointer;
}
.atl-chip-all { color: var(--accent); border-color: var(--accent); }
@media (hover: hover) {.atl-chip:hover { border-color: var(--accent); }}

/* One user's detail sheet. Label/value rows rather than the Accounts row's
   `·`-joined runs — the same facts, with the room to let the values line up in
   a column you can read down. */
.atl-det-sub { font-size: 12.5px; color: var(--muted); overflow-wrap: break-word; }
.atl-det-badges { display: flex; flex-wrap: wrap; gap: 5px; }
.atl-det-head {
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
  color: var(--muted); margin-bottom: 2px;
}
.atl-det-row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 14px;
  padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 13px;
}
.atl-det-row:last-child { border-bottom: 0; }
/* The LABEL wraps, the VALUE never does — same split the Budget screen makes. */
.atl-det-lab { color: var(--muted); min-width: 0; }
.atl-det-val {
  flex: 0 0 auto; color: var(--text); font-weight: 600;
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.atl-det-val.is-warn { color: var(--red); }
/* A breakdown of the row above it — indented, quieter, and no rule of its own
   so the group reads as one entry rather than as N more facts. The last sub-row
   restores the divider, or the next real row would butt straight up to it. */
/* ⚠️ A SUB-TABLE IS A GRID, NOT FLEX ROWS. `.atl-det-row` is
   justify-content: space-between with ONE value — right for a label/value pair,
   and unable to align a second numeric column: the numbers land wherever their
   digits fall and drift row to row. `--sub-cols` is set inline per table so one
   rule serves both the 2-column account split and the 1-column linked split,
   and the columns end at the right edge like every other figure here. */
.atl-subtbl {
  display: grid;
  grid-template-columns: 1fr repeat(var(--sub-cols, 1), minmax(4ch, auto));
  column-gap: 16px;
}
.atl-sub-row { display: contents; }
.atl-sub-row > * { padding: 3px 0; }
.atl-sub-row.is-head > * { padding-top: 5px; }
.atl-sub-lab { padding-left: 14px; font-size: 12px; color: var(--muted); min-width: 0; }
.atl-sub-n {
  font-size: 12px; font-weight: 500; color: var(--muted); text-align: right;
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.atl-sub-row.is-head .atl-sub-n {
  font-size: 10px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
/* The sub-table sits INSIDE the flex column of label/value rows, so it needs to
   restore the divider the row after it would otherwise butt against. */
.atl-subtbl + .atl-det-row { border-top: 1px solid var(--border); }
.atl-det-note { font-size: 11.5px; color: var(--muted); line-height: 1.5; margin: 4px 0 0; }
.atl-det-link { width: 100%; }
/* A link riding a label/value row's VALUE cell — sized to sit beside the value
   without changing the row's height, so the column of figures stays aligned. */
.atl-inline-link {
  background: none; border: 0; padding: 0 0 0 8px; margin: 0;
  color: var(--accent); font-size: 12px; font-weight: 600; cursor: pointer;
  font-family: inherit; white-space: nowrap;
}
@media (hover: hover) {.atl-inline-link:hover { color: var(--text); }}
/* ⚠️ NO `.atl-refresh-icon` RULES HERE ANY MORE. Refresh was a spinning header
   icon on this screen and on the user sheet; both are menu items now (owner,
   2026-08-19), and a menu item closes as it fires, so there is no element left
   to spin or dim — the "Refreshing…" toast is the feedback. A reappearing
   `.atl-refresh-icon` rule means something is writing that element again, and
   the overlap guard it used to carry now lives in `_atlRefreshing`. */
/* Pocket use: a real table, so the two number columns can be read DOWN. The
   label wraps, the numbers never do — same split every figure block here makes.
   `auto` columns, not fixed, so the widest count sets both and they still line
   up when someone has 3 digits. */
/* Two number columns: total / unlinked & due (owner, 2026-08-18). */
.atl-tbl { display: grid; grid-template-columns: 1fr auto auto; column-gap: 16px; }
/* With the suggestion columns there are six, which will not fit a phone — so
   the TABLE scrolls sideways inside the sheet rather than any column being
   dropped or the numbers shrinking (nothing in this app scales text to fit).
   The label column is sticky so you never lose which row you are reading, the
   same treatment the timeline's own lane labels get. */
/* ⚠️ minmax(max-content, 1fr), NOT 1fr (owner-reported 2026-08-23, screenshot:
   "Standard0", "Spending0 / Fund"). A bare 1fr is free to collapse to ZERO once
   the five auto columns want more than the sheet is wide — which on a phone they
   always do — and `.atl-tbl-lab`'s own `min-width: 0` removes the last floor.
   Measured at 390px: the label track resolved to 8px (its padding, no content)
   while "Spending Fund" rendered 76px, so the text overflowed 47px and sat ON
   TOP of the TOTAL number. The min keeps the track at the longest label, which
   is exactly what the surrounding overflow-x scroller exists to absorb — the
   table was always meant to scroll rather than squeeze a column. The 1fr max
   keeps it filling a wide sheet, so the numbers stay right where they were. */
.atl-tbl.has-recs { grid-template-columns: minmax(max-content, 1fr) repeat(5, auto); column-gap: 14px; }
/* ⚠️ Two column GROUPS counting different things — pockets, then
   recommendations. Labelled and separated, because a row reading "4 … 5" with
   nothing between them looks like one quantity contradicting itself. The rule
   sits on the FIRST suggestion cell of every row (4th child) so it runs the
   full height of the table rather than only under the header. */
.atl-tbl-grp {
  font-size: 9.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--muted); opacity: 0.75; text-align: center; padding-bottom: 2px;
}
/* ⚠️ The group boundary sits on the FIRST suggestion cell, which moved
   from the 4th child to the 5th when the pockets group grew a column. Left
   behind it would rule between "no links" and "past due" — two columns that
   are the same kind of thing. */
.atl-tbl.has-recs .atl-tbl-row > *:nth-child(4) {
  border-left: 1px solid var(--border); padding-left: 14px; margin-left: -1px;
}
.atl-tbl-row.atl-tbl-group > * { padding-top: 0; border-bottom: 0; }
.atl-tbl-scroll { overflow-x: auto; margin: 0 -2px; padding: 0 2px; }
.atl-tbl.has-recs .atl-tbl-lab {
  position: sticky; left: 0; background: var(--surface); z-index: 1; padding-right: 8px;
}
.atl-tbl-row { display: contents; }
.atl-tbl-row > * { padding: 7px 0; border-bottom: 1px solid var(--border); }
.atl-tbl-row:last-child > * { border-bottom: 0; }
.atl-tbl-head > * { padding-top: 0; }
.atl-tbl-head .atl-tbl-n {
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
  color: var(--muted);
}
.atl-tbl-lab { font-size: 13px; color: var(--muted); min-width: 0; }
.atl-tbl-n {
  font-size: 13px; font-weight: 600; color: var(--text); text-align: right;
  font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 3ch;
}
/* The total row is the sum of the four under it, so it leads. */
.atl-tbl-row.is-lead .atl-tbl-lab, .atl-tbl-row.is-lead .atl-tbl-n { color: var(--text); font-weight: 700; }
/* Some of this type exist but not all are linked — the priors gap. */
.atl-tbl-n.is-warn { color: var(--red); }
/* None of this type at all: not a problem, just nothing to say. */
.atl-tbl-n.is-zero { color: var(--muted); font-weight: 500; }
/* The Accounts row's usage line reuses .admin-reset-counts; only the number
   that means "these pockets will never auto-advance" takes the warning tone. */
.admin-count-warn { color: var(--red); font-weight: 600; }

/* ── Onboarding Timeline (dev) ── one row per wizard screen, in order. */
.onb-step { padding: 7px 0; border-bottom: 1px solid var(--border); }
.onb-step:last-child { border-bottom: 0; }
.onb-step-main { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; }
.onb-step-name { font-size: 13px; font-weight: 600; color: var(--text); min-width: 0; }
/* The duration never wraps — it is the column you read down. */
.onb-step-dwell {
  flex: 0 0 auto; font-size: 13px; font-weight: 600; color: var(--text);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.onb-step-dwell.is-warn { color: var(--muted); }
.onb-step-meta { font-size: 11px; color: var(--muted); margin-top: 1px; overflow-wrap: break-word; }
/* An absence between two screens, drawn rather than folded into the next
   screen's dwell — in an abandoned funnel the gap IS the finding. */
.onb-gap {
  font-size: 11px; color: var(--muted); font-style: italic;
  padding: 5px 0 5px 12px; border-left: 2px solid var(--border); margin: 2px 0 2px 2px;
}

/* ── Transaction cards ── */
.tx-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin-bottom: 10px; cursor: pointer; transition: border-color 0.15s; position: relative; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; touch-action: pan-y; }
@media (hover: hover) { .tx-card:hover { border-color: var(--accent); } }
.tx-card.selectable { padding-left: 44px; }
.tx-card.selectable::before { content: ''; position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border); background: transparent; transition: background 0.12s, border-color 0.12s; }
.tx-card.selectable.selected { border-color: var(--accent); }
.tx-card.selectable.selected::before { background: var(--accent); border-color: var(--accent); }
.tx-card.selectable.selected::after { content: '✓'; position: absolute; left: 18px; top: 50%; transform: translateY(-50%); color: #fff; font-size: 13px; font-weight: 700; line-height: 1; }
#tx-select-bar { position: fixed; left: 50%; bottom: calc(var(--nav-h) + 12px); transform: translateX(-50%); z-index: 1000; display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 10px 30px rgba(0,0,0,0.45); max-width: calc(100% - 16px); white-space: nowrap; }
#tx-select-bar button { flex-shrink: 0; white-space: nowrap; }
#tx-select-bar .btn-primary { width: auto; padding: 8px 18px; font-size: 14px; }
#tx-select-bar .btn-ghost { padding: 8px 12px; font-size: 14px; }
.tx-card-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.tx-amount-group { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.tx-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--muted); }
.tx-card-bottom { display: flex; align-items: center; justify-content: space-between; gap: 6px; flex-wrap: wrap; }
.tx-meta-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.tx-meta-right { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-left: auto; }
.tx-acct-badge { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 99px; background: var(--surface2); color: var(--muted); border: 1px solid var(--border); white-space: nowrap; }
.tx-pocket-badge { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 99px; background: rgba(173, 161, 255,0.12); color: var(--accent); border: 1px solid rgba(173, 161, 255,0.3); white-space: nowrap; }
.tx-category { font-size: 11px; color: var(--muted); }
.tx-date { font-size: 11px; color: var(--muted); white-space: nowrap; }
.tx-status { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 99px; }
.tx-status.posted { background: rgba(74,222,128,0.1); color: var(--green); border: 1px solid rgba(74,222,128,0.3); }
.tx-status.pending  { background: rgba(250,204,21,0.1); color: var(--yellow); border: 1px solid rgba(250,204,21,0.3); }

/* ── Transaction modal detail ── */
.tx-detail-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
.tx-detail-main { flex: 1 1 auto; min-width: 0; }
.tx-detail-merchant-wrap { margin-bottom: 4px; }
.tx-detail-merchant-wrap .btn-icon { display: inline-flex; vertical-align: middle; margin-left: 6px; }
.tx-detail-merchant-text { font-size: 18px; font-weight: 700; word-break: break-word; }
.tx-detail-merchant-subtext { font-size: 12px; color: var(--muted); margin-bottom: 6px; word-break: break-word; }
.tx-detail-raw { font-size: 12px; color: var(--muted); word-break: break-word; }
.tx-detail-raw:last-of-type { margin-bottom: 6px; }
.tx-detail-raw-label { color: var(--muted); opacity: 0.75; }
/* ⚠️ The date shares its line with the Manual badge (owner, 2026-08-21): both
   are facts about the RECORD — when it happened, and who entered it — rather
   than about the money. space-between puts Manual on the same right edge the
   amount and the account below it end on. */
.tx-detail-date { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px;  }
.tx-detail-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
/* ⚠️ The amount and Manual share the right-hand column (owner, 2026-08-21), so
   Manual sits directly under the figure it qualifies rather than in the badge
   row, which is now only the Pockets' categories and the posting status. */
.tx-detail-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
.tx-detail-amount { font-size: 26px; font-weight: 800; color: var(--text); white-space: nowrap; flex-shrink: 0; text-align: right; }
/* The account on its own line, directly under Recommended Category — one more
   fact ABOUT the transaction, kept out of the row that says what it is filed
   under. */
/* ⚠️ The right-hand column mirrors the CARD (owner, 2026-08-21): Manual left of
   the amount, then the account on the line beneath with Pending to ITS left.
   Both rows end flush right, which is what keeps the amount and the account
   on one edge the eye can run down. */
/* ⚠️ `.tx-detail-amount-row` is DELETED, not left dormant. It existed only to
   sit Manual beside the amount; a rule reappearing there means that pairing is
   coming back, which is the tell worth keeping. */
/* One fact per line in the right-hand column, each ending on the same edge:
   amount, then account, then status. flex-end and not just the column's
   align-items, because these rows WRAP on a narrow screen and a wrapped line
   would otherwise start at the left of the box. */
.tx-detail-status { display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
  justify-content: flex-end; }
/* ⚠️ `justify-content: flex-end` and not just the parent's align-items: this row
   WRAPS on a narrow screen, and a wrapped line would otherwise start at the
   left of the box rather than staying on the right edge. */
.tx-detail-account { display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
  justify-content: flex-end; }
.tx-detail-amount.credit { color: var(--green); }
.tx-detail-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 4px; }

/* ── Transaction modal action layout ── */
/* No margin-top: these are direct .modal-body flex children, so the container's
   gap:10px spaces them — same as how the pocket edit modal's buttons/fields work.
   Adding margin here would double the gap (10px flex gap + margin). */
.tx-assign-btn { width: 100%; padding: 12px 14px; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* ── Transaction split rows ──────────────────────────────────────────────────
   ONE GRID PER POCKET:

     [ Pocket            ] [ $amount ] [ Split | + | trash ]
     [ 6 items   $172.01 ]                                     <- column 1 only
     [ Advance Due Date ......................................] <- all columns

   ⚠️ The grid is what makes the itemisation read as belonging to its row (owner,
   2026-08-21: "it's hard to tell which Itemize button belongs to which row...
   the pocket row and itemization need to feel congruent"). The Items button is
   placed in COLUMN 1, so it is exactly the Pocket button's width and shares both
   its edges — no magic offsets, and it cannot drift if the amount field is ever
   resized. Before this it was `width: calc(100% - 52px)`, which spanned the
   Pocket AND the amount and therefore pointed at neither.

   ⚠️ Everything else here is placed AUTOMATICALLY. The control lands in column 3
   when split and column 2 when not, which is why the columns change rather than
   the elements: pinning grid-column on the control would break the unsplit row.
   Only the advance card is pinned, because it must span the lot. */
.txs-part { display: grid; grid-template-columns: 1fr auto;
  column-gap: 8px; row-gap: 6px; align-items: stretch; }
.txs-part.is-split { grid-template-columns: 1fr 118px 44px; }
/* ⚠️ A RULE BETWEEN POCKETS, which is all this ever needed (owner, 2026-08-21:
   "this was so simple from the start... divider lines between the pockets").
   Five shapes went into trying to enclose one Pocket's group — a column-width
   button, a tucked tray, a black panel, an open bracket, and finally the pill —
   when the thing actually missing was a line saying where one group ends and the
   next begins. Separating them needs no container at all.
   ⚠️ The remainder is a .txs-part too and takes the rule with them, which is
   right: it is the last line item in the same list, and the only one whose
   figure has to be read against the others. */
.txs-part + .txs-part { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }

.txs-pocket { grid-column: 1; min-width: 0; margin: 0; }
.txs-ctl { padding: 12px 14px; font-size: 14px; font-weight: 600; margin: 0; }
/* Icon-only controls are square, so a column of them lines up with the amount
   field beside it whatever the label was. */
.txs-ctl-icon { padding: 0; font-size: 18px; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center; }
.txs-ctl-icon svg { display: block; }
/* Delete is destructive and says so on hover only — a column of permanently red
   trashcans reads as a warning about the split rather than a control on it. */
.txs-ctl-danger { color: var(--muted); }
@media (hover: hover) { .txs-ctl-danger:hover { color: var(--red); border-color: var(--red); } }

/* ⚠️ THE APP'S OWN MONEY FIELD (owner, 2026-08-20). This used to be a bespoke
   flex box with a <span>$</span> and a right-aligned borderless input, and it
   read as foreign next to every other amount in the app — which are all
   .money-input-wrap: a ::before dollar at 14px, standard 12px/14px padding, left
   aligned. Right-alignment was also why the caret landed on the rounded corner
   at the end of a full value. So NOTHING is declared here but the numerals; the
   width comes from the grid column and the look from the shared rule. */
.txs-amt input { font-variant-numeric: tabular-nums; }
/* An itemised row is driven by its list, so its total is not typed here —
   tapping opens the list instead. Styled as a control rather than greyed out:
   it is not disabled, it leads somewhere. */
.txs-amt-input.is-itemised { cursor: pointer; }

/* ── The Items control ───────────────────────────────────────────────────────
   A small enclosed pill under the Pocket button: a list glyph, then either
   "Itemize" or the count. Nothing else.

   ⚠️ NO TOTAL (owner, 2026-08-21). It always equals the amount in the field
   directly above — "if it's there it has to match OR it created the amount" —
   so printing it was the same figure twice. Dropping it is what let the control
   shrink from a full-width band to something that can be enclosed on its own.

   ⚠️ `justify-self: start` is what makes it a PILL rather than a row. It still
   spans grid-column 1 / -1 so it is free to grow with its label, but it hugs its
   content instead of stretching — which is the difference between a button and a
   band, and the band never "felt enclosed" in any of its three forms.

   ⚠️ FOURTH SHAPE. Gone before it: a column-1-only button; a filled tray tucked
   under the row with a negative margin and an inset shadow; a black var(--bg)
   panel around the part; and an open bracket with no top border. None of their
   machinery survives — a reappearing z-index, box-shadow, panel background or
   border-top: none here is one of them coming back. */
.txs-part:has(.txs-items-btn) { row-gap: 7px; }
/* ⚠️ "Suggested", after the Pocket name inside its own button (owner,
   2026-08-21). It rides INSIDE the button rather than beside it because it
   qualifies that name — moved outside, it would read as a second control and
   would need its own place in the grid.
   ⚠️ `font-weight: 500` against the button label's 600, and --muted: the name is
   the answer and this is a note about it. Louder and it competes with the thing
   it is describing. */
/* ⚠️ YELLOW, not the neutral grey it started as (owner, 2026-08-21). Same
   tinted-pill recipe .tx-status uses, so it reads as native rather than as a new
   kind of mark: 10% fill, 30% border, solid text.
   ⚠️ IT IS THE SAME --yellow AS `.tx-status.pending`, which sits a few rows above
   it on this very screen — and the two co-occur constantly, because a new charge
   is pending AND unlinked. Raised with the owner rather than silently substituted;
   if it reads as a colour key, this is the rule to move, not Pending. */
.txs-suggested { margin-left: 8px; font-size: 10px; font-weight: 600;
  letter-spacing: 0.02em; color: var(--yellow); background: rgba(250,204,21,0.1);
  border: 1px solid rgba(250,204,21,0.3); border-radius: 99px; padding: 2px 7px;
  /* ⚠️ The label can wrap on a narrow screen; this must not be split off its
     own name, and must not be squeezed to nothing either. */
  white-space: nowrap; flex: 0 0 auto; }

.txs-items-btn { grid-column: 1 / -1; justify-self: start;
  display: inline-flex; align-items: center; gap: 6px;
  margin: 0; padding: 5px 11px;
  background: var(--surface2); border: 1px solid var(--border); border-radius: 999px;
  color: var(--muted); font-size: 12.5px; font-weight: 600; font-family: inherit; cursor: pointer; }
.txs-items-btn svg { display: block; flex: 0 0 auto; }
/* Holding a list is said by the TEXT and the count. A second background would be
   the filled tray again, which is what this replaced. */
.txs-items-btn.has-items { color: var(--text); }
@media (hover: hover) { .txs-items-btn:hover { border-color: var(--accent); color: var(--accent); } }

/* The derived remainder. Same grid, so its amount lines up with the real ones —
   it is the one row whose number has to be read against them. Dashed and muted
   because it is not a Pocket and not something to tap. */
.txs-row-remainder { align-items: center; }
.txs-remainder-label { grid-column: 1; display: flex; align-items: center;
  padding: 12px 14px; font-size: 14px; font-weight: 600; color: var(--muted);
  background: transparent; border: 1px dashed var(--border); border-radius: var(--radius);
  min-width: 0; }
.txs-amt-static { display: flex; align-items: center; justify-content: flex-end;
  padding: 12px 14px; font-size: 15px; font-weight: 600; color: var(--muted);
  background: var(--surface2); border: 1px solid var(--border); border-radius: 12px;
  font-variant-numeric: tabular-nums; }
/* Over-allocated is a different claim from unallocated and gets a different
   colour: the parts ask for more than was spent, which Save refuses. */
.txs-remainder-over { color: var(--red); border-color: var(--red); }
.txs-advance { grid-column: 1 / -1; padding: 0; overflow: hidden; margin: 2px 0 0 0; }

/* 118px of amount + 44px of control + gaps leaves the Pocket button under 120px
   on a 320px screen, which ellipsises a name to almost nothing. Below 360 the
   grid collapses to one column and everything stacks — restack, never shrink. */
@media (max-width: 359px) {
  .txs-part, .txs-part.is-split { grid-template-columns: 1fr; }
  .txs-advance { grid-column: 1; }
}

/* ── Items list ──────────────────────────────────────────────────────────── */
.txi-subtitle { font-size: 13px; font-weight: 500; color: var(--muted); margin-left: 6px; }
.txi-row { display: flex; align-items: stretch; gap: 8px; }
.txi-row + .txi-row { margin-top: 8px; }
.txi-desc { flex: 1 1 auto; min-width: 0; }
.txi-amt { flex: 0 0 118px; }
.txi-amt input { font-variant-numeric: tabular-nums; }
.txi-del { flex: 0 0 44px; }
/* ⚠️ 44px EXPLICITLY, because .txs-ctl-icon carries no width of its own.
   In .txs-part it is a grid item and the 44px COLUMN sizes it; here the row is
   flex, so `flex: 0 0 auto` collapsed the button to its 16px glyph and the tap
   target with it (owner: the delete button is squished). Same figure as the
   split row control column, so the two trashcans are the same object. */
/* Pinned outside the scroller, so the list clips above it rather than sliding
   under it — the app's standard action-bar shape, and why it needs no fade. */
.txi-footer { flex: 0 0 auto; padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
  border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
.txi-total-row { display: flex; align-items: baseline; justify-content: space-between;
  font-size: 13px; color: var(--muted); }
.txi-total { font-size: 20px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
/* ⚠️ ONE-POCKET LISTS ONLY (PART 7). A split row absorbs any difference by
   construction, so it never shows this; a single Pocket has no remainder row and
   the shortfall has nowhere else to be said. Sits under the running total it is
   measured against, inside the pinned footer, so it is on screen while typing. */
.txi-balance { font-size: 12px; line-height: 1.4; }
.txi-balance.is-short { color: var(--muted); }
/* ⚠️ Over the transaction is the only one of the two that is WRONG rather than
   merely unfinished — a list can be part-typed, it cannot legitimately exceed
   what was spent. */
.txi-balance.is-over { color: var(--red); }
@media (max-width: 359px) {
  .txi-row { flex-wrap: wrap; }
  .txi-desc { flex: 1 1 100%; }
  .txi-amt { flex: 1 1 auto; }
}


/* ── Linked transaction rows (in pocket modal) ── */
.tx-linked-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; gap: 10px; }
.tx-linked-row:last-child { border-bottom: none; }
/* A row that ends a section drops its hairline: the section header below it
   already draws a rule, and stacking the two put a double line above every
   label. The header's own underline is the divider there. */
.tx-linked-row:has(+ .tx-section-header) { border-bottom: none; }
.tx-linked-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.tx-linked-date { font-size: 12px; color: var(--muted); }
.tx-linked-right { display: flex; align-items: center; gap: 8px; }
.tx-linked-unlink { background: transparent; border: none; color: var(--muted); font-size: 14px; line-height: 1; padding: 4px 6px; border-radius: 6px; transition: color 0.15s, background 0.15s; cursor: pointer; }
@media (hover: hover) {.tx-linked-unlink:hover { color: var(--red); background: rgba(248,113,113,0.1); }}
.tx-linked-unlink .tx-link-off { display: none; }
@media (hover: hover) {.tx-linked-unlink:hover .tx-link-on { display: none; }}
@media (hover: hover) {.tx-linked-unlink:hover .tx-link-off { display: inline-block; }}

/* ── Collapsible card section (linked tx, funding history) ── */
.card-collapse { border: 0; padding: 0; margin: 0; }
.card-collapse > summary { list-style: none; cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 0; }
.card-collapse > summary::-webkit-details-marker { display: none; }
.card-collapse > summary .card-label { margin-bottom: 0; flex: 1; }
.card-collapse > summary .card-collapse-chevron { width: 12px; height: 12px; color: var(--muted); transition: transform 0.15s; flex-shrink: 0; }
.card-collapse[open] > summary .card-collapse-chevron { transform: rotate(90deg); }
.card-collapse > .card-collapse-body { padding-top: 4px; }

/* ── Funding history (own modal) ── */
.fh-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 10px; }
.fh-row:last-child { border-bottom: none; }
.fh-row-tappable { cursor: pointer; }
@media (hover: hover) {.fh-row-tappable:hover { background: var(--surface); }}
.fh-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.fh-date { font-size: 14px; color: var(--text); }
.fh-trigger { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.fh-trigger.fh-trigger-auto { color: var(--green); }
.fh-amount { font-size: 14px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
.fh-row-chevron { width: 14px; height: 14px; color: var(--muted); flex-shrink: 0; }
.fh-empty { color: var(--muted); font-size: 13px; padding: 8px 0; }
/* Pocket History — reuses the .fh-* row above, adds the signed amount over the
   balance it left behind. Right-aligned so the amounts form one column. */
/* .fh-info is the flexible side of the row, so a long merchant must not push
   the amount off the edge.
   ⚠️ The TITLE clips; the SUB-LINE WRAPS (owner, 2026-08-14: "I can't read the
   full details on some of them"). They earn different treatment because they
   carry different things: a title is a fixed phrase from a table and clipping
   one loses nothing, while the sub-line is the row's only variable content —
   the counterparty, the schedule, the reason — and "SAVINGS GOAL TRANSFER
   FRO…" answers nothing. Wrapping costs a second line on the rows that need
   one and leaves the rest alone.
   ⚠️ `overflow-wrap: break-word`, NOT `anywhere`: a long merchant should break
   between words, and `anywhere` drops the min-content contribution so the flex
   row could squeeze .fh-info to nothing. Same distinction the group-header
   note in CLAUDE.md documents. */
.ph-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ph-sub { white-space: normal; overflow-wrap: break-word; }
.ph-amounts { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; flex-shrink: 0; }
.ph-in  { color: var(--green); }
.ph-out { color: var(--red); }
.ph-balance { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
/* A Need change moves no money, so its figure is the new goal — neutral, not
   green/red, or it reads as a deposit. */
.ph-goal { color: var(--text); }
.ph-over { color: var(--yellow); text-transform: none; letter-spacing: 0; }

/* Funding-run detail view */
.fh-detail-head { padding: 4px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
.fh-detail-when { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.fh-detail-total { font-size: 20px; font-weight: 700; color: var(--text); margin-top: 4px; font-variant-numeric: tabular-nums; }
.fh-pocket-row { display: flex; justify-content: space-between; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.fh-pocket-row:last-child { border-bottom: none; }
.fh-pocket-name { font-size: 14px; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fh-pocket-amt { font-size: 14px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; flex-shrink: 0; }

/* ── Account groups ── */
.account-group-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 12px; }
.account-group-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 10px; }
.account-group-list { display: flex; flex-direction: column; }
.account-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 10px; }
.account-row:last-child { border-bottom: none; }
.account-row-disabled .account-name { color: var(--muted); }
.account-row-disabled .account-debt { color: var(--muted); }
.account-row-info { flex: 1; min-width: 0; }
.account-row-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.account-balance-wrap { text-align: right; }
/* Enabled rows tap through to the account's filtered Transactions view. */
.account-row:not(.account-row-disabled) .account-row-info,
.account-row:not(.account-row-disabled) .account-balance-wrap { cursor: pointer; }
  .account-disabled-badge { font-size: 10px; font-weight: 600; color: var(--muted); border: 1px solid var(--muted); border-radius: 99px; padding: 2px 8px; margin-left: 6px; vertical-align: middle; }
  /* Manual reuses the Disabled badge's exact geometry — same pill, same slot
     after the account name — because the two are mutually exclusive and should
     occupy the row identically. Only the color differs, so a hand-kept account
     reads as a note rather than a warning. */
  .account-manual-badge { color: #cbd5e1; border-color: rgba(148,163,184,0.55); }
.connection-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--border); }
.connection-row:last-child { border-bottom: none; }
.connection-info { flex: 1; min-width: 0; }
.connection-name { font-size: 15px; font-weight: 500; }
.connection-count { font-size: 12px; color: var(--muted); margin-top: 2px; }
.menu-item { display: block; width: 100%; text-align: left; border-radius: 0 !important; padding: 12px 16px !important; font-size: 14px !important; font-weight: 500 !important; background: transparent !important; color: var(--text) !important; }
@media (hover: hover) {.menu-item:hover { background: var(--surface2) !important; }}
.menu-item.danger { color: var(--red) !important; }
/* Soft-gated menu item: dimmed text + a Premium badge on the right.
   Still clickable — the click handler routes to the upsell modal. */
.menu-item-gated { display: flex !important; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted) !important; }
@media (hover: hover) {.menu-item-gated:hover { color: var(--text) !important; }}
/* "?" info affordance on the Refresh Account row → opens the explainer. */
/* Refresh menu-item: label grows so the ? help affordance is pushed to the far
   right, well away from the tap zone that fires the (paid) refresh. */
.menu-item-refresh { display: flex !important; align-items: center; gap: 8px; }
.menu-item-refresh > [data-refresh-label] { flex: 1 1 auto; white-space: nowrap; }
.menu-item-refresh > [data-refresh-badge]:empty { display: none; }
/* Bigger, right-anchored tap target: a 22px visible circle with 7px of
   invisible padding all around (≈36px hit area). Vertical padding is offset by
   negative margin so the menu row doesn't grow taller. */
/* Standard inline "?" help icon. `.info-q` is the canonical class; use it
   anywhere a "?" explainer is needed and it will look identical everywhere.
   `.refresh-info-q` is the original alias, kept for the existing call sites. */
.refresh-info-q, .info-q { position: relative; display: inline-flex; align-items: center; justify-content: center; box-sizing: content-box; width: 22px; height: 22px; padding: 7px; margin: -7px 0; flex: 0 0 auto; color: var(--muted); font-size: 12px; line-height: 1; cursor: pointer; border-radius: 50%; }
.refresh-info-q::before, .info-q::before { content: ''; position: absolute; inset: 7px; border: 1px solid var(--muted); border-radius: 50%; }
@media (hover: hover) {.refresh-info-q:hover, .info-q:hover { color: var(--text); }}
@media (hover: hover) {.refresh-info-q:hover::before, .info-q:hover::before { border-color: var(--text); }}
/* "Don't show this again" row in the Refresh explainer modal. */
.refresh-info-checkline { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); cursor: pointer; margin: 0 0 16px; }
.refresh-info-checkline input { cursor: pointer; }
/* "Account disabled" visual — mirrors .menu-toggle-disabled so disabled
   action buttons match the disabled GTG/Fund-from-GTG toggles in the same
   menu. Colour-based dimming (not opacity) so a Premium badge on the row
   (Import Transactions / Refresh Account on a disabled account) stays legible
   instead of fading with the row. Distinct from .menu-item-gated, which keeps
   the button live for the premium upsell. */
.menu-item-disabled { color: var(--muted) !important; cursor: default; pointer-events: none; }
@media (hover: hover) {.menu-item-disabled:hover { background: transparent !important; }}
.premium-badge { font-size: 10px; font-weight: 600; letter-spacing: 0.4px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; background: var(--surface2); color: var(--muted); border: 1px solid var(--border); }
@media (hover: hover) {.menu-item-gated:hover .premium-badge { color: var(--text); }}

/* Premium-gated full-width button (e.g. "+ Funding Schedule" on the
   accounts tab). Grayed at rest, restores to full text colour on hover
   so it still reads as clickable — the click opens the upsell modal. */
.btn-secondary.premium-locked {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  color: var(--muted);
}
@media (hover: hover) {.btn-secondary.premium-locked:hover { color: var(--text); }}
@media (hover: hover) {.btn-secondary.premium-locked:hover .premium-badge { color: var(--text); }}

/* Premium upsell plan picker (monthly / yearly checkout) */
.premium-plans { display: flex; flex-direction: column; gap: 10px; }
.premium-plan {
  display: flex; flex-direction: column; gap: 2px; text-align: left; width: 100%;
  padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px;
  background: var(--surface2); color: var(--text); cursor: pointer;
  transition: border-color 0.15s, background-color 0.15s;
}
@media (hover: hover) {.premium-plan:hover { border-color: var(--accent); background: var(--surface); }}
.premium-plan-best { border-color: var(--accent); }
.premium-plan-head { display: flex; align-items: center; gap: 8px; }
.premium-plan-name { font-size: 14px; font-weight: 600; }
.premium-plan-badge {
  font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--accent); background: rgba(173, 161, 255, 0.14);
  border: 1px solid rgba(173, 161, 255, 0.35); border-radius: 999px; padding: 1px 7px;
}
.premium-plan-price { font-size: 18px; font-weight: 700; }
.premium-plan-per { font-size: 13px; font-weight: 500; color: var(--muted); }
.premium-plan-sub { font-size: 12px; color: var(--muted); }
.premium-plan.is-loading { opacity: 0.6; pointer-events: none; }

/* ── Premium modal ──
   Fullscreen on phones, but a CENTERED pop-up on desktop — NOT the
   fullscreen-mobile two-pane presentation (which slides the page into a left
   pane). Uses its own class so the desktop two-pane :has() rules never match it;
   the base .modal-overlay already centers it over a blocking backdrop. */
@media (max-width: 600px) {
  .modal-overlay.premium-overlay { padding: 0; align-items: stretch; justify-content: stretch; }
  .modal-overlay.premium-overlay > .modal-box {
    width: 100vw; height: 100dvh;
    max-width: none; max-height: none;
    border-radius: 0; border: none;
  }
}
@media (min-width: 601px) {
  /* Mirror the full-height presentation it had as a two-pane, just CENTERED:
     a tall ~520px card, not a short dialog. */
  .modal-overlay.premium-overlay > .modal-box {
    width: 520px; max-width: 520px;
    height: calc(100dvh - 32px); max-height: calc(100dvh - 32px);
  }
}
.premium-modal-box { display: flex; flex-direction: column; }
.premium-modal-body { flex: 1 1 auto; overflow-y: auto; }
.premium-tagline { font-size: 16px; line-height: 1.4; color: var(--text); margin: 0 0 18px; }
.premium-tagline strong { color: var(--accent); }
.premium-benefits { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
.premium-benefits li { display: flex; gap: 12px; align-items: flex-start; }
.premium-benefit-check {
  flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 13px; font-weight: 700; color: var(--accent);
  background: rgba(173, 161, 255, 0.14); border: 1px solid rgba(173, 161, 255, 0.35);
}
.premium-benefit-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.premium-benefit-text strong { font-size: 14px; font-weight: 600; color: var(--text); }
.premium-benefit-text span { font-size: 13px; line-height: 1.45; color: var(--muted); }

/* Pinned footer: cadence toggle + price + CTA */
.premium-modal-footer {
  flex: 0 0 auto; padding: 14px 16px calc(14px + env(safe-area-inset-bottom));
  border-top: 1px solid var(--border); background: var(--surface);
  display: flex; flex-direction: column; gap: 12px;
}
/* Segmented control (Monthly | Yearly) — side-by-side, not a switch. */
.premium-seg-group {
  display: flex; padding: 3px; gap: 3px; border-radius: 10px;
  background: var(--surface2); border: 1px solid var(--border);
}
/* Each button carries its OWN price so both cadences are visible at once;
   tapping only changes the selection, never starts checkout. Taller column
   layout: label · price · note. */
.premium-seg {
  flex: 1 1 0; display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding: 12px 6px; border-radius: 8px; border: none; background: transparent;
  color: var(--muted); cursor: pointer; text-align: center;
  transition: background-color 0.15s, color 0.15s;
}
.premium-seg.active { background: var(--accent); color: #17141f; }
.premium-seg-label { font-size: 13px; font-weight: 600; letter-spacing: 0.02em; }
.premium-seg-price { font-size: 22px; font-weight: 700; color: var(--text); line-height: 1.15; }
.premium-seg.active .premium-seg-price { color: #17141f; }
.premium-seg-price .premium-seg-per { font-size: 13px; font-weight: 500; color: var(--muted); }
.premium-seg.active .premium-seg-price .premium-seg-per { color: #17141f; opacity: 0.75; }
.premium-seg-note { font-size: 11px; font-weight: 500; min-height: 14px; opacity: 0.85; }

/* Taller Get Premium CTA — the gold button in the Premium modal footer. */
#premium-get-btn { padding: 15px; font-size: 16px; border-radius: 12px; }

/* Profile subscription-management card (premium users). Free users get the
   gold "Get Premium" button in this same slot instead. */
.manage-sub-card {
  background: var(--surface2); border: 1px solid var(--border); border-radius: 12px;
  padding: 14px 16px; display: flex; flex-direction: column; gap: 10px;
}
.manage-sub-loading { font-size: 13px; padding: 4px 0; }
.manage-sub-head { display: flex; align-items: center; justify-content: space-between; }
.manage-sub-title { font-size: 15px; font-weight: 700; color: var(--text); }
.manage-sub-row {
  display: flex; align-items: center; justify-content: space-between;
  font-size: 13px; color: var(--muted);
}
.manage-sub-row span:last-child { color: var(--text); font-weight: 500; }
.manage-sub-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.manage-sub-actions > button { width: 100%; }
.manage-sub-note { font-size: 12px; line-height: 1.45; margin-top: 4px; }

/* ── Premium-lapse downgrade screen ── */
.downgrade-lede { font-size: 15px; line-height: 1.5; color: var(--text); margin: 0 0 18px; }
.downgrade-lede strong { color: var(--accent); }
.downgrade-conn-list { display: flex; flex-direction: column; gap: 10px; }
.downgrade-conn {
  display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
  padding: 14px; border-radius: 12px; cursor: pointer;
  background: var(--surface2); border: 1px solid var(--border); color: var(--text);
  transition: border-color 0.15s, background-color 0.15s;
}
.downgrade-conn.selected { border-color: var(--accent); background: rgba(173, 161, 255, 0.10); }
.downgrade-radio {
  flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%;
  border: 2px solid var(--border); position: relative;
}
.downgrade-conn.selected .downgrade-radio { border-color: var(--accent); }
.downgrade-conn.selected .downgrade-radio::after {
  content: ''; position: absolute; inset: 3px; border-radius: 50%; background: var(--accent);
}
.downgrade-conn-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.downgrade-conn-name { font-size: 15px; font-weight: 600; }
.downgrade-conn-sub { font-size: 12px; color: var(--muted); }

/* A TRUE blocking full-screen overlay on EVERY viewport — not the fullscreen-
   mobile two-pane desktop presentation (which leaves the app visible + clickable
   in a left pane). The box fills the viewport at all widths, over everything,
   and the base .modal-overlay backdrop (position:fixed; inset:0) blocks any
   click behind it. Used for the premium-lapse screen, which must be resolved
   (Renew or pick a connection) before the app is usable. */
.modal-overlay.blocking-fullscreen { padding: 0; align-items: stretch; justify-content: stretch; }
.modal-overlay.blocking-fullscreen > .modal-box {
  width: 100vw; height: 100dvh;
  max-width: none; max-height: none;
  border-radius: 0; border: none;
}
/* Keep the reading column comfortable on wide desktops while the box fills (and
   blocks) the whole viewport. */
.modal-overlay.blocking-fullscreen .modal-header,
.modal-overlay.blocking-fullscreen .premium-modal-body,
.modal-overlay.blocking-fullscreen .premium-modal-footer {
  width: 100%; max-width: 560px; margin-left: auto; margin-right: auto;
}

/* ── Dev paid-API-call dashboard ────────────────────────────────────── */
/* Hero stat at the top of the panel — the headline number that drives
   premium pricing decisions: combined cost per active user per month. */
.paid-hero { background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 14px; text-align: center; margin-bottom: 14px; }
.paid-hero-value { font-size: 26px; font-weight: 700; color: var(--text); line-height: 1.1; }
.paid-hero-label { font-size: 12px; font-weight: 500; color: var(--muted); margin-top: 4px; }
.paid-hero-breakdown { font-size: 11px; color: var(--muted); margin-top: 6px; opacity: 0.8; }

/* Top stat tiles: one row, wraps on narrow screens. */
.paid-stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 8px; }
.paid-stat-row + .paid-stat-row { margin-bottom: 14px; }
.paid-stat-row-secondary .paid-stat-tile { background: transparent; border-style: dashed; }
.paid-stat-tile { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; min-width: 0; }
.paid-stat-value { font-size: 16px; font-weight: 600; color: var(--text); line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.paid-stat-label { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* Section headers between tile group and each list. */
.paid-section-header { font-size: 11px; font-weight: 600; letter-spacing: 0.6px; text-transform: uppercase; color: var(--muted); margin: 14px 0 6px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }

/* Generic row used by all three lists. */
.paid-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 0; border-bottom: 1px dashed transparent; }
.paid-row + .paid-row { border-top: 1px solid var(--border); }
.paid-row-stack { flex-direction: column; align-items: stretch; gap: 2px; }
.paid-row-compact { padding: 4px 0; }
.paid-row-primary { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.paid-row-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); flex-wrap: wrap; }
.paid-row-left { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
.paid-row-right { display: flex; align-items: baseline; gap: 8px; flex-shrink: 0; }

.paid-name { font-size: 13px; font-weight: 500; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paid-name-small { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.paid-endpoint { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.paid-endpoint-small { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paid-cost { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; }
.paid-cost-small { font-size: 12px; }
.paid-meta { font-size: 11px; color: var(--muted); white-space: nowrap; }
.paid-recent-when { font-size: 12px; font-weight: 500; color: var(--text); }

/* Provider pill — distinguishes Plaid vs Teller cost rows at a glance. */
.paid-provider-pill { font-size: 9px; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; padding: 1px 6px; border-radius: 4px; line-height: 1.4; }
.paid-provider-plaid { background: rgba(0, 122, 255, 0.12); color: rgb(0, 122, 255); border: 1px solid rgba(0, 122, 255, 0.25); }
.paid-provider-teller { background: rgba(255, 102, 51, 0.12); color: rgb(232, 90, 40); border: 1px solid rgba(255, 102, 51, 0.25); }

.paid-empty { font-size: 12px; color: var(--muted); font-style: italic; padding: 10px 0; text-align: center; opacity: 0.7; }

/* ── Multi-select filter (wrapping the custom dropdown) ── */
.multi-select-wrap { position: relative; flex: 1; }
.multi-select-btn { width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; padding: 10px 36px 10px 14px; font-size: 13px; color: var(--text); cursor: pointer; text-align: left; font-family: inherit; font-weight: 500; transition: border-color 0.2s; display: flex; align-items: center; justify-content: space-between; }
@media (hover: hover) {.multi-select-btn:hover { border-color: var(--accent); }}
.multi-select-btn svg { color: var(--muted); flex-shrink: 0; }
.multi-select-menu { display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; z-index: 50; overflow: hidden; box-shadow: 0 8px 24px rgba(0,0,0,0.4); min-width: 180px; }

/* ── Modals (new class-based system) ── */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 300; align-items: center; justify-content: center; padding: 16px; }
.modal-overlay.open { display: flex; }
/* Standalone confirm overlay (e.g. discard-changes) — same look as a modal but
   intentionally NOT a .modal-overlay, so it sits outside the modal-stack /
   history machinery. High z-index so it floats over any open screen, including
   the desktop two-pane right pane. */
.confirm-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 2000; align-items: center; justify-content: center; padding: 16px; }
.confirm-overlay.open { display: flex; }
/* Lock body scroll while any modal is open. Stops the "weird scrolling
   page underneath a fullscreen modal" feel without converting modals
   to real routes. :has() is supported in every browser shipped from
   late 2023 onward, which matches our PWA install base. */
html:has(.modal-overlay.open),
body:has(.modal-overlay.open) {
  overflow: hidden;
  /* Pin the page in place so the scroll position doesn't reset to 0
     when the lock engages and restore exactly where the user was when
     the modal closes. position:fixed approaches do that but also
     require recording/restoring scrollY in JS — overflow:hidden alone
     keeps the scrollbar position intact in modern browsers. */
}
.modal-box { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); width: 100%; max-width: 480px; max-height: 90vh; max-height: 90dvh; overflow-y: auto; display: flex; flex-direction: column; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 20px 0; }
.modal-header h2 { font-size: 18px; }
.modal-header-actions { display: flex; align-items: center; gap: 8px; }
.modal-body { padding: 16px 20px 24px; display: flex; flex-direction: column; gap: 10px; }
/* ⚠️ A SCROLLING MODAL BODY SQUISHES ITS CONTENT UNLESS THIS IS HERE (owner,
   2026-08-14, on Chart options with the dev groups showing). .modal-body is a
   flex COLUMN, so its children default to flex-shrink: 1 — on a short screen
   they absorb the shortfall and compress instead of the body overflowing, and
   `overflow-y: auto` never engages because there is nothing to overflow. It is
   not subtle: measured on Chart options, the option cards went 126 → 63 → 37px
   as the viewport went 900 → 560 → 420, with the body reporting scrollHeight
   exactly equal to its own height the whole way down.
   Eight modal bodies are scroll containers, so this is deliberately GENERAL
   rather than eight copies — a new scrolling modal gets the right behaviour
   without knowing this rule exists.
   ⚠️ It must stay ABOVE .modal-scroll-region below: that element is SUPPOSED
   to shrink (it is the inner scroller in the .modal-scroll-contained shape),
   and it wins only because its equal-specificity rule comes later. */
.modal-body > * { flex-shrink: 0; }
/* ⚠️ SCROLL ROOM FOR THE FIELD UNDER THE KEYBOARD (owner-reported 2026-08-21).
   `_initKeyboardViewportPin` holds the box at its PRE-keyboard height, so the
   box extends below what you can see and a field near the bottom has nowhere to
   scroll TO — the reveal in that function has nothing to move. `--app-kb` is the
   height the keyboard took, written ONLY while the pin is engaged, so this is
   `0px` and inert everywhere else, including every desktop and any browser that
   honours interactive-widget=resizes-visual.
   ⚠️ A margin on the last flex child, NOT padding on the body: the bodies carry
   their own padding and restating it here would silently change the ones that
   differ from the default. Nothing in these forms carries a bottom margin (the
   10px gap is the rhythm), so there is nothing for this to collide with. */
.modal-body > *:last-child,
.wiz-body > *:last-child { margin-bottom: var(--app-kb, 0px); }
/* ⚠️ THE MARGIN IS THE RANGE; THIS IS THE AIM, and neither works alone.
   `scroll-padding-bottom` tells the browser's own scroll-into-view to leave
   this much clearance below the field — i.e. to land it above the keyboard
   rather than flush with the scroller's bottom edge, which is behind it. It
   creates no scrollable area of its own, so without the margin above there is
   nothing to scroll into for a field near the end; and without this the margin
   is just blank space the browser has no reason to use. Together they are what
   make the browser SCROLL rather than PAN, which is the whole difference
   between the content moving and the entire viewport moving. */
.modal-body, .wiz-body { scroll-padding-bottom: var(--app-kb, 0px); }
/* Recommended Pockets: body scrolls, the onboarding "Finish setup" footer pins
   to the bottom (the list scrolls behind it). Scoped — no other modal changes. */
#pocket-suggestions-overlay .modal-box { overflow: hidden; }
#pocket-suggestions-overlay .modal-body { flex: 1; min-height: 0; overflow-y: auto; }
#ps-onboarding-finish {
  flex-shrink: 0; background: var(--surface); border-top: 1px solid var(--border);
  padding: 14px 20px calc(14px + var(--safe-bottom, 0px));
}
#ps-onboarding-finish button { width: 100%; }

/* Opt-in layout for modals that want a fixed header & footer with an internal
   scroll region in the middle. The modal-box caps at 90vh (base rule), its
   body fills remaining vertical space, and any descendant marked
   .modal-scroll-region grows to fill leftover space and scrolls internally.
   Intro copy above and action buttons below stay on screen as the list grows. */
.modal-box.modal-scroll-contained { overflow: hidden; }
.modal-box.modal-scroll-contained .modal-body { flex: 1 1 auto; min-height: 0; }
.modal-scroll-region { flex: 1 1 auto; min-height: 0; overflow-y: auto; }

/* Catch Up + Fund Now: the summary/result box is its own scroll region —
   content-height when short, capped + internally scrollable when the
   preview/result list is long. flex: 0 1 auto means it never grows past
   its content but will shrink (and scroll) to make room. The action
   buttons pin to the bottom of the viewport (full-page modal on mobile)
   via margin-top:auto. The modal body itself does NOT scroll — only the
   box does, so the buttons stay put. */
#catch-up-modal-overlay #catch-up-modal-summary-wrap,
#fund-confirm-modal-overlay #fund-summary-wrap {
  flex: 0 1 auto; min-height: 0; overflow-y: auto;
}
#catch-up-modal-overlay #catch-up-btn-row,
#fund-confirm-modal-overlay #fund-actions { margin-top: auto; }

/* Mobile: page-like / form modals tagged .fullscreen-mobile fill the viewport
   instead of floating as a 480px card with a dimmed border. Confirmation,
   info, and picker modals stay as floating dialogs even on mobile.
   !important is needed on width/max-width because two overlays in this group
   (fund-confirm, catch-up) carry an inline style="max-width:400px" that
   would otherwise out-rank the class selector. */
@media (max-width: 600px) {
  .modal-overlay.fullscreen-mobile { padding: 0; align-items: stretch; justify-content: stretch; }
  .modal-overlay.fullscreen-mobile > .modal-box {
    /* ⚠️ `--app-vh` is the pre-keyboard viewport height, stashed by
       _initKeyboardViewportPin. It falls back to 100dvh, which is the correct
       value everywhere the browser honours `interactive-widget=resizes-visual`
       — the viewport meta's declaration. iOS Safari IGNORES that declaration
       and shrinks the LAYOUT viewport when the keyboard opens, which shortens
       100dvh and drags the pinned Done bar up with the keyboard (owner,
       2026-08-14, on his phone — the third time this has bitten). Holding the
       height at its pre-keyboard value puts the bar back behind the keyboard,
       which is where a pinned bar belongs. */
    width: 100vw !important; height: var(--app-vh, 100dvh);
    max-width: none !important; max-height: none;
    border-radius: 0; border: none;
  }
}

/* In-between widths (601–1099px): too narrow for the desktop two-pane layout,
   but a full-page (.fullscreen-mobile) screen must NOT collapse to a short
   content-height card the way the base .modal-box rules would leave it. Keep it
   centered over the content (backdrop around it — the user is fine with that),
   but give it full-page sizing: fill the viewport height and widen it. !important
   beats the picker's ID height clamp (#tx-pocket-picker-overlay { height:min(80vh,720px) })
   and the inline max-width some confirm modals carry. Non-fullscreen dialogs
   (plain confirms, dropdowns) are untouched — they stay small centered cards. */
@media (min-width: 601px) and (max-width: 1099px) {
  .modal-overlay.fullscreen-mobile > .modal-box {
    width: 100% !important;
    /* Match the >=1100px two-pane pane width (--pane-w: 520px) so the box keeps
       the same slim page width across both bands instead of ballooning wide. */
    max-width: 520px !important;
    height: calc(100dvh - 32px) !important;
    max-height: calc(100dvh - 32px) !important;
  }
}

/* ── Modals-as-pages ──
   A modal opened over another (a pushed sub-page) slides in from the right and
   shows a leading back arrow instead of the dismiss ✕ — see _markModalOpened /
   _setModalPagePresentation in src/util.js. No animation fill-mode is used so
   the box is left with NO residual transform once the slide ends (a lingering
   transform would turn position:fixed descendants — combobox/date-picker
   dropdowns — into box-relative, mispositioning them). */
.modal-overlay.modal-page > .modal-box {
  animation: modal-page-in 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);
}
@keyframes modal-page-in {
  from { transform: translateX(100%); }
  to   { transform: translateX(0); }
}
.modal-page-back { margin-right: 4px; }
/* The header is justify-content: space-between; with the ✕ hidden, give the
   title (the element right after the injected back arrow) margin-right:auto so
   back + title group at the left — and any trailing header actions still sit
   right. Scoped to .modal-page-back so self-managed headers (Funding History)
   are untouched. */
.modal-overlay.modal-page .modal-header > .modal-page-back + * { margin-right: auto; }

/* ── Desktop two-pane: full-page screens open BESIDE the tab, not over it ──
   On a wide desktop a .fullscreen-mobile modal (a "full page screen") is
   presented as a RIGHT PANE: the active tab content slides into a left pane
   and the screen slides in beside it, both visible within the persistent
   fixed header + bottom-nav chrome. Non-fullscreen modals (confirmations,
   pickers, info dialogs) are untouched — they stay centered overlays. Below
   1100px there isn't room for two columns, so full-page modals keep their
   normal centered-card desktop presentation. Driven entirely by :has() — the
   JS modal stack / history / slide animations are unchanged. */
@media (min-width: 1100px) {
  :root { --pane-w: 520px; --pane-gap: 48px; }

  /* The base rule locks BOTH html and body scroll while any modal is open. In
     two-pane the left pane IS the page, so re-allow document scroll on desktop —
     the right pane rides a position:fixed overlay and stays put — so the user
     can scroll the left pane to a different pocket/transaction to open. Mobile
     keeps the lock (below this breakpoint) so you can't scroll a page you can't
     see under a fullscreen modal. (More specific :has() arg + later source
     order, so this wins over the base lock.) */
  html:has(.modal-overlay.fullscreen-mobile.open),
  body:has(.modal-overlay.fullscreen-mobile.open) { overflow: auto; }

  /* Left pane: the active tab content shifts left by half a (pane + gap) so the
     two columns sit centered around the viewport midline. The transition lives
     on the base selector so it animates both in (a screen opens) and out (it
     closes). The swipe pager is touch-only, so it never fights this on desktop;
     it also writes inline transforms that override this rule while a drag is
     live, then clears them — so the two states never overlap. */
  #app-gtg-wrapper,
  .page.active {
    transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
  }
  body:has(.modal-overlay.fullscreen-mobile.open) #app-gtg-wrapper,
  body:has(.modal-overlay.fullscreen-mobile.open) .page.active {
    transform: translateX(calc(-1 * (var(--pane-w) + var(--pane-gap)) / 2));
  }

  /* Right pane: the full-page overlay no longer dims or captures clicks (the
     left pane stays live and the backdrop can't close it — use the X / back).
     It spans the gap between the fixed header and bottom nav, and parks its box
     in the right column. The box keeps its modal-page slide animation, which
     now reads as sliding in from the right edge. */
  .modal-overlay.fullscreen-mobile.open {
    /* Third site that clears the header by the same constant — the desktop
       right pane starts below it, so it moves with the banner too. */
    top: calc(84px + var(--offline-h));
    bottom: var(--nav-h);
    left: 0;
    right: 0;
    background: transparent;
    pointer-events: none;
    align-items: stretch;
    justify-content: center;
    padding: 16px 0 16px calc(var(--pane-w) + var(--pane-gap));
  }
  .modal-overlay.fullscreen-mobile.open > .modal-box {
    pointer-events: auto;
    width: var(--pane-w) !important;
    max-width: var(--pane-w) !important;
    max-height: 100%;
    box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
  }

  /* ONBOARDING EXCEPTION — modals center instead of parking in the right pane.
     The two-pane split above only reads correctly because the app content
     shifts LEFT to meet it (the transform on #app-gtg-wrapper / .page.active).
     The setup wizard is neither of those elements, so during onboarding nothing
     shifts: the wizard stays centered and the modal slides off to one side,
     looking detached rather than paired.
     Keyed on #welcome-overlay.wiz-mode, which is present exactly while the
     wizard (or the replayable Tutorial) is open and removed by _wizFinish. */
  body:has(#welcome-overlay.wiz-mode) .modal-overlay.fullscreen-mobile.open {
    top: 0; bottom: 0; left: 0; right: 0;
    background: rgba(0, 0, 0, 0.6);   /* restore the dim the two-pane drops */
    pointer-events: auto;             /* and the click-to-dismiss backdrop */
    align-items: center;
    justify-content: center;
    padding: 16px;
  }
  body:has(#welcome-overlay.wiz-mode) .modal-overlay.fullscreen-mobile.open > .modal-box {
    width: 100% !important;
    max-width: 560px !important;
    max-height: calc(100vh - 32px);
  }
  /* Don't shift the app content underneath either — it's hidden behind the
     wizard, so the transform buys nothing and would otherwise be mid-animation
     when the wizard finishes and reveals the app. */
  body:has(#welcome-overlay.wiz-mode) #app-gtg-wrapper,
  body:has(#welcome-overlay.wiz-mode) .page.active {
    transform: none;
  }

  /* ⚠️ FULL-SCREEN CHART EXCEPTION — the same problem as onboarding, and the
     same fix (owner, 2026-08-15: "the full screen slide still happens on the
     full screen insights and then it snaps back").
     The two-pane split only reads because the app content shifts LEFT to meet
     the pane. `.gtg-spend-hero-wrap.is-fullscreen` is `position: fixed` over
     everything and is neither `#app-gtg-wrapper` nor `.page.active`, so it
     doesn't shift — Chart options slid into a right pane beside a chart that
     stayed put, while the page underneath (invisible behind the chart) did the
     shifting, and un-shifted on close. That un-shift IS the snap-back.
     Keyed on `body.mg-fullscreen`, written by toggleMoneyGoFullscreen. */
  body.mg-fullscreen .modal-overlay.fullscreen-mobile.open {
    top: 0; bottom: 0; left: 0; right: 0;
    background: rgba(0, 0, 0, 0.6);   /* restore the dim the two-pane drops */
    pointer-events: auto;             /* and the click-to-dismiss backdrop */
    align-items: center;
    justify-content: center;
    padding: 16px;
  }
  body.mg-fullscreen .modal-overlay.fullscreen-mobile.open > .modal-box {
    width: 100% !important;
    max-width: 560px !important;
    max-height: calc(100vh - 32px);
  }
  /* Nothing underneath should move: it is hidden behind the chart, so the
     transform buys nothing and would be mid-animation when full screen exits. */
  body.mg-fullscreen #app-gtg-wrapper,
  body.mg-fullscreen .page.active {
    transform: none;
  }
}

/* ── .fullscreen-desktop — opt a full-page screen OUT of the desktop card/pane ──
   A .fullscreen-mobile modal is a slim page everywhere above 600px: a 520px
   centered card in the 601–1099 band, then the 520px right pane above that.
   That is right for a form, and wrong for a screen whose content is WIDE —
   the Activity Timeline is a swimlane grid with a 560px minimum, so at 520px
   it side-scrolled inside a narrow column with the whole desktop empty around
   it. Adding .fullscreen-desktop alongside .fullscreen-mobile makes it fill
   the viewport at EVERY width, i.e. the mobile presentation all the way up.
   Opt-in per modal, because most full-page screens are forms that read better
   in a column. Selectors carry .open so they out-specify the two-pane rules
   above by class count rather than by source order (a tie there would be
   decided by whichever block moved last); width/max-width need !important to
   beat both the two-pane !important and any inline max-width on the box. */
@media (min-width: 601px) {
  .modal-overlay.fullscreen-mobile.fullscreen-desktop.open {
    top: 0; bottom: 0; left: 0; right: 0;
    padding: 0;
    background: rgba(0, 0, 0, 0.6);
    pointer-events: auto;
    align-items: stretch;
    justify-content: stretch;
  }
  .modal-overlay.fullscreen-mobile.fullscreen-desktop.open > .modal-box {
    width: 100vw !important;
    max-width: none !important;
    height: 100dvh;
    max-height: none !important;
    border-radius: 0;
    border: none;
    box-shadow: none;
  }
  /* The page underneath is fully covered, so the two-pane left shift buys
     nothing and would be mid-animation on close — same reasoning as the
     onboarding and full-screen-chart exceptions above. */
  body:has(.modal-overlay.fullscreen-mobile.fullscreen-desktop.open) #app-gtg-wrapper,
  body:has(.modal-overlay.fullscreen-mobile.fullscreen-desktop.open) .page.active {
    transform: none;
  }
  /* ...and re-lock document scroll, which the two-pane block re-allows so its
     left pane stays scrollable. There is no left pane here to scroll. */
  html:has(.modal-overlay.fullscreen-mobile.fullscreen-desktop.open),
  body:has(.modal-overlay.fullscreen-mobile.fullscreen-desktop.open) { overflow: hidden; }
}

/* ── Danger button ── */
.btn-danger { background: #dc2626; border: 1px solid #dc2626; color: #fff; font-size: 14px; padding: 10px 16px; width: 100%; }
@media (hover: hover) {.btn-danger:hover:not(:disabled) { background: #b91c1c; }}

/* User Profile: the modal box is full-height (desktop right-pane and mobile
   fullscreen), so fill the body to that height and let Delete Account's
   margin-top:auto push it to the very bottom, away from Change Password. On the
   mid-width centered-card presentation the body is content-height, so the auto
   margin simply collapses and the button sits under Change Password — no harm. */
#user-delete-account-btn { margin-top: auto; }

/* ── Custom dialogs (showConfirm / showAlert / showPrompt) ── */
.app-dialog-overlay {
  position: fixed; inset: 0; z-index: 10000;
  background: rgba(0,0,0,0.6);
  display: flex; align-items: center; justify-content: center; padding: 20px;
}
/* ⚠️ A flex COLUMN capped at the overlay's height, so the MESSAGE scrolls and
   the title and buttons stay put. It was a plain block with no cap: a dialog
   taller than the screen overflowed a centred flex item, which puts its top
   ABOVE the viewport where it cannot be scrolled to — the same trap
   `.wiz-body` documents. Harmless while every dialog was a sentence; the
   multi-block explainers can genuinely be that tall on a short phone. */
.app-dialog {
  background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
  padding: 22px; width: 100%; max-width: 360px; box-shadow: 0 16px 50px rgba(0,0,0,0.5);
  display: flex; flex-direction: column; max-height: 100%;
}
.app-dialog-title { font-size: 17px; font-weight: 700; color: var(--text); margin-bottom: 8px; flex: 0 0 auto; }
/* `0 1 auto`: takes its content height while it fits, then caps and scrolls
   internally. `min-height: 0` is what lets it shrink below that content at all. */
.app-dialog-msg {
  font-size: 14.5px; color: var(--text); line-height: 1.5; white-space: pre-wrap;
  flex: 0 1 auto; min-height: 0; overflow-y: auto;
}
.app-dialog-input, .app-dialog-actions { flex: 0 0 auto; }
/* ⚠️ A RICH dialog is wider, because it is not a sentence — it is several
   labelled blocks being compared, and at 360px that reads as a narrow column
   scrolling for ever (owner, 2026-08-15). A plain confirm stays narrow: one
   sentence set 480px wide is harder to read, not easier. `:has()` keys it on
   the content rather than asking every caller to pass a size. */
.app-dialog:has(.app-dialog-msg-rich) { max-width: 480px; }
/* Rich (messageHtml) dialogs lay out real elements, so the pre-wrap that keeps
   plain-text dialogs' newlines would turn the template's own indentation into
   visible whitespace. */
.app-dialog-msg-rich { white-space: normal; }
.app-dialog-msg-rich p { margin: 0 0 12px; }
.app-dialog-msg-rich p:last-child { margin-bottom: 0; }
.app-dialog-msg-rich strong { color: var(--text); font-weight: 700; }
/* A dialog explaining a CHOICE gets one block per option rather than prose —
   the reader is comparing two things, and paragraphs make them hunt for which
   sentence belongs to which (owner, 2026-08-15, on Funding Method). The
   heading names the option EXACTLY as its button does, so the block and the
   control it describes are matched by their words. */
.app-dialog-msg-rich h4 {
  font-size: 14px; font-weight: 700; color: var(--text); margin: 16px 0 6px;
}
.app-dialog-msg-rich h4:first-child { margin-top: 0; }
.app-dialog-msg-rich ul { margin: 0 0 12px; padding-left: 18px; }
.app-dialog-msg-rich ul:last-child { margin-bottom: 0; }
.app-dialog-msg-rich li { margin: 0 0 5px; }
.app-dialog-msg-rich li:last-child { margin-bottom: 0; }
/* Warning callout inside a dialog. Same yellow as the app's inline warnings so
   a caution reads identically wherever it appears. */
.dlg-warn {
  font-size: 13px; line-height: 1.45; margin: 0 0 12px; padding: 10px 12px;
  background: rgba(250,204,21,0.10); border: 1px solid rgba(250,204,21,0.38);
  border-radius: 8px; color: #f3e4a6;
}
/* Same SHAPE as the warning, deliberately NOT its colour (owner, 2026-08-15).
   A "heads up" is an aside about a case that doesn't apply to you yet — not a
   caution about what you are doing — and yellow would give it the weight of the
   confirm-a-credit warning. Neutral surface + border, so it reads as set apart
   rather than as flagged. Sits LAST in a dialog, which is why it has no bottom
   margin of its own. */
.dlg-note {
  font-size: 13px; line-height: 1.45; margin: 16px 0 0; padding: 10px 12px;
  background: var(--surface2); border: 1px solid var(--border);
  border-radius: 8px; color: var(--muted);
}
.dlg-note strong { color: var(--text); font-weight: 600; }
.app-dialog-input {
  width: 100%; margin-top: 14px; padding: 11px 12px; border-radius: 10px;
  background: var(--surface2); border: 1px solid var(--border); color: var(--text); font-size: 15px;
}
.app-dialog-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); outline: none; }
/* A question with more than two answers. Full-width, stacked, all secondary —
   these are ALTERNATIVES, so giving one the primary treatment would recommend
   it, and the whole reason the dialog exists is that no answer is safe to pick
   for the user. The sub-line says what the answer DOES; it is the part read.
   ⚠️ ITS OWN BLOCK, appended after a complete rule. The first version of this
   was spliced INTO `.app-dialog-input, .app-dialog-actions { flex: 0 0 auto; }`
   — between the two selectors — so the stack silently took the input's 4px top
   margin instead of the actions row's 20px and sat against the message (owner
   reported it as "the text and buttons touch"), while `.app-dialog-input` lost
   its `flex: 0 0 auto`. A closing brace is not a safe insertion point when the
   next line is a selector list. */
.app-dialog-choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
.app-dialog-choice {
  display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
  width: 100%; text-align: left; padding: 11px 14px; white-space: normal;
}
.app-dialog-choice-label { font-size: 14px; font-weight: 600; color: var(--text); }
/* Wraps — it carries amounts and a Pocket name, which is user text of unknown
   length, and the row above it is the thing that must stay scannable. */
.app-dialog-choice-sub { font-size: 12px; font-weight: 400; color: var(--muted); line-height: 1.35; }
.app-dialog-actions { display: flex; gap: 10px; margin-top: 20px; }
.app-dialog-btn { flex: 1; padding: 11px; border-radius: 10px; font-size: 14px; font-weight: 600; width: auto; }

/* ── Pocket modal action row ── */
/* ⚠️ NOT .modal-scroll-contained — that class means "an inner
   .modal-scroll-region scrolls while the body doesn't", which is the opposite
   shape. Here the BODY is the scroller, so this mirrors the rule
   #pocket-suggestions-overlay already carries for the same layout. */
#pocket-modal-overlay .modal-box { overflow: hidden; }
#pocket-modal-overlay > .modal-box > .modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* The Savings Goal source picker uses the same pinned-bar shape: the list
   scrolls and clips at Done rather than passing behind it. Both halves are
   needed — the box must stop scrolling before the body can start. */
#goal-sources-overlay .modal-box { overflow: hidden; }
#goal-sources-overlay > .modal-box > .modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* The pinned bar the action row now lives in. Padding matches .modal-body's
   sides and bottom so the buttons keep the same margins they had when they
   were the last thing in the form. No background and no fade: the body clips
   above it, so there is never anything behind these to hide. */
.pm-actions-bar { flex: 0 0 auto; padding: 0 20px 24px; display: flex; flex-direction: column; gap: 10px; }
.pm-actions-bar .alert { margin: 0; }
.pocket-modal-actions { display: flex; gap: 8px; flex-direction: row-reverse; }
.pocket-modal-actions .btn-primary { flex: 1; }
/* Pause = gray (pocket is active, action is to pause it)
   Resume = red  (pocket is paused, action is to resume it) */
.btn-pause { flex: 0 0 35%; font-size: 14px; padding: 10px 12px; border-radius: 12px; cursor: pointer; font-weight: 600; transition: background 0.15s, border-color 0.15s, color 0.15s; text-align: center; }
.btn-pause.state-pause  { background: transparent; border: 1px solid var(--muted); color: var(--muted); }
@media (hover: hover) {.btn-pause.state-pause:hover  { border-color: var(--text); color: var(--text); }}
.btn-pause.state-resume { background: transparent; border: 1px solid #f87171; color: #f87171; }
@media (hover: hover) {.btn-pause.state-resume:hover { background: rgba(248,113,113,0.12); }}

/* ── Category combobox ── */
/* .combo-wrap handles outer container; cat-combo-wrap kept for JS selector compat */
/* max-height/overflow deliberately live on the later .cat-combo-dropdown rule
   (search "Scrollable fixed dropdowns") — one place, not two competing ones. */
.cat-combo-dropdown { position: fixed; background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; z-index: 600; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }
.cat-option { display: flex; align-items: center; gap: 10px; padding: 14px 14px; font-size: 14px; cursor: pointer; color: var(--text); border-bottom: 1px solid rgba(255,255,255,0.04); }
.cat-option:last-child { border-bottom: none; }
/* Same rule as .cs-opt: keyboard focus is not hover, so it is unconditional. */
.cat-option.focused { background: var(--surface); }
@media (hover: hover) {.cat-option:hover { background: var(--surface); }}
.cat-option-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Pocket count for a category, sitting just left of the edit pencil. Muted and
   unboxed — it's a reference number you scan down a long list, not a
   notification, so it must not compete with the category name. Tabular figures
   keep the column aligned. */
.cat-option-count {
  font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums;
  flex-shrink: 0; min-width: 14px; text-align: right;
}
.cat-option-edit {
  background: none; border: none; cursor: pointer; padding: 6px;
  border-radius: 6px; color: var(--muted);
  display: inline-flex; align-items: center; justify-content: center;
  flex-shrink: 0;
}
@media (hover: hover) {.cat-option:hover .cat-option-edit { color: var(--text); }}
@media (hover: hover) {.cat-option-edit:hover { background: var(--surface); color: var(--accent); }}
.cat-option-add { color: var(--accent); font-weight: 600; }
.cat-option-add .cat-add-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--accent); color: #fff;
  font-weight: 700; font-size: 16px; line-height: 1; flex-shrink: 0;
}
.cat-option-empty { padding: 14px; color: var(--muted); font-size: 13px; }
/* The pocket-modal category input is read-only on purpose so mobile keyboards
   don't pop on tap — but it should still LOOK editable. */
/* Category trigger — sits inside .combo-wrap in place of an <input>. Styled to
 * mirror the input's dimensions/border so the toggle button on the right keeps
 * the same shape. <button> instead of <input> means no text caret, no
 * tap-to-select highlight, and no Android copy/paste popup. */
.cat-trigger {
  flex: 1; min-width: 0;
  display: flex; align-items: center;
  background: var(--surface2); color: var(--text);
  border: 1px solid var(--border); border-right: none;
  border-radius: 12px 0 0 12px;
  padding: 0 12px; height: 44px;
  font-size: 14px; font-family: inherit; text-align: left;
  cursor: pointer;
  -webkit-user-select: none; user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.cat-trigger:focus { outline: none; }
.cat-trigger:focus-visible { border-color: var(--accent); }
.cat-trigger-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cat-trigger-text.placeholder { color: var(--muted); }
.cat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
.pocket-group-header .cat-dot { width: 12px; height: 12px; }

/* ── Manage Categories rows (flat list, drag-reorder) ── */
/* Full-bleed the list past the modal-body's 20px side padding so rows span the
   full width; rows re-pad to 20px so their content still lines up with the
   Add button above. */
#manage-categories-list, #manage-categories-hidden-list { margin-left: -20px; margin-right: -20px; }
.mc-row { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.06); background: var(--surface); transition: transform 0.18s cubic-bezier(0.2,0.8,0.2,1); }
.mc-row .cat-dot { width: 12px; height: 12px; }
/* The lifted row follows the finger with NO transition so it can't lag behind. */
.mc-row.reorder-dragging { transition: none; position: relative; z-index: 5; border-radius: 10px; border-bottom-color: transparent; background: var(--surface2, var(--surface)); box-shadow: 0 8px 24px rgba(0,0,0,0.4); }
/* touch-action:pan-y so a swipe on the grip scrolls natively (real momentum);
   JS runs a hold timer on top — a still press picks the row up to drag (and
   blocks scroll via a non-passive touchmove for the rest of that touch), a
   press that drifts first stays a native momentum scroll. */
.mc-grip { flex: 0 0 auto; display: flex; align-items: center; color: var(--muted); cursor: grab; padding: 8px 8px 8px 2px; margin: -8px 0; touch-action: pan-y; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.mc-grip:active { cursor: grabbing; }
.mc-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
.mc-name.ma-name-wrap { display: flex; flex-direction: column; gap: 2px; white-space: normal; overflow: visible; }
.ma-name-wrap .ma-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ma-name-wrap .ma-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mc-row.mc-hidden-row .mc-name { color: var(--muted); }
.mc-edit { flex: 0 0 auto; }
body.list-reorder-dragging, body.list-reorder-dragging * { user-select: none; }
/* Held for the tail of a long press that opened a modal — see
   _suppressPressSelection. Inputs are exempt so the field the dialog lands on is
   still selectable the moment the finger lifts. */
body.suppress-press-select *:not(input):not(textarea) {
  -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
}
/* Manage Categories header button in the category edit modal — a compact
   labeled pill (matches the app's small secondary buttons), not full-width. */
.cat-edit-manage-chip { width: auto; padding: 7px 12px; font-size: 13px; white-space: nowrap; flex: 0 0 auto; }
.field-label-action { float: right; font-size: 11px; color: var(--accent); cursor: pointer; font-weight: 400; }

/* ── Category pill on pocket card ── */
.pocket-category { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; border-radius: 99px; padding: 2px 8px; border: 1px solid; flex-shrink: 0; margin-bottom: 5px; }

/* ── Pocket group header (group-by view) ──
   A collapsible block tinted in the group's own colour, using the SAME recipe
   as the category badge on a pocket card: the colour for the text, 10% behind
   it, 25% on the border. --gh-tint / --gh-line / --gh-name are set per header
   inline (see renderPockets). The tint keeps the surface dark, so --text and
   --muted read on it exactly as they do on a card.

   The row does NOT wrap. Caret and label sit in a single centred row against a
   right-hand column that stacks the two figures, so the caret and name are
   vertically centred on the whole block however tall it gets — with wrapping,
   the label pinned itself to the top row and looked stranded on a two-line
   header. Padding matches .pocket-card (14px sides), so the figures line up
   with the amounts on the cards underneath.

   Bottom margin is 10px to MATCH .pocket-card's margin-bottom, so the gap under
   a header equals the gap between two cards. It is the only thing contributing
   to that gap: .pocket-group-body has no margin or padding and the first
   .pocket-card has no margin-top. */
.pocket-group-header {
  display: flex; align-items: center; gap: 8px;
  font-size: 17px; font-weight: 700;
  margin: 20px 0 10px; padding: 11px 14px;
  background: var(--gh-tint, var(--surface));
  border: 1px solid var(--gh-line, var(--border)); border-radius: 12px;
  cursor: pointer; user-select: none;
  /* Uniform height across every grouping, so a list doesn't ripple as some
     groups carry two figures and others one or none. Sized a little above the
     two-figure header (64px measured) rather than exactly to it, so the
     tallest COMMON case still has breathing room. box-sizing is border-box
     globally, so this is the outer height. A long name that wraps grows past
     it — the only case that should, since the alternative is truncating. */
  min-height: 68px;
}
.pocket-group-header:first-child { margin-top: 0; }
/* An open overflow menu has to clear the header BELOW it.
   `.reorderable` (every category header) sets `will-change: transform` for the
   drag animation, and that makes each header its own STACKING CONTEXT — so the
   menu's `z-index: 50` is scoped INSIDE its own header and can't outrank a
   sibling. The next header, being later in DOM at the same level, painted
   straight through it: the menu looked transparent with "Average $x per
   paycheck" written across it. Worst with the group COLLAPSED, which is when
   the next header sits directly under the menu instead of the group's cards
   (those are unpositioned, so they never won). Raising the header that owns the
   open menu lifts the whole stacking context, menu included. */
.pocket-group-header:has(.bank-menu.open) { position: relative; z-index: 60; }
/* Wraps rather than truncating when a name is long: the header simply grows and
   stays centred, where an ellipsis would clip a name as ordinary as "Business
   Expenses" to make room for "Average $25.30 per paycheck". */
/* overflow-wrap is the backstop: min-width:0 lets this box shrink, but a single
   long word ("Memberships") can't wrap below its own width, so it used to spill
   out and sit ON TOP of the figures. This breaks it instead — a last resort the
   shrink-to-fit pass (_fitGroupHeader) should almost always prevent.
   `break-word`, NOT `anywhere`: anywhere also drops the box's min-content
   contribution, which re-splits the flex row and made the un-fitted layout much
   worse than before — a bad trade for a rule that only exists as a fallback. */
.pocket-group-label { flex: 1 1 auto; min-width: 0; overflow-wrap: break-word; color: var(--gh-name, var(--text)); }
/* "Uncategorized" and friends carry the brand purple like any other non-category
   grouping, just quieter, so a real category still wins the eye. */
.pocket-group-header.muted .pocket-group-label { opacity: 0.7; }

/* Right-hand column: average spend on top, next auto-fund beneath. Both are
   right-aligned so they read as one column of figures with the card amounts
   below. There is deliberately NO Pocketed / Need total — pockets in a group
   have different due dates and frequencies, so summing their Needs looks
   authoritative and answers nothing. */
.pocket-group-figures {
  margin-left: auto; flex-shrink: 0;
  display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
  /* Set here rather than on each child so the pair always matches; the children
     take `inherit`. NOTHING scales this — see .gh-stacked below for what
     happens when the row runs out of room. */
  font-size: 12px;
}
/* ── Crowded header: stack instead of shrink ────────────────────────────────
   When the name can't sit beside the figures without wrapping past two lines or
   splitting a word, the figures drop to their own line underneath and both keep
   full size. Applied to every header in the list at once (see _fitGroupHeaders)
   — one row here and two rows there reads as broken, and the whole point of
   this pass is a list that looks deliberate.

   `order` puts the figures last so the wrap lands between the ⋮ and them,
   giving row 1 = caret + name + ⋮ and row 2 = the figures. The indent lines
   them up under the NAME rather than the caret: 18px of caret + the 8px flex
   gap. */
/* ⚠️ GRID, not flex-wrap, so the caret and the ⋮ can span BOTH rows and sit
   vertically centred on the header as a whole (owner, 2026-08-12). Wrapped as
   flex they stayed on row 1 with the name, which left them riding high above a
   two-row header. Grid areas also make the figures' indent a column rather
   than a hand-counted padding. */
.pocket-group-header.gh-stacked {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  grid-template-areas: "caret label menu" "caret figures menu";
  align-items: center;
  column-gap: 8px;
  row-gap: 2px;
}
.gh-stacked > .pocket-group-toggle { grid-area: caret; align-self: center; }
.gh-stacked > .pocket-group-label  { grid-area: label; }
.gh-stacked > .pocket-group-figures { grid-area: figures; }
.gh-stacked > .bank-menu-wrap,
.gh-stacked > .pocket-group-menu   { grid-area: menu; align-self: center; }
/* `flex-basis: 0` so the name claims only the space left over on row 1 rather
   than its full content width. With `auto` a long name filled the line and the
   ⋮ wrapped to a row of its own — flex-wrap breaks a line BEFORE it shrinks
   anything, so the name never gave way. From 0 it grows into what's left after
   the caret and the ⋮ and wraps inside its own box, keeping all three on the
   same row. */
.gh-stacked .pocket-group-figures {
  margin-left: 0;
  padding-left: 0;
  align-items: flex-start;
  justify-content: flex-start;
}
.pgh-avg { white-space: nowrap; font-size: inherit; font-weight: 500; color: var(--muted); }
.pgh-avg-val { font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
.pocket-group-header .meta-per-paycheck { font-size: inherit; margin-left: 0; }

.pocket-group-toggle { color: var(--muted); transition: transform 0.2s; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
.pocket-group-toggle.collapsed { transform: rotate(-90deg); }
.pocket-group-caret { width: 18px; height: 18px; display: block; }
.pocket-group-body.collapsed { display: none; }

/* ── Category edit popup (shared add/edit form) ── */
.cat-edit-modal-box { max-width: 420px; }
.modal-header-actions { display: flex; align-items: center; gap: 4px; }
/* ── Custom-colour popover (replaces <input type="color">) ──
   Hangs off the rainbow swatch with an arrow pointing back at it, so the bubble
   is visibly "for that swatch". Fixed positioning escapes the modal's overflow;
   _openCatColorPop places it and keeps it anchored on scroll. */
.cat-color-pop {
  position: fixed; z-index: 700;
  width: min(288px, calc(100vw - 20px));
  padding: 14px; box-sizing: border-box;
  background: var(--surface2); border: 1px solid var(--border);
  border-radius: 14px; box-shadow: 0 12px 32px rgba(0,0,0,0.55);
}
/* Arrow: a rotated square straddling the top edge. --ccp-arrow is set inline to
   the swatch's centre, so it still points correctly when the bubble is clamped
   against a screen edge. */
.cat-color-pop::before {
  content: ''; position: absolute; top: -6px; left: var(--ccp-arrow, 32px);
  width: 11px; height: 11px; margin-left: -6px;
  background: var(--surface2);
  border-left: 1px solid var(--border); border-top: 1px solid var(--border);
  transform: rotate(45deg); border-radius: 2px 0 0 0;
}
/* Flipped above the swatch (no room below): the arrow moves to the bottom edge
   and the borders swap to the two sides now facing out. */
.cat-color-pop.ccp-above::before {
  top: auto; bottom: -6px;
  border-left: none; border-top: none;
  border-right: 1px solid var(--border); border-bottom: 1px solid var(--border);
  border-radius: 0 0 2px 0;
}
/* Hue by angle (conic), saturation by radius (white core fading out). The
   lightness overlay is a flat black/white veil driven by --ccp-l so the wheel
   previews the chosen lightness instead of always showing the 50% slice. */
/* Sized by JS (--ccp-wheel) to whatever vertical room the bubble has, so the
   picker always fits on screen instead of running off the bottom. */
.ccp-wheel {
  position: relative; border-radius: 50%;
  width: var(--ccp-wheel, 100%); height: var(--ccp-wheel, auto); aspect-ratio: 1;
  margin: 0 auto 12px; cursor: crosshair; touch-action: none;
  background:
    radial-gradient(circle closest-side, #fff 0%, rgba(255,255,255,0) 70%),
    conic-gradient(from 90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,0.35);
}
.ccp-marker {
  position: absolute; width: 18px; height: 18px; margin: -9px 0 0 -9px;
  border-radius: 50%; border: 2px solid #fff; pointer-events: none;
  box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 2px 6px rgba(0,0,0,0.5);
}
.ccp-light {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 12px; border-radius: 99px; margin: 0 0 12px;
  border: 1px solid var(--border); outline: none;
}
.ccp-light::-webkit-slider-thumb {
  -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
  background: #fff; border: 2px solid var(--surface); cursor: pointer;
  box-shadow: 0 0 0 1px rgba(0,0,0,0.5);
}
.ccp-light::-moz-range-thumb {
  width: 18px; height: 18px; border-radius: 50%;
  background: #fff; border: 2px solid var(--surface); cursor: pointer;
}
.ccp-hex-row { display: flex; align-items: center; gap: 8px; }
.ccp-hex-dot {
  width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0;
  box-shadow: inset 0 0 0 1px rgba(255,255,255,0.25);
}
.ccp-hex {
  flex: 1; min-width: 0; padding: 8px 10px; font-size: 14px;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  text-transform: lowercase;
}
/* Dismisses the bubble. Tapping outside also closes it, but on a phone the
   nearest "outside" is often another swatch — which would change the colour on
   the way out. This is the exit that doesn't do anything else. */
.ccp-done { width: 100%; margin-top: 12px; padding: 9px 12px; font-size: 14px; }
/* One row, always. The swatches divide whatever width is available rather than
   wrapping onto a second line — a wrapped palette made the row's height jump
   around as colours were added, and pushed the custom swatch away from the
   popover it anchors. max-width keeps them from ballooning into lozenges on a
   wide screen; min-width keeps them tappable when the row is crowded. */
.cat-color-swatches {
  display: flex;
  flex-wrap: nowrap;
  gap: 8px;
  align-items: center;
}
.cat-swatch {
  position: relative;
  flex: 1 1 0; min-width: 22px; max-width: 34px; aspect-ratio: 1;
  height: auto;
  border-radius: 50%;
  border: 2px solid transparent;
  cursor: pointer; padding: 0;
  -webkit-appearance: none; appearance: none;
  font-family: inherit;
  display: inline-flex; align-items: center; justify-content: center;
  background-clip: padding-box;
}
@media (hover: hover) {.cat-swatch:hover { border-color: var(--border); }}
.cat-swatch.selected {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(99,102,241,0.35);
  transform: scale(1.08);
}
.cat-swatch-check {
  display: inline-flex; align-items: center; justify-content: center;
  width: 100%; height: 100%;
}
/* Color-wheel button: rainbow conic gradient inside the circle. When the
   user has picked a custom (non-preset) color the wheel hides and the
   button fills with their selection like any other swatch. */
.cat-swatch-custom { background: var(--surface2); }
.cat-swatch-wheel {
  display: block;
  width: 100%; height: 100%;
  border-radius: 50%;
  background: conic-gradient(from 0deg, #ef4444, #f59e0b, #eab308, #22c55e, #06b6d4, #3b82f6, #a855f7, #ec4899, #ef4444);
  pointer-events: none;
}
/* ⚠️ auto-flow columns, not a fixed `1fr 1fr`. The Cancel here is INJECTED, so
   the row holds one button or two depending on whether anything has changed —
   a fixed two-column grid would leave a button-shaped hole beside a lone Done.
   Auto columns size to however many children there are. */
.cat-edit-modal-actions {
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  gap: 10px; margin-top: 18px;
}
.cat-edit-modal-actions .btn-primary,
.cat-edit-modal-actions .btn-secondary { width: 100%; }

/* ── Category group reorder mode (flow-based) ── */
.pocket-group-header.reorderable {
  transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
  will-change: transform;
  -webkit-touch-callout: none;
}
#pockets-list.cat-reorder-active .pocket-group-body { display: none; }
/* The Cancel/Done bar floats above the bottom nav (`bottom: nav-h + 12px`
   plus ~52 px of bar height). Pad the list so the last cards aren't
   hidden under it and can still be tapped/dragged. */
#pockets-list.cat-reorder-active { padding-bottom: 96px; }
#pockets-list.cat-reorder-active .pocket-group-header.reorderable.reorder-card {
  display: flex; align-items: center; gap: 10px;
  margin: 0 0 8px;
  padding: 14px 14px;
  background: var(--surface2);
  border: 1px solid var(--border);
  border-radius: 12px;
  cursor: grab;
  min-height: 0;              /* drag cards keep their own compact height */
  /* touch-action:pan-y so a swipe scrolls natively (real momentum). The shared
     hold-drag engine runs a hold timer on top: a still press picks the card up
     (and blocks scroll via a non-passive touchmove for the rest of that touch);
     a press that drifts first stays a native momentum scroll. */
  touch-action: pan-y;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  font-size: 16px;
}
#pockets-list.cat-reorder-active .pocket-group-header.reorderable.reorder-card.reorder-dragging {
  transition: none;
  position: relative;
  z-index: 10;
  cursor: grabbing;
  background: var(--surface);
  box-shadow: 0 12px 28px rgba(0,0,0,0.5);
  border-color: var(--accent);
}
#pockets-list.cat-reorder-active .pocket-group-header:not(.reorderable) {
  margin-top: 24px;
  opacity: 0.6;
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  pointer-events: none;
}
.pocket-group-dot {
  width: 10px !important; height: 10px !important;
  border-radius: 50%; flex-shrink: 0;
}
/* The grip is rendered as a CSS pseudo-element on the reorder card itself,
   not as a separate DOM node. Reason: iOS Safari tracks small elements
   for press detection (the touch ring) even when they have
   `pointer-events: none`, and can fire `pointercancel` if the finger
   appears to drift off them — which silently kills taps that started on
   the grip's area. A pseudo-element doesn't exist as a hit target. */
#pockets-list.cat-reorder-active .reorder-card::before {
  content: '';
  flex-shrink: 0;
  width: 16px;
  height: 12px;
  margin-right: 8px;
  background-image: linear-gradient(
    to bottom,
    var(--muted) 0, var(--muted) 2px,
    transparent 2px, transparent 5px,
    var(--muted) 5px, var(--muted) 7px,
    transparent 7px, transparent 10px,
    var(--muted) 10px, var(--muted) 12px
  );
}
.reorder-card.reorder-dragging::before {
  background-image: linear-gradient(
    to bottom,
    var(--text) 0, var(--text) 2px,
    transparent 2px, transparent 5px,
    var(--text) 5px, var(--text) 7px,
    transparent 7px, transparent 10px,
    var(--text) 10px, var(--text) 12px
  );
}
/* Edit button on each reorder row — mirrors `.cat-option-edit` in the
   pocket modal's category dropdown so the icon style is consistent. */
.cat-reorder-row-edit {
  background: none; border: none; cursor: pointer; padding: 8px;
  border-radius: 6px; color: var(--muted);
  display: inline-flex; align-items: center; justify-content: center;
  flex-shrink: 0;
  margin-left: auto;
}
@media (hover: hover) {.cat-reorder-row-edit:hover,
.cat-reorder-row-edit:active { background: var(--surface); color: var(--accent); }}
body.cat-reorder-dragging-body { user-select: none; -webkit-user-select: none; }
body.cat-reorder-dragging-body * { -webkit-user-select: none; }

/* Reorder action bar: floating Cancel / Done above the bottom nav.
   Modelled on #tx-select-bar so it lines up with the multi-select pattern
   the rest of the app already uses. */
.cat-reorder-bar {
  position: fixed;
  left: 50%;
  bottom: calc(var(--nav-h) + 12px);
  transform: translateX(-50%);
  z-index: 1000;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 14px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  box-shadow: 0 10px 30px rgba(0,0,0,0.45);
  max-width: calc(100% - 32px);
  /* The bar floats over the list, so any card that scrolls into its strip
     would otherwise have its taps swallowed (the bar's background, padding,
     and title would all eat them). Make the container itself transparent
     to pointer events — only the actual buttons below capture taps. */
  pointer-events: none;
}
.cat-reorder-bar > button { pointer-events: auto; }
.cat-reorder-bar .btn-primary { width: auto; padding: 8px 18px; font-size: 14px; }
.cat-reorder-bar .btn-ghost { padding: 8px 12px; font-size: 14px; }
.cat-reorder-bar-title {
  font-size: 13px; color: var(--muted);
  white-space: nowrap;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: 600;
}
/* Pre-launch lockout: muted/locked variant of buttons we want regular users
   to see (and tap to get the explainer popup) while devs with the toggle
   on get the real button. Mirrors the .dev-only pattern. */
body:not(.dev-tools-on) .pre-launch-unlocked-only { display: none !important; }
body.dev-tools-on .pre-launch-locked-only { display: none !important; }

/* ── Tx pocket picker ── */
.pocket-select-btn {
  width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px;
  background: var(--surface2); border: 1px solid var(--border); border-radius: 12px;
  padding: 12px 14px; color: var(--text); font-size: 15px; font-family: inherit; cursor: pointer;
  transition: border-color 0.2s, background 0.2s;
}
@media (hover: hover) {.pocket-select-btn:hover { border-color: var(--accent); }}
.pocket-select-label { flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pocket-select-caret { color: var(--muted); flex-shrink: 0; }

.tx-pocket-picker-list { margin: 0 -20px -24px; }

/* No explicit height clamp: the picker fills whatever the shared full-page
   rules give it — 100dvh on mobile, the 601–1099px gap rule, or the two-pane
   right pane (align-items:stretch) — so it's ALWAYS full height when not mobile
   (that fixed full height also kills the filter-jitter the old clamp guarded
   against). The list scrolls inside; the search input stays pinned at the top. */
#tx-pocket-picker-overlay > .modal-box {
  display: flex;
  flex-direction: column;
}
#tx-pocket-picker-overlay > .modal-box > .modal-body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
#tx-pocket-picker-overlay #tx-pocket-picker-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}
/* Flat full-width tile rows — matches Manage Categories / User Profile /
   Connections. Padding + hairline live on .tx-pocket-option so every row
   (pocket cards, the Good to Go / None header cards) shares the look. */
.tx-pocket-option { padding: 16px 20px; font-size: 14px; cursor: pointer; color: var(--text); background: var(--surface); border-bottom: 1px solid rgba(255,255,255,0.06); }
.tx-pocket-option:last-child { border-bottom: none; }
@media (hover: hover) {.tx-pocket-option:hover, .tx-pocket-option.focused { background: var(--surface2); }}
/* Per-row hairlines replace the single section divider between the header
   cards and the pockets. */
.txp-divider { display: none; }
.txp-card { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.txp-card-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.txp-card-name { font-weight: 500; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.txp-card-due { font-size: 11px; color: var(--muted); }
.txp-card-amounts { font-size: 15px; font-weight: 700; color: var(--text); white-space: nowrap; flex-shrink: 0; }
.txp-card-sep, .txp-card-goal { color: var(--muted); font-weight: 500; }
.txp-gtg .txp-card-name { font-weight: 700; }

/* ── Scrollable fixed dropdowns (no visible scrollbar, thin on hover) ──
   Height is content-driven up to a flat cap — 260px was far too short for a
   20-category list on a phone. The cap is a share of the VIEWPORT and never
   consults the field's position, so the dropdown is the same size wherever the
   modal is scrolled; if it runs past the bottom, the list scrolls internally.
   dvh (dynamic viewport) is what makes that honest on mobile — 60vh measures
   the address-bar-hidden viewport and would overflow while the bar is showing.
   The vh line is the fallback for anything without dvh. */
.cat-combo-dropdown { max-height: 60vh; max-height: 60dvh; overflow-y: auto; }

/* ── Custom select (cs-*) — replaces native <select> everywhere ── */
.cs-wrap { position: relative; }
.cs-btn {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  width: 100%; padding: 10px 14px; background: var(--surface2); border: 1px solid var(--border);
  border-radius: 12px; color: var(--text); font-size: 14px; cursor: pointer; text-align: left;
  transition: border-color 0.2s; font-family: inherit; font-weight: 400;
}
@media (hover: hover) {.cs-btn:hover:not(:disabled) { border-color: var(--accent); }}
/* Disabled: matches the locked date field — dimmed + non-interactive.
 * A native disabled <button> won't dispatch onclick, so csToggle never
 * fires when the freq picker is locked (Spending Fund / CC pocket). */
.cs-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.cs-btn svg { color: var(--muted); flex-shrink: 0; transition: transform 0.2s; }
.cs-btn.open svg { transform: rotate(180deg); }
/* ⚠️ A custom select SIDE BY SIDE with a text input has to match its height
   (owner, 2026-08-15: "Frequency dropdown is shorter than the due date field").
   The two drifted apart: an input is `padding: 12px 14px; font-size: 15px` while
   .cs-btn is 10px/14px, which is ~5px shorter — invisible on its own, obvious
   in a two-column row where they sit shoulder to shoulder.
   ⚠️ Scoped to .field-row-2col rather than fixed on .cs-btn itself. That class
   is the app's select everywhere — account filters, the tx filter, menus — and
   a global height change is not something to make blind. This targets the
   PAIRING, which is where the mismatch is visible, and there are THREE of them:
   Due Date | Frequency, Next Pay Date | Pay Frequency, and Type | Currency.
   A row holding two selects is unaffected in appearance since both move. */
.field-row-2col .cs-btn { padding: 12px 14px; font-size: 15px; }
.cs-menu {
  display: none; position: fixed; background: var(--surface2); border: 1px solid var(--border);
  border-radius: 12px; z-index: 600; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
  max-height: 260px; overflow-y: auto;
}
/* Type-to-search readout, pinned so it stays visible while the matches scroll.
   Doubles as the affordance: on a long list it reads "Type to search" before
   anything is typed, which is the only hint that the keys do something. */
.cs-search {
  position: sticky; top: 0; z-index: 1;
  padding: 8px 14px; font-size: 12px; letter-spacing: 0.02em;
  color: var(--muted); background: var(--surface2);
  border-bottom: 1px solid var(--border);
  border-radius: 12px 12px 0 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Once they're typing it's their text, not a prompt — full weight and colour. */
.cs-search.has-query { color: var(--text); font-weight: 600; }
.cs-empty { padding: 10px 14px; font-size: 14px; color: var(--muted); }
.cs-opt { padding: 10px 14px; font-size: 14px; cursor: pointer; color: var(--text); }
.cs-opt:first-child { border-radius: 12px 12px 0 0; }
/* The hint row owns the top corners when it's present. */
.cs-search + .cs-opt { border-radius: 0; }
.cs-opt:last-child  { border-radius: 0 0 12px 12px; }
.cs-opt:only-child  { border-radius: 12px; }
/* ⚠️ `.focused` is KEYBOARD state and must not live in a hover query — the
   same rule the donut's selection follows. Parked in there it left an
   arrow-key user with no highlight on any device without a mouse. */
.cs-opt.focused { background: var(--surface); }
@media (hover: hover) {.cs-opt:hover { background: var(--surface); }}
.cs-opt.active { color: var(--accent); font-weight: 600; }

/* ── Freq trigger button ── */
.freq-trigger {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  width: 100%; padding: 12px 14px; background: var(--surface2); border: 1px solid var(--border);
  border-radius: 12px; color: var(--text); font-size: 15px; cursor: pointer;
  text-align: left; font-family: inherit; font-weight: 400; transition: border-color 0.2s;
}
@media (hover: hover) {.freq-trigger:hover { border-color: var(--accent); }}
.freq-trigger span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.freq-trigger svg { color: var(--muted); flex-shrink: 0; }

/* Stepper */
.freq-stepper-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
.freq-stepper-label { color: var(--muted); font-size: 14px; }
.freq-stepper-controls { display: flex; align-items: center; gap: 6px; background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 4px 8px; }
.freq-step-btn { background: none; border: none; color: var(--text); font-size: 20px; padding: 0 6px; cursor: pointer; border-radius: 6px; line-height: 1.2; font-weight: 300; }
@media (hover: hover) {.freq-step-btn:hover:not(:disabled) { background: var(--border); }}
.freq-step-btn:disabled { opacity: 0.3; cursor: default; transform: none; }
.freq-every-val { font-size: 16px; font-weight: 700; min-width: 24px; text-align: center; }
.freq-stepper-unit { color: var(--muted); font-size: 14px; }

/* Section sub-labels ("on", "on the") — lowercase intentional, continuation of the stepper sentence */
.freq-section-label { font-size: 12px; color: var(--muted); font-weight: 500; padding-top: 10px; margin-bottom: 6px; }
.freq-day-section-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; padding-top: 4px; }

/* Weekday grid — padded 10% each side → ~20% smaller circles */
.freq-weekday-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; padding: 0 10%; }
.freq-weekday-btn {
  aspect-ratio: 1; border-radius: 50%; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
  font-size: 11px; font-weight: 700; cursor: pointer; transition: all 0.15s;
  line-height: 1; text-align: center;
}
@media (hover: hover) {.freq-weekday-btn:hover { border-color: var(--accent); color: var(--accent); }}
.freq-weekday-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }

/* Day-of-month grid */
.freq-day-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.freq-day-btn {
  padding: 6px 4px; border-radius: 8px;
  background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
  font-size: 11px; font-weight: 600; cursor: pointer; transition: all 0.15s;
}
@media (hover: hover) {.freq-day-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }}
.freq-day-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.freq-day-hint { grid-column: span 4; font-size: 10px; opacity: 0.5; cursor: default !important; border-style: dashed; text-align: center; }

/* Annual compact month + day selectors */
.freq-annual-compact { display: flex; gap: 12px; align-items: flex-start; }
.freq-annual-date { display: inline-block; font-size: 13px; font-weight: 600; color: var(--text); background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 7px 14px; }

.pocket-paycheck-preview {
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface2);
  padding: 10px 14px;
  min-height: 42px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.pocket-paycheck-preview-value {
  font-size: 14px;
  font-weight: 600;
  color: var(--accent);
  text-align: center;
}
.pocket-paycheck-preview-value.muted {
  font-size: 13px;
  font-weight: 500;
  color: var(--muted);
}
.pocket-paycheck-preview-value.behind { color: var(--red); }
/* Funded for the current drain — the value shown is the PROJECTED next-cycle
   need (what the next paycheck will owe once this drain hits). Italic + the
   trailing → mirror the pocket-card .meta-per-paycheck.next-cycle cue so the
   number reads as "coming up," not "owed now." */
.pocket-paycheck-preview-value.next-cycle { font-style: italic; }
/* inner span carries the flex so the trailing svg.wiz-arrow centers with the
   text; the value element itself stays block/centered. */
.pocket-paycheck-preview-value .npc-flex { display: inline-flex; align-items: center; }

/* ── Multi-cycle funding-plan strip (pocket modal) ── */
.pm-funding-plan {
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface2);
  padding: 10px 14px;
}
.pm-funding-plan-head {
  font-size: 13px; font-weight: 600; color: var(--text);
  margin-bottom: 6px; text-align: center;
}
.pm-funding-plan-head.behind { color: var(--red); }
/* "Fully funded for <date>" note above the next-drain section — the pocket
   already covers its current due date, so the plan below funds the NEXT one.
   Same size/weight as the Need header; green to signal the covered state. */
.pm-funding-plan-covered {
  font-size: 13px; font-weight: 700; color: var(--green);
  text-align: center; margin-bottom: 4px;
}
/* "Short $738.00 for Aug 31, 2026" — the RED counterpart of the green line
   above, in the same slot and the same size/weight, because they answer the
   same question about the drain the plan has walked past: covered, or not.
   The pair is only legible as a pair if nothing but the colour differs. */
.pm-funding-plan-short {
  font-size: 13px; font-weight: 700; color: var(--red);
  text-align: center; margin-bottom: 4px;
}
.pm-funding-plan-row {
  display: flex; align-items: baseline; gap: 8px;
  font-size: 13px; padding: 3px 0;
}
.pm-funding-plan-amt { font-weight: 700; color: var(--accent); min-width: 64px; }
.pm-funding-plan-cycle { color: var(--text); flex: 1; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pm-funding-plan-date { color: var(--muted); font-size: 12px; white-space: nowrap; }
/* "Show N more" / "Show less" toggle for long funding plans — centered. */
.pm-funding-plan-toggle {
  display: block; width: 100%; margin-top: 6px; padding: 2px 0;
  background: none; border: none; cursor: pointer; text-align: center;
  font: inherit; font-size: 12px; font-weight: 600; color: var(--accent);
}
@media (hover: hover) {.pm-funding-plan-toggle:hover { text-decoration: underline; }}

/* The "Funding Schedule" heading sits BELOW the cadence bubble (owner,
 * 2026-08-15) and titles only the dates under it. The wrap is a .field-group,
 * so its children sit on a 4px gap — right for label→control, too tight where
 * a heading starts a new sub-section. Applied by _pmSyncFundingScheduleWrap
 * only when the bubble is actually present; see the note there for why this is
 * not an adjacent-sibling rule. */
.pm-fs-label-spaced { margin-top: 8px; }

/* ── Pocket modal inline frequency picker ── */
/* One slot for everything: editable bubble (Daily/Weekly/Monthly/Yearly) AND
 * read-only summary (Once / Per Paycheck) share this look. Variable parts
 * inside the bubble use .freq-var (accent color) so the user can scan
 * which words will change if they edit. */
.pm-freq-settings .freq-settings-bubble,
.pc-freq-settings .freq-settings-bubble {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  background: var(--surface2); border: 1px solid var(--border); border-radius: 12px;
  padding: 10px 14px; flex-wrap: wrap;
  font-size: 14px; color: var(--text); text-align: center;
  min-height: 42px;
}
/* Variable text inside the bubble — accent color + bold so it stands out
 * from the static "Every", "on", connector words (which stay default
 * weight + default text color). Global so the same coloring works inside
 * the funding-schedule summary box at the bottom of the pay cycle modal,
 * not just the inline bubble. */
.freq-var { color: var(--accent); font-weight: 700; }
.freq-var-muted { color: var(--muted); font-style: italic; }
/* Tappable number — looks like a button until tapped, then swaps for
 * an inline numeric input. */
.pm-freq-settings .freq-num-btn,
.pc-freq-settings .freq-num-btn {
  background: rgba(173, 161, 255,0.1); border: 1px solid rgba(173, 161, 255,0.3);
  color: var(--accent); font-weight: 700; font-size: 14px;
  padding: 4px 10px; min-width: 36px; border-radius: 8px; cursor: pointer;
  font-family: inherit; transition: background 0.15s;
}
@media (hover: hover) {.pm-freq-settings .freq-num-btn:hover,
.pc-freq-settings .freq-num-btn:hover { background: rgba(173, 161, 255,0.2); }}
.pm-freq-settings .freq-num-input,
.pc-freq-settings .freq-num-input {
  background: var(--surface); border: 1px solid var(--accent);
  color: var(--accent); font-weight: 700; font-size: 14px;
  padding: 4px 8px; min-width: 36px; max-width: 60px; border-radius: 8px;
  text-align: center; font-family: inherit; outline: none;
  -moz-appearance: textfield;
  appearance: textfield;
}
.pm-freq-settings .freq-num-input::-webkit-outer-spin-button,
.pm-freq-settings .freq-num-input::-webkit-inner-spin-button,
.pc-freq-settings .freq-num-input::-webkit-outer-spin-button,
.pc-freq-settings .freq-num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.pm-freq-settings .freq-day-chip,
.pc-freq-settings .freq-day-chip {
  background: rgba(173, 161, 255,0.1); border: 1px solid rgba(173, 161, 255,0.3);
  color: var(--accent); font-weight: 700; font-size: 13px;
  padding: 4px 10px; border-radius: 99px; cursor: pointer;
  font-family: inherit;
}
@media (hover: hover) {.pm-freq-settings .freq-day-chip:hover,
.pc-freq-settings .freq-day-chip:hover { background: rgba(173, 161, 255,0.2); }}

/* Day-picker popup (anchored beneath the chip, fixed-position so it
 * floats above the rest of the modal layout instead of pushing content
 * down). Replaces the old inline-expand row. */
.freq-pop {
  position: fixed; z-index: 9999;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 12px; padding: 10px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.freq-pop-weekday-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; min-width: 224px; }
.freq-pop-day-grid     { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; min-width: 252px; }
.freq-pop .freq-weekday-btn {
  aspect-ratio: 1; border-radius: 50%; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
  font-size: 12px; font-weight: 700; cursor: pointer; transition: all 0.15s;
}
/* Unselected hover: accent border, white text (so the number stays
 * legible on the dark surface). Selected hover: white border, white
 * text (purple bg stays, the border lightens to signal hover without
 * washing out the digit). Same pattern for weekday, day-of-month,
 * and ordinal buttons. */
@media (hover: hover) {.freq-pop .freq-weekday-btn:hover { border-color: var(--accent); color: var(--text); }}
.freq-pop .freq-weekday-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
@media (hover: hover) {.freq-pop .freq-weekday-btn.active:hover { border-color: #fff; color: #fff; }}
.freq-pop .freq-day-btn {
  aspect-ratio: 1; border-radius: 6px; padding: 0;
  background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
  font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.15s;
}
@media (hover: hover) {.freq-pop .freq-day-btn:hover:not(.freq-day-hint) { border-color: var(--accent); color: var(--text); }}
.freq-pop .freq-day-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
@media (hover: hover) {.freq-pop .freq-day-btn.active:hover:not(.freq-day-hint) { border-color: #fff; color: #fff; }}
.freq-pop .freq-day-hint {
  grid-column: span 7; aspect-ratio: auto; padding: 4px; font-size: 10px;
  color: var(--muted); cursor: default; opacity: 0.7;
}

/* Tab strip at the top of the monthly freq popup (gated behind debug
 * tools for now — toggles between "Day(s) of Month" and "Nth Weekday"). */
.freq-pop-tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.freq-pop-tab {
  flex: 1; padding: 6px 8px; font-size: 12px; font-weight: 600;
  background: var(--surface2); border: 1px solid var(--border);
  color: var(--muted); border-radius: 6px; cursor: pointer; transition: all 0.15s;
}
@media (hover: hover) {.freq-pop-tab:hover { border-color: var(--accent); color: var(--accent); }}
.freq-pop-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }

/* Ordinal chips (1st 2nd 3rd 4th 5th) for the Nth-weekday picker. */
.freq-pop-ordinal-row { display: flex; gap: 4px; margin-bottom: 8px; }
.freq-ordinal-btn {
  flex: 1; padding: 6px 4px; font-size: 12px; font-weight: 600;
  background: var(--surface2); border: 1px solid var(--border);
  color: var(--muted); border-radius: 6px; cursor: pointer; transition: all 0.15s;
}
@media (hover: hover) {.freq-ordinal-btn:hover { border-color: var(--accent); color: var(--text); }}
.freq-ordinal-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
@media (hover: hover) {.freq-ordinal-btn.active:hover { border-color: #fff; color: #fff; }}

/* Locked date field (Spending Fund pins it to next pay date) */
.dp-field-wrap.is-locked { opacity: 0.55; pointer-events: none; }

/* Money input — wraps a number input with a $ glyph on the left.
 * The $ is a non-interactive ::before so the input keeps its native
 * keyboard + validation; the input's left padding makes room for it. */
.money-input-wrap { position: relative; display: block; }
/* ⚠️ The money field hovers like everything beside it (owner, 2026-08-21: "they
   all hover in purple, except the amount field"). Nothing in this app gave an
   <input> a hover at all — only buttons had one — so in a row of buttons the
   amount was the one dead-looking control.
   Written on the WRAP rather than the input, which is the pattern
   `.dp-field-wrap:hover .dp-input` already set: the wrap is the thing with a
   box, and hovering its dollar sign should light the field too. */
@media (hover: hover) {
  .money-input-wrap:hover input:not(:disabled):not([readonly]) { border-color: var(--accent); }
  /* Its row-mate, so an items row is uniform rather than half-lit. */
  .txi-desc:hover:not(:disabled) { border-color: var(--accent); }
}
/* ── The Pocketed field's own overflow (owner, 2026-08-14) ────────────────
   Add Funds / Remove Funds, on the field that holds the number they change.
   Absolutely positioned INSIDE the wrap rather than beside it, so the field
   keeps its half of the two-column amounts row and the row's geometry is
   untouched — the money fields are measured tight enough that a third column
   in the row would squeeze them (see .field-row-2col.has-goal, where the Goal
   Amount takes its own line for the same reason). */
/* ⚠️ MEASURED, not guessed, and every pixel here is bought from the amount
   itself — this field is half a phone wide. The button renders 28px at this
   padding, and it sits 8px off the edge so it reads like the insights block's
   ⋮ instead of jammed against the border (owner, 2026-08-14). That is 36px of
   the field spoken for, hence the input padding. */
.money-input-wrap.has-menu input { padding-right: 38px; }
/* ⚠️ NO `transform` — centred with `inset-block: 0` + flex instead (owner,
   2026-08-14, with a screenshot of the menu half-covered by the Goal Amount
   field). A transform makes a STACKING CONTEXT, which traps the menu's own
   `z-index: 50` inside this wrap: it then paints in the wrap's own layer, so
   every later sibling — the Goal Amount field on the row's second line, the
   insights block below — drew straight over it. Without the transform the menu
   is an ordinary positioned element with a positive z-index in the modal's
   stacking context, and it beats all of them by construction. */
.pm-stashed-menu-wrap {
  position: absolute; right: 8px; top: 0; bottom: 0;
  display: flex; align-items: center;
}
.pm-stashed-menu-wrap .bank-menu-btn { padding: 2px 4px; font-size: 20px; }
/* ⚠️ Opens LEFT and sizes to content, for the same reason .pm-spend-menu does:
   the trigger is at the field's right edge and this field is HALF a phone
   wide, so the .bank-menu 240px floor anchored left would run clean off the
   screen. */
.pm-stashed-menu { right: 0; left: auto; min-width: 0; width: max-content; max-width: calc(100vw - 48px); }
/* ⚠️ The amounts row is ALSO lifted while the menu is open. Belt and braces
   with the no-transform rule above: that one lets the menu beat its siblings
   inside this row, this one lets it beat everything after the row. Removing
   the transform was the fix that mattered — a row-level z-index cannot help a
   sibling INSIDE the same row, which is what the owner's screenshot showed
   (the Goal Amount field cutting the menu in half on the row's second line). */
.field-row-2col:has(.pm-stashed-menu.open) { position: relative; z-index: 60; }
.money-input-wrap::before {
  content: '$'; position: absolute; left: 14px; top: 50%;
  transform: translateY(-50%); color: var(--muted);
  font-size: 15px; pointer-events: none; line-height: 1;
}
.money-input-wrap input { padding-left: 26px; }

/* Spending Fund badge — PURPLE (owner, 2026-08-15: "Make spending... purple in
 * the app"). It was a neutral white pill, which is why the budget list's own
 * Spending Fund badge disagreed with the pocket card's; now both are --accent
 * and the two surfaces match. Same 12%/45% recipe as the Savings Goal and
 * Credit Card badges. Lives in the same name-row slot as Paused/Behind; only
 * one shows at a time.
 * ⚠️ The white pill this used to be still exists as `.badge-plain`, for things
 * that wanted the LOOK rather than the meaning — the Legacy subscription badge
 * borrowed this class and would otherwise have turned purple with it. */
.badge-spending-fund { color: var(--type-fund); background: var(--type-fund-12); border-color: var(--type-fund-45); }
.badge-plain { color: var(--text); background: var(--surface2); border-color: var(--text); }

/* ── Pay cycle rows ── */
.paycycle-row {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 16px; border: 1px solid var(--border); border-radius: 8px; 
  background: var(--surface2); cursor: pointer; gap: 10px; margin-bottom: 8px;
  transition: border-color 0.15s, background-color 0.15s;
}
@media (hover: hover) {.paycycle-row:hover { border-color: var(--accent); background: var(--surface); }}
.paycycle-row:last-child { margin-bottom: 0; }
/* GTG paycheck pill — red wash when the pay period doesn't cover itself, i.e.
   `shortBy > 0`: the design gap and the Good to Go catch-up, netted. Soft red
   fill (matching the soft purple of the default state) plus red border + red
   amount so the signal is unambiguous. */
.gtg-paycheck-btn.is-short {
  background: rgba(248, 113, 113, 0.10);
  border-color: rgba(248, 113, 113, 0.45);
}
/* ⚠️ THE NEED LINE ONLY REDDENS WHEN THE TROUBLE IS ITS OWN (owner,
   2026-08-18). The pill shows the NEXT paycheck and the verdict row reports the
   SOONEST schedule in trouble — which is often a LATER one. Reddening every
   figure said "this paycheck is over budget" about a paycheck that is fine, and
   the screenshot made it plain: Aug 28 was covered, Aug 31 was not, and both
   lines were red. The wash and border still turn, because the pill as a whole
   is carrying bad news; only the Need figures stay neutral.
   `.is-short-here` is added when the offending schedule IS the one on the line. */
.gtg-paycheck-btn.is-short .gpl-short,
.gtg-paycheck-btn.is-short .gpl-short strong,
.gtg-paycheck-btn.is-short-here strong { color: var(--red); }
@media (hover: hover) {.gtg-paycheck-btn.is-short:hover {
  background: rgba(248, 113, 113, 0.18);
  border-color: var(--red);
}}
.paycycle-info { flex: 1; min-width: 0; }
.paycycle-name { font-size: 15px; font-weight: 500; transition: color 0.15s; }
.paycycle-primary-badge {
  display: inline-block; margin-left: 8px; vertical-align: 1px;
  font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--accent);
  background: rgba(173, 161, 255, 0.14);
  border: 1px solid rgba(173, 161, 255, 0.35);
  border-radius: 999px; padding: 1px 7px;
}
.paycycle-sub { font-size: 12px; color: var(--muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paycycle-row svg { color: var(--muted); flex-shrink: 0; }
/* Disabled funding schedule — a non-primary cycle switched off by a premium
   downgrade. Colour-dimmed (not opacity) so the Premium badge stays legible;
   a tap routes to the multi-schedule upsell. */
.paycycle-row-disabled .paycycle-name { color: var(--muted); }
.paycycle-row-disabled .premium-badge { margin-left: 8px; vertical-align: 1px; }
/* Right-side cluster for a .paycycle-row that carries an inline control
   (chevron → divider → toggle), e.g. the Notifications row. The row's own
   onclick opens the modal; the toggle stops propagation for a quick on/off. */
.paycycle-end { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.paycycle-end-divider { width: 1px; height: 26px; background: var(--muted); opacity: 0.45; flex-shrink: 0; }
/* Padded hit area around the pill so the tap target is comfortable without
   changing the row height (negative margin offsets the padding). */
.toggle-tap { display: flex; align-items: center; padding: 8px 4px; margin: -8px -2px; border-radius: 8px; }
@media (hover: hover) {.toggle-tap:hover { background: var(--surface2); }}
.paycycle-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.paycycle-fund-btn { padding: 7px 10px; font-size: 12px; white-space: nowrap; }

/* ── Funding Schedules modal — accordion ── */
/* ⚠️ FLAT ROWS, NOT CARDS (owner, 2026-08-18: "no outline on them. Row
   style, probably like the Your Budget design"). Each schedule was a bordered
   rounded box, which made a list of two look like two separate panels rather
   than one list. Same shape .fsb-acc uses on the Budget screen: a hairline
   BETWEEN rows and nothing around them, with the last one dropping its rule so
   the list does not end on a line. */
/* ⚠️ THE LIST BLEEDS TO THE MODAL EDGES, which is what "flat" means
   everywhere else in this app (owner, 2026-08-18: "The side padding is still
   indenting it, unlike any other page"). Dropping the row padding was not
   enough: `.modal-body` has 20px of its own, so the rows AND their dividers
   still sat in from the edge and read as a floating list.
   Same trick `.flat-list` uses — cancel the body padding on the container,
   then put it back on the ROW, so the divider spans the full width while the
   text still lines up with everything above it. */
#fs-accordion { margin-left: -20px; margin-right: -20px; }
.fs-row { border-bottom: 1px solid var(--border); }
.fs-row:last-child { border-bottom: none; }
/* ⚠️ Over budget now colours the NAME, because there is no border left to
   colour — and the name is the row's identity, the same call the Budget
   screen's .is-trouble makes. (.fs-row-over .fs-row-name below does the work;
   this rule is deliberately empty of border styling.) */
.fs-row-header {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  /* No side inset: with the card gone the row runs to the body's own padding,
     like every other flat list in the app. */
  padding: 14px 20px; cursor: pointer; user-select: none;
  transition: background-color 0.15s;
}
/* No hover wash either — it painted a card-shaped block on a flat row. */
.fs-row-info { flex: 1; min-width: 0; }
.fs-row-name { font-size: 15px; font-weight: 500; }
.fs-row-sub { font-size: 12px; color: var(--muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fs-row-over .fs-row-name { color: var(--red); }
/* Disabled schedule row (non-primary, switched off by a premium downgrade):
   grayed via colour so the Premium badge stays readable; not expandable, but
   the ⋮ menu stays live so it can still be deleted. */
.fs-row-disabled .fs-row-name { color: var(--muted); }
.fs-row-disabled .premium-badge { margin-left: 8px; vertical-align: 1px; }
.fs-row-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.fs-row-actions .bank-menu-btn { padding: 4px 8px; font-size: 20px; }
/* Leads the row and flips UP when open — an expand, not a navigate. Same
   geometry and flip as .fsb-acc-caret on the Budget screen. */
/* ⚠️ A LEADING caret points IN when collapsed and DOWN when open (owner,
   2026-08-18: "If in front of the word it should point in not down... That's
   how we've been doing it? Like the group header?"). He is right, and the
   precedent is .pocket-group-toggle, which rotates -90deg while COLLAPSED off
   the same down-pointing SVG.
   ⚠️ This is NOT what .fsb-acc-caret does on the Budget screen — that one is
   down-when-collapsed, up-when-open. Two conventions exist; a LEADING caret
   follows the group header, the older and more common of the two. */
.fs-row-chevron {
  color: var(--muted); flex-shrink: 0; transition: transform 0.15s;
  transform: rotate(-90deg);
}
.fs-row-open .fs-row-chevron { transform: none; }
/* The row uses overflow:hidden so the expanded panel's bottom corners
   are clipped by the row's border-radius. That same clip would chop the
   per-row overflow-menu dropdown — release overflow only while the
   menu is open. */
.fs-row:has(.bank-menu.open) { overflow: visible; }
.fs-row-panel:not(:empty) {
  padding: 4px 20px 16px;
  /* No divider between a schedule's header and its expanded form — reads
     cleaner as one block, for both existing and new schedules (owner request). */
}

/* Delete-schedule button — quiet until hovered, lives at the foot of
   the expanded schedule form. */
.fs-delete-btn {
  width: 100%; margin-top: 12px;
  background: transparent; border: 1px solid var(--border);
  border-radius: 8px; padding: 10px;
  font: inherit; font-size: 13px; font-weight: 500;
  color: var(--red); cursor: pointer;
  transition: border-color 0.15s, background-color 0.15s;
}
@media (hover: hover) {.fs-delete-btn:hover { border-color: var(--red); background: rgba(248, 113, 113, 0.08); }}

/* ── Card label row with action button ── */
.card-label-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-label-row .card-label { margin-bottom: 0; }

/* ── Account tab section labels ── */
.acct-section-label {
  font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--muted); margin: 18px 0 8px; padding: 0 2px;
}
.acct-section-label:first-of-type { margin-top: 4px; }
/* Per-group account header: group name left, reorder (sort) button right,
   styled like the Transactions-page btn-sm buttons. */
.account-group-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.acct-group-sort-btn { padding: 6px 10px; margin: -4px 0; font-size: 12px; font-weight: 600; }

/* Per-group inline reorder: grips appear only in the group being reordered;
   only the grip is interactive there (so a stray tap can't open a menu), and
   disabled rows are shown ungrayed + in place (they sink back on exit). */
.account-drag-grip { display: none; }
.account-group-card.reordering .account-drag-grip {
  display: inline-flex; align-items: center; justify-content: center;
  flex: 0 0 auto; margin-right: 10px; color: var(--muted);
  cursor: grab; touch-action: none;
}
.account-group-card.reordering .account-row-info,
.account-group-card.reordering .account-row-right { pointer-events: none; }
/* With the grip inserted, let info grow so grip hugs the left and the balance
   stays on the right (the row is normally 2-child space-between). */
.account-group-card.reordering .account-row-info { flex: 1 1 auto; min-width: 0; }
/* Don't gray disabled accounts while sorting — you still want to see + drag them. */
.account-group-card.reordering .account-row-disabled .account-name,
.account-group-card.reordering .account-row-disabled .account-debt,
.account-group-card.reordering .account-balance.muted { color: var(--text); }
.account-row.reorder-dragging {
  position: relative; z-index: 5; border-radius: 10px;
  background: var(--surface2, var(--surface));
  box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}

/* ── GTG paycheck pill — lives inside .gtg-hero, mirrors the marketing
   site's preview pill so brand and product feel like one family. Pill
   shape (border-radius:999px), soft-accent fill, no full-width stretch
   since it's centered under the GTG amount, not part of an action row. */
.gtg-paycheck-btn {
  /* Column so the single-schedule shortfall line stacks centered BELOW
     the "Need $X on date" row. (It used to be a wrapping row where the
     over line forced a break via flex-basis:100% — but a percentage basis
     inside a shrink-to-fit inline-flex resolves against the wrong box and
     blew the pill's width out. Column drops the hack entirely.) The single
     need-row is itself an inline-flex (.gtg-paycheck-line), so its Need /
     amount / on cells still align on the baseline within that row. */
  display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
  font: inherit; font-size: 13px; font-weight: 600;
  /* Room for the caret, which is positioned rather than flowed - see below. */
  position: relative;
  color: var(--text);
  /* Matches the Insights range pill: a translucent wash rather than a solid
     surface, with a neutral border instead of the accent one (owner,
     2026-08-12). The short state below still overrides both, so the red
     signal is unaffected. */
  background: rgba(255, 255, 255, 0.07);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 999px;
  padding: 10px 34px 10px 18px;
  cursor: pointer; line-height: 1.2;
  white-space: nowrap;
  transition: border-color 0.15s, background 0.15s, color 0.15s;
}
/* ⚠️ The caret points RIGHT, not down: this pill opens a whole screen, where
   the range pill opens a menu beneath itself. Same mask technique so the two
   read as the same family of control; rotated rather than a second asset. */
/* ⚠️ ABSOLUTE, not a flex child. This pill is a COLUMN so the shortfall line
   can stack beneath the need row (see the note on the rule above); a flowed
   ::after would stack under BOTH. Positioned, it also stays vertically centred
   on the pill as a whole when that second line appears - the same reason the
   stacked group header had to become a grid. */
.gtg-paycheck-btn::after {
  content: '';
  position: absolute; right: 14px; top: 50%;
  width: 12px; height: 8px;
  background-color: var(--muted);
  transform: translateY(-50%) rotate(-90deg);
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 1 6 7 11 1'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 1 6 7 11 1'/%3E%3C/svg%3E");
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
}
/* Same hover as the Insights range pill: the wash simply gets brighter. The old
   rule swapped to var(--surface), which reads as dark grey against the
   translucent resting state (owner, 2026-08-12), and pulled in the accent
   border the pill no longer uses. */
@media (hover: hover) {.gtg-paycheck-btn:hover {
  background: rgba(255, 255, 255, 0.13);
}}
.gtg-paycheck-btn[disabled] { cursor: default; }
.gtg-paycheck-btn strong { color: var(--text); font-weight: 700; }
.gtg-paycheck-label { font-size: 12px; font-weight: 500; color: var(--muted); }
.gtg-paycheck-btn.muted { color: var(--muted); background: transparent; border-color: var(--border); }
/* ⚠️ THE MULTI-SCHEDULE GRID IS GONE (owner, 2026-08-18). It laid the
   pill out as 4 columns so several stacked schedule lines could share an "on"
   column — and the pill shows only the NEXT paycheck now, so there was
   nothing left to align and its left-aligned cells stopped both the phrase
   and the verdict row under it from centring. A rule named
   .gtg-paycheck-btn-multi reappearing here means the stack is back. */
.gtg-paycheck-line {
  display: inline-flex; align-items: baseline; gap: 4px;
  white-space: nowrap;
  /* Single-schedule pill: the same cap, for the same reason. */
  max-width: 100%;
}
.gpl-on {
  display: inline-flex; align-items: baseline; gap: 4px;
  white-space: nowrap;
}
.gpl-need-label, .gpl-need-amt, .gpl-name { white-space: nowrap; }
/* ⚠️ The schedule name TRUNCATES; nothing else on this pill does. A name is
   user-typed and unbounded, where the amount and the date are the answer the
   pill exists to give. `min-width: 0` is what allows it inside both the flex
   line and the grid cell — without it the box refuses to shrink below its
   content and the ellipsis never engages. */
.gpl-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* Shortfall line inside the Need pill — how far this pay period comes up
   short once both the design gap and the Good to Go catch-up are counted. ONE
   line for the whole pill (the figure is a whole-budget one), red to match the
   pill's wash. Single-schedule: the button is flex-column, so this just centers
   on its own row. Multi (grid): span every column so it sits under the last
   schedule's row, left-aligned with "Need". */
.gpl-short {
  text-align: center;
  color: var(--red);
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
}


/* ── Two-column field row (e.g. Stashed / Goal) ── */
.field-row-2col { display: flex; gap: 12px; }
.field-row-2col > .field-group { flex: 1; min-width: 0; }
/* ⚠️ A Savings Goal's THIRD money field ALWAYS takes its own row (owner,
   2026-08-14: "it doesn't look bad there, and it saves us the squished and
   changing row issue"). Pocketed and Fund Amount share the top line; Goal
   Amount spans below them at every width.
   Three-up was tried and is gone. It never fitted a phone anyway — an
   8-character amount ("50000.00") needs a 105px input, a third of a 320px
   screen is 85px, and the Pocketed field additionally gives 36px to its funds
   overflow — so the row restacked on every real device and the only thing the
   breakpoint bought was a different layout on a tablet. What it COST was two
   bugs: with wrapped flex the decision came from INTRINSIC widths, so a
   focused empty field measured differently from a filled one and the row
   re-flowed under the user's finger and stayed reflowed; and the Pocketed
   field needed a raised floor to keep its amount clear of the button, which
   left the three uneven. One fixed layout has neither, and needs no floors:
   equal grid tracks size the top two by construction.
   The class is written by _pmRenderAmountLabels rather than matched with :has
   on the inline style — the markup says `display:none;` with no space, so a
   `[style*="display: none"]` test silently never matches. */
.field-row-2col.has-goal {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;
}
.field-row-2col.has-goal > #pm-goal-group { grid-column: 1 / -1; }
/* ⚠️ The estimated completion date is a ROW INSIDE the insights block
   (_pmRenderSpendAvg), not an element of its own — it used to be a standalone
   line under the amounts and the owner moved it in so the figures form one
   column. There is deliberately no .pm-goal-eta rule left; if one reappears,
   something is writing to a second element again.
   A row whose amount is empty collapses to one full-width line, which is what
   the "Goal reached" sentence needs. */
.pm-spend-avg-row .pm-spend-avg-amt:empty { display: none; }

/* ── Funding line ("$30 auto-funds on 7/15") ──
   Right-aligned on the pocket-card meta row (margin-left:auto). Main text
   color for readability against the dark card; behind state goes red. The
   paycheck DATE carries the "when," so there's no italic/next-cycle
   variant here anymore — a funded-ahead pocket just shows a later date. */
/* ⚠️ No `margin-left: auto` — .pocket-meta uses space-between instead, which
   is what lets this line sit LEFT-aligned once it wraps to a row of its own
   rather than staying pinned to the right edge. */
.meta-per-paycheck { color: var(--muted); font-weight: 700; white-space: nowrap; flex-shrink: 0; }
/* Amount + date read as primary (--text); the "Auto-Funds" word between them
   stays muted (inherits the base color above). */
.meta-per-paycheck .mpp-strong { color: var(--text); }
.meta-per-paycheck.behind, .meta-per-paycheck.behind .mpp-strong { color: var(--red); }

/* ── Behind badge + card ── */
.badge-behind { background: rgba(248,113,113,0.15); color: var(--red); border-color: rgba(248,113,113,0.4); }
.pocket-card.behind { border-color: rgba(248,113,113,0.3); }
/* Canceled subscription — yellow (user acted; not an error state). */
.badge-canceled { background: rgba(250,204,21,0.12); color: var(--yellow); border-color: rgba(250,204,21,0.45); }
/* Active subscription — green. */
.badge-active { background: rgba(74,222,128,0.12); color: var(--green); border-color: rgba(74,222,128,0.35); }
@media (hover: hover) { .pocket-card.behind:hover { border-color: var(--red); } }

/* ── Credit-card pocket: gold border + card-name pill ── */
/* ⚠️ The gold marks CREDIT CARD INTEGRATION BEING ON, not the link existing
 * (owner, 2026-08-15). Both are written from one flag, `ccBadgeActive` in
 * renderPockets, which needs a linked credit account that is enabled AND has
 * cc_fund_from_gtg. The gold is saying "this pocket fills ITSELF — every charge
 * on the card sets its own repayment aside", which is genuinely unlike any
 * other pocket. With integration off it is an ordinary scheduled bill that
 * happens to name a card, and it looks like one.
 * ⚠️ The LINK is not hidden by that: the pocket keeps its Credit Card type
 * (_pocketTypeKey never consults cc_fund_from_gtg), so it still groups under
 * Credit Card Pockets and still carries the type badge in the budget lists, and
 * the pocket editor's insights block reports the card's balance either way.
 * Behind state still wins on color: a CC pocket whose target > 0 and is
 * underfunded gets the red treatment (behind class is applied alongside
 * pocket-card--credit but red border-color overrides gold). */
.badge-credit-card { background: var(--type-card-12); color: var(--type-card); border-color: var(--type-card-45); }
/* ⚠️ A Savings Goal is bordered ONLY when spending funds sweep into it (owner,
   2026-08-15). The border says "this pocket takes automatic DEPOSITS", which
   nothing else in the list does; a goal with no sources is a spending fund with
   a target on it and gets the badge alone. That condition is what keeps this
   from being the third bordered type that made the list read as a colour key
   when the border was tried unconditionally on 2026-08-14. */
.pocket-card--savings { border-color: var(--type-savings-45); }
@media (hover: hover) { .pocket-card--savings:hover { border-color: var(--type-savings); } }
.pocket-card--credit { border-color: rgba(250,204,21,0.45); }
@media (hover: hover) { .pocket-card--credit:hover { border-color: var(--yellow); } }
.pocket-card.behind.pocket-card--credit { border-color: rgba(248,113,113,0.3); }
@media (hover: hover) { .pocket-card.behind.pocket-card--credit:hover { border-color: var(--red); } }
/* The app-managed "Pockets by Hoopy Premium" bill pocket — brand-purple outline
 * marks it as our special subscription pocket (still a normal pocket otherwise).
 * Mirrors the credit-card treatment: muted border at rest, brighter on hover. */
.pocket-card--premium { border-color: var(--accent-dim); }
@media (hover: hover) { .pocket-card--premium:hover { border-color: var(--accent); } }
/* Savings Goal badge. Colour is --type-savings; see the token block for why
   this hue and not green.
   ⚠️ A Savings Goal card is bordered ONLY when spending funds sweep into it
   (.pocket-card--savings, above) — bordering every one made a third bordered
   card type after Credit Card and Premium and turned the list into a colour
   key. The badge is what marks the type; the border marks "this one takes
   automatic deposits".
   The badge colour briefly came from the user's own Savings CATEGORY, written
   inline by renderPockets; that worked and the owner preferred one colour of
   his choosing, so the inline style and its helper are gone rather than left as
   a dormant path. */
.badge-savings-goal {
  background: var(--type-savings-12); color: var(--type-savings);
  border-color: var(--type-savings-45);
}

/* ── Custom date picker field — text input IS the display ── */
/* Date field — input on the left (typeable), calendar-icon button on the
 * right (opens the picker). Mirrors .combo-wrap + .combo-toggle-btn
 * exactly so the date and combobox patterns read as siblings. The
 * !important on the input border-radius is required to win over the
 * generic input[type="text"] rule (matches the combobox approach). */
.dp-field-wrap { position: relative; display: flex; align-items: stretch; }
.dp-field-wrap .dp-input {
  flex: 1; min-width: 0;
  border-radius: 12px 0 0 12px !important;
  border-right: none !important;
  cursor: pointer;
}
.dp-field-wrap .dp-input:focus { border-color: var(--accent); box-shadow: none; }
.dp-field-wrap:focus-within .dp-input { border-color: var(--accent); }
.dp-field-wrap:focus-within .dp-cal-btn { border-color: var(--accent); }
.dp-cal-btn {
  background: var(--surface2); border: 1px solid var(--border);
  border-left: none;
  border-radius: 0 12px 12px 0; padding: 0 13px;
  cursor: pointer; display: flex; align-items: center;
  color: var(--muted); transition: color 0.15s, border-color 0.15s;
  flex-shrink: 0;
}
@media (hover: hover) {.dp-field-wrap:hover .dp-input,
.dp-field-wrap:hover .dp-cal-btn { border-color: var(--accent); }}
@media (hover: hover) {.dp-field-wrap:hover .dp-cal-btn { color: var(--accent); }}

/* ── Custom date picker panel — scaled up for readability ── */
#dp-panel {
  position: fixed; z-index: 3000;
  background: #2d2d3e; border: 1px solid rgba(255,255,255,0.12);
  border-radius: 16px; padding: 14px; width: 340px;
  box-shadow: 0 16px 48px rgba(0,0,0,0.65);
  overflow: hidden;
}
.dp-header {
  display: flex; align-items: center; justify-content: space-between;
  margin-bottom: 10px; gap: 4px;
}
.dp-nav {
  width: 42px; height: 42px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12);
  border-radius: 10px; color: var(--text); font-size: 18px;
  cursor: pointer; transition: background 0.15s, border-color 0.15s, color 0.15s;
  flex-shrink: 0;
}
@media (hover: hover) {.dp-nav:hover { background: rgba(255,255,255,0.14); border-color: var(--accent); color: var(--accent); }}
.dp-header .dp-nav:first-child { margin-right: 2px; }
.dp-header .dp-nav:last-child  { margin-left:  2px; }
.dp-title-group { display: flex; gap: 6px; flex: 1; justify-content: center; align-items: center; min-width: 0; }
.dp-title-btn {
  background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12);
  border-radius: 10px; color: #dde; font-weight: 700; font-size: 15px;
  padding: 8px 14px; cursor: pointer; font-family: inherit; line-height: 1;
  transition: background 0.15s, border-color 0.15s, color 0.15s;
}
@media (hover: hover) {.dp-title-btn:hover { background: rgba(255,255,255,0.14); border-color: var(--accent); color: var(--accent); }}
.dp-list {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
  max-height: 260px; overflow-y: auto; padding: 2px;
}
.dp-list-years { grid-template-columns: repeat(4, 1fr); }
.dp-list-item {
  height: 40px; display: flex; align-items: center; justify-content: center;
  border-radius: 9px; font-size: 14px; font-weight: 500; cursor: pointer;
  border: 1px solid transparent; background: transparent; color: #dde;
  font-family: inherit; padding: 0;
  transition: background 0.1s, border-color 0.1s;
}
@media (hover: hover) {.dp-list-item:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.15); }}
.dp-list-item.selected { background: var(--accent); border-color: var(--accent); color: #fff; }
.dp-weekdays {
  display: grid; grid-template-columns: repeat(7, 1fr);
  margin-bottom: 3px;
}
.dp-weekdays span {
  text-align: center; font-size: 11px; color: rgba(200,200,230,0.6);
  font-weight: 700; padding: 4px 0;
}
.dp-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; width: 100%; }
.dp-day {
  height: 40px; width: 100%;
  display: flex; align-items: center; justify-content: center;
  border-radius: 9px; font-size: 14px; font-weight: 500; cursor: pointer;
  border: 1px solid transparent; background: transparent; color: #dde;
  transition: background 0.1s, border-color 0.1s; padding: 0;
}
@media (hover: hover) {.dp-day:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.15); }}
.dp-day.focused { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
.dp-day.other-month { color: rgba(200,200,230,0.25); }
.dp-day.today { border-color: var(--accent); color: var(--accent); }
.dp-day.selected { background: var(--accent); border-color: var(--accent); color: #fff; }
.dp-day.today.selected { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Footer sits below the calendar grid; Clear empties the currently-open
   field from inside the picker so any date field in the app can be cleared. */
.dp-footer {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding-top: 10px; margin-top: 6px; border-top: 1px solid rgba(255,255,255,0.08);
}
/* Primary on the right, same as every other action row in the app. */
.dp-done-btn {
  padding: 7px 18px; border-radius: 8px; border: 1px solid var(--accent);
  background: var(--accent); color: #fff; font-size: 13px; font-weight: 600;
  cursor: pointer;
}
@media (hover: hover) { .dp-done-btn:hover { filter: brightness(1.08); } }
/* The month swipe owns horizontal drags on the grid; the pager opt-out is the
   `no-swipe` class in the markup, since that gesture is JS on `window` and no
   CSS property reaches it. Vertical panning is left alone so the panel can
   still scroll inside a short viewport. */
.dp-days { touch-action: pan-y; }
/* The grid slides under the finger and settles back. Transform only — the
   panel's own box never moves, so nothing reflows mid-gesture. */
.dp-days.dp-sliding { transition: none; }
.dp-days:not(.dp-sliding) { transition: transform 0.18s ease-out; }
.dp-clear-btn {
  background: transparent;
  border: none;
  padding: 6px 8px;
  color: var(--muted);
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  border-radius: 6px;
  transition: color 0.15s, background 0.15s;
}
@media (hover: hover) {.dp-clear-btn:hover { color: var(--red); background: rgba(248,113,113,0.08); }}


/* ── Unified combobox: text input + caret toggle button ── */
.combo-wrap { position: relative; display: flex; align-items: stretch; }
.combo-wrap input {
  flex: 1; min-width: 0;
  border-radius: 12px 0 0 12px !important;
  border-right: none !important;
}
.combo-wrap input:focus { border-color: var(--accent); box-shadow: none; }
.combo-wrap:focus-within input { border-color: var(--accent); }
.combo-toggle-btn {
  background: var(--surface2); border: 1px solid var(--border);
  border-radius: 0 12px 12px 0; padding: 0 13px;
  cursor: pointer; display: flex; align-items: center;
  color: var(--muted); transition: color 0.15s, border-color 0.15s;
  flex-shrink: 0;
}
@media (hover: hover) {.combo-toggle-btn:hover { border-color: var(--accent); color: var(--accent); }}
.combo-wrap:focus-within .combo-toggle-btn { border-color: var(--accent); }
.combo-caret { transition: transform 0.2s; }
.combo-toggle-btn.open .combo-caret { transform: rotate(180deg); }

/* Action button beside the category combo: "Edit" when the field holds a real
   category (opens the rename modal), "Add" when the typed name isn't one yet
   (opens create, prefilled). Replaces the old in-dropdown "Add New Category"
   row. Hidden when the field is empty. */
.combo-with-edit { display: flex; align-items: stretch; gap: 8px; }
.combo-with-edit .combo-wrap { flex: 1; min-width: 0; }
.combo-action-btn {
  flex-shrink: 0; min-width: 62px;
  background: var(--surface2); border: 1px solid var(--border);
  border-radius: 12px; padding: 0 16px;
  cursor: pointer; display: flex; align-items: center; justify-content: center;
  color: var(--muted); font-size: 13px; font-weight: 600; white-space: nowrap;
  transition: color 0.15s, border-color 0.15s;
}
@media (hover: hover) {.combo-action-btn:hover { border-color: var(--accent); color: var(--accent); }}

/* Tx type filter pills */
.tx-type-pills { display: flex; gap: 6px; }
.tx-type-pill, .tx-acct-type-pill {
  flex: 1; padding: 6px 8px; border-radius: 20px; font-size: 12px; font-weight: 600;
  background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
  cursor: pointer; transition: all 0.15s; text-align: center;
}
@media (hover: hover) {.tx-type-pill:hover, .tx-acct-type-pill:hover { border-color: var(--accent); color: var(--accent); }}
.tx-type-pill.active, .tx-acct-type-pill.active { background: var(--accent); border-color: var(--accent); color: #fff; }

/* ── Spending Hero (Transactions tab) ──────────────────────────────────────
   Last 30 days of spending as a donut, one slice per pocket category, plus
   a "From GTG" slice for unlinked transactions (transfers are excluded from
   that slice). No legend, no labels — hovering a slice slides it outward,
   dims the others, and the center shows category + amount + %. Clicking
   commits the selection and filters the transactions list below. */
.gtg-spend-hero-wrap {
  margin: 0 0 20px;
  padding: 0;
  border-radius: var(--radius);
  /* Matches .gtg-hero: purple-jewel base + brand-purple glows in both corners
     (UL + LR), no gold. CSS gradients (no image-fetch delay). */
  background:
    radial-gradient(circle at top left, rgba(195, 183, 255, 0.42) 0%, rgba(195, 183, 255, 0.18) 40%, rgba(195, 183, 255, 0.05) 62%, transparent 85%),
    radial-gradient(circle at bottom right, rgba(195, 183, 255, 0.42) 0%, rgba(195, 183, 255, 0.18) 40%, rgba(195, 183, 255, 0.05) 62%, transparent 85%),
    linear-gradient(135deg, #2c2270 0%, #171142 100%);
  border: 1px solid #4a4470;
  box-shadow: 0 10px 32px -20px rgba(0, 0, 0, 0.55);
}
.gtg-spend-hero {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  /* Containing block for the hover bubble, which lives here rather than in the
     bar stage so a scrolling or clipping stage can't cut it off. */
  position: relative;
}
.gtg-spend-heading {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  text-align: center;
}
.gtg-spend-title {
  font-size: 22px;
  font-weight: 800;
  letter-spacing: -0.6px;
  line-height: 1.15;
  color: #ffffff;
}
.gtg-spend-subtitle {
  /* ⚠️ 13px, matching the category pill beside it and the summary pill below —
     ONE size for every pill on this card (owner, 2026-08-16). It was 12px, from
     when this was a caption under the title rather than a control in a row of
     them, and two pills side by side at different sizes read as two kinds of
     thing. The `min-height: 30px` in `.mg-pill-group` was covering for exactly
     this mismatch and now has nothing to reconcile. */
  font-size: 13px;
  color: var(--muted);
  letter-spacing: 0.02em;
  line-height: 1.4;
}
.gtg-spend-summary {
  font-size: 13px;
  color: var(--text);
  letter-spacing: 0.01em;
  line-height: 1.4;
  margin-top: 2px;
  font-weight: 500;
}
/* The average (or the total — the row toggles), above the chart. In --text,
   not --muted: it's the figure the card is for, and dimming it made it read as
   a footnote. */
/* The stack and the "?" side by side. NO flex-wrap: the "?" is a sibling of
   the lines now (so it can centre against both), and wrapping is what let it
   land on a line of its own back when it was one. */
.gtg-spend-summary .mg-sum-row {
  display: flex;
  align-items: center;
  justify-content: center;
}
/* ⚠️ ONE PILL FOR BOTH FIGURES AND THE SWAP ICON (owner, 2026-08-16), now that
   it sits in the controls row beside two icon buttons. Loose text there read as
   a caption that had drifted into a toolbar; a pill says it is one object, and
   it is exactly what the tap target already was — the whole row has always
   flipped the mode, the icon being the hint rather than the only target.
   Same `--mg-pill-*` tokens as the range and category pills, so the card has
   ONE pill rather than a second thing that nearly matches. */
.mg-action-row .gtg-spend-summary {
  padding: 5px 12px; border-radius: 999px;
  background-color: var(--mg-pill-bg);
  border: 1px solid var(--mg-pill-border);
}
@media (hover: hover) {
  .mg-action-row .gtg-spend-summary.is-toggle:hover { background-color: var(--mg-pill-bg-hover); }
}
/* ⚠️ The swap icon's negative block margins exist to keep its 7px hit padding
   out of the line box. Inside a pill that also means it cannot push the pill
   taller than its text, which is what keeps this the same height as the two
   icon buttons either side. */
/* ⚠️ ONE ROW WHERE IT FITS, wrapping to two where it doesn't (owner,
   2026-08-16). This REVERSES the always-a-column rule of 2026-08-11, and the
   reason it can be reversed is that the heading no longer changes with the
   selection: it always reports the window total, and the selected slice is read
   in the donut's centre instead. The old objection — that two figures side by
   side invite you to compare them as a pair — was about a heading that reported
   one category next to whole-window income, which genuinely wasn't comparable.
   Spend and In over the same window are.
   ⚠️ The 2-line `min-height` reservation goes with it, and only because of the
   same change: it existed because selecting a category dropped the "In" half
   and the heading shrank by a line mid-interaction, shoving the whole chart up
   the page. Nothing removes a part any more, so the height now varies only with
   VIEWPORT WIDTH, which nothing on screen moves in response to.
   ⚠️ The column-gap is wide on purpose — there is no separator between the two
   parts, so the gap is the only thing keeping "…$2,962.14 Average In…" from
   reading as one run.
   ⚠️ CENTRED, not flex-start (owner, 2026-08-16 — it was briefly flex-start and
   he asked for it back). The pill shrinks to its content, so on ONE line there
   is no free space and the two are identical; the difference is the WRAPPED
   case, where flex-start hangs the shorter second line off the left edge of a
   centred pill and centring keeps the two halves balanced under each other. */
.gtg-spend-summary .mg-sum-lines {
  display: flex;
  flex-flow: row wrap;
  align-items: center;
  justify-content: center;
  column-gap: 16px;
  row-gap: 1px;
}
/* ⚠️ Both halves are pinned to the pill's own size, and the lead word with them.
   Nothing was sizing them differently — the two parts are built from one
   template and render identical markup — but the size was only ever inherited,
   so any rule that ever lands on one of these would silently split the row.
   Declared, it can't. */
.gtg-spend-summary .mg-sum-part,
.gtg-spend-summary .mg-sum-mode { font-size: inherit; }
.gtg-spend-summary .mg-sum-part { white-space: nowrap; }
/* Only when there's a second figure to flip to (see _mgRenderSummary). */
.gtg-spend-summary.is-toggle .mg-sum-row { cursor: pointer; }
/* The leading word — "Average" — opens the explainer, and the dotted underline
   is what says so. It replaced a `.info-q` "?" parked at the end of the line:
   the word is the thing being asked about, so marking it directly reads better
   than a glyph a phrase away, and it leaves the end of the row for the swap
   control. `text-underline-offset` keeps the dots off the descenders. "Total"
   renders as plain text — no arithmetic, nothing to explain. */
/* ⚠️ The rule is a BACKGROUND, not `text-decoration: underline dotted`.
   A decoration with `text-underline-offset` paints OUTSIDE the text's content
   box — ink overflow — and this row is exactly where that goes wrong: selecting
   a category drops the "In" line, and the surviving line re-centres inside the
   reserved two-line box, so the vacated strip is only repainted if the damage
   rect covers that overflow. It didn't always, and a ghost dotted line was left
   behind where the second line's underline had been (owner, 2026-08-12 — seen
   in both average and total mode, intermittent, not reproducible on demand).
   A background paints inside the element's own box and invalidates with it, so
   the failure mode doesn't exist. Vertical padding on an INLINE element does not
   affect the line box, so the 3px offset costs no height. */
.gtg-spend-summary .mg-sum-mode {
  cursor: pointer;
  padding-bottom: 3px;
  background-image: linear-gradient(to right, var(--muted) 50%, transparent 50%);
  background-repeat: repeat-x;
  background-size: 4px 1px;
  background-position: left bottom;
}
@media (hover: hover) {
  .gtg-spend-summary .mg-sum-mode:hover {
    background-image: linear-gradient(to right, var(--text) 50%, transparent 50%);
  }
}
/* Swap control: average ⇄ total. Same 22px box as the `.info-q` it sits where,
   so the row's right end keeps its weight and rhythm. Icon-only and functional
   — it names an action the numbers can't state about themselves. Centred
   against BOTH lines by .mg-sum-row's align-items, not hung off one of them. */
.gtg-spend-summary .mg-sum-swap {
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  box-sizing: content-box;
  width: 22px; height: 22px; padding: 7px; margin: -7px 0 -7px 5px;
  flex: 0 0 auto;
  color: var(--muted);
  cursor: pointer;
  border-radius: 50%;
}
.gtg-spend-summary .mg-sum-swap::before {
  content: ''; position: absolute; inset: 7px;
  border: 1px solid var(--muted); border-radius: 50%;
}
@media (hover: hover) {
  .gtg-spend-summary .mg-sum-swap:hover { color: var(--text); }
  .gtg-spend-summary .mg-sum-swap:hover::before { border-color: var(--text); }
}
.gtg-spend-pie-stage {
  position: relative;
  width: 260px;
  height: 260px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.gtg-spend-pie-svg { display: block; overflow: visible; }
.gtg-pie-slice {
  cursor: pointer;
  /* transform is here for the scrub's pull-out (below). Shorter than the other
     two so the wedge arrives before the dim finishes settling — the movement is
     what you're meant to notice. */
  transition: opacity 0.2s, filter 0.2s, transform 0.13s;
  will-change: opacity, filter;
}
/* ── Picked out: SELECTED, SCRUBBED or HOVERED ────────────────────────────
   All three answer the same question — "this is the slice in question" — and
   all three get the SAME treatment: the wedge glows in its own colour and stays
   exactly where it is, while the rest dim.

   ⚠️ It does NOT slide out of the pie, except under a finger mid-drag (owner,
   2026-08-12: "the slices popping out is annoying me, but they are useful when
   dragging your finger over the donut"). The pull was written as a touch fix
   and then applied to all three; on a pointer it is movement nobody needs,
   because a cursor is not covering the thing it points at.

   ⚠️ SELECTION and SCRUB are unconditional, NOT inside the hover query below.
   They used to be, which meant a PHONE got no feedback at all: tapping a slice
   filtered the list but nothing glowed and nothing dimmed. Selecting is not
   hovering, and a phone is the only thing that scrubs — neither belongs in a
   hover query. */
/* ⚠️ NO GLOW (owner, 2026-08-15). This carried a `drop-shadow` tinted to the
   slice's own colour, and it was never the thing doing the work: the signal is
   everything ELSE dropping to 0.28 opacity, which is a far bigger contrast than
   a 3px halo could add. On a chart made of adjacent saturated wedges the halo
   also bled onto its neighbours, so the lit slice read as slightly smudged
   rather than slightly brighter. The brightness lift stays — it is what marks
   the slice itself rather than marking the gap around it. */
.gtg-pie-slice.is-selected,
.gtg-pie-slice.is-pointed {
  filter: brightness(1.08);
}
.gtg-spend-pie-stage.has-focus .gtg-pie-slice:not(.is-selected) { opacity: 0.28; }

/* THE PULL, and the ONE case that earns it: a finger is down and dragging.
   `.is-scrubbing` is written by _mgSetScrubbing on a non-mouse pointerdown, so
   it can't be reached by a hover or by a resting selection.

   Why it earns it here and only here: the thumb doing the scrubbing SITS ON the
   slice it is highlighting, so the highlight is under the hand that made it,
   and movement at the wedge's EDGES reads either side of a thumb where a
   brightness change in the middle cannot. Units are the SVG's own so the pull
   scales with the donut, and `overflow: visible` keeps the outer edge from
   clipping. --pull-x / --pull-y are written per slice at render time.

   ⚠️ ONE WEDGE OUT AT A TIME. A selection no longer pulls at rest, so there is
   nothing to drop back in — which is what the (0,5,0) `.is-selected:not(
   .is-pointed) { transform: none }` rule used to be for. Gone with the cause. */
.gtg-spend-pie-stage.is-scrubbing .gtg-pie-slice.is-pointed {
  transform: translate(var(--pull-x, 0), var(--pull-y, 0));
}

/* ⚠️ There are NO `:hover` rules for slices, deliberately. A mouse drives the
   same `.is-pointed` class a finger does, set from the angle test in
   _ensureDonutScrub. `:hover` asks "is the cursor over this path", and while
   the pull applied to a hover it MOVED the path — park the cursor near a
   boundary and the slice slid out from under it, the neighbour took the hover,
   which put the first one back, and the centre readout flickered between two
   figures. The pull is gone from hover now, but the angle test stays: it is
   what makes a 2% wedge as easy to land on as a 40% one.

/* The scrub dims everything else the way a selection does. This last rule
   outranks the has-focus dim (both 0,4,0, this one later), so the slice under
   the finger stays lit even while a DIFFERENT category is selected. */
.gtg-spend-pie-stage.is-pointing .gtg-pie-slice:not(.is-pointed) { opacity: 0.28; }
.gtg-spend-pie-stage.is-pointing .gtg-pie-slice.is-pointed { opacity: 1; }
/* The gesture owns drags that start on the donut: a scrub runs in every
   direction round the ring, so leaving the browser any pan axis would hand it
   half the circle. Scoped to the SVG alone — the card's heading, its controls
   row, the space beside the donut and the whole transactions list below all
   scroll normally, and in fullscreen the page doesn't scroll at all. */
/* ⚠️ The callout/selection suppression is what makes the scrub work ON iOS at
   all. The gesture is a press-and-hold, which is exactly what WebKit reads as
   "select this / show me the callout menu" — so without these the hold can pop
   a system menu or start a text selection and take the gesture away mid-drag.
   Android never does this, so it can't be caught on the owner's own device.
   Same set `.tx-card` and `.mc-grip` already carry for their long-press. */
.gtg-spend-pie-svg {
  touch-action: none;
  -webkit-touch-callout: none;
  -webkit-user-select: none; user-select: none;
  /* ⚠️ The ROOT is not a hit target — `.mg-pie-hit`, the transparent circle
     _renderGtgSpendPie draws first, is the only thing in here that is. An
     <svg> is a square around a round drawing, so its corners are ~21% of the
     box and all of them empty; leaving them targetable meant `touch-action`
     and `.no-swipe` above applied there too, and a swipe starting in the
     purple neither scrolled the page nor changed tabs (owner, 2026-08-12).
     Both properties still apply over the donut, because the circle's ancestor
     chain runs through here. The wedges stay non-targets as well: every hit
     test in this chart is by ANGLE, never by path, since a pulled-out wedge
     moves. */
  pointer-events: none;
}
/* The cursor comes from whatever is hit-tested, and that's this circle now —
   without it the donut lost its pointer cursor on desktop. */
.gtg-spend-pie-svg .mg-pie-hit { pointer-events: fill; cursor: pointer; }
/* The hole flips Average ⇄ Total; it is built in `_renderGtgSpendHero`, which
   draws the donut inline (there is no `_renderGtgSpendPie`). The SVG root is
   `pointer-events: none`, so like the outer hit circle this needs its own. */
.gtg-spend-pie-svg .mg-pie-hole-hit { pointer-events: fill; cursor: pointer; }
/* Kill Chrome-on-Android's tap highlight over both charts. It paints over the
   tapped element's BOUNDING BOX, so tapping a curved wedge flashed a grey
   rectangle across the donut (owner, 2026-08-12) — a shape that belongs to
   nothing on screen. The charts have their own press feedback (the wedge pulls
   out, the segment lifts), so nothing is lost. Inherited, so setting it on each
   SVG covers every slice, segment and hit-rect inside it. Same treatment
   `.cat-trigger`, `.wiz-tip-dot` and `.tag-token` already get. */
.gtg-spend-pie-svg,
.gtg-spend-bar-svg { -webkit-tap-highlight-color: transparent; }
/* Set by _mgApplyDonutCenterFit when the ring is too small to hold its own
   readout — see there for why it hides rather than shrinks. */
.gtg-spend-pie-center.mg-center-hidden { display: none; }
.gtg-spend-pie-center {
  position: absolute;
  pointer-events: none;
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  max-width: 150px;
  transition: color 0.15s;
}
.gtg-spend-pie-center-label {
  font-size: 11px;
  /* --text, not --muted (owner, 2026-08-12). Unconditional rather than scoped
     to a selection: this line shows a category name during a SCRUB as well, and
     a colour that keyed on `.has-focus` would leave a scrubbed-but-unselected
     name dim while a selected one was bright — the same words in two colours
     depending on state you can't see. "All Categories" brightens with it, which
     is the same reason the share-of-total line sits at --muted rather than
     lighter: this text is over a busy donut and wants contrast. */
  color: var(--text);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  line-height: 1.2;
  max-width: 140px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gtg-spend-pie-center-amount {
  font-size: 26px;
  font-weight: 700;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  line-height: 1.05;
}
.gtg-spend-pie-center-sub {
  font-size: 12px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}


/* Share-of-total, a row below the sub-line, at the SAME weight as it. It was
   dimmed to protect the reading order, but this text sits over a busy donut and
   anything lighter than --muted stops being legible there. */
.gtg-spend-pie-center-pct {
  font-size: 12px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  margin-top: 1px;
}

/* Overflow menu on a category group header: last element in the row, PAST the
   average and auto-fund lines, so it sits at the true right edge of the card
   rather than between the label and the figures. The figures block takes the
   free space; this just needs to not shrink. */
.pocket-group-menu { margin-left: 6px; flex-shrink: 0; align-self: center; }
/* ⚠️ ONE-ROW ONLY — hence the `:not(.gh-stacked)`. This pushes the figures to
   the right end of a single-row header, which is right there and wrong the
   moment the header restacks: on two rows the figures get a row of their own
   under the name and belong at its LEFT edge, lined up with the name above
   them (owner, 2026-08-15).
   ⚠️ It already had a competing `margin-left: 0` in the `.gh-stacked` block —
   at IDENTICAL specificity (0,2,0) and EARLIER in the file, so this one won on
   source order alone and the figures sat right-aligned on their own row. Same
   trap the chart layout was rebuilt to remove. Excluding the case here beats
   inflating the other rule's specificity: the two no longer overlap at all, so
   neither can win the wrong way whatever order they end up in. */
.pocket-group-header:not(.gh-stacked) .pocket-group-figures { margin-left: auto; }
.pocket-group-menu .bank-menu-btn { opacity: 0.6; padding: 2px 6px; }
@media (hover: hover) { .pocket-group-menu .bank-menu-btn:hover { opacity: 1; } }
.pocket-group-menu .menu-item .premium-badge { margin-left: 8px; }

/* ── Welcome flow ──
   Three full-screen fade-in screens shown right after signup. Sits above
   every other page (z-index 9000) and dismisses when the flow completes.
   Animations use CSS keyframes with staggered delays so each line lands
   on its own beat — the user can also tap anywhere to skip ahead. */
.welcome-overlay {
  position: fixed; inset: 0; z-index: 9000;
  background: var(--app-backdrop);
  display: flex; flex-direction: column; align-items: center;
  padding: 8vh 20px 24px; overflow-y: auto;
  /* The pinned bars' backing is 100vw (see .wiz-actions::before). With a
     desktop scrollbar present that exceeds the client width, so clip it rather
     than let it produce a horizontal scrollbar. `hidden` not `clip` for
     universal support; fixed-position descendants (the back / ✕ buttons) are
     unaffected — this element sets no containing block for them. */
  overflow-x: hidden;
  cursor: pointer;
}

/* ── Consent gate ─────────────────────────────────────────────────────────
 * Full-screen blocking overlay shown after sign-in when the account has not
 * recorded Terms/Privacy agreement. Sits above the welcome overlay so no auth
 * path can slip past it. */
.consent-overlay {
  /* Sits over the revealed onboarding/app surface. Boot hides the splash and
     reveals that surface BEFORE showing this gate (see applySession), so the
     splash is never in the way and this doesn't need to out-stack it. */
  position: fixed; inset: 0; z-index: 9500;
  background: var(--app-backdrop);
  display: flex; align-items: center; justify-content: center;
  padding: 24px; overflow-y: auto;
}
.consent-card {
  width: 100%; max-width: 420px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 16px; padding: 28px 24px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.consent-title { font-size: 20px; font-weight: 700; color: var(--text); margin: 0 0 12px; }
.consent-body { font-size: 15px; line-height: 1.5; color: var(--muted); margin: 0 0 18px; }
.consent-body a { color: var(--accent); text-decoration: none; font-weight: 500; }
.consent-actions { display: flex; gap: 12px; }
.consent-actions button { flex: 1; }

/* ── Bank-connect "getting your account ready" screen ─────────────────────────
 * Standalone full-screen process view shown after linking a NEW bank from the
 * connections screen (outside onboarding). Same backdrop as the welcome/consent
 * overlays; sits above app modals (z-index:300) so it covers the connections
 * modal it was launched from. Waits on onboarding_ready(), then flips to success. */
.conn-sync-overlay,
.setup-overlay {
  position: fixed; inset: 0; z-index: 9600;
  background: var(--app-backdrop);
  display: flex; align-items: center; justify-content: center;
  padding: 24px; overflow-y: auto;
}
.conn-sync-card {
  width: 100%; max-width: 400px; text-align: center;
  display: flex; flex-direction: column; align-items: center; gap: 16px;
}
.conn-sync-spinner-lg { width: 40px; height: 40px; }
.conn-sync-title { font-size: 24px; font-weight: 700; letter-spacing: -0.01em; color: var(--text); margin: 0; }
.conn-sync-sub { color: var(--muted); font-size: 16px; line-height: 1.55; max-width: 340px; margin: 0; }
.conn-sync-actions { display: flex; flex-direction: column; gap: 10px; width: 100%; margin-top: 8px; }
.conn-sync-hide {
  position: absolute; top: calc(14px + var(--safe-top, 0px)); right: 16px;
  background: none; border: none; color: var(--muted); font-size: 14px;
  padding: 8px 10px; cursor: pointer; width: auto;
}

/* ── Setup wizard ─────────────────────────────────────────────────────────
 * Reuses the welcome-overlay backdrop but behaves like a full-page flow:
 * content centered, action buttons pinned to the bottom (Accept on the right). */
.welcome-overlay.wiz-mode {
  cursor: default; padding: 0; align-items: stretch;
  /* The OVERLAY does not scroll in wizard mode — .wiz-body does (see below).
     That's what keeps content from passing behind the pinned buttons, so they
     need no fade. The plain welcome carousel (no .wiz-mode) still scrolls
     itself. */
  overflow: hidden;
  /* Persistent onboarding background: sit ABOVE the app (so it's never visible
     mid-flow) but BELOW modals (z-index:300), so the reused full-screen pages —
     schedule form, Try-Plaid, recommended pockets — float OVER this clean
     welcome background instead of over the app. Teller/Plaid iframes use a much
     higher z-index, so they still cover everything during the connect step. */
  z-index: 290;
}

/* ── Onboarding skin for the reused modal pages ───────────────────────────
 * During setup, the funding-schedule and recommended-pockets modals are
 * repainted with the wizard gradient (opaque, hiding the wiz screen behind),
 * stripped of their floating-card chrome, and given a numbered step eyebrow —
 * so they read as wizard pages, not separate popups. Gated on .wiz-skin,
 * added by _wizSkinModal only while the wizard is active. */
.modal-overlay.wiz-skin {
  background: var(--app-backdrop);
}
.modal-overlay.wiz-skin > .modal-box {
  background: transparent; border: none; box-shadow: none; border-radius: 0; max-width: 460px;
}
/* The skinned modal's native header (title + ✕) is dropped — the shared
   .wiz-header is injected instead. */
.modal-overlay.wiz-skin .modal-header { display: none; }
/* ── Shared wizard header bar: back (left) · step label (center) · ✕ (right).
   Used by both the native wizard screens and the reused (skinned) modals so
   every screen's chrome reads identically. */
.wiz-header {
  width: 100%; max-width: 440px; margin: 0 auto; flex: 0 0 auto;
  display: flex; align-items: center; gap: 8px;
  padding: calc(12px + env(safe-area-inset-top, 0px)) 18px 0;
}
/* Desktop: the onboarding column is narrow but the screen isn't — push the
   back / ✕ out to roughly frame the (wider, bleeding) card carousel so the
   header doesn't read like a squished mobile bar. Applies to every wizard
   screen (native + skinned modals). */
@media (min-width: 860px) {
  .wiz-header { max-width: 680px; }
}
.wiz-header .wiz-step-label, .wiz-header .wiz-header-mid {
  flex: 1; min-width: 0; justify-content: center; margin: 0;
}
/* Step label shrinks to fit between the buttons so it never wraps. */
.wiz-header .wiz-step-label { font-size: clamp(13px, 3.8vw, 16px); white-space: nowrap; }
.wiz-header .wiz-step-name { overflow: hidden; text-overflow: ellipsis; }
.wiz-hbtn, .wiz-hbtn-spacer { width: 36px; height: 36px; flex: 0 0 auto; }
.wiz-hbtn {
  border-radius: 50%; padding: 0; cursor: pointer;
  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15);
  color: var(--muted); font-size: 20px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  transition: color 0.15s, border-color 0.15s;
}
@media (hover: hover) {.wiz-hbtn:hover { color: var(--text); border-color: var(--accent); }}
.wiz-hbtn-ico { width: 17px; height: 17px; display: block; }
/* Funding schedule in onboarding = setting up the FIRST schedule, so the
   accordion chrome (row header, chevron, per-row overflow ⋮) and the
   "+ Add Schedule" button are noise. Show just the form. */
.modal-overlay.wiz-skin #fs-add-btn,
.modal-overlay.wiz-skin .fs-row-header { display: none; }
.modal-overlay.wiz-skin .fs-row { border: none; border-radius: 0; margin: 0; overflow: visible; }
.modal-overlay.wiz-skin .fs-row-panel:not(:empty) { padding: 0; border-top: none; }
/* The projected-surplus banner is basically the bank balance — a lie at this
   point — and Fund Now has no place during setup. Hide both. */
.modal-overlay.wiz-skin #pc-budget-warn,
.modal-overlay.wiz-skin #pc-fund-now-btn { display: none !important; }
/* Pin the form's action row to the bottom of the PAGE like the wizard's continue
   buttons. Fixed (not sticky) so it sits at the viewport bottom even when the
   form is short; the modal-body gets bottom padding so nothing hides behind it. */
.modal-overlay.wiz-skin #pc-action-row {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 9002;
  max-width: 460px; margin: 0 auto;
  padding: 16px 22px calc(16px + env(safe-area-inset-bottom, 0px));
}
/* The ONE bar that still needs a backing. Unlike the wizard's own footers this
   one can't sit outside the scroll box — it's nested deep inside the schedule
   form, so pinning it means position:fixed, and the form genuinely scrolls
   under it. Full-bleed (it's fixed, so no ancestor clips it) with a constant
   24px ramp, so it never reads as a rectangle the width of the button and
   never grows with the row. If this is ever moved to be a direct child of
   .modal-box, delete this and let it clip like the others. */
.modal-overlay.wiz-skin #pc-action-row::before {
  content: ''; position: absolute; z-index: -1; pointer-events: none;
  left: 50%; width: 100vw; transform: translateX(-50%);
  top: -24px; bottom: 0;
  background: linear-gradient(to bottom, transparent 0, var(--app-backdrop-bottom) 24px);
}
.modal-overlay.wiz-skin #pc-save-btn { font-size: 17px; padding: 15px; }
#funding-schedules-modal-overlay.wiz-skin .modal-body { padding-bottom: 120px; }
/* Keep onboarding text off "standard" size inside the skinned modals too. */
.modal-overlay.wiz-skin .field-label { font-size: 15.5px; }
.modal-overlay.wiz-skin .ps-row-name { font-size: 17px; }
.modal-overlay.wiz-skin .ps-row-meta { font-size: 14.5px; }
.modal-overlay.wiz-skin .ps-row-actions .btn-primary,
.modal-overlay.wiz-skin .ps-row-actions .btn-secondary { font-size: 14px; padding: 9px 14px; }
/* Recommended pockets in onboarding: drop the app intro + top Dismiss/Accept-All
   row (the bottom button replaces it), and blend the footer into the gradient. */
.modal-overlay.wiz-skin #ps-intro,
.modal-overlay.wiz-skin #ps-actions-row { display: none !important; }
/* No background needed: this is a flex sibling of .modal-body, which is the
   scroll container (#pocket-suggestions-overlay .modal-body), so the list clips
   at this footer rather than running under it. */
.modal-overlay.wiz-skin #ps-onboarding-finish { background: transparent; border-top: none; display: flex; gap: 10px; }
/* Match the bottom Accept All / Done button to the other wizard continue buttons;
   a short Skip sits beside Accept All. */
.modal-overlay.wiz-skin #ps-onboarding-finish button { width: auto; flex: 1; padding: 15px; font-size: 17px; font-weight: 600; border-radius: 12px; }
.modal-overlay.wiz-skin #ps-onboarding-finish .ps-skip-btn { flex: 0 0 auto; min-width: 96px; }
/* Don't let a row clip its own open overflow dropdown. */
.ps-row:has(.bank-menu.open) { overflow: visible; }
/* Native onboarding paycheck screen hosting the real funding form. Left-align the
   form fields and hide the in-modal action row / budget banner (we use the
   wizard's own "It looks good" button). */
.wiz-sched-form-host { width: 100%; text-align: left; }
.wiz-sched-form-host #pc-action-row,
.wiz-sched-form-host #pc-budget-warn { display: none !important; }
/* Explanation text above the onboarding schedule form. */
.wiz-sched-explain { margin-bottom: 18px; }
/* Native recommended-pockets list inside the wizard. Left-align rows; bump the
   row text off "standard" size like the rest of onboarding. */
.wiz-ps-list { width: 100%; text-align: left; margin-top: 6px; }
.wiz-ps-list .ps-row-name { font-size: 16px; }
.wiz-ps-list .ps-row-meta { font-size: 14px; }
.wiz-ps-empty { text-align: center; color: var(--muted); font-size: 16px; padding: 28px 8px; }
/* Skip / Accept All sit below the list, which scrolls in its own box (.wiz-body)
   and clips here — they are never overlapped, so no background. Slightly
   tighter top padding than the default .wiz-actions since the list runs right
   up to it. */
#wiz-ps-actions {
  padding: 14px 0 calc(26px + var(--safe-bottom, 0px));
}
.wiz-sched-explain p { font-size: 16px; line-height: 1.5; color: var(--muted); margin: 0 0 10px; }
.wiz-sched-explain p:last-child { margin: 0; }

.wiz-back-btn {
  position: fixed; top: 14px; left: 14px;
  width: 36px; height: 36px; border-radius: 50%;
  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15);
  color: var(--muted); font-size: 20px;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; padding: 0 2px 2px 0; line-height: 1; z-index: 9001;
  transition: color 0.15s, border-color 0.15s;
}
@media (hover: hover) {.wiz-back-btn:hover { color: var(--text); border-color: var(--accent); }}
/* Numbered step eyebrow at the top of each onboarding step screen
   (e.g. ① Connect Your Bank), mirroring the splash's step list. */
/* In-flow at the top of the content (scrolls with the page) — NOT fixed, so it
   never crowds the back/✕ corner buttons. */
.wiz-step-label {
  display: flex; align-items: center; justify-content: center; gap: 9px;
  font-size: 16px; font-weight: 700; color: var(--text); margin: 0 0 8px;
}
.wiz-step-label .wiz-step-num { width: 26px; height: 26px; font-size: 14px; }
.wiz-screen {
  width: 100%; max-width: 440px; min-height: 0; flex: 1 1 auto; margin: 0 auto;
  /* No bottom padding here — the sticky .wiz-actions owns the bottom gap
     (incl. the safe-area inset) so the buttons stay pinned to the viewport
     bottom while a tall body scrolls behind them. */
  padding: 2vh 22px 0;
  display: flex; flex-direction: column;
}
/* THE scroll container for a wizard screen. The overlay is overflow:hidden and
   .wiz-actions is a plain footer, so a long body scrolls INSIDE this box and
   clips at the buttons' top edge — content never passes behind them, which is
   why no bar needs a fade. (It used to be the other way round: the overlay
   scrolled, .wiz-actions was sticky, and the recommended-pockets rows slid
   under the buttons and showed through the gap between them.) */
.wiz-body, .wiz-splash {
  flex: 1; min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column;
  align-items: center; justify-content: flex-start; text-align: center; gap: 14px;
}
/* Centered by auto margins, NOT justify-content: center — a centered flex
   container that overflows pushes its first child past the scroll origin, where
   it can't be reached. Auto margins collapse to 0 once the content overflows,
   so a short screen is centered and a long one scrolls from the top. Screens
   that anchor to the top opt out. */
.wiz-body:not(.wiz-body-top):not(.wiz-building) > :first-child { margin-top: auto; }
.wiz-body:not(.wiz-body-top):not(.wiz-building) > :last-child { margin-bottom: auto; }
/* Splash: logo at top, brand under it, a greeting, then the steps lower down. */
.wiz-splash { justify-content: flex-start; gap: 0; padding-top: 3vh; }
.wiz-logo { width: 72px; height: 72px; }
.wiz-brand { font-size: 24px; font-weight: 700; letter-spacing: -0.01em; margin-top: 14px; }
.wiz-greeting { font-size: 21px; font-weight: 700; color: var(--accent); margin-top: 22px; }
/* One line, scales down on narrow screens so it never wraps. */
.wiz-welcome { font-size: clamp(20px, 6.4vw, 27px); font-weight: 800; letter-spacing: -0.02em; color: var(--text); margin-top: 36px; white-space: nowrap; }
.wiz-tagline { color: var(--muted); font-size: 15px; }
.wiz-steps-intro { font-size: 17.5px; font-weight: 500; color: var(--muted); line-height: 1.5; margin-top: 18px; }
/* Block centered to its content width so the rows stay left-aligned and the
   number chips line up in a column. */
.wiz-steps { display: flex; flex-direction: column; align-items: flex-start; gap: 16px; width: fit-content; max-width: 100%; margin: 20px auto 0; }
.wiz-step { display: flex; align-items: center; gap: 14px; font-size: 19px; font-weight: 500; }
.wiz-step-num {
  flex-shrink: 0; width: 30px; height: 30px; border-radius: 50%;
  background: var(--accent); color: #fff; font-weight: 700; font-size: 15px;
  display: flex; align-items: center; justify-content: center;
}
/* Inviting staggered entrance for the splash (logo → brand → welcome → steps). */
@keyframes wiz-in { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
.wiz-splash > * { animation: wiz-in 0.5s ease both; }
.wiz-splash > *:nth-child(1) { animation-delay: 0.05s; }
.wiz-splash > *:nth-child(2) { animation-delay: 0.18s; }
.wiz-splash > *:nth-child(3) { animation-delay: 0.30s; }
.wiz-splash > *:nth-child(4) { animation-delay: 0.42s; }
.wiz-splash > *:nth-child(5) { animation-delay: 0.54s; }
.wiz-splash > *:nth-child(6) { animation-delay: 0.66s; }
.wiz-emoji { font-size: 44px; line-height: 1; }
.wiz-title { font-size: 27px; font-weight: 700; letter-spacing: -0.01em; margin: 0; }
.wiz-sub { color: var(--muted); font-size: 18px; line-height: 1.55; max-width: 400px; margin: 0; }
.wiz-sub-spaced { margin-top: 12px; }
/* Sits content near the top instead of vertically centered (paycheck popups). */
.wiz-body-top { justify-content: flex-start; padding-top: 2vh; }
/* Trailing button arrow is an inline SVG (svg.wiz-arrow). Flex-center it with the
   text; vertical-align is the fallback if the button stays inline. */
button:has(> .wiz-arrow) { display: inline-flex; align-items: center; justify-content: center; }
svg.wiz-arrow { width: 1.05em; height: 1.05em; flex: 0 0 auto; margin-left: 4px; vertical-align: -0.16em; }
.wiz-note { color: var(--yellow); font-size: 15px; margin: 0; }
.wiz-list { text-align: left; color: var(--muted); font-size: 14.5px; line-height: 1.7; margin: 4px 0 0; padding-left: 20px; }
.wiz-list strong { color: var(--text); }
.wiz-popup {
  width: 100%; max-width: 380px; background: var(--surface2);
  border: 1px solid var(--border); border-radius: 16px;
  padding: 24px 22px; display: flex; flex-direction: column;
  align-items: center; gap: 14px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.wiz-detect-card {
  width: 100%; max-width: 320px; background: var(--surface);
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 18px 20px; display: flex; flex-direction: column; align-items: center; gap: 6px;
  text-align: center; cursor: pointer; font: inherit; color: inherit;
  transition: border-color 0.15s ease, background 0.15s ease;
}
@media (hover: hover) {.wiz-detect-card:hover { border-color: var(--accent); }}
.wiz-detect-card:active { background: var(--surface2, var(--surface)); }
.wiz-detect-amount { font-size: 30px; font-weight: 700; color: var(--text); }
.wiz-detect-line { font-size: 17px; color: var(--text); }
.wiz-detect-from { font-size: 15px; color: var(--muted); }
.wiz-detect-next { font-size: 16px; font-weight: 600; color: var(--accent); margin-top: 4px; }
.wiz-detect-edit { font-size: 13px; font-weight: 600; color: var(--accent); margin-top: 6px; text-align: center; }
.wiz-acct-list { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
.wiz-acct-row {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 13px 16px;
}
.wiz-acct-name { font-size: 15px; font-weight: 600; color: var(--text); text-align: left; }
.wiz-acct-type { font-size: 12.5px; color: var(--muted); white-space: nowrap; }
.wiz-gtg-hero { width: 100%; max-width: 340px; margin: 8px auto 4px; cursor: default; }
/* "Check you've got a Pocket for everything" callout on the all-done screen. */
.wiz-done-note {
  width: 100%; max-width: 400px; margin: 6px auto 0; text-align: left;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 14px; padding: 15px 17px;
}
.wiz-done-note-h { font-size: 15.5px; font-weight: 700; color: var(--text); margin-bottom: 6px; }
.wiz-done-note p { font-size: 14px; line-height: 1.5; color: var(--muted); margin: 0; }
.wiz-done-note strong { color: var(--text); }
/* A plain footer, NOT sticky and with NO background. .wiz-body above it is the
   scroll container, so it stops exactly at this bar's top edge — nothing ever
   passes behind these buttons and there is nothing to fade out. margin-top:auto
   holds the bar at the bottom on a screen whose body doesn't fill the height. */
.wiz-actions {
  margin-top: auto; flex-shrink: 0; display: flex;
  padding: 22px 0 calc(26px + var(--safe-bottom, 0px));
}
.wiz-actions-2 { gap: 10px; }
.wiz-actions .wiz-btn { flex: 1; padding: 15px; font-size: 17px; font-weight: 600; border-radius: 12px; }
/* Outline variant — mirrors the pocket modal's Pause button (transparent,
   muted border). Used for the onboarding pockets "Skip". */
.wiz-spinner {
  width: 34px; height: 34px; border-radius: 50%;
  border: 3px solid var(--border); border-top-color: var(--accent);
  animation: wiz-spin 0.8s linear infinite;
}
@keyframes wiz-spin { to { transform: rotate(360deg); } }

/* Onboarding "building your account" screen */
/* Cluster (spinner/title/sub) anchored near the top, not vertically centered,
 * so it reads high on the screen and the tip carousel has the lower half. */
/* An ordinary .wiz-body scroll box, at every width. That only works because the
   deck now sizes itself to fit this element (see .wiz-tip-stage) instead of
   escaping it with negative margins — a scroll container clips BOTH axes
   (`overflow-y: auto` computes overflow-x to `auto`), so anything that bleeds
   sideways out of here would be cut off and offer a horizontal scrollbar. */
.wiz-building {
  gap: 18px; position: relative; justify-content: flex-start; padding-top: 7vh;
  padding-left: 22px; padding-right: 22px;
}
/* The building screen's column runs edge to edge, because the tips deck is
   viewport-wide by design and this box is a scroll container (which clips BOTH
   axes). The reading column is restored on the children below, so only the deck
   actually uses the extra width. */
.wiz-screen:has(> .wiz-building) { max-width: none; padding-left: 0; padding-right: 0; }
.wiz-screen:has(> .wiz-building) > .wiz-actions {
  width: 100%; max-width: 440px; box-sizing: border-box;
  margin-left: auto; margin-right: auto;
  padding-left: 22px; padding-right: 22px;
}
.wiz-building > .wiz-title { max-width: 440px; }
/* Cancels this element's own padding so the deck reaches the screen edge. */
.wiz-tip-region { margin-left: -22px; margin-right: -22px; }
.wiz-build-actions { flex-direction: column; gap: 10px; min-height: 54px; }
.wiz-spinner-done { animation: none; border-color: var(--green); position: relative; }
.wiz-spinner-done::after {
  content: '✓'; position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  color: var(--green); font-size: 18px; font-weight: 700;
}
/* Tip carousel fills the space between the sub-header and the Continue button
 * and centers the card vertically in it. Because the cluster above is
 * top-anchored, a tip's (variable) height never shifts the title/sub. */
.wiz-tip-stage {
  /* Height is the tallest card (measured in JS → --wiz-tip-h) + breathing room,
   * so the stage is only as tall as a card and the dots sit right beneath it
   * (not pushed to the bottom of a flex:1 filler). --tip-stage-pad is that room;
   * JS reads it so its height cap and this rule can't drift apart.
   *
   * WIDTH IS UNCAPPED — the deck runs the full width of its box at every size
   * (owner, 2026-08-14: "remove the restrictions on number of tiles completely.
   * Let them fill off screen on mobile and let them fill to the edge of the
   * screen on desktop"). THE CARD COUNT IS WHAT ADAPTS NOW: _wizTipDeckRadius
   * measures how many cells it takes to cover the stage and builds that many
   * nodes, so a wide screen gets more cards rather than a wider gap or a bigger
   * card. Two earlier shapes are gone with it — a 736px cap whose clip line fell
   * mid-card, dissolved by a mask-image ramp so wide it erased the whole peek
   * (it read as a vignette over the page), and the 1348px three-card cap that
   * replaced it, which left dead space either side on anything wider.
   *
   * Nothing is ever CUT at the stage edge because there is no stage edge inside
   * the screen any more — the boundary is the viewport, and a card running off
   * the viewport is what a carousel is supposed to look like. So no fade. */
  --tip-stage-pad: 26px;
  /* % not vw: the stage must fit its box, and a vertical scrollbar makes 100vw
   * wider than the box — which would overflow a scroll container sideways. */
  flex: 0 0 auto; width: 100%;
  height: calc(var(--wiz-tip-h, 200px) + var(--tip-stage-pad));
  display: flex; align-items: center; justify-content: center;
  padding: 0; box-sizing: border-box;
  touch-action: pan-y; overflow: hidden;
  position: relative;
}
/* Flickity-style carousel: equal-size cards laid out in a row, the current one
 * centered with its neighbours beside it. Advancing slides the whole row one
 * cell-width left; the mirror on back. Cards just slide — no dim, no fade.
 *
 * A card's position is its SLOT, an integer offset from centre carried in
 * --slot (and mirrored to [data-slot] so CSS can select the centre one). Slot 0
 * is the middle; the outermost slot each side is the off-screen recycle buffer,
 * which is what makes the loop seamless. This replaced five named .wiz-slot-*
 * classes, which could only ever describe a five-card deck — the deck now sizes
 * itself to the screen, so the count is a runtime number and the transform has
 * to be arithmetic rather than an enum.
 *
 * ⚠️ scale() is LAST in the transform list, which means it is applied FIRST.
 * Put it anywhere else and it scales the translation too (a transform list
 * applies right to left), so a 1.06 centre card would sit 6% further out than
 * its slot and the row would stop lining up. The translateX percentage resolves
 * against the card's own unscaled border box, so the cell grid is unaffected.
 *
 * Every card is the same height (the tallest tip, measured in JS) so the row
 * reads as one clean strip. --tip-gap is the space between adjacent cells. */
.wiz-tip {
  --tip-gap: 14px;
  --tip-scale: 1;
  position: absolute; top: 50%; left: 50%;
  /* % of the STAGE, not vw — so the card can never exceed the stage and get
   * clipped (which is what 76vw did between 578 and 640px). */
  width: min(440px, 76%); height: var(--wiz-tip-h, 200px);
  background: var(--surface);
  border: 1px solid var(--border); border-radius: 20px;
  padding: 26px 28px; text-align: left;
  /* Top-aligned content so every card's header sits at the same y (the cards
   * are equal height, taller than the shortest tips). */
  display: flex; flex-direction: column; justify-content: flex-start;
  box-sizing: border-box;
  cursor: pointer; user-select: none; -webkit-user-select: none;
  will-change: transform;
  box-shadow: 0 12px 34px rgba(0, 0, 0, 0.38);
  z-index: 1;
  transform: translate(-50%, -50%)
             translateX(calc(var(--slot, 0) * (100% + var(--tip-gap))))
             scale(var(--tip-scale));
  transition: transform 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
/* The centre card sits above its neighbours — it is the one being read, and on
 * desktop it is also the one that grows (below), so it must not be overlapped. */
.wiz-tip[data-slot="0"] { z-index: 2; cursor: grab; }
.wiz-tip[data-slot="0"]:active { cursor: grabbing; }
/* DESKTOP ONLY: the centre card grows and the side cards shrink, so the eye is
 * told where to start without anything being dimmed or hidden (owner,
 * 2026-08-14: "putting focus on it gives them a guide of where to look ... make
 * the center one slightly bigger, and all the side ones slightly smaller").
 * On a phone the centre card is nearly the whole screen and needs no such cue —
 * and there the neighbours are close enough that resizing anything would crowd
 * them, which is the thing he asked to avoid.
 *
 * Scaling BOTH ways is what makes a subtle difference read: 1.08 against 0.94
 * is a 15% size step between neighbours, where growing the centre alone to a
 * value that obvious would have it reaching across the gap. Shrinking the sides
 * buys that contrast by moving their edges AWAY, so the clearance improves
 * rather than shrinking.
 *
 * The gap widens in the same breath, and that is not decoration: a 440px card
 * at 1.08 gains 18px per side, which would more than eat a 14px gap and leave
 * the cards overlapping. The stage gets more vertical room for the same reason
 * — the card grows in both directions, and the extra height has to come from
 * somewhere or the top and bottom clip.
 *
 * ⚠️ The scale is on .wiz-tip generally, so it applies to the recycle buffer
 * too. That matters: a card must already be at its side size while hidden, or
 * it would visibly resize at the moment it slides into view. */
@media (min-width: 900px) {
  .wiz-tip { --tip-gap: 28px; --tip-scale: 0.94; }
  .wiz-tip[data-slot="0"] { --tip-scale: 1.08; }
  .wiz-tip-stage { --tip-stage-pad: 44px; }
}
.wiz-tip.wiz-tip-noanim { transition: none; }
/* The one width breakpoint in the deck is the desktop centre-focus block above.
 * Everything else is arithmetic: the card is a % of the stage, the stage is the
 * full width of its box, and the number of cards is measured at runtime. */
/* Wraps the card stage + dots and grows to fill the space below the intro text,
 * centering the tips between that text and the buttons (the building screen's
 * top cluster keeps its own 7vh spacing; only the tips block centers here). */
.wiz-tip-region {
  flex: 1; align-self: stretch; min-height: 0;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
}
/* Page dots: how many tips + which one you're on. Clickable (jump to that tip),
 * with a padded hit area around a small visual dot. Sits close under the cards. */
.wiz-tip-dots {
  display: flex; gap: 3px; justify-content: center;
  padding-top: 4px; flex: 0 0 auto;
}
.wiz-tip-dot {
  width: 22px; height: 22px; padding: 0; border: none; background: none;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.wiz-tip-dot::before {
  content: ''; width: 7px; height: 7px; border-radius: 50%;
  background: rgba(255, 255, 255, 0.24);
  transition: background 0.2s, transform 0.2s;
}
@media (hover: hover) {.wiz-tip-dot:hover::before { background: rgba(255, 255, 255, 0.45); }}
.wiz-tip-dot.active::before { background: var(--accent); transform: scale(1.3); }
/* Onboarding: show the funding-schedule modal as a single paycheck form —
 * hide the accordion row header (it's the only, already-expanded row). */
#funding-schedules-modal-overlay.wiz-onboarding .fs-row-header { display: none; }
#funding-schedules-modal-overlay.wiz-onboarding .fs-row {
  border: none; background: none; padding: 0; margin: 0;
}
/* Onboarding: the surplus/shortfall ("you'll have an extra…") box and Fund Now
 * aren't relevant while first setting up the schedule. */
#funding-schedules-modal-overlay.wiz-onboarding #pc-budget-warn,
#funding-schedules-modal-overlay.wiz-onboarding #pc-fund-now-btn { display: none !important; }
.wiz-tip-title { font-size: 21px; font-weight: 800; letter-spacing: -0.01em; color: var(--accent); margin-bottom: 10px; }
/* The card is a flex column and the body is the part that gives: when the card
   is capped to the available room (see _wizBuildApplyTipHeight) a long tip
   scrolls HERE instead of growing the card and pushing the dots and Continue
   button down. min-height:0 is what lets a flex item shrink below its content;
   overscroll-behavior stops a flick at the end of the text from scrolling the
   wizard screen behind it. */
.wiz-tip-body {
  font-size: 17.5px; line-height: 1.55; color: var(--text);
  min-height: 0; overflow-y: auto; overscroll-behavior: contain;
}

/* Short viewports (e.g. iPhone SE, 375×667): the building screen's title + sub
   + tip carousel + buttons don't fit at full size. Shrink the CONTAINERS —
   padding, gaps, the spinner, the tip card's own box — and let the text scroll
   inside what's left. It used to shrink the TEXT too, which is why a zoomed-in
   user never got a scrollbar: the type quietly scaled back down to fit instead,
   undoing the size they had asked for. The tip card's cap plus the scrolling
   .wiz-tip-body do that job now (see _wizBuildApplyTipHeight). */
@media (max-height: 740px) {
  .wiz-building { padding-top: 3vh; gap: 10px; }
  .wiz-building .wiz-spinner { width: 28px; height: 28px; }
  .wiz-building .wiz-tip { padding: 17px 19px; border-radius: 16px; }
  .wiz-build-actions .wiz-btn { padding: 13px; }
  .wiz-build-actions { gap: 8px; }
  /* Tighter pinned-button gap on short screens (all wizard screens). */
  .wiz-actions { padding-top: 14px; padding-bottom: calc(14px + var(--safe-bottom, 0px)); }
}
/* Even tighter for very short screens. */
@media (max-height: 640px) {
  .wiz-building { padding-top: 2vh; }
}
.wiz-btn-muted { opacity: 0.65; }
/* Small purple "Skip" affordance on the paycheck screens — styled like the
 * ghost Sign Out button, accent-colored. */
.wiz-skip-row { display: flex; justify-content: center; padding-top: 10px; }
.wiz-skip-btn { width: auto; color: var(--accent); font-size: 14px; font-weight: 600; padding: 6px 14px; }
/* Skip belongs INSIDE .wiz-actions, not after it. .wiz-actions is the sticky
   bottom bar: it carries the fade-out gradient and owns the bottom padding
   (including the safe-area inset) because .wiz-screen deliberately has none.
   Anything placed after it therefore rendered below the gradient — a dark band
   under the buttons — and had no bottom gap of its own, which is exactly what
   the onboarding link screen looked like. Nested, the row rides the column
   gap, so its own padding-top would double the spacing. */
.wiz-actions .wiz-skip-row { padding-top: 0; width: 100%; }

/* .wiz-contact / .wiz-link removed with the onboarding "Can't connect your
   bank? Contact us" line — a Teller-era affordance for connect errors support
   can't actually fix. The manual-account path is the answer now. */

/* Close button — sits in the upper-right of the overlay regardless of
 * which screen is rendered. Tap-to-advance is wired on the overlay
 * itself, so the close button needs to stop propagation to avoid
 * accidentally advancing instead of dismissing. */
.welcome-close-btn {
  position: fixed; top: 14px; right: 14px;
  width: 36px; height: 36px; border-radius: 50%;
  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15);
  color: var(--muted); font-size: 18px;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; padding: 0; line-height: 1;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
  z-index: 9001;
}
@media (hover: hover) {.welcome-close-btn:hover { color: var(--text); border-color: var(--accent); background: rgba(255,255,255,0.1); }}
.welcome-screen {
  width: 100%; max-width: 520px;
  display: flex; flex-direction: column;
  flex: 1; min-height: 100%;
}
/* Top zone: brand wordmark + spacer pushing content down a bit */
.welcome-screen-top {
  text-align: center; margin-bottom: 32px;
  font-size: 14px; color: var(--muted); letter-spacing: 0.5px;
}
.welcome-content {
  flex: 1;
  display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
  gap: 32px; text-align: center;
}
.welcome-content > * {
  /* Each child fades in as a fade-stage; the screen renderer assigns delays
   * + per-element durations from data-duration on the element. */
  opacity: 0;
  animation: welcomeFade 1000ms cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
}
/* Screen 1: pin the quote to the viewport's vertical centerline using
 * position:fixed (relative to the viewport, since the welcome-overlay is
 * itself fixed). Auto-margin centering inside welcome-content drifted off
 * 50dvh because of the asymmetric padding above (8vh) vs below (24px).
 * Fixed positioning ignores the surrounding flow so 50% always means 50%.
 *
 * The blockquote stays opaque so each .qpiece inside can manage its own
 * opacity timeline. Per-section accent bars on each <p> child fade in/out
 * with their respective sections (see .welcome-quote-main.bar-on etc).  */
.welcome-content > .welcome-quote {
  opacity: 1;
  animation: none;
  position: fixed;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: calc(100% - 40px);
  max-width: 460px;
  margin: 0;
}
/* Quote pieces "type in" left-to-right via an animated mask. We can't use
 * clip-path here because the parenthetical pieces are plain inline text
 * (so they flow as one wrapping paragraph rather than stacking per chunk
 * — clip-path on inline is unreliable across browsers).
 *
 * The trick: a CSS Houdini @property declaration lets us animate a
 * percentage variable smoothly, which we plug into a mask-image gradient.
 * The gradient is opaque up to --reveal-pos and transparent after, so as
 * the variable sweeps 0% → 100% the text reveals from left to right.
 *
 * Fallback: browsers without @property support (very old Safari) treat
 * --reveal-pos as a regular custom property — it can't animate, but its
 * default of 100% leaves the mask fully opaque, and the opacity animation
 * still gives a clean fade-in. */
@property --reveal-pos {
  syntax: '<percentage>';
  initial-value: 100%;
  inherits: false;
}
.qpiece {
  display: inline;
  --reveal-pos: 100%;
  opacity: 0;
  -webkit-mask-image: linear-gradient(90deg, #000 var(--reveal-pos), transparent var(--reveal-pos));
          mask-image: linear-gradient(90deg, #000 var(--reveal-pos), transparent var(--reveal-pos));
  animation: welcomeTypeIn 1000ms cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
}
.qpiece-block {
  display: block;
  --reveal-pos: 100%;
  opacity: 0;
  -webkit-mask-image: linear-gradient(90deg, #000 var(--reveal-pos), transparent var(--reveal-pos));
          mask-image: linear-gradient(90deg, #000 var(--reveal-pos), transparent var(--reveal-pos));
  animation: welcomeTypeIn 1000ms cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
}
@keyframes welcomeTypeIn {
  from { opacity: 0; --reveal-pos: 0%; }
  to   { opacity: 1; --reveal-pos: 100%; }
}
/* Brand block on screen 1 — logo above, wordmark below with "by Hoopy"
 * in brand purple, then the tagline tucked directly under. Tagline animates
 * separately (own opacity:0 + animation) so the brand-name lands first
 * with the tagline arriving on its own beat shortly after. */
.welcome-brand {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.welcome-brand img { width: 72px; height: 72px; margin-bottom: 4px; }
.welcome-brand-name {
  font-size: 28px; font-weight: 800; letter-spacing: -0.5px; color: var(--text);
  line-height: 1.1;
}
.welcome-brand-name .brand-accent { color: var(--accent); font-weight: 800; }
.welcome-brand-tagline {
  /* Sub-feel comes from a smaller size + lighter weight than the wordmark
   * above it — full text color, no italic, just a clean tagline. */
  font-size: 16px; color: var(--text);
  font-weight: 500;
  letter-spacing: 0.2px;
  /* Animates independently from its parent's fade-in so it can land on its
   * own stopwatch beat per the welcome timing chart. */
  opacity: 0;
  animation: welcomeFade 1000ms cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
}
/* Body copy "by Hoopy" highlight — used inside paragraphs on screens 2/3
 * so the brand reads as styled wherever it's mentioned. */
.brand-accent { color: var(--accent); font-weight: 700; }
@keyframes welcomeFade {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}
.welcome-line {
  font-size: 18px; line-height: 1.45; color: var(--text);
  max-width: 480px;
}
.welcome-line-primary {
  font-size: 22px; font-weight: 700; line-height: 1.35;
}
/* Welcome-line paragraphs opt out of the welcome-content > * opacity fade
 * so the .qpiece spans inside can drive the type-in timing themselves.
 * Otherwise the paragraph's own fade stacks (multiplicatively) with each
 * piece's mask animation and the result reads as half-faded mush. */
.welcome-content > .welcome-line { opacity: 1; animation: none; }

/* Strikethrough animation. Used on page 2 to line out "towel" before
 * "money" gets injected. text-decoration-color is animatable, so the
 * line fades in over 500ms via the .struck class added by JS at the
 * right beat. */
.strikable {
  text-decoration: line-through;
  text-decoration-color: transparent;
  text-decoration-thickness: 2px;
  transition: text-decoration-color 500ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
.strikable.struck { text-decoration-color: currentColor; }

/* Word injection — used on page 2 to slide "money" into place between
 * struck-out "towel" and "is!". Starts at display:none so it occupies no
 * layout space, and "is!" sits flush against "towel". When .injected is
 * added, display becomes inline (instant layout reflow — "is!" jumps
 * right) and opacity transitions from 0 to 1 over 600ms. */
.word-injection {
  display: none;
  opacity: 0;
  transition: opacity 600ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
.word-injection.injected {
  display: inline;
  opacity: 1;
}
/* Brand-purple highlight for words like "hoopy", "simple", "Good to Go" */
.welcome-accent { color: var(--accent); font-weight: 700; }
/* Hitchhiker's quote block. Hierarchy is established with font-size and
 * style (italic / not, weight) rather than dimmed colors — full-strength
 * text reads as content the user is meant to engage with, not an aside
 * to skim past. */
.welcome-quote {
  font-size: 17px; line-height: 1.55; color: var(--text);
  font-style: italic; max-width: 460px;
  text-align: left;
  /* border-left + padding-left moved onto each <p> child below so each
   * section can have its own accent bar that fades in/out with the section
   * it belongs to. */
}
/* Per-section accent bar. The bar fades in via the .bar-on class (added
 * by JS at the section's scheduled time) and out when the next section
 * begins, so only the section currently revealing has a visible bar. */
.welcome-quote-main,
.welcome-quote-aside,
.welcome-quote-cite {
  border-left: 2px solid transparent;
  padding-left: 14px;
  margin: 0;
  transition: border-left-color 600ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
.welcome-quote-main.bar-on,
.welcome-quote-aside.bar-on,
.welcome-quote-cite.bar-on {
  border-left-color: var(--accent);
}
/* Each section "owns" the gap that follows it (via padding-bottom rather
 * than the next section's padding-top). When a section's bar lights up,
 * it extends slightly past the section's text, hinting that more content
 * is coming. The final section (cite/Douglas) has no trailing padding so
 * its bar lands flush — a visual "we're done" signal. Visible text
 * positions are identical to the previous padding-top arrangement; only
 * the bar coverage shifts. */
.welcome-quote-main  { font-size: 18px; font-weight: 500; padding-bottom: 10px; }
.welcome-quote-aside { font-size: 15px; font-weight: 400; padding-bottom: 14px; }
.welcome-quote-cite  { font-size: 14px; font-style: normal; font-weight: 500; }
/* GTG demo on screen 3 — wraps the live hero markup. Constrained width so
 * it doesn't stretch on desktop, and its own pointer-events:none so taps
 * fall through to the overlay's tap-to-advance handler. */
.welcome-gtg-demo {
  width: 100%; max-width: 420px;
  pointer-events: none;
}
.welcome-gtg-demo .gtg-info-btn { display: none; }
/* Bottom: button row anchored to the bottom of the screen */
.welcome-actions {
  display: flex; justify-content: center; padding-top: 24px;
}
.welcome-btn {
  background: var(--accent); color: #fff; border: 0;
  border-radius: 12px; padding: 12px 28px; font-size: 15px; font-weight: 700;
  cursor: pointer; opacity: 0;
  animation: welcomeFade 700ms cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
  transition: background 0.15s, transform 0.1s;
}
@media (hover: hover) {.welcome-btn:hover { background: var(--accent-dim); }}
.welcome-btn:active { transform: scale(0.97); }


/* ── Responsive ── */
@media (max-width: 720px) {
  /* Match the site's .top-nav narrow-viewport padding so the header
     proportions stay aligned across site → app on mobile. */
  #app-header { padding: 12px 20px; min-height: 64px; }
}
@media (max-width: 539px) {
  .header-gtg-label { display: none; }
}

/* Dev tools visibility gate. Dev-only UI is rendered into the DOM at init
   for any dev account, then the toggle in Profile flips body.dev-tools-on
   to show it. Pure CSS so the toggle is an instant UI flip with no
   re-renders. Non-dev users never get the body class, so .dev-only stays
   hidden for them too. */
body:not(.dev-tools-on) .dev-only { display: none !important; }
/* Inverse gate: shown when dev tools are OFF (or the user isn't a dev
   account at all). Used to render a free-floating Merchant Rules row
   for regular users while admins get a row-group with that row PLUS
   Manage Global Rules tucked in alongside it. */
body.dev-tools-on .non-dev-only { display: none !important; }
/* Per-feature dev gate for the GTG spending donut on the Transactions
   page. The chart used to be gated by the umbrella `.dev-only` class,
   but per the dev-tools toggle redesign it now has its own toggle
   ("Where did my money go? chart") so a dev account can keep general
   dev tools on without the donut taking up the top of every
   Transactions visit. */
body:not(.spending-insights-on) .spending-insights-only { display: none !important; }

/* The Insights TAB is retired. What it was going to be now lives in the spend
   card on Transactions, integrated with the list it describes rather than
   stranded on a page of its own. Hidden for everyone including debug users --
   the tab, its nav item, and its page are all still in the tree, so bringing it
   back for a genuinely different kind of insight is undoing this one rule. */
#nav-insights, #page-insights { display: none !important; }
.mg-cat-picker .cs-btn { cursor: pointer; }

/* ── Row group ─────────────────────────────────────────────────────
   Universal wrapper for stacking related .paycycle-row items inside a
   shared surface. Use whenever 2+ rows form a logical group (multiple
   funding schedules, Merchant Rules + Manage Global Rules, dev-tools
   sub-actions like the suggestion debug pair). A single row should
   remain free-floating; only reach for this when ≥2.

   Padding matches the pre-redesign .card / .account-group-card spec
   (padding: 16px) so the group reads as a peer to the Account-section
   connection cards. Inner rows get a 10px gap — slightly looser than
   the free-floating 8px stacking so the grouping has visible breathing
   room without feeling crowded. */
/* Dead: the funding schedules were the only user, and they no longer wrap
   (owner, 2026-08-12 — a section made entirely of button rows should not look
   like a container). Kept as a note rather than left as an unused rule.
   .row-group was: --surface card, 1px border, --radius, 16px padding. */

/* ── Demo mode ─────────────────────────────────────────────────────
   /demo route runs a sandboxed in-memory copy of the app. Hide UI
   that doesn't make sense without a real backend (bank connect,
   account management, push opt-in, sign out) and surface a banner so
   the visitor knows their changes won't persist. */
body.demo-mode #user-delete-account-btn,
body.demo-mode #user-edit-name-btn,
body.demo-mode #user-edit-email-btn,
body.demo-mode #user-change-password-btn,
body.demo-mode #signout-btn,
body.demo-mode #push-section-label,
body.demo-mode #push-card,
body.demo-mode #install-banner,
body.demo-mode #connections-list .bank-menu-wrap {
  display: none !important;
}

/* (Demo banner removed — it also collided with the body::before gradient
   backdrop, which is the same pseudo-element, and stretched to cover the
   app. The demo now looks exactly like the real app.) */

/* ── Demo fake Teller Connect ──────────────────────────────────────
   A faithful-looking stand-in for Teller Connect (intro → bank picker →
   credentials → demo notice), shown from the demo's "+ Bank Connection"
   button. Uses Teller's own dark-navy palette regardless of app theme, and
   is entirely cosmetic — nothing connects, no credentials are collected. */
.demo-plaid-overlay {
  /* Plaid Link light tokens, scoped to this modal so the app's dark theme
     doesn't bleed in (Plaid Link is a white, light-mode surface). */
  --dp-bg: #ffffff; --dp-elev: #f5f6f8; --dp-line: #e4e7ec;
  --dp-text: #1a1d24; --dp-muted: #5b6472; --dp-dark: #111318; --dp-blue: #2e5ff2;
}
.demo-plaid-box {
  width: 360px; max-width: 100%; height: min(600px, 92dvh);
  background: var(--dp-bg); border: 1px solid var(--dp-line); border-radius: 16px;
  overflow: hidden; display: flex; flex-direction: column; color: var(--dp-text);
}
.dp-inner { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.dp-step { flex: 1; min-height: 0; display: flex; flex-direction: column; }

/* Top bar (back / wordmark / close) */
.dp-topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 6px; }
.dp-topbar--close { justify-content: flex-end; }
.dp-wordmark { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; font-size: 15px; letter-spacing: 0.3px; color: var(--dp-text); }
.dp-icon-btn, .dp-x {
  background: none; border: none; color: var(--dp-muted); cursor: pointer;
  display: flex; align-items: center; justify-content: center; padding: 4px;
  border-radius: 8px; font-size: 16px; line-height: 1;
}
@media (hover: hover) {.dp-icon-btn:hover, .dp-x:hover { color: var(--dp-text); }}

/* Stepped progress bar (Plaid) */
.dp-progress { display: flex; gap: 5px; padding: 4px 16px 10px; }
.dp-progress span { flex: 1; height: 4px; border-radius: 99px; background: var(--dp-line); }
.dp-progress span.on { background: var(--dp-blue); }

/* Screen 1 — phone intro */
.dp-intro-hero { padding: 12px 24px 0; text-align: center; }
.dp-chip-row { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 20px; }
.dp-chip {
  width: 50px; height: 50px; border-radius: 13px; flex-shrink: 0; overflow: hidden;
  background: var(--dp-elev); border: 1px solid var(--dp-line);
  display: flex; align-items: center; justify-content: center;
}
.dp-chip--app { background: #14141d; }
.dp-chip--app svg { width: 30px; height: 30px; }
.dp-chip--plaid { background: #0a0b0d; border-color: #0a0b0d; }
.dp-intro-title { font-size: 18px; line-height: 1.4; font-weight: 600; color: var(--dp-text); }
.dp-intro-title strong { font-weight: 700; }
.dp-intro-body { padding: 24px 22px 0; }
.dp-phone-field {
  display: flex; align-items: center; gap: 8px; padding: 13px 14px;
  border: 1px solid var(--dp-line); border-radius: 11px; background: var(--dp-bg);
}
.dp-phone-field:focus-within { border-color: var(--dp-dark); }
.dp-flag { font-size: 18px; line-height: 1; }
.dp-cc { font-size: 15px; font-weight: 600; color: var(--dp-text); }
.dp-phone-field input { flex: 1; border: none; outline: none; font-size: 15px; font-family: inherit; background: none; color: var(--dp-text); }
.dp-phone-field input::placeholder { color: var(--dp-muted); }
.dp-phone-hint { display: flex; gap: 8px; align-items: flex-start; margin-top: 12px; font-size: 12.5px; line-height: 1.5; color: var(--dp-muted); }
.dp-phone-hint svg { flex-shrink: 0; margin-top: 1px; }
.dp-phone-hint a, .dp-terms a { color: var(--dp-text); font-weight: 600; text-decoration: underline; }

/* Shared footer + buttons */
.dp-foot { margin-top: auto; padding: 14px 20px calc(16px + env(safe-area-inset-bottom, 0px)); }
.dp-terms { text-align: center; font-size: 11.5px; line-height: 1.5; color: var(--dp-muted); margin-bottom: 12px; }
.dp-btn {
  width: 100%; padding: 14px; border: none; border-radius: 99px;
  background: var(--dp-dark); color: #fff; font-size: 15px; font-weight: 600;
  font-family: inherit; cursor: pointer; transition: filter 0.15s;
}
@media (hover: hover) {.dp-btn:hover { filter: brightness(1.25); }}
.dp-linkbtn {
  width: 100%; margin-top: 10px; padding: 8px; border: none; background: none;
  color: var(--dp-text); font-size: 14px; font-weight: 600; font-family: inherit; cursor: pointer;
}
@media (hover: hover) {.dp-linkbtn:hover { text-decoration: underline; }}
.dp-demo-note { margin-top: 14px; text-align: center; font-size: 11px; line-height: 1.45; color: var(--dp-muted); }
.dp-demo-note--creds { margin: 14px 2px 0; text-align: left; }

/* Bank avatars (monogram) */
.dp-bank-logo {
  width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  color: #fff; font-weight: 800; font-size: 14px; letter-spacing: -0.3px;
}
.dp-bank-logo--lg { width: 44px; height: 44px; font-size: 15px; }

/* Screen 2 — bank picker */
.dp-search { position: relative; margin: 2px 16px 6px; }
.dp-search svg { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--dp-muted); pointer-events: none; }
.dp-search input {
  width: 100%; padding: 12px 12px 12px 38px; border-radius: 11px;
  background: var(--dp-elev); border: 1px solid var(--dp-line); color: var(--dp-text);
  font-size: 15px; font-family: inherit;
}
.dp-search input::placeholder { color: var(--dp-muted); }
.dp-search input:focus { outline: none; border-color: var(--dp-dark); }
/* Two-column tile grid so every bank (incl. USAA) is on screen without
   scrolling — the auto-walkthrough taps USAA, which was below the fold in a
   single tall list. */
.dp-list {
  flex: 1; min-height: 0; overflow-y: auto; padding: 6px 12px;
  display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-content: start;
}
.dp-bank {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 11px 12px; border: 1px solid var(--dp-line); border-radius: 11px;
  background: var(--dp-bg); color: var(--dp-text); font-size: 13.5px; font-family: inherit;
  cursor: pointer; text-align: left; transition: background 0.12s, border-color 0.12s;
}
@media (hover: hover) {.dp-bank:hover { background: var(--dp-elev); border-color: var(--dp-dark); }}
.dp-bank .dp-bank-logo { width: 32px; height: 32px; font-size: 12px; }
.dp-bank-name { font-weight: 500; line-height: 1.25; }
.dp-fineprint { padding: 12px 20px calc(14px + env(safe-area-inset-bottom, 0px)); font-size: 10.5px; line-height: 1.5; color: var(--dp-muted); border-top: 1px solid var(--dp-line); text-align: center; }

/* Screen 3 — credentials */
.dp-creds-body { padding: 8px 20px 0; }
.dp-creds-bank { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.dp-creds-bank-name { font-size: 17px; font-weight: 700; color: var(--dp-text); }
.dp-creds-powered { font-size: 12.5px; color: var(--dp-muted); }
.dp-creds-intro { font-size: 13px; line-height: 1.55; color: var(--dp-muted); margin-bottom: 20px; }
.dp-field { position: relative; margin-bottom: 14px; }
.dp-field input {
  width: 100%; padding: 15px 44px 15px 16px; border-radius: 11px;
  background: var(--dp-bg); border: 1px solid var(--dp-line); color: var(--dp-text);
  font-size: 15px; font-family: inherit;
}
.dp-field input::placeholder { color: var(--dp-muted); }
.dp-field input:focus { outline: none; border-color: var(--dp-dark); }
.dp-field svg { position: absolute; right: 15px; top: 50%; transform: translateY(-50%); color: var(--dp-muted); pointer-events: none; }

/* Screen 4 — select accounts */
.dp-accts-head { padding: 6px 22px 12px; }
.dp-accts-title { font-size: 20px; font-weight: 700; color: var(--dp-text); margin-bottom: 4px; }
.dp-accts-sub { font-size: 13px; line-height: 1.5; color: var(--dp-muted); }
.dp-accts-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 16px; }
.dp-acct {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 14px 14px; border: 1px solid var(--dp-line); border-radius: 12px; margin-bottom: 10px;
}
.dp-acct-name { font-size: 15px; font-weight: 600; color: var(--dp-text); }
.dp-acct-mask { font-size: 12.5px; color: var(--dp-muted); margin-top: 2px; }
.dp-acct-check {
  width: 22px; height: 22px; flex-shrink: 0; border-radius: 6px;
  background: var(--dp-dark); display: flex; align-items: center; justify-content: center;
}

/* Screen 5 — demo notice */
.dp-done { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; text-align: center; padding: 20px 26px; }
.dp-done-icon { width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: rgba(46, 95, 242, 0.12); color: var(--dp-blue); }
.dp-done-title { font-size: 17px; font-weight: 700; color: var(--dp-text); }
.dp-done-sub { font-size: 13.5px; line-height: 1.55; color: var(--dp-muted); }

/* ── Teach-a-bank: tag the parts of a notification ────────────────────────────
   The user taps words in their bank's real alert to mark which span is the
   amount / merchant / card digits; the extraction pattern is then built from
   those spans. Tokens are buttons so the whole thing is keyboard-operable, but
   they have to read as inline text, hence the aggressive button reset. */
.tag-token {
  display: inline; font: inherit; color: var(--text);
  background: none; border: none; padding: 1px 0; margin: 0;
  border-radius: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {.tag-token:hover { background: rgba(255,255,255,0.07); }}
/* Assigned tokens get a filled chip in their field's color. box-decoration-break
   keeps the highlight intact when a multi-word merchant wraps a line. */
.tag-token.on {
  padding: 1px 3px; font-weight: 600;
  -webkit-box-decoration-break: clone; box-decoration-break: clone;
}
.tag-token.on-amount   { background: rgba(74,222,128,0.22);  color: var(--green); }
.tag-token.on-merchant { background: var(--accent-glow);     color: var(--accent); }
.tag-token.on-last4    { background: rgba(250,204,21,0.20);  color: var(--yellow); }

#teach-tag-text {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 12px 14px; font-size: 14px; line-height: 1.9;
  white-space: pre-wrap; word-break: break-word;
}

/* Field rows — which part you're tagging, and what's currently tagged. */
.tag-field {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 9px 11px; border-radius: 10px; cursor: pointer;
  background: var(--surface); border: 1px solid var(--border); text-align: left;
}
.tag-field.active { border-color: var(--accent); background: var(--surface2); }
.tag-field-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.tag-field-dot.amount   { background: var(--green); }
.tag-field-dot.merchant { background: var(--accent); }
.tag-field-dot.last4    { background: var(--yellow); }
.tag-field-name { font-size: 13px; font-weight: 600; color: var(--text); }
.tag-field-val  { font-size: 12px; color: var(--muted); margin-top: 1px; }
.tag-field-val.set { color: var(--text); }
.tag-field-clear {
  margin-left: auto; flex-shrink: 0; background: none; border: none;
  color: var(--muted); font-size: 16px; line-height: 1; padding: 4px 6px; cursor: pointer;
}
@media (hover: hover) {.tag-field-clear:hover { color: var(--text); }}


/* ── CSV import preview table ────────────────────────────────────────────────
   Replaces a pipe-joined monospace block whose columns never lined up, because
   nothing padded the cells to a common width. A table aligns by construction;
   the wrapper scrolls horizontally so a wide CSV doesn't stretch the modal. */
.imp-prev-wrap {
  background: var(--surface2); border: 1px solid var(--border);
  border-radius: 8px; overflow-x: auto; max-width: 100%;
}
.imp-prev-table {
  border-collapse: collapse; width: max-content; min-width: 100%;
  font-size: 12px; font-variant-numeric: tabular-nums;
}
.imp-prev-table th,
.imp-prev-table td {
  text-align: left; padding: 6px 10px; white-space: nowrap;
  border-right: 1px solid var(--border);
}
.imp-prev-table th:last-child,
.imp-prev-table td:last-child { border-right: none; }
.imp-prev-table thead th {
  color: var(--muted); font-weight: 600; font-size: 11px;
  border-bottom: 1px solid var(--border); background: var(--surface);
  position: sticky; top: 0;
}
.imp-prev-table tbody tr + tr td { border-top: 1px solid rgba(255,255,255,0.05); }
.imp-prev-table td { color: var(--text); }
/* A mapped column is the one that will actually be imported — tint it so the
   mapping above can be checked against real data at a glance. */
.imp-prev-table .imp-prev-mapped { background: rgba(139,124,246,0.07); }
.imp-prev-table thead th.imp-prev-mapped { color: var(--text); }
/* The slot name ([date], [merchant]) sits under the CSV's own header, so the
   two aren't read as one run-on label. */
.imp-prev-tag {
  display: block; color: var(--accent); font-weight: 600;
  font-size: 10px; text-transform: lowercase; margin-top: 2px;
}
.imp-prev-empty { padding: 10px; color: var(--muted); font-size: 12px; }

/* ── Insights tab ──────────────────────────────────────────────────────────
   Premium reporting page (docs/insights-page.md). Phase 1 is the shell + the
   premium gate; charts land on top. Deliberately reuses the existing tokens
   (--surface / --border / --muted / --accent) rather than inventing a palette,
   so a chart added later inherits the same surface every card already sits on. */
.insights-head { padding: 4px 0 14px; }
.insights-title { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
.insights-sub { margin-top: 4px; color: var(--muted); font-size: 13px; line-height: 1.45; }

.insights-placeholder {
  background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
  padding: 18px; color: var(--muted); font-size: 13px; line-height: 1.55;
}
.insights-placeholder p { margin: 0; }

/* Free-tier wall. The art is an inert SVG of the chart they'd be getting —
   cheaper and more honest than blurring a real one before any chart exists.
   When the trend charts land, this becomes a blurred REAL chart per §3.2 of
   the doc, which is why the copy and button live outside .insights-gate-art. */
.insights-gate {
  background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
  padding: 20px; text-align: center;
}
.insights-gate-art { color: var(--accent); opacity: 0.5; margin-bottom: 14px; }
.insights-gate-copy {
  margin: 0 0 16px; color: var(--text); font-size: 14px; line-height: 1.6;
  text-align: left;
}
.insights-gate-copy em { color: var(--accent); font-style: normal; font-weight: 600; }
.insights-gate .btn-primary { width: 100%; }

/* ── Insights: chart heading, controls, legend ─────────────────────────────
   The donut itself reuses .gtg-spend-pie-stage / .gtg-spend-pie-center* /
   .gtg-pie-slice unchanged, so it is visually the same chart that used to sit
   on Transactions. Only the chrome around it is new. */


/* Legend doubles as the accessible, tappable slice list — on a phone it is a
   far better hit target than the ring, and it stays in sync with hover, tap
   and touch-scrub through the same is-on class. */

/* The scrub gesture owns vertical drags that start on the ring, so the browser
   must not also scroll the page with them. Scoped to the stage alone — a fixed
   260px block — so the rest of the page scrolls normally.
   margin:auto because .gtg-spend-pie-stage is a fixed 260px box that used to be
   centred by the .gtg-spend-hero flex parent on the Transactions page; this
   page doesn't have that wrapper, so without it the donut sits hard left. */

/* Per-paycheck figure is the headline (it's the unit Needs are in); the window
   total sits under it in muted small text — the answer to "what did that
   actually cost me", without competing with the comparable number. */

/* ── Insights: dashboard layout ────────────────────────────────────────────
   Stacked on a phone; two columns once there's room, chart left and the
   category list right. The donut was sized for a 390px viewport and left most
   of a laptop empty, so it grows with the breakpoint. */

/* ⚠️ The app shell caps EVERY .page at 520px (line ~118) — right for a
   mobile-first budget app, wrong for an analytics view. A media query alone is
   not enough here and was the bug: at 1400px the query fired while the
   container was still 472px, so `minmax(340px, 42%) 1fr` resolved to
   "420px 12px" — a 12px list column, which collapsed every label to zero width
   and pushed the amounts outside their own background. Insights has to opt out
   of the cap for the columns to have anywhere to go. Percentage track sizes are
   gone too; minmax(0, …) keeps a long category name from forcing a column wider
   than its share. */

/* Category rows carry the SAME tint recipe as the Pockets group headers —
   colour for the text, 1a (10%) behind, 40 (25%) on the border — so a row here
   and its header over there read as the same object. */

/* preserveAspectRatio="none" stretches the bars to fill the box, which is what
   we want — but it also stretches stroke width, so the mean line is drawn with
   a vector-effect that keeps it hairline at any scale. */

/* Three controls now — wrap them rather than crushing each to a third of a
   phone screen. */

/* Toggles under the category list. Reuses the app's .toggle-pill switch so it
   behaves and looks like every other toggle in Profile. */

/* Only one select left in the row (breakdown and the unpocketed picker became
   toggles), so cap it rather than letting flex:1 stretch it across the page. */

/* Toggles read as buttons, matching the app's other tappable rows, rather than
   bare text with a switch floating beside it. */

/* The category list scrolls on its own — pinning the chart while a long list
   moves is the whole point of putting them side by side. Capped to the donut's
   height so the two columns stay visually balanced. */

.gtg-spend-hero-wrap { position: relative; }
/* The card's controls, in ONE row under the chart: mode swap on the left,
   category picker in the middle, options gear on the right.
   The two buttons used to be `position: absolute` on the wrap's bottom corners
   while the picker sat inside the chart body. That reads as a row and isn't
   one, and it fell apart the moment the body scrolled — fullscreen landscape on
   a phone, where the picker scrolled up out of the card and left the two
   buttons parked at the bottom (owner, 2026-08-12). A real row that is a
   SIBLING of the scroll container fixes it by construction, and matches the
   pinned-action-bar rule: no fade, no overlay, the chart just clips above it. */
.mg-action-row {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 6px 12px 12px;
  flex-shrink: 0;
}
/* Auto margins centre the pill GROUP in the row — NOT `flex: 1`, which stretched
   the pill to the full gap between the buttons (771px on a landscape phone)
   instead of leaving it its content width. The two buttons are the same size,
   so centring between them and centring in the card are the same thing.
   ⚠️ On the GROUP, not on the picker inside it: an auto margin on a child of a
   centred flex container pushes that child off the group's own centre, so with
   the range pill beside it the pair sat off to the right. */
/* ⚠️ The pill group moved ABOVE the chart and the SUMMARY took its place in this
   row (owner, 2026-08-16). Same sizing job either way: the group must have a
   DEFINITE width, because the pills' 50% cap is a percentage OF THIS BOX.
   Content-sized, "50%" means half of the two labels added together — narrower
   than the longer label, which then truncates at every width. Hence the stretch
   chain on the heading below.
   The summary is content-sized and centred, being a readout rather than a
   control: stretching it would stretch the pill to the full gap between the two
   icon buttons. */
.mg-action-row .gtg-spend-summary { flex: 0 1 auto; min-width: 0; margin: 0 auto; }
/* ⚠️ STRETCH CHAIN. `.gtg-spend-hero` is a flex COLUMN with `align-items:
   center`, so both the heading and the group inside it would take their content
   width — and a percentage cap measured against content is the bug above. Each
   link has to be stretched explicitly; missing any one puts it back. */
.gtg-spend-heading { align-self: stretch; }
.gtg-spend-heading > .mg-pill-group { align-self: stretch; width: 100%; }
.mg-action-row .btn-icon { margin: 0; flex: 0 0 auto; }
@media (hover: hover) { .moneygo-options-btn:hover, .moneygo-mode-btn:hover { opacity: 1; } }
/* Show the chart you'd GET, not the one you're looking at — a three-way cycle
   now: donut → bars → line → donut (owner, 2026-08-29). Exactly one glyph is
   displayed in every mode, so each rule below hides the two that don't apply
   rather than relying on a default. Written as hide-all-then-show-one so a
   fourth mode cannot silently leave two icons up. */
.moneygo-mode-btn .mg-mode-icon-bars,
.moneygo-mode-btn .mg-mode-icon-donut,
.moneygo-mode-btn .mg-mode-icon-line { display: none; }
/* Donut mode (no mode class on the wrap) → offer the bars. */
.gtg-spend-hero-wrap:not(.mg-mode-bars):not(.mg-mode-line) .moneygo-mode-btn .mg-mode-icon-bars { display: block; }
.mg-mode-bars .moneygo-mode-btn .mg-mode-icon-line  { display: block; }
.mg-mode-line .moneygo-mode-btn .mg-mode-icon-donut { display: block; }

/* ── Stacked spend-per-pay-period bars ──────────────────────────────────── */
/* One stage is mounted at a time; the wrap's .mg-mode-bars decides which. */
/* Positioned: the hover bubble is placed in percentages of this box.
   The fixed height is load-bearing in two ways -- _mgGeom measures it to build
   a 1:1 viewBox, and it's the SAME height the donut stage gets, so switching
   views no longer resizes the card. */
.gtg-spend-bar-stage {
  display: none; position: relative; width: 100%; height: var(--mg-stage-h);
  padding: 0;
}
.mg-mode-bars .gtg-spend-bar-stage { display: block; }
.mg-mode-bars .gtg-spend-pie-stage { display: none; }
/* ── Line mode, IN THE CARD ONLY ────────────────────────────────────────────
   ⚠️ `:not(.is-fullscreen)` makes the card rules and the full-screen ones
   DISJOINT, so neither can win the wrong way. Full screen owns every stage's
   display through the `.mg-layout-*` rules further down, and those are also
   TWO-class selectors — so an unscoped `.mg-mode-line .mg-line-stage` would TIE
   with them and the winner would be decided by SOURCE ORDER, which is the trap
   this file documents repeatedly.
   ⚠️ MEASURED, not assumed: today the full-screen block sits later in the file,
   so it would win that tie anyway and an unscoped rule happens to behave. That
   makes this belt-and-braces rather than load-bearing RIGHT NOW — and it is
   still worth having, because "it works because of where the block happens to
   sit" is exactly the property that breaks silently when someone moves one.
   The stage takes `--mg-stage-h` — the SAME height the donut and the bars get —
   so cycling through the three never resizes the card. */
.gtg-spend-hero-wrap.mg-mode-line:not(.is-fullscreen) .mg-line-stage {
  display: block; height: var(--mg-stage-h);
}
.gtg-spend-hero-wrap.mg-mode-line:not(.is-fullscreen) .gtg-spend-pie-stage,
.gtg-spend-hero-wrap.mg-mode-line:not(.is-fullscreen) .gtg-spend-bar-stage { display: none; }
.gtg-spend-bar-svg { width: 100%; height: 100%; display: block; overflow: visible; }

/* Stands in for the chart when the selection matches nothing. Takes the stage's
   own height so the card doesn't jump as you move on and off an empty
   category, and wins over the mode rules above — whichever stage the mode would
   have mounted, this replaces it. */
/* Waiting on the category set. Same shape as .mg-empty and for the same
   reason — it takes the stage's own height so the card doesn't jump when the
   chart arrives — but it replaces the HEADING and the action row too, because
   the range/category pickers and the summary figures are as category-shaped as
   the wedges are. See loadGtgSpendHero: a chart drawn before `_categories` is
   complete is one grey Uncategorized arc, and `_mgChartSignature` cannot tell,
   so a bad drawing would be cached and repainted for ever. */
.gtg-spend-loading { display: none; }
.gtg-spend-hero-wrap.mg-awaiting-cats .gtg-spend-loading {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  width: 100%; height: var(--mg-stage-h); gap: 14px;
  color: var(--muted); font-size: 14px; text-align: center; padding: 0 16px;
}
.gtg-spend-hero-wrap.mg-awaiting-cats .gtg-spend-heading,
.gtg-spend-hero-wrap.mg-awaiting-cats .gtg-spend-pie-stage,
.gtg-spend-hero-wrap.mg-awaiting-cats .mg-line-stage,
.gtg-spend-hero-wrap.mg-awaiting-cats .gtg-spend-bar-stage,
.gtg-spend-hero-wrap.mg-awaiting-cats .mg-empty,
.gtg-spend-hero-wrap.mg-awaiting-cats .mg-action-row { display: none !important; }

.mg-empty { display: none; }
.mg-is-empty .mg-empty {
  display: flex; align-items: center; justify-content: center;
  width: 100%; height: var(--mg-stage-h);
  color: var(--muted); font-size: 14px; text-align: center; padding: 0 16px;
}
.mg-is-empty .gtg-spend-bar-stage,
.mg-is-empty .gtg-spend-pie-stage { display: none; }
/* ⚠️ The line needs the same treatment, at matching specificity — without it a
   card in line mode with nothing to draw showed the empty message AND an empty
   plot below it. Scoped out of full screen for the reason above. */
.gtg-spend-hero-wrap.mg-is-empty:not(.is-fullscreen) .mg-line-stage { display: none; }
/* Fullscreen has no fixed stage height — it fills what's left, same as a
   chart would. */
.is-fullscreen.mg-is-empty .mg-empty { flex: 1; height: auto; min-height: 0; }

/* ── Collapsible ────────────────────────────────────────────────────────── */
/* A button in its own right, matching the pocket group headers -- same caret,
   same row shape -- but keeping the hero's own surface so it still reads as
   part of this card rather than a control floating above it. */
/* ⚠️ `auto 1fr auto`, with the title LEFT (owner, 2026-08-12 — reversing the
   centred version). The title takes the free middle column and sits at its
   start, so the caret leads it and the end group is pushed to the right edge
   whatever is in it. Nothing depends on the two gutters matching any more,
   which is what the centred version needed and never quite had. */
.gtg-spend-title-row {
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center;
  gap: 10px; width: 100%;
  min-height: 44px; padding: 10px 14px; margin: 0;
  background: none; border: 0; border-radius: 14px;
  color: var(--text); text-align: left;
}
/* ⚠️ Every child is PLACED explicitly. Auto-placement fills the columns in
   order, so hiding the caret in full screen slid the title into column 1 and it
   stretched across the whole 1fr instead of centring. A hidden item must not
   move the ones after it. */
/* ⚠️ Sized DOWN in this row, and that is load-bearing rather than cosmetic.
   The outer columns are `1fr` each, but an fr track can't shrink below its
   content, so a badge wider than its share grows column 1 and pushes the
   centred title off centre — measured on a 360px phone: tracks came out
   64 / 159 / 45 and the title sat 10px right of the row's middle. At this size
   the badge fits the share on every phone width, so the two gutters stay equal
   and the title stays where the owner asked for it. */
.gtg-spend-title-row > .mg-title-lead  { grid-column: 1; justify-self: start; }
.gtg-spend-title-row > .gtg-spend-title-group { grid-column: 2; justify-self: start; min-width: 0; }
.gtg-spend-title-row > .mg-title-end   { grid-column: 3; justify-self: end; }
.mg-title-lead, .mg-title-end { display: inline-flex; align-items: center; gap: 6px; }
/* Title and BADGE are one cell, so the badge travels with the title rather than
   sitting in a gutter — which also kept it out of the end group, where it would
   compete with the expand button and (in full screen) the range pill. */
.gtg-spend-title-group {
  display: inline-flex; align-items: center; gap: 6px; min-width: 0;
  pointer-events: auto; cursor: pointer;
}
.gtg-spend-title-row .mg-premium-badge {
  font-size: 9px; padding: 1px 5px; letter-spacing: 0.3px;
}
/* The ROW is not the control -- only the caret and the title are. A full-width
   hit area meant a stray tap anywhere along the header collapsed the card,
   including next to the fullscreen control.
   ⚠️ `pointer-events: none` here is also what lets a tap on the row's dead area
   fall through to the wrap, which toggles the card while it's collapsed. The
   parts below take their events back. */
.gtg-spend-title-row { pointer-events: none; }
.gtg-spend-title-row:active,
.gtg-spend-title-row:focus,
.gtg-spend-title-row .gtg-spend-title:active {
  transform: none; scale: none; background: none; outline: none;
}
.gtg-spend-title-row .pocket-group-toggle,
.gtg-spend-title-row .gtg-spend-title { pointer-events: auto; cursor: pointer; }
/* The buttons in the side columns need their events back too — they were
   siblings of this row until they moved into it. */
.gtg-spend-title-row .btn-icon { pointer-events: auto; }
@media (hover: hover) { .gtg-spend-title-row > .gtg-spend-title-group:hover { opacity: 0.85; } }
/* Both states use the SAME box -- no divider, no inset, no padding change --
   so expanding opens space BELOW the label and never moves the label. The card
   itself has no padding; the chart body carries it, under the row. */
.gtg-spend-hero-wrap > .gtg-spend-title-row { margin: 0; width: 100%; }
.gtg-spend-hero-wrap > .gtg-spend-hero { padding: 2px 20px 22px; }
.gtg-spend-hero-wrap.is-collapsed { padding: 0; }
.mg-premium-badge { justify-self: end; flex-shrink: 0; }


/* Dev rows that sit among the ordinary Preferences rows, rather than inside the
   Dev Tools card. Outlined so they're findable at a glance in a list they don't
   otherwise belong to -- the border does the same job it does on a credit-card
   Pocket: "this row is a different kind of thing". Brand purple rather than
   gold, because gold is the Get Premium colour everywhere else and these sit
   directly beside Manage Premium. */
.paycycle-row.dev-row-gold {
  border: 1px solid var(--accent);
  box-shadow: inset 0 0 0 1px rgba(173, 161, 255, 0.14);
}
.gtg-spend-hero-wrap.is-collapsed > .gtg-spend-hero,
.gtg-spend-hero-wrap.is-collapsed .btn-icon,
.gtg-spend-hero-wrap.is-collapsed .mg-action-row,
.gtg-spend-hero-wrap.is-collapsed .mg-locked-veil { display: none !important; }
.gtg-spend-hero-wrap.is-collapsed { padding: 0; }

/* ── Category picker ────────────────────────────────────────────────────── */
/* Matches the range pill above the chart -- both are chart controls, so they
   should read as the same kind of thing. */
/* ⚠️ THE TWO PICKERS SIT TOGETHER IN THE CONTROLS ROW, range first (owner,
   2026-08-16). The range used to live in `.gtg-spend-heading` above the chart,
   which put the two controls that narrow the same chart at opposite ends of
   it. Reading order is WHEN then WHAT.
   ⚠️ THE GROUP FILLS THE ROW; the pills inside it do NOT fill the group. That
   split is what makes the 50% cap below a cap rather than a size — a percentage
   resolves against the group, so the group has to be the space that's really
   there and not the sum of two labels. The pills stay at `flex-grow: 0`, so
   they keep their content width inside it, and `justify-content: center` keeps
   the pair centred between the two icon buttons (which are the same size, so
   centring in the group and centring in the row are the same thing).
   The group used to be content-sized with `margin: 0 auto` doing the centring;
   that is what a percentage cap can't be measured against.
   ⚠️ NOWRAP: the pills SHRINK past their labels instead (see below), so the
   controls row is one row tall at every width. It wrapped at first, and a row
   that grows a second line moves the chart above it. */
.mg-pill-group {
  display: flex; align-items: center; justify-content: center;
  flex-wrap: nowrap; gap: 8px;
  min-width: 0;
}
/* Two different type sizes (12px pill, 13px button) that must read as a pair. */
.mg-pill-group .gtg-spend-subtitle.is-tappable,
.mg-pill-group .cs-btn { min-height: 30px; }
/* ⚠️ BOTH PILLS SHRINK NARROWER THAN THEIR LABELS AND TRAIL OFF (owner,
   2026-08-16). A range label runs from "Last 30 days" to "All time · 128 pay
   periods" and a category is a name the user typed, so the pair genuinely
   cannot both hold their content width on a phone. Trailing a label off is the
   cheap loss: the caret still says the pill opens, and the menu it opens shows
   every name in full.
   ⚠️ `min-width: 0` at EVERY level of the chain. A flex item's default is
   `auto`, i.e. its own content, so ONE missing declaration anywhere down the
   chain pins the pill open and nothing shrinks — which reads as the rule not
   working rather than as a specific box refusing. */
.mg-pill-group > *,
.mg-pill-group .cs-btn,
.mg-pill-label { min-width: 0; }
/* ⚠️ CONTENT WIDTH WHEN IT FITS, HALF THE GROUP AT WORST (owner, 2026-08-16).
   `flex: 0 1 auto` — a CONTENT basis, so a pill is its label's width and
   truncates nothing while there is room — plus a 50% CAP, so a long label can
   never squeeze its neighbour past half. Neither pill grows.
   ⚠️ The cap is what makes "shrink to 50%" different from "always 50%", and
   `flex: 1 1 0` was the wrong tool for it: a zero basis made the group's
   max-content resolve to TWICE the longer label instead of the sum of the two,
   so the row squeezed the group and BOTH pills trailed off with room to spare —
   "Cat…" beside "All ti…" (owner-reported). A `min-width: 50%` is wrong in the
   other direction: min-width also GROWS a short pill, so "Category" would be
   padded out to half the row whenever it fits.
   ⚠️ `- 4px` is half the 8px gap, so two capped pills come to exactly 100%. */
.mg-pill-group > * { flex: 0 1 auto; max-width: calc(50% - 4px); }
/* The picker is a block wrapper around the button; as a flex parent it hands
   both its width and the shrink through instead of letting the button size
   itself out of the capped box. */
.mg-pill-group .mg-cat-picker { display: flex; }
.mg-pill-group .mg-cat-picker > .cs-btn { flex: 1 1 auto; }
/* Only the RANGE label needs this — `.cs-btn > span` has carried the same three
   declarations app-wide since long dropdown labels started pushing chevrons out
   of narrow triggers, so the category pill was already ellipsis-ready and a
   copy here would just be a second place to keep in step. */
/* ⚠️ `display: block` is load-bearing on a SPAN: `text-overflow` applies to
   block containers, not inline boxes. It happens to work in the tappable state
   because the flex parent blockifies its items — but a user with no pay cycle
   gets a plain, non-tappable pill whose parent is an ordinary block, and there
   the label would silently stop ellipsizing. Blockification makes this a no-op
   in the flex case, so one declaration covers both. */
.mg-pill-label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The carets are the whole affordance — they must survive the squeeze. The
   range pill's is a ::after that already carries `flex: none`. */
.mg-pill-group .cs-btn > svg { flex: 0 0 auto; }
.mg-cat-picker { position: relative; max-width: 100%; }
.mg-cat-picker .cs-btn {
  /* ⚠️ NO min-width. It was 190px, to give "Select Category" some presence, and
     a floor is exactly what stops a flex item shrinking — with the range pill
     beside it the row wanted 190 + ~186 + two 34px buttons + gaps ≈ 484px
     against a 390px phone, so the floor was what forced the wrap. Content width
     is a fine target at ~150px. */
  display: inline-flex; align-items: center; gap: 8px; justify-content: space-between;
  border-radius: 999px; padding: 6px 14px;
  /* Same scrim + wash as the range pill — see the note there. */
  background-color: var(--mg-pill-bg);
  background-image: linear-gradient(var(--mg-pill-wash), var(--mg-pill-wash));
  border: 1px solid var(--mg-pill-border);
  color: var(--text); font-size: 13px; cursor: pointer;
}
.mg-cat-picker .cs-btn.has-value { border-color: var(--accent); }
@media (hover: hover) { .mg-cat-picker .cs-btn:hover {
  background-color: var(--mg-pill-bg-hover);
} }
/* Anchored to the picker, and flips ABOVE the button when it sits low in the
   viewport -- in fullscreen it's near the bottom edge, where a downward menu
   was simply off-screen. */
.mg-cat-menu { top: calc(100% + 6px); bottom: auto; left: 50%; transform: translateX(-50%); }
.mg-cat-menu.drop-up { top: auto; bottom: calc(100% + 6px); }
.mg-cat-menu.open { max-height: 320px; overflow-y: auto; }

/* ── Free tier ──────────────────────────────────────────────────────────── */
/* Free tier renders the REAL chart with invented data (see _mgLoadPreviewData)
   and blurs it here. Because nothing was fetched, the picture can't respond to
   filters and no true figure is in the DOM to un-blur -- so the blur is
   presentation, not protection. Two earlier attempts were worse: blurring LIVE
   data still moved with the filters and leaked enough shape to read trends, and
   a pre-rendered image drifted from the real layout the moment anything here
   changed. Same renderer, so it can't drift; nothing behind it, so it can't
   leak. */
/* ⚠️ THE LINE STAGE IS IN THIS LIST TOO. The preview builds twelve periods of
   invented bars, and the line is drawn from those — so a free user whose stored
   mode is `line` gets a real-looking plot, and leaving it out of the blur would
   have shown it unblurred beside a blurred heading. */
.gtg-spend-hero-wrap.is-locked .gtg-spend-heading,
.gtg-spend-hero-wrap.is-locked .gtg-spend-pie-stage,
.gtg-spend-hero-wrap.is-locked .gtg-spend-bar-stage,
.gtg-spend-hero-wrap.is-locked .mg-line-stage,
.gtg-spend-hero-wrap.is-locked .mg-cat-picker {
  filter: blur(6px); pointer-events: none; user-select: none;
}
/* Corner controls would be blurred furniture that does nothing. */
.gtg-spend-hero-wrap.is-locked .btn-icon { display: none !important; }
/* Overlays the chart instead of sitting under it, so the button lands in the
   middle of the picture it's offering. */
.mg-locked-veil {
  position: absolute; inset: 44px 0 0; z-index: 4; padding: 16px;
  display: flex; align-items: center; justify-content: center;
}
/* Just the button -- the blurred chart behind it makes the case better than a
   headline could. Same gold as the Get Premium CTA everywhere else, so it reads
   as the one recognizable "this is the upgrade" control. */
/* Sized to be the obvious subject of the card rather than a control resting on
   top of it -- it's the only thing here a free user can act on. Width tracks
   the card so it stays proportionate from a phone up to fullscreen, with a cap
   so it doesn't stretch into a banner on a wide screen. */
.mg-locked-btn {
  width: min(62%, 260px); min-width: 190px;
  padding: 15px 24px; font-size: 16px; font-weight: 700;
  border-radius: 12px; box-shadow: 0 8px 22px -8px rgba(0, 0, 0, 0.5);
}

/* One height for both charts. The donut grows into it rather than the bars
   shrinking to the donut -- the extra room is useful to the bars and harmless
   to the donut. */
.gtg-spend-hero-wrap { --mg-stage-h: 300px; }
.gtg-spend-pie-stage { height: var(--mg-stage-h); display: flex; align-items: center; justify-content: center; }
.gtg-spend-pie-svg { width: auto; height: 100%; max-height: 300px; }
.is-fullscreen { --mg-stage-h: auto; }

.mg-bar-grid { stroke: var(--border); stroke-width: 1; opacity: 0.5; }
.mg-bar-ylabel { fill: var(--muted); font-size: 10px; text-anchor: end; }
/* ⚠️ The average, drawn OVER the bars (owner, 2026-08-15). Dashed and in the
   accent so it can't be mistaken for a gridline — those are flat grey rules at
   round numbers, and this one lands wherever the data puts it.
   `non-scaling-stroke` because the bar viewBox is widened per bar in the
   scrolling layouts; without it the dash pattern would stretch with the chart.
   `pointer-events: none` so it can never take a click meant for the column
   underneath — it spans every one of them. */
.mg-bar-avg {
  stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 5 4;
  opacity: 0.85; vector-effect: non-scaling-stroke; pointer-events: none;
}
.mg-bar-avg-label {
  fill: var(--accent); font-size: 10px; font-weight: 600; text-anchor: end;
  pointer-events: none;
}
/* Boundary dates sit upright, centered under their tick. */
.mg-bar-xlabel { fill: var(--muted); font-size: 10px; text-anchor: middle; }
.mg-bar-tick { stroke: var(--muted); stroke-width: 1.5; opacity: 0.95; }
/* The boundary carried up through the plot, so a column reads as the span
   between two dated rules rather than as a floating block. */
.mg-bar-bound { stroke: var(--muted); stroke-width: 1; opacity: 0.4; }
/* A label above the column, not a headline on top of it -- the bar carries the
   emphasis, the number just says what it is. */
.mg-bar-total {
  fill: var(--text); opacity: 0.72; font-size: 12px; text-anchor: middle;
  font-weight: 500; font-variant-numeric: tabular-nums;
}
.mg-bar-total.is-pending { opacity: 0.5; }
/* ⚠️ The in-progress column is HATCHED because it is not counted — every figure
   on this card covers completed periods only. The stroke colour lives here
   rather than on the <pattern> so it can follow the theme; the pattern element
   itself only carries geometry. Light enough to read as a marking over any
   category colour, faint enough not to become the loudest thing on the chart:
   this column is context, not the subject. */
#mg-hatch line { stroke: rgba(255, 255, 255, 0.5); }
.mg-bar-hatch { pointer-events: none; }
/* Bands are flat color separated by a hairline gap, clipped to a rounded
   column. No highlight, no bevel -- both read as dated, and both break down
   once bars go thin at long ranges. */

/* Whole-column hit target for selecting a pay period. Invisible until it's the
   active one, then a faint wash so the selected column reads as a unit. */
.mg-bar-hit { fill: transparent; cursor: pointer; }
@media (hover: hover) { .mg-bar-hit:hover { fill: rgba(255, 255, 255, 0.03); } }

/* The period still being spent. A dashed outline of the FULL plot height says
   "this bar hasn't finished growing"; a different fill would just look like a
   different category. */
.mg-bar-pending {
  fill: none; stroke: var(--border); stroke-width: 1;
  stroke-dasharray: 3 3; opacity: 0.8; pointer-events: none;
}

.mg-bar-seg { cursor: pointer; transition: opacity 0.12s ease; }
/* Selecting a category fades the rest rather than hiding them, so the bar keeps
   its full height and you can still see the share you've picked out. */
.mg-bar-seg.is-dim { opacity: 0.2; }
.mg-bar-seg.is-selected { stroke: var(--text); stroke-width: 1; paint-order: stroke; }
@media (hover: hover) { .mg-bar-seg:hover { opacity: 0.82; } }

/* ── Hover bubble ───────────────────────────────────────────────────────── */
/* Tinted to the segment it describes and tethered by a small arrow, so a stack
   of similar colours is still unambiguous about which band you're reading. */
/* Above the donut, the bars and the locked veil — it's a readout about
   something you're pointing at, so nothing on the card should cover it. */
.mg-bar-bubble {
  position: absolute; z-index: 6; pointer-events: none;
  transform: translate(10px, -50%) scale(0.96); transform-origin: left center;
  opacity: 0; transition: opacity 0.1s ease, transform 0.1s ease;
  background: var(--bubble-color, var(--accent)); border: 0;
  border-radius: 8px; padding: 7px 10px; min-width: 92px;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5);
}
.mg-bar-bubble.open { opacity: 1; transform: translate(10px, -50%) scale(1); }
.mg-bar-bubble.flip { transform: translate(calc(-100% - 10px), -50%) scale(0.96); transform-origin: right center; }
.mg-bar-bubble.flip.open { transform: translate(calc(-100% - 10px), -50%) scale(1); }
/* The tether. Sits on whichever edge faces the segment. */
.mg-bar-bubble::before {
  content: ''; position: absolute; top: 50%; margin-top: -4px;
  border: 4px solid transparent; left: -7px;
  border-right-color: var(--bubble-color, var(--accent));
}
.mg-bar-bubble.flip::before { left: auto; right: -7px; border-right-color: transparent; border-left-color: var(--bubble-color, var(--accent)); }
/* DONUT variant: parked above the ring and centred on it, rather than tethered
   to the side of a segment. Its `left`/`top` are the donut's top-centre, so the
   translate lifts it clear. No arrow — it isn't pointing at a spot, it's a
   readout for whatever the finger is on, and an arrow aimed at the middle of a
   chart would be claiming something it doesn't know. */
.mg-bar-bubble.mg-bubble-donut { transform: translate(-50%, -100%) scale(0.96); transform-origin: center bottom; }
.mg-bar-bubble.mg-bubble-donut.open { transform: translate(-50%, -100%) scale(1); }
.mg-bar-bubble.mg-bubble-donut::before { display: none; }
/* Text sits directly on the category color, so it's white with a soft shadow
   rather than a themed token -- the backgrounds run from pale to dark and a
   single token can't stay legible across all of them. */
.mg-bubble-top { display: flex; align-items: baseline; gap: 12px; justify-content: space-between; }
.mg-bubble-cat, .mg-bubble-amt, .mg-bubble-sub {
  color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); white-space: nowrap;
}
.mg-bubble-cat { font-size: 12px; font-weight: 600; }
.mg-bubble-amt { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
.mg-bubble-sub { font-size: 10px; opacity: 0.85; }

/* ── Range menu ─────────────────────────────────────────────────────────── */
/* ⚠️ The pill fill is a SCRIM PLUS a wash, not a bare white wash, and both
   pills on this card share it (owner, 2026-08-12: "the category dropdown
   matches the rest of the app, but the time one is darker").
   They had identical CSS and still looked different, because a translucent
   white over the hero's gradient composites to whatever is underneath it: the
   range pill sits in the bright band under the top-left glow, where a 7% lift
   barely registers, and the category pill sits over a darker stretch, where the
   same 7% reads clearly. The scrim knocks the local ground back toward the
   hero's own base colour first, so the wash lands on the same starting point
   wherever the pill happens to be — in the card and full screen alike. */
/* ⚠️ RETUNED TO THE PANEL SURFACE (owner, 2026-08-16): the pills carry the same
   wash, blur and light edge as the options bubble and the two dropdown menus,
   so every floating thing on this chart reads as one material.
   ⚠️ The BLUR replaces the scrim the note above describes, and supersedes it.
   That scrim existed because a translucent white over the hero's gradient
   composites to whatever is beneath it — bright under the top-left glow, darker
   elsewhere — so two pills with identical CSS looked different. A backdrop blur
   solves the same problem at its source: it flattens what is behind before
   anything is laid over it, so a pill lands on the same ground wherever it
   sits. The wash survives only for the HOVER lift, which needs something to
   lift.
   ⚠️ `-webkit-` copy alongside, or Safari renders a flat panel and the pills go
   back to reading differently in the two positions. */
.gtg-spend-hero-wrap {
  /* ⚠️ THE SAME PILL AS `.gtg-paycheck-btn` ON THE GTG HERO (owner,
     2026-08-16) — a plain translucent WHITE wash and a light border, values
     copied from it so the app has one pill, not three that nearly match.
     A pill sits ON its chart rather than over it: it is a control you read
     past, so the purple comes through. The panels' dark wash made the four of
     them read as holes punched in the chart, which is what a SURFACE should
     look like and a control should not.
     ⚠️ NO backdrop blur, for the same reason — the budget pill has none, and
     the blur is what made these read as panels. Accepted trade, and it is the
     trade the scrim note below was originally avoiding: a white wash
     composites to whatever is beneath it, so the two pills will differ
     slightly between the bright band under the hero's glow and the darker
     stretch below. The hero pill has always lived with that; matching it
     matters more than the last few percent of evenness. */
  --mg-pill-bg: rgba(255, 255, 255, 0.07);
  --mg-pill-bg-hover: rgba(255, 255, 255, 0.13);
  --mg-pill-wash: transparent;
  --mg-pill-border: rgba(255, 255, 255, 0.12);
}
.gtg-spend-subtitle.is-tappable {
  cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 12px; border-radius: 999px;
  background-color: var(--mg-pill-bg);
  background-image: linear-gradient(var(--mg-pill-wash), var(--mg-pill-wash));
  border: 1px solid var(--mg-pill-border);
  color: var(--text);
}
@media (hover: hover) { .gtg-spend-subtitle.is-tappable:hover {
  background-color: var(--mg-pill-bg-hover);
} }
/* The same chevron the category picker draws inline (12×8, 2px round-capped
   polyline), drawn as a mask so it takes a flat colour. It was a '▾' text
   glyph, which renders at whatever weight and baseline the font feels like and
   read as a typed character rather than a control. Muted, not currentColor:
   the label is the content and the caret only says the pill opens. */
.gtg-spend-subtitle.is-tappable::after {
  content: '';
  width: 12px;
  height: 8px;
  flex: none;
  background-color: var(--muted);
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 1 6 7 11 1'/%3E%3C/svg%3E");
          mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 1 6 7 11 1'/%3E%3C/svg%3E");
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
}
/* ⚠️ SAME SURFACE AS THE OPTIONS BUBBLE (owner, 2026-08-15). All three floating
   panels on this chart now read as one family: the app header's dark
   translucent wash over a heavy backdrop blur, with a light white edge. One
   declaration shared by BOTH menus — the range pill's and the category
   picker's, which already share this class. The options bubble carries its own
   copy only because it also sets layout there. */
.mg-range-menu {
  display: none; position: absolute; z-index: 20; top: 52px; left: 50%;
  transform: translateX(-50%); min-width: 148px; padding: 5px;
  background: rgba(15, 15, 19, 0.72);
  backdrop-filter: blur(14px) saturate(160%);
  -webkit-backdrop-filter: blur(14px) saturate(160%);
  border: 1px solid rgba(255, 255, 255, 0.22);
  border-radius: 12px; box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5);
}
/* ⚠️ The category menu's UPWARD placement is NOT here — `.mg-cat-menu.drop-up`
   already existed further up this file and `toggleMgCategoryMenu` now always
   adds that class. A second rule doing the same job from a different selector
   is the exact drift this chart was rebuilt to remove; don't add one. */
.mg-range-menu.open { display: block; }
.mg-range-opt {
  display: block; width: 100%; text-align: left; border: 0; background: none;
  color: var(--text); font-size: 13px; padding: 8px 10px; border-radius: 7px; cursor: pointer;
}
.mg-range-opt.is-on { color: var(--accent); font-weight: 600; }
@media (hover: hover) { .mg-range-opt:hover { background: var(--surface); } }

/* ── Corner controls ────────────────────────────────────────────────────── */
/* Full colour, not the usual muted glyph: these are the entry point to a
   feature, so they read as an invitation rather than as chrome. */
/* Top-right in both states: it's the same control either way (expand/shrink),
   so it shouldn't move when pressed. */
/* ⚠️ LAID OUT in the title row, not absolutely positioned over the card
   (owner, 2026-08-12). Floating it at the corner is what made the row's right
   end unusable — anything put there landed underneath it. */
.moneygo-full-btn { margin: 0; }
/* Circular hit area with a purple wash on hover, rather than the default
   rounded-rectangle-on-dark, which read as a separate surface floating over
   the card instead of a control on it. */
.moneygo-full-btn, .moneygo-mode-btn, .moneygo-options-btn {
  opacity: 1; color: var(--text);
  width: 34px; height: 34px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 50%; background: transparent; transition: background 0.15s, color 0.15s;
}
@media (hover: hover) {
  .moneygo-full-btn:hover, .moneygo-mode-btn:hover, .moneygo-options-btn:hover {
    color: #fff; background: rgba(173, 161, 255, 0.28);
  }
}
.moneygo-full-btn .mg-full-icon-close,
.is-fullscreen .moneygo-full-btn .mg-full-icon-open { display: none; }
.is-fullscreen .moneygo-full-btn .mg-full-icon-close { display: block; }
/* ── Scoped view (one pocket / one category) ──
   Strips the card down to what a scoped, full-screen-only view can use. There
   is nothing to collapse to, nothing to pick (the scope IS the pick), nothing
   to swap to (a donut of one category is a single wedge), and the options
   describe what the shared card counts, not this one. What's left is the range
   pill, the summary line and the bars. */
.mg-full-icon-x { display: none; }
.mg-scoped .moneygo-full-btn .mg-full-icon-open,
.mg-scoped .moneygo-full-btn .mg-full-icon-close { display: none; }
.mg-scoped .moneygo-full-btn .mg-full-icon-x { display: block; }
/* ⚠️ The title row STAYS while scoped (owner, 2026-08-13: "the full screen mode
   from the pocket screen is still missing the close button and header").
   It was hidden back when the row held only the collapse caret and the expand
   button, neither of which a scoped view has any use for — but it now carries
   the back arrow, the "<Name> Insights" title and the close X, which are the
   only way out of a view that exists full screen and nowhere else.
   The caret is what actually has nothing to do here, and it was already hidden
   for every full-screen view a few rules down — a scoped view is always full
   screen, so that covers this with nothing new. */
/* The chart-swap button STAYS while scoped (both modes are meaningful now); the
   category picker and the options button go. The row therefore stays, holding
   the swap on the left and the merchant legend where the picker was. */
/* The category picker stays while scoped and becomes the MERCHANT LEGEND -
   merchants carry no colour anywhere else, so the swatch in each row is the
   only key to the slices. Only the options button goes (it describes what the
   shared card counts, not this one). */
/* ⚠️ ...EXCEPT for a debug user, because the trend-line smoother lives in that
   modal and the line is on screen in exactly this view. The gear was hidden on
   the reasoning that the options describe the shared card, which is true of
   every group in there but that one — so the button comes back and
   _syncMoneyGoOptions hides the groups that don't apply. */
/* ⚠️ THE GEAR SHOWS ON A SCOPED CHART TOO (owner, 2026-08-16), reversing the
   2026-08-15 removal. That removal was right about what was in the modal at the
   time — every group described what the SHARED card counts — and the grain
   switch changed the premise: "per pay period or per calendar month" is what
   the bars, the line and every average MEAN, on every view there is. Hiding the
   gear left a scoped chart with no way to say it.
   Which groups apply is decided in `_syncMoneyGoOptions`, not here — a rule
   hiding the button can only ever be all-or-nothing. */
/* A scope with ONE bucket has no donut worth drawing, so the swap goes too. */
.mg-scope-solo .moneygo-mode-btn { display: none !important; }
/* ⚠️ At least as wide as its pill. The merchant list opened narrower than the
   button that owns it, which reads as a rendering fault rather than a menu
   (owner, 2026-08-12). Same floor the .cs-menu positioner applies in JS; this
   family is CSS-positioned, so it says it here. */
.mg-cat-picker { position: relative; }
.mg-cat-picker .mg-range-menu { min-width: 100%; width: max-content; max-width: calc(100vw - 24px); }
.mg-legend-dot {
  display: inline-block; width: 10px; height: 10px; border-radius: 50%;
  margin-right: 8px; vertical-align: -1px; flex: 0 0 auto;
}

/* ── Fullscreen ─────────────────────────────────────────────────────────── */
body.mg-fullscreen { overflow: hidden; }
.gtg-spend-hero-wrap.is-fullscreen {
  position: fixed; inset: 0; width: 100vw; height: 100dvh;
  z-index: 8000; margin: 0; border-radius: 0; max-width: none;
  /* Deliberately NOT --app-backdrop: the card keeps its own purple ground so
     going fullscreen reads as the same card growing, not as a different screen.
     The base rule's gradients still apply; only the radius and border go. */
  border: 0; padding: 12px;
  display: flex; flex-direction: column;
}
/* ⚠️ Chart options must sit ABOVE the fullscreen chart (owner, 2026-08-15: "the
 * options button opens under the full screen"). `.modal-overlay` is z-index
 * 300 and the fullscreen wrap above is 8000, so the modal opened BEHIND the
 * thing that launched it — invisible, with the page apparently frozen.
 * ⚠️ This is the only .modal-overlay reachable from inside the fullscreen
 * chart. Its siblings in that action row are fine for a different reason: the
 * range and category menus are `position: absolute` INSIDE the wrap, so they
 * stack within it, and the app dialogs (10000) and toasts (100000) already
 * clear 8000.
 * ⚠️ Unconditional rather than scoped to a :has(.is-fullscreen) check — this
 * modal is only ever opened from the chart, and it should be on top in both
 * states. Keeping it below the dialogs is deliberate: an explainer opened from
 * inside options still lands on top. */
/* ⚠️ THIS RULE CANNOT WIN ON ITS OWN and never could — `_markModalOpened`
   writes an INLINE `z-index: 300 + seq` on every modal it opens, and an inline
   value beats any stylesheet rule. `openMoneyGoOptions` therefore writes 8100
   inline as well, after that call. Kept as the documented intent (and as the
   value that one place reads from), not as the mechanism. */
#moneygo-options-overlay { z-index: 8100; }
/* The inner hero has to be a flex column too, or the stage inside it keeps its
   auto height and the chart sits short in a full-screen box -- which is exactly
   what "every view in fullscreen got short" was. */
.is-fullscreen > .gtg-spend-hero {
  flex: 1; min-height: 0; display: flex; flex-direction: column; width: 100%;
  /* The card's 22px bottom padding was clearance for the two corner buttons
     that used to float over the chart. .mg-action-row is a real sibling now and
     brings its own, so that reservation is dead height — and height is the
     scarce axis in fullscreen, especially landscape. */
  padding-bottom: 4px;
}
.is-fullscreen .gtg-spend-heading { flex-shrink: 0; }
/* At 6px this was touching the viewport edge. The other two controls live in
   .mg-action-row now and are laid out, not pinned. */
/* Full screen already has a back arrow at the top left, and the card it would
   collapse to isn't on screen — two carets in front of one heading (owner,
   2026-08-12). The disclosure control lives in the card; the page has the
   arrow. */
.is-fullscreen > .gtg-spend-title-row .pocket-group-toggle { display: none; }
/* ...and the right-hand control is a plain CLOSE here, not "shrink back to the
   card". Same reasoning the scoped view already used; this just says it for
   every full-screen view rather than only that one. */
.is-fullscreen .moneygo-full-btn .mg-full-icon-open,
.is-fullscreen .moneygo-full-btn .mg-full-icon-close { display: none; }
.is-fullscreen .moneygo-full-btn .mg-full-icon-x { display: block; }
/* Back arrow: fullscreen only, top left, mirroring the X on the right. */
/* Full screen only: the card in the page has nothing to go back from. It takes
   the caret's place in the lead group, which full screen hides. */
.moneygo-back-btn { display: none; }
.is-fullscreen .moneygo-back-btn { display: block; margin: 0; }
.is-fullscreen .mg-action-row { padding: 6px 16px 4px; }
/* Use the width that's actually there -- the bars were still card-sized. */
.is-fullscreen:not(.mg-mode-bars) .gtg-spend-bar-stage { width: 100%; }
/* Height is the point of fullscreen as much as width -- the stack needs room to
   separate before a 12-category bar means anything.

   The SVG scales UNIFORMLY, so a wide viewBox in a narrow box shrinks the whole
   chart to fit the width and leaves the bars stubby in a mostly-empty screen --
   the "zoomed out" look. Pinning height and letting width run past the viewport
   keeps the bars full height and turns a long range into a horizontal scroll,
   which is what "the width goes further back" actually means. */
.is-fullscreen .gtg-spend-bar-stage {
  flex: 1; min-height: 0; display: flex; align-items: stretch;
  overflow-x: auto; overflow-y: hidden;
}
.is-fullscreen .gtg-spend-bar-svg { height: 100%; width: auto; max-width: none; flex: 0 0 auto; margin: auto; }
/* The 34px that used to be reserved here is gone with the corner buttons: the
   x-axis ran under them when they floated over the chart, and .mg-action-row
   now takes its own space below it. _mgGeom reads the SVG's box, so the plot
   picked the room up automatically. */

/* Fullscreen donut mode shows BOTH charts -- the donut answers "what share",
   the bars answer "how has it moved", and they're worth reading together. Here
   the bars FIT the width instead of side-scrolling: this is the compact view,
   and the scrolling one is what bar mode is for. */
/* The donut leads here and the bars take what's left — NOT the other way round.
   Pinning the bar strip at `flex: 0 0 42%` meant the donut got whatever remained
   after the heading and the picker, which on a zoomed phone (fewer CSS px of
   height to divide) came out SMALLER than the same donut in the card. Expanding
   has to make it bigger, never smaller, so both charts carry a floor in PIXELS
   and the hero scrolls when the screen can't hold the pair — the same
   shrink-the-container-not-the-content rule the rest of the app follows.

   The floors are px on purpose: a percentage min-height resolves against the
   parent's height, and the hero's is indefinite (a `flex: 1` item), so
   `min(300px, 55%)` silently collapsed to 0 and the donut shrank to 220px on a
   492px-tall viewport anyway. Measured, not reasoned. */
/* ══ FULL-SCREEN LAYOUT ══════════════════════════════════════════════════
   ONE class decides everything: `mg-layout-*`, written by _mgApplyLayout from
   the pure resolver in app.js. Band heights arrive as custom properties, so
   nothing here computes a share and nothing depends on source order.

   ⚠️ WHAT THIS REPLACED, so it doesn't grow back: the layout used to be the
   INTERSECTION of five classes (is-fullscreen / mg-mode-bars / mg-scoped /
   mg-scope-solo / mg-is-empty) over ~8 rule sites, several resolving by source
   order. Nothing could enumerate the configurations, so fixes kept breaking one
   nobody was looking at and the two entry points drifted apart.
   ⚠️ SCOPE IS NOT AN INPUT ANY MORE. A scoped chart (from a pocket or a group
   header) shows exactly what the unscoped one shows at the same size and mode
   — that was the owner's actual complaint (2026-08-15). All a scope changes is
   what the buckets MEAN. */

/* ⚠️ `gap: 0` in full screen, and it is load-bearing. The card's 14px gap is
   spacing the allocator knows nothing about, so with it the bands overflowed
   the content box by one gap per row and the charts crept down the screen.
   Every pixel of vertical spacing in full screen belongs to the allocator;
   stages carry their own padding where they need breathing room. */
.is-fullscreen > .gtg-spend-hero { overflow: hidden; gap: 0; }

/* ── 3-up: donut ⎸ line on top, bars beneath ───────────────────────────── */
.mg-layout-3up > .gtg-spend-hero {
  display: grid;
  /* ⚠️ `auto 1fr`, NOT `1fr 1fr`. The donut is square, so in a half-width cell
     it can only ever use its own height of the width — the owner's screenshot
     had a 165px donut alone in a 500px cell with 335px of empty purple beside
     it. Sizing the column to the donut hands that width to the LINE, which is
     the one chart that can actually spend it (more horizontal resolution on the
     trend). */
  grid-template-columns: auto 1fr;
  grid-template-rows: auto var(--mg-top-h) var(--mg-bars-h);
  grid-template-areas: "head head" "pie line" "bars bars";
  column-gap: 18px;   /* ⚠️ MG_GRID_GAP in app.js — the allocator subtracts it */
  align-items: stretch;
}
.mg-layout-3up .gtg-spend-heading   { grid-area: head; }
/* ⚠️ Plus the aside. `--mg-aside-w` is 0 unless the readout has moved out of
   the donut's hole, so this is a no-op in the ordinary case — and when it
   isn't, the extra width comes off the LINE's column, because the grid is
   `auto 1fr` and the donut column is the `auto` one. That is the intended
   trade: a small donut leaves the line a wide column with room to give. */
.mg-layout-3up .gtg-spend-pie-stage {
  grid-area: pie;
  width: calc(var(--mg-donut-h) + var(--mg-aside-w, 0px));
}
.mg-layout-3up .mg-line-stage       { grid-area: line; }
.mg-layout-3up .gtg-spend-bar-stage { grid-area: bars; }

/* ── Stacked: donut, line, bars, each full width ───────────────────────── */
.mg-layout-stacked .gtg-spend-pie-stage { height: var(--mg-donut-h); width: 100%; }
.mg-layout-stacked .mg-line-stage       { height: var(--mg-line-h); }
.mg-layout-stacked .gtg-spend-bar-stage { height: var(--mg-bars-h); }

/* ── Two-chart fallbacks: only when all three can't meet their floors ──── */
.mg-layout-pair-donut-bars .gtg-spend-pie-stage { height: var(--mg-top-h); width: 100%; }
.mg-layout-pair-donut-bars .gtg-spend-bar-stage { height: var(--mg-bars-h); }
.mg-layout-pair-line-bars  .mg-line-stage       { height: var(--mg-top-h); }
.mg-layout-pair-line-bars  .gtg-spend-bar-stage { height: var(--mg-bars-h); }

/* ── Which stages exist, per token. Every stage named in every layout, so a
      configuration can never fall through to whatever it happened to inherit. */
.is-fullscreen .gtg-spend-pie-stage,
.is-fullscreen .mg-line-stage,
.is-fullscreen .gtg-spend-bar-stage { display: none; flex: none; min-height: 0; }
.mg-layout-3up .gtg-spend-pie-stage,
.mg-layout-3up .mg-line-stage,
.mg-layout-3up .gtg-spend-bar-stage,
.mg-layout-stacked .gtg-spend-pie-stage,
.mg-layout-stacked .mg-line-stage,
.mg-layout-stacked .gtg-spend-bar-stage,
.mg-layout-pair-donut-bars .gtg-spend-pie-stage,
.mg-layout-pair-donut-bars .gtg-spend-bar-stage,
.mg-layout-pair-line-bars .mg-line-stage,
.mg-layout-pair-line-bars .gtg-spend-bar-stage { display: flex; }
.is-fullscreen .mg-line-stage { align-items: stretch; }

/* The donut fills the band it was allocated, on both axes — the band height IS
   its diameter, so no clamp may make it smaller than that. */
.is-fullscreen .gtg-spend-pie-svg { max-height: 100%; max-width: 100%; height: 100%; width: auto; }
.is-fullscreen .mg-line-svg { width: 100%; height: 100%; }

/* ⚠️ The bars SCROLL in 3-up and stacked and FIT in the two-chart fallbacks.
   _mgGeom keys the viewBox width on `overflow-x: auto`, so these two rules and
   that check are one decision — change either and the bars are drawn against a
   box they aren't in. */
.mg-layout-3up .gtg-spend-bar-stage,
.mg-layout-stacked .gtg-spend-bar-stage { overflow-x: auto; overflow-y: hidden; align-items: stretch; }
.mg-layout-3up .gtg-spend-bar-svg,
.mg-layout-stacked .gtg-spend-bar-svg { width: auto; height: 100%; max-width: none; flex: 0 0 auto; margin: auto; }
.mg-layout-pair-donut-bars .gtg-spend-bar-svg,
.mg-layout-pair-line-bars .gtg-spend-bar-svg { width: 100%; height: 100%; }

/* Nothing to swap between while all three are up. */
.mg-layout-3up .moneygo-mode-btn,
.mg-layout-stacked .moneygo-mode-btn { display: none !important; }

/* The empty state stands in for every chart, so it takes the whole box. */
.mg-layout-empty .gtg-spend-pie-stage,
.mg-layout-empty .mg-line-stage,
.mg-layout-empty .gtg-spend-bar-stage { display: none !important; }

/* A short landscape box has no height to spare, so the title stops taking a
   full line's worth of it. */
@media (orientation: landscape) and (max-height: 520px) {
  .is-fullscreen .gtg-spend-heading { margin-bottom: 2px; }
  .is-fullscreen .gtg-spend-title { font-size: 13px; }
}

/* ⚠️ TYPE AND PADDING MATCH `.paycycle-row`, the app's standard settings row
   (owner, 2026-08-14: "the fonts are all a lot smaller than the buttons and
   options throughout the rest of the app"). This screen used to run a whole
   step smaller — 13px/600 labels over 11px descriptions in 13px of side padding
   — which is what made it read as cramped even once it stopped compressing.
   The app's row is 15px/500 over 12px in 16px of padding; keep them equal. */
.mg-opt-row {
  display: flex; align-items: center; justify-content: space-between; gap: 14px;
  padding: 12px 16px; border-radius: 10px; cursor: pointer;
  background: var(--surface); border: 1px solid var(--border); margin-bottom: 8px;
}
@media (hover: hover) { .mg-opt-row:hover { background: var(--surface2); } }
.mg-opt-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mg-opt-label { font-size: 15px; font-weight: 500; color: var(--text); }
.mg-opt-desc { font-size: 12px; color: var(--muted); line-height: 1.45; }

/* Options rows nest to show dependency: each level only applies when the level
   above it is on, which the indent says without another line of copy. */
/* A group is ONE card carrying related rows, not a box around boxes. The rows
   inside give up their own border, background and margin and are separated by a
   hairline, so a group looks exactly like a single .mg-opt-row that happens to
   have more in it -- and adding a row expands the card instead of introducing a
   second one. */
.mg-opt-group {
  border: 1px solid var(--border); border-radius: 10px;
  background: var(--surface); overflow: hidden;
  /* No margin: .modal-body is a flex column with a 10px row-gap, so a margin
     here stacks on top of it and doubles the spacing. */
}
.mg-opt-group .mg-opt-row {
  border: 0; border-radius: 0; margin: 0; background: none;
}
.mg-opt-group .mg-opt-row + .mg-opt-row { border-top: 1px solid var(--border); }

/* Group header lives inside the card, above the first row's divider. */
.mg-opt-group-head { padding: 12px 16px 10px; }
.mg-opt-group-title { font-size: 15px; font-weight: 600; color: var(--text); }
.mg-opt-group-desc { font-size: 12px; color: var(--muted); line-height: 1.45; margin-top: 3px; }
.mg-opt-group-head + .mg-opt-row { border-top: 1px solid var(--border); }

/* Radio dot, for a group where the options are alternatives rather than
   independent switches. Same 22px slot a .toggle-pill occupies so the rows in
   either kind of group line up. */
.mg-opt-radio {
  width: 18px; height: 18px; flex-shrink: 0; border-radius: 50%;
  border: 2px solid var(--border); box-sizing: border-box; transition: border-color .15s;
}
.mg-opt-radio.on { border-color: var(--accent); border-width: 5px; }

/* Chart options ride the app's standard .fullscreen-mobile presentation (full
   screen on mobile, right pane with the page shifted left on desktop). Its
   pinned Done bar is now the shared `.tx-filter-actions` rule up beside that
   class, alongside the two filter panels — the three had the same shape and
   only this one had the behaviour. */
#tutorial-modal-overlay > .modal-box > .modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }

/* At 22px/800 beside a caret the title read as a page heading, not a control. */
/* ⚠️ One line, ellipsised — never wrapped. At 320px with the Premium badge
   beside it there isn't room for the full phrase, and the header grew to two
   lines and pushed the whole card down. Every other width fits it whole. */
.gtg-spend-title-row .gtg-spend-title {
  font-size: 16px; font-weight: 700; letter-spacing: -0.2px; line-height: 1.2;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
}
/* The heading block centers the subtitle and summary over the data. The title
   row is no longer inside it, so it needs no alignment override. */
.gtg-spend-heading { justify-content: center; }
.mg-opt-row .info-q { flex-shrink: 0; margin-right: 2px; }

/* ── Scoped view: the overview line above the detail bars ──
   Only ever shown inside .mg-scoped. Top third / bottom two thirds, by flex
   share rather than percentages, so it works in a box of indefinite height
   (the same reason the fullscreen combo view uses shares). */
.mg-line-stage { display: none; }
/* A quarter of the height, not a third (owner, 2026-08-12) - it is an overview,
   and the chart it summarises deserves the room. */
/* ⚠️ Padding only. This used to carry `display`, `flex` and a `min-height` —
   i.e. it LAID OUT the line stage, and only for a scoped chart, which is
   exactly how the two entry points came to show different things at the same
   size. Whether the line shows and how tall it is belongs to mg-layout-*
   alone now. */
.mg-line-stage { width: 100%; padding: 4px 0 6px; box-sizing: border-box; }
/* ⚠️ THE .mg-scoped LAYOUT RULES ARE GONE (owner, 2026-08-15). They hid the
   bars in donut mode, re-ordered the stages and re-clamped the donut — which
   is precisely what made the same window show different charts depending on
   whether you arrived from Transactions or from a pocket. Layout is decided
   by mg-layout-* alone now; .mg-scoped survives only for CHROME (the title,
   the stripped controls) further up this file. */

/* ⚠️ THE `min-aspect-ratio: 7/5` MEDIA BLOCK IS GONE (owner, 2026-08-15).
   It asked the wrong question: "wide enough for two columns" was proxied by a
   RATIO, so a 1161x1003 desktop window failed it for being TALL while a
   smaller landscape one passed — "even when there is plenty of room".
   A media query also cannot ask the question that actually matters, which is
   "what diameter would this layout produce for the donut". That is arithmetic
   over a measured box, so it lives in _mgResolveLayout. */
/* Aspect ratio is PRESERVED here (`meet`, set in the markup), unlike a bare
   sparkline: the axis labels are text, and stretching the viewBox would stretch
   them with it. */
.mg-line-svg { width: 100%; height: 100%; display: block; }
/* --mg-line-color is set per render when a merchant is selected, so the line
   matches its slice; unset it falls back to the accent. */
.mg-line-path {
  fill: none; stroke: var(--mg-line-color, var(--accent)); stroke-width: 2;
  stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke;
}
/* The readings. Dimmed against the trend on purpose: they are the evidence and
   the curve is the answer, so the curve has to stay legible through a cloud of
   them. No area fill any more — it was drawn under a line that joined every
   reading, and under a trend it would swallow every dot that fell below it. */
.mg-line-dot {
  stroke: var(--mg-line-color, var(--accent)); opacity: .5;
  stroke-width: 4; stroke-linecap: round; vector-effect: non-scaling-stroke;
}
/* ⚠️ A translucent WHITE, not --border. This chart sits on the hero's purple,
   where --border (#3a3a4c, a dark grey) reads as a dark rule cutting across a
   light ground — the owner spotted the x-axis as "a dark line instead of light,
   like the rest of this view". Everything else on this surface is a white wash;
   the gridlines are too. */
.mg-line-grid { stroke: rgba(255, 255, 255, 0.18); stroke-width: 1; vector-effect: non-scaling-stroke; }
/* The lines BETWEEN the bounds are reference, not scale — dimmed so the two
   that carry the range still lead, the same split the bar chart's
   labelled/minor boundaries make. */
.mg-line-grid.is-minor { stroke: rgba(255, 255, 255, 0.09); }
.mg-line-tick.is-minor { fill: rgba(255, 255, 255, 0.38); }
/* One unit is one CSS pixel now (see _mgLineBox), so this is the size the
   labels actually render at, in the card and full screen alike. 9px was tuned
   for the old fitted viewBox, where full screen scaled it up ~3x. */
.mg-line-tick { fill: rgba(255, 255, 255, 0.6); font-size: 11px; }
/* Full-height tap strips, one per point: a 2.5px dot is not a target. */
/* One strip over the whole plot: the drag is continuous, so per-point targets
   would leave gaps. touch-action: none because the gesture owns the horizontal
   axis, and no-swipe (in the markup) because the tab pager is a JS gesture that
   CSS never reaches. */
.mg-line-hit { fill: transparent; cursor: ew-resize; }
.mg-line-svg { touch-action: none; }
.mg-line-head-rule { stroke: var(--text); stroke-width: 1; opacity: .55; vector-effect: non-scaling-stroke; }
/* Translucent, like the range pill above it, rather than a solid dark disc
   (owner, 2026-08-12). A filled surface colour reads as a control sitting ON
   the chart; a wash reads as part of it, and the chart behind stays visible
   through the grip. */
.mg-line-head-grip {
  fill: rgba(255, 255, 255, 0.07); stroke: rgba(255, 255, 255, 0.35);
  stroke-width: 1; vector-effect: non-scaling-stroke;
}
.mg-line-head-bar { stroke: var(--text); stroke-width: 1; opacity: .8; vector-effect: non-scaling-stroke; }
/* ⚠️ SMALLER ON A POINTER ONLY. The grip is sized for a fingertip and that is
   correct on a phone (owner: "it's fine on mobile"); a cursor hits a 4px target
   exactly, so at finger size it is just a big disc parked on the data. The
   radius is a viewBox unit and the SVG is fitted to its box, so this shrinks it
   on the big screens where it looked worst. */
@media (hover: hover) and (pointer: fine) {
  .mg-line-head-grip { r: 4.5; }
  .mg-line-head-bar { display: none; }
}

/* Badge inside a dropdown row (archived pockets). Muted outline so the row
   still reads as its name first. */
.cs-opt-badge {
  font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .4px;
  color: var(--muted); border: 1px solid var(--border); border-radius: 5px;
  padding: 1px 5px; margin-left: 6px; vertical-align: middle; white-space: nowrap;
}

/* Recommended Pockets: its own full-width row under the Pockets title. */
.pockets-recommended-row {
  width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
  margin-bottom: 12px; font-weight: 500;
}

/* "No transactions" in the amount slot: same position as a figure, plainly not
   one. */
.pm-spend-avg-none { color: var(--muted); font-weight: 500; white-space: nowrap; }

/* The injected Cancel: it turns whatever container the primary sits in into an
   equal-width pair. Applied to the ROW rather than to the buttons so a row that
   already has other actions (the transaction editor's Delete) keeps them. */
/* The row made for a primary whose parent is a form rather than an action row
   (see _saveCancelRow). It inherits the margin the bare button had as a direct
   child of .modal-body, so the Done state looks exactly as it did. */
.save-cancel-row { display: flex; gap: 10px; }
.modal-body > .save-cancel-row { margin-top: 16px; }
.save-cancel-row > button { flex: 1 1 0; min-width: 0; }
/* Buttons that act on the RECORD rather than the form — Reset, and Delete on a
   hand-entered transaction. They stand beside Done and step aside while a
   Cancel is up: you don't reset or delete a thing you are part-way through
   editing, you keep the change or drop it (owner, 2026-08-13).
   ⚠️ Driven from the row class, not from JS writing display, because their own
   visibility belongs to the dialog — a Reset with nothing to reset to, a Delete
   only on a user-entered row. Two owners of one property is how a reopened
   dialog inherited the previous record's state. */
.has-save-cancel > .sc-standing { display: none !important; }
/* One button left in the row — a Reset with nothing to reset to is hidden, so
   Done is alone. Sized to its own text it reads as a control that lost its
   neighbour, so it takes the row (owner, 2026-08-13). Set by _syncRowFill. */
.sc-solo > button { flex: 1 1 auto; width: 100%; }
.has-save-cancel { display: flex; align-items: center; gap: 10px; }
/* ⚠️ The class is doubled for SPECIFICITY, not by accident. Rows like
   `.pocket-modal-actions .btn-primary { flex: 1 }` are (0,2,0) and would beat a
   single-class rule, leaving the primary on an auto basis while the injected
   Cancel sat on a zero one - a 2px difference in rendered width. Same
   equal-halves trap as .pm-funds-row. */
.has-save-cancel.has-save-cancel > button { flex: 1 1 0; min-width: 0; }
/* And the same box-extras rule that trap taught: a zero flex-basis adds border
   and padding on TOP of each button's share, so .btn-primary needs the border
   .btn-secondary has or it comes out 2px wider. */
.has-save-cancel > .btn-primary { border: 1px solid transparent; }


/* ── Your Budget ──────────────────────────────────────────────────────────
   A running column of arithmetic (owner, 2026-08-14): one labelled row per
   figure, amounts right-aligned, the ones that come OUT carrying a minus, and
   each total the sum of the rows above its rule — so the maths is visible
   rather than described. It renders in the full-page Budget screen ONLY — it
   used to appear at the top of the Funding Schedules modal as well, and that
   second home is gone (owner, 2026-08-16): the schedules screen is for setting
   schedules UP, and the same figures are one tap away behind the Budget pill on
   the Good to Go hero.
   ⚠️ `fsb-` prefixed, not `fs-`: `.fs-row`, `.fs-label` and friends are ALREADY
   the funding-schedules ACCORDION's classes, and reusing them gave every row in
   this table that accordion's 1px border. */
/* ⚠️ `.fs-budget-summary` is GONE with the container it styled — it was the
   CARD around this table on the schedules screen. The Budget screen renders the
   same rows with no box, which is why nothing replaced it. */
.fsb-intro { font-size: 13px; line-height: 1.5; color: var(--muted); margin: 0 0 14px; }
.fsb-intro strong { color: var(--text); font-weight: 600; }
.fsb-note { font-size: 12px; line-height: 1.5; color: var(--muted); margin: 12px 0 0; }
.fsb-block { display: flex; flex-direction: column; gap: 4px; }
.fsb-table { display: flex; flex-direction: column; gap: 4px; }
/* ⚠️ The LABEL wraps and the AMOUNT never does. "Amount needed to fund
   Pockets" is 200px of text on a narrow screen, so something has to give, and
   it must not be the number — a wrapped or truncated amount is the one thing
   this table exists to show. Same title-clips / detail-wraps split the pocket
   history rows make, for the same reason. */
.fsb-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.fsb-label { font-size: 13px; line-height: 1.4; color: var(--muted); min-width: 0; }
/* ⚠️ WEIGHT marks the two TOTALS and nothing else (owner, 2026-08-15: "when
   it's bold it's just a list of 3 bold values"). Bold every figure and none of
   them stands out; bold only the sums and each reads as the answer to the block
   above it. Current Good to Go keeps its colour and loses the weight — it is an
   input to the last sum, not a sum.
   ⚠️ 400 against 700, not 400 against 600: at 13px in tabular figures the
   one-step gap was there and unreadable ("hard to tell if the numbers are bold
   or not", owner 2026-08-15). ⚠️ And the SIZE stays 13px — bumping the totals a
   point alongside the weight read as "way too big" (owner, same day). Weight is
   the whole signal; size is a second one nobody asked for. */
.fsb-amt {
  font-size: 13px; font-weight: 400; color: var(--text);
  font-variant-numeric: tabular-nums; white-space: nowrap; flex-shrink: 0;
}
/* The rule under the inputs is what makes the next row read as a sum. */
.fsb-total { border-top: 1px solid var(--border); padding-top: 5px; margin-top: 1px; }
.fsb-total .fsb-label { color: var(--text); font-weight: 600; }
.fsb-total .fsb-amt { font-weight: 700; }
/* ⚠️ Colour is per NUMBER, and only the TOTALS earn one — plus Current Good to
   Go, which is the same figure the hero shows and should look the same. The
   other inputs are facts; a row-level class painted all of them red and made a
   line with one bad figure read as three (owner, 2026-08-14). */
.fsb-amt.fs-bad  { color: var(--red); }
.fsb-amt.fs-good { color: var(--green); }

/* ⚠️ THE PAGE SCROLLS, NOT THE LIST — this REVERSES the 2026-08-15 shape
   (owner, 2026-08-18: "just expand it and let the whole page scroll like
   normal"). That shape gave .fsb-detail a capped height of its own and
   scrolled it, which was right while the screen showed ONE paycheck and the
   only thing that could overflow was the Pocket list. With an accordion the
   PAGE is what overflows: the open row's totals stopped reaching the bottom
   of the screen with nothing able to scroll to them. It also would not have
   survived raising the three-paycheck cap.
   ⚠️ So there is deliberately NO flex chain and NO inner overflow here. The
   body is an ordinary scrolling modal body; an expanded row just makes the
   page longer. If a capped inner scroller ever reappears in this screen, this
   is the bug coming back. */

/* The per-Pocket breakdown, disclosed under the row it explains. Indented, so
   it reads as detail of that line rather than as more terms in the sum. */
.fsb-disclose {
  display: flex; align-items: center; gap: 6px;
  background: none; border: 0; padding: 2px 0 2px 12px; margin: 0;
  font-size: 12px; color: var(--accent); cursor: pointer; text-align: left;
}
.fsb-disclose svg { transition: transform 0.15s; flex-shrink: 0; }
.fsb-disclose.open svg { transform: rotate(180deg); }
.fsb-detail {
  display: flex; flex-direction: column; gap: 3px;
  padding: 4px 0 6px 12px; border-left: 1px solid var(--border); margin-left: 3px;
}

/* ── "Which Pockets, and how much" rows ───────────────────────────────────
   One shape for the Budget screen's breakdown, the Fund Now preview, the Catch
   Up preview and both Catch Up results lists — five lists answering the same
   question, so they share a renderer (_pocketListHtml) instead of five copies
   of an inline style.
   ⚠️ PURE ALPHABETICAL, NO COLOUR, ONE PLAIN BADGE on the three non-Standard
   types (owner, 2026-08-15: "a plain white badge for each type" / "Don't need a
   badge on standard"). Sixth shape in three days: accent on spending funds only
   → per-type coloured badges → coloured section headings → one purple heading →
   headings dropped, colour on the row name → this.
   ⚠️ STANDARD IS BARE, and that is the answer rather than an omission: it is the
   default type and most of a budget's rows, so a pill on every one buries the
   three that carry information. Driven by the absence of a `badge` key in
   POCKET_TYPE_META, not by CSS.
   ⚠️ Per-type coloured badges were tried on 2026-08-14 and rejected, and this
   IS that shape again — with one difference that turned out to be the whole
   thing: Standard is bare, so there is no unbadged row sitting beside three
   coloured pills looking unstyled. What the colour buys, in a form the earlier
   attempts didn't have: it is contained in a PILL rather than tinting the
   pocket's name, so the text you read stays white and the colour is skippable.
   The earlier complaints — "it's not super obvious what it's supposed to be",
   the purple clashing with the Hide control, the green sitting too close to the
   positive-money green — were all about colour applied to the words. */
.pl-row { display: flex; justify-content: space-between; gap: 8px; padding: 4px 0; font-size: 13px; }
/* The badge shares a WRAPPING group with the name, so a long name pushes it to a
   second line rather than squeezing the amount — the amount never wraps, same
   rule the budget table follows. `align-items: center` because baseline-aligning
   a pill against text sits it low. */
.pl-main { display: flex; flex-wrap: wrap; align-items: center; column-gap: 6px; row-gap: 3px; min-width: 0; }
.pl-name { min-width: 0; overflow-wrap: break-word; }
.pl-amt { font-variant-numeric: tabular-nums; flex-shrink: 0; }
/* ⚠️ THE BADGE IS COLOURED; THE POCKET'S NAME STAYS WHITE (owner, 2026-08-15:
   "let's give each badge their color. Words of the pocket stay white though").
   That split is the point of this shape — the colour is contained in a pill the
   eye can skip, instead of tinting the text you are actually reading, which is
   what every earlier colour attempt did and what made a long list feel noisy.
   Shape follows the app's existing badge recipe: the colour on the text, 12%
   behind it, 45% on the border. Size steps down for a compact list row. */
.pl-badge {
  display: inline-flex; align-items: center; flex-shrink: 0;
  font-size: 9px; font-weight: 600; padding: 1px 6px; border-radius: 99px;
  color: var(--text); background: var(--surface2); border: 1px solid var(--border);
}
/* ⚠️ ONE PALETTE, app-wide (owner, 2026-08-15: "Make spending... purple in the
   app"). These three match `.badge-spending-fund` / `.badge-savings-goal` /
   `.badge-credit-card` on a pocket card, and POCKET_TYPE_META's hexes for the
   Pockets-tab group headers. They briefly disagreed — this list had Spending
   Fund purple while the card had it white — and the fix was to move the CARD,
   not this. Change one and change all three. */
.pl-spending_fund .pl-badge {
  color: var(--type-fund); background: var(--type-fund-12); border-color: var(--type-fund-45);
}
.pl-savings_goal .pl-badge {
  color: var(--type-savings); background: var(--type-savings-12); border-color: var(--type-savings-45);
}
.pl-credit_card .pl-badge {
  color: var(--type-card); background: var(--type-card-12); border-color: var(--type-card-45);
}
/* An error outranks the type — what went wrong matters more than what it was. */
.pl-error { color: var(--red); }
.pl-row.pl-error .pl-name { color: var(--red); }
/* Inside the Budget screen's disclosure the rows are secondary detail, so they
   step down a size and the amounts go muted — the figure they explain is the
   one above them. The type colours survive, which is the point. */
.fsb-detail .pl-row { font-size: 12px; padding: 3px 0; }
.fsb-detail .pl-amt { font-weight: 500; color: var(--muted); }

/* ── The band splitter ─────────────────────────────────────────────────────
   Drag the line between the top band and the bars; the donut resizes to the
   new band height and the line takes back whatever width the donut gave up
   (owner, 2026-08-15). Only ever shown in a layout that HAS two bands to
   split — a two-chart fallback has nothing to trade.
   ⚠️ `touch-action: none` AND the pager's `no-swipe` (in the markup), for the
   same reason the donut scrub and the line playhead need both: the first stops
   the browser panning, the second stops the JS tab pager stealing the gesture.
   Neither substitutes for the other. */
/* ⚠️ ABSOLUTELY POSITIONED, in every layout. In flow it would be a fourth thing
   the allocator has to subtract from the budget — and the band heights only
   work because they sum EXACTLY to the content box. Out of flow it costs
   nothing and sits wherever --mg-split-y puts it, which JS derives from the
   same numbers it just wrote. (The hero is already `position: relative` — it is
   the containing block for the hover bubble.) */
.mg-split { display: none; }
.mg-layout-3up .mg-split,
.mg-layout-stacked .mg-split {
  display: flex; align-items: center; justify-content: center;
  position: absolute; left: 0; right: 0; z-index: 3;
  top: var(--mg-split-y); transform: translateY(-50%);
  /* A 3px line is not a target; the strip is 14px and the rule is drawn inside
     it, which is why it is a child element rather than a border. */
  height: 14px; cursor: row-resize; touch-action: none;
}
.mg-split > span {
  display: block; width: 46px; height: 3px; border-radius: 2px;
  background: rgba(255, 255, 255, 0.22);
  transition: background 0.15s ease, width 0.15s ease;
}
@media (hover: hover) { .mg-split:hover > span { background: rgba(255, 255, 255, 0.5); width: 64px; } }
.mg-split:focus-visible { outline: none; }
.mg-split:focus-visible > span { background: var(--accent); width: 64px; }
/* While dragging: no transitions anywhere in the hero (they would fight the
   frame-by-frame resize) and no text selection under the pointer. */
body.mg-splitting { user-select: none; }
body.mg-splitting .gtg-spend-hero * { transition: none !important; }

/* ── The centre readout, moved OUT beside the donut ────────────────────────
   When the ring is too small to hold its own figures (below
   _MG_DONUT_CENTER_MIN of drawn diameter) they used to simply vanish, on the
   reasoning that the heading above repeats them. They now move to a bubble at
   the donut's right instead (owner, 2026-08-15) — the heading only carries the
   window average, not the SELECTED slice's name, amount and share, which is
   exactly what a small donut most needs spelled out.

   ⚠️ SAME ELEMENT, repositioned — not a copy. `position: static` drops it out
   of the absolute centring and into the stage's flex row, so there is one set
   of ids and one writer (_gtgUpdateCenter). A second element would be a second
   thing to keep in step.
   ⚠️ The stage widens by --mg-aside-w to hold it (see .mg-layout-3up above),
   and the allocator only sets that width when the other charts can spare it. */
.gtg-spend-pie-stage.mg-center-aside { gap: 10px; }
.mg-center-aside > .gtg-spend-pie-center {
  position: static;
  /* ⚠️ A FIXED width, not `var(--mg-aside-w)`. The variable is the STAGE's
     reservation, and if the two ever disagreed — a stale value, a pass that
     set 0 — the panel rendered at zero width and the readout was simply gone,
     which is exactly what was reported. The stage reserves the room; this
     just fills it. 150 mirrors MG_ASIDE_W in app.js. */
  width: 150px;
  flex: 0 0 auto;
  /* Left-aligned, because it is a panel now rather than a label centred in a
     hole — and the amount and its caption should share an edge to read down. */
  text-align: left;
  align-items: flex-start;
  /* The bubble treatment the chart's other readouts use, so a figure that
     moves out of the donut still looks like it belongs to it. */
  background: var(--mg-pill-bg, rgba(255,255,255,0.06));
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 10px;
  padding: 10px 12px;
  max-width: none;
}
/* Its own long words (a merchant name, "Average per pay period") have a narrow
   column to sit in, so let them wrap rather than overflow the bubble. */
.mg-center-aside > .gtg-spend-pie-center > * { white-space: normal; }

/* ── Top-bucket lines overlaid on the trend ────────────────────────────────
   ⚠️ Thinner and translucent, and drawn UNDER the main line: they are context
   for it, not competitors. Same `non-scaling-stroke` the main path uses, so a
   widened viewBox can't thicken them. `pointer-events: none` because the
   playhead's hit strip spans the whole plot and these must never take a drag
   meant for it. */
.mg-line-overlay {
  fill: none; stroke-width: 1.5; opacity: 0.62;
  stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke;
  pointer-events: none;
}
/* ⚠️ The trend-overlay toggle's on-chart button is GONE (owner, 2026-08-15) —
   it moved to a Dev Tools row while the chart's on-screen controls are
   reworked. `position: relative` stays on the stage: the playhead's grip is
   positioned against it too. */
.mg-line-stage { position: relative; }

/* ══ CHART OPTIONS AS A POPOVER ═══════════════════════════════════════════
   A bubble that comes out of the gear, not a page (owner, 2026-08-15: "like an
   overflow menu, rather than a modal… with a translucent blur as the
   container"). Dropping `fullscreen-mobile` is what removes the desktop
   right-pane, the injected back arrow and the slide-in — all of which announce
   "you have gone somewhere", and you haven't.

   ⚠️ THE SURFACE IS THE APP HEADER'S, at his ask ("use the same style
   background as the header of the app… the way that goes blurry makes a good
   background"): a dark translucent wash over a heavy backdrop blur, so what is
   behind shows through, reads as dimmed, and never competes with the text on
   top. Taken from #app-header rather than re-invented — if that surface is
   retuned, this should be retuned with it.
   ⚠️ `-webkit-backdrop-filter` alongside, or Safari renders a flat panel and
   the whole effect is a dark box. */
.modal-overlay.mg-opts-pop {
  /* No dimming sheet of its own: the bubble does the dimming, locally, and a
     full-screen scrim would darken the very chart you are adjusting. It still
     covers the viewport so a click outside closes it. */
  background: transparent;
  display: none;
  padding: 0;
}
.modal-overlay.mg-opts-pop.open { display: block; }
.mg-opts-pop > .modal-box {
  position: fixed;
  /* A little room to breathe where there is space, and no more (owner,
     2026-08-15: "not a lot wider"). The descriptions are why it matters — at
     360 nearly every one wrapped to three lines, which is what made a short
     list read as a tall one. */
  width: min(420px, calc(100vw - 20px));
  max-width: none;
  background: rgba(15, 15, 19, 0.72);
  backdrop-filter: blur(14px) saturate(160%);
  -webkit-backdrop-filter: blur(14px) saturate(160%);
  /* "A very light border. White probably. Or a shade of white." */
  border: 1px solid rgba(255, 255, 255, 0.22);
  border-radius: 16px;
  /* ⚠️ NO box-shadow. It was `0 18px 50px rgba(0,0,0,0.5)` — an 18px DOWNWARD
     offset, so its darkest part pooled exactly where the caret hangs, and the
     caret's own wash landed on top of that. THAT is what made the tail read as
     a black rhombus, not the backdrop-root problem I chased first. The border
     and the blur already separate the bubble from the chart, and a shadow that
     everything hanging off the box has to work around is not paying for
     itself. */
  /* ⚠️ `visible`, so the caret below is not clipped off. The SCROLLING moves to
     .modal-body instead — a box cannot both clip its content and grow a
     pointer out of its own edge. */
  overflow: visible;
  /* ⚠️ A FLEX COLUMN capped at the room JS measured above the gear, with the
     body as the only flexible part. That is what keeps the header and Done on
     screen: without it the box took its natural height, ran past the bottom of
     a phone, and Done was cut off with nothing left to scroll (owner,
     2026-08-15). */
  display: flex; flex-direction: column;
  max-height: var(--mg-pop-max, 70vh);
  /* Grows out of the gear rather than fading in place — the movement is what
     says which control it belongs to. */
  transform-origin: var(--mg-pop-caret, 100%) 100%;
  animation: mgPopIn 0.14s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
.mg-opts-pop > .modal-box.pop-below { transform-origin: var(--mg-pop-caret, 100%) 0; }
@keyframes mgPopIn { from { opacity: 0; transform: scale(0.94); } }

/* ⚠️ THE CARET — a rotated square, not a border triangle, because it has to
   carry the SAME wash and blur as the bubble and a `border-color` triangle can
   carry neither. Only its two outer edges take the border, so the join with the
   bubble is seamless.
   It sits at `--mg-pop-caret`, which JS derives from the gear's centre AFTER
   the box has been clamped into the viewport — so it keeps pointing at the gear
   however far the clamp pushed the bubble along. */
/* ⚠️ NO CARET / TAIL, dropped 2026-08-16 after four attempts. Worth recording
   why, so it isn't attempted a fifth time the same way.
   The bubble is TRANSLUCENT over a BACKDROP BLUR. A tail is a separate box, and
   a separate box cannot reproduce that:
     • as a CHILD it cannot blur at all — a parent with a backdrop-filter is a
       backdrop root, and a descendant's filter samples only what is painted
       inside it, which is nothing out past the box's edge;
     • as a SIBLING it can blur, but then it no longer inherits the bubble's
       grow-in animation and the bubble's border draws straight across the join;
     • either way its ground differs from the bubble's — unblurred page versus
       blurred page, plus whatever the box's shadow contributes — so it reads as
       a different colour however the wash is tuned.
   The only version that can work is ONE element whose SHAPE includes the tail
   (a clip-path silhouette, with the border faked by a drop-shadow that follows
   it), so there is a single backdrop-filter and a single ground. That is a real
   piece of work and the bubble reads fine without a tail — it is anchored to
   the gear, and the gear rotates while it is open, which is what actually says
   which control it belongs to.
   `--mg-pop-caret` survives and is still used: it is the grow-in's
   transform-origin, which is what makes the bubble appear to come OUT of the
   gear. */

/* The body is the only flexible part, so the header and Done always keep their
   space and the list gives way instead. */
.mg-opts-pop .modal-header { flex: 0 0 auto; }
.mg-opts-pop .modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
@media (prefers-reduced-motion: reduce) { .mg-opts-pop > .modal-box { animation: none; } }

/* ⚠️ FLAT LIST — the rows lose their boxes and the POPOVER is their container
   (owner, 2026-08-15: "take the buttons out of their outer containers… flat
   list style"). Cards inside a card is what made this read as a settings PAGE;
   one surface with hairline dividers reads as a menu, which is what it is now.
   It also fixes the blur: a stack of opaque `--surface` cards inside a blurred
   panel is an opaque panel with a blurred rim. With the boxes gone the light
   reaches all the way through, which is the whole point of the surface. */
.mg-opts-pop .mg-opt-group,
.mg-opts-pop .mg-opt-row {
  background: none; border: 0; border-radius: 0; margin: 0; overflow: visible;
}
/* One hairline between every pair of rows, ACROSS groups as well as within
   them — the groups are no longer visible objects, so a divider stopping at
   their edges would leave arbitrary gaps down the list. */
/* ⚠️ `.mg-opt-slide` is a wrapper BETWEEN a row and its neighbour, so the plain
   `+` chain no longer reaches through it — the divider above the sliding row
   has to be selected through the wrapper, and the wrapper itself has to act as
   a row for whatever follows it. Miss either and the list loses a hairline
   exactly where two options meet. */
.mg-opts-pop .mg-opt-row + .mg-opt-row,
.mg-opts-pop .mg-opt-row + .mg-opt-slide .mg-opt-row,
.mg-opts-pop .mg-opt-slide + .mg-opt-row,
.mg-opts-pop .mg-opt-group-head + .mg-opt-row,
.mg-opts-pop .mg-opt-group + .mg-opt-group { border-top: 1px solid rgba(255, 255, 255, 0.1); }
.mg-opts-pop .mg-opt-row,
.mg-opts-pop .mg-opt-group-head { padding-left: 0; padding-right: 0; }
/* ⚠️ A row whose control is a SEGMENTED one stacks: label and description
   above, the control full-width beneath. Every other row here ends in a 42px
   pill that sits comfortably beside its text; two labelled buttons do not, and
   side by side they squeeze the description into a column too narrow to read.
   Full width also makes the two options equal targets, which is the whole
   claim a segmented control makes — that these are alternatives, not a setting
   and its absence. */
.mg-opt-row.mg-opt-stack {
  flex-direction: column; align-items: stretch; gap: 10px; cursor: default;
}
.mg-opt-row.mg-opt-stack .seg-ctl { width: 100%; }
/* The bubble's rows are flat and translucent, so an opaque `--surface2` track
   would be the one box left inside it. The active button keeps its fill — that
   IS the selection — but the track behind it goes to glass. */
.mg-opts-pop .mg-grain-ctl { background: rgba(255, 255, 255, 0.06); }
@media (hover: hover) {
  /* No fill on hover — there is no card left to fill. The label brightens. */
  .mg-opts-pop .mg-opt-row:hover { background: none; }
  .mg-opts-pop .mg-opt-row:hover .mg-opt-label { color: #fff; }
}
/* ⚠️ NO rule under the header and NONE above Done (owner, 2026-08-15). Those
   two lines were the last of the container-inside-a-container look: the bubble
   already has an edge, and a rule parallel to it a few pixels in reads as a
   second box drawn inside the first. The dividers BETWEEN rows stay — those
   separate things that are actually separate. */
.mg-opts-pop .modal-header { border-bottom: 0; }

/* ⚠️ IT SLIDES UP UNDER THE ROW ABOVE — it does not just collapse (owner,
   2026-08-16: "as if we were scrolling up and it was scrolling underneath the
   include not in pockets"). TWO animations in step, and the second is the one
   the first attempt was missing:
     • the WRAPPER is a 0fr→1fr grid row, which shrinks the space it occupies —
       the only way to transition to a height nobody has measured;
     • the ROW inside translates up by its own height, so it MOVES rather than
       being clipped from the bottom edge. Without it the row stayed put and
       the box closed over it from below, which reads as a blind coming down,
       not as sliding under.
   The clipper between them is what hides the part that has moved past the top,
   and the two share a duration so the row's bottom edge lands exactly as the
   space reaches zero — the divider below meets the divider above. */
.mg-opt-slide { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.24s ease; }
.mg-opt-slide.is-open { grid-template-rows: 1fr; }
.mg-opt-slide-clip { overflow: hidden; min-height: 0; }
.mg-opt-slide .mg-opt-row { transform: translateY(-100%); transition: transform 0.24s ease; }
.mg-opt-slide.is-open .mg-opt-row { transform: none; }
/* Closed, it must take NO divider with it — otherwise the collapsed wrapper
   leaves a stray hairline where the row used to be. */
.mg-opts-pop .mg-opt-slide:not(.is-open) .mg-opt-row { border-top: 0; }
@media (prefers-reduced-motion: reduce) {
  .mg-opt-slide, .mg-opt-slide .mg-opt-row { transition: none; }
}
/* The body's padding is the list's only inset now, and what the rows sit
   against. Its row-gap goes with the cards it used to separate. */
.mg-opts-pop .modal-body { gap: 0; }

/* The gear turns while its bubble is up: the control and the thing it opened
   read as one object. */
.moneygo-options-btn svg { transition: transform 0.18s cubic-bezier(0.2, 0.9, 0.3, 1.2); }
.moneygo-options-btn.is-open { opacity: 1; }
.moneygo-options-btn.is-open svg { transform: rotate(60deg); }

/* ⚠️ A HEADER WITH A TRAILING CONTROL GROUP MUST GROUP IT AT THE RIGHT.
   `.modal-header` is `justify-content: space-between`, which spreads THREE
   children evenly — so an overflow between the title and the ✕ landed in the
   middle of the header, reading as though it belonged to neither (owner,
   2026-08-17). Growing the title absorbs the free space instead, so the
   controls sit together where every other dismiss control is.
   Scoped to headers that actually carry one: with only a title and a ✕,
   space-between already does the right thing, and a globally growing h2 would
   change truncation on every modal in the app. */
/* ⚠️ Also covers a header carrying a + button (Funding Schedules), for the
   same reason — three children under space-between spread evenly, so the
   control lands mid-header instead of grouping with the ✕. */
/* ⚠️ A header control needs room from the ✕ (owner, 2026-08-18: "+ Schedule
   button is too close to the X"). `.modal-header` is space-between with no gap
   — fine while its two children sat at opposite edges, and once the title grows
   to absorb the slack the trailing controls end up flush against each other.
   The gap goes on the HEADER so any future control gets it too. */
.modal-header:has(> .pocket-header-btn) { gap: 12px; }
.modal-header:has(> .bank-menu-wrap) > h2,
.modal-header:has(> .pocket-header-btn) > h2 {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── Budget screen: the upcoming-paycheck list ───────────────────────────
   One row per upcoming paycheck. NOT an accordion — any number may be open at
   once (owner, 2026-08-18: "no accordian. Allow them to be expanded
   together"). It was one for a single revision and he replaced it: with the
   figures off the heads there is nothing to read from a collapsed row but its
   date, so closing one to open another costs a comparison you were probably
   making.
   The head leads with the DATE because collapsed this is a list you scan down
   a column of dates; the "your paycheck from X lands on Y" sentence only reads
   for the first row and is false on the third, so it lives INSIDE the open
   one. */
.fsb-acc { border-bottom: 1px solid var(--border); }
.fsb-acc:last-of-type { border-bottom: none; }
.fsb-acc-head {
  display: flex; align-items: center; gap: 10px;
  width: 100%; padding: 14px 0; background: none; border: none;
  color: var(--text); font-size: 15px; text-align: left; cursor: pointer;
}
.fsb-acc-caret { flex: 0 0 auto; color: var(--muted); transition: transform 0.15s; }
.fsb-acc-head.open .fsb-acc-caret { transform: rotate(180deg); }
.fsb-acc-date { flex: 0 0 auto; font-weight: 600; }
/* The schedule name is the only variable-length thing on the row, so it is the
   part that gives way — never the date, which is what the row is scanned for.
   Same rule the Good to Go pill follows for the same reason. */
.fsb-acc-name {
  flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: 13px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ⚠️ There is deliberately NO amount rule here. The collapsed head carries no
   figure (owner, 2026-08-18: "ditch your stupid summary when collapsed. No. I
   don't want numbers there."). A rule reappearing means one is being put back. */
.fsb-acc-body { padding-bottom: 6px; }


/* ⚠️ EVERY paycheck in trouble is marked here, not just the one the Good to Go
   pill named (owner, 2026-08-18: "All that are negative or short are red"). The
   pill has room for one verdict and picks the soonest; this list is where the
   whole set is visible, so suppressing the others would make the list agree
   with the pill by hiding what the pill had no room for.
   The DATE takes the colour, not the whole row: it is the row's identity, and
   colouring the muted schedule name too would read as one long red phrase. */
.fsb-acc-head.is-trouble .fsb-acc-date { color: var(--red); }

/* ⚠️ NO PRESS-IN SCALE ON THESE TWO (owner, 2026-08-18: "shrink weirdly and too
   much ... I don't think we make expanding things shrink like that anywhere
   else"). The global `button:active { transform: scale(0.97) }` is sized for a
   COMPACT button, where 3% is a few pixels. These are full-width rows, so the
   same 3% moves both edges ~7px each and reads as the row collapsing rather
   than being pressed — and unlike a button that navigates, the row is about to
   change size anyway, so the two animations fight.
   The same opt-out .nav-item and .profile-identity-action already take, for the
   same reason: a row is not a button-shaped thing. */
.fsb-acc-head:active,
.fsb-disclose:active { transform: none; }

/* ── Stuck-Pocket explainer + picker search ──────────────────────────────── */
.stuck-p { font-size: 14px; line-height: 1.55; color: var(--text); margin: 0 0 12px; }
.stuck-p:last-child { margin-bottom: 0; }


/* ⚠️ No rule above the buttons here (owner, 2026-08-18). `.tx-filter-actions`
   carries a border-top for the FILTER panels, where it separates a long
   scrolling form from its actions. This dialog is three short paragraphs that
   never scroll, so the line divides nothing and just boxes the buttons off. */
#stuck-pocket-overlay .tx-filter-actions { border-top: none; }

/* ── Pick a transaction to link ──────────────────────────────────────────
   ⚠️ A ROW IS A SINGLE CHOICE here — tapping it links. That is the whole
   difference from the recommendations modal, whose rows carry Accept and
   Dismiss and which is a review queue, not a picker. */
.tx-pick-search {
  width: 100%; padding: 11px 13px; font-size: 15px; flex-shrink: 0;
  background: var(--surface2); color: var(--text);
  border: 1px solid var(--border); border-radius: 10px;
}
.tx-pick-search::placeholder { color: var(--muted); }
.tx-pick-row {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 12px 0; background: none; border: none;
  border-bottom: 1px solid var(--border);
  color: var(--text); font-size: 15px; font-weight: 400; text-align: left;
}
.tx-pick-row:last-child { border-bottom: none; }
/* The merchant is the only variable-length part, so it is the part that gives
   way — never the amount, which is half of how you recognise the row. */
.tx-pick-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.tx-pick-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The sub-line carries the date AND the account badge. It wraps rather than
   squeezing: an account name is user text ("USAA Classic Checking") and on a
   narrow phone it can be wider than the date beside it. Wrapping matches
   `.tx-card-meta`, which does the same with the same badge. */
.tx-pick-sub { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
.tx-pick-date { font-size: 12px; color: var(--muted); }
.tx-pick-amt { flex: 0 0 auto; font-variant-numeric: tabular-nums; }
/* Same green `.tx-amount.credit` uses, so a deposit reads identically here and
   in the Transactions list. */
.tx-pick-amt.credit { color: var(--green); }
.tx-pick-note { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }

/* ── Which schedules fund a Pocket ───────────────────────────────────────
   ⚠️ The MARK carries the cardinality: a square for a standard Pocket (subset,
   default all) and a circle for a flat one (exactly one, default primary).
   Those are the two meanings a tick has here — add, versus move — so the shape
   has to say which before it is pressed. */
.pc-cycles-note { font-size: 13px; color: var(--muted); line-height: 1.5; margin: 0 0 4px; }
.pc-cycle-row {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 12px 0; background: none; border: none;
  border-bottom: 1px solid var(--border);
  color: var(--text); font-size: 15px; font-weight: 400; text-align: left;
}
.pc-cycle-row:last-child { border-bottom: none; }
.pc-cycle-row:active { transform: none; }   /* a row, not a button — see .fsb-acc-head */
.pc-cycle-mark {
  flex: 0 0 auto; width: 20px; height: 20px; border-radius: 6px;
  border: 1.5px solid var(--border); background: var(--surface2);
  position: relative; transition: background 0.12s, border-color 0.12s;
}
.pc-cycle-mark.is-radio { border-radius: 999px; }
/* ⚠️ BOTH ROW TYPES. The mark is reused by the Assign Pockets list, whose
   rows are .ac-row — scoped to .pc-cycle-row alone it rendered an empty box
   on every selected Pocket there, so the only feedback was the action button
   waking up (owner-reported, 2026-08-18). Any new consumer of .pc-cycle-mark
   needs adding here, which is the argument for keying off the mark itself.
   */
.pc-cycle-row.is-on .pc-cycle-mark,
.ac-row.is-on .pc-cycle-mark { background: var(--accent); border-color: var(--accent); }
.pc-cycle-row.is-on .pc-cycle-mark::after,
.ac-row.is-on .pc-cycle-mark::after {
  content: ''; position: absolute; inset: 0;
  background: no-repeat center/12px 12px
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1.5 6.5 4.5 9.5 10.5 2.5'/%3E%3C/svg%3E");
}
.pc-cycle-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.pc-cycle-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pc-cycle-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ── Assign Pockets to Schedules ─────────────────────────────────────────
   The assignment column is MUTED at its default ("All schedules") and takes
   the text colour only when the Pocket is limited — so a screen of defaults
   reads as unconfigured, and the exceptions are what your eye lands on. */
.ac-controls { display: flex; gap: 8px; flex-shrink: 0; }
/* ⚠️ Both verbs at once, each disabled when it is a no-op — see the note at
   #ac-bulkbar in index.html. Plain text buttons: the no-decorative-symbols rule
   applies, and there is no room in .ac-controls for a third pill (three
   flex: 1 1 0 buttons split a 320px phone into ~90px, against ~102px of
   "Only limited"). */
.ac-bulkbar { display: flex; align-items: center; gap: 16px; padding: 2px 2px 0; }
.ac-bulk-btn {
  background: none; border: none; padding: 4px 0;
  font-size: 13px; font-weight: 600; color: var(--accent); cursor: pointer;
}
.ac-bulk-btn:disabled { color: var(--muted); opacity: 0.6; cursor: default; }
.ac-bulk-count { margin-left: auto; font-size: 12px; color: var(--muted); }
.ac-filter-btn { flex: 1 1 0; padding: 9px 12px; font-size: 13px; }
.ac-filter-btn.is-on { background: var(--accent); color: #fff; border-color: var(--accent); }
.ac-row {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 11px 0; background: none; border: none;
  border-bottom: 1px solid var(--border);
  color: var(--text); font-size: 15px; font-weight: 400; text-align: left;
}
.ac-row:last-child { border-bottom: none; }
.ac-row:active { transform: none; }
.ac-main { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 8px; }
.ac-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ac-sched {
  flex: 0 1 auto; min-width: 0; font-size: 12px; color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ac-sched.is-limited { color: var(--text); }

/* ── PINNED BOTTOM CHROME STAYS BEHIND THE KEYBOARD ───────────────────────────
   The rule this file has carried since 2026-08-14: a bar pinned to the bottom
   of the page must never surface ON TOP of the on-screen keyboard. Three
   different mechanisms have been needed to keep it true, because the keyboard
   reaches the web layer in two completely different ways.

   ⚠️ TWO REGIMES, OPPOSITE CORRECTIONS — see _initKeyboardViewportPin in
   app.js for the full account. In short:
     (a) LAYOUT-SHRINK — the layout viewport gets shorter, so a bottom-anchored
         bar rides UP onto the keyboard, by the height the keyboard took.
     (b) VISUAL-PAN — the layout viewport is untouched and the browser instead
         SCROLLS the visual viewport down inside it to reveal the field. Every
         `position: fixed` box is anchored to the LAYOUT viewport, so the bar
         that was safely below the fold is dragged up into the visible band.
         `--kb-pan` is exactly that scroll offset; translating down by it
         leaves the bar where it was in SCREEN terms — behind the keyboard —
         while the content still rises to show the field. This is the regime
         the Android shell entered on 2026-08-25 when it stopped padding its
         root view for the IME, and the one every browser honouring
         `interactive-widget=resizes-visual` was always in.
         ⚠️ Giving the content more bottom room is the obvious alternative and
         only a PARTIAL fix: the browser scrolls a scrollable ancestor before it
         pans, so room helps a field inside a scrolling `.modal-body` and does
         nothing for one in a box that does not scroll. Those still pan. The two
         compose — room that succeeds drives `--kb-pan` to 0 and these rules go
         inert on their own — so this is the floor, not a competing approach.

   ⚠️ TWO VARIABLES CARRY THE CORRECTIONS, and `--app-kb` is NOT one of them —
   it is the keyboard's HEIGHT, written in BOTH regimes, and its only job is the
   scroll room above. The corrections are `--kb-lift` (the shrink plus the pan,
   for chrome nothing has corrected yet) and `--kb-pan` (the pan alone).

   ⚠️ THE TWO GROUPS BELOW ARE NOT INTERCHANGEABLE — a bar in the wrong one is
   corrected twice and lands a whole keyboard-height too low, leaving a band of
   backdrop where it used to be. What separates them is WHAT DECIDES THE BAR'S
   BOTTOM EDGE, not what it looks like:
     · LAYOUT-ANCHORED bars are `position: fixed` to the viewport (or the last
       child of a fixed, inset:0 overlay that is not height-pinned). Nothing has
       corrected them yet, so they take `--kb-lift`.
     · BOX-ANCHORED bars are the last flex child of a full-page
       `.modal-overlay.fullscreen-mobile > .modal-box`, whose height is ALREADY
       held at its pre-keyboard value by `--app-vh` in regime (a). They take
       `--kb-pan` ONLY — `--kb-lift` would correct them a second time.

   ⚠️ Gated on `html.kb-up` rather than left permanently at `translateY(0px)`.
   A transform — even a zero one — makes its element a containing block for
   `position: fixed` descendants, which would re-anchor any fixed dropdown
   opened from one of these bars (`.cs-menu` is fixed and placed from a measured
   rect). Only while the keyboard is up, so nothing else in the app changes.

   ⚠️ NO `transition`. `--kb-pan` tracks a live scroll offset — it changes on
   every visualViewport scroll event while the keyboard is up — so an eased
   transform lags the pan and the bar creeps back into view mid-gesture. These
   bars are moving off-screen; there is nobody to see the snap. */

/* LAYOUT-ANCHORED — both terms. */
html.kb-up #bottom-nav,
html.kb-up .wiz-actions,
html.kb-up .modal-overlay.wiz-skin #pc-action-row {
  transform: translateY(var(--kb-lift, 0px));
}
/* Same group, but these already carry a centring translateX that must survive. */
html.kb-up #tx-select-bar,
html.kb-up .cat-reorder-bar {
  transform: translateX(-50%) translateY(var(--kb-lift, 0px));
}

/* BOX-ANCHORED — pan only; `--app-vh` already holds the box open in regime (a). */
html.kb-up .pm-actions-bar,
html.kb-up .tx-save-row,
html.kb-up .tx-filter-actions,
html.kb-up .txi-footer,
html.kb-up #ps-onboarding-finish {
  transform: translateY(var(--kb-pan, 0px));
}
