/* ONE THEME: DARK. Zenith is a phone app its owner reads in the evening, and
   he asked for the light half to go (operator, 2026-08-13: "for this project I
   don't want light and dark mode"). The dark values were merged into :root and
   the `[data-theme]` overrides and `prefers-color-scheme` blocks deleted, so
   there is one place a colour is defined rather than three. Verified by
   snapshotting the computed colour, background and border of 15 constructs
   before and after: identical. */
:root {
  /* The build this stylesheet belongs to. app.js compares it against the build
     the DOCUMENT was served as: a service worker falling back to cache when the
     network blips can pair a new page with an old stylesheet, which is how the
     operator got circular tabs and a half-drawn screen. Kept in step with
     sw.js's CACHE_NAME by a test. */
  --build: "zenith-shell-v898";

  /* text */
  --ink: #e4eae7;
  --muted: #98a5a0;
  /* structure */
  --line: #313b37;
  --surface: #1a211e;
  --surface-2: #232c28;
  --page: #10150f;
  --group: #242e2a;
  --group-nested: #1d2521;
  /* THE EMPTY PART OF A PROGRESS BAR IS STILL PART OF THE BAR.
     This was --group (#242e2a) against a card on --surface-2 (#232c28) -- one
     point of red and two of green apart, which is no difference at all on a
     phone. So the unspent remainder read as a hole in the card rather than as
     track, and the only segment that looked like bar was the current week,
     because its wash was the sole thing giving the track any tone (operator,
     2026-08-15: "the color between the actual spending and the week marker
     [shouldn't] be the same color as the background... it's the same color as a
     background, which is a little confusing").
     Its own token, not a nudge to --group: --group is a grouping surface used
     across the app and this is a track. Two jobs, two names, so changing one
     cannot silently move the other. The ladder from here is deliberate:
     card < track < current-week wash < fill. */
  --bar-track: #333f3a;
  /* header / dark bands (text that sits on the always-dark header) */
  --header: #0e1512;
  --on-header: #eaf1ee;
  --on-header-muted: #9fb0a9;
  /* brand */
  --accent: #178a70;        /* interactive fill (white text sits on it) */
  --accent-dark: #12735d;   /* hover fill */
  --accent-line: #3fbe9d;   /* active underlines / total top-border */
  --on-accent: #ffffff;
  --link-fg: #5cc7aa;
  --focus: #3fbe9d;
  /* inputs & buttons */
  --input-bg: #232c28;
  --input-border: #41504a;
  --btn-secondary-bg: #232c28;
  --btn-secondary-border: #41504a;
  --topbar-btn-border: #4a5a53;
  /* table chrome */
  --th-bg: #232e2a;
  /* THE TOTAL ROW IS THE TABLE'S BOTTOM CHROME, so it takes the same colour as
     the table's TOP chrome. It used to take --header, the TOPBAR green-black
     (#0e1512), on the reasoning that a total pinned to the viewport bottom
     bookends the brand bar at the top. That holds only while it is pinned --
     and several tables (.joint-debt-table, .budget-*-table, everything inside a
     dialog) set the row `position: static`, where it is not app chrome at all
     but a band inside a card, roughly 8 shades darker than every other row
     around it (operator, 2026-08-14: "especially that black backgrounded
     subtotal line"). Header #232e2a, group rows #242e2a, body #1a211e -- the
     total at #0e1512 was the only near-black thing in the table. */
  --total-row-bg: var(--th-bg);
  /* WHERE A LEDGER ROW'S VALUE COLUMN BEGINS, measured from the row's own left
     edge. The Transfers card and the Contribution table are two different
     constructs -- a flex row and a <table> -- drawing the same kind of line, and
     the operator reads them stacked (2026-08-14: "Left Align the amount with
     the 1964 due in the section above"). Deriving both from one value keeps
     them aligned if either is retuned; matching them by eye would not survive
     the next change to either. */
  --ledger-value-x: 8rem;
  /* Dedicated header text: --muted (#64706b) on --th-bg only reaches 3.84:1, so
     column headers get their own darker token verified at 5.0:1 on --th-bg. */
  --th-fg: #98a5a0;
  --tab-count-bg: #2b3833;
  --view-tabs-border: #33403b;
  --bar-week-line: rgba(255, 255, 255, 0.42);
  --dialog-border: #3a4641;
  --backdrop: rgba(0, 0, 0, 0.58);
  --toast-bg: #2b3833;
  --toast-fg: #eaf1ee;
  /* connection pill */
  --conn-border: #4a5a53;
  --conn-online-fg: #9fe3c4;
  --conn-online-border: #3f7a5f;
  --conn-error-fg: #f4b6ae;
  --conn-error-border: #8a5651;
  /* state colours */
  --danger: #e08078;
  --danger-soft: #3a2320;
  --on-danger: #2a1310;
  --warning: #cea764;
  --warning-soft: #362b16;
  --warning-strong: #d69235;
  /* A TRUE ORANGE, distinct from --warning. The runway bar runs a
     four-step scale (operator, 2026-08-11: "we could still have green
     yellow orange red") and --warning-strong is only a shade deeper
     than --warning -- side by side on one bar they read as the same
     colour, so the scale would have had three steps wearing four
     names. */
  --caution: #c18258;
  /* THE RED THE BARS USE, which is NOT --danger. --danger is a salmon (L* 76)
     tuned to stay legible as TEXT on a dark surface; painted as a solid block
     it reads pink, and -- worse -- it is LIGHTER than the orange step before
     it, so the ramp got softer exactly where the trouble got worse (operator,
     2026-08-14: "The orange is too bright... It was pink before. I'm not sure
     why."). This one is a real red and continues the ramp downward: the four
     steps now run L* 71 -> 71 -> 57 -> 49. Do not merge it back into --danger,
     which has 28 consumers, most of them text. */
  --bar-over: #b86259;
  /* PIE / CATEGORY PALETTE. Muted, and ORDERED BY HUE so the ring sweeps warm
     to cool rather than jumping (operator, 2026-08-12). Hues are spread FIRST
     -- no two closer than 25 deg -- because sorting by hue deliberately puts
     the nearest colours side by side, and because "Other" sorts wherever its
     total lands, so any pair can end up adjacent. The ring also wraps: the last
     slice touches the first.
     SEPARATION IS MEASURED IN LAB (deltaE), NOT IN HUE DEGREES. The first
     version spread the hues 27 deg apart and still merged: at 24-28%
     saturation a hue gap carries almost no perceptual difference, and moss vs
     teal came out deltaE 22 with lightness 54 vs 52 -- one weak axis
     (operator, 2026-08-12: "the colors are so muted I can't really tell the
     difference between the blue and the green"). Three cool hues in one region
     was the crowding; the teal became a plum, and the closest pair is now
     deltaE 30 with no more saturation.
     Deeper in light mode: these read on a dark card at 40-54% lightness and
     wash out on white. "Other" is not here -- it takes --muted, being the
     absence of a category rather than one. */
  --pie-1: #bd7556;
  --pie-2: #ab903f;
  --pie-3: #438949;
  --pie-4: #4672a4;
  --pie-5: #96619e;
  --success: #62c187;
  --success-soft: #1e3227;
  /* badges */
  --badge-bg: #252f2b;
  --badge-border: #3d4a44;
  --badge-legal-fg: #f0a49c;
  --badge-legal-border: #7a4741;
  --badge-prelegal-fg: #e6bb6d;
  --badge-prelegal-border: #6d5a2c;
  --badge-settlement-fg: #78d29b;
  --badge-settlement-border: #3c6b4e;
  --bucket-joint-fg: #7fd4bd;
  --bucket-joint-bg: #1c342d;
  --bucket-joint-border: #3a6b5c;
  --bucket-personal-fg: #c3a9e8;
  --bucket-personal-bg: #2b2440;
  --bucket-personal-border: #54467a;
  /* elevation */
  --shadow: 0 12px 34px rgba(0, 0, 0, 0.5);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-md: 0 1px 3px rgba(0, 0, 0, 0.45), 0 6px 18px rgba(0, 0, 0, 0.4);
  /* raised-panel top edge: light needs none (drop shadow reads on white), dark
     overrides this with a hairline light inset so elevation reads on near-black. */
  --raise-edge: inset 0 1px 0 rgba(255, 255, 255, 0.05);
  /* subtle row tint (hover / quiet fills) */
  --surface-alt: #1f2723;
  /* spacing scale — one 4px ramp; every pad/margin/gap snaps to a step */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  /* radius scale */
  --radius-sm: 5px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-pill: 999px;
  /* type scale */
  --fs-eyebrow: 11px;
  --fs-caption: 12px;
  --fs-cell: 13px;
  /* THE MISSING TOKEN. 15px is used all over the app -- every card's headline
     figure, every rideshare week row -- but had no name, so new components
     reached for --fs-cell (13px) and came out visibly smaller than what sat
     beside them (operator, 2026-08-03: "increase font size for this section,
     they seem smaller than other text on that tab"). A size the app uses
     everywhere belongs in the scale; the alternative is everyone guessing. */
  --fs-figure: 15px;
  /* A HIGHLIGHT, not a second colour. At .42 this grey wash sat over the fill
     and turned the current week into a visibly different shade of whatever the
     fill already was, so one bar showed the same status in two tones and the
     palette stopped meaning anything (operator, 2026-08-11: "all different
     colors don't really tell a story"). Lowered so it reads as "you are here"
     over the fill rather than as its own state. */
  --bar-week-now: rgba(160, 160, 160, 0.20);
  /* --bar-week-edge retired 2026-08-11: the bar rule it coloured always landed
     on a week divider and read as an extra tick.
     --bar-week-over-edge SURVIVES, but for TEXT only -- it is what turns the
     "Week N of M" label amber once the week's share is spent
     (.track-week-now.is-over). Deleting it along with the bar rule left that
     label resolving an undefined custom property, which is not an error, just a
     silently dropped colour. Caught by grepping every CONSUMER rather than
     every caller. Amber, not red: red already means over the MONTH's budget on
     the bar beside it, and the two must stay distinguishable. */
  --bar-week-over-edge: rgba(245, 189, 92, 0.95);
  --fs-title: 21px;
  --fs-kpi: 21px;
  /* motion */
  /* THREE SPEEDS, AND THEY ARE NOT THE SAME KIND OF THING.
     --fast is DIRECT MANIPULATION: a row pressing under a finger. Any delay
     here reads as lag, not polish, so it stays at 120ms however slow the rest
     gets.
     --base is a CONTEXT CHANGE: a sheet arriving, a tab or a level swapping.
     iOS presents a sheet in roughly 300-400ms, and at 200ms this read as a cut
     -- "should we slow down so it's more gradual instead of instant" (operator,
     2026-08-13). 280ms is inside that band and still short enough that an app
     opened forty times a day never feels like it is waiting on you.
     --slow is something arriving ON ITS OWN, unasked: a figure changing under
     the reader. It can afford to be gentle because nobody is waiting for it. */
  --motion-fast: 120ms;
  --motion-base: 380ms;
  --motion-slow: 420ms;
  --ease: cubic-bezier(0.2, 0.6, 0.2, 1);
  /* A GENTLE OVERSHOOT for things that ARRIVE. Pure ease-out decelerates into
     place and reads as correct-but-flat; a few percent past the mark and back
     is what makes a sheet feel like an object rather than a fade. Used only on
     entrances -- never on anything a finger is holding, where overshoot reads
     as the control slipping. */
  --ease-spring: cubic-bezier(0.34, 1.32, 0.64, 1);
  /* Brand mark — the header logo tile reuses the favicon's ledger-bars motif.
     These are a fixed brand asset (same greens as favicon.svg), so they are NOT
     redefined per theme: the tile is self-coloured and reads on both light and
     dark headers, exactly like an app icon. */
  --brand-tile-1: #1a8b74;
  --brand-tile-2: #0f5b4d;
  color-scheme: light;
  --rideshare-uber: #2f9d84;
  --rideshare-lyft: #7fd8bd;
  --accent-fill: #157a63;
}

/* Dark palette — the same green finance dashboard, dark. Reds/greens/ambers are
   lightened so they clear 4.5:1 on the dark surfaces; the header stays a deep
   green so the safe-area band and grand-total row read as the same brand mark. */
:root { --rideshare-uber: #147864; --rideshare-lyft: #79c3a9;
        --rideshare-rest: #cfd9d5; }




* { box-sizing: border-box; }
html {
  background: var(--page);
}
/* The rule under the tabs, spanning the full content width while the labels sit
   inset from it. */
.view-tabs::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: var(--line);
}
body {
  margin: 0;
  color: var(--ink);
  background: var(--page);
  font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select, textarea { font: inherit; }
button, select { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible {
  /* Full opacity, not a 40% mix. Composited against the dark surfaces the mix
     measured 2.28:1 on the topbar buttons and 1.29:1 on the primary button --
     below the 3:1 WCAG 1.4.11 floor and, on the primary, effectively invisible.
     A focus indicator nobody can see is the same as outline:none for anyone
     navigating by keyboard. The scoped overrides elsewhere already use the full
     colour and measure 7.09:1. */
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.topbar {
  min-height: 76px;
  padding: 14px 22px;
  background: var(--header);
  color: var(--on-header);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 18px;
  /* Freeze the brand bar to the top while content scrolls. --topbar-h is
     measured in JS so the tab row below can sit flush under it at any height. */
  position: sticky;
  top: 0;
  z-index: 30;
}
.topbar h1 { margin: 0; font-size: var(--fs-title); font-weight: 700; letter-spacing: -0.015em; }
.topbar p { margin: 3px 0 0; color: var(--on-header-muted); font-size: 12px; }
/* Identity tagline under the wordmark — a quiet uppercase eyebrow, distinct from
   the operational freshness line below it. Tinted brighter than the timestamp so
   the name/tagline reads as one brand unit and the freshness reads as metadata. */
.brand-tagline {
  margin: 2px 0 0;
  color: var(--on-header);
  opacity: 0.72;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.09em;
}
.top-actions { display: flex; align-items: center; gap: var(--space-2); }

/* Header brand lockup — the logo mark + title + freshness line read as one unit.
   The mark is centred against the two-line title block; min-width:0 lets the
   freshness line truncate rather than push the actions off the row. */
.brand { display: flex; align-items: center; gap: var(--space-3); min-width: 0; }
.brand-text { min-width: 0; }
.brand-mark {
  flex-shrink: 0;
  width: 28px;
  height: 28px;
  display: block;
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
}
/* The tile gradient + ledger art are routed through the brand tokens so the mark
   stays in lock-step with the favicon and re-skins from one place. White art on
   the green tile is the mark's own artwork (like an app icon), constant in both
   themes. */
.brand-stop-1 { stop-color: var(--brand-tile-1); }
.brand-stop-2 { stop-color: var(--brand-tile-2); }
.brand-edge { stroke: #ffffff; stroke-opacity: 0.14; }
.connection { font-size: 12px; padding: 4px 8px; border: 1px solid var(--conn-border); border-radius: 4px; color: var(--on-header); }
.connection.online { color: var(--conn-online-fg); border-color: var(--conn-online-border); }
.connection.error { color: var(--conn-error-fg); border-color: var(--conn-error-border); }

/* Theme toggle — sits in the header actions as an understated bordered icon
   button, matching the topbar's secondary buttons. The moon reads "switch to
   dark"; the sun reads "switch to light". Only one shows per theme. */


/* The bottom pad clears the iOS home indicator. Without the safe-area inset the
   last table row and the total sat under it -- readable only by over-scrolling,
   which a sticky-header page does not do gracefully. */
main {
  padding: 14px 18px calc(28px + env(safe-area-inset-bottom, 0px));
}
/* THE POSITIONING CONTEXT for a view sliding out (see .is-leaving) -- the views
   only, never the tab strip above them. */
.view-stage { position: relative; }
/* THE CLIP ONLY EXISTS WHILE SOMETHING IS SLIDING.
   `overflow-x: hidden` was declared here permanently, to stop a view travelling
   a full screen-width from widening the page. It also makes `main` a scroll
   CONTAINER, which broke the resting layout: the first card was cut off at the
   top and the space under the tab strip went dead (operator, 2026-08-15, with a
   screenshot: "The top is completely cut off"). A property needed for 420ms was
   left switched on for the other 99% of the time.
   Scoped to the transition instead -- added when a slide starts, removed when
   it ends -- so the normal state is exactly what it was before. */
/* clip-path, NOT overflow. `overflow-x: hidden` makes the element a scroll
   CONTAINER, which is how the first attempt at this clipped the top of the page
   and ate the space under the tabs. clip-path clips the paint without creating
   a scroll container, so nothing about scrolling or stickiness changes. */
.view-stage.is-switching { clip-path: inset(0); }
/* Segmented control, not an underlined text row. Four bare words with a rule
   under the active one reads like a document's table of contents; a segmented
   control reads like a control you operate, which is what this is -- and it is
   the idiom every desktop finance app uses for exactly this job.
   The tabs SPREAD across the full width (equal segments), and the track's own
   padding keeps the first and last label off the screen edge -- equal widths
   look deliberate inside a visible track, where as bare text they just looked
   scattered. */
.view-tabs {
  position: relative;
  /* Clearance between the rule and the first card. The thicker underline sat
     almost on top of the content below it. */
  margin-bottom: var(--space-3);
  /* UNDERLINE TABS. The segmented track needed every segment the same width, and
     with labels from "Debt" to "Cash Flow" that left the short ones floating in
     space. Removing the boxes removes the problem: with nothing to size, the
     labels simply space out evenly and their different lengths stop mattering
     (operator, 2026-07-30: "Since there are different lengths, should we remove
     the borders and just use underlines and space them all out evenly").
     It also drops a whole material off the top of the screen -- this row now
     reads as navigation rather than as another row of cards. */
  display: flex;
  justify-content: space-between;
  gap: 4px;
  /* Inset to match a CARD'S TEXT, not the card's edge. space-between pins the
     first and last labels flush, and at 10px they sat closer to the screen edge
     than anything below them -- the cards start at 10 but their contents start
     at 22 (operator, 2026-07-30: "I don't want the tab names too close to the
     edge"). 12px puts "Accounts" on the same left edge as "Food" underneath it.
     The rule itself stays full width -- see ::after -- because it is a divider
     across the content, not part of the label row. */
  padding: 0 12px;
  border: 0;
  border-radius: 0;
  margin: -2px 0 12px;
  /* The sticky element keeps the PAGE background so scrolling content cannot
     show through the track's rounded corners; the track itself is painted by
     ::before, inset by this padding. */
  position: sticky;
  top: var(--topbar-h, 76px);
  z-index: 20;
  /* Back to the page colour. There is no track any more -- but it must stay
     OPAQUE, because this is sticky and scrolling content must not show through
     behind the labels. */
  background: var(--page);
}
/* Second-level navigation, shown only under Debt.
   Styled as UNDERLINED TABS, not as the pill used by the Settled/Paid filters
   further down the same screen. Two identical-looking controls doing different
   jobs is a trap, and on the Debt tabs they end up a few hundred pixels apart.
   Sticks directly beneath the top row: --tabs-h is measured in JS because the
   row's height changes with width, and a hard-coded offset would either overlap
   the row above or leave a gap. */
/* `display: flex` below OVERRIDES the `hidden` attribute -- an author rule beats
   the UA's `[hidden] { display: none }` -- so without this the bar was invisible
   on Cash and Bills but still reserving 43px of empty space above the content. */
.view-subtabs[hidden] { display: none; }

/* Every [hidden] guard in one place. An author `display` rule outranks the UA's
   `[hidden] { display: none }`, so ANY element the app hides via the attribute
   while its class sets a display keeps its box: invisible, still taking space,
   still hit-testable. The design review found three more of these after the
   first was fixed, including a nav that was painted AND clickable while
   `hidden === true`. Guarding `.button` immunises every future one. */
.tabs[hidden] { display: none; }
.button[hidden] { display: none; }
.link-button[hidden] { display: none; }
.joint-detail-list > div[hidden] { display: none; }

.view-subtabs {
  position: sticky;
  /* +6px of clearance. --topbar-h and --tabs-h are MEASURED in JS, and when
     either reads a pixel or two under the real height the sub-tabs stick too
     high and slide UNDER the tab row above -- shaving the top off the pills.
     Measured overlap was 2px here and the operator saw more on his phone, where
     the chrome is taller. Cheaper to leave a gap than to chase the measurement. */
  top: calc(var(--topbar-h, 76px) + var(--tabs-h, 57px) + 6px);
  z-index: 19;
  display: flex;
  /* Wider than the pills needed: with no boxes, the space between labels IS the
     separation. */
  gap: 20px;
  /* Was -10px, pulling the sub-tabs up against the row above. That worked when
     both rows were floating pills; against a segmented TRACK with a hard edge
     it read as crowding (operator, 2026-07-30: "Move the sub tabs down"). */
  margin: 4px 0 12px;
  /* Enough bottom padding that content scrolling underneath does not crowd the
     active underline -- a table row sliding up to touch it reads as a glitch. */
  padding: 0 0 10px;
  background: var(--page);
  max-width: 640px;
}
/* CHIPS, not a second segmented control and not bare text with a rule under it.
   The parent tabs are a full-width segmented pill; repeating that idiom one
   level down reads as two competing controls, while the underlined plain text
   it replaced read as unstyled next to the pill above it (operator, 2026-07-29:
   "the plain text looks odd"). A capsule that fills when selected is the
   ordinary iOS answer for a secondary filter row -- clearly related to the
   control above, clearly subordinate to it. */
/* UNDERLINES, not pills. The primary row became a segmented track on
   2026-07-30, and two segmented tracks stacked would compete for which is the
   senior control -- while leaving these as pills made the SECONDARY row look
   like the primary one used to, which is worse. A different material at each
   level is what makes "Debt > Personal" legible in one glance: a solid object
   above, bare text below. Operator asked whether they should match; they should
   not. */
.view-subtab {
  appearance: none; cursor: pointer;
  padding: 5px 2px;
  min-height: 32px;                    /* thumb-sized without being a button */
  font: inherit; font-size: 13px; font-weight: 600;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-bottom: 2px solid transparent;
  border-radius: 0;
  white-space: nowrap;
  transition: background var(--motion-fast) var(--ease),
              color var(--motion-fast) var(--ease),
              border-color var(--motion-fast) var(--ease);
}
/* The underline carries the selection; no fill, no border box. The parent row
   above owns the solid shape, which keeps it the louder of the two. */
.view-subtab.active {
  color: var(--ink);
  font-weight: 700;
  border-bottom-color: var(--accent-line);
}
.view-subtab:hover:not(.active) { color: var(--ink); }

/* CHIPS at both levels, one language, two weights. The segmented pill here and
   the chip row underneath were two different materials stacked on each other,
   which is what read as incoherent -- the parent looked like a control and the
   child looked like filters. Hierarchy now comes from SIZE and from which one
   fills with accent, not from a different idiom.
   Capped rather than edge-to-edge: across a 1440px window equal segments give
   each label ~345px of empty pill, which reads as a stretched button. */
.view-tabs { max-width: 640px; }
.view-tab {
  /* Share the row rather than hugging their labels. Three primary tabs sized to
     "Cash", "Bills", "Debt" left two thirds of the row empty and gave the most
     used control in the app a small target (operator, 2026-07-30: "Can you try
     having bigger pills?"). Equal widths also stop the row reflowing when a
     label changes. */
  flex: 1 1 0;
  /* EQUAL widths, so the labels sit on an even rhythm. flex-basis:0 alone was
     not enough: a segment will not shrink below its content, so "Cash Flow"
     took 102px and left the others 85 (operator, 2026-07-30: "The tab names need
     to be spaced evenly"). min-width:0 lets every segment take exactly its
     share, and the padding is sized so the longest label still fits inside it.
     Centring was never the problem -- a button centres its text already. */
  /* Sized to its own label now, not to an equal share -- that is the point of
     dropping the boxes. space-between on the row puts equal gaps between them. */
  flex: 0 0 auto;
  min-height: 40px;                /* the tap target is the label plus this */
  /* Room under the label so the thicker rule is not printed against it. */
  padding: 8px 2px 10px;
  text-align: center;
  border: 0;
  /* THICKER (operator, 2026-07-31: "I want to underline beneath the tabs to be
     thicker. Make sure there's enough space underneath it though"). At 2px it
     read as a hairline beneath 16px/700 labels.
     The first attempt at this edited `.view-subtab` -- the block ABOVE this one
     and the first match in the file -- so the measurement still said 2px.
     Rule 0e again: match the selector, not the declaration. */
  border-bottom: 3px solid transparent;
  border-radius: 2px 2px 0 0;
  color: var(--muted);
  background: transparent;
  font-size: 16px;
  font-weight: 600;
  white-space: nowrap;
  transition: background var(--motion-fast) var(--ease),
              color var(--motion-fast) var(--ease),
              border-color var(--motion-fast) var(--ease);
}
.view-tab:hover:not(.active) { color: var(--ink); }
/* The primary row FILLS with accent; the secondary row uses the tinted version
   below. Two solid green rows stacked would fight, and neither would read as
   the one you are actually in. */
/* The underline carries the selection. Nothing fills, nothing raises. */
.view-tab.active {
  color: var(--ink);
  font-weight: 700;
  background: transparent;
  border-bottom-color: var(--accent-line);
  margin-bottom: -1px;             /* sits ON the row's own rule, not under it */
  /* ABOVE the row's rule, not under it. `.view-tabs::after` is absolutely
     positioned, so it painted over the middle of this border: the underline
     spans y222-225 and the 1px rule sits at y223-224, leaving green above and
     below with grey through the centre -- "two smaller lines" (operator,
     2026-07-31). At 2px the rule covered all but one pixel and it read as a
     thin line; thickening it to 3px is what exposed the overlap.
     A positioned, stacked element paints after the parent's ::after, so the
     3px border now covers the 1px rule completely. */
  position: relative;
  z-index: 1;
}
.view-tab:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.view-tab.active .tab-count { background: var(--accent); color: var(--on-accent); }
.dashboard-view[hidden] { display: none; }
/* THE TAB SWITCH. This is the interaction the app gets most -- four tabs, forty
   times a day -- and it was a hard cut, which is most of why the first pass at
   motion "didn't look any different" (operator, 2026-08-13). A view arriving
   from the direction of its tab is the one movement that is impossible to
   miss. */
/* RETIRED 2026-08-15. `view-in` rose the incoming view 22px; `view-enter` then
   did the same job 12px the other way, and only won because it is declared
   later in this file -- two rules of equal specificity describing one movement.
   Both are replaced by the paired horizontal slide below, which is the one the
   operator asked for: "having the current one slide off to the left and the new
   one come in from the right at the same time". */
/* One rule for the whole class of bug above. Keep it: without it, every new
   component that sets `display` has to remember its own [hidden] line. */
[hidden] { display: none !important; }
.kpi-strip {
  display: grid;
  grid-template-columns: repeat(6, minmax(130px, 1fr));
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  overflow: hidden;
  background: var(--surface);
  box-shadow: var(--shadow-sm), var(--raise-edge);
  margin-bottom: var(--space-3);
  /* Freezes under the brand bar + tab row (--chrome-h is their combined height,
     measured in JS) so the headline numbers stay visible while the tables
     scroll. The strip is opaque, so rows pass cleanly beneath it. */
  position: sticky;
  top: var(--chrome-h, 122px);
  z-index: 15;
}
/* The sticky pin is for the PAGE strip, which freezes under the app chrome.
   Inside a dialog the scroll container is .dialog-content, so `top: --chrome-h`
   pushed the strip down 128px from its flow position and its z-index painted it
   over the content below -- the whole budget-history table, the Save-minimum
   controls, and nine joint fields. */
dialog .kpi-strip { position: static; z-index: auto; }
.kpi-strip > div { padding: var(--space-2) var(--space-3); border-right: 1px solid var(--line); min-height: 58px; }
.kpi-strip > div:last-child { border-right: 0; }
.kpi-strip > div > span:first-child { display: block; }
.kpi-strip span { display: block; color: var(--muted); font-size: var(--fs-eyebrow); font-weight: 600; letter-spacing: 0.01em; }
/* A stat VALUE never wraps: a wrapped value makes the tile taller than its
   neighbours and the whole strip ragged. Long values truncate with an ellipsis
   and carry the full text in a title tooltip (set in app.js). */
/* Cadence suffixes are VALUES, not labels: keep them inline and lower-case.
   The Title Case sweep targets `.kpi-strip span`, which these are, so they were
   rendering as "/Mo" and "/Wk". */
.kpi-strip .cad, .kpi-strip .cad-sep { display: inline; text-transform: none; }
.kpi-strip strong { display: inline-block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; margin-top: var(--space-1); font-size: var(--fs-kpi); font-weight: 700; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
.kpi-strip .danger-kpi strong { color: var(--danger); }
/* Estimated / Actual pairing: headline strong = planned, sub-line = spend to date.
   The sub renders as a small, non-eyebrow line so it doesn't compete with the label. */
/* One row: the Actual figure sits beside the planned value rather than on a
   third line. nowrap keeps 'Actual $N.NN' together -- letting it break mid-pair
   reads worse than the stacked version it replaced. */
.kpi-strip .kpi-sub { display: inline; white-space: nowrap; margin-top: 0; margin-left: var(--space-2); color: var(--muted); font-size: 12px; font-weight: 600; text-transform: none; letter-spacing: 0; font-variant-numeric: tabular-nums; }
.kpi-strip .kpi-sub b { font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }
.kpi-strip .kpi-sub.danger-sub b { color: var(--danger); }
/* The Expenses cell is a SUM of the components before it (Bills + Food +
   Discretionary); an accent left rule separates it as a computed total. */
.kpi-strip .kpi-sum { border-left: 2px solid var(--accent-line); }
.alerts-list { display: grid; gap: 8px; }
.alert-item { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 13px 14px; border: 1px solid var(--line); border-left: 5px solid var(--warning); border-radius: 6px; background: var(--surface); }
.alert-item.critical { border-left-color: var(--danger); }
.alert-item.high { border-left-color: var(--warning-strong); }
.alert-item.medium { border-left-color: var(--success); }
.alert-heading { display: flex; align-items: center; gap: 8px; }
.alert-heading h2 { margin: 0; font-size: var(--fs-figure); }
.alert-copy p { margin: 5px 0; }
.alert-actions { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; justify-content: flex-end; }

.filter-toggle { display: none; }   /* desktop: filters are always visible */
.controls {
  display: flex;
  align-items: end;
  gap: 9px;
  flex-wrap: wrap;
  padding: var(--space-3);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm), var(--raise-edge);
  margin-bottom: var(--space-3);
}
.controls label { display: grid; gap: 3px; color: var(--muted); font-size: var(--fs-eyebrow); font-weight: 600; letter-spacing: 0.01em; }
.controls input[type="search"] { min-width: 270px; }
/* Mute the search placeholder toward --muted so an empty field reads empty, not
   like a typed value. --muted clears 4.5:1 on the input surface in both themes;
   opacity:1 defeats the browser default that dims it further. */
.controls input[type="search"]::placeholder { color: var(--muted); opacity: 1; }
.controls input[type="search"], .controls select, .form-grid input, .form-grid select, .form-grid textarea {
  min-height: 34px;
  border: 1px solid var(--input-border);
  border-radius: var(--radius-sm);
  color: var(--ink);
  background: var(--input-bg);
  padding: 6px 8px;
}
/* Segmented filter: three mutually exclusive states on one axis. Reads as a
   control you set, not a box you tick -- which matters because "Unsettled" is a
   view of the book, not the absence of a filter. */
/* This row is a FLEX wrap, overriding the .controls grid. The grid was written
   for a search box plus four selects; a 4-button segmented control does not fit
   one of its tracks, so the segset overflowed its cell and the Legal risk chip
   was painted on top of it -- on a phone, tapping "Settled" toggled Legal risk.
   Caught by a click that reported the chip intercepting pointer events. */
.debt-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
/* The label belongs to the control beside it, so it sits closer to it than the
   controls sit to each other. */
.control-label + .segset { margin-left: -4px; }
.debt-controls > label, .debt-controls > .segset { width: auto; }
.segset {
  /* Never shrink below the buttons inside it: a squeezed track leaves the last
     segment painted outside its own background. */
  flex: 0 0 auto;
  display: inline-flex; align-items: stretch;
  /* No padding: the selected pill reaches the edge of the track rather than
     floating inside it with a ring of background showing around it. */
  padding: 0; gap: 0;
  overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius-pill);
  background: var(--group);
}
.seg {
  min-height: 30px; padding: 4px 12px;
  border: 0; border-radius: var(--radius-pill);
  background: transparent; color: var(--muted);
  font: inherit; font-size: 13px; font-weight: 600; white-space: nowrap; cursor: pointer;
  transition: background var(--motion-fast) var(--ease), color var(--motion-fast) var(--ease);
}
.seg:hover:not(.is-on) { color: var(--ink); }
.seg.is-on { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: var(--shadow-sm), var(--raise-edge); }
.seg:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
/* Bucket size as a superscript on the control -- so "how many are unsettled" is
   answered before you tap, not after. */
.seg-count {
  margin-left: 3px; font-size: 9px; font-weight: 700; line-height: 1;
  vertical-align: super; font-variant-numeric: tabular-nums; opacity: .75;
}
.seg-count.is-zero { opacity: .4; }
.seg.is-on .seg-count { opacity: .9; }
/* A single-button track, for an axis that is on or off rather than one-of-N. */
.segset-solo .seg.is-on { background: var(--danger); color: #ffffff; }
.seg-legal.is-on { box-shadow: var(--shadow-sm); }

/* Chip toggles. Kept for any future on/off filter; the debt controls now use
   the segmented pills above, which read as one control set rather than a
   segmented control with a checkbox stuck beside it. */
/* .controls is a grid at narrow widths, so the chip row has to claim the whole
   row explicitly -- otherwise it inherits a single ~1/3 column and every chip
   wraps onto its own line, which is how it turned into a 230px stack. */
.toggle-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; width: 100%; grid-column: 1 / -1; }
/* Legal risk keeps its own colour even when off: the standing rule is that
   severe legal terms stay easy to SEE, and that includes the control. */
.toggle-chip.is-legal:has(input:checked) { border-color: var(--danger); background: var(--danger); }
.button {
  min-height: 34px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 6px var(--space-3);
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  font-weight: 600;
  text-decoration: none;
  white-space: nowrap;
  transition: background var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease);
}
/* The button fill gets its OWN token rather than reusing --accent. White text
   on the dark-mode accent (#178a70) measures 4.28:1, under the 4.5:1 floor for
   body-size text -- but --accent also paints borders, the subtab underline and
   the deposit panel edge, where it is doing its job and darkening it would just
   dull them. Light mode already passes at 5.38:1 and is left alone. */
.button.primary { color: var(--on-accent); background: var(--accent-fill, var(--accent)); border-color: var(--accent-fill, var(--accent)); }
.button.primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.button.secondary { color: var(--ink); background: var(--btn-secondary-bg); border-color: var(--btn-secondary-border); }
.topbar .button.secondary { color: var(--on-header); background: transparent; border-color: var(--topbar-btn-border); }

.table-shell {
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface);
  box-shadow: var(--shadow-sm), var(--raise-edge);
  overflow: auto;
  /* NO MIN-HEIGHT WHEN THERE IS CONTENT. This was a flat `min-height: 360px`,
     which is right for the long accounts table and wrong for every short one:
     a two-row Contribution table and a five-row Personal Debt table were each
     padded out to 360px, leaving dead space under the last row and pushing
     whatever came next off the screen (operator, 2026-08-14: "Make this all fit
     on one page", and the empty band under Contribution).

     The floor exists so the loading skeleton has somewhere to draw, so it now
     applies while loading and not after -- `.is-loading` is removed by
     clearLoading() once the data lands. */
  min-height: 0;
  max-height: calc(100vh - 230px);
  /* The gap to whatever follows. Was --space-4, which combined with the next
     section's own --space-2 top margin to put 24px between two tables that
     read as one list. */
  margin-bottom: var(--space-2);
  /* Theme the scrollbars so the horizontal bar under the sticky footer isn't a
     bright-white strip in dark mode. */
  scrollbar-width: thin;
  scrollbar-color: var(--line) transparent;
}
.table-shell.is-loading { min-height: 360px; }
.table-shell::-webkit-scrollbar { height: 11px; width: 11px; }
.table-shell::-webkit-scrollbar-track { background: transparent; }
.table-shell::-webkit-scrollbar-thumb {
  background: var(--line);
  border-radius: 6px;
  border: 2px solid var(--surface);
}
.table-shell::-webkit-scrollbar-corner { background: transparent; }
table { border-collapse: separate; border-spacing: 0; width: 100%; }
/* SCOPED TO THE PAGE. This floor exists for the wide Debts grid, which lives in
   a horizontally scrolling .table-shell. On the bare `table` selector it also
   applied inside dialogs, where there is no such shell: a popout table ran
   1760px wide inside a 980px dialog and its last money columns rendered off the
   right of the SCREEN, reading as simply missing. Dialogs sit outside <main>. */
main table { min-width: 1760px; }
/* Give the slack to PURPOSE (free text, benefits from room) and keep the money
   column tight -- letting the trailing money column absorb it pushed the
   right-aligned figures ~1000px away from the row they belong to. */
.heloc-table th:nth-child(1), .heloc-table td:nth-child(1) { width: 1%; white-space: nowrap; }
.heloc-table th:nth-child(3), .heloc-table td:nth-child(3) { width: 100%; }
.heloc-table th:nth-child(2), .heloc-table td:nth-child(2) { width: 1%; white-space: nowrap; }
.heloc-table th:nth-child(4), .heloc-table td:nth-child(4) { width: 1%; white-space: nowrap; }
.heloc-table th:last-child, .heloc-table td:last-child { width: 1%; white-space: nowrap; }
.heloc-table { min-width: 760px; }

/* Hairline-only rhythm: horizontal dividers carry the row structure; vertical
   grid lines are dropped so the table reads as a designed ledger, not a
   spreadsheet. Right-aligned tabular numerals keep the money columns legible
   without needing separators between them. */
/* One shared row height across every tab -- Budget, Personal Debt, Joint
   Debt and HELOC previously ran 30/40/29-65/32px and read as four
   different apps. */
/* Data cells stay on ONE line (design rule: a wrapped value makes its row
   taller than its neighbours and the table reads ragged). Long free text is
   handled per-column with an ellipsis + title tooltip; group/subtotal rows opt
   back out below since they legitimately span the table. */
td { white-space: nowrap; }
.group-row td, .subtotal-row td, .grand-total-row td { white-space: normal; }
.col-grouped { display: none; }
/* Numeric and date columns shrink to their content; the free-text columns take
   the slack. width:1%+nowrap is the shrink-to-fit idiom -- max-width is ignored
   on table cells under auto layout. */
#accounts-table th.number, #accounts-table td.number,
#accounts-table th.date-cell, #accounts-table td.date-cell { width: 1%; white-space: nowrap; }
#accounts-table td:nth-child(2) .clip-cell,
#accounts-table td:nth-child(3) .clip-cell { max-width: none; }
/* Two labels per creditor; desktop shows the full one, phones the short one. */
.nm-short { display: none; }
th, td { height: 28px; padding: 3px var(--space-3); border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; font-size: var(--fs-cell); }
/* Freeze the first column so the row's identity stays visible while the wide
   money columns scroll horizontally. Each cell type carries its own opaque
   background (rows, group headers, subtotals, the grand total) so scrolling
   columns pass cleanly beneath. z-index sits under the sticky <th> row (3) so
   the corner cell still stacks correctly. */
th:first-child, td:first-child { position: sticky; left: 0; z-index: 2; background: var(--surface); }
th:first-child { z-index: 4; background: var(--th-bg); }
tbody tr:hover td:first-child { background: var(--surface-alt); }
.group-row td:first-child { background: var(--group); }
.group-row.nested td:first-child { background: var(--group-nested); }
.grand-total-row td:first-child { background: var(--total-row-bg); z-index: 5; }
th {
  position: sticky;
  top: 0;
  z-index: 3;
  background: var(--th-bg);
  color: var(--th-fg);
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.04em;
  white-space: nowrap;
}
td { background: var(--surface); }
td.number, th.number { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.account-row { cursor: pointer; }
.account-row td { transition: background var(--motion-fast) var(--ease); }
.account-row:hover td { background: var(--surface-2); }
.account-name { display: block; font-weight: 600; max-width: 340px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.account-id { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
/* Free-text columns are held to a single line: truncate with an ellipsis and
   carry the full text in a title attribute (set in app.js) so nothing is lost.
   This keeps rows compact and phone-friendly rather than growing the table wide
   enough to fit the longest note. */
.clip-cell, .collector, .status-cell, .joint-notes, .heloc-tx-memo {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Desktop has the room: 200px was clipping collector names like 'Midland
   Credit Management'. The phone block tightens these again. */
.clip-cell { max-width: 300px; }
.collector { max-width: 330px; }   /* fits 'Resurgent Capital Services' */
.status-cell { max-width: 260px; }
/* Group headers PIN under the column header while you scroll, so the bucket /
   category a row belongs to is never off screen. Each one holds until the next
   group pushes it up. 28px = the header row height. */
.group-row td {
  background: var(--group); font-weight: 700; vertical-align: middle;
  position: sticky; top: 28px; z-index: 2;
}
.group-row.nested td { top: 56px; z-index: 1; }
.group-row.nested td { background: var(--group-nested); }
.group-label { display: flex; align-items: center; gap: 7px; }
.group-toggle {
  width: 24px;
  height: 24px;
  border: 1px solid var(--btn-secondary-border);
  background: var(--btn-secondary-bg);
  color: var(--ink);
  border-radius: 3px;
  padding: 0;
  font-weight: 700;
}
.group-count { color: var(--muted); font-size: 11px; font-weight: 500; }
.date-cell { white-space: nowrap; }

/* An em-dash marks a value that is genuinely absent -- not settled, nothing paid
   yet, no payoff term. It is deliberately quieter than a real figure so a
   settled row reads as populated at a glance, but stays clearly legible rather
   than looking like a rendering failure. Colour comes from currentColor so it
   stays correct on the body rows and on the dark grand-total row in either
   theme. */
.blank-value {
  display: inline-block;
  min-width: 14px;
  color: color-mix(in srgb, currentColor 45%, transparent);
  font-weight: 400;
  cursor: help;
}
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
  .blank-value { opacity: 0.45; }
}

/* Grand total over every visible (filtered) account. Pinned to the bottom of the
   scroll container and given the header treatment so it never reads as just
   another group subtotal. */
.grand-total-row td {
  position: sticky;
  /* Pin ABOVE the iOS home indicator, not under it. `bottom: 0` put the book's
     headline total behind the indicator on a real phone -- page padding cannot
     help a sticky element, which is positioned against the viewport. */
  bottom: env(safe-area-inset-bottom, 0px);
  z-index: 4;
  background: var(--total-row-bg);
  color: var(--on-header);
  font-weight: 700;
  vertical-align: middle;
  border-top: 2px solid var(--accent-line);
  border-bottom: 0;
}
.grand-total-row .group-count { color: var(--on-header-muted); }
/* NOTHING PINS INSIDE A DIALOG, and nothing pins in a short table. `th` sticks
   to --chrome-h and the grand total to the bottom of the viewport; both are
   calibrated against the PAGE, and a dialog scrolls in its own box where those
   offsets mean nothing -- the header lands mid-table and draws over the rows.
   
   This was fixed three times by naming tables one at a time: the totals in the
   budget tables, then their headers, and then the projection table's header did
   exactly the same thing because it was not on the list. Scoping it to `dialog`
   covers every table in every popout, including ones not written yet. Same
   class as `main table` and `dialog .kpi-strip`: page geometry has no meaning
   in a modal. */
/* `:first-child` is a pseudo-CLASS, so `th:first-child` scores (0,1,1) and beats
   a plain `dialog th` at (0,0,2) -- the first attempt at this rule looked right
   and changed nothing, because the pinned column outranked it. The first-child
   variants are spelled out to win. */
dialog th,
dialog th:first-child,
dialog td:first-child,
dialog .grand-total-row td,
dialog .grand-total-row td:first-child { position: static; }
/* THESE THREE STAY UNPINNED, and the split is not drift -- it was removed on
   2026-08-08 as "an inconsistency" and put straight back the same day.
   #accounts-table is long and scrolls inside its shell, so a header that holds
   while the figures move is worth having. These three are SHORT. `th` carries
   `top: var(--chrome-h)` for the page-scroll case, and on a two-row table that
   offset pushes the header DOWN past its own first row: measured on
   Contributions at table-top 391, row 1 at 419-447 and the header at 447-475 --
   an empty band where the heading belongs, then a row, then the heading under
   it. The operator saw a Contribution card with a blank strip and only one of
   his two paycheques (2026-08-09: "what's happening here?").
   A header cannot pin usefully inside a box shorter than its own offset. */
.budget-income-table .grand-total-row td,
.budget-bills-table .grand-total-row td,
.joint-debt-table .grand-total-row td,
.budget-income-table th,
.budget-bills-table th,
.joint-debt-table th { position: static; }
/* The shared .table-shell budget of 230px is smaller than the space the debts
   header + KPI strip + controls actually occupy (measured 308px on desktop), so
   the shell used to overflow the viewport and the bottom-sticky total pinned to
   a scrollport edge that was itself below the fold. Budget for the real stack so
   the grand total stays on screen. */
@media (min-width: 901px) {
  #view-debts .table-shell {
    /* Measured stack above the table (333px) + its own 16px margin + main's 28px
       bottom padding. The PAGE must never scroll -- chrome stays put and the
       table scrolls inside itself. On a 982px-tall screen every account fits
       with no scroll at all; at 900px it scrolls ~95px internally. */
    max-height: calc(100dvh - 377px);
    min-height: 240px;
  }
}
/* Below 900px the KPI strip goes 2-up and the controls wrap, so the stack above
   the debts table measures ~635px -- far more than the shared 320px budget, and
   the 360px min-height alone would push the shell (and its pinned grand total)
   below the fold. Budget for that real phone stack and drop the min-height so the
   whole footer row stays on screen; the table scrolls internally in what remains.
   dvh tracks the mobile toolbar; vh is the fallback for engines without it. */
@media (max-width: 900px) {
  #view-debts .table-shell {
    min-height: 88px;
    max-height: calc(100vh - 360px);
    max-height: calc(100dvh - 360px);
  }
}
.risk-list { display: flex; flex-wrap: nowrap; gap: 4px; min-width: 155px; white-space: nowrap; }
/* ONE in-row status chip. `.badge` (priority, risk, contribution kind),
   `.bill-pill` (bill payment state), `.settle-pct` (settlement share) and
   `.pill` (autopay and friends) are the same construct in four places, and
   they had drifted to four padding pairs, three font weights and two radii --
   `.badge` hard-coding `3px`, which is not any token this stylesheet defines.
   Geometry lives here once; the classes below set COLOUR only, so a variant
   can never re-litigate the shape. Weight is 650 throughout: `.badge` was 400
   and carries the legal-risk labels, which the invariants require to stay easy
   to see, so the flattening makes those louder rather than quieter. */
/* `.txn-flag` joined this list on 2026-08-13. It is a fifth in-row status chip
   -- "Processing", "One-off", "Split" -- and it had been carrying its own
   padding, weight, tracking and radius, rendering 19px tall beside a 21px
   badge. That is precisely the drift the comment above says this shared rule
   fixed, one chip short of complete. */
.badge, .bill-pill, .settle-pct, .pill, .txn-flag {
  display: inline-block;
  padding: 2px 7px;
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  font-size: var(--fs-eyebrow);
  font-weight: 650;
  white-space: nowrap;
  vertical-align: middle;
}
.badge { border-color: var(--badge-border); background: var(--badge-bg); color: var(--ink); }
.badge.high, .badge.legal { color: var(--badge-legal-fg); background: var(--danger-soft); border-color: var(--badge-legal-border); }
.badge.medium, .badge.prelegal { color: var(--badge-prelegal-fg); background: var(--warning-soft); border-color: var(--badge-prelegal-border); }
.badge.low, .badge.settlement { color: var(--badge-settlement-fg); background: var(--success-soft); border-color: var(--badge-settlement-border); }
/* Critical outranks high. High is a tinted red; critical is a filled red, so the
   two are separable at a glance and neither can be mistaken for a low badge. */
.badge.critical {
  color: var(--on-danger);
  background: var(--danger);
  border-color: var(--danger);
  font-weight: 700;
  letter-spacing: 0.02em;
}
/* Sized so it does not inflate the row beyond the shared height. The 44px
   touch target is restored via the ::after overlay in the mobile block. */
.row-action { min-height: 20px; padding: 0 8px; }
/* Row navigation: a CHEVRON, not an "Open" button.
   The whole row already opens the account, so a labelled button repeated on
   every row was redundant -- and on a 393px phone it cost ~80px of 371px, a
   fifth of the screen spent restating what the row does. The chevron is the
   standard "this leads somewhere" affordance and costs ~28px.
   It stays a real <button> rather than a click handler on the <tr>: a row is
   not a control, so making it one breaks table semantics for screen readers
   and leaves keyboard users with nothing to focus. This keeps both. */
.go-cell { width: 1%; text-align: right; }
.row-open {
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; padding: 0;
  border: 0; border-radius: var(--radius-sm);
  background: transparent; color: var(--muted); cursor: pointer;
  transition: color var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease);
}
.row-open svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.25; stroke-linecap: round; stroke-linejoin: round; }
/* The chevron brightens with the row, so hovering anywhere on the row shows
   what the click will do. */
.account-row:hover .row-open { color: var(--ink); }
.row-open:hover, .row-open:focus-visible { color: var(--ink); background: var(--btn-secondary-bg); }
/* Rows are interactive: give the pointer and the keyboard the same feedback. */
.account-row:active td { background: var(--surface-2); }
/* Paid-in-full tick beside a joint total (phones only -- see the Amount-paid
   note in the max-width:600 block). Muted-green so it reads as a state, not as
   another figure competing with the number it annotates. */
/* "filtered" flag on the Personal Debt total row: the one signal that this
   figure is smaller than the book total in the tiles above, not in conflict
   with it. Only rendered while a filter is actually narrowing the set. */
.filtered-flag {
  margin-left: 8px; padding: 1px 6px; border-radius: var(--radius-pill);
  background: var(--tab-count-bg); color: var(--muted);
  font-size: var(--fs-eyebrow); font-weight: 600; letter-spacing: 0.02em; text-transform: none;
}
/* Who contributes what, shown on the section heading so the split reads while
   the section is COLLAPSED. Quiet on purpose: context for the total beside it,
   not a competing figure. */
/* The share chips. `margin-left: auto` pushes them to the end of the heading
   row, and `flex-shrink: 0` keeps them whole -- without it they were the item
   that gave way and ended up overrunning the card's own padding by 2px, so they
   read as flush against the edge (operator, 2026-07-30: "The buttons for the
   split are too close to the edge"). */
/* The share chips, at the end of the heading row. NOT flex-shrink:0 -- I tried
   that and they overflowed the card by 34px, because the row genuinely had more
   in it than fits at 393px. The fix was removing an item from the heading (Days
   Off moved into the body), not forcing these to keep their size. */
.section-breakdown {
  display: inline-flex;
  gap: 4px;
  margin-left: auto;
  text-transform: none;
}
.income-share {
  margin-left: 0; padding: 1px 6px; border-radius: var(--radius-pill);
  background: var(--tab-count-bg); color: var(--muted);
  font-size: var(--fs-caption); font-weight: 600; font-variant-numeric: tabular-nums;
}
/* Settlement share, beside the figure. Colour alone would not carry it, so the
   pill says which it is: "50% settled" vs "10% offer". Most of this book is
   offers, and an offer read as a settlement is the expensive mistake. */
.settle-pct {
  margin-left: 8px; letter-spacing: 0.01em;
  font-variant-numeric: tabular-nums;
}
.settle-pct.nm-short { display: none; }
.settle-pct.is-agreed { background: var(--badge-settlement-bg, var(--group)); color: var(--success); }
.settle-pct.is-offer { background: var(--tab-count-bg); color: var(--warning); }
/* Stand-in for the chevron column on phones, where that column is dropped to
   make room for Amount paid. The row is tappable either way; this keeps the
   "leads somewhere" cue. */
.row-chevron { display: none; margin-left: 5px; color: var(--muted); font-weight: 700; }
/* The name cell holds three things -- name, settlement badge, chevron -- and only
   the NAME may shrink. The flex container has to be a DIV inside the cell, not
   the cell itself: `display:flex` on a <td> takes it out of the table's column
   sizing and the column grew instead of shrinking (205px -> 287px). */
.name-cell { display: flex; align-items: center; min-width: 0; }
.name-cell .account-name { flex: 1 1 auto; min-width: 0; }
.name-cell > .settle-pct, .name-cell > .row-chevron { flex: 0 0 auto; }
.paid-mark { margin-left: 6px; color: var(--success); font-weight: 700; }
.paid-mark.is-partial { color: var(--muted); }

/* Info tip: reference detail on demand, anchored to the tile it describes.
   Used for the HELOC loan terms, which were a permanent second KPI strip --
   three tiles of text you read once a month, holding a row above the data you
   open the tab for. */
.kpi-info-cell { position: relative; }
.info-button {
  position: absolute; top: 6px; right: 8px;
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; padding: 0;
  border: 0; border-radius: 50%;
  background: transparent; color: var(--muted); cursor: pointer;
  transition: color var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease);
}
.info-button svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.info-button:hover, .info-button:focus-visible, .info-button[aria-expanded="true"] {
  color: var(--ink); background: var(--btn-secondary-bg);
}
/* FIXED, not absolute. The tile lives inside .kpi-strip, which sets
   `overflow: hidden` for its rounded corners -- so an absolutely positioned
   panel hanging below the strip was clipped to nothing. It opened correctly and
   was simply invisible. Fixed positioning escapes every overflow ancestor; the
   coordinates are set in JS from the trigger's rect, so it still anchors to the
   button it belongs to. */
.info-tip {
  position: fixed; z-index: 60;
  width: max-content; max-width: min(340px, calc(100vw - 32px));
  padding: var(--space-2) var(--space-3);
  background: var(--surface); color: var(--ink);
  border: 1px solid var(--dialog-border); border-radius: var(--radius-md);
  box-shadow: var(--shadow), var(--raise-edge);
  text-align: left;
}
.info-tip[hidden] { display: none; }
.info-tip-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
.info-tip-close {
  display: inline-flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; margin: -4px -4px 0 0; padding: 0;
  border: 0; border-radius: var(--radius-sm);
  background: transparent; color: var(--muted); font-size: var(--fs-figure); line-height: 1; cursor: pointer;
}
.info-tip-close:hover, .info-tip-close:focus-visible { background: var(--btn-secondary-bg); color: var(--ink); }
/* Title Case, not ALL CAPS -- house rule. Caps-era tracking (0.04em) drops with
   it: letter-spacing that helps ALL CAPS hurts Title Case. */
.info-tip-head {
  margin: 0 0 var(--space-2); color: var(--muted);
  font-size: var(--fs-eyebrow); font-weight: 600; letter-spacing: 0.01em;
}
.info-tip-list { display: grid; gap: 6px; margin: 0; }
/* minmax(0, 1fr), NOT 1fr: a grid track defaults to min-width:auto, so the
   payment sentence ("Interest-only during draw period based on outstanding
   balance") refused to shrink and pushed straight out of the panel instead of
   wrapping inside it. */
.info-tip-list > div { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: var(--space-2); align-items: baseline; }
.info-tip-list dt { color: var(--muted); font-size: 12px; font-weight: 600; }
/* The payment structure is a sentence; inside a popover it has room to wrap.
   text-transform:none is load-bearing -- this panel sits inside .kpi-strip,
   whose eyebrow rule capitalizes every word and was rendering the terms as
   "Interest-Only During Draw Period Based On Outstanding Balance". */
.info-tip-list dd { margin: 0; font-size: 13px; font-weight: 600; white-space: normal; text-transform: none; }
.info-tip-list dd, .info-tip-list dt, .info-tip-head { display: block; }
/* The VALUES are rendered as <span> inside the dd, and `.kpi-strip span` styles
   any span in this strip (block + capitalize). An inherited text-transform on
   the dd loses to that span's own rule, so target the spans directly -- same
   specificity, later in the file. */
/* white-space is the load-bearing one here. The VALUE is rendered inside a
   .nm-short/.nm-full span, and that span was picking up `nowrap` from the KPI
   strip this panel is nested in -- so the payment sentence could not wrap and
   ran off the panel even though its <dd> allowed wrapping and reported clean.
   The dd was fine; the span inside it was not. */
.info-tip span { display: inline; text-transform: none; letter-spacing: 0; font-size: inherit; color: inherit; white-space: normal; }

/* Empty / zero states. The table-shell already supplies the hairline panel, so an
   empty view centres a quiet, intentional message inside it rather than dumping
   raw text into the top-left. The em-dash badge echoes the blank-value motif used
   in cells: a deliberate absence, not a failure. */
/* display:flex would otherwise beat the [hidden] attribute's UA display:none, so
   the panel must explicitly collapse when JS marks it hidden (data present). */
.empty-state[hidden] { display: none; }
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: 220px;
  padding: var(--space-6) var(--space-4);
  text-align: center;
  color: var(--muted);
  font-size: var(--fs-cell);
}
.empty-state::before {
  content: "\2014";
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: var(--radius-lg);
  border: 1px solid var(--line);
  background: var(--surface-alt);
  color: color-mix(in srgb, var(--muted) 65%, transparent);
  font-size: 20px;
  line-height: 1;
}
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
  .empty-state::before { color: var(--muted); opacity: 0.7; }
}

/* Loading skeleton. While a table body is in flight the shell shows a faux header
   band + placeholder ledger rows instead of a bare "Loading..." string. The real
   table is hidden (removing its wide min-width) so the skeleton fills the shell
   with no horizontal scroll and no layout shift when data lands. The shimmer is a
   transform-only sheen over a static tint, so reduced-motion users keep the calm
   tint and lose only the movement. */
.skeleton { display: none; padding: var(--space-2) 0 var(--space-3); }
.table-shell.is-loading table { display: none; }
.table-shell.is-loading .empty-state { display: none; }
.table-shell.is-loading .skeleton { display: block; }
.skeleton-head,
.skeleton-row {
  position: relative;
  overflow: hidden;
  margin: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  background: var(--surface-alt);
}
.skeleton-head { height: 22px; margin-bottom: var(--space-3); background: var(--group); }
.skeleton-row { height: 15px; }
.skeleton-row:nth-child(2n) { width: 82%; }
.skeleton-row:nth-child(3n) { width: 90%; }
.skeleton-row:nth-child(4n) { width: 74%; }
.skeleton-head::after,
.skeleton-row::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--line) 55%, transparent), transparent);
  transform: translateX(-100%);
  animation: skeleton-sheen var(--motion-slow) var(--ease) infinite;
  animation-duration: 1400ms;
}
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
  .skeleton-head::after, .skeleton-row::after { background: linear-gradient(90deg, transparent, var(--line), transparent); }
}
@keyframes skeleton-sheen { to { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) {
  .skeleton-head::after, .skeleton-row::after { animation: none; display: none; }
}
.heloc-table td:nth-child(1), .heloc-table td:nth-child(2), .heloc-table td:nth-child(3) { white-space: nowrap; }

/* Joint debt: fewer, wider columns than the debts table, so it does not need the
   1760px minimum. Notes wrap; every other column stays on one line. */
#view-joint .kpi-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
/* Credit line is a different KIND of figure from the three beside it: those are
   principal drawn, this is the size of the facility they are drawn against. It
   just needs the same hairline the other tiles use -- an accent rule shouted,
   and read as a status colour rather than a separator. */
/* NO border-left. Every tile already carries `border-right: 1px`, and the grid
   gap is 0, so a left border here paints a SECOND hairline against the
   neighbour's right one -- the Credit Line seam measured 2px while its two
   siblings measured 1px. The "same-divider siblings" rule, and easy to miss
   because at 393px the strip wraps 2x2 and Credit Line starts a row, so only
   one border paints and the phone looks correct. */
.kpi-strip > div.is-divided { border-left: 0; }
/* 4 tiles, 4 tracks. It asked for 5, which left a permanently empty track on
   the right and made the strip look like a column had failed to load. */
#view-heloc .kpi-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); }
/* The terms strip has 3 cells, not 5 -- inheriting the 5-track grid left two
   empty tracks and squeezed the longest value ("Interest-only during draw
   period based on outstanding balance") until it truncated. Size the first two
   to their content and give Payment every remaining pixel. */
#view-heloc .heloc-terms-strip { grid-template-columns: max-content max-content 1fr; }
/* 5 tiles after dropping 'Visible accounts' -- match columns to items so the
   strip has no empty track (no-orphans rule). DESKTOP ONLY: an ID rule
   outranks the responsive .kpi-strip class rules, and 5 money values across a
   393px phone clip. */
@media (min-width: 901px) {
  /* 6 tiles: 3 x 2 divides evenly, no orphan. */
  #view-debts .kpi-strip { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
/* Type is the row's identity here, so it takes the slack; the money columns
   shrink to content rather than each hoarding an equal share. */
.joint-debt-table th:first-child, .joint-debt-table td:first-child { width: 1%; min-width: 240px; white-space: nowrap; }
/* Notes absorbs the slack on desktop; the trailing chevron column shrinks.
   These were the same cell until the chevron was added -- :last-child now
   means the button, so the absorb rule has to name Notes explicitly. */
.joint-debt-table th:nth-child(8), .joint-debt-table td:nth-child(8) { width: 100%; }
.joint-debt-table th:last-child, .joint-debt-table td:last-child { width: 1%; }
.joint-debt-table th.number, .joint-debt-table td.number,
.joint-debt-table th.date-cell, .joint-debt-table td.date-cell { width: 1%; white-space: nowrap; }
.joint-debt-table { min-width: 960px; }
/* Notes stays on ONE line so joint rows match every other tab's height; the
   full text is on the cell's title tooltip. Uniform row height across tabs beats
   showing a long note inline -- a ragged 29/47/65px column reads as broken.
   Targeted by CLASS, not :last-child: the last cell is the Open chevron, so
   this had been constraining a button while Notes -- the ninth column's left
   neighbour -- wrapped freely and produced the ragged heights the rule was
   written to prevent. */
.joint-debt-table td.joint-notes { max-width: 460px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.joint-notes { max-width: 620px; }
/* Joint debt is a short table (a handful of rows). The shared 360px shell
   min-height left a large empty slab below the last row on desktop and phone, so
   this shell hugs its content instead — the sticky subtotal sits just under the
   final row, no blank void. While loading, the skeleton supplies the height. */
.joint-table-shell { min-height: 0; }
/* A "?" (or similar) placeholder total is real, deliberately-recorded data, but
   it is not a figure -- render it in the quieter em-dash tone so it never reads
   as a confirmed balance. */
.joint-total-placeholder { color: color-mix(in srgb, currentColor 55%, transparent); font-weight: 400; }
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
  .joint-total-placeholder { opacity: 0.55; }
}
.badge.bucket-joint { color: var(--bucket-joint-fg); background: var(--bucket-joint-bg); border-color: var(--bucket-joint-border); }
.badge.bucket-personal { color: var(--bucket-personal-fg); background: var(--bucket-personal-bg); border-color: var(--bucket-personal-border); }
/* NO ORPHANS: this band always renders exactly 5 terms, and a 4-column grid
   strands the 5th alone on a row beside three empty cells. Match the column
   count to the item count so the set reads as one row. Desktop-only — the
   responsive rules below still collapse it to 2 then 1 column. */
@media (min-width: 901px) {
  #strategy-heloc-terms { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
.strategy-body .muted { color: var(--muted); font-style: italic; }
/* Only four columns, so it fits any desktop -- drop the wide-grid min-width and
   let the Status cell wrap instead of clipping off the right edge. */
.strategy-table { min-width: 880px; }
.strategy-table tr.paid-off td { color: var(--muted); }
/* Geometry comes from the shared chip rule; this sets colour only. */
.pill.done { color: #0f5132; background: #d1e7dd; }



/* Budget view. Bill categories reuse the HELOC bucket + pre-legal token palettes
   so no new colours enter the system; each category stays distinguishable by its
   label as well as its tint (never colour alone). */
.badge.cat-mortgage { color: var(--bucket-personal-fg); background: var(--bucket-personal-bg); border-color: var(--bucket-personal-border); }
.badge.cat-joint { color: var(--bucket-joint-fg); background: var(--bucket-joint-bg); border-color: var(--bucket-joint-border); }
.badge.cat-heloc { color: var(--badge-prelegal-fg); background: var(--warning-soft); border-color: var(--badge-prelegal-border); }
.pill.autopay { color: var(--badge-settlement-fg); background: var(--success-soft); border-color: var(--badge-settlement-border); border: 1px solid var(--badge-settlement-border); }
.pill.computed { color: var(--muted); background: var(--group); border: 1px solid var(--line); }

/* Tighter between sections: they read as one list of what is owed, and 24px
   between them (this margin plus the shell's own bottom margin) was reading as
   a break (operator, 2026-08-14: "some space in between the two sections that
   could be tightened up"). */
.budget-section { margin-top: var(--space-1); }
/* Bills and Contribution sit side by side: Contribution is only a couple of
   rows, so stacking it below wasted the horizontal space AND stole height the
   long bills list wanted. Bills takes the slack; Contribution is sized to its
   content. Stacks again on narrow screens. */
/* ONE COLUMN, always: Personal Debt sits UNDERNEATH Joint Bills (operator,
   2026-07-31: "can you move a personal debt table underneath the joint one on
   the bills tab on the desktop version?").
   The two-column split needed ~1600px to be worth having -- 840 for the left
   table plus 720 for the right -- and the content column is now capped at
   1120px, so the split could only ever fire inside a box too narrow to hold
   it. The old rule and its 1600px stacking breakpoint are gone rather than
   left dormant; a media query that can no longer be satisfied is a trap for
   whoever changes the width next. */
.budget-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); align-items: start; }
/* minmax(0, 1fr), NOT 1fr: a grid track defaults to min-width:auto, so the
   720px-min bills table forced the column to 805px on a 393px phone and pushed
   the whole page sideways. minmax(0,...) lets the track shrink and the table
   scroll inside its own shell instead. */
/* Stack until BOTH columns can actually be satisfied. At 900px the split
   turned on, but the left column takes its full 840px and the right one holds a
   table with min-width:720px -- so at 1194px the right column got 302px and hid
   420px of Personal Debt behind a sideways scroll, while the NARROWER iPad
   portrait showed the whole thing. A two-column layout that is worse than one
   column is not a layout (background sweep, 2026-07-30).
   840 + 720 + the gap is about 1600. */
/* (The 1600px stacking breakpoint was removed with the two-column rule above --
   there is nothing left to stack, the layout is single-column at every width.) */
.budget-section > h2 { margin: 0 0 var(--space-1); font-size: var(--fs-figure); }
/* Collapsible section heading: the <h2> stays the heading, the button inside it
   is the control, so the outline is unaffected by making the section foldable. */
/* ONE SECTION HEADING for the whole tab: name, total, then qualifying detail.
   Transfers and Surplus were each drawn their own way -- Transfers led with the
   date and Surplus was an ACCOUNT card borrowed from the other tab -- so three
   blocks on one screen had three different shapes (operator, 2026-08-07: "can
   we format the three sections inside the cash flow tab consistently. each
   section should have its title at the top"). Built on the same declarations
   .budget-section > h2 and .section-toggle already used, so all three move
   together from here. */
.section-head {
  display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
  margin: 0 0 var(--space-1); font-size: var(--fs-figure);
}
.section-name { font-weight: 700; }
/* THE SURPLUS FIGURE GOES TO THE RIGHT EDGE, under the "Annual at plan" figure
   below it (operator, 2026-08-13: "move the 2900 to the right edge, aligned
   with the annual amount"). Two money figures in one card, one tucked against
   its label and one at the margin, gave the card no column to read down.
   Scoped to this heading rather than fixed on `.section-head`, which other
   sections use with a figure that is meant to sit beside its name. */
#kpi-budget-net { margin-left: auto; }
.section-head > strong {
  font-size: var(--fs-figure); font-weight: 700;
  font-variant-numeric: tabular-nums;
}
/* The qualifying detail -- the payday date. A pill, matching the contribution
   shares beside it, so the two headings read as the same component. */
.section-pill {
  font-size: var(--fs-eyebrow); font-weight: 700; letter-spacing: 0.01em;
  padding: 2px 8px; border-radius: var(--radius-pill);
  background: var(--surface-2); color: var(--accent-line);
  text-transform: none; white-space: nowrap;
}
/* The surplus card is a SECTION now, not an account track. Same card tokens as
   its two siblings; the figures below the heading keep their own rows. */
/* THE VIEW THAT ARRIVES SETTLES INTO PLACE. An animation, not a transition:
   the view has just been unhidden, and a transition needs a committed starting
   state that an element going from display:none does not have.
   `backwards` fill so the first frame is the offset one -- without it the view
   paints at its final position for one frame and then jumps up to start, which
   is a flicker rather than an entrance. */
/* THE TABS ARE PEERS IN A ROW, so they move sideways -- the direction follows
   the tab strip, so going right in the strip moves the content left, and going
   back reverses it. A vertical slide said "this came from above", which is what
   a SHEET does and is a different relationship.
   TWO elements animate here, which the operator rightly questioned. It is one
   gesture, not two: both travel the same way at the same time, the way a page
   turns, so the pair reads as a single movement rather than as competing ones.
   The alternative -- animating only the arrival -- leaves the old view vanishing
   under it, which is the jump-cut this replaces. */
@keyframes view-slide-in-right {
  from { opacity: 0; transform: translateX(100%); }
  to   { opacity: 1; transform: none; }
}
@keyframes view-slide-in-left {
  from { opacity: 0; transform: translateX(-100%); }
  to   { opacity: 1; transform: none; }
}
@keyframes view-slide-out-left {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateX(-100%); }
}
@keyframes view-slide-out-right {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateX(100%); }
}
/* THE VIEW ON ITS WAY OUT IS TAKEN OUT OF FLOW for the duration, so the arriving
   one can occupy the same space instead of being pushed below it. `main` is the
   positioning context; without that this would anchor to the page and the
   leaving view would slide across the header. */
.dashboard-view.is-leaving {
  position: absolute; inset: 0; z-index: 0; pointer-events: none;
}
.dashboard-view.is-leaving.to-left {
  animation: view-slide-out-left 420ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
.dashboard-view.is-leaving.to-right {
  animation: view-slide-out-right 420ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
.dashboard-view.is-entering.from-right {
  animation: view-slide-in-right 420ms cubic-bezier(0.4, 0, 0.2, 1) backwards;
}
.dashboard-view.is-entering.from-left {
  animation: view-slide-in-left 420ms cubic-bezier(0.4, 0, 0.2, 1) backwards;
}
/* Kept only for a switch with no known direction (a restored tab on load). */
@keyframes view-enter {
  from { opacity: 0; transform: translateY(-28px); }
  to   { opacity: 1; transform: none; }
}
.dashboard-view.is-entering:not(.from-right):not(.from-left) {
  /* THE SHEET'S CURVE, and for the same measured reason (operator, 2026-08-15,
     after the sheet was fixed: "How about the same effect for changing tabs?").
     The old one, (0.22, 0.61, 0.36, 1), had done 27% of the travel at a tenth of
     the duration and 71% by a third -- front-loaded, so it popped and then
     drifted, which is exactly what made the sheets feel too fast however long
     they ran. (0.4, 0, 0.2, 1) runs 2 / 13 / 44 / 77 at the same marks.
     420ms, not the sheet's 620: a sheet is a surface you are being shown and
     can carry weight, a tab is something a finger hits repeatedly, and past
     about this the motion starts being a wait. */
  animation: view-enter 420ms cubic-bezier(0.4, 0, 0.2, 1) backwards;
}
@media (prefers-reduced-motion: reduce) {
  .dashboard-view.is-entering { animation: none; }
}

/* The history sheet's filter row. Wraps rather than scrolls: three short
   selects fit two-up on a phone, and a horizontally scrolling filter bar hides
   the fact that a filter is set. */
.rs-filters {
  display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3);
  margin-bottom: var(--space-3);
}
.rs-filter { display: flex; flex-direction: column; gap: 4px; flex: 1 1 30%; }
.rs-filter > span {
  font-size: var(--fs-eyebrow); font-weight: 600; color: var(--muted);
  letter-spacing: .04em;
}
/* 16px, the input floor: unlike the transaction chip these open a wheel from a
   full-width control, and iOS zooms a sub-16px select in that position. */
/* EVERY THEMED SELECT, not one screen's. This was written for the history
   filters and the deposit form's Platform select was left native -- a white box
   in a dark card, the same defect twice because the rule named a screen instead
   of the construct. */
.rs-filter select,
.rideshare-report-form select {
  /* appearance:none FIRST, or none of the rest of this applies. WebKit draws a
     native select with its own OS chrome and ignores `background` and `color`
     entirely -- which rendered three white boxes with grey text in the middle
     of a dark sheet. The transaction chip already learned this; the same reset
     is needed anywhere a select has to obey the theme. */
  appearance: none; -webkit-appearance: none;
  font-size: 16px; padding: 8px 32px 8px 10px; min-height: 40px;
  border: 1px solid var(--line); border-radius: var(--radius-md);
  background: var(--surface); color: var(--ink);
  /* The chevron the reset removed, drawn back in as a background image so it
     costs no markup. data: URI because the CSP forbids external resources. */
  background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%238b9a94' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 10px center;
  background-size: 12px 8px;
}
/* The wheel's own list is drawn by the OS; these keep it legible in dark mode
   on the engines that do honour it. */
.rs-filter select option,
.rideshare-report-form select option { color: var(--ink); background: var(--surface); }
.rs-summary {
  margin: 0 0 var(--space-2); color: var(--muted);
  font-size: var(--fs-eyebrow); font-weight: 600;
}
.rs-months { margin-top: var(--space-3); }
/* THE SHEET'S INSET, declared the same way #budget-history-dialog declares its
   own. `.dialog-content` carries no horizontal padding globally -- each sheet
   that needs it says so -- so without this the labels sat against the left edge
   and every figure against the right. Matching the existing scoped rule rather
   than inventing a variant keeps the two history sheets identical. */
#rideshare-history-dialog .dialog-content { padding-inline: var(--space-4); }

/* Every <details> slides open and shut; see slideEveryDetails in app.js. The
   class is only present WHILE it moves, so a settled section keeps its ordinary
   overflow and nothing inside it is clipped. */
details.is-sliding {
  overflow: hidden;
  transition: height var(--slide-ms, 360ms) cubic-bezier(0.4, 0, 0.2, 1);
  /* THE TALLEST SECTION IS THE ONE THAT STUTTERS. Animating height reflows
     everything below the element on every frame, and the outermost section --
     a whole month of weeks -- has the most under it and the most inside it, so
     it is the only level where that cost shows (operator, 2026-08-15: "The top
     level in the rideshare drill down, doesn't smoothly slide. The sub levels
     slide perfectly"). These are hints, not layout: `contain: paint` keeps its
     repaint from escaping, `will-change` warns the compositor what is moving.
     A first attempt blamed margin collapsing; measurement put that at 1px. */
  contain: paint;
  will-change: height;
}
@media (prefers-reduced-motion: reduce) {
  details.is-sliding { transition: none; }
}

/* A COLUMN CHART OF PEERS. Columns rather than the horizontal progress bars
   used elsewhere: those measure one figure against ITS OWN target, and these
   are peers measured against each other, which is what a column reads as.
   Shared by the surplus history and the rideshare history -- named for the
   shape rather than for its first caller, so the second one is not borrowing
   a name that describes something else. */
.surplus-history { margin: 0 0 18px; }
.bar-chart {
  display: flex; align-items: flex-end; gap: 10px;
  list-style: none; margin: 12px 0 0; padding: 0;
  /* Scrolls rather than squeezing: a year of months at phone width would make
     every column too thin to compare, which defeats the chart. */
  overflow-x: auto; -webkit-overflow-scrolling: touch;
}
.bar-col {
  display: flex; flex-direction: column; align-items: center; gap: 5px;
  flex: 0 0 auto; min-width: 52px;
}
.bar-col-track {
  display: flex; align-items: flex-end; justify-content: center;
  width: 30px; height: 96px;
  background: var(--bar-track); border-radius: var(--radius-pill);
  overflow: hidden;
}
.bar-col-track > i {
  display: block; width: 100%; background: var(--accent);
  border-radius: var(--radius-pill);
  /* Columns grow UP: the track is a flex box aligned to flex-end, so animating
     height alone raises them off the axis. Height starts unset (zero) and JS
     assigns the real value, so again the transition is the whole animation. */
  height: 0;
  transition: height 720ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* A month that has not finished is drawn hollow, so a part-month is never read
   as a completed one standing beside its peers. */
.bar-col.is-running .bar-col-track > i { opacity: .55; }
.bar-col.is-negative .bar-col-track > i { background: var(--danger); }
.bar-col-figure {
  font-size: var(--fs-eyebrow); font-weight: 700;
  font-variant-numeric: tabular-nums; color: var(--ink);
}
.bar-col-label {
  display: flex; flex-direction: column; align-items: center;
  font-size: var(--fs-eyebrow); color: var(--muted); font-weight: 600;
}
.bar-col-label > em { font-style: normal; opacity: .75; font-weight: 400; }

.surplus-panel {
  padding: var(--space-2) var(--space-3);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm), var(--raise-edge);
}
.surplus-panel .track-bar { margin: 0; }
.surplus-panel .track-balance {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 8px; margin-top: 4px;
}
/* Not uppercase: the caption above it ("Last month $N,NNN") is sentence case,
   and two label rows in one card set in two different cases is the same drift
   this whole pass is about. */
.surplus-panel .track-balance > span {
  color: var(--muted); font-size: var(--fs-eyebrow); font-weight: 600;
  letter-spacing: 0.01em;
}
.surplus-panel .track-balance > strong {
  font-size: var(--fs-figure); font-weight: 700;
  font-variant-numeric: tabular-nums;
}
/* Even spacing between the three, matching the account tracks' 8px column. */
#view-payday > .cash-card + .cash-card,
#view-payday > .budget-section + .cash-card,
#view-payday > .cash-card + .budget-section { margin-top: 10px; }

.section-toggle {
  display: inline-flex; align-items: center; gap: 8px;
  min-height: 30px; padding: 0; border: 0; background: transparent;
  color: inherit; font: inherit; text-transform: inherit; cursor: pointer;
}
.section-caret {
  width: 0; height: 0;
  border-left: 5px solid currentColor;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  color: var(--muted);
  transition: transform var(--motion-fast) var(--ease);
}
.section-toggle[aria-expanded="true"] .section-caret { transform: rotate(90deg); }
/* The total rides the heading, so folding the section never hides the figure
   the rest of the tab is computed against. */
.section-total {
  color: var(--muted); font-size: 13px; font-weight: 600;
  font-variant-numeric: tabular-nums; text-transform: none;
}
.section-toggle:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: var(--radius-sm); }
/* These are secondary ledgers (like the joint/HELOC-transaction bands), so they
   size to their content instead of reserving the 360px the primary debts table
   needs to keep its sticky grand-total footer in view. */
.budget-bills-shell, .budget-income-shell { min-height: 0; }
/* The 13-row bills list alone ran ~506px and pushed the rest of the tab off
   screen; cap it and let it scroll in place (its header stays sticky). */
/* Fill the page: the bills list takes whatever height is left after the chrome
   above and the income table below, rather than a fixed cap -- so it grows
   automatically as the header gets tighter. */
.budget-bills-shell { max-height: calc(100dvh - 368px); }
/* THE FLOOR IS FOR THE LONG LIST, BY ID, NOT FOR THE CLASS. Both the Joint
   bills shell and the Personal Debt shell wear `.budget-bills-shell`, so a
   min-height written for a 13-row scrolling list also padded a 5-row table out
   to 190px -- 50px of empty shell under its last row (operator, 2026-08-14:
   "There's still some space at the very bottom inside the personal debt
   section"). The debt table is short by nature and should be its own height. */
#budget-bills-shell { min-height: 190px; }

/* A STICKY FOOTER HAS TO BE PAID FOR IN PADDING. `.grand-total-row td` is
   `position: sticky; bottom: …` inside a scrolling shell, so it floats over the
   rows -- and the last row can only be read if there is scrollable space
   BENEATH it to lift it clear. With none, the final account sits permanently
   under the total and no amount of scrolling reveals it (operator, 2026-08-14:
   "I can't scroll the bottom up because it's hidden behind the sticky total
   line").
   The reserve is the total row's own height (28px) plus a little air. Scoped to
   the shell whose total ACTUALLY sticks: the budget and joint tables set theirs
   `position: static`, and padding there would be the dead space we just spent
   the afternoon removing. */
#accounts-shell { padding-bottom: 36px; }
/* Income is only a couple of rows -- it must NEVER scroll or clip. Any height
   the tab needs comes out of the long bills list instead. */
.budget-income-shell { max-height: none; overflow-y: visible; }
/* Tight columns AND full-width rows. width:100% alone spread the leftover
   ~500px into every gap (over-wide, ragged columns); width:auto fixed that but
   stopped the row striping mid-shell. Instead let the TRAILING column absorb
   all the slack, so every data column is content-tight and the empty space
   pools once, at the right edge, with the rows still spanning the shell. */
.budget-bills-table th:nth-child(3), .budget-bills-table td:nth-child(3) { max-width: 86px; }
.budget-bills-table th:last-child, .budget-bills-table td:last-child,
.budget-income-table th:last-child, .budget-income-table td:last-child { width: 100%; }
.budget-bills-table { min-width: 720px; }
/* Bill names (e.g. "Pawtucket Water Supply Board (PWSB)") get the full column —
   lift the shared 240px .account-name cap here so they never truncate. */
.budget-bills-table .account-name { max-width: none; display: inline; }

.budget-income-table { min-width: 520px; }

/* Food-budget card. A calm panel that carries the weekly amount + first period,
   and either a spent/remaining progress bar (once live) or a "begins" note. */
/* ONE ROW per panel: heading sits inline with the four figures instead of
   stacking above them, halving the panel height. */
.budget-food { display: flex; align-items: center; gap: var(--space-5); margin: var(--space-2) 0 var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); box-shadow: var(--shadow-sm), var(--raise-edge); }
/* `display:inline-block` above outranks the [hidden] attribute's UA display:none,
   which left an empty bordered pill on screen. Restore hiding explicitly. */
.budget-food-status[hidden] { display: none; }

/* Rollup band: group totals lifted OUT of the table. Reads as a summary of the
   ledger below, not as another KPI strip -- lighter, inline, and it changes
   with the "Roll up by" control. */
.rollup-band {
  display: flex; flex-wrap: wrap; gap: var(--space-2);
  margin: 0 0 var(--space-2);
}
.rollup-band[hidden] { display: none; }
/* One line on a phone. Three chips that wrap leave the last one stranded, and a
   rollup that reads as two rows stops looking like one summary. */
@media (max-width: 600px) {
  .rollup-band { flex-wrap: nowrap; gap: 5px; }
  .rollup-item { padding: 4px 8px; gap: 5px; min-width: 0; }
  .rollup-item > span { font-size: var(--fs-eyebrow); }
  .rollup-item > strong { font-size: 12px; }
}
/* Over the dining guess. Amber, not red: the guess is explicitly a guess, so
   this flags "worth a look" rather than "something is wrong". */
/* Transactions popout. */
/* Names the table under it -- that the rows have not happened yet. A NOUN, not
   a sentence: the explanation of what "scheduled" means belongs in the rows
   ("Planned transfer") and the note under the table, not in the heading
   (§2.12, and the operator caught this one too). */
/* `.projection-heading`, NOT `.projection-title` -- the dialog's own <h2>
   already owns `id="projection-title"`, and a class and an ID reading the same
   is a trap for whoever styles either one next. */
/* Label left, the way out right, sharing one baseline. */
.projection-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; margin: var(--space-3) 0 4px;
}
.projection-head > .projection-heading { margin: 0; }
/* TITLE CASE, not ALL CAPS (§2.12). This rule was drawing "SCHEDULED",
   "TAGGING" and "CATEGORY BREAKDOWN" -- three of the loudest strings in the
   app, in the one style the house rules say not to use, because caps are
   slower to scan. Caps-era tracking comes down with it: 0.04em helps ALL CAPS
   and hurts Title Case. */
.projection-heading {
  margin: var(--space-3) 0 4px; font-size: var(--fs-eyebrow);
  font-weight: 700; letter-spacing: 0.01em;
  color: var(--muted);
}

.txn-link-row { margin: 8px 0 0; }
/* Actions sharing one row. `gap` rather than a separator: they are peers, and
   a middot between links reads as one control with a mode.
   SPREAD ACROSS THE FULL WIDTH (operator, 2026-08-12: "these links should be
   equally spread out across the row"). With a fixed gap and two links they sat
   as a clump at the left with the rest of the row empty; a third made the
   clump longer without making it deliberate. space-between pins the outer two
   to the edges the rest of the popout already uses, so they read as a row of
   peers rather than a sentence that ran out.
   `gap` stays as the FLOOR -- it is what stops them touching once the row
   wraps at a narrow width, where space-between has no space left to give.
   12px and not 18: with three links the floor decides whether the row fits at
   all, and measured at 375px (SE, 13 mini) the 18px version overflowed by six
   pixels and wrapped to a second line -- costing more height than the clump it
   was meant to fix. space-between still opens the gaps up to 24px at 393 and
   42px at 430; the floor is only ever reached when the row is genuinely tight.
   At 320px it still wraps, which is the honest limit of three links and two
   two-word labels; wrapping is the graceful failure, clipping would not be. */
.txn-link-row-pair { display: flex; align-items: baseline; gap: 12px;
  flex-wrap: wrap; justify-content: space-between; }
.txn-month { border-top: 1px solid var(--line); padding: 8px 0; }
.txn-month > summary {
  /* PACKED LEFT. `space-between` was fine while a total sat on the right, but
     the `::before` triangle is a third flex item, so with two children it
     parked the month in the MIDDLE. With the total gone the label belongs
     against its own triangle. */
  display: flex; align-items: baseline; justify-content: flex-start;
  gap: 8px; cursor: pointer; font-size: 13px; font-weight: 700;
  list-style: none;
}
/* The column heading row. Same grid as .txn-row -- it carries that class -- so
   the names sit over the values they name. Quiet: this is a key, not data. */
/* SIZED BY THE SAME NUMBERS AS A ROW, not by matching properties one at a time.
   Two independent grids can only line up if every input to their track sizing
   agrees -- font-size, font-family, letter-spacing, anything touching glyph
   metrics, because the tracks are declared in `ch`. Chasing those one by one
   fixed the date column, then the font, then letter-spacing, and it was still
   6px out.
   Declaring the heading's tracks in absolute px removes the dependency
   entirely: it no longer matters what the heading's text is set in. The values
   are the ROWS' computed tracks, which are themselves stable because every
   input to them is fixed. */
/* COMPOUND SELECTOR so the cascade cannot decide this. `.txn-head` alone has
   the same specificity as `.txn-row` and is declared earlier in the file, so
   the row's own tracks won and the heading never moved -- the third time today
   a same-specificity rule placed above its rival silently lost. */
.txn-row.txn-head {
  /* SAME VARIABLE as the rows -- it is set on their shared container, so the
     heading cannot drift from the columns it names. The other tracks stay
     pinned in px because `ch` resolves against the grid container and this
     one's cells are set at a different size; see the note below. */
  grid-template-columns: 44px minmax(0, 1fr) var(--txn-chip, 64px) 80.16px 53.44px;
}
.txn-head {
  /* NO font-size ON THE CONTAINER. The row's tracks are declared in `ch`, and
     `ch` resolves against the GRID CONTAINER's font size -- so shrinking the
     heading's text to 10px shrank its 10ch/9ch/6ch tracks with it, and the
     heading's columns started up to 47px away from the rows they name.
     The size belongs on the CELLS, which are not grid containers. */
  /* letter-spacing ALSO belongs on the cells, not here. WebKit folds
     letter-spacing into the `ch` advance, so .04em on this grid container
     inflated every ch-based track -- 10ch measured 97.4px against the rows'
     89.1px, and the headings drifted right of the columns they name. Two text
     properties, same trap: anything that changes glyph metrics on a grid
     container changes its `ch` tracks. */
  font-weight: 600;
  color: var(--muted); text-transform: none;
  border-bottom: 1px solid var(--line); padding-bottom: 3px; margin-bottom: 1px;
  cursor: default;
}
/* COMPOUND SELECTOR, for the same reason as the tracks above and the fourth
   time this trap has bitten in one day. `.txn-head > *` is one class, exactly
   the same specificity as `.txn-date`, `.txn-amount` and `.txn-balance` -- all
   three of which are declared LATER in this file, so each won its own cell and
   the heading rendered at three different sizes in one row (operator,
   2026-08-15: "The column headers on the transactions are of different
   fonts"). Two classes settles it for every cell at once. */
.txn-row.txn-head > * { font-weight: 600; color: var(--muted); font-size: 10px;
                        letter-spacing: .04em; }
/* THE MONTH SUBTOTAL. app.js writes a bare <strong> inside a summary that is
   already 700, so the UA resolved `bolder` to 900 -- heavier than anything else
   in the app -- and it inherited proportional digits, so a subtotal did not line
   up with the column it totals. Both stated explicitly here. */
.txn-month > summary strong {
  font-weight: 700; font-variant-numeric: tabular-nums;
}
.txn-month > summary::-webkit-details-marker { display: none; }
.txn-month > summary::before { content: "\25B8"; margin-right: 6px; color: var(--muted); }
.txn-month[open] > summary::before { content: "\25BE"; }
/* One line per transaction: date, merchant, tag choices, amount. The amount
   carries a min-width so the column still lines up down the list even though
   each row is its own grid. */
.txn-row {
  /* The description gets the room. The date and amount are fixed-width and the
     chip strip is absent on accounts without buckets, so on those rows the name
     now takes everything left over. */
  /* The CHIPS track must be shrinkable. At `auto` it took its full content
     width -- 218px of a 393px row -- and the description, sized minmax(0,1fr),
     collapsed to five pixels. minmax(0, auto) lets it give way, and the
     description carries a 96px floor of its own. */
  display: grid;
  /* THE TAG NEVER GIVES WAY; THE DESCRIPTION DOES.
     Fitting the chip by counting pixels failed twice, because the count was
     done in headless WebKit on Linux and the phone renders SF Pro at different
     widths -- a layout that clears by two pixels here clips there (operator,
     2026-08-05: "still overlapping on uncategorized"). So the chips track is
     `auto` against a description floor low enough that the chip always wins:
     the description scrolls behind its fade, which is the behaviour it already
     has and the one that degrades without looking broken.
     76px, not 96 -- the floor is a safety valve for a font wider than any
     measured here, not the width the description normally gets. On the widest
     real row it still lands around 120px. */
  /* EVERY TRACK THE SAME ON EVERY ROW, because each .txn-row is its own grid.
     With the chips track at `auto` each row sized that column from its own
     chip -- "Gas" against "Kids Clothing" -- so the amount column started at
     three different x down one list and the balance ran off the right edge.
     Reserving the chip in `ch` makes the five tracks identical for all rows,
     which is the only way independent grids can line up.
     Measured, not guessed: the widest label is "Entertainment"; the reserve is
     set from it and the select ellipsises anything wider rather than pushing
     the money out of column. */
  /* The date track stays `auto`. A 7ch reserve was tried and took 62px for a
     45px date, stealing seventeen from the description and wrapping the row --
     it was a fix aimed at the wrong cause (the real one was a pending row
     rendering four children into a five-track grid; see canTag in app.js). */
  /* 10ch for the chip, not 13. At 13 the four reserves plus the date left the
     description 11px, so a PENDING row -- whose name cell also carries the
     "Processing" flag -- could not fit and wrapped its balance onto a second
     line. 10ch still holds the widest category label and gives the
     description room to exist. */
  /* 8ch, down from 10. The reserve is not the width of the widest LABEL --
     the two longest ("Entertainment", "Kids Clothing") have always ellipsised
     inside it -- it is just how much of that label survives. Ten characters of
     reserve bought two more letters of category and cost the description the
     same width, which is the wrong trade: the category is one of eight known
     values and reads from its first few letters, while the description is the
     only thing identifying the charge (operator, 2026-08-15: "Descriptions
     below on the line items are still being cut off because the category is
     not moved over"). justify-self on .txn-chips already pushed the pill
     against the money; this is the part that actually hands pixels over. */
  /* --txn-chip is published per list by fitChipColumn() in app.js, from the
     chips actually rendered. The fallback is the old fixed reserve, so a list
     that never gets measured looks exactly as it did. */
  grid-template-columns: 44px minmax(0, 1fr) var(--txn-chip, 64px) 9ch 6ch;
  /* THE DATE TRACK IS FIXED TOO, since 2026-08-15. It was `auto`, and `auto`
     is sized by each row's OWN content -- every .txn-row is a separate grid --
     so the heading row ("Date", 28px) and a data row ("Aug 14", 36px) started
     their second column eight pixels apart, and every column after it inherited
     the drift. Measured: head [29,63,199,257,326] against rows
     [29,71,152,227,315].
     44px, not the 7ch tried before: `ch` is wide in this face and took 62px for
     a 45px date. This is the width of the longest real date plus a hair, so
     nothing truncates and no column moves. */
  /* 3px gutters. Four of them, so this is 20px back from the 8px the row
     started with -- "there is more space between columns to squeeze them
     together" (operator, same message). The two money columns keep a wider
     gutter of their own; see .txn-balance. */
  align-items: center; gap: 3px; padding: 5px 0;
}
/* No tag chips on this account -> one fewer column, and the description takes
   the space the chips would have used. */
#personal-txn-body .txn-row { grid-template-columns: auto minmax(7ch, 1fr) auto auto; }
/* A PROCESSING ROW pays for its flag out of the tag, which it does not render
   -- see txnRow() for why that tag could never have stuck. One fewer column,
   and the description absorbs the width. The money columns are deliberately
   NOT touched: the balance is blank on these rows but its width stays
   reserved, because collapsing it moved this row's amount 56px right of every
   other amount in the list.

   THE FOUR-TRACK OVERRIDE IS GONE (2026-08-13). It existed because a pending
   row really did render four children -- it could not be tagged, so the chips
   cell was omitted. That is what put its amount 1.44px off every other row's:
   a different number of cells in a different grid, which is not a column.
   Pending rows now render the chips cell EMPTY (see `canTag` in app.js) and
   take the same five tracks as everything else. A row that cannot be tagged
   yet is still a row in the same table. */
/* The chips cell on a pending row is empty by definition, so its width goes to
   the DESCRIPTION rather than sitting blank -- the row keeps the same five
   tracks, so the money columns stay in tracks 4 and 5 with every other row,
   and the description gets back the space the old four-track override used to
   give it. Same alignment, no clipping. */
.txn-row.is-pending .txn-name { grid-column: 2 / span 2; }
.txn-row.is-pending .txn-chips { display: none; }
.txn-row + .txn-row { border-top: 1px solid var(--line); }
/* The row is the tap target for the detail view. Quiet about it -- a list of
   forty rows cannot each shout that they are buttons -- but not silent: the
   cursor changes, and it takes focus and a visible ring from the keyboard. */
.txn-row[data-open-txn] { cursor: pointer; }
.txn-row[data-open-txn]:focus-visible {
  outline: 2px solid var(--focus); outline-offset: -2px; border-radius: 6px;
}
@media (hover: hover) {
  .txn-row[data-open-txn]:hover { background: var(--surface-2); }
}
.txn-date {
  font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.txn-name {
  /* A FLOOR of 96px, so the chips can never squeeze it out of existence again.
     SCROLLS rather than truncates. "GENERAL HEALTH 3042..." and three
     identical "Dick's Sporting Goo..." rows are indistinguishable once the
     ellipsis lands, and the description is the only thing identifying the
     charge (operator, 2026-08-02: "the descriptions are getting cut off, make
     them wider and make them scroll if you need to").
     overflow-x on the cell keeps the row height fixed, so a long name does not
     reflow the list. The scrollbar is hidden -- the overflow is discoverable by
     dragging, which is how every horizontally scrollable row in this app
     already behaves.

     The SCROLLING lives on .txn-text, not here, so the "Processing" flag can
     sit in this cell without scrolling out of view with the description.

     THIS COMMENT WAS BROKEN FOR A WHILE and the damage was invisible: it closed
     after "already behaves", the two lines above ran on as CSS, and error
     recovery skipped to the next `;` -- which was this `min-width`. So the 96px
     floor these twelve lines exist to justify did not exist, the description
     column collapsed to 62px, and the money columns it was protecting landed at
     three different x down the list. An invalid declaration is not an error;
     the browser drops it and the page still renders. */
  /* NO min-width. It used to carry a 96px floor, which a broken comment had
     been silently deleting for weeks -- and when the comment was repaired the
     floor came back and immediately overflowed the row, because the grid track
     above now owns the description's width. The declaration had been dead so
     long that the layout had been rebuilt around its absence. Restoring a
     declaration is a change, not a no-op. */
  font-size: 13px;
  display: flex; align-items: center; gap: 5px;
}
.txn-text {
  /* The flex equivalent of minmax(0, 1fr) -- `min-width: 0` is what lets a long
     merchant name give way instead of widening the row, and `flex: 1` is what
     makes the box fill the cell rather than shrink-wrap the text. */
  flex: 1 1 auto; min-width: 0;
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
  /* ELLIPSIS, not a scrolling cell with a fade. The cell used to scroll because
     nothing else in the app showed the full description; the drill-down does
     now, so the row can end cleanly. A cell that scrolls sideways under the
     finger also fights the tap that opens that drill-down. */
}
/* The word, not a tint. A grey italic amount is a convention you have to
   already know; "Processing" is not. */
/* Provenance, not a warning: muted, unlike the amber Processing flag it shares
   a class with. It states where the money came from, which is a fact rather
   than something to act on. */
.txn-flag.is-external { color: var(--muted); background: var(--group); font-style: normal; }
/* A one-off is a STATEMENT OF FACT, not a warning: the base .txn-flag is amber
   because "Processing" is a thing about to change, and this is not. Same
   neutral treatment as .is-external, which is the same kind of label. */
.txn-flag.is-oneoff { color: var(--muted); background: var(--group); font-style: normal; }
/* A split share. Same neutral treatment as the other two labels: it states how
   the charge was divided, which is a fact rather than a warning. */
.txn-flag.is-split { color: var(--muted); background: var(--group); font-style: normal; }
.txn-detail-check { display: flex; align-items: center; gap: 8px; }
/* accent-color, or the box renders in the browser's default BLUE against an
   app whose every other control is green. */
.txn-detail-check input {
  width: 18px; height: 18px; flex: 0 0 auto; accent-color: var(--accent);
}
/* COLOUR ONLY. The geometry comes from the shared chip rule above -- see the
   comment there, which says a variant may never re-litigate the shape. */
.txn-flag {
  flex: 0 0 auto; font-style: normal; white-space: nowrap;
  color: var(--warning); background: var(--warning-soft);
}
.txn-amount {
  font-size: 13px; font-variant-numeric: tabular-nums;
  /* WIDE ENOUGH FOR THE WIDEST FIGURE, so the column starts at one x down the
     whole list (operator, 2026-08-12: "write aligned for the categories so that
     the amount field is always in the same place -- we should do a fixed with
     on that one").
     This was 2.8rem, deliberately the NARROWEST figure the column reaches, on
     the reasoning that the balance beside it set the alignment. It does set the
     RIGHT edge; the left edge still moved with the digits, so the amounts read
     ragged and the chip before them ended somewhere different on every row.
     9ch, in CH not px: the two previous attempts at this column hard-coded
     pixels measured in headless WebKit and clipped on the phone, which renders
     SF Pro narrower. A ch is a real character in whatever font is actually
     drawing, and `tabular-nums` above makes every digit the same one.
     MIN-width, not width: a five-figure amount grows the box instead of being
     cut off. Alignment holds for every figure these accounts reach; anything
     larger degrades to today's behaviour rather than to a truncated number. */
  text-align: right; min-width: 9ch; white-space: nowrap;
}
/* THE BALANCE THE ACCOUNT WAS LEFT AT -- the running figure a bank statement
   carries beside each line. Muted, because it is context for the amount next to
   it rather than a second number of equal weight. The min-width is what keeps
   the column lined up down the list, since every row is its own grid. */
.txn-balance {
  font-size: 13px; font-variant-numeric: tabular-nums; color: var(--muted);
  /* 7ch, for the same reason and in the same unit as .txn-amount above: this
     column sits to the RIGHT of the amount, so anything that widens it pushes
     the amount left and breaks the alignment that reserve was just widened to
     provide. It was 3rem, which held for a $N,NNN balance and gave way on a
     negative one -- measured 2026-08-12 at three different left edges down one
     list, and the amount inherited every one of them.
     The margin is a WIDER GUTTER for this one seam. Two right-aligned money
     columns four pixels apart read as a single number; everywhere else 4px is
     just a gutter. */
  text-align: right; min-width: 6ch; margin-left: 4px; white-space: nowrap;
}
/* Ledger convention: a pending debit is not final money. The colour cue is gone
   -- the flag beside the description now says it in words -- but the italic
   stays, so the row still reads as provisional at a glance. */
.txn-row.is-pending .txn-amount { font-style: italic; }

/* A segmented control, not three loose pills -- sharing one border and losing
   the gaps between them is what makes the row fit a 390px phone. */
.txn-chips {
  /* NOT SHRINKABLE ANY MORE. It used to be: at `flex: 0 0 auto` the THREE
     chips of the old strip claimed 218px of a 393px row and squeezed the
     description to five pixels (operator, 2026-08-02: "where are the
     transactions? the tags are only shown"), so the cell was made to scroll
     within itself.
     There is only ONE chip here now, and a cell that scrolls has a min-content
     width of zero -- which is exactly what let the grid shave a few pixels off
     the pill and clip its border (operator, 2026-08-05: "still overlapping on
     uncategorized"). Letting the content set the minimum makes the tag
     unshrinkable, and the description -- which scrolls behind a fade and is
     built to give way -- absorbs it instead. */
  display: inline-flex; flex: 0 0 auto;
  /* IT MUST FIT ITS TRACK. `flex: 0 0 auto` above makes the pill unshrinkable,
     which was right when the track was reserved wide enough to hold the longest
     label -- and wrong the moment the track became a measured ceiling. The pill
     kept drawing its full content width, and since it is end-aligned it grew
     LEFTWARD, over the description it was supposed to be giving room to.
     min-width:0 lets the cell take the track's width; the select's own
     max-width makes the label ellipsise inside the pill instead of the pill
     outgrowing the cell. The pill's border stays intact either way, which is
     what failed when this cell was made to scroll instead. */
  /* max-width, and it has to be on the CELL. `justify-self: end` sizes a grid
     item to fit-content instead of stretching it, so the cell took its own
     content width -- the full pill -- and, being end-aligned, the surplus grew
     LEFTWARD over the description it was meant to be freeing. Measured: 72px of
     pill in a 52px track, seventeen of them on top of the name. A percentage
     max-width resolves against the grid AREA, which is the one number that
     actually bounds this. */
  min-width: 0; max-width: 100%; overflow: hidden;
  /* PUSHED AGAINST THE MONEY. The track is a fixed 10ch so the five independent
     row-grids line up, but the pill inside it is narrower than that, and the
     amount beside it is right-aligned in ITS track -- so the slack from both
     collected between them as one wide gap while the description truncated a
     few pixels away (operator, 2026-08-15: "I can barely see the description
     because there's so much room between the category tag and the amount, we
     need to move that over").
     justify-self moves the pill only; the tracks are untouched, so the columns
     still line up down the list. The leftover space now sits beside the
     description, where the eye reads it as breathing room rather than as a
     hole. */
  justify-self: end;
  /* NO BORDER OF ITS OWN. This outlined a SEGMENTED STRIP of several chips
     divided by hairlines -- the container was the strip. Only one control
     lives here now, a <select> that draws its own pill, so the outline became
     a second rounded box around the first and the pair read as a slider with
     an empty track (operator, 2026-08-14: "These look like sliders, but
     they're really just buttons"). Vestigial since the strip became a
     dropdown; removed rather than restyled, because the select IS the
     affordance. */
}
/* Small, but each still clears the 24px WCAG floor via padding. */
.txn-chip {
  font-size: var(--fs-eyebrow); font-weight: 700; padding: 6px 7px; min-height: 26px;
  border: 0; border-left: 1px solid var(--line);
  background: none; color: var(--muted); cursor: pointer; white-space: nowrap;
}
.txn-chip.is-on { background: var(--accent-soft, var(--surface-2)); color: var(--accent); }
/* Filled = the operator set this one; outlined = inferred. */
/* --surface, not --bg: this app has no --bg token, so the reversed-out text
   colour was never applied and a FILLED accent chip drew its label in the
   inherited ink (found 2026-08-11 by zenith_css_var_check.py). */
.txn-chip.is-on.is-manual { background: var(--accent); color: var(--surface); }

.txn-rules { margin-top: var(--space-3); font-size: 12px; }
.txn-rules > summary { cursor: pointer; font-weight: 600; color: var(--muted); }
/* THE RULES LIST, now its own dialog rather than a <details> at the bottom of
   one account's transaction popout (operator, 2026-08-12: it "should be
   somewhere a lot more central"). The type scale came from `.txn-rules`, which
   went with the disclosure. */
#txn-rules { font-size: 12px; }
/* WHAT IT IS on the left, WHAT YOU CAN DO on the right. The two buttons used
   to run on straight after the text, so with rules of different lengths Edit
   landed at a different x on every row. Wrapping them makes one column to aim
   at -- the same left-label / right-control row the Scheduled block and the
   pie's Clear already use. */
.txn-rule { display: flex; align-items: baseline; justify-content: space-between;
  gap: 8px; padding: 4px 0; }
.txn-rule-what { min-width: 0; }
.txn-rule-actions { display: flex; align-items: baseline; gap: 10px;
  flex: 0 0 auto; }
/* The account a rule is confined to. Quiet: it is the qualifier on the rule,
   not the rule. */
.txn-rule-scope { color: var(--muted); }

/* Payday plan. A short instruction, so it is laid out as one: a heading line
   with the total, the per-account list, then a quiet footnote. Deliberately not
   the .track grid it originally borrowed -- that is three columns sized for
   label / bar / balance, and the footnote landed in the narrow right column
   where it right-aligned and wrapped alongside the total. */
/* Section-level balance stamp. Quiet, one line, above the tracks. */
/* Spent-to-date, on the header row after the budget figures. */
.cad-actual { color: var(--muted); font-weight: 600; font-size: 12px; margin-left: 8px; }
/* An empty pace caption must take NO space, or moving the figure up saves
   nothing. */
.kpi-pace:empty { display: none; }

.tracks-stamp {
  margin: 0 0 4px; font-size: 11px; color: var(--muted); text-align: right;
}

.deposit-panel {
  /* Horizontal padding matches .track (8px) MINUS this panel's 1px border, so
     its labels land on the same left edge as the tracks above it. They were 5px
     apart, which reads as a wobble down the column rather than as a distinct
     panel. */
  padding: var(--space-2) 7px;
  background: var(--surface);
  /* Plain edge. The accent outline made this shout louder than the balances
     above it, and it is a suggestion, not an alert. */
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
}
.deposit-panel[hidden] { display: none; }
/* Pushed to the far right, with real space before it. It was FLOATED, which took
   it out of flow into the same line box as the total -- "Schedule" printed
   straight over "$N,NNN". margin-left:auto keeps the label and total together on
   the left and gives the link the whole gap. */
.deposit-more {
  font-size: var(--fs-caption); font-weight: 600;
  margin-left: auto; padding-left: var(--space-3);
}
/* Each payday reads as its own block. The dates were the same 11px eyebrow as
   a row label, so the weeks ran together into one long list -- the date is the
   thing you scan for, so it gets its own weight. */
.schedule-day { padding: 0 0 var(--space-4, 20px); }
.schedule-day + .schedule-day {
  /* A full-width rule plus real space, not a hairline squeezed between rows. */
  border-top: 1px solid var(--line);
  margin-top: var(--space-3);
  padding-top: var(--space-4, 20px);
}
.schedule-day .deposit-head { align-items: baseline; margin-bottom: 2px; }
.schedule-day .deposit-head > span {
  font-size: var(--fs-figure); font-weight: 700; color: var(--ink);
  letter-spacing: 0.01em; text-transform: none;
}
/* The panel head pushes its total right via the Schedule link's margin-left:auto.
   These rows have no link, so the total needs to claim that space itself --
   without this it sat glued to the date ("Fri 7/31 · next $N,NNN"). */
.schedule-day .deposit-head > strong { font-size: var(--fs-figure); margin-left: auto; }
/* The next payday is the one being acted on. */
.schedule-day.is-next .deposit-head > span,
.schedule-day.is-next .deposit-head > strong { color: var(--accent); }
/* Rows sit closer to their own date than to the next block. */
.schedule-day .deposit-rows { margin-top: 6px; padding-top: 6px; }
/* Its OWN flex item, sized to content and never shrinking. Nested inside the
   name span it was clipped away by that span's ellipsis; stacked below it, it
   doubled every row's height. */
/* Tabular, like every other figure in the app -- these carry money inside a
   caption and were inheriting proportional digits. */
.deposit-due {
  flex: 0 0 auto; font-style: normal; font-size: var(--fs-caption); color: var(--muted);
  white-space: nowrap; margin-right: auto; padding-left: 6px;
  font-variant-numeric: tabular-nums;
}
.deposit-head {
  /* Tighter than --space-2. Four children now share 347px at 393px wide --
     title, amount, the reason and the Schedule link -- and three full gaps of
     8px were what pushed it over. */
  display: flex; align-items: baseline; gap: 4px;
}
.deposit-head > span { flex: 0 0 auto; }
.deposit-head > span {
  color: var(--muted); font-size: var(--fs-eyebrow); font-weight: 600;
  letter-spacing: 0.01em;
}
/* The date matches the SCHEDULE's date exactly -- same size, weight and colour
   (operator, 2026-07-31). It is the same fact in two places, and it was reading
   as an eyebrow caption here and as a heading there.
   Scoped to the ID rather than widening `.deposit-head > span`, which also
   dresses the "why" note beside it; changing that would have restyled two
   things to fix one. Accent because this panel is always the NEXT payday --
   the schedule paints its next row `.is-next` in the same colour. */
/* The date is a PILL in the heading now (see .section-pill), sized to match the
   contribution shares beside it. Its old 15px/700 accent styling made it the
   loudest thing in a row where it is the qualifier, not the subject. */
#deposit-plan-title {
  margin-right: 2px;
}
/* Same size as a track's balance. This panel sits in a column of tracks, so it
   has to use their type scale or it reads as a different component shouting. */
.deposit-head > strong {
  font-size: var(--fs-figure); font-weight: 700; font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.deposit-rows {
  display: flex; flex-direction: column; gap: 3px;
  margin: 8px 0 0; padding: 8px 0 0;
  border-top: 1px solid var(--line);
}
.deposit-row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
}
/* flex: 1 1 auto with min-width: 0 -- a flex item with overflow:hidden and no
   grow shrinks to nothing before its sibling gives up any space. */
/* Label + value, exactly as a track row states them: muted eyebrow, solid
   value. */
.deposit-row > span {
  flex: 1 1 auto; min-width: 0;
  font-size: var(--fs-eyebrow); font-weight: 600; color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.deposit-row > strong {
  flex: 0 0 auto; font-size: var(--fs-figure); font-weight: 700;
  font-variant-numeric: tabular-nums;
}
/* What-if controls. Deliberately quiet: this is a scratch pad, not a saved
   setting, and it should not compete with the real figures around it. */
/* Priority + risk badges at the top of the account dialog. */
.dialog-flags { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 var(--space-3); }
.dialog-flags[hidden] { display: none; }

/* The Settings tip is the one popout holding a TABLE, so it takes a fixed
   width instead of the shared `width: max-content`. Under max-content the 1fr
   name track resolves from the tip's own content width and lands a fraction of
   a pixel short, which is enough for WebKit to draw an ellipsis on
   "Joint Discretionary" -- and no amount of trimming the other columns fixed
   it, because the panel just shrank to match. Still clamped to the viewport. */
#alerts-tip { width: min(340px, calc(100vw - 32px)); }

/* The week inside the Food month. Deliberately quieter than the bar above it:
   the month is the judgement, the week is the lookup. Two items pushed apart so
   the figure lands under the bar's right edge. */
/* The month-to-date figure, positioned under the point the fill reaches. The
   row reserves its own height so the card does not resize as the label moves,
   and the label itself is absolutely placed from JS. */
/* A pending hold, beside the balance it has already been taken out of. Quiet:
   it explains the figure rather than competing with it. */
.balance-held {
  display: block; font-size: var(--fs-eyebrow); font-weight: 500;
  color: var(--muted); white-space: nowrap;
}
/* `.balance-free` and `.balance-under` lived here and are gone: the card no
   longer prints an over/under-floor note. See the comment in app.js -- the
   figure ignored everything due before the next transfer, and the accurate
   replacement duplicated the over/under pace caption. */

.track-spent { position: relative; height: 15px; margin-top: 2px; }
/* The week we are in, tucked into the header row's spare middle. margin-right
   auto pushes the figure that follows it back to the right edge, so the label
   costs no height and no horizontal room that was being used. */
/* The label says WHICH week; amber says this one is spent. */
.track-week-now.is-over { color: var(--bar-week-over-edge); font-weight: 700; }
/* An inline note sharing row two with the figure -- same slot as the week
   label, same reason: it must not cost the card a line. */
.track-inline-note {
  grid-column: 2; justify-self: start; min-width: 0; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap;
}

.track-week-now {
  grid-column: 2; justify-self: start;
  font-size: var(--fs-eyebrow); color: var(--muted);
  white-space: nowrap; min-width: 0; overflow: hidden;
}
/* The pace caption sits on the HEAD row, at the right end of the line that
   starts with "week 2 of 4" -- it used to share the bar's row, which is the
   space the spend-to-date figure needs (operator, 2026-08-09: "I want the pace
   metric to the right of the 'week 2 of 4' text so we can have the money spent
   so far underneath the progress bar"). Same grid cell as the week label,
   pushed to the far end, which is where every other figure on this card sits.
   `margin-top` is cleared because .kpi-pace is a block caption everywhere else
   and here it is inline on a baseline-aligned row. */
/* The PACE caption goes right, beside the week label it qualifies. The
   `:not()` matters: `.track-inline-note` is also a `.kpi-pace`, and this rule
   outranked its own `justify-self: start` and dragged the low-balance notes
   over to the right-hand column with a gap in front of them (operator,
   2026-08-10: "please left align those notes"). Those sit on accounts with no
   week label, so there is nothing over there for them to sit beside. */
.track-head > .kpi-pace:not(.track-inline-note) {
  grid-column: 3; justify-self: end;
  margin-top: 0; text-align: right;
}
.track-head > .track-inline-note { margin-top: 0; }

.track-spent > span {
  position: absolute; top: 0; white-space: nowrap;
  font-size: 11px; font-variant-numeric: tabular-nums; color: var(--ink);
}
/* The pace figure, riding the footer beside the amount it qualifies rather than
   sitting a column away on the header row. Same colours as the header caption
   it replaced, so the meaning of over/under did not move with it. */
/* The .budget-edit block that lived here is gone with the inline goal form it
   styled -- goals moved to Settings > Budget Goals on 2026-08-12 and are laid
   out by .goal-row at the end of this file. Left as a note rather than
   silence: the next reader looking for the goal editor's styles should be
   sent somewhere, not left to grep. */

.track-spent .pace-inline { font-weight: 600; }
.track-spent .pace-inline.is-ok { color: var(--success); }
.track-spent .pace-inline.is-warn { color: var(--warning); }
.track-spent .pace-inline.is-high { color: var(--caution); }
.track-spent .pace-inline.is-over { color: var(--danger); }
.track-week.is-over .track-week-figure { color: var(--danger); }

/* Surplus breakdown. A ledger, so the figures line up in one right-hand column
   and the eye can add them without hunting. */
.sb-row {
  display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
  padding: 7px 0; font-size: var(--fs-figure);
}
.sb-label { min-width: 0; }
.sb-value { white-space: nowrap; font-variant-numeric: tabular-nums; }
/* The subtractions sit indented under the contribution they come off. */
.sb-group { padding-left: 12px; border-left: 2px solid var(--line); margin: 2px 0; }
.sb-row.is-subtotal { border-top: 1px solid var(--line); font-weight: 600; }
/* EACH SIDE OF THE LEDGER IS AN ENCLOSED CARD, built from the SAME tokens as
   the account tracks and the payday panel -- surface, hairline, --radius-md,
   the shared shadow pair. Nothing invented here: the operator asked for it to
   "look like the other tabs" (2026-08-07), and a summary that has its own
   private card style is exactly how a section stops looking like the app it is
   in. If those tokens change, this moves with them. */
.sb-section {
  padding: var(--space-2) var(--space-3);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm), var(--raise-edge);
}
.sb-section + .sb-section { margin-top: 10px; }
@media (max-width: 600px) {
  /* The same step .track takes at this width. Without it the popout's cards
     were the only ones on a phone still padded for a desktop. */
  .sb-section { padding: 6px var(--space-2); }
}
/* The eyebrow the rest of the app uses for a label above figures. */
/* Title Case, not ALL CAPS -- the house rule the stylesheet already stated
   twice, applied everywhere on 2026-08-07 at the operator's instruction.
   Caps-era tracking (0.03-0.04em) drops with it. */
.sb-head {
  margin: 0 0 2px; font-size: var(--fs-eyebrow); font-weight: 700;
  letter-spacing: 0.01em; color: var(--muted);
}
/* THE COLOURED LINE closes the card. Accent on BOTH: amber on the expenses
   total read as a warning, and there is nothing wrong with spending less than
   you earn (operator, 2026-08-07). The +/- signs already say which side it is.
   The .is-in/.is-out classes stay as DOM labels; they carry no colour. */
.sb-section .sb-row.is-subtotal {
  border-top: 2px solid var(--accent-line);
}
/* The grand total answers BOTH cards, so it sits outside them and keeps the
   accent rule it already had. */
.sb-section + .sb-row.is-total { margin-top: 14px; }
.sb-row.is-total {
  border-top: 2px solid var(--accent-line); font-weight: 700; font-size: 16px;
}
/* The whole card opens the breakdown. No chevron: the operator wants it reading
   like every other card on the page (2026-08-03), so the affordance is the tap
   target itself. Focus is still drawn, because a keyboard user has nothing else
   to go on. */
.is-openable { cursor: pointer; }
.is-openable:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.floor-list {
  display: grid; gap: 6px; margin: 0 0 var(--space-2);
  /* The popout sizes itself to its content, so trimming the number columns did
     not widen the name column -- it just made the whole panel narrower, leaving
     "Joint Discretionary" one pixel short and ellipsised. Claim a width floor
     instead. `min(100%, …)` so a 320px screen still cannot be made to scroll
     sideways. */
  min-width: min(100%, 300px);
}
/* Grid, not flex, so the two number columns line up under their headings --
   with flex the headings drifted off the inputs as soon as one account name
   was longer than another. minmax(0, 1fr) lets the name ellipsis rather than
   push the numbers off a 390px screen. */
/* STACKED per account: name on its own line, then the three figures and the
   toggle. Four columns already left the name 116px at 390px; a fifth would have
   forced account names to truncate, which is not acceptable here. */
/* The keypad, docked under the settings rows. Inside the panel rather than
   fixed to the screen, so it moves with the sheet and cannot be what covers the
   field being edited. */
/* `.lock-grace` is gone with the card it styled. The lock timeout is a
   `.floor-field` row now, like every other setting -- it was "a different KIND
   of setting so it gets its own card", which is exactly the reasoning that
   left four settings screens looking like four different apps. */
#lock-grace-state { color: var(--accent-line); font-size: var(--fs-eyebrow); }

.numpad { margin-top: var(--space-3); }
.numpad[hidden] { display: none; }
.numpad-keys {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
}
.numpad-key {
  min-height: 42px; padding: 0;
  border: 1px solid var(--line); border-radius: var(--radius-md);
  background: var(--surface-2, var(--group)); color: var(--ink);
  font: inherit; font-size: var(--fs-figure); font-weight: 600;
  font-variant-numeric: tabular-nums; cursor: pointer;
}
.numpad-key:active { background: var(--group); }
.numpad-done { width: 100%; margin-top: 6px; min-height: 40px; }
/* The field being edited, so it is obvious which figure the keys are changing
   when three sit side by side. */
.floor-input.is-editing {
  border-color: var(--accent); box-shadow: 0 0 0 2px rgba(62, 190, 157, 0.25);
}

/* Each account reads as a CARD, like every other grouping in the app -- the
   settings were a bare list of inputs on a flat panel and looked like a form
   bolted on rather than part of the product (operator, 2026-08-05: "the
   settings looks a little plain compared to the rest of the site").
   Chrome is deliberately thin: this block was just cut from 54px to 42px, and
   polish that undoes that is not an improvement. */
.floor-block {
  display: grid; gap: 1px;
  margin-bottom: 4px; padding: 1px 6px 3px;
  border: 1px solid var(--line); border-radius: var(--radius-md);
  background: var(--surface-2, var(--group));
}
/* The account name, in the same eyebrow the account cards use for theirs. */
.floor-block > .floor-name {
  font-size: var(--fs-eyebrow); font-weight: 600; letter-spacing: 0.01em;
  color: var(--muted); text-align: left; line-height: 1.3;
}
/* The figures sit on the panel behind the card, so each one reads as an input
   rather than as text on a tile. */
/* The figures read like the app's figures: same weight, same tabular numerals,
   same ink -- they were regular weight against 700 everywhere else, which is
   most of why the panel felt like a form rather than part of the product. */
.floor-block .floor-input {
  background: var(--surface); font-weight: 700; color: var(--ink);
}
/* The toggle in the app's accent, not the browser's blue -- the one colour on
   this panel that belonged to no palette at all. */
.floor-alert input { width: 17px; height: 17px; accent-color: var(--accent); }
/* The last track is a FIXED width, not `auto`. With `auto` the heading row --
   whose fourth cell is empty -- sized that column to zero and handed the 48px
   to the name, so every heading sat 27px left of the column it named. Measured
   at 390px, 2026-08-02. */
.floor-head {
  display: grid; grid-template-columns: 1fr 1fr 1fr 44px; gap: 6px;
  /* Aligned to the CARD's inner edges, not the panel's, so each heading sits
     over the column it names now that the rows are inset by their padding. */
  padding: 0 6px;
  /* Title Case, NOT uppercase. I had reached for caps to make the headings feel
     designed, but this app deliberately moved off ALL CAPS -- caps are slower to
     scan and the rest of the panel labels are Title Case. Matching the app is
     the point; decorating against it is what made this look bolted on. */
  font-size: var(--fs-eyebrow); font-weight: 600; letter-spacing: 0.01em;
  color: var(--muted); text-align: right;
  /* Room above. The headings first sat flush against an explanatory paragraph
     and read as its last line; that paragraph has since been removed, and the
     gap is still needed to separate them from the panel title. */
  margin-top: var(--space-2); padding-bottom: 2px;
}
/* Centred over the checkbox, which is centred in its own column. */
.floor-head span:last-child { text-align: center; }
/* Scoped through .floor-row on purpose. These live inside .info-tip, and
   `.info-tip span` (0,1,1) at the top of this file -- plus its narrow-screen
   twin -- beats a lone `.floor-name` (0,1,0) and forces white-space: normal,
   overflow: visible and font-size: inherit. The single-input layout had room to
   hide it; with two number columns the account names wrapped to two lines.
   Caught by measuring at 390px, 2026-08-02, not by looking. */
.floor-block .floor-name {
  min-width: 0; font-size: 12px; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.floor-input {
  width: 100%; min-width: 0; text-align: right;
  /* Size comes from the global 16px input floor. It is NOT set here and must
     not be: anything under 16px makes iOS zoom the page the moment the field
     takes focus -- see the note where the old [data-keypad] exemption used to
     be, at the end of this file. */
  font: inherit; font-variant-numeric: tabular-nums;
  /* TIGHT VERTICAL PADDING. The height had to come from here: the text stays
     at 16px because anything under that makes iOS zoom the page on focus, and
     these fields no longer open a keyboard anyway so they need no thumb-sized
     hit area -- tapping one only opens the in-page keypad. */
  padding: 1px 8px; border-radius: var(--radius-sm);
  border: 1px solid var(--line); background: var(--surface); color: var(--ink);
}

/* An edited figure must be obviously not-the-real-one at a glance. */

/* Bill payment state. Shown only where it carries information -- see
   billStatusPill() -- so these are always meant to draw the eye. */
.bill-pill {
  flex: 0 0 auto;
  letter-spacing: 0.02em;
}
/* These are <button>s now -- Pending and Not seen became tappable so a bill
   paid from another account can be settled by hand. Keep them looking like the
   labels they were: the affordance is the cursor and the title, not a second
   loud control on a table where most rows already carry one. */
button.bill-pill { font-family: inherit; cursor: pointer; }
.bill-pill.is-paid { background: var(--success-soft); color: var(--success); }
/* Neutral, not a warning. "Pending" is the expected state for a few days after
   every due date, so it must not read like a problem -- if it did, most of the
   table would look alarming for a week of every month. */
.bill-pill.is-pending { background: var(--surface-2); color: var(--muted); }
.bill-pill.is-overdue { background: var(--danger-soft); color: var(--danger); }
/* A question, not a statement: it is a button and should look pressable. */
.bill-pill.is-proposed {
  background: var(--warning-soft); color: var(--warning-strong);
  border-color: var(--warning); cursor: pointer;
}
.bill-pill.is-proposed:hover { filter: brightness(1.05); }
/* Recorded, but still openable -- inspecting a match the app made for you is
   the counterweight to it not having asked. Reads as a statement, not a
   question: no border, no warning colour. */
.bill-pill.is-paid { cursor: pointer; }
.bill-pill.is-paid:hover { filter: brightness(1.05); }

/* A Plaid description is a long unbroken run of shop, city and reference --
   "POS Withdrawal (FIS) YOUTUBE GOOGLE *YOUTUBE TV MOUNTAIN VIEW" -- and the
   table rules it inherits clip it to one line. It is the whole evidence for
   the match, so it wraps in full rather than trailing off (operator,
   2026-07-30: "Description is cut off"). */
.projection-table td.wrap-cell {
  white-space: normal;
  overflow-wrap: anywhere;
  text-overflow: clip;
  overflow: visible;
  max-width: none;
  line-height: 1.4;
}

.rollup-item.is-over { border-color: var(--warning); }
.rollup-item.is-over > strong { color: var(--warning-strong, var(--warning)); }

/* The history band carries up to five chips -- groceries, dining, other, avg
   shop, dining vs guess -- where the HELOC rollup it borrows from carries
   three. The phone rule below pins that band to ONE line, which clips the last
   chips here, so this band wraps instead. It sits inside a popout rather than a
   summary header, where a second row costs nothing. */
#history-breakdown { flex-wrap: wrap; row-gap: 6px; }

.rollup-item.is-total { border-color: var(--accent-line); }
.rollup-item.is-total > strong { color: var(--ink); }

/* Draw vs repayment, as a word rather than a sign. Once monthly repayments
   start, a column of figures where only the minus sign distinguishes money out
   from money in is easy to misread. */
.flow-tag {
  display: inline-block; padding: 1px 7px; border-radius: var(--radius-pill);
  font-size: 11px; font-weight: 600; white-space: nowrap;
  background: var(--tab-count-bg); color: var(--muted);
}
.flow-tag.is-repay { color: var(--success); }

/* Budget tracks: one row per thing being tracked. Three tiles across gave each
   bar ~120px and no room for a balance; a full-width row gives the bar real
   length and puts the account balance where it belongs -- next to the budget it
   is being spent against. */
/* A LIST OF CARDS, not one card with rules in it. 8px rather than the 12px
   between page sections: these four belong together, so they are separated but
   still legible as a group. */
.budget-tracks {
  display: grid; gap: 8px;
  margin-bottom: var(--space-3);
}
.budget-tracks > .track {
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm), var(--raise-edge);
}
/* Each answers a different question, so each gets its own card rather than
   another row in the accounts slab: balances, then what to move on Friday, then
   what is left for the month. A hairline divider says "next item in this list";
   a gap says "different thing". */
.cash-card {
  /* The surplus is a CONCLUSION drawn from the cards above it, not another card
     in the list. Measured at 12px, which read as one more account (operator,
     2026-08-03: "can we add more space between the monthly surplus and other
     accounts"). */
  margin-top: var(--space-5);
  margin-bottom: var(--space-3);
  /* EVEN PADDING, ample on every side. This was `2px var(--space-3)
     var(--space-2)` -- two pixels above the heading -- so every card on Cash
     Flow printed its title hard against its own top edge (operator,
     2026-08-14: "The tops of these panels are too close to the text. We need
     ample padding around every panel on the app"). Asymmetric interior padding
     has no defence here: nothing about a card's top needs less room than its
     bottom. */
  padding: var(--space-4);
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface);
  box-shadow: var(--shadow-sm), var(--raise-edge);
  overflow: hidden;
}

.track {
  display: grid;
  grid-template-columns: minmax(120px, 0.9fr) minmax(0, 2fr) minmax(96px, 0.7fr);
  align-items: center; gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  background: var(--surface);
}
/* TWO COLUMNS on the second row. This is a single-column grid, so every child
   gets its OWN ROW -- which is why adding the week label grew the card by a
   line wherever it was placed, in the markup order or after it. The name spans
   both columns; the figure and the week share row two, in space that was
   already empty. */
.track-head {
  /* Column one shrinks to the FIGURE, so the week label sits immediately beside
     it rather than being flung to the right edge by a 1fr (operator,
     2026-08-04: "week one of four next to the 500/week"). */
  /* figure | week label | pace. Three columns, because the week label and the
     pace share a ROW and putting both in column 2 stacked them instead. */
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
  gap: 1px 8px; min-width: 0; align-items: baseline;
}
.track-head > span:first-child { grid-column: 1 / -1; }
.track-head > strong { grid-column: 1; }
/* Quiet: an identifier, not a figure. Same weight as the label it trails. */
.track-mask { font-style: normal; font-weight: 500; opacity: .6; margin-left: 4px; }
.track-head > span { color: var(--muted); font-size: var(--fs-eyebrow); font-weight: 600; letter-spacing: 0.01em; }
.track-head > strong { font-size: var(--fs-figure); font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.track-bar { display: grid; gap: 2px; min-width: 0; position: relative; }
/* Names the shaded band as the current week. Centred on the band using the same
   two custom properties that paint it, so the word and the tint can never drift
   apart -- one source, two consumers.
   `position: absolute` keeps it OUT of the grid flow: it prints in the 2px gap
   and the leading the row already has, so naming the band costs the card no
   height. That matters because the operator has twice refused to spend vertical
   space on week information (2026-08-04: "I don't want to take up any more
   vertical space with the week number, just put it where you can find space").
   Quiet on purpose: it is a key to the tint, not a figure to read. */
.track-bar > .week-now-tag {
  position: absolute; bottom: 100%; margin-bottom: 1px;
  left: var(--wk-mid, 50%);
  transform: translateX(-50%);
  font-style: normal; font-size: 9px; font-weight: 600;
  letter-spacing: .04em; color: var(--muted);
  line-height: 1; pointer-events: none; white-space: nowrap;
}
.track-bar > .week-now-tag[hidden] { display: none; }
.track-balance { display: grid; gap: 1px; justify-items: end; text-align: right; min-width: 0; }
.track-balance > span { color: var(--muted); font-size: var(--fs-eyebrow); font-weight: 600; }
.track-balance > strong { font-size: var(--fs-figure); font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* The stamp is small on purpose but never absent: this figure comes off a timer,
   not off page load, so "as of" is part of the reading. */
.track-stamp { color: var(--muted); font-size: var(--fs-eyebrow); white-space: nowrap; }
/* Surplus has no budget and no account, so it reads as a summary line rather
   than a track with two empty thirds. */
.track.is-summary { background: var(--surface-2); }
/* A track that opens a projection. The row IS the target -- these rows are
   dense and a chevron squeezed in beside a balance would be a worse hit area
   than the row itself. */
/* A CHEVRON, because `cursor: pointer` and `:hover` are the only two things
   marking these cards as openable and a PHONE can show NEITHER. The tour was
   carrying a whole step to say "tap a card" -- which is the app failing to
   label itself, not something a tour should fix (operator, 2026-08-01: "if we
   have to over explain, maybe that means we need to make the app clear on that
   feature with better labeling"). Scoped to .is-openable, which is set only on
   cards that are actually wired, so it never promises a card that does nothing. */
/* The chevron's lane, sized from the new base padding rather than left at the
   18px that suited the old tight one. */
.track.is-openable { cursor: pointer; position: relative; padding-right: 24px; }
.track.is-openable::after {
  content: "\203A";                 /* single right-pointing angle quote */
  position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
  color: var(--muted); font-size: 19px; line-height: 1; opacity: .75;
  pointer-events: none;
}
.track.is-openable:hover { background: var(--surface-2); }
.track.is-openable:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
/* Projection table: dated movements and the balance after each. */
/* Date shrinks to its content, the movement name absorbs the slack, the money
   columns size to their figures. Without this the date column collapsed and
   "Aug 4" printed on top of "Narragansett Bay Commission", while the Balance
   column ran off the right edge. */
.projection-table { width: 100%; margin-top: var(--space-2); table-layout: auto; }
.projection-table th:first-child, .projection-table td:first-child {
  /* min-width, not width:1%. The movement column's `width:100%` starves any
     column that only asks for its content, so the date collapsed to 22px for
     50px of text and printed over the description. */
  width: 1%; min-width: 3.9rem; white-space: nowrap; padding-right: 10px;
}
/* :not(.number) matters. This rule makes column 2 absorb the table's slack and
   ellipsise -- right for a description, WRONG for a figure. The budget-history
   table reuses this class and its column 2 is Budget, so the header rendered
   "Bu..." and the value "$N...". A money column must never be the slack
   absorber; positional rules on a SHARED table class have to say what kind of
   column they mean. */
.projection-table th:nth-child(2):not(.number),
.projection-table td:nth-child(2):not(.number) {
  width: 100%; max-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.projection-table th.number, .projection-table td.number {
  width: 1%; white-space: nowrap; padding-left: 8px;
}
/* The history table's first column is a date RANGE ("Aug 1-Aug 8"), not a
   single day, so it needs more floor than the projection table -- and it is
   allowed to wrap rather than bleed over the Budget column. */
#history-table th:first-child, #history-table td:first-child {
  min-width: 5.2rem; white-space: normal;
}
/* THE BALANCE is what is below the floor -- not the bill that got it there.
   Reddening the whole row made the row's SUBJECT look like the problem:
   "I want you to highlight the balance of $NNN but not the gas bill. It seems
   like the gas bill is an issue otherwise" (operator, 2026-08-10). The date
   and the payee are facts, and neither is at fault. */
.projection-table td.is-below { color: var(--danger); font-weight: 700; }
/* AS MANY COLUMNS AS THERE ARE TILES, in equal shares. This was hard-coded to
   three; removing "Scheduled Out" on 2026-08-12 left two tiles in a three-track
   grid, so Balance was squeezed into a third while Minimum sprawled across the
   rest (operator: "no cell should have this much space... both boxes should
   have equal weight on that line").
   Column auto-flow reads the tile count instead of restating it, so the next
   tile added or removed cannot strand the row again -- the same failure the
   original comment here was written about ("no fields should be orphaned",
   2026-07-29).

   NOT `repeat(auto-fit, minmax(0, 1fr))`: with a flexible track that repeats
   without bound it generated several hundred zero-width phantom tracks, which
   sized correctly and read as nonsense in the computed style. Auto-flow creates
   exactly one track per child. */
#account-dialog-projection .joint-detail-strip {
  grid-template-columns: none;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
}
/* Every tile divided the same way: a rule to the right of all but the last. */
#account-dialog-projection .joint-detail-strip > div {
  border-bottom: 0;
  border-right: 1px solid var(--line);
}
#account-dialog-projection .joint-detail-strip > div:last-child { border-right: 0; }

/* The minimum is set rarely -- once per account, then never -- so the field row
   stays folded behind this and the resting state is a single row of figures. */
.tile-edit {
  appearance: none; border: 0; background: none; cursor: pointer;
  padding: 0; margin-left: 8px;
  font: inherit; font-size: 11px; font-weight: 600;
  color: var(--accent);
}
.floor-form[hidden] { display: none; }
/* ONE row: the field and both buttons. Wrapping put "Clear" alone underneath,
   which is the same orphan in a different control. */
.floor-form {
  display: flex; flex-wrap: nowrap; align-items: end;
  gap: var(--space-2); margin: var(--space-2) 0;
}
.floor-form label { flex: 1 1 auto; min-width: 0; }
.floor-form .button { flex: 0 0 auto; white-space: nowrap; }
.floor-form #projection-floor-state { flex: 0 0 auto; }
.floor-form label { display: grid; gap: 3px; color: var(--muted); font-size: 11px; }
.floor-form input {
  /* An input carries an intrinsic width from its `size` attribute, so raising
     the font to 16px (the iOS zoom guard) made it wider than the grid cell it
     sits in and it spilled its label. Explicit width, min-width: 0 so the grid
     may shrink it. */
  width: 100%; min-width: 0;
  min-height: 34px; padding: 6px 8px;
  border: 1px solid var(--input-border); border-radius: 4px;
  background: var(--input-bg); color: var(--ink);
}
#projection-floor-state { color: var(--muted); font-size: 12px; }
.track.is-summary .track-bar { align-self: center; }
@media (max-width: 600px) {
  /* Balance sits at the TOP of the box, beside the budget it is spent against
     -- that pairing is the whole point of the row, and burying it under the bar
     made it read as a footnote. Explicit placement, because DOM order puts the
     bar between them: head top-left, balance top-right, bar across the bottom.
     7px over a phone screen is still a scroll, so the padding stays tight. */
  .track {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas: "head balance" "bar bar";
    /* Was `6px var(--space-2)`, kept deliberately tight because a taller card
       means a longer scroll. He has since asked for the opposite outright, and
       a scroll is cheaper than a cramped card. */
    gap: 4px var(--space-3); padding: var(--space-3);
  }
  .track-head { grid-area: head; }
  .track-balance { grid-area: balance; justify-items: end; align-content: start; }
  .track-bar { grid-area: bar; }
}

/* Budget tile: a bar carries "how far through the budget" far better than three
   lines of figures. <i> is spend-to-date; <u> is the pace marker -- where you'd
   expect to be given how much of the period has elapsed. */
/* --seg is one week as a share of the period (set in JS). The repeating
   gradient draws a hairline at each week boundary, so a month-long bar reads as
   four-and-a-bit weeks rather than one undifferentiated run. */
.kpi-budget .bar, .track .bar {
  position: relative; display: block; height: 8px; margin: 6px 0 4px;
  border-radius: var(--radius-pill); overflow: hidden;
  background: var(--bar-track);
}
/* Week dividers ride ON TOP of the fill, not on the track behind it. As the
   background they were painted over the moment the bar filled past week one,
   and even unfilled they were a --line hairline on --group: nearly invisible.
   The gradient is clipped to the budget portion so it does not stripe the
   overage room. */
/* REMOVED 2026-08-11: `.is-week-over` re-pointed --bar-week-edge to amber, and
   that edge was drawn at the current week's right-hand end. But that end is
   always a multiple of one week, so it landed exactly on a week divider and
   painted it a different colour -- a line that read as an EXTRA tick sitting
   inside the current week rather than as the boundary it already was
   (operator, 2026-08-11: "the extra tick mark is on week two"). The overspend
   is already carried by the `s` segment below, which says how much rather than
   just that. The class is still set; it simply no longer paints. */
/* Spent beyond THIS WEEK'S share. On the app's own warning colour, so it is the
   same amber used everywhere else, and clearly not the red that means over the
   MONTH's budget. */
.kpi-budget .bar s, .track .bar s {
  position: absolute; top: 0; bottom: 0; width: 0; z-index: 0;
  background: var(--warning);
  transition: width var(--motion-base) var(--ease), left var(--motion-base) var(--ease);
}
.kpi-budget .bar s[hidden], .track .bar s[hidden] { display: none; }
.kpi-budget .bar.has-weeks::before, .track .bar.has-weeks::before {
  content: ""; position: absolute; inset: 0 20% 0 0; z-index: 1;
  pointer-events: none;
  /* TWO layers on the one overlay: the current week's band, then the dividers.
     It cannot go on the track's own background -- `.bar` sets `background` as a
     SHORTHAND further down this file, which resets background-image, and the
     fill would paint over it anyway (the same reason the dividers moved here).
     Percentages are of THIS element, which is already clipped to the budget
     portion, so they need no headroom correction. */
  background:
    /* 1. The band filling the week we are in. It is the ONLY thing marking the
       current week now: the separate bright edge rule that used to sit on top
       of it was removed, because the current week's right-hand end is always a
       week boundary and therefore always already drawn as a divider. */
    linear-gradient(to right,
      transparent 0 var(--wk-a, 0%),
      var(--bar-week-now) var(--wk-a, 0%) var(--wk-b, 0%),
      transparent var(--wk-b, 0%) 100%),
    /* 2. The week dividers, built in JS as explicit INTERNAL boundaries. A
       repeating gradient cannot express "every week except the last", and the
       line it drew at the far end was the budget edge masquerading as a week. */
    var(--week-lines, none);
}
/* Fill colour rides a green -> amber -> red ramp driven by PACE (--heat, 0..1),
   so it drifts as spending creeps rather than snapping between states. Because
   it tracks the position against the WHOLE period, a bad week can travel back
   toward green as later weeks recover -- one overspent week must not lock a
   month-long bar red, or the colour stops meaning anything. */
.kpi-budget .bar i, .track .bar i {
  position: absolute; inset: 0 auto 0 0; width: 0; opacity: .95; z-index: 0;
  /* RISES FROM ZERO (operator's animation batch, 2026-08-15). The fill is
     already born at width:0 and given its real width by JS a moment later, so
     the transition alone IS the rise -- nothing has to be sequenced.
     Slower than the sheets on purpose: a sheet is a surface being moved and
     wants to feel light, a bar is a QUANTITY being counted out and reads better
     unhurried. The ease-out lands it softly on the figure rather than stopping
     dead.
     `width` only. Colour is on the same element and travels its own ramp; a
     shared `transition: all` would drag the state colour through intermediate
     hues on every re-render. */
  transition: width 720ms cubic-bezier(0.22, 0.61, 0.36, 1);
  border-radius: var(--radius-pill);
  background: var(--success);   /* JS sets the state colour on this element */
  /* DEPTH, NOT INFORMATION. A gentle darkening left to right, laid over
     whatever colour the state put here -- so the fill reads as lit rather than
     flat, and nothing about the gradient means anything.
     Deliberately NOT a colour ramp along the bar. This app ran one of those and
     the operator rejected it -- "the colors of pink and then to yellow" -- and
     it would contradict the four states outright: the state is decided by PACE,
     so you can be 80% through the pot and dead on pace, and a positional
     gradient would paint that as trouble (asked again 2026-08-14; same answer,
     with the reason). */
  background-image: linear-gradient(90deg,
    rgba(255, 255, 255, .10) 0%,
    rgba(255, 255, 255, 0) 45%,
    rgba(0, 0, 0, .12) 100%);
  transition: width var(--motion-base) var(--ease), background var(--motion-base) var(--ease);
}
/* Hard red once actually over budget, regardless of pace. */
/* Overage: the segment past the budget line. Its own element rather than a
   longer fill, so "spent it all" and "spent 40% more than planned" cannot look
   the same. */
.kpi-budget .bar b, .track .bar b {
  position: absolute; top: 0; bottom: 0; width: 0; z-index: 0;
  /* THIS gradient does carry meaning, and it is the only one that does. The
     segment begins exactly at the budget line, so running the orange into the
     red across it says "this is where you crossed" about a boundary that
     genuinely exists -- unlike a gradient along the fill, which would invent
     one. */
  background: linear-gradient(90deg, var(--caution) 0%, var(--bar-over) 62%);
  border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
  transition: width var(--motion-base) var(--ease);
}
/* The budget line: where 100% sits on a track that runs to 125%. Without it the
   headroom just looks like a bar that never fills. */
.kpi-budget .bar::after, .track .bar::after {
  content: ""; position: absolute; top: -2px; bottom: -2px; left: 80%;
  width: 2px; z-index: 2;
  background: var(--surface);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
/* NOT forced red when over. The within-budget fill keeps its pace colour so the
   boundary between "the budget" and "past it" stays visible -- painting both
   segments danger made the whole bar one red block again, which is the thing
   the headroom exists to avoid. The red segment IS the over-budget signal. */
/* WHERE YOU SHOULD BE BY NOW. A HALF-HEIGHT notch rising from the bottom edge,
   not a full-height hairline: as a 2px --muted line it was the same width, the
   same brightness and the same shape as a week divider, so on a bar that also
   carries three of those it simply read as a fourth week tick in the wrong
   place (operator, 2026-08-11: "look at the tick marks, I think we have an
   extra one" -- the one he was counting sat inside the current week, which is
   exactly where pace falls mid-week). Shape carries the distinction, because
   colour alone has to survive being laid over green, amber and red fill. */
.kpi-budget .bar u, .track .bar u {
  /* FULL HEIGHT, like the runway bar's. It was a half-height notch (top: 50%)
     to keep it from reading as one more week divider -- but at that size it
     read as a DOT instead, and the same marker looked like two different
     things on two bars in the same app (operator, 2026-08-14: "make the food
     on target dot into a line just like the joint discretionary").
     ONE WIDTH FOR EVERY BAR: 2px. It was 3px here and 2px on the runway bar,
     which is the same mark rendered two ways in one screen -- the operator
     spotted it immediately (2026-08-15: "I want the bar and food for where we
     should be to be the same thickness as the one in joint discretionary...
     the food bar just has a thicker white line").
     WHY 2px IS SAFE NOW, since 3px was itself a fix: at 2px in a MUTED colour
     this was indistinguishable from a week divider and got counted as an extra
     tick (operator, 2026-08-11: "look at the tick marks, I think we have an
     extra one"). What separates them today is BRIGHTNESS, not width -- this is
     --ink at .95 against hairline dividers in --bar-week-line, which is a far
     bigger difference than one pixel ever was. Do not reintroduce a muted
     colour here on the assumption that the width is carrying the distinction. */
  position: absolute; top: 0; bottom: 0; width: 2px;
  background: var(--ink); opacity: .95; border-radius: 1px;
}
/* Explicit, not relying on the UA sheet's [hidden] rule surviving alongside the
   positioning above -- the marker is held for a period's first week. */
.kpi-budget .bar u[hidden], .track .bar u[hidden] { display: none; }
/* RUNWAY BAR (monthly pots). The marker here is not "pace within a week", it
   is HOW MUCH OF THE MONTH IS GONE, and it is the only thing the fill is meant
   to be compared against -- so unlike the weekly bar's half-height notch it
   runs the FULL height and reads as a hard line the fill is either behind or
   past. There are no week dividers to confuse it with on this bar. */
.kpi-budget .bar.bar-runway u, .track .bar.bar-runway u {
  top: 0; bottom: 0; width: 2px; background: var(--ink); opacity: .9;
}
/* The fill colour is set inline from the verdict, so nothing here fights it. */
.kpi-budget .bar.bar-runway i, .track .bar.bar-runway i { opacity: 1; }
/* The budget line stays on the runway bar: this track runs to 125% again, so
   the line at 80% marks where the pot actually ends and the overshoot begins.
   (It was suppressed while the runway bar had no headroom, when it drew a
   vertical rule with nothing to mean, next to the elapsed marker and reading
   as a rival to it.) */
/* The overshoot: money past the budget line. Square on its left so it reads as
   a continuation of the fill rather than a separate pill. */
.kpi-budget .bar.bar-runway b, .track .bar.bar-runway b {
  border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
}
.kpi-budget .bar.bar-runway b[hidden], .track .bar.bar-runway b[hidden] {
  display: none;
}
/* Surplus has no budget to track, but it sits beside two tiles that do. An
   invisible bar of the same height keeps all three tiles one shape, so the
   labels, values and captions line up across the strip instead of the odd one
   out floating. Purely a spacer -- hidden from assistive tech at the markup. */
.kpi-budget .bar-ghost, .track .bar-ghost { visibility: hidden; }
.kpi-budget .bar.is-idle u, .track .bar.is-idle u { display: none; }
/* The week slice under a MONTHLY budget (Discretionary is fungible across the
   month, so the weekly pulse still reads) used to be a second thinner bar. That
   made this tile one row taller than Food's and pushed its bar out of line with
   the rest of the strip. It now rides the end of the caption, dimmer than the
   month figure it qualifies -- one bar per tile, all on the same baseline. */
.kpi-pace .pace-extra { opacity: .72; font-weight: 500; }
/* `.kpi-strip span` (block + capitalize, §.kpi-strip) outranks the global
   .nm-full/.nm-short helpers, so EVERY dual label inside a KPI tile needs its
   own pair at matching specificity -- otherwise both variants render, stacked
   and capitalized. Declared once here (and once in the mobile block) rather
   than per-component: this trap has now cost three separate fixes. */
.kpi-strip .nm-full { display: inline; }
.kpi-strip .nm-short { display: none; }
.kpi-pace span { display: inline; text-transform: none; }
/* Scoped to .kpi-pace itself, not to .kpi-strip: this caption moved out of the
   strip into .budget-tracks, the .kpi-strip-scoped rules stopped matching, and
   both the full and short labels rendered end to end. */
.kpi-pace .nm-short { display: none; }
.kpi-pace .nm-full { display: inline; }

/* Leading context stat: HELOC principal borrowed to pay personal debt. A calm
   highlighted band (accent left-rule + soft surface), not an alarming figure. */
/* Compact: label, value and note sit on ONE baseline row so this context strip
   costs ~48px instead of ~94px, helping the whole Budget tab fit one screen. */
.budget-heloc-stat {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  margin-bottom: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--line);
  border-left: 3px solid var(--accent-line);
  border-radius: var(--radius-md);
  background: var(--surface-2);
  box-shadow: var(--shadow-sm), var(--raise-edge);
}

/* Budget KPI strip carries seven cells; let each shrink so nothing overflows. */
/* Equal tracks wasted the slack: the single-figure cells (Contributions, Bills)
   sat half-empty while Food and Discretionary -- which carry both cadences, an
   actual and a pace line -- were squeezed. Weight the tracks to what each cell
   actually has to say. */
/* Three tiles now (Food, Discretionary, Surplus) -- the six-track weighted
   list belonged to a strip that also carried Contributions, Bills and
   Expenses, all of which were table totals repeated from further down. */
#view-bills .kpi-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
/* Pace line: is spending on track for the period, not merely under budget. */
/* text-transform:none is load-bearing -- .kpi-strip span capitalizes every word
   for the tile eyebrows, which turned "$N.NN of $N,NNN.NN" into "... Of ...". */
.kpi-pace { display: block; margin-top: 2px; font-size: 11px; font-weight: 600; color: var(--muted); white-space: nowrap; text-transform: none; }
.kpi-pace.is-ok { color: var(--success); }
.kpi-pace.is-warn { color: var(--warning); }
/* The runway bar's third step. Without this the caption fell back to inherited
   ink at exactly the point the bar went orange, so the two disagreed. */
.kpi-pace.is-high { color: var(--caution); }
/* The runway FIGURE ("$N left") on the same four steps as the bar beneath it.
   The figure is the biggest thing on the tile, so it is what a glance actually
   reads -- leaving it plain ink while the bar went red said two different
   things at two sizes. `--ink` at rest rather than green: a budget that is
   simply fine should look calm, not congratulated. */
.track-head > strong.budget-figure.is-warn { color: var(--warning); }
.track-head > strong.budget-figure.is-high { color: var(--caution); }
.track-head > strong.budget-figure.is-over { color: var(--danger); }
.kpi-pace.is-over { color: var(--danger); }
/* One size for every track amount. These two were 14px to give the longer
   both-cadence string room, which left Personal Debt, Joint Bills and HELOC
   reading a size larger than Food and Discretionary in the same column
   (operator, 2026-07-30: "The personal debt font is bigger than the other
   fonts"). A column of figures that do not share a size reads as a mistake
   whatever the reason for it. Verified at 393px: nothing clips at 15px. */
.kpi-estact .kpi-sub { font-size: 11px; margin-left: var(--space-1); }

/* Food + Joint-expenses panels read as a matched set: side-by-side on wide, stacked on narrow. */
.budget-panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--space-3); margin: var(--space-2) 0 var(--space-4); }
/* Heading sits OUTSIDE the card, matching the Fixed bills / Income sections. */
.budget-panel > h2 { margin: 0 0 var(--space-1); font-size: var(--fs-figure); }

.pill.wife { color: var(--accent-line); background: var(--group-nested); border: 1px solid var(--line); }

dialog {
  width: min(980px, calc(100vw - 32px));
  max-height: calc(100vh - 32px);
  max-height: calc(100dvh - 32px);
  /* Pin to a FIXED top offset instead of the UA's `margin:auto` centring. When
     centred, every tab panel's different height re-centres the dialog, so the
     tab row jumps to a new y each time you switch tabs and the cursor has to
     chase it. Anchored to the top, the tabs stay put. */
  margin-top: 16px;
  margin-bottom: auto;
  padding: 0;
  border: 1px solid var(--dialog-border);
  border-radius: 6px;
  box-shadow: var(--shadow);
  color: var(--ink);
  background: var(--surface);
}
/* DIMMED AND BLURRED, not blacked out (operator, 2026-08-12: "the background
   should be dimmed and blurred when the settings pop up", pointing at the
   World Cup app, where the list behind the sheet is plainly there and plainly
   unreadable).

   This was nearly opaque for a real reason worth preserving: at 0.56/0.64 with
   only a 3px blur the page behind stayed LEGIBLE, and a dialog shorter than
   the viewport left the Bills tab's income table showing under it -- read,
   reasonably, as part of the popup itself (2026-07-30: "all the pop-ups for
   the accounts incorrectly show the contributions at the bottom").

   Both hold at once, because the two knobs do different jobs. Opacity decides
   how much shows through; BLUR decides whether what shows through can be read.
   At 14px nothing behind is legible as text at any opacity -- so the figure
   can come down far enough to see the app is still there, without ever
   layering two readable screens. */
dialog::backdrop {
  background: var(--backdrop);
  backdrop-filter: blur(14px) saturate(0.9);
  -webkit-backdrop-filter: blur(14px) saturate(0.9);
}
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 16px 10px; border-bottom: 1px solid var(--line); }
.dialog-header h2 { margin: 1px 0 0; font-size: 20px; letter-spacing: 0; }
.eyebrow { color: var(--muted); font-size: 11px; }
/* A HIT AREA IS NOT A VISIBLE BOX. This drew a bordered, filled 34px square --
   44px on a phone -- around a single character. Beside a dialog's <h2> that is
   proportionate; beside the 12px eyebrow heading of an inline panel it is the
   largest object on the panel, which is what the operator saw in the category
   breakdown (2026-08-12: "the close button is way too big").
   The tap target still measures 44px, because that is what a thumb needs and
   what the WCAG check reads. Only the CHROME goes: the button reads as a glyph
   and grows a background on hover/focus so it is still obviously a control. */
.icon-button {
  width: 34px; height: 34px; border: 1px solid transparent;
  border-radius: var(--radius-sm); background: none; color: var(--muted);
  font-size: 15px; line-height: 1;
}
.icon-button:hover, .icon-button:focus-visible {
  background: var(--btn-secondary-bg); color: var(--ink);
  border-color: var(--btn-secondary-border);
}
.tabs { display: flex; gap: 0; overflow-x: auto; border-bottom: 1px solid var(--line); padding: 0 12px; }
.tab { border: 0; border-bottom: 3px solid transparent; background: var(--surface); padding: 10px 12px 8px; color: var(--muted); }
.tab.active { color: var(--ink); border-bottom-color: var(--accent-line); font-weight: 700; }
/* Height follows the content -- a short panel (e.g. one evidence card) must not
   stretch to full height and leave dead space. The tab row still stays put
   across tabs because the dialog itself is pinned to a fixed top offset. */
/* Side padding so rows are not printed against the screen edges on a phone
   (operator, 2026-08-01: "too close to either edge of the screen"). */
/* Popouts whose content is a bare list or ledger need their own inline padding
   -- .dialog-content has none, and the dialogs that look right get it from a
   child (.tab-panel, a table). Without it the figures sit hard against the
   screen edge (operator: "for all popouts that have transactions they can't be
   too close to the edge"). Listed rather than applied to .dialog-content
   globally, because the ones that pad via a child would then get 32px. */
#surplus-dialog .dialog-content,
/* Added 2026-08-07 after a design pass measured both at left=0, right=0. This
   list is the mechanism -- it gets extended whenever a dialog ships whose
   content is a bare list rather than a .tab-panel, and these two were missed
   when they shipped. Third occurrence of the same rule; see §4z. */
#tag-picker-dialog .dialog-content,
#joint-dialog .dialog-content { padding-inline: 16px; }
/* ...but joint's .tab-panel already pads itself, so it must not double up. */
#joint-dialog .dialog-content > .tab-panel { padding-inline: 0; }
.dialog-content { overflow: auto; max-height: calc(100vh - 150px);
  max-height: calc(100dvh - 150px); }
.tab-panel { display: none; padding: var(--space-4); }
.tab-panel.active { display: block; }
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; }
.form-grid label { display: grid; align-content: start; gap: 3px; color: var(--muted); font-size: 11px; }
.form-grid label.wide { grid-column: 1 / -1; }
.form-grid textarea { resize: vertical; }
/* Read-only detail fields, laid out on exactly the same grid and rhythm as the
   editable .form-grid in the account dialog, so the two debt popouts read as
   one product. A <dl> was left unstyled here and fell back to the browser
   default -- indented <dd>s under full-width <dt>s, which looked like a bug.
   The values sit in the same box shape as an input but visibly inert: no
   border, a flat surface, so it reads as "recorded elsewhere", not "disabled". */
.joint-detail-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; margin: 0; }
.joint-detail-list > div { display: grid; align-content: start; gap: 3px; min-width: 0; }
.joint-detail-list > div.wide { grid-column: 1 / -1; }
.joint-detail-list dt { color: var(--muted); font-size: 11px; }
.joint-detail-list dd {
  margin: 0;
  min-height: 34px;
  padding: 6px 8px;
  border: 1px solid transparent;
  border-radius: 4px;
  background: var(--surface-2);
  color: var(--ink);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
/* Notes is prose and the one field allowed to run to several lines. */
.joint-detail-list > div.wide dd { font-weight: 500; white-space: pre-wrap; }
.joint-detail-note { margin: 0 0 var(--space-2); color: var(--muted); font-size: 12px; }
/* A proposed plan is not an arrangement. Flag it so the figures below are never
   read as money already being paid. */
.joint-detail-note.is-proposed {
  padding: 6px var(--space-3);
  border-left: 3px solid var(--warning);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--ink); font-weight: 600;
}
.joint-detail-source {
  margin: var(--space-3) 0 0; padding-top: var(--space-2);
  border-top: 1px solid var(--line);
  color: var(--muted); font-size: 11px;
}
/* NO ORPHANS: four figures. 2x2 on a phone rather than 3 + 1 stranded. */
.joint-detail-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.form-actions { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding-top: 14px; }
#save-state { color: var(--muted); font-size: 12px; }
.subform { border-top: 1px solid var(--line); margin-top: 14px; padding-top: 14px; }
.subform h3 { margin: 0 0 10px; font-size: var(--fs-figure); }
.record-list { display: grid; gap: 0; border-top: 1px solid var(--line); }
.record { padding: 10px 4px; border-bottom: 1px solid var(--line); }
.record h3 { margin: 0; font-size: var(--fs-figure); }
.record-meta { color: var(--muted); font-size: 11px; margin: 2px 0 5px; }
.record p { margin: 4px 0 0; white-space: pre-wrap; }
.record a { color: var(--link-fg); font-weight: 650; }
/* "Open document" is a button rather than an anchor so the request can carry the
   dashboard token, but it should still read as the link it replaced. */
/* NO UNDERLINE on a standalone action link, and this is now the app-wide rule
   (operator, 2026-08-13: "we should choose whether we want underline links
   across the app or not" -- pointing at a row where Monthly History was
   underlined and Report A Deposit beside it was not).
 *
 * The choice: an underline marks a link inside a BLOCK OF PROSE, where colour
 * alone would carry the meaning and fail WCAG 1.4.1. A standalone control --
 * one that sits in a row of its own, or at the end of a heading -- takes its
 * affordance from position and the accent colour, the way every native app on
 * the phone does. Underlining those adds a rule under a third of the words on
 * a dense financial screen.
 *
 * The prose exception is real and kept: see `.prose-link` below, which is what
 * an inline link inside a sentence must use. */
.link-button {
  padding: 0;
  border: 0;
  background: none;
  color: var(--link-fg);
  font-weight: 650;
  text-decoration: none;
}
/* THE EXCEPTION. A link inside running text is not distinguishable by position,
   so colour alone would be the only signal -- which is exactly what WCAG 1.4.1
   forbids. These keep the underline. */
.prose-link, p > a, .password-help a, .joint-detail-note a {
  text-decoration: underline; text-underline-offset: 2px;
}
/* Modal tab empty states (no offers / evidence / documents / history). A centred
   hairline panel with the same em-dash badge as the table empty states, so a tab
   with nothing in it reads as intentional rather than blank. */
.record-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
  padding: var(--space-5) var(--space-4);
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface-alt);
  color: var(--muted);
  text-align: center;
  font-size: var(--fs-cell);
  /* EXPLICIT, because eight call sites are <p> and eight are <div>, and the
     UA margin on one of them was silently deciding the gap. */
  margin-bottom: var(--space-3);
}
.record-empty::before {
  content: "\2014";
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: var(--radius-lg);
  border: 1px solid var(--line);
  background: var(--surface);
  color: color-mix(in srgb, var(--muted) 65%, transparent);
  font-size: 18px;
  line-height: 1;
}
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
  .record-empty::before { color: var(--muted); opacity: 0.7; }
}

/* IN THE TOP LAYER, or it is invisible exactly when it matters most.
   An open <dialog> paints in the top layer, above everything in the normal
   stacking context -- so every confirmation and every ERROR raised from inside
   a modal rendered behind it. `elementFromPoint` at the toast's own centre
   returned the dialog while the toast sat there saying "Tagged as Groceries",
   and a failed write said "Network request failed" to nobody (2026-08-07).
   `position: fixed` cannot escape that; only the top layer can, and
   `popover` is what puts a non-dialog there. */
#toast:popover-open, #toast { position: fixed; right: 18px; bottom: 18px; max-width: 360px; padding: 10px 12px; color: var(--toast-fg); background: var(--toast-bg); border-radius: 5px; box-shadow: var(--shadow); opacity: 0; pointer-events: none; transform: translateY(8px); transition: opacity .16s, transform .16s; }
#toast.visible { opacity: 1; transform: translateY(0); }
#toast { margin: 0; border: 0; overflow: visible; inset: auto 18px 18px auto; }
#toast::backdrop { background: transparent; }

@media (prefers-reduced-motion: reduce) {
  #toast { transition: none; }
}

@media (max-width: 900px) {
  .topbar { align-items: flex-start; padding: 12px 14px; }
  .top-actions { flex-direction: column; align-items: flex-end; }
  main { padding: 10px; }
  .kpi-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  #view-bills .kpi-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .kpi-strip > div { border-bottom: 1px solid var(--line); }
  .controls { align-items: stretch; }
  .controls label, .controls .button { flex: 1 1 145px; }
  .controls .search-field { flex-basis: 100%; }
  /* The track head keeps its figures on one line by default; in this band the
     grid cell is only ~175px and nowrap made it bleed 72px over its neighbour.
     Wrapping costs a second line only when it actually does not fit. */
  .track-head > strong { white-space: normal; }
  .controls input[type="search"] { min-width: 0; width: 100%; }
  /* ONE SCROLLING SECTION. The shell used to be its own scroll container on top
     of the page scroll, so a phone had two nested scrollers: the page moved,
     then the table moved inside it, and a flick did whichever the finger
     happened to be over. The page is now the only thing that scrolls; the
     tables are sized to fit the width, so they no longer need their own box. */
  /* Moved to the 600px block: the justification below only holds once the
     narrow-table column set kicks in at 600. Between 601 and 900 every column
     is still shown, so the shell must stay a scroll container -- without it the
     1760px table pushed the DOCUMENT to 2058px and the entire app scrolled
     sideways while the chrome stayed in the left 768px. */
  /* The KPI strip stops pinning here -- it is six tiles tall on a phone and,
     with the page as the scroller, it would eat the screen above the rows.
     The column header takes over the pinning job instead. */
  .kpi-strip { position: static; }
  th { top: var(--chrome-h, 122px); }
  .group-row td { top: calc(var(--chrome-h, 122px) + 28px); }
  .group-row.nested td { top: calc(var(--chrome-h, 122px) + 56px); }
  .form-grid, .joint-detail-list { grid-template-columns: 1fr 1fr; }
  /* NO ORPHANS: 4 figures, 2x2, instead of 3 across with one stranded.
     Scoped to #joint-dialog originally, which left the budget-history strip on
     four ~85px columns -- every figure there is "$X of $Y" and every one of
     them ellipsised. Any dialog carrying this strip needs the 2x2. */
  dialog .joint-detail-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  dialog .joint-detail-strip:not(.is-three-up) > div:nth-child(2n) { border-right: 0; }
  .terms-band dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* NO ORPHANS: 5 terms in 2 columns leaves the 5th alone; let it span the row. */
  #strategy-heloc-terms > div:last-child { grid-column: 1 / -1; }
}

@media (max-width: 600px) {
  /* A title attribute is unreachable on touch, so the movement column wraps
     here rather than clipping -- "Pawtucket Water Supply Board (PWSB)" lost
     116px to an ellipsis in a 160px column. */
  .projection-table td:nth-child(2):not(.number) {
    max-width: none; white-space: normal; overflow-wrap: anywhere;
  }
  /* DESKTOP-FIRST, but keep the phone usable: the desktop one-screen compaction
     hurts here. Four money figures across a 393px panel clip, and capping the
     tables fights the natural page scroll a phone already has. */
  /* The one-line stat rule exists so tiles in a ROW stay equal height. On a
     phone the tiles stack, so truncating a money value to "$NNN,N..." only
     hides it -- let values wrap instead. */
  .kpi-strip strong { white-space: normal; overflow: visible; text-overflow: clip; }
  .kpi-estact > div { white-space: normal; }
  .kpi-pace { white-space: normal; }
  .budget-food { display: block; }
  .budget-food-body { flex: none; }
  .budget-food-figures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: var(--space-2); }
  .budget-bills-shell, .budget-income-shell { max-height: none; }
  .budget-heloc-stat { gap: 2px var(--space-2); }

  /* Phone header compaction. The desktop stack (title + stacked actions, tall
     6-tile KPI grid, 4-row controls) ate the whole viewport, leaving ~2 rows and
     pushing the sticky grand-total footer off screen. Everything below tightens
     that chrome so several rows AND the footer stay visible. Scoped to <=600px;
     desktop/tablet layout is untouched. */

  /* Header: title on its own line, actions in a single right-aligned wrapping
     row (theme toggle + status pill + Export) instead of a 3-high column. */
  /* One row, not a stack: the actions sat on their own 44px line and the
     tagline added a third, so the FROZEN topbar cost 137px -- 21% of an iPhone
     screen. Title + freshness on the left, controls on the right. */
  .topbar { flex-direction: row; align-items: center; gap: 8px; padding: 8px 14px; min-height: 0; }
  .top-actions { flex-direction: row; align-items: center; gap: 6px; flex-shrink: 0; }
  /* Decorative on a phone; the wordmark already says what the app is. */
  .brand-tagline { display: none; }
  /* Ambient "all good" status isn't worth frozen pixels here -- an ERROR still
     shows, because that one the user needs. */
  .connection.online, .connection.pending { display: none; }
  .brand-mark { width: 24px; height: 24px; }

  /* NOTHING WRAPS ON A PHONE (operator, 2026-07-26). Anything that would wrap
     gets smaller or shorter instead -- a wrapped label makes its row or tile
     taller than its neighbours and the layout reads ragged. */
  .nm-full { display: none; }
  .nm-short { display: inline; }
  .settle-pct.nm-short { display: inline-block; margin-left: 4px; padding: 1px 4px; font-size: var(--fs-eyebrow); }
  .row-chevron { margin-left: 3px; }
  /* Same specificity fight as the desktop rule above: .kpi-strip span beats the
     bare .nm-* helpers, so every dual label in a tile needs its pair here too. */
  .kpi-strip .nm-full { display: none; }
  .kpi-strip .nm-short { display: inline; }
  .kpi-pace .nm-full { display: none; }
  .kpi-pace .nm-short { display: inline; }
  /* The name column ABSORBS the leftover width instead of taking a fixed cap.
     The 132px cap here dates from when this column was frozen beside a 1778px
     table; now that mobile shows three columns the cap only threw space away --
     "Narragansett Bay Commission" ellipsised at 128px inside a 186px cell.
     `max-width: 0` + `width: 100%` is the auto-layout idiom for "one flexible
     column": max-width:0 drops this cell's preferred width to the minimum so it
     never inflates the table, and width:100% then hands it every pixel the
     shrink-to-fit columns did not claim. Plain `max-width` in px is ignored by
     table auto-layout, which is why the earlier attempts had no effect. */
  th:first-child, td:first-child { width: 100%; max-width: 0; }
  /* Everything else is a figure or a short label: shrink to content. */
  th:not(:first-child), td:not(:first-child) { width: 1%; white-space: nowrap; }
  .clip-cell, .collector { max-width: none; overflow: hidden; text-overflow: ellipsis; }
  /* HELOC is the exception: its flexible column is Purpose (3rd), not the
     first -- Date and Allocation are both fixed-width figures. Point the
     absorb rule at Purpose so it gets the leftover instead of ellipsising
     "<partner> credit card (car repair)" while Date sits on unused width. */
  .heloc-table th:first-child, .heloc-table td:first-child { width: 1%; max-width: none; }
  .heloc-table th:nth-child(3), .heloc-table td:nth-child(3) { width: 100%; max-width: 0; }
  /* Bucket drops on a phone: the rollup band above already states each bucket's
     total, and Purpose is the column that was being cut off. */
  .heloc-table th:nth-child(2), .heloc-table td:nth-child(2) { display: none; }
  /* Tighter gutters on this table only. Three columns of it are fixed-width
     figures, so every pixel taken out of padding goes straight to Purpose --
     32px across four columns, which is the difference between the longest
     description fitting and not. */
  .heloc-table th, .heloc-table td { padding-left: 8px; padding-right: 8px; }
  /* A 15-column table cannot work on a 393px phone -- it was 1778px wide with
     only 2 columns on screen, so every other figure needed horizontal swiping.
     Show the few columns that answer "what is this and what do I owe", and drop
     the desktop min-widths so the table fits the screen instead of scrolling.
     The row still opens the full detail. Desktop keeps every column. */
  main table, .budget-bills-table, .budget-income-table,
  .joint-debt-table, .heloc-table { min-width: 0; }
  /* Now that the tables fit the width, they no longer need their own box:
     one page scroller instead of a scroller nested in a scroller. */
  .table-shell { max-height: none; overflow: visible; }

  /* Personal Debt: creditor, what's left, and the Open chevron. */
  /* Group and total rows now carry one cell per column (padding cells instead
     of colspans), so this single rule addresses every row type: a colspan slot
     cannot be targeted by nth-child, which both hid the footer's sums AND made
     the row four columns wide in a three-column table -- pushing figures 70px
     off the right edge. */
  /* Account / Total / Remaining. The chevron COLUMN is dropped here to pay for
     the third column -- three money-width columns plus a button do not fit
     373px. The row itself is still tappable and the name cell carries a
     chevron glyph, so the affordance survives at ~12px instead of 48.

     Total replaced Amount Paid on 2026-08-07, for two reasons. It is the
     headline figure of the book, and with it hidden the grand-total row could
     not show a FILTERED total balance on a phone -- the KPI strip deliberately
     totals the whole book (so it reconciles against the spreadsheet), so the
     footer is the only place a filtered figure appears, and the column it
     appears in was not on screen. It also makes this tab agree with Joint
     Debt, which already shows Type / Total / Amount Left; the note below says
     the two debt tabs should not read as different products, and until now
     they did. */
  #accounts-table th:not(:nth-child(1)):not(:nth-child(4)):not(:nth-child(9)),
  #accounts-table td:not(:nth-child(1)):not(:nth-child(4)):not(:nth-child(9)) { display: none; }
  /* Money columns take their content; the creditor name gets the rest. */
  #accounts-table th:first-child, #accounts-table td:first-child {
    width: 100%; min-width: 0; max-width: 0;
  }
  #accounts-table th:nth-child(4), #accounts-table td:nth-child(4),
  #accounts-table th:nth-child(9), #accounts-table td:nth-child(9) {
    width: 1%; max-width: none; white-space: nowrap;
  }
  /* Wrap the creditor name rather than ellipsise it -- the same choice the
     bills table makes. Two tables showing a name in their first column on the
     same phone should not resolve an overlong one two different ways. */
  #accounts-table td:first-child,
  #accounts-table td:first-child .account-name {
    white-space: normal; overflow-wrap: break-word; text-overflow: clip;
  }
  /* Gutters: 12px on the name, 6px on the value columns -- the same split the
     other four tables use. This was a blanket 8px, which made Personal Debt
     simultaneously tighter than everyone else on the name and looser on the
     money, for no reason anybody wrote down. */
  #accounts-table th:nth-child(4), #accounts-table td:nth-child(4),
  #accounts-table th:nth-child(9), #accounts-table td:nth-child(9) {
    padding-left: 6px; padding-right: 6px;
  }
  .row-chevron { display: inline; }

  /* Joint debt mirrors Personal Debt exactly: identity, what's left, chevron
     (there: Account / Amount left / Open). The two debt tabs should not read as
     different products. "Amount left" is now derived from the settlement plan
     rather than a NULL column, so it finally carries a real figure.

     Historical note on why this was not always 1/5/9: */
  /* Joint debt: type and what's owed. TWO columns, not three.
     NOT "Amount left" (col 5) -- NULL on every joint row, so the phone rendered
     a table of em dashes. And no longer "Amount paid" (col 4) either: the
     longest type, "Synchrony (Pipe Repair)", MEASURES 206px, and two money
     columns leave the name 161px. Abbreviating to fit ("Business" -> "Bus")
     made the clipping metric pass while the operator still could not read the
     row -- shortening the CONTENT is not fitting the COLUMN. Amount paid is
     absent or exactly equal to the total on every row, so it collapses to a
     tick beside the total and the name gets its full width at full size. */
  /* Type / Total / Amount Left / open.
     Amount Left ALONE was the wrong choice: it is zero or blank on 4 of these 6
     rows (three joint debts are settled to zero and one has no figure), so the
     phone showed a column of zeros while Total -- populated on 5 of 6, and what
     the KPI strip above actually sums -- was hidden. Showing both costs one
     column and answers "what is this?" as well as "what is left?". */
  .joint-debt-table th:not(:nth-child(1)):not(:nth-child(2)):not(:nth-child(5)):not(:nth-child(9)),
  .joint-debt-table td:not(:nth-child(1)):not(:nth-child(2)):not(:nth-child(5)):not(:nth-child(9)) { display: none; }
  /* Squeeze the money columns to their content, not the other way round.
     Operator, 2026-07-28: "you can just shorten the columns on the right
     instead of abbreviating the names". Five-figure amounts need ~72px; the
     rest of the row belongs to the name. */
  .joint-debt-table th:nth-child(2), .joint-debt-table td:nth-child(2),
  .joint-debt-table th:nth-child(5), .joint-debt-table td:nth-child(5) {
    width: 1%; white-space: nowrap; padding-left: 6px; padding-right: 6px;
  }
  /* The desktop rule gives Type `min-width: 240px` so it dominates a wide
     table. On a phone that floor is larger than the space left after two money
     columns, so the table grew to 454px and Amount paid ran off the screen --
     min-width beats the width:100%/max-width:0 absorb trick every time. Clear
     it here and let Type take exactly what is left. */
  .joint-debt-table th:first-child, .joint-debt-table td:first-child {
    min-width: 0; width: 100%; max-width: 0;
  }
  .joint-debt-table th:last-child, .joint-debt-table td:last-child { width: 1%; }
  /* Wrap the name here too. This rule reset only the SIZING, so `white-space:
     nowrap` from the desktop rule survived into the phone and this was the one
     table of five still cutting a name -- two of its six rows, by 18px and
     31px. Same declarations as the bills and debt tables above; a name in the
     first column of a table should not resolve an overlong value differently
     depending on which tab it is on. */
  .joint-debt-table td:first-child,
  .joint-debt-table td:first-child .account-name {
    white-space: normal; overflow-wrap: break-word; text-overflow: clip;
  }
  /* Drop the 44px Open BUTTON and fall back to the inline chevron glyph, which
     is what Personal Debt does. The button column was taking 68px of a 393px
     screen -- 18% -- for an affordance the row itself already has, and its
     min-height was inflating joint rows to 51px against the 28px every other
     table uses. One shared row height across every tab is a rule this table
     was quietly breaking. */
  .joint-debt-table td.go-cell, .joint-debt-table th:nth-child(9) { display: none; }
  /* Bills: which bill, what it actually charges, how often, when it's next due.
     (Columns are Bill, Amount, Cadence, Next due, Autopay, Monthly.)
     The phone used to keep Monthly and drop Amount + Cadence, which left a
     fortnightly bill reading as its larger monthly EQUIVALENT with nothing
     on the row to say so. Amount and Cadence are the operational
     facts; the monthly figure is a budgeting total and it already rides the
     section heading (.section-total), so no per-row copy is owed here. */
  .budget-bills-table th:nth-child(n + 5),
  .budget-bills-table td:nth-child(n + 5) { display: none; }
  /* The autopay marker inside the Cadence cell, PUSHED TO THE RIGHT EDGE so
     the pills form a column. Trailing the cadence word put them at a different
     x on every row -- "Bimonthly" is 16px wider than "Monthly", so Bayside's
     pill sat proud of the rest (operator, 2026-08-10: "I want the auto to be
     right aligned"). `margin-left: auto` in a flex cell does it.
     `.nm-short` would otherwise flatten the pill to display:inline. */
  /* The flex box is a WRAPPER inside the cell, not the cell itself: `display:
     flex` on a <td> takes it out of table layout and the column collapsed to
     12px. */
  .budget-bills-table .cadence-cell {
    display: flex; align-items: center; gap: 6px; width: 100%;
  }
  .budget-bills-table .autopay-mark,
  .budget-bills-table .bill-pill { margin-left: auto; }
  /* The name cell holds only the NAME now, so it no longer needs to wrap or to
     make room for a pill beside it. */
  .budget-bills-table td:first-child .name-cell { flex-wrap: nowrap; }
  /* Every value column sized to what it actually holds. They were rendering
     106/63/129/73 -- 265px for three short values -- while the NAME had 106px
     and needed up to 158 for "Verizon Internet" plus its Pending pill, which is
     what forced those rows onto two lines (operator, 2026-08-10: "fix the
     wrapping lines"). Tightening the gutters here returns that width to the
     name; nothing is abbreviated to buy it. */
  .budget-bills-table th:nth-child(n + 2):nth-child(-n + 4),
  .budget-bills-table td:nth-child(n + 2):nth-child(-n + 4) {
    padding-left: 4px; padding-right: 4px;
  }
  /* Give the leftover width to the NAME. Amount, Cadence and Next due were
     taking 246px of 371px between them to render values as short as "Aug 6",
     so every pixel of squeeze landed on the bill name -- fine at 393px in this
     font, clipped the moment the font is wider (design-standards 4aa). Sizing
     the three value columns to their content makes the name the flexible one
     instead of the sacrificial one. */
  /* `max-width: 0` is the shrink-and-ellipsise trick, and it was pinning this
     column to its MINIMUM -- 97px -- while the row had spare width sitting in
     the value columns. The name is the thing that should absorb the slack
     here, not surrender it, so it takes `width: 100%` without the cap. The
     value columns opposite are `width: 1%` and shrink to their content, which
     is what leaves the slack to absorb. */
  .budget-bills-table th:first-child, .budget-bills-table td:first-child {
    width: 100%; min-width: 0;
  }
  .budget-bills-table th:nth-child(n + 2):nth-child(-n + 4),
  .budget-bills-table td:nth-child(n + 2):nth-child(-n + 4) {
    width: 1%; max-width: none; white-space: nowrap;
    padding-left: 6px; padding-right: 6px;
  }
  /* AMOUNT SITS BESIDE THE NAME, not against the next column. The column is
     `.number`, so its values were right-aligned inside a 63px cell -- which put
     a short bill's figure roughly 90px from the end of its own name, with
     nothing in between (operator, 2026-08-14: "Move the amount column to the
     left since we have more space since we're wrapping the descriptions").
     Measured at 393px: Bill 97px, Amount 63, Cadence 138, Next Due 73 -- the
     row is completely spoken for, so there is no slack to reclaim by resizing.
     The distance is the alignment, and that is what changes here.
     PHONE ONLY. On a wider screen the column is long enough for right
     alignment to do its real job, which is making magnitudes comparable down
     the list. */
  .budget-bills-table th:nth-child(2),
  .budget-bills-table td:nth-child(2) { text-align: left; }
  /* `.budget-bills-table .account-name` is display:inline so a desktop name is
     never capped -- but text-overflow:ellipsis does nothing on a non-replaced
     inline box, so on a phone a name too long for the column (e.g. "Bank of
     America") was cut off mid-word with no ellipsis to show it. Put it back to
     a block here so the shared truncation above actually applies. */
  .budget-bills-table td:first-child .account-name { display: block; }
  /* ...and then let it WRAP rather than ellipsise. Even at 157px the longest
     names lost their tail ("House Cle...", "Vamco Water H..."), which is the
     precise complaint from 2026-08-06. A second line costs nothing on a phone;
     a cut-off bill name costs the one fact the row exists to give. */
  .budget-bills-table td:first-child,
  .budget-bills-table td:first-child .account-name {
    white-space: normal; overflow-wrap: break-word; text-overflow: clip;
  }
  /* The name must never be squeezed narrower than its longest WORD. `.name-cell`
     is a flex row and `.account-name` carries `min-width: 0`, so on a row that
     also holds a status pill the name was shrunk to make room and then broken
     mid-syllable -- "House Cleaning" came out as four lines reading "Hous / e /
     Clea / ning". `min-content` is the floor that stops it; `flex-wrap` gives
     the pill somewhere to go once the name has taken what it needs, which costs
     a second line on the few rows that have both and nothing anywhere else. */
  .budget-bills-table td:first-child .name-cell,
  #accounts-table td:first-child .name-cell {
    align-items: baseline; flex-wrap: wrap; row-gap: 2px;
  }
  .budget-bills-table td:first-child .account-name,
  #accounts-table td:first-child .account-name { min-width: min-content; }
  /* The debt table shares .budget-bills-table, so it inherits the rules above. */
  /* Contributions: who, what each paycheck actually is, how often, and when it
     lands. (Columns are Source, Kind, Amount, Cadence, Monthly, Pay Day.)
     The phone used to keep Source and Monthly ONLY -- the same mistake the
     bills table made, and a worse version of it: the per-paycheck amount, the
     cadence and the pay day were all unreachable on a phone, leaving a
     normalised monthly figure that matches no deposit that ever arrives. Kind
     is a badge that repeats what Source already says, and the monthly total
     rides the section heading (.section-total), so those two are the ones to
     drop. */
  /* Kind (2) and Monthly (4). Monthly was nth-child(5) until Amount moved to
     the end -- these indices track column ORDER, so a reorder silently changes
     which columns vanish. */
  .budget-income-table th:nth-child(2), .budget-income-table td:nth-child(2),
  .budget-income-table th:nth-child(4), .budget-income-table td:nth-child(4) { display: none; }
  .budget-income-table tfoot { display: none; }
  /* Source is the flexible column; the three value columns take their content
     so no squeeze lands on the name (see the bills table above). */
  /* SOURCE IS FIXED AT THE SAME WIDTH AS THE TRANSFERS ROW'S FIRST COLUMN, so
     Amount begins at the same x as "$N,NNN due" in the card above it (operator,
     2026-08-14: "Left Align the amount with the 1964 due in the section
     above"). 7.5rem is not a number picked to match a screenshot -- it is the
     literal value on `.deposit-row > span:first-child`, so the two stay
     aligned if either is ever retuned (design-standards 4aa).
     It was `width: 100%`, which made Source absorb every spare pixel and push
     Amount and Cadence to the right-hand end of the row. */
  .budget-income-table th:first-child, .budget-income-table td:first-child {
    width: var(--ledger-value-x); min-width: var(--ledger-value-x); max-width: none;
    white-space: normal; overflow-wrap: break-word; text-overflow: clip;
  }

  .budget-income-table th:nth-child(3), .budget-income-table td:nth-child(3),
  .budget-income-table th:nth-child(4), .budget-income-table td:nth-child(4),
  .budget-income-table th:last-child, .budget-income-table td:last-child {
    width: 1%; max-width: none; white-space: nowrap;
    padding-left: 6px; padding-right: 6px;
  }
  /* AFTER the group rule above, deliberately. Written before it, these lost:
     same specificity, so source order decides, and the group's
     `padding-left: 6px` and `width: 1%` silently beat them. Order is the fix,
     not more specificity.

     SOURCE is pinned to --ledger-value-x, the same value the Transfers row uses
     for its first column, so this table's values begin exactly where that
     card's captions do.

     THE THREE REMAINING COLUMNS SPLIT WHAT IS LEFT, EQUALLY -- Cadence (3),
     Pay Day (5), Amount (6) -- so the row is evenly spaced from where the
     values start to the right edge, instead of clumping (operator, 2026-08-14:
     "space off the columns on the right equally but only starting from where
     amount is now"). Three equal percentage claims over-constrain the table,
     which is what makes the browser divide the remainder between them. */
  .budget-income-table th:first-child, .budget-income-table td:first-child {
    width: var(--ledger-value-x);
    min-width: var(--ledger-value-x);
    max-width: var(--ledger-value-x);
  }
  .budget-income-table th:nth-child(3), .budget-income-table td:nth-child(3),
  .budget-income-table th:nth-child(5), .budget-income-table td:nth-child(5),
  .budget-income-table th:nth-child(6), .budget-income-table td:nth-child(6) {
    width: 33.33%;
    padding-left: 0;
  }
  /* Amount ends at the row's right edge, which is where the figure sits on
     every other panel of this tab. */
  .budget-income-table th:nth-child(6), .budget-income-table td:nth-child(6) {
    text-align: right;
  }
  .budget-income-table td:first-child .account-name {
    display: block; white-space: normal; overflow-wrap: break-word; text-overflow: clip;
  }
  /* HELOC on a phone: the ALLOCATION is the point of the table, and it was
     being pushed off screen by a wide ISO date. Date shrinks to a compact
     "Jul 9", Allocation is pinned to its content, Purpose absorbs the rest. */
  /* (Removed: a Date/Purpose/Allocation width trio written as nth-child 1/2/3.
     nth-child counts DOM columns, and Bucket -- column 2 -- is only hidden by
     `.col-grouped`, which does NOT renumber its siblings. So those rules landed
     one column left of their intent: Purpose got the shrink meant for
     Allocation and ellipsised at 65px while Allocation sat on 140px. The
     .heloc-table pair above targets the right indices.) */

  /* The frozen column is pointless once the table fits the screen, so drop the
     HORIZONTAL freeze -- but only that.
     Two traps have already been sprung on this one rule:
     1. It used to say `max-width: none`, which silently undid the `max-width: 0`
        absorb idiom above, so the name column could not shrink and any table
        that grew a column ran off screen instead of squeezing the name.
     2. It used to say `position: static`, which also killed the cell's VERTICAL
        stickiness from `th { position: sticky; top: 0 }`. The first header cell
        scrolled away while its siblings stayed pinned, so "Next Due | Monthly"
        floated over an unrelated data row with no "Bill" label beside them --
        a 219px split that read as a corrupted row. The grand-total footer tore
        the same way: the "Total" label scrolled off while its two figures stayed
        pinned, leaving two unlabelled amounts at the bottom of the screen.
     Reset `left` and `z-index` instead and leave `position` alone. */
  th:first-child, td:first-child { left: auto; z-index: auto; }
  /* ...but the HEADER's first cell still has to out-stack the body's. With both
     on `z-index: auto` they are painted in DOM order, so every name cell below
     draws over the pinned "Bill"/"Account" heading -- trap 2 above, arriving by
     a different route the moment these tables started pinning at all (v653).
     It showed as a bill row sitting on top of the header strip. */
  th:first-child { z-index: 4; }

  /* The name fills its cell and ellipsises only at the cell's real edge -- no
     fixed cap, so a short table gives the name every spare pixel. */
  td:first-child .account-name { max-width: none; overflow: hidden; text-overflow: ellipsis; }
  .view-tab { white-space: nowrap; padding-left: 6px; padding-right: 6px; }
  .filter-toggle { white-space: nowrap; }
  th, td { white-space: nowrap; }
  .kpi-pace { white-space: nowrap; font-size: var(--fs-eyebrow); }
  /* Values shrink rather than wrap; 2-up tiles leave ~186px, enough for
     "$NNN,NNN.NN" at this size. */
  .kpi-strip strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-figure); }
  .kpi-strip span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  /* ...but not inside a popover nested in the strip. That rule is meant for
     TILE labels, which must stay one line; the terms panel is prose and has to
     wrap. Same specificity, so it has to come after. */
  .info-tip span { white-space: normal; overflow: visible; text-overflow: clip; }
  .kpi-estact > div { white-space: nowrap; }
  /* Long prose values (HELOC payment structure) truncate instead of running to
     five lines; the full text stays on the title tooltip. */
  #view-heloc .kpi-strip strong { font-size: var(--fs-figure); }
  /* The desktop `max-content max-content 1fr` track list is an ID-qualified
     rule, so it outranks the generic 2-up mobile grid and forced the payment
     term 32px past the right edge. Restate it here at equal specificity and
     give the payment its own full-width row -- it is the one prose value and
     needs the whole 373px to read without an ellipsis. */
  #view-heloc .heloc-terms-strip { grid-template-columns: 1fr 1fr; }
  #view-heloc .heloc-terms-strip > div:last-child { grid-column: 1 / -1; }
  /* Filters cost 193px on Personal Debt and 121px on HELOC, pushing the table
     start past half the screen before any data showed. On a phone they're a
     secondary action, so collapse them behind a toggle, closed by default. */
  .filter-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 4px 10px;
    border: 1px solid var(--input-border); border-radius: var(--radius-sm);
    background: var(--btn-secondary-bg); color: var(--ink); font: inherit; font-size: 13px; font-weight: 600; }
  /* The quick-filter chips are ALWAYS visible -- they are the point of the row.
     Only the advanced controls (search, grouping, expand/collapse) collapse
     behind "More". The old rule hid every child except .filter-toggle, and that
     button now lives inside the chip row, so it hid the whole thing. */
  /* Every remaining control is a filter worth showing; there is no bar left to
     collapse. Grouping and search live on as hidden inputs. */
  /* 379px of controls in a 373px row put Legal on a line of its own -- an
     orphan. Six pixels was the whole problem: trim the segment padding and the
     row gap and all five sit on one line. */
  .seg { font-size: 12px; padding: 4px 7px; min-height: 32px; }
  .controls.debt-controls { gap: 6px; padding: 8px 6px; }
  /* A segmented track cannot wrap without looking broken, so the row it sits in
     must be able to. Caught by the audit: "None" was 35px past the right edge. */
  .segset { flex-wrap: nowrap; }
  #view-heloc .controls { row-gap: 6px; }
  .toggle-chip { font-size: 12px; padding: 4px 9px; }
  .controls { padding: 6px 0; gap: 6px; }
  /* A ~120px tile can't hold both cadences; keep the budget's native period.
     Needs .kpi-strip scoping to outrank `.kpi-strip span { display: block }`. */
  .kpi-strip .cad-2nd, .kpi-strip .cad-sep { display: none; }
  #view-bills .kpi-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .topbar h1 { font-size: 19px; }
  /* Scoped to the BRAND block. As `.topbar p` this also clamped prose inside
     the alerts popover -- a descendant of .topbar -- cutting 30px off a
     sentence in a panel with room to spare. */
  #freshness[hidden] { display: none; }
.topbar > .brand p { margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
  .info-tip p { white-space: normal; overflow: visible; }
  .top-actions { flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
  .connection { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; }

  /* Tabs: swap the long HELOC label for the short one and hold all three to one
     line so nothing wraps. */
  /* The bottom pad has to clear the iOS home indicator. This rule used to end
     in a flat 14px and, being the last `main` rule in the file, it quietly won
     over the safe-area version -- the final table row and the total sat under
     the indicator. */
  /* 56px of reserved bottom gutter was more than the home indicator needs and
     was the last thing keeping the Cash tab off a single screen. The standard
     is ~8px above the indicator; env() still supplies the real inset on a
     device, so this only trims the fixed padding stacked on top of it. */
  main { padding: 8px 10px calc(20px + env(safe-area-inset-bottom, 0px)); }
  /* Chips size to their label, so a phone just needs the row tightened. The
     ::before track went with the segmented control -- there is no track to
     inset any more. */
  /* Only the margin. The track's own padding and gap are decided ONCE in the
     base rule -- this used to set `padding: 4px 0 9px` for the old pill row and
     went on applying it to the segmented track, leaving 9px of dead space under
     the segments (operator: "Reduce the outside box to fit the buttons
     exactly"). Same specificity-by-accident as rule 0e. */
  /* These two used to RESET the base rule on the one device the change was made
     for -- padding back to 6px and the clearance back to 8px, so the thicker
     underline and its breathing room were invisible on his phone. Keep the
     bottom padding; only the sides tighten here. */
  .view-tabs { margin-bottom: var(--space-3); }
  .view-tab { padding: 6px 4px 10px; }
  /* Tabs size to their LABEL, not to an equal share of the row. `flex: 1 1 33%`
     gave "Budget" and "HELOC" the same box as "Personal Debt", so short labels
     floated in a pool of padding, the gaps between words read as random, and
     the active underline ran far wider than the word it underlines. Content
     width + space-between distributes the row evenly, starts flush left, and
     makes the underline match its label. */
  .tab-label-full { display: none; }
  .tab-label-short { display: inline; }

  /* KPI strip: 6 short tiles in a 3-up, 2-row grid instead of a tall 2-up grid. */
  .kpi-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 8px; }
  .kpi-strip > div { padding: 6px 8px; min-height: 42px; }
  .kpi-strip > div:nth-child(3n) { border-right: 0; }
  .kpi-strip span { font-size: var(--fs-eyebrow); letter-spacing: 0.01em; }
  .kpi-strip strong { margin-top: 2px; font-size: var(--fs-figure); }
  #view-debts .kpi-strip, #view-bills .kpi-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  /* NO ORPHANS: 3 tiles -- 2 columns would strand the third. */
  #view-joint .kpi-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  /* NO ORPHANS: 4 tiles in 3 columns left Credit line alone on row 2 next to
     two empty cells. 2 x 2 is the only balanced split for four. */
  #view-heloc .kpi-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  #view-heloc .kpi-strip > div:nth-child(2n) { border-right: 0; }
  /* ...and PUT BACK the divider on cell 3. It is the first cell of row 2 in a
     two-column grid, but it still matches the three-column `nth-child(3n)` rule
     above, so it lost its right border while the identical 2x2 strip in
     #joint-dialog kept it. Stated at matching specificity rather than by
     reordering, so the two-column intent survives the next edit to the 3n rule. */
  #view-heloc .kpi-strip > div:nth-child(2n+1) { border-right: 1px solid var(--line); }

  /* (Removed: an order/border reshuffle that kept the two bar tiles from
     splitting across a row break. The strip is three tiles on one row now,
     so there is no break to work around.) */

  /* Controls: an explicit 3-column grid — search spans the row, the three selects
     share one row, and Include-paid + Expand + Collapse share the last row. Cuts
     the four wrapped rows down to three tight ones. */
  .controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; align-items: end; padding: 8px; margin-bottom: 8px; }
  /* ...but NOT the debt controls. That grid was written for a search box plus
     four selects; a 4-button segmented control does not fit a 113px track, so it
     overflowed its cell and the Legal risk chip was painted on top of it --
     tapping "Settled" toggled Legal risk instead. This override has to live
     AFTER the grid rule, not with the component, or it simply loses. */
  /* Any control row using segments, not just the debts one. Scoping this to
     #view-debts left the HELOC row on the 3-column grid: its segset became a
     113px flex item, shrank below its content, and the buttons spilled OUTSIDE
     their own rounded track -- "None" sat beyond the pill. The grid's
     `align-items: end` also dropped the label to the bottom of the row. */
  .controls.debt-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
/* The label belongs to the control beside it, so it sits closer to it than the
   controls sit to each other. */
.control-label + .segset { margin-left: -4px; }
/* Inline label for a segmented control. Sitting ON the row rather than stacked
   above it keeps the control one line tall and reads as "roll up by: bucket". */
.control-label {
  color: var(--muted); font-size: var(--fs-eyebrow); font-weight: 600;
  letter-spacing: 0.01em; white-space: nowrap;
}
  .controls label, .controls .button { flex: none; }
  .controls .search-field { grid-column: 1 / -1; }
  .controls input[type="search"], .controls select { min-height: 40px; font-size: 16px; }
  /* Grid items default to min-width:auto (their content min-width); in WebKit the
     last select then overflows its 1fr track and pushes the page ~17px wide at
     320px. min-width:0 lets each control honour its track (fixes §4.1 overflow). */
  .controls label, .controls select, .controls .button { min-width: 0; }
  .controls .checkbox-field { min-height: 40px; padding: 0 4px; justify-content: flex-start; }

  /* Table height budget: with the tightened chrome the stack above the debts
     table is far shorter than the old 664px assumption, so give the shell more
     room while keeping the bottom-sticky grand total on screen. */
  #view-debts .table-shell {
    min-height: 120px;
    max-height: calc(100vh - 500px);
    max-height: calc(100dvh - 360px);
    margin-bottom: max(10px, env(safe-area-inset-bottom));
  }

  /* EVERY POPOUT IS THE SAME INSET CARD, first level or second. It used to be
     edge-to-edge and square-cornered on a phone -- a full takeover -- so a
     second-level popout had no way to read as "the same box, redrawn": it was
     just another takeover replacing the last one.
     One card geometry, and a child that swaps the contents then looks like
     what it is. Matches the reference the operator gave (the World Cup app,
     2026-08-12) where both levels are the same rounded, inset sheet. */
  dialog {
    width: calc(100vw - 24px); max-width: 560px;
    border: 1px solid var(--dialog-border); border-radius: 14px;
    box-shadow: 0 18px 44px rgba(0, 0, 0, 0.45);
  }
  /* dvh tracks the VISIBLE viewport; 100vh in Safari-with-toolbars overshoots
     it and pushes the dialog footer under the browser chrome. */
  /* MINUS the 16px top margin, which is what keeps the tab row from chasing
     the cursor. 16 + 100dvh is 100dvh + 16, so every sheet hung its last 16px
     below the fold -- under the home indicator on an iPhone. */
  /* HEIGHT FOLLOWS THE CONTENT, capped at the viewport (operator, 2026-08-12:
     "I want the secondary pop outs to be only as big as they need to be to
     show the data and not taking up the whole screen").
     This was `height: calc(100dvh - 16px)`, so a sheet holding one select and
     a sentence was drawn exactly as tall as one holding sixty transactions --
     a screen of empty surface under a two-line answer.
     `bottom: auto` is the load-bearing half: an open <dialog> gets `inset: 0`
     from the UA stylesheet, and with BOTH top and bottom pinned the box
     stretches to fill whatever `height: auto` says. That is why the password
     and PIN sheets needed the same line below, where it is measured: 836px of
     box for a 349px form.
     A LONG dialog is unaffected -- it hits max-height and .dialog-content
     scrolls, exactly as before. */
  dialog { max-height: calc(100dvh - 16px); height: auto; bottom: auto; }
  .dialog-content { max-height: calc(100vh - 132px);
    max-height: calc(100dvh - 132px); }
  .form-grid, .joint-detail-list { grid-template-columns: 1fr; }
  .form-grid label.wide, .joint-detail-list > div.wide { grid-column: auto; }
  /* 16px inputs so iOS Safari never zooms on focus in the account/offer modal;
     44px min so every field clears the Apple touch-target floor. */
  .form-grid input, .form-grid select, .form-grid textarea { font-size: 16px; min-height: 44px; }
  .alert-item { align-items: stretch; flex-direction: column; }
  .alert-actions { justify-content: flex-start; }
  .terms-band dl { grid-template-columns: 1fr; }

  /* Touch-target floor (WCAG 2.5.5 / Apple HIG 44px). In-table controls keep
     their small visible glyph and expand only the HIT AREA via an invisible
     ::after overlay so the table stays dense. */
  /* Same overlay for the two controls the review measured under the WCAG 2.5.8
     24px floor: the HELOC terms ⓘ at 22×22 and the "Paid?" bill pill at 47×20.
     The glyph is unchanged; only the tap box grows. */
  .group-toggle, .row-action, .info-button,
  .bill-pill.is-proposed, .bill-pill.is-paid { position: relative; }
  /* Going from absolute to relative drops the glyph into the inline flow right
     after the figure, with nothing between them: measured a -0.1px gap against
     "$NNN,NNN" at 393px, the button box overlapping the value by 3.6px. It is
     only wrong on the phone, because on desktop it is still pinned to the
     tile corner and sits 55-122px clear. */
  .info-button { margin-left: 8px; }
  .group-toggle::after,
  .info-button::after,
  .bill-pill.is-proposed::after,
  .bill-pill.is-paid::after,
  .row-action::after {
    content: "";
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    min-width: 44px;
    min-height: 44px;
    width: 100%;
    height: 100%;
  }

  /* Chrome buttons that a box-measuring linter reads directly: give the element
     itself a >=44px hit box (the glyph/label is unchanged, only the tap padding
     grows). Overlay pseudo-elements don't satisfy a rect-based measure. */
  /* 44px squares dominated a 62px bar with three of them in a row. 36 keeps a
     comfortable target while giving the brand and status line the space. */
  .icon-only { width: 36px; min-height: 36px; }
  #export-csv { min-height: 44px; }
  .icon-button { width: 44px; height: 44px; }
  /* The primary tab size is decided ONCE, in the base rule. This block used
     to cap it at 44px and quietly undo it. */
  /* Checkbox box itself toward the WCAG 2.5.8 floor (label already extends the
     row's hit area, but the control glyph was 17px). flex-shrink:0 stops the
     controls grid from squeezing the box below 22px on the narrowest widths. */
  .checkbox-field input { width: 22px; height: 22px; flex-shrink: 0; }

  /* Modal tab strip: tighten per-tab padding so all five come as close to fitting
     320-393px as possible; where they still overflow (narrowest widths) a trailing
     fade signals the strip scrolls so "History" isn't mistaken for the end. */
  .tabs {
    padding: 0 8px;
    -webkit-mask-image: linear-gradient(to right, #000 86%, transparent);
    mask-image: linear-gradient(to right, #000 86%, transparent);
  }
  .tab { padding: 10px 9px 8px; }
}

/* Narrow phones: three KPI tiles clip long money like $NNN,NNN.NN (~104px inner
   needed; 3-up only clears that at ~393px+). Below 393px drop to a 2-up grid
   (~150px/tile) so no figure truncates; 393-600px keeps 3-up where it fits. */
@media (max-width: 392.98px) {
  .kpi-strip,
  #view-joint .kpi-strip,
  #view-heloc .kpi-strip,
  #view-bills .kpi-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  /* Same guard for the 3-column strips: a last tile landing in column 1 is
     alone on its row, so let it span rather than float. */
  #view-joint .kpi-strip > div:last-child:nth-child(3n + 1),
  #view-heloc .kpi-strip > div:last-child:nth-child(3n + 1),
  #view-bills .kpi-strip > div:last-child:nth-child(3n + 1) { grid-column: 1 / -1; }
  /* Money tiles in 3 columns are too narrow at this width; 2 columns keeps every
     figure readable. */
  #view-debts .kpi-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Span the last tile ONLY when it would otherwise sit alone. The old rule
     spanned it unconditionally, which was right for the 5 tiles it was written
     for and wrong the moment a 6th arrived: tile 5 was then stranded alone AND
     tile 6 spanned, giving two orphan rows instead of none. Keyed to position
     rather than to a count, so it stays correct as tiles come and go. */
  #view-debts .kpi-strip > div:last-child:nth-child(odd) { grid-column: 1 / -1; }
  .kpi-strip > div:nth-child(3n) { border-right: 1px solid var(--line); }
  .kpi-strip > div:nth-child(2n) { border-right: 0; }
}

/* ─────────────────────────────────────────────────────────────────────────
   Authentication surface: the sign-in page, the topbar identity chip, the
   default-password banner, and the change-password dialog.

   Everything here is routed through the same tokens as the dashboard, so the
   login screen re-skins with the app and reads as the same product in both
   themes. Mobile-first sizing: 16px inputs (iOS zooms any smaller field on
   focus) and 44px minimum touch targets.
   ───────────────────────────────────────────────────────────────────────── */

.auth-body { background: var(--page); }
.auth-page {
  min-height: 100vh;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: max(var(--space-5), env(safe-area-inset-top)) var(--space-4)
           max(var(--space-5), env(safe-area-inset-bottom));
}
.auth-card {
  width: min(400px, 100%);
  display: grid;
  gap: var(--space-4);
  padding: var(--space-5);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow), var(--raise-edge);
}
.auth-brand { display: grid; justify-items: center; gap: 2px; text-align: center; }
.auth-mark { width: 44px; height: 44px; border-radius: var(--radius-lg); }
.auth-brand h1 {
  margin: var(--space-2) 0 0;
  font-size: var(--fs-title);
  font-weight: 700;
  letter-spacing: -0.015em;
  color: var(--ink);
}
.auth-tagline {
  margin: 0;
  color: var(--muted);
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.01em;
}

.auth-error,
.auth-note {
  margin: 0;
  padding: 10px var(--space-3);
  border-radius: var(--radius-sm);
  font-size: var(--fs-caption);
  line-height: 1.45;
}
.auth-error { background: var(--danger-soft); border: 1px solid var(--danger); color: var(--danger); }
.auth-note { background: var(--warning-soft); border: 1px solid var(--warning); color: var(--warning); }
.auth-note code { font-size: 11.5px; }
.auth-field { display: grid; gap: var(--space-1); color: var(--muted); font-size: var(--fs-caption); font-weight: 600; }
.auth-field input {
  /* 16px is the iOS Safari zoom floor; 44px clears the Apple/WCAG touch floor. */
  font-size: 16px;
  min-height: 44px;
  padding: 0 var(--space-3);
  color: var(--ink);
  background: var(--input-bg);
  border: 1px solid var(--input-border);
  border-radius: var(--radius-sm);
}
.auth-field input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.auth-password { position: relative; display: block; }
.auth-password input { width: 100%; padding-right: 46px; }
.auth-reveal {
  position: absolute;
  right: 0;
  top: 0;
  width: 44px;
  height: 100%;
  min-height: 44px;
  display: grid;
  place-items: center;
  border: 0;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}
.auth-reveal svg { width: 18px; height: 18px; }
.auth-reveal[aria-pressed="true"] { color: var(--accent); }
.auth-submit { min-height: 48px; font-size: 16px; justify-content: center; }
.auth-footnote { margin: 0; text-align: center; color: var(--muted); font-size: var(--fs-caption); }

/* Topbar identity chip + its menu. */
.user-menu-wrap { position: relative; }
.alerts-wrap { position: relative; display: inline-flex; }
.icon-only { display: inline-flex; align-items: center; justify-content: center; width: 34px; padding: 0; }
.icon-only svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
/* `.alerts-empty` is gone -- its one call site uses .record-empty now, like
   the other 21 empty states. A class with a single user is a divergence
   waiting to be copied. */
.alerts-planned { margin: 0; padding-left: 18px; color: var(--muted); font-size: 12px; display: grid; gap: 4px; }
.user-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 190px; }
.user-chip .user-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-initials {
  display: none;
  font-weight: 700;
  font-size: 12px;
  letter-spacing: 0.04em;
}
.user-menu {
  position: absolute;
  right: 0;
  top: calc(100% + 6px);
  z-index: 40;
  min-width: 208px;
  padding: var(--space-1);
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--dialog-border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow), var(--raise-edge);
}
.user-menu[hidden] { display: none; }
.user-menu-head {
  margin: 0;
  padding: var(--space-2) var(--space-3);
  color: var(--muted);
  font-size: var(--fs-caption);
  border-bottom: 1px solid var(--line);
}
.user-menu-head strong { color: var(--ink); }
.user-menu-item {
  display: block;
  width: 100%;
  min-height: 44px;
  padding: 0 var(--space-3);
  text-align: left;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  cursor: pointer;
}
.user-menu-item:hover { background: var(--surface-2); }
.user-menu-item:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }

/* Default-password nag. Persistent by design while password_changed_at is NULL:
   dismissing hides it for the session only, and it returns on the next load. */
.password-banner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
  padding: 10px var(--space-3);
  background: var(--warning-soft);
  border: 1px solid var(--warning);
  border-radius: var(--radius-md);
  color: var(--warning);
  font-size: var(--fs-caption);
  line-height: 1.45;
}
.password-banner[hidden] { display: none; }
/* Reinstated for the drift banner: an alert that names money which did not
   arrive. Same shape as the password banner, in the danger colour. */
.danger-banner {
  margin-bottom: var(--space-3);
  padding: 10px var(--space-3);
  background: var(--danger-soft, var(--warning-soft));
  border: 1px solid var(--danger);
  border-radius: var(--radius-md);
  color: var(--danger);
  font-size: var(--fs-caption);
  line-height: 1.45;
}
.danger-banner[hidden] { display: none; }
.danger-banner strong { display: block; margin-bottom: 4px; }
.danger-banner span { display: block; padding: 2px 0; }

/* A week the contribution cannot cover. Quiet but unmistakable -- it changes
   what he does that Friday. */
/* Outside the contribution: still a transfer he makes on Friday, just not from
   the weekly contribution. Accent rather than warning -- it is the intended plan. */
.deposit-due.is-outside { color: var(--accent); font-style: normal; }
/* 7.5rem + the row's 8px gap = --ledger-value-x. */
.deposit-row > span:first-child {
  flex: 0 1 auto; min-width: calc(var(--ledger-value-x) - 8px);
}
/* IT WRAPS RATHER THAN TRUNCATES. Unifying every figure on this view to
   14px/600 (operator, 2026-08-14: "All the numbers on this page should be the
   same font and color") widened this caption past its column, and
   `text-overflow: ellipsis` cut a caption of the form "$N + $N surplus" off
   mid-word -- a money caption truncated to nonsense, which is worse than the
   inconsistency the font change was fixing. A second line costs one row of height; a clipped
   figure costs the meaning. Same call as the bill names in the Bills table.
   `line-height` is tightened so a wrapped caption does not shove the row's
   figures apart. */
.deposit-row .deposit-due {
  flex: 1 1 auto; min-width: 0;
  white-space: normal; overflow-wrap: break-word;
  line-height: 1.25;
}

.deposit-short {
  margin: 6px 0 0; padding: 6px 8px;
  border-radius: var(--radius-sm, 4px);
  background: var(--danger-soft, var(--surface-2));
  color: var(--danger); font-size: 12px; font-weight: 600; line-height: 1.4;
}

/* REMOVED 2026-08-12: a second `.deposit-incoming` rule, written for a prose
   sentence on the Cash Flow panel while the schedule popout already used the
   same class for a titled block of rows. Two rules for one class, the later
   one silently winning on every property they shared. The panel now renders
   the popout's markup, so it inherits the popout's styling -- which is the
   point of them being one construct. See the block further down this file. */

/* Drift against the target average: a figure to look up, not an alarm. */
.pace-note {
  margin: 0 0 var(--space-2); font-size: var(--fs-caption);
  color: var(--muted); line-height: 1.45;
}
.pace-note strong { color: var(--ink); font-variant-numeric: tabular-nums; }
.pace-note .pace-detail { white-space: nowrap; }
.password-banner-text { min-width: 0; }
.password-banner-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }

/* Change-password dialog: a short form, so it should not open at the account
   dialog's 980px width. */
#password-dialog { width: min(440px, calc(100vw - 32px)); }
.password-form { display: grid; gap: var(--space-3); padding: var(--space-4); }
.password-form label { display: grid; gap: 3px; color: var(--muted); font-size: var(--fs-eyebrow); font-weight: 600; }
/* The SAME declarations as .auth-field input. These three fields carried only
   size and padding, so border, radius, background and colour fell to the user
   agent -- rendering a grey 2px box on rgb(30,30,30) in dark mode, the one
   surface in the app that did not follow the tokens. */
.password-form input {
  font-size: 16px;
  min-height: 44px;
  padding: 0 var(--space-3);
  color: var(--ink);
  background: var(--input-bg);
  border: 1px solid var(--input-border);
  border-radius: var(--radius-sm);
}
.password-form input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.password-help, .password-hint { margin: 0; color: var(--muted); font-size: var(--fs-caption); line-height: 1.45; }
#password-state { color: var(--muted); font-size: var(--fs-caption); }
#password-state.error { color: var(--danger); }

@media (max-width: 600px) {
  /* Phone topbar is held to ~62px: the chip drops to initials only so the
     brand, theme toggle, Export CSV, and identity all stay on one row. */
  .user-chip { padding: 0 9px; min-height: 36px; max-width: none; }
  .user-chip .user-name { display: none; }
  .user-initials { display: inline; }
  .user-menu { min-width: 200px; }
  .password-banner { align-items: flex-start; flex-direction: column; }
  .password-banner-actions { width: 100%; }
  .password-banner-actions .button { flex: 1 1 auto; justify-content: center; min-height: 44px; }
  /* Full-width like the account dialog, but height follows the content: three
     fields stretched to 100vh would leave two-thirds of the sheet empty.
     The PIN sheet needs the SAME exemption, and for a sharper reason: at full
     viewport height the keyboard could not fit under it, so iOS scrolled the
     whole dialog up and took the title and close button off the top of the
     screen (operator, 2026-08-01: "the screen moves up and the X in the corner
     gets cut off"). Sized to its content, the keyboard has room. */
  #password-dialog, #pin-dialog { width: 100vw; max-width: none; height: auto;
    max-height: 100vh; max-height: 100dvh;
    /* `bottom: auto` is what makes height:auto mean "as tall as the content".
       An open <dialog> gets inset:0 from the UA sheet, so with BOTH top and
       bottom pinned the box stretches to fill regardless of height:auto -- it
       measured 836px for a 349px form. The old height:100dvh hid this by
       giving a definite height. */
    bottom: auto; }
  .auth-card { padding: var(--space-4); box-shadow: none; }
  .auth-page { padding-top: max(var(--space-4), env(safe-area-inset-top)); }
}

/* ============================================================================
   LAST WORD on dialog KPI-strip dividers.
   Six overlapping rules decided these -- a base border, a :last-child reset, a
   2-up dialog rule, and three more inside phone media queries keyed on
   nth-child(2n) and nth-child(3n). The result was a 2x2 strip with a divider
   between the top pair and none between the bottom pair (operator, 2026-07-30:
   "All cells should always have a divider... All styles should be consistent").
   Reasoning about which one won took longer than stating the intent outright.

   Written with the same nth-child specificity as the rules it replaces, and
   placed last, so position decides rather than a specificity accident:
   every cell carries a divider except the one at the end of its row.
   ========================================================================= */
dialog .joint-detail-strip > div:nth-child(n) { border-right: 1px solid var(--line); }
/* REMOVED: this was a copy of the rule inside @media (max-width: 900px),
   but unconditional -- and at >=901px the strip is FOUR columns, so clearing
   the border on every 2nd cell put dividers after cells 1 and 3 only. That is
   the "some cells have a divider and others do not" defect he has reported
   before, sitting on the popout he opens most. The correctly scoped copy at
   the 900px breakpoint still does the job where the grid really is 2 columns. */
dialog .joint-detail-strip.is-three-up > div:nth-child(3n) { border-right: 0; }
dialog .joint-detail-strip > div:nth-child(n):last-child { border-right: 0; }

/* Label/value pairs, not a grid of figures. .projection-table starves its first
   column (width:1%, max-width:0) and makes the second the slack absorber --
   right for a dated table, wrong here: "Description" and "Matched On" are wider
   than the starved column allows and printed straight over their own values.

   Placed at the END, per rule 0e. The first attempt sat at line 1607 -- ahead of
   the .projection-table rule at 1703, same specificity -- so it lost on position
   and changed nothing. Same failure as the sticky dialog header, twice in one
   day: when a rule has to beat an existing one, position is the lever.

   nth-child on the value cell to match the specificity of the rule it displaces
   rather than merely tie it. */
.detail-pairs td:first-child {
  width: auto;
  min-width: 0;
  max-width: none;
  white-space: nowrap;
  vertical-align: top;
  padding-right: 14px;
}
.detail-pairs td:nth-child(2):not(.number) {
  width: 100%;
  max-width: none;
  overflow: visible;
  text-overflow: clip;
  white-space: normal;
}

/* The rate behind a derived figure. Sits under the amount, quiet enough to read
   as provenance rather than as a second number in the column. */
.rate-note {
  display: block;
  font-size: 11px;
  font-style: normal;
  color: var(--muted);
  line-height: 1.2;
  margin-top: 1px;
}

/* A heading and the rows it names are one block. Left loose, the "Deposited By
   Ab" caption floated above the list and read as a page heading rather than as
   the label ON those transfers (operator, 2026-07-30: "I want the label to be
   in the same section as the related transfers, so it looks connected"). Both
   groups are built the same way, so neither looks more attached than the other. */
.deposit-group {
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  /* Tight side padding on purpose: every pixel here comes straight out of the
     detail column, which is the part that gets clipped first. */
  padding: 8px 6px;
}
.deposit-group .deposit-incoming-head { margin-bottom: 6px; }
/* The total reads as a figure, not as part of the label: same size as the row
   amounts it sums, in the ink colour rather than the muted heading grey. */
.deposit-incoming-head > strong {
  font-size: var(--fs-figure);
  letter-spacing: 0;
  text-transform: none;
  color: var(--ink);
}
.deposit-incoming .deposit-incoming-head > strong { color: var(--muted); }

/* Money that arrives on its own, ruled off from the transfers he must make.
   The line is the whole point: above it is a checklist, below it is
   information, and confusing the two costs either a missed transfer or one
   made twice. */
.deposit-incoming {
  margin-top: 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  padding: 8px 6px;
}
/* Both blocks carry the same heading style: naming who makes each set of
   transfers is what keeps them apart, so neither may look more like a heading
   than the other. */
.deposit-incoming-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin: 0 0 4px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--muted);
}
/* Quieter than the rows above: these are not instructions. */
.deposit-incoming .deposit-row > span,
.deposit-incoming .deposit-row > strong { color: var(--muted); }

/* Who funds a section, beside its total. Quieter than the figure it follows --
   this is provenance, not another number to read. */
.section-source {
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
  white-space: nowrap;
}

/* Why a payday's total is not the standing contribution. Sits beside the
   figure, quiet enough to read as a footnote on it rather than a second fact. */
.deposit-why {
  /* Beside the amount it qualifies, tucked against it. The header row is 347px
     at 393px and the title, figure and Schedule link take 214 of it, so this is
     sized and spaced to fit rather than reworded -- the operator asked for these
     exact words. Measured at every step, never guessed. */
  margin-left: -2px;
  font-size: 11px;
  font-weight: 500;
  color: var(--muted);
  white-space: nowrap;
  /* It fits at 393px with nothing to spare -- 347 of 347. A device whose font
     renders a hair wider would otherwise burst the card, so this is the one
     element allowed to give: it shrinks and ellipsises instead. Never triggers
     at the measured width; it is the failure mode, not the layout. */
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The only control in the app that spends money, so it is the only one that
   cannot be triggered by a tap. Holding fills the bar underneath; releasing
   early cancels and the fill snaps back. */
/* The billed call, DESKTOP ONLY. On a phone the same action is the
   pull-and-hold gesture -- a tap is reflexive, a mouse click on a 28px target
   is not. Showing both on a phone would put a one-tap way to spend money beside
   a deliberately two-step one.

   (hover: hover) and (pointer: fine) is a mouse. Note a headless browser
   reports fine-pointer even at a phone viewport, so this cannot be verified by
   resizing -- only on a real touch device or with touch emulation. */
.live-refresh { display: none; }
@media (hover: hover) and (pointer: fine) {
  .live-refresh {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    margin-left: 6px;
    padding: 0;
    border: 1px solid var(--line);
    border-radius: 50%;
    background: var(--group);
    color: var(--muted);
    cursor: pointer;
    vertical-align: middle;
  }
  .live-refresh:hover { color: var(--ink); border-color: var(--muted); }
  .live-refresh:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
  .live-refresh[disabled] { opacity: 0.5; cursor: default; }
  /* Turning while it asks, so a slow bank does not look like a dead button. */
  .live-refresh.is-busy svg { animation: spin 900ms linear infinite; }
}
@keyframes spin { to { transform: rotate(360deg); } }

.live-refresh-note { margin-left: 8px; font-size: 12px; color: var(--muted); }
.live-refresh-note.is-ok { color: var(--success); }
.live-refresh-note.is-over { color: var(--danger); }

/* Something has posted since the balances were last confirmed, so the figures
   may be behind. Warning-coloured, not danger: it is a reason to check, not a
   problem in itself. */
#balances-stamp.is-stale { color: var(--warning-strong, var(--warning)); }

/* The Joint debt table dragged the WHOLE PAGE sideways at every width above the
   phone -- 1460px of document in a 1280px window, with the topbar and tabs
   sliding off. Found by the background sweep, 2026-07-30.

   Its shell is already `overflow-x: auto`, and that was not enough: the overflow
   escaped to the root anyway. Neither `overflow: hidden` nor forcing descendants
   to `position: static` helped. `contain: paint` does -- it promises the browser
   nothing paints outside the box, which lets the shell actually clip its own
   1453px table.

   Why only this table: the Debts shell scrolls VERTICALLY too, and a shell that
   already scrolls in one axis contains its overflow in both. The Joint table is
   short enough not to, so nothing was clipping it. */
.joint-table-shell { contain: paint; }

/* A column of labelled fields. Without this the labels and inputs ran together
   inline -- "Date [ ] Bucket [Joint] Direction" across one line -- because the
   markup relies on the label wrapping its control and nothing was stacking
   them. */
.stack-form {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.stack-form > label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
}
.stack-form > label > input,
.stack-form > label > select,
.stack-form > label > textarea {
  width: 100%;
  min-height: 40px;            /* comfortable to hit on a phone */
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface-2, var(--group));
  color: var(--ink);
  /* 16px, and the threshold is the OTHER WAY ROUND from what this comment used
     to claim. iOS zooms when a focused field is UNDER 16px -- so the previous
     "15 is safe" was naming the exact trigger as the cure, and every control
     built on this class zoomed. See the iOS zoom guard at the end of the file. */
  font-size: 16px;
  font-weight: 500;
}
.stack-form > label > textarea {
  min-height: 72px;
  resize: vertical;
  line-height: 1.4;
}
.stack-form > label > input:focus-visible,
.stack-form > label > select:focus-visible,
.stack-form > label > textarea:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* HIT AREAS FOR THE PRIMARY ACTIONS.
 *
 * Done, Schedule and Days Off are the three things the Cash Flow tab exists to
 * make you do, and they measured 17px tall -- the smallest targets on the
 * screen were the most important ones (audit, 2026-08-07). The same for the
 * settings checkboxes at 18px and the HELOC terms button at 22px, both under
 * the 24px WCAG floor, never mind the 44px Apple one.
 *
 * PADDING OUT, MARGIN BACK IN: the target grows to 44px while the text stays
 * exactly where it was, so nothing on the page moves. Horizontal growth is kept
 * to 8px because Schedule and Done sit side by side and their areas must not
 * overlap. */
.deposit-more,
.section-action > span[role="button"] {
  display: inline-flex; align-items: center;
  min-height: 44px;
  padding: 0 8px;
  /* LONGHAND, and the left stays `auto`. This was `margin: -13px -8px`, a
     shorthand added later to pull a 44px touch box back to the text's line --
     and it silently overwrote the `margin-left: auto` set 2200 lines earlier,
     which is the whole mechanism that pushes these actions to the right. The
     result was Schedule and Done clumped mid-row with 62px of dead space
     beside them (operator, 2026-08-13, with a screenshot). With `auto` on each,
     the free space splits between them and they spread across the row. */
  margin: -13px -8px -13px auto;
}
#heloc-terms-btn, #live-refresh, .info-tip-close, .row-open.row-action {
  min-width: 44px; min-height: 44px;
}
/* ...except the HELOC terms glyph on a phone, where it must stay 22px.
   The phone block above already gives it a 44px hit area as an invisible
   ::after overlay -- small visible control, full-size tap box -- and this ID
   rule was silently defeating that technique: (1,0,0) outranks `.info-button`,
   so the BUTTON grew to 44x44 instead of the overlay. On a phone the glyph is
   `position: relative` and therefore IN FLOW inside its KPI tile, so those
   extra 22px pushed "Credit Line" down and made the strip's second row 72px
   against the first row's 51px (operator, 2026-08-08: "format the second row
   to be the same height as the first row"). Placed here rather than in the
   phone block because the two selectors tie on specificity and source order
   decides. Desktop is untouched: there the button is absolutely positioned in
   the tile corner, so 44px costs no height. */
@media (max-width: 600px) {
  #heloc-terms-btn { min-width: 0; min-height: 0; width: 22px; height: 22px; }
}
/* The checkbox itself cannot grow without looking wrong, so the LABEL carries
   the target and the box stays where it is. */
.floor-alert {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 44px; min-height: 44px;
}

/* Pull to refresh, the way every other app does it: the CONTENT moves down and
   the indicator is revealed in the space above it. The first attempt overlaid a
   floating badge on top of the page, which is what made it look homemade --
   the icon sat across a banner and the label crossed a button.

   Nothing here is fixed-position: the indicator sits ABOVE <main> in the flow,
   pulled off-screen by a negative margin, and the pull translates <main> down
   to reveal it. */
/* --- Pull to refresh -----------------------------------------------------
   Zero-height and out of the flow: the gap the user sees is the CONTENT
   moving, not this element growing. That is the difference between a pull
   that tracks the finger and one that re-lays out the page 60 times a second. */
.pull-refresh {
  position: relative;
  height: 0;
  overflow: visible;
  pointer-events: none;
  z-index: 1;
}
/* iOS/Teams-style spoked spinner, drawn from twelve tapered bars. No SVG and
   no library -- twelve elements and one rotation is the whole thing. */
.pull-spinner {
  position: absolute;
  left: 50%;
  top: 0;
  width: 26px;
  height: 26px;
  margin-left: -13px;
  opacity: var(--pull-opacity, 0);
  transform: translateY(var(--pull-spinner-y, 0px)) scale(var(--pull-scale, 0.7));
  transform-origin: 50% 50%;
}
.pull-spinner > i {
  position: absolute;
  left: 50%;
  top: 0;
  width: 2px;
  height: 7px;
  margin-left: -1px;
  border-radius: 1px;
  background: var(--muted);
  transform-origin: 1px 13px;
  /* Spokes light up in sequence as the pull deepens -- the same progressive
     reveal iOS uses, so the gesture itself is the progress indicator. */
  opacity: 0.15;
}
.pull-refresh.is-armed .pull-spinner > i { opacity: 1; }
/* The note, under the spinner and travelling with it. Absolutely positioned in
   the same zero-height container, so adding it moves nothing on the page --
   the whole point of this pattern is that the CONTENT translates and the
   chrome does not reflow. */
.pull-note {
  position: absolute;
  left: 50%;
  top: 0;
  /* IT TRAVELS WITH THE ICON, and sits above it. The first version was pinned
     at a static `top: 34px` with no transform and no opacity -- so while the
     spinner rode down on --pull-spinner-y the note stayed put, hidden over the
     content until the gap grew to reach it, and at a full pull the icon
     travelled straight through it (measured: note fixed at y86-103, spinner
     52 -> 110, colliding around 80). Operator, 2026-08-14: "The pulldown
     refresh note should be on top of the refresh icon. It's hidden until you
     pull further down."
     Same y as the spinner minus its own height, so the pair moves as one and
     can never overlap. */
  /* 26px above the spinner, up from 20: with the busy gap widened the pair has
     room, and a note crowding its own spinner reads as one smudge rather than
     two things. */
  transform: translate(-50%, calc(var(--pull-spinner-y, 0px) - 26px));
  opacity: var(--pull-opacity, 0);
  white-space: nowrap;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--muted);
  transition: color var(--motion-fast) var(--ease);
}
/* Once it is far enough to fire, the note says so and stops being quiet. */
.pull-refresh.is-armed .pull-note { color: var(--accent-line); }
/* WHILE BUSY the spokes must NOT be uniform.
 *
 * Twelve identical spokes 30 degrees apart, stepped by exactly 30 degrees, land
 * every spoke where the previous one was -- the image after each step is
 * pixel-identical to the one before it. The animation was running correctly and
 * looked completely frozen (operator, twice: "the refresh graphic is still
 * static"). Fixing the keyframes earlier made it VALID; it was never visible.
 *
 * A graduated trail breaks the symmetry, which is why every real spinner has
 * one: the bright spoke is what you see moving.
 */
.pull-refresh.is-busy .pull-spinner > i { opacity: 0.16; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(1)  { opacity: 1;    }
.pull-refresh.is-busy .pull-spinner > i:nth-child(2)  { opacity: 0.86; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(3)  { opacity: 0.72; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(4)  { opacity: 0.60; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(5)  { opacity: 0.50; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(6)  { opacity: 0.42; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(7)  { opacity: 0.35; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(8)  { opacity: 0.29; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(9)  { opacity: 0.25; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(10) { opacity: 0.22; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(11) { opacity: 0.19; }
.pull-refresh.is-busy .pull-spinner > i:nth-child(12) { opacity: 0.16; }
.pull-spinner > i:nth-child(1)  { transform: rotate(0deg);   }
.pull-spinner > i:nth-child(2)  { transform: rotate(30deg);  }
.pull-spinner > i:nth-child(3)  { transform: rotate(60deg);  }
.pull-spinner > i:nth-child(4)  { transform: rotate(90deg);  }
.pull-spinner > i:nth-child(5)  { transform: rotate(120deg); }
.pull-spinner > i:nth-child(6)  { transform: rotate(150deg); }
.pull-spinner > i:nth-child(7)  { transform: rotate(180deg); }
.pull-spinner > i:nth-child(8)  { transform: rotate(210deg); }
.pull-spinner > i:nth-child(9)  { transform: rotate(240deg); }
.pull-spinner > i:nth-child(10) { transform: rotate(270deg); }
.pull-spinner > i:nth-child(11) { transform: rotate(300deg); }
.pull-spinner > i:nth-child(12) { transform: rotate(330deg); }
/* Stepped, not smooth: a spoked spinner that rotates continuously reads as a
   blur. Twelve steps lands each spoke exactly where the last one was. */
.pull-refresh.is-busy .pull-spinner { animation: pull-spin 0.9s steps(12) infinite; }
/* BOTH keyframes, with the SAME function list including rotate.
   With only a `to`, the from-state was the element's base transform --
   `translateY(...) scale(...)`, two functions against the target's three. A
   mismatched list makes the browser interpolate as MATRICES, and the matrix of
   rotate(360deg) is the identity matrix. So it animated from one matrix to an
   identical one: valid, and completely motionless (operator, 2026-08-02: "the
   refresh animation doesn't spin, it just sits there frozen"). */
@keyframes pull-spin {
  from { transform: translateY(var(--pull-spinner-y, 0px))
                    scale(var(--pull-scale, 1)) rotate(0deg); }
  to   { transform: translateY(var(--pull-spinner-y, 0px))
                    scale(var(--pull-scale, 1)) rotate(360deg); }
}

/* The content is what moves. Only eased on RELEASE -- during the drag it must
   track the finger with no transition at all, or it lags behind the thumb. */
main {
  transform: translate3d(0, var(--pull-shift, 0px), 0);
}
body.is-pull-settling main { transition: transform 320ms cubic-bezier(.22,.61,.36,1); }
body.is-pull-settling .pull-spinner { transition: opacity 240ms var(--ease); }
body.is-pulling main { will-change: transform; }

@media (prefers-reduced-motion: reduce) {
  /* No rotation. The spinner still SAYS something is happening -- it fades --
     but nothing spins, and the content snaps back rather than gliding. */
  .pull-refresh.is-busy .pull-spinner {
    animation: pull-pulse 1.2s ease-in-out infinite;
  }
  body.is-pull-settling main { transition-duration: 1ms; }
}
@keyframes pull-pulse { 50% { opacity: 0.35; } }

/* Shown wherever a touch drag can happen. It was hidden on fine-pointer devices
   while a desktop button existed; with the button gone, hiding it would leave no
   route at all. A trackpad overscroll cannot trigger it anyway -- it needs a
   touchmove past 88px AND a held second. */

/* Keep the browser's own overscroll out of the way: without this, Safari
   rubber-bands the page at the same time as the pull and the two fight.
   `contain`, NOT `none`. Briefly set to `none` to kill the boundary bounce on
   a view with nothing to scroll, then put back: he decided the bounce is fine
   and the real fix was confining the PULL to the Accounts view (2026-08-14:
   "You can leave the bounce. Just only enable it on the account page."). Worth
   keeping the distinction in mind -- `contain` stops a scroll CHAINING to the
   parent, `none` also removes the element's own bounce. */
body { overscroll-behavior-y: contain; }

/* Days-off calendar. A month you can tap, because marking a week off is four
   taps here and four typed dates in a form -- and seeing the month is how you
   notice a day landed in the wrong week. */
.cal-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-2); margin-bottom: var(--space-2);
}
.cal-head > strong { font-size: var(--fs-figure); }
.cal-dow, .cal-grid {
  display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px;
}
.cal-dow > span {
  text-align: center; font-size: 11px; font-weight: 700;
  color: var(--muted); padding-bottom: 4px;
}
.cal-day {
  aspect-ratio: 1;
  display: flex; align-items: center; justify-content: center;
  min-height: 40px;                 /* thumb-sized */
  border: 1px solid var(--line); border-radius: var(--radius-md);
  background: var(--group); color: var(--ink);
  font-size: var(--fs-figure); font-weight: 600; cursor: pointer;
}
.cal-day.is-outside { visibility: hidden; }
/* Weekends are not working days, so marking one would mean nothing. */
.cal-day.is-weekend { background: transparent; color: var(--muted); cursor: default; opacity: 0.45; }
.cal-day.is-today { outline: 1px solid var(--accent-line); outline-offset: -1px; }
.cal-day.is-off { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
.cal-key.is-off { background: var(--danger-soft); border: 1px solid var(--danger); }
/* What the marks actually DO -- the point of the screen. */
.cal-effect { margin-top: var(--space-3); font-size: 13px; }
.cal-effect > div {
  display: flex; justify-content: space-between; gap: var(--space-2);
  padding: 6px 0; border-top: 1px solid var(--line);
}
.cal-effect strong { font-variant-numeric: tabular-nums; }

/* NOTHING in this header wraps. It is four short things and a figure; wrapping
   turns it into two ragged lines (operator: "there should be no wrapping"). */
.deposit-head { flex-wrap: nowrap; }
.deposit-head > span,
.deposit-head > strong { white-space: nowrap; }

/* Monday to Friday only. Weekends were rendered inert and greyed, which is two
   columns of nothing on a phone -- he does not work them, so they are not part
   of the question (operator, 2026-07-30). */
.cal-dow, .cal-grid { grid-template-columns: repeat(5, 1fr); }

/* The Days Off control inside the Contribution heading. Styled as a link rather
   than a button so it does not compete with the section toggle it sits on. */
#open-days-off {
  color: var(--link-fg);
  text-decoration: underline;
  cursor: pointer;
  font-weight: 600;
}
#open-days-off:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

/* An action inside a collapsible section, on its own row. It was in the heading
   until 2026-07-30, which put five things in one line and pushed the share chips
   outside the card. */
.section-action {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin: 0 0 var(--space-2);
  font-size: 12px;
}
.section-action-hint { color: var(--muted); }
/* LEADING, not trailing. `.section-action > span[role="button"]` carries
   `margin-left: auto`, which is what pushes Schedule and Done to the right --
   correct for actions that sit opposite a heading, wrong for Days Off, which
   the operator wanted back on the left where the section's own text starts
   (2026-08-14: "Move the days off to the left side"). Overriding the margin
   rather than the flex alignment keeps the 44px touch box and its negative
   offsets exactly as they were. */
.section-action-lead > span[role="button"] { margin-left: 0; }

/* A finished payday. Sits where the transfer rows would be, so the eye lands
   in the same place whether there is something to do or nothing. */
.deposit-done {
  margin: 0;
  padding: 10px 2px 2px;
  /* --muted, not --text-muted: no such token, so the "nothing to transfer"
     note rendered in inherited ink rather than muted (found 2026-08-11). */
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.45;
}

/* Monthly spending bars. Hand-drawn from divs on purpose: the project bans
   third-party frontend runtime, and a chart library for six bars would be a
   dependency bigger than the feature. */
.spend-chart { margin: var(--space-3) 0 0; padding: 0; }
.spend-chart > figcaption {
  font-size: 12px; letter-spacing: 0.01em;
  color: var(--muted); margin-bottom: var(--space-2);
}
.spend-bars {
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  align-items: end; gap: 6px; min-height: 132px;
}
.spend-bar { display: flex; flex-direction: column; align-items: center;
             justify-content: flex-end; gap: 4px; min-width: 0; height: 132px; }
/* The column is the TRACK, always full height, so every month occupies the
   same footprint and the bars are read against a common baseline. */
.spend-bar > .spend-track {
  width: 100%; flex: 1 1 auto; display: flex; align-items: flex-end;
  border-radius: 4px 4px 0 0; overflow: hidden;
}
.spend-bar > .spend-track > span {
  display: block; width: 100%; background: var(--accent);
  border-radius: 4px 4px 0 0; min-height: 2px;
}
/* The current month is only as complete as the month is old, and drawing it
   solid alongside finished months reads as a fall in spending that has not
   happened. That WAS drawn as diagonal hatching, which the operator disliked
   on sight (2026-08-03: "I don't like the diagonal stripes at all") -- and with
   tracking only days old, every bar on the chart qualified as partial, so the
   whole chart was hatched and the distinction carried nothing anyway.
   A quieter fill, and the LABEL says "so far", which is what a stripe was
   trying to say without words. */
.spend-bar.is-partial > .spend-track > span { opacity: .5; }
.spend-bar > .spend-value {
  font-size: 11px; font-variant-numeric: tabular-nums; color: var(--ink);
  white-space: nowrap;
}
.spend-bar > .spend-label {
  font-size: 11px; color: var(--muted); white-space: nowrap;
}
/* Quieter than the month it qualifies -- it is a caveat, not a second label. */
.spend-sofar { opacity: .7; }
.spend-bar.is-empty > .spend-value { color: var(--muted); }
@media (max-width: 420px) {
  .spend-bars { gap: 4px; }
  .spend-bar > .spend-value, .spend-bar > .spend-label { font-size: var(--fs-eyebrow); }
}

/* Accounts private to the signed-in user need NO styling of their own: they are
   injected as real `.track` children of `.budget-tracks`, so every rule that
   dresses an account card already applies. An earlier version wrapped them in a
   container and had to fight `display: contents` for it. */

/* Face ID sign-in: the mark alone. A button whose whole content is an icon
   needs its own sizing -- the shared .button padding is built around a text
   label and leaves the glyph adrift in a wide pill. */
.auth-passkey {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0;
  width: 52px;
  height: 52px;
  padding: 0;
  margin: var(--space-2) auto 0;
  border-radius: 14px;
}
.auth-passkey > .faceid-glyph { display: block; }

/* Transactions on the first level of the account popout. They used to have a
   dialog to themselves, so they need a heading here to separate them from the
   figures above rather than running straight on from them. */
/* TIGHTER, now that the heading is gone. --space-4 separated the list from a
   heading that no longer exists; against the filter row directly it just pushed
   the first transaction down the screen, which is the space the operator asked
   to reclaim. */
.popout-transactions { margin-top: var(--space-2); }
/* `.popout-subhead` is gone. Its one heading -- "Transactions" -- now uses
   .projection-heading, which its SIBLING heading in the same sheet already
   used. Two ways to draw one thing, 23 lines apart. */

/* Waiting on the authenticator. The button holds an SVG, so the busy state
   cannot be a text swap -- it dims and stops responding instead. */
.auth-passkey.is-busy { opacity: 0.55; cursor: default; }

/* --- Desktop: a centred column, not a full-bleed sprawl ------------------
   Operator, 2026-07-31: "the desktop version shouldn't be filling the screen
   since there's not enough data ... maybe center everything".

   Measured before the change: at 1920px the cards ran to 1884px wide while the
   tab row stayed pinned at 640px on the left. The mismatch is what read as
   sprawl -- a wall of near-empty cards with the navigation stranded in one
   corner.

   1120px rather than a literal 50%: half of a 2560px monitor and half of a
   1440px one are very different measurements, and a fixed column keeps the
   figures a readable distance apart on both. Phones and tablets are untouched
   -- max-width simply does not bind below the breakpoint.

   The topbar keeps its full-bleed background and centres only its CONTENTS,
   which is what stops the header looking detached from the page under it. */
@media (min-width: 901px) {
  :root { --content-max: 1120px; }
  main {
    max-width: var(--content-max);
    margin-inline: auto;
  }
  .topbar {
    /* +18px so the brand and the account chip line up with the CARD edges,
       not with main's outer box -- main carries 18px of its own padding, and
       without this the header sat 18px proud of every card beneath it. */
    padding-inline: max(22px, calc((100% - var(--content-max)) / 2 + 18px));
  }
  /* Centred within the column rather than stretched across it. Spreading four
     tabs over 1120px would put a chasm between them; keeping the row at its
     own width and centring it reads as deliberate. */
  .view-tabs { margin-inline: auto; }
}

/* Transaction filters: ONE collapsed row until asked for.
   Expanded, this was a heading, a full-width search box, a "More Filters"
   header and four stacked label-over-input pairs -- most of a phone screen
   before a single transaction (operator, 2026-08-06: "tighten up the filter
   section"). Now it is a summary line, and the panel behind it puts each label
   INLINE with its control, which halves the field rows from four to two. */
.txn-filters { margin: 0 0 var(--space-2); }
/* The <details> chrome that lived here is gone with the panel it opened
   (2026-08-12): the bar is a plain row and the fields moved into a dialog.
   `.txn-filters` survives as the hook `syncTxnFilterBars` writes the count and
   Clear into, which is why the class stayed on the new row. */
/* What is hidden, ON THE SUMMARY -- the panel that used to carry this is now
   closed by default, and a count you have to open the filters to read cannot do
   its job. Not uppercase: it is a figure, not a label. */
.txn-filter-count {
  font-variant-numeric: tabular-nums; text-transform: none;
  letter-spacing: 0; font-weight: 600; color: var(--accent-line);
}
/* Last, and pushed right, so it never sits between the label and the count. */
.txn-filter-clear {
  margin-left: auto; border: 0; background: none; cursor: pointer;
  padding: 4px 2px; min-height: 24px;
  font-size: 12px; font-weight: 600; text-transform: none; letter-spacing: 0;
  color: var(--accent-line);
}
/* The shortcut row that replaced the collapsible filter panel: Search, the
   count, the Other chip, Clear. One line, and it never takes height from the
   list the way an expanded panel did. */
.txn-filter-bar {
  display: flex; align-items: center; gap: 10px;
  padding: 6px 0 8px; border-bottom: 1px solid var(--line);
  margin-bottom: 4px;
}
/* Search reads as the primary way in, so it carries the icon and a border --
   the chip beside it is a filter, not a doorway. */
.txn-filter-search {
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  padding: 4px 12px 4px 10px; min-height: 30px;
  border: 1px solid var(--input-border, var(--line));
  border-radius: var(--radius-pill);
  background: var(--input-bg, var(--surface-2)); color: var(--ink);
  font-size: 13px; font-weight: 600;
}
.txn-filter-search svg { flex: 0 0 auto; opacity: .8; }
.txn-search-actions {
  display: flex; align-items: center; gap: 14px; margin-top: var(--space-3);
}

/* The triage-queue toggle. A CHIP rather than a link: it is a state you turn on
   and leave on while working through the list, unlike Clear which fires once.
   `margin-left: auto` pushes it right; Clear then sits beside it rather than
   taking the whole gap. */
.txn-filter-unknown {
  margin-left: auto; cursor: pointer;
  padding: 3px 10px; min-height: 24px;
  border: 1px solid var(--input-border, var(--line));
  border-radius: var(--radius-pill);
  background: none; color: var(--muted);
  font-size: 12px; font-weight: 600; text-transform: none; letter-spacing: 0;
}
/* FILLED when on, for the same reason a manual tag is filled: the state has to
   read at a glance from outside the panel, which is closed by default. */
.txn-filter-unknown.is-on {
  background: var(--accent); border-color: var(--accent);
  color: var(--on-accent);
}
/* Clear keeps its place at the right, but must not claim the gap the chip now
   uses -- two `margin-left: auto` siblings would split it between them. */
.txn-filter-unknown ~ .txn-filter-clear { margin-left: 8px; }

.txn-filter-grid { display: grid; gap: 6px; padding: 6px 0 2px; }
.txn-filter-grid input, .txn-filter-grid select {
  width: 100%;
  /* PINNED, not min-height. A native date control sizes its own box and came
     out 39px against 38px for the text inputs -- one pixel, but it is the
     same-size-siblings rule and it shows as a stepped row. box-sizing and
     appearance:none stop the widget deciding for itself. */
  height: 38px;
  box-sizing: border-box;
  -webkit-appearance: none;
  appearance: none;
  padding: 7px 10px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface-2, var(--surface));
  color: var(--ink);
  /* No font-size here on purpose: the iOS zoom guard at the end of the file
     sets 16px on every input and select and is deliberately last, so anything
     declared here would either be dead or be re-introducing the zoom. */
}
.txn-filter-grid input::placeholder { color: var(--muted); }
/* appearance:none matched the select's height to the inputs and took its
   chevron with it. Drawn back on the wrapper, not fetched -- no asset, and
   nothing for the CSP to refuse. */
.txn-filter-wide { position: relative; }
/* ONLY on the field that holds a select. `.txn-filter-wide` now also carries
   the Description input -- both are full-width -- and the chevron was being
   drawn on a text box, which reads as a dropdown that will not open. */
.txn-filter-select::after {
  content: "\25be"; position: absolute; right: 10px; top: 50%;
  transform: translateY(-50%);
  color: var(--muted); pointer-events: none; font-size: 11px;
}
.txn-filter-select > select { padding-right: 24px; }
/* The narrow pairs share a row; search and category take the full width. */
.txn-filter-grid {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
.txn-filter-grid > input[type="search"], .txn-filter-wide { grid-column: 1 / -1; }
/* LABEL INLINE WITH THE CONTROL, not above it. Stacked, each of the four cost
   two lines and the pairs came to four rows; inline they come to two, and the
   label still reads -- which a placeholder alone did not, since the date pair
   was two identical empty boxes with no way to tell "from" from "to". */
.txn-filter-field {
  display: flex; align-items: center; gap: 6px; min-width: 0;
}
.txn-filter-field > span {
  /* A shared width, so the four inputs line up in two columns instead of
     starting wherever their label happens to end -- "TO" is half the width of
     "MIN $" and the boxes came out visibly stepped. */
  flex: 0 0 auto; min-width: 2.9rem;
  font-size: 11px; letter-spacing: 0.01em;
  color: var(--muted);
}
.txn-filter-field > input, .txn-filter-field > select { flex: 1 1 auto; min-width: 0; }
@media (min-width: 640px) {
  /* Room for all four side by side once there is room for all four. */
  .txn-filter-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

/* --- Guided tour --------------------------------------------------------
   Spotlight cut from a full-screen scrim by an enormous box-shadow, same
   technique as the World Cup wallchart. Hidden until opened. */
.tour-ov { position: fixed; inset: 0; z-index: 3000; display: none; }
.tour-ov.on { display: block; }
@media (prefers-reduced-motion: reduce) { .tour-hole { transition: none; } }

/* --- App lock -----------------------------------------------------------
   Opaque, not a scrim: the point is that the figures behind it are not
   readable by whoever picked the phone up. */
.app-lock {
  position: fixed; inset: 0; z-index: 4000;
  display: flex; align-items: center; justify-content: center;
  background: var(--page);
}
.app-lock[hidden] { display: none; }
.app-lock-card {
  display: grid; justify-items: center; gap: 10px;
  padding: 28px 24px; width: min(340px, calc(100vw - 32px));
  text-align: center; color: var(--ink);
}
.app-lock-card > h2 { margin: 6px 0 0; font-size: 19px; font-weight: 700; }
.app-lock-card > p { margin: 0 0 8px; font-size: var(--fs-figure); color: var(--muted); }
.app-lock-card > .button { min-width: 160px; }
.app-lock-card > .faceid-glyph { color: var(--accent); }

/* Face ID is running. The OS sheet can be slow to appear and gives no hint that
   anything was asked for, so the glyph pulses while we wait (operator,
   2026-07-31: "I'm not sure when Face ID is being used"). */
.app-lock.is-scanning .faceid-glyph { animation: faceid-pulse 1.1s ease-in-out infinite; }
@keyframes faceid-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50%      { opacity: 0.5; transform: scale(1.08); }
}
@media (prefers-reduced-motion: reduce) {
  .app-lock.is-scanning .faceid-glyph { animation: none; opacity: 0.6; }
}

/* PIN entry on the lock screen. Wide tracking so six digits read as a code
   rather than a number. */
.app-lock-pin { display: grid; gap: 10px; justify-items: center; width: 100%; }
.app-lock-pin > input {
  width: 190px; height: 46px; text-align: center;
  font-size: 22px; letter-spacing: 0.35em; font-variant-numeric: tabular-nums;
  border: 1px solid var(--line); border-radius: 10px;
  background: var(--surface-2, var(--surface)); color: var(--ink);
}
.app-lock-error { margin: 0; font-size: 13px; color: var(--danger); }
#pin-dialog .auth-field > input {
  width: 100%; height: 42px; padding: 8px 10px;
  font-size: 18px; letter-spacing: 0.2em; font-variant-numeric: tabular-nums;
  border: 1px solid var(--line); border-radius: 8px;
  background: var(--surface-2, var(--surface)); color: var(--ink);
}

/* While the lock is working out what this device has set up, show the glyph
   and nothing else. The card is already opaque and already protecting; what it
   must not do is display a guess and then rewrite itself. */
/* Show whichever glyph is CORRECT while the card resolves -- never Face ID by
   default. `has-faceid` is set only once a passkey is confirmed enrolled. */
.app-lock-card > .lock-glyph { color: var(--accent); }
.app-lock-card > .faceid-glyph { display: none; }
.app-lock-card.has-faceid > .faceid-glyph { display: block; }
.app-lock-card.has-faceid > .lock-glyph { display: none; }
.app-lock-card.is-resolving > :not(.faceid-glyph):not(.lock-glyph) { visibility: hidden; }

/* The dialog header stays put while the body scrolls. When a keyboard opens,
   iOS scrolls the focused field into view and takes the whole dialog with it --
   which pushed the title and the close button off the top of the screen. */
.dialog-header {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--surface);
}

/* --- PIN keypad ----------------------------------------------------------
   Drawn in the page rather than asking for the system keyboard. iOS scrolls
   the document wherever it likes when its keyboard opens, and every attempt
   to compensate for that -- shortening the sheet, then pinning it to
   visualViewport -- produced a visible jump instead of curing one. With no
   system keyboard there is nothing to scroll. Used by BOTH the PIN setup
   dialog and the app-lock unlock screen; they must not behave differently. */
.pinpad { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.pinpad-label { color: var(--muted); font-size: var(--fs-figure); }

.pinpad-dots { display: flex; gap: 14px; }
.pinpad-dot {
  width: 13px; height: 13px; border-radius: 50%;
  border: 1.5px solid var(--input-border); background: transparent;
  transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.pinpad-dot.is-on {
  background: var(--accent); border-color: var(--accent); transform: scale(1.12);
}
/* A wrong PIN shakes the dots. Cheaper to read than a sentence, and the
   sentence is still there underneath for anyone who needs it. */
.pinpad-dots.is-wrong { animation: pinpad-shake 420ms ease; }
@keyframes pinpad-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-7px); } 40% { transform: translateX(6px); }
  60% { transform: translateX(-4px); } 80% { transform: translateX(2px); }
}

.pinpad-keys {
  display: grid; grid-template-columns: repeat(3, 74px);
  gap: 14px; justify-content: center;
}
.pinpad-key {
  height: 68px; border-radius: 50%;
  border: 1px solid var(--line); background: var(--surface-2);
  color: var(--ink); font-size: 27px; font-weight: 400;
  font-variant-numeric: tabular-nums;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;
  transition: background-color 90ms ease, transform 90ms ease;
}
.pinpad-key:active { background: var(--accent); color: var(--on-accent); transform: scale(0.94); }
.pinpad-key:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
.pinpad-key.is-blank { border: 0; background: none; pointer-events: none; }
.pinpad-del { font-size: 22px; border-color: transparent; background: none; }
.pinpad-del:active { background: var(--group); color: var(--ink); }

/* The lock screen sits on a dark scrim, so its keypad needs its own contrast
   rather than the page tokens. */
.app-lock .pinpad-key { background: rgba(255, 255, 255, 0.09); border-color: rgba(255, 255, 255, 0.16); color: #fff; }
.app-lock .pinpad-key:active { background: var(--accent); }
.app-lock .pinpad-del { background: none; border-color: transparent; }
/* The empty cell must stay empty. `.app-lock .pinpad-key` and
   `.pinpad-key.is-blank` have IDENTICAL specificity (0,2,0), so the later rule
   won and the spacer rendered as a filled circle that looked like a button
   nobody could press. Scoping it here is what actually settles it -- caught in
   a screenshot, not by measurement. */
.app-lock .pinpad-key.is-blank { background: none; border-color: transparent; }
.app-lock .pinpad-dot { border-color: rgba(255, 255, 255, 0.45); }
.app-lock .pinpad-dot.is-on { background: #fff; border-color: #fff; }

/* A short phone should not need to scroll to reach the keypad. */
@media (max-height: 700px) {
  .pinpad { gap: 12px; }
  .pinpad-keys { grid-template-columns: repeat(3, 66px); gap: 10px; }
  .pinpad-key { height: 58px; font-size: 24px; }
}
@media (prefers-reduced-motion: reduce) {
  .pinpad-dot, .pinpad-key { transition: none; }
  .pinpad-dots.is-wrong { animation: none; }
}

/* Standing explanation on the payday panel -- quiet, but never hidden. */
/* Its own line, directly under the date and amount -- it used to sit beside
   them and wrap into three lines beside the figure. */
.deposit-note {
  margin: 2px 0 0;
  /* CAPTION, not eyebrow. Cash Flow read a size smaller than every other tab
     because its secondary text was set at the eyebrow scale while the same kind
     of text elsewhere sits at caption (operator, 2026-08-03: "the font size on
     the cash flow tab is smaller than the other tabs"). */
  color: var(--muted); font-size: var(--fs-caption); font-weight: 500;
  letter-spacing: .01em;
}

/* --- Rideshare (weekly earnings goal) -------------------------------------
   Counts UP to a floor, where the food budget counts DOWN from a cap. The bar
   fills toward the goal rather than draining toward it, so "full" is good here
   and bad there -- hence its own colour treatment rather than reusing
   .track-bar, which would read as an overspend at exactly the wrong moment. */
.rideshare-card { display: grid; gap: 12px; }
.rideshare-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
/* --fs-kpi, not 26px. This was the largest figure in the app by five pixels
   and off the type scale entirely -- a literal nobody chose against the other
   headline figures (operator, 2026-08-13: "154 is way too big"). --fs-kpi is
   what every other headline figure uses, which is the point of having it. */
.rideshare-head > strong {
  font-size: var(--fs-kpi); font-weight: 700; font-variant-numeric: tabular-nums;
}
.rideshare-bar {
  /* Thin again: nothing is printed inside it now that the remainder reads in
     the legend, so the bar only has to be a bar. */
  height: 10px; border-radius: 999px; background: var(--group); overflow: hidden;
}
/* The gap to the goal is a SEGMENT, not empty track -- it reads as the third
   part of the stack rather than as background (operator, 2026-08-01: "I want
   the remaining amount to be another bar in the stacked bar"). */
.seg-rest { background: var(--rideshare-rest); }
/* One line, both platforms, sitting above the bar it describes. */
.rideshare-legend {
  display: flex; gap: 16px; margin: 0;
  /* Figure size, matching the week rows below it and every other card's
     headline row. At --fs-cell this block read as a footnote to its own card. */
  font-size: var(--fs-figure); color: var(--muted);
}
.rideshare-legend b { color: var(--ink); font-variant-numeric: tabular-nums; }
/* Stacked: the two segments sit side by side inside one track, so their
   combined length is progress toward the single goal. */
.rideshare-bar { display: flex; }
.rideshare-bar > span {
  display: flex; align-items: center; height: 100%; width: 0;
  transition: width 260ms ease;
}
.rideshare-bar > span:first-child { border-radius: 999px 0 0 999px; }
.rideshare-bar > span:last-child  { border-radius: 0 999px 999px 0; }
/* A segment at zero must not claim a rounded cap that makes it look present. */
.rideshare-bar > span[style*="width: 0px"] { border-radius: 0; }
.seg-uber { background: var(--rideshare-uber); }
.seg-lyft { background: var(--rideshare-lyft); }
.rideshare-key {
  display: inline-block; width: 9px; height: 9px; border-radius: 50%;
  margin-right: 6px; vertical-align: baseline;
}
.rideshare-card.is-met .rideshare-bar > span { background: var(--accent); }
.rideshare-status { margin: 0; color: var(--muted); font-size: var(--fs-figure); }
.rideshare-card.is-met .rideshare-status { color: var(--accent-line); font-weight: 600; }

.rideshare-week {
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  padding: 10px 0; border-bottom: 1px solid var(--line);
}
.rideshare-week:last-child { border-bottom: 0; }
.rideshare-week .rideshare-week-when { color: var(--muted); font-size: 13px; }
.rideshare-week .rideshare-week-split { color: var(--muted); font-size: 12px; }
.rideshare-week > strong { font-variant-numeric: tabular-nums; white-space: nowrap; }
.rideshare-week.is-met > strong { color: var(--accent-line); }
@media (prefers-reduced-motion: reduce) { .rideshare-bar > span { transition: none; } }

/* FIVE tabs. "Rideshare" pushed the row 30px past a 393px screen -- the labels
   are sized to their own text (flex: 0 0 auto) with space-between, which is
   right for four short words and wrong for five. Rather than shrink the type
   for everyone, only the narrow case tightens, and the row is allowed to scroll
   as a backstop so a future sixth tab degrades instead of breaking the layout.
   Measured before and after at 393px: 423px wide -> no overflow. */
@media (max-width: 460px) {
  .view-tabs {
    gap: 0;
    overflow-x: auto;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }
  .view-tabs::-webkit-scrollbar { display: none; }
  .view-tab {
    font-size: var(--fs-figure);
    padding-inline: 0;
    white-space: nowrap;
  }
}

/* Deposits behind the rideshare figure. */
/* The deepest level: the individual payouts inside a day. */
.rideshare-deposit {
  margin-left: 14px;
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  padding: 5px 0; border-bottom: 1px solid var(--line);
  line-height: 1.3;
}
.rideshare-deposit:last-child { border-bottom: 0; }
.rideshare-deposit-what { font-weight: 600; }
.rideshare-deposit .rideshare-deposit-when { color: var(--muted); font-weight: 400; }
.rideshare-deposit > strong { font-variant-numeric: tabular-nums; white-space: nowrap; }

/* LEFT, under the bar. */

.rideshare-average { margin: 0 0 10px; color: var(--muted); font-size: 13px; }
.rideshare-month {
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  padding: 10px 0; border-bottom: 1px solid var(--line);
}
.rideshare-month:last-child { border-bottom: 0; }
.rideshare-month .rideshare-month-split { color: var(--muted); font-size: 12px; }
.rideshare-month > strong { font-variant-numeric: tabular-nums; white-space: nowrap; }

.rideshare-month-figures { display: grid; justify-items: end; gap: 1px; }
.rideshare-month-avg { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }

/* Spacing now does the work the border-bottom used to, so give the blocks a
   little more of it -- they were 10px apart WITH a rule between them. */
/* ONE STEP PER LEVEL. Month > week > day > deposit all began at nearly the same
   x, so four levels of nesting read as one flat list and the only cue to depth
   was the size of the type (operator, 2026-08-15: "Can we indent more on each
   drill down. Hard to read when with no indents. We have the room too").
   These nest, so each margin compounds with the ones above it -- 14px a level,
   which is wide enough to see at a glance and still leaves the figures on the
   right their full column. */
.rideshare-week-block { margin-bottom: 16px; margin-left: 14px; }
.rideshare-week-block:last-child { margin-bottom: 0; }
.rideshare-week-head {
  font-size: var(--fs-figure);
  /* GRID, not flex, and explicitly two rows: the date and total on the left,
     the Uber/Lyft breakdown pushed to the right, the bar spanning underneath.
     As a single flex line all four children competed for the same row, so the
     date broke after "Aug" and the two figures dropped under their own labels
     (operator, 2026-08-03: "I don't want you wrapping so push the Uber and
     Lyft breakdowns to the right"). Nothing here may wrap -- see below. */
  /* The DATE is the flexible one. At 390px everything fits with nothing to
     spare, so on a narrower phone something has to give -- and it must be the
     title, which can ellipsis, never the figures, which would wrap and read as
     two numbers. minmax(0, auto) is what lets it shrink. */
  /* Tight column gaps. Insetting this block to clear the screen edge took
     24px off the row, and the date was the piece that gave -- "Jul 27 - Aug 2"
     ellipsised to "Jul 27 - Au...". The gaps had the slack; the date did not. */
  /* FIXED COLUMNS, so every week reads identically (operator, 2026-08-03:
     "each week should be formatted the same"). Each summary is its own grid --
     details/summary cannot share one -- so `auto` columns sized to each row's
     OWN content and the totals landed at different x down the list. Pinning
     the date and total tracks makes separate grids agree. */
  /* minmax(0, 1fr) on the last track: a plain `1fr` takes its MINIMUM from the
     content, so the legend's min-width pushed the whole row 17px past the card
     instead of being constrained by it. */
  /* 128px. The longest date measures 122px at THIS row's font plus 3px of
     chevron padding -- an earlier probe said 110 because it wiped the font
     before measuring and read the body size instead. 134 hoarded space the
     legend needed (the two platform groups ended up touching); 120 clipped the
     dates. Measure with the element's own font, or the number is fiction. */
  /* 130px = the longest date (115px at this row's 600/15px font) plus the
     chevron pseudo-element and its margin, plus a little slack. Both earlier
     numbers were wrong for the same reason in different directions: the probe
     read the body font (getComputedStyle().font is "" in WebKit) and it never
     included the ::before at all. */
  /* The DATE track may now SHRINK below 130px, which is what the block comment
     above always said should happen: "it must be the title, which can ellipsis,
     never the figures". It was pinned, so when the legend needed more the only
     thing that could give was the platform NAME -- and "Lyft" ellipsised to
     "L..." on the one row with a four-figure Lyft total. A clipped date still
     reads as a date; a clipped label loses the word. */
  /* 112px for the date, down from 130. That reserve was measured against
     "Aug 10 - Aug 16"; the label is "Aug 10-16" now, which needs about 90. The
     18px it was still holding is where the 14px lost to the new drill-down
     indent comes from -- the legend beside it has no slack of its own and was
     clipping the second platform's figure mid-number. Measured, not trimmed by
     eye. */
  display: grid; grid-template-columns: minmax(0, 112px) 46px minmax(0, 1fr);
  align-items: baseline; column-gap: 4px; row-gap: 0;
  padding: 6px 0 5px; margin-bottom: 0;
  /* No rule under each week (operator, 2026-08-03: "remove the separator
     lines"). The progress bar already ends each block, so the border drew a
     second horizontal line directly beneath the first and read as a stray
     duplicate of it. Spacing between blocks does the separating. */
  font-weight: 700; font-variant-numeric: tabular-nums;
  cursor: pointer; list-style: none;
}
/* Safari draws its own disclosure triangle unless this is removed. */
.rideshare-week-head::-webkit-details-marker { display: none; }
.rideshare-week-title::before {
  /* 4px, not 8. This marker lives INSIDE the title's clipping box, so its
     margin comes straight out of the space the date has -- and it is the piece
     every width probe missed, because a probe measuring textContent does not
     see a pseudo-element. */
  content: "\203A"; display: inline-block; margin-right: 4px;
  color: var(--muted); transition: transform 140ms ease;
}
.rideshare-week-block[open] .rideshare-week-title::before { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) {
  .rideshare-week-title::before { transition: none; }
}

/* EYEBROW SIZE, and this is the documented exception to the one-scale rule:
   the row carries a date, a total and two platform figures, and at caption size
   the pair genuinely does not fit a 390px screen -- the second item was pushed
   past the card edge. Everything else on this row stays on the scale. */
.rideshare-legend-sm { font-size: var(--fs-eyebrow); margin: 3px 0 4px; }
.rideshare-bar-sm { height: 6px; margin-bottom: 6px; }
/* Its segments rise with the same curve as the budget bars. Shorter, because a
   6px strip has less distance to cover and the same duration reads as lag. */
.rideshare-bar-sm > span { transition: width 520ms cubic-bezier(0.22, 0.61, 0.36, 1); }

/* The week header's own three pieces. Each is sized to its content and none of
   them may break: a wrapped figure reads as two numbers. */
.rideshare-week-head > .rideshare-week-title {
  /* MUTED, so the date reads as the label it is and the total beside it stands
     out. Both were bright bold and ran together as one string (operator,
     2026-08-03: "can we change the color of either the date or the amount to be
     different?"). Same convention as every other card here -- label quiet,
     figure loud. */
  color: var(--muted); font-weight: 600;
  white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis;
  /* ROOM FOR THE ROTATED CHEVRON. `overflow: hidden` is here so a long date
     ellipsises, but it also clips the ::before marker -- and once open that
     marker is rotated 90deg, so its glyph extends past the inline box it was
     measured in and loses a sliver on the left (operator, 2026-08-03: "still
     slightly cut off", visible only zoomed in). Clipping happens at the PADDING
     edge, so a few pixels of padding gives the rotation somewhere to go while
     the ellipsis still works. */
  padding-left: 3px;
}
.rideshare-day-head > .rideshare-day-title { padding-left: 3px; }
/* Right-aligned in its fixed track, so the figures form a column. */
.rideshare-week-head > strong { white-space: nowrap; text-align: right; }
/* Equal-width platform groups, or "Lyft $N" and "Lyft $NNN" push the dots out
   of line between rows. */
.rideshare-week-head > .rideshare-legend-sm {
  /* Uber pinned left, Lyft pinned right, both at their natural width. Forcing
     EQUAL halves squeezed the Uber item below its content and clipped its dot
     to a sliver (operator, 2026-08-03: "the legend circle is being cut off").
     Space-between gives the same alignment down the list -- every Uber dot at
     the box's left edge, every Lyft figure at its right -- without asking
     either item to be narrower than the words inside it. */
  justify-content: space-between;
}
/* TWO EQUAL TRACKS. Content-sized groups put the second dot at a different x on
   every row -- "Lyft $N" is narrower than "Lyft $NNN" -- so the dots stepped in
   and out down the list. Equal halves fix both dots and both figures in place. */
.rideshare-week-head > .rideshare-legend-sm {
  /* 78px for the Uber group (its widest form measures 78), the rest for Lyft,
     and a real gap between them -- equal 1fr halves left the Uber figure butted
     against the Lyft dot with nothing separating them. Fixed tracks are what
     keeps both dots at the same x on every row; the gap is what stops the two
     groups reading as one string. */
  /* 78px and a 10px gap. Once each group closed up, 6px left the Uber figure
     butted against the Lyft dot and the pair read as one run of numbers
     (operator, 2026-08-10: "there is separation between the Uber and Lyft
     earnings") -- but the first attempt at fixing it took 88px and an 18px
     gap, which stole 22px from the Lyft track and clipped its NAME to "L..".
     The legend has 165px total and no slack: the date track beside it is
     pinned at 130px with its own measurement history, so the separation has to
     come out of the Uber track, not out of the row. */
  display: grid; grid-template-columns: 78px 1fr; column-gap: 10px;
  /* 10, not 12: at 12 the widest Lyft figure left its NAME 2px short and it
     ellipsised to "Lyft". Two pixels, on one row, measured -- which is the
     only way that shows up. */
}
/* Under the bar, at the end it reaches -- the same place the budget tracks put
   their spend figure, so the two read as one idea across the app. Quiet: it is
   good news, not something to act on. */
.rideshare-week-head > .rideshare-legend-sm > span {
  /* DOT, NAME, FIGURE -- packed together, with the slack trailing. This was
     `auto minmax(0,1fr) auto`, which pushed the figure to the far end of its
     track and left a hole between the word and its own number: "Lyft" at one
     end of the row and "$NN" at the other, reading as two unrelated things
     (operator, 2026-08-10: "the numbers closer to their labels").
     The group's START is still fixed by the parent's track, so the coloured
     dots stay in a column down the list -- that alignment was hard-won and is
     not what was wrong. Only the space INSIDE the group moves. */
  display: flex; align-items: baseline; gap: 4px; min-width: 0;
}
/* The NAME is the only part allowed to give. A clipped dot loses the colour
   coding and a clipped figure loses the number; a shortened word does not. */
.rideshare-leg-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ...except in the WEEK rows, where it must not give at all. Packed beside its
   figure it is only ~23px, and letting it shrink is what produced "L..." while
   the date beside it sat on a pinned 130px it was not using. Refusing to shrink
   is what makes the date give instead -- the grid can only take space from a
   track that will yield it. */
.rideshare-week-head .rideshare-leg-name {
  flex: 0 0 auto; overflow: visible; text-overflow: clip;
}
/* The dot belongs to the word beside it. Its 6px margin plus the flex gap put
   NINE pixels between them while only four separated the dot from the total to
   its left -- so it read as attached to the wrong thing (operator, 2026-08-03:
   "the legend dots should be closer to the labels, the dot is too close to the
   total amount as well"). Margin off; the flex gap alone spaces it. */
.rideshare-week-head .rideshare-key { margin-right: 0; }
/* The dot never shrinks. It is 7px of meaning; the text can give instead. */
.rideshare-key { flex: 0 0 auto; }
.rideshare-week-head > .rideshare-legend-sm {
  /* STRETCH, not end-aligned. Sized to its own content the box started at a
     different x on every row -- "Lyft $N" is narrower than "Lyft $NNN" -- so
     the coloured dots stepped in and out down the list. Filling the track makes
     the two halves identical in every row. */
  /* Breathing room from the total, which sits immediately left of this track
     and was only a 4px grid gap away.
     NO `gap` here: this rule sits AFTER the grid rule above, so a shorthand
     `gap: 0` silently reset the column-gap that separates the two platform
     groups -- they rendered touching while the CSS above said 6px. The gap
     belongs with the grid that uses it. */
  justify-self: stretch; margin: 0; padding-left: 6px;
  white-space: nowrap;
}
.rideshare-week-head > .rideshare-legend-sm span { white-space: nowrap; }
/* Full width on its own row, under all three. */
.rideshare-week-head > .rideshare-bar { grid-column: 1 / -1; margin-top: 5px; }

/* A day inside a week: collapsible, date first. */
.rideshare-day { border-bottom: 1px solid var(--line); margin-left: 14px; }
.rideshare-day:last-child { border-bottom: 0; }
.rideshare-day-head {
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  padding: 6px 0; cursor: pointer; list-style: none;
  font-variant-numeric: tabular-nums; line-height: 1.3;
}
.rideshare-day-head::-webkit-details-marker { display: none; }
.rideshare-day-title::before {
  content: "\203A"; display: inline-block; margin-right: 8px;
  color: var(--muted); transition: transform 140ms ease;
}
.rideshare-day[open] .rideshare-day-title::before { transform: rotate(90deg); }
.rideshare-day > .rideshare-deposit { padding-left: 18px; border-bottom: 0; }
@media (prefers-reduced-motion: reduce) {
  .rideshare-day-title::before { transition: none; }
}

/* The detail block, inset to match the card above it. .cash-card pads its
   content by --space-3; without the same inset here the week and day rows --
   and the chevron that opens them -- started at the very edge of the screen. */
.rideshare-detail { padding-inline: var(--space-3); }

.rideshare-goal-edit {
  display: flex; align-items: center; gap: 8px; margin: 6px 0 0;
}
.rideshare-goal-edit label { flex: 0 0 auto; color: var(--muted); font-size: var(--fs-eyebrow); }
.rideshare-goal-edit input {
  flex: 1 1 auto; min-width: 0; text-align: right;
  font: inherit; font-size: var(--fs-cell); font-variant-numeric: tabular-nums;
  padding: 5px 8px; border-radius: var(--radius-sm);
  border: 1px solid var(--line); background: var(--surface); color: var(--ink);
}

.rideshare-report { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
.rideshare-report-head {
  cursor: pointer; list-style: none; font-weight: 600; color: var(--link-fg);
  padding: 4px 0;
}
.rideshare-report-head::-webkit-details-marker { display: none; }
/* TWO ROWS, NOT FOUR. Platform and Day share the first row, Amount and the
   actions share the second -- three stacked full-width controls plus an actions
   row filled most of a phone screen to capture three values, two of which are
   usually already right (operator, 2026-08-16: "Way too big and clunky").
   The 44px touch floor on each control is untouched; only the stacking changed. */
.rideshare-report-form {
  display: grid; gap: 8px 10px;
  grid-template-columns: 1fr 1fr;
  align-items: end;
}
/* The actions sit beside Amount rather than under everything. */
.rideshare-report-form > .form-actions {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
/* One quiet line above the fields, replacing the old paragraph. */
.rideshare-report-note {
  grid-column: 1 / -1;
  margin: 0 0 2px; color: var(--muted);
  font-size: var(--fs-eyebrow); line-height: 1.35;
}
/* INLINE, beside the platform. As a block under the amount it made every
   reported row a line taller than a confirmed one, so the list had two
   different row heights and the spacing read as random (operator, 2026-08-02:
   "make the spacing between lines smaller and consistent"). */
.rideshare-reported {
  font-style: normal; font-size: var(--fs-eyebrow); font-weight: 600; letter-spacing: .02em;
  color: var(--muted); margin-left: 6px;
  /* NO border and line-height:1 -- a 1px border made the reported rows 29px
     against everything else's 28px, which is exactly the ragged rhythm this
     was meant to fix. A tint carries the same meaning without occupying space. */
  background: var(--group); border-radius: var(--radius-pill);
  padding: 1px 5px; line-height: 1;
}
/* Renamed from .rideshare-forget when the row stopped offering a delete. Same
   quiet treatment -- it is an affordance ON the entry, not a second action
   competing with the figure beside it -- but at eyebrow size, because it is a
   WORD now rather than a single glyph and would otherwise outweigh the amount.
   line-height:1 stays: at the default it made reported rows 29px against
   everything else's 28px, which is the ragged rhythm this was fixing. */
/* The weekday leads, quieter than the date it qualifies, on a fixed reserve so
   every day in the list starts its date at the same x -- "Fri" and "Wed" are
   not the same width in this face, and without the reserve the dates stagger. */
.rideshare-day-dow {
  /* 3.9ch, measured, not estimated: at 3.2 the reserve held every weekday but
     "Wed", whose date started three pixels right of the other five -- exactly
     the stagger the reserve exists to prevent. Widest label plus a hair. */
  display: inline-block; min-width: 3.9ch; margin-right: 6px;
  color: var(--muted); font-style: normal; font-weight: 600;
}

.rideshare-edit {
  border: 0; background: none; color: var(--accent-line); cursor: pointer;
  font-size: var(--fs-eyebrow); font-weight: 600;
  padding: 0 4px; line-height: 1;
}

/* A redistribution is not an alarm. Same block, quieter treatment: the money
   moved, it just went somewhere else. */
#schedule-drift.is-soft {
  border-color: var(--line);
  background: var(--surface-2);
  color: var(--ink);
}
#schedule-drift.is-soft strong { color: var(--ink); }

/* Dismiss control on the transfer note. Floated so it never displaces the text
   it sits beside. */
#schedule-drift { position: relative; padding-right: 34px; }
.drift-dismiss {
  position: absolute; top: 6px; right: 6px;
  border: 0; background: none; color: inherit; opacity: .55;
  font-size: 20px; line-height: 1; cursor: pointer;
  padding: 4px 8px;              /* keeps the tap target near 44px */
}
.drift-dismiss:hover, .drift-dismiss:focus-visible { opacity: 1; }

/* Filters COLLAPSED by default. They are read once in a while and were taking
   the top of every transactions list (operator, 2026-08-01). */
/* The "Filters" wrapper is GONE.
   It was added to make the filter bar closed by default, but the bar already
   had its own "More Filters" disclosure -- so there were two collapsible
   controls for one thing, and the outer one read as a heading above the
   transactions (operator, 2026-08-02: "just remove the top right filter, we
   don't need it"). The search box is the filter bar; More Filters is the
   disclosure. One level, as it was before. */

/* BREATHING ROOM in every popout that lists transactions. A row printed against
   the screen edge reads as clipped (operator, 2026-08-01: "for all pop outs
   that have transactions they can't be too close to the edge"). */
/* THESE TWO GET THE INSET. They were in this list and getting the OPPOSITE of
   it until 2026-08-08: a comment was inserted between the selectors below, and
   the `> .tab-panel` line -- which genuinely needs ZERO -- stayed joined to the
   same comma list, so all three resolved to `padding-inline: 0`. The personal
   register and the transaction detail sheet were drawn flush against both
   screen edges for as long as that rule existed, which is exactly what this
   block was written to prevent (operator, 2026-08-08: "personal should have
   the same formatting as any other account").
   A comment does not end a selector list. A brace does. */
#personal-txn-dialog .dialog-content,
#txn-search-dialog .dialog-content,
#txn-rule-dialog .dialog-content,
#tag-rules-dialog .dialog-content,
#txn-detail-dialog .dialog-content { padding-inline: var(--space-4); }
/* Its .tab-panel already carries the inset; adding another here put this one
   dialog's rows at 32 while every sibling sat at 16. */
#budget-history-dialog .dialog-content > .tab-panel { padding-inline: 0; }
#budget-history-dialog .dialog-content { padding-inline: var(--space-4); }
/* And a little breathing room under the header, since this dialog's first row
   is a value rather than a heading. */
#txn-detail-dialog .dialog-content { padding-block: 4px 16px; }
/* ROOM UNDER THE LAST CONTROL. The rule above sets the inline inset and nothing
   set the block end, so a sheet whose final row is a button printed it hard
   against the sheet's bottom edge (operator, 2026-08-14: "The done and clear
   buttons are too close to the bottom margin"). Deeper than the 16px sides on
   purpose: the bottom edge of a sheet is the one a thumb arrives at, and the
   sides have a screen bezel beyond them where this has nothing. */
#personal-txn-dialog .dialog-content,
#txn-search-dialog .dialog-content,
#txn-rule-dialog .dialog-content,
#tag-rules-dialog .dialog-content { padding-block-end: var(--space-5); }

/* The Platform select was shorter than the Day and Amount inputs beside it --
   a browser default, not a decision (operator, 2026-08-02). */
/* A CHEVRON, so the heading reads as something that opens and shuts. Without
   it the only way back was tapping the same words again, which nobody guesses
   (operator, 2026-08-16: "there's no way to close it when you're done"). */
.rideshare-report-head::after {
  content: "\203A"; display: inline-block; margin-left: 6px;
  transform: rotate(90deg); transition: transform 160ms var(--ease);
  color: var(--muted);
}
.rideshare-report[open] .rideshare-report-head::after { transform: rotate(-90deg); }
@media (prefers-reduced-motion: reduce) {
  .rideshare-report-head::after { transition: none; }
}

.rideshare-report-form select,
.rideshare-report-form input {
  width: 100%; box-sizing: border-box;
  min-height: 44px; padding: 10px 12px;
}
.rideshare-report-form select {
  /* Room on the right for the chevron the appearance reset draws back in --
     without it the arrow sat under the text. */
  padding-right: 32px;
  font-size: 16px;                    /* 16px stops iOS zooming the page */
  border: 1px solid var(--input-border); border-radius: var(--radius-md);
  background: var(--input-bg); color: var(--ink);
}

/* ONE TRANSACTION, in full. Label left, value right, one fact per line -- the
   same shape as every other detail list in the app. */
.txn-detail { margin: 0; display: grid; gap: 0; }
.txn-detail-row {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; padding: 9px 0;
}
.txn-detail-row + .txn-detail-row { border-top: 1px solid var(--line); }
.txn-detail-row > dt {
  flex: 0 0 auto; font-size: var(--fs-eyebrow); font-weight: 600;
  letter-spacing: 0.01em; color: var(--muted);
}
.txn-detail-row > dd {
  margin: 0; flex: 1 1 auto; min-width: 0; text-align: right;
  font-size: var(--fs-figure); font-variant-numeric: tabular-nums;
  /* 600, matching .joint-detail-list dd -- the app's other detail sheet.
     This declared no weight at all, so the transaction amount rendered at
     400 while every other money figure in the app is 600 or 700. */
  font-weight: 600;
}
/* THE RAW DESCRIPTION. It is the reason this dialog exists, so it gets its own
   line and WRAPS -- truncating it here would leave the app with no surface that
   shows it at all. Left-aligned and monospaced-by-numerals, because it is a
   bank string being read character by character, not a value being compared. */
.txn-detail-row.is-full { display: block; padding-top: 12px; }
.txn-detail-row.is-full > dd {
  text-align: left; margin-top: 4px; white-space: pre-wrap;
  overflow-wrap: anywhere; font-size: 13px; color: var(--muted);
}
/* The two decisions you can make about a charge, in the sheet rather than
   behind buttons that open a second dialog. Label above control, because the
   value is the point and it has to be readable without opening anything. */
.txn-detail-controls {
  margin: var(--space-3) 0 0; display: grid; gap: 12px;
}
.txn-detail-controls > label { display: grid; gap: 4px; min-width: 0; }
.txn-detail-controls > label[hidden] { display: none; }
.txn-detail-controls > label > span {
  font-size: var(--fs-eyebrow); font-weight: 600; letter-spacing: 0.01em;
  color: var(--muted);
}
/* The text box gets the SAME treatment as the pickers beside it -- only
   `select` was styled, so an <input> in this sheet came out as an unstyled
   native field a third of the height of its neighbours. */
.txn-detail-controls select,
.txn-detail-controls input[type="text"] {
  width: 100%; box-sizing: border-box; min-height: 44px;
  padding: 10px 12px;
  border: 1px solid var(--input-border, var(--line));
  border-radius: var(--radius-md);
  background: var(--input-bg, var(--surface-2)); color: var(--ink);
}
/* `.txn-detail-controls > label` sets `display: grid` at (0,2,0) and outranks
   `.txn-detail-check` at (0,1,0), so the checkbox row needs the matching
   specificity or it stacks the box above its own words. Same class of trap as
   the .deposit-head / .section-name note in the template. */
.txn-detail-controls > label.txn-detail-check {
  display: flex; align-items: center; gap: 10px; min-height: 44px;
}
.txn-detail-controls > label.txn-detail-check > span {
  font-size: var(--fs-cell); font-weight: 600; color: var(--ink);
  letter-spacing: 0;
}

/* ONE tag on the row. The full strip of choices moved into a picker. */
.txn-tag {
  /* 5px of side padding, down from 7 (and 9 before that). Each trim buys 4px
     across the widest labels -- "Entertainment", "Kids Clothing" -- and each
     was spent on the same thing: letting those rows fit the container instead
     of overflowing it. On 2026-08-12 the amount column was widened to a fixed
     reserve so it starts at one x down the list, and the Entertainment row
     came out exactly 4px over, with its description already above its floor
     and nothing left to give. This is that 4px.
     The touch target is held by min-height, not by this. */
  /* 11px STANDS, and this is a deliberate exception to the 16px input floor.
     A design audit flagged it as the same iOS zoom bug the settings figures
     had. It is not, and the difference is what the floor is actually for: iOS
     zooms to make TEXT YOU ARE ABOUT TO EDIT legible. A <select> opens a
     native wheel and edits nothing in place, so there is no zoom -- which is
     why these chips have been tapped daily since 2026-08-12 without a single
     report, while the number fields drew a complaint the day they shipped.
     Raising this to 16px would widen every chip in a row whose track budget is
     already fought over four pixels at a time, and would re-break the money
     columns immediately above.
     IF A ZOOM IS EVER SEEN HERE, the fix is not a bigger font: it is to stop
     being a <select> (see the note where the [data-keypad] exemption was). */
  /* 10px, DOWN from 11, and 4px of side padding down from 5 (2026-08-15).
     The long note above defends 11px against being RAISED to the 16px input
     floor, and that reasoning is untouched -- a <select> opens a native wheel
     and never zooms. This is the other direction, and it was bought
     deliberately: "Auto Svc" needed 69px at 11px against a 64px ceiling, and
     the alternative was widening the column, which takes the pixels straight
     back off the description. At 10px it measures 62. The touch target is
     unaffected -- it is held by min-height, not by the type.
     Every other label got smaller with it, so the measured fit now shrinks the
     track further on most lists and the description gains again. */
  flex: 0 0 auto; font-size: 10px; font-weight: 700; padding: 5px 4px;
  min-height: 26px; border: 1px solid var(--line); border-radius: var(--radius-pill);
  background: none; color: var(--muted); cursor: pointer; white-space: nowrap;
  /* IT IS A <select> NOW (2026-08-12), so the browser's own chrome has to go:
     a native control brings an arrow, a background and a min-width, and the
     first two undo the chip while the third undoes the column reserve those
     four pixels above were won for. No arrow on purpose -- the chip already
     read as tappable, and what changed is what happens after the tap, not
     whether to take it. */
  appearance: none; -webkit-appearance: none;
  text-align: center; text-align-last: center;
  width: auto; max-width: 100%;
  /* Safari gives a select a baseline of its own; without this the chip sits a
     pixel or two off the amount beside it. */
  vertical-align: middle;
}
/* iOS renders a disabled-looking grey for select text unless it is forced. */
/* `flex: 0 0 auto` on .txn-tag below keeps the pill from shrinking inside its
   own cell, which is what made the cell's fit-content the full label width.
   Allowed to shrink, the label ellipsises and the pill keeps its border. */
.txn-tag { max-width: 100%; min-width: 0; flex-shrink: 1;
           text-overflow: ellipsis; overflow: hidden; }
.txn-tag option { color: var(--ink); background: var(--surface); }
/* A tag HE set reads as settled; an automatic one stays quiet, so the two are
   distinguishable at a glance rather than only in the tooltip. */
.txn-tag.is-manual { border-color: var(--accent); color: var(--accent-line); }
.txn-tag.is-untagged { border-style: dashed; }
.txn-tag:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

.tag-picker-options { display: grid; gap: 8px; }
.tag-picker-option {
  display: flex; align-items: center; justify-content: space-between;
  width: 100%; min-height: 48px; padding: 12px 14px; cursor: pointer;
  border: 1px solid var(--line); border-radius: var(--radius-md);
  background: var(--surface-2); color: var(--ink);
  font-size: var(--fs-figure); font-weight: 600; text-align: left;
}
.tag-picker-option.is-current { border-color: var(--accent); color: var(--accent-line); }
.tag-picker-option.is-current::after { content: "\2713"; font-weight: 700; }

/* ---------------------------------------------------------------------------
   iOS ZOOM GUARD -- every text control is at least 16px.

   Mobile Safari zooms the page whenever a focused field's font-size is under
   16px, and does not zoom back out. The operator hit it opening the rideshare
   goal editor and noted it was not the first time: "when I click to edit
   everything zooms. This has happened before. Zoom shouldn't happen. It should
   just stay exactly where it is."

   A census at 390px found 35 of 70 controls under the threshold -- filter
   boxes, the HELOC editor, the balance floors -- so this is a house rule
   rather than a fix for one field. Deliberately LAST in the file and specific
   enough to beat a component's own size: any rule that sets a smaller size is
   re-introducing the bug, whatever it looks like on a desktop.

   The other way out -- maximum-scale=1 on the viewport -- is worse: it disables
   pinch-zoom for everyone, and modern iOS ignores it anyway.
   --------------------------------------------------------------------------- */
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
select,
textarea { font-size: 16px; }

/* THE EXEMPTION IS GONE, and the premise under it was wrong.
 *
 * It read: a `data-keypad` field is `readonly` with `inputmode="none"`, no
 * system keyboard ever opens, so there is no zoom to prevent and the figures
 * can sit below the 16px floor. Every part of that is true except the
 * conclusion. **iOS zooms on FOCUS of an input whose font-size is under 16px,
 * whether or not a keyboard appears.** The zoom is the browser deciding the
 * text is too small to edit -- the keyboard is a separate thing.
 *
 * The cost was the bug it caused twice: tapping a figure zoomed the page and
 * pushed the left edge of the sheet off screen, so the labels were cut in half
 * (operator, 2026-08-13, with a screenshot: "when I click on one of the
 * numbers it zooms in and cuts off the left side. We run into this multiple
 * times").
 *
 * These fields now take the 16px floor like every other input. If a figure
 * ever genuinely needs to be smaller than 16px, it must stop being an <input>
 * -- a readonly display with a tap target has nothing to focus and nothing to
 * zoom -- rather than being an input that claims an exemption.
 */

/* The week row carries four things -- date, total, two platform figures -- and
   below about 360px they genuinely do not fit one line: the fixed date and
   total tracks left the legend 88px for content needing 156, and it pushed the
   PAGE sideways rather than clipping. Reflow instead: date and total on one
   line, the legend under them, still identical row to row. */
@media (max-width: 360px) {
  .rideshare-week-head { grid-template-columns: minmax(0, 1fr) auto; }
  .rideshare-week-head > .rideshare-legend-sm {
    grid-column: 1 / -1; justify-content: flex-start; gap: 14px;
    margin-top: 2px;
  }
}

/* ---------------------------------------------------------------------------
   CATEGORY RING (budget popout). Where the period's money went, as shares.
   The ring is drawn from stroke-dasharray on concentric circles -- no arc
   maths and no chart library, which the CSP and the no-third-party-runtime
   rule both require. */
/* FLOATS OVER THE TRANSACTIONS, and stays put while they scroll under it
   (operator, 2026-08-12: "I just don't like the breaking continuity when we go
   to pop out and then I have to go back to transactions and go back-and-forth").
   The point is to watch the list narrow as each category is tapped, which a
   modal cannot do -- it covers the answer.

   STICKY, not fixed, and not a <dialog>. This lives inside the budget popout's
   own scroll container: a non-modal dialog opened from a modal one is drawn
   BENEATH it, because only modal dialogs enter the top layer, so `show()`
   would produce a panel that never appears. Sticky needs no top layer at all.

   The card is opaque and shadowed on purpose -- rows sliding under a
   transparent panel read as a rendering fault rather than as depth. */
/* SLIDES DOWN, rather than appearing. The panel used to flip `hidden`, so a
   card the height of six rows arrived in one frame directly over the
   transaction list and the eye had nothing to follow (operator, 2026-08-15:
   "When I open up the category breakdown, I want that to slide down with a
   smooth animation").
   grid-template-rows 0fr -> 1fr is the one honest way to animate to a height
   the content decides: `height: auto` is not animatable, and a fixed max-height
   is a guess that either clips a seventh category or leaves dead space after
   three. The inner wrapper needs `min-height: 0` and `overflow: hidden` or the
   content refuses to be compressed by the collapsed track.
   Same curve and duration as the sheet it lives in, so opening a panel inside a
   sheet does not feel like a different app. */
.category-pie.is-opening,
.category-pie:not([hidden]) {
  display: grid;
  /* HEIGHT ONLY, AND THE SAME BOTH WAYS. A 160ms opacity fade ran alongside the
     520ms collapse, so the card was invisible for two thirds of its own exit:
     opening read as a slide, closing read as a blink, at identical durations
     (operator, 2026-08-15: "Is the slide back up speed supposed to be faster
     than the slide down?" -- no, and it was not). The height IS the motion.
     Same lesson as the sheet's crossfade: opacity between 0 and 1 does not
     describe movement, it just stops you seeing it. */
  /* ITS OWN CURVE, not the sheet's. cubic-bezier(0.32, 0.72, 0, 1) approaches
     the end very gradually -- on a full-screen sheet that long settle reads as
     weight, but on a panel collapsing a few hundred pixels the last third is
     movement too small to see, and it read as the animation hesitating before
     it finished (operator, 2026-08-15: "The closing on the category pie chart
     is too slow now. It hesitates at the very end").
     0.4/0/0.2/1 accelerates away and arrives decisively -- no crawl at either
     end. Duration matched to the travel rather than to the sheet's: this panel
     moves a fraction of the distance, so the same milliseconds would make it
     appear slower even though it is not. */
  /* HEIGHT IN PIXELS, NOT `1fr` -> `0fr`.
     THE REASON IS THE BOX, NOT THE UNIT. Padding, margin and border sit
     OUTSIDE the row track, so animating grid-template-rows alone left roughly
     50px of card still standing at the end of a "finished" collapse, which then
     vanished the moment [hidden] landed. That one-frame step is a real defect
     independent of any measurement (operator, 2026-08-15: "The slide back up
     animation on the category seems a little choppy. it kind of snaps").
     Animating the height lets every contributor travel together.
     HONEST PROVENANCE: I first wrote here that WebKit does not interpolate fr
     units, "measured" from a trace of 190 -> 75 -> 4 -> 0. That claim was
     FALSE. A control transition -- plain `height 360ms linear` on a throwaway
     div -- produced the same 4-frame trace, because headless WebKit runs rAF at
     about 90ms a frame. The instrument could not see animation at all, so it
     could not have shown the fr version failing. The fr approach may well have
     been fine; this one is chosen for the box, and is not evidence against it.
     The height must be measured in JS (see slideOpen/slideShut in app.js) and
     handed back to `auto` at the end, or this reintroduces the hard-coded
     height the fr approach existed to avoid. */
  transition: height 360ms cubic-bezier(0.4, 0, 0.2, 1),
              padding-block 360ms cubic-bezier(0.4, 0, 0.2, 1),
              margin-block 360ms cubic-bezier(0.4, 0, 0.2, 1),
              border-block-width 360ms cubic-bezier(0.4, 0, 0.2, 1);
  overflow: hidden;
  /* CONTAIN THE COST. This panel floats above the transaction list, and its
     height is animated -- so without containment every frame invalidated layout
     for the whole list underneath, which is what dropped frames (measured: the
     collapse advanced 2,2,2,4,6,5,7,9,13px per sampled frame instead of an even
     ramp). `contain: paint` promotes it and stops its own repaint escaping;
     `will-change: height` warns the compositor what is about to move. Both are
     hints, not behaviour -- nothing about the layout changes. */
  contain: paint;
  will-change: height;
}
.category-pie.is-collapsed {
  /* Height itself is set in JS (it must be a measured pixel value); these are
     the parts that are the same on every panel. */
  padding-block: 0; margin-block: 0; border-block-width: 0;
}
.category-pie > .category-pie-inner { min-height: 0; overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
  .kpi-budget .bar i, .track .bar i,
  .rideshare-bar-sm > span, .bar-col-track > i { transition: none; }
  .category-pie.is-opening, .category-pie:not([hidden]) { transition: none; }
  .category-pie.is-collapsed { padding-block: 0; margin-block: 0; }
}
.category-pie {
  position: sticky; top: 0; z-index: 4;
  margin: var(--space-3) 0 0; padding: var(--space-3);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius-md);
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
}
/* NO DOUBLE INSET AT THE TOP. .projection-head carries a --space-3 top margin
   for when it separates sections in a flowing panel; inside this card the
   card's own padding already provides that, so the two stacked and the heading
   sat 24px down in a panel that floats over the transaction list -- every pixel
   of it hiding a row (operator, 2026-08-15: "Make it a little shorter, there's
   a lot of empty space at the top"). Scoped to the FIRST head so a second
   section inside the card would still get its separation. */
.category-pie > .projection-head:first-child { margin-top: 0; }
/* With the heading screen-reader-only the head row holds just the close button,
   so it no longer needs to reserve a text line -- the legend starts at the top
   of the card instead. `justify-content: flex-end` keeps X in its corner now
   that nothing balances it on the left. */
/* THE CLOSE BUTTON COSTS NO ROW. Its 44px touch target was 44 of the 57px
   between the card's top edge and the first category -- a whole row spent on a
   control, in a panel that floats over the transaction list (operator,
   2026-08-15: "Still too much room at the top of the section. The x shouldn't
   be taken up a whole [row]").
   Lifted out of flow so the legend starts immediately under the padding. The
   target stays 44px -- it is only the LAYOUT that stops reserving space for it,
   which is the whole point: a touch target should not have to be a row. */
/* The head row and its close button are gone (see index.html); nothing needs
   lifting out of flow or padding around any more. */

.category-pie-body {
  display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
}
.category-pie-body > svg {
  /* 84px, down from 108. Floating over the list, every pixel of this panel is
     a transaction row it hides (operator, 2026-08-12: "make the pie chart
     categories tighter doesn't take as much space"). The ring encodes SHARE,
     which is angle -- and angle survives shrinking far better than the legend
     text beside it would. */
  width: 84px; height: 84px; flex: 0 0 auto;
  /* The ring starts at twelve o'clock; SVG angles start at three. */
  transform: rotate(-90deg);
}
/* Quiet, and below the chart it governs. It is a refinement of what is already
   on screen, not a choice to make before reading it. */
.pie-subcats {
  display: flex; align-items: center; gap: 8px;
  margin: 10px 2px 2px; color: var(--muted);
  font-size: var(--fs-eyebrow); font-weight: 600; cursor: pointer;
}
.pie-subcats input { width: 16px; height: 16px; accent-color: var(--accent); }

.category-pie-legend {
  flex: 1 1 150px; min-width: 0; margin: 0; padding: 0; list-style: none;
  display: grid; gap: 0;
}
.category-pie-legend > li { display: block; }
/* THE ROW IS THE CONTROL. The grid moved off the <li> and onto the button
   inside it when the legend became a way into the transaction list -- a
   button that only wraps the text leaves the percentage and the amount
   untappable, which on a row this dense is most of its width. */
.pie-row {
  /* FIXED RESERVES IN `ch`, NOT `auto` (§5f-bis, and this legend broke it the
     week the rule was written). With `auto auto` the percentage and the amount
     were sized by their own row, so a one-digit percentage and a two-digit one
     started at different x, and a two-figure amount and a four-figure one
     ended at different x -- a column that does not line up is read one row at
     a time instead of scanned.
     4ch holds a full percentage; 9ch holds a four-figure amount with cents,
     the same reserve .txn-amount uses in the transaction list, because it is
     the same construct.
     `ch` and not px: headless WebKit renders wider than SF Pro, so a reserve
     measured in pixels on this machine clips on the phone. */
  /* 7ch for the amount, not 9. The 9 was copied from .txn-amount "because it
     is the same construct", and that was one similarity too far: a transaction
     can be four figures with cents, while a category TOTAL here is a
     four-figure sum at most -- six characters with the sign. The extra two were dead reserve taken straight
     out of the name, which was ellipsising "Entertainment" and "Kids Clothing"
     at 68px (measured, operator 2026-08-15). 6px gutters rather than 8 give
     back another six. Names now clear ~93px, which holds the longest label in
     the bucket list.
     A truncated category name loses the only thing identifying the row; a
     narrower amount column loses nothing, because the figures do not fill it. */
  display: grid; grid-template-columns: 10px minmax(0, 1fr) 4ch 7ch;
  align-items: baseline; gap: 6px;
  /* 26px. Trimmed 32 -> 30 -> 26 across one evening, the last time because the
     rows still read as a loose list rather than a compact key (operator,
     2026-08-12, with a screenshot: "shrink vertical space between each of the
     categories"). At six rows every pixel here is six pixels of transactions
     hidden behind a panel that floats over them.
     26px is BELOW the 44px primary-touch floor and that is a deliberate trade,
     not an oversight: this is a dense legend of secondary controls sitting
     beside its own chart, the kind of row a native list app draws at 28-32.
     Do not take it lower -- the rows stop being separable by thumb. */
  width: 100%; min-height: 26px; padding: 1px 6px;
  margin: 0; border: 0; border-radius: 8px;
  background: none; color: inherit; text-align: left;
  font: inherit; font-size: 12px; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
/* A READ-ONLY key. No pointer, no press feedback, no focus ring -- it is a
   label beside a chart, not a control (see the `interactive` flag). */
.pie-row.is-static { cursor: default; }
.pie-row:hover { background: var(--surface-2); }
/* THE SELECTED SLICE, and everything else stepped back from it. Dimming the
   others is what makes the ring read as "showing one category" rather than as
   a chart that has changed colour for no reason. */
.pie-row[aria-pressed="true"] { background: var(--surface-2); }
.category-pie-legend.has-selection .pie-row[aria-pressed="false"] { opacity: 0.45; }
.pie-key { width: 10px; height: 10px; border-radius: 3px; align-self: center; }
.pie-name { color: var(--ink); min-width: 0;
            overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* RIGHT-ALIGNED, both of them. A number column reads down its last digit --
   left-aligning a percentage puts the units of 7% under the tens of 31%. */
.pie-share { color: var(--muted); font-variant-numeric: tabular-nums;
             text-align: right; }
.pie-amount { font-variant-numeric: tabular-nums; text-align: right; }



/* ---- Settings, in levels ---------------------------------------------------
   The menu that replaced the all-in-one panel. Each row is a label and the
   value it already holds, so most visits are answered without a tap. */
/* Quiet, and last. Reading it is a diagnostic act, not part of using the app. */
.settings-build {
  margin: var(--space-3) 0 0; text-align: center;
  color: var(--muted); font-size: var(--fs-eyebrow);
  font-variant-numeric: tabular-nums; letter-spacing: .04em;
}

.settings-menu { padding-bottom: 6px; }
.settings-row {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  width: 100%; min-height: 44px; padding: 8px 0;
  margin: 0; border: 0; border-bottom: 1px solid var(--line);
  background: none; color: inherit; text-align: left;
  font: inherit; cursor: pointer;
}
.settings-row:last-child { border-bottom: 0; }
.settings-row:hover { background: var(--surface-2); }
.settings-row-name { font-size: 13px; font-weight: 600; color: var(--ink); }
/* The value is the ANSWER to the row, so it is quiet and right-aligned -- the
   same shape as every other label/figure pair in the app. The chevron is drawn
   here rather than in markup so a row is one element to read. */
.settings-row-value {
  font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums;
}
.settings-row-value::after { content: " \203A"; color: var(--muted); }

/* A budget goal: the figure, the window it applies to, and the way to commit
   it. Three columns because a goal is not editable without saying WHICH period
   it changes -- the same two choices the old inline form offered. */
.goal-row {
  /* Two columns since the Save went: the figure, then the window it applies
     to. Same shape as a .floor-row -- figures left, qualifier right. */
  display: grid; grid-template-columns: 7ch minmax(0, 1fr);
  align-items: center; gap: 6px; margin-top: 2px;
}
/* THE GOAL FIELD STAYS AT THE 16px FLOOR, unlike the minimums beside it in
   Settings. It is a normal field with a system keyboard, and anything under
   16px makes iOS zoom the page on focus. The minimums escape that only because
   they are readonly with inputmode="none" and open the in-page keypad instead.
   A two-pixel difference between two settings screens is a smaller price than
   a page that jumps every time he edits a goal. */
.goal-input { text-align: right; }

/* The month arrows on the days-off calendar. Found by the control-proportion
   check, not by eye: 32x34 is under the 40px touch floor -- the smallest
   targets in the app -- while still drawing a full bordered button around a
   single chevron. Bigger to hit, quieter to look at, which is the same trade
   the close buttons just made. */
#cal-prev, #cal-next {
  min-width: 44px; min-height: 44px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border-color: transparent; background: none; color: var(--muted);
  font-size: 20px; line-height: 1;
}
#cal-prev:hover, #cal-next:hover,
#cal-prev:focus-visible, #cal-next:focus-visible {
  background: var(--btn-secondary-bg); color: var(--ink);
  border-color: var(--btn-secondary-border);
}

/* ---- SECOND-LEVEL POPOUTS ---------------------------------------------------
   Nothing here any more, and that is the point. A second-level popout is the
   SAME card as a first-level one -- see the `dialog` rule in the phone block
   above -- because "replace the first modal box" only reads as a replacement
   if both levels are the same box. The class survives as a HOOK for the
   rendered check, which asserts a child is not drawn edge to edge. */

/* THE SETTINGS HEADER. `.settings-head` shipped in the markup with no rule at
   all on 2026-08-12, so the back control and the title block stacked instead of
   sitting on one line and the close button floated against a two-line left
   side. A class added to markup and never styled is invisible in review and
   obvious on a phone. */
/* BACK SITS ABOVE, not beside (operator, 2026-08-13: "I don't like how the
   back button pushes the title to the right. Maybe put it above it"). Beside
   the title it was indenting every level's heading by its own width, so
   "Account Minimums" started at a different x from "Settings" one tap earlier
   -- the title moving is a worse cost than the line the back control takes. */
.settings-head {
  display: flex; flex-direction: column; align-items: flex-start;
  gap: 2px; min-width: 0;
}
.settings-head > div { min-width: 0; }
/* The close button rides the TOP of a now two-part left side rather than
   centring against it, so it stays level with the first thing in the header. */
#settings-dialog .dialog-header { align-items: flex-start; }
/* Back reads as a control, at the size of the eyebrow above the title rather
   than of the title itself -- it is the way out, not the subject. */
.settings-back {
  display: inline-flex; align-items: center; gap: 2px;
  /* 44px of hit area, pulled tight visually with negative margins so the row
     it now occupies costs the header ~26px rather than a full 44. Same trade
     as the close buttons: the thumb gets the area, the eye gets the glyph. */
  min-height: 44px; padding: 0 8px 0 0; margin: -9px 0 -9px 0;
  border: 0; background: none; cursor: pointer;
  color: var(--accent-line); font: inherit; font-size: 13px; font-weight: 650;
}
.settings-back > span { font-size: 19px; line-height: 1; }
.settings-back:hover, .settings-back:focus-visible { color: var(--ink); }
.settings-back[hidden] { display: none; }

/* ---- Account minimums, as a list ------------------------------------------
   Five summary lines instead of fifteen input boxes (operator, 2026-08-13:
   "it looks a lot more modern than those big pill boxes we have"). A row is
   the same .settings-row the menu uses -- same construct, same tokens -- with
   a second line under the name for the two figures that only warn. */
.floor-summary { align-items: center; }
/* ABSORBS THE SLACK. With three children and `space-between` the middle child
   floats on the width of the first, so the category column landed at fourteen
   different x down a 21-row list. Letting the name block grow instead pins the
   value and the actions to the right. */
.floor-summary-main { display: grid; gap: 1px; min-width: 0; text-align: left;
  flex: 1 1 auto; }
.floor-summary-detail {
  font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums;
}
/* The MINIMUM is the row's value, because it is the one that moves money --
   the other two ride the sub-line. */
/* --fs-figure, not a literal: this is the same headline figure the KPI cells
   and account tracks draw, and it was rendering one pixel smaller than all of
   them. The token exists precisely so nobody guesses. */
.floor-summary > .settings-row-value { font-size: var(--fs-figure);
  font-weight: 700; color: var(--ink); }

/* One account's thresholds: label and explanation on the left, the figure on
   the right. Each field says what it is, which is what the column headings
   used to do from three rows away. */
.floor-field {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  /* No side padding of its own -- the level supplies the inset, so every row
     in the sheet starts on the same x as the title above it. */
  padding: 12px 0; border-bottom: 1px solid var(--line);
}
.floor-field:last-child { border-bottom: 0; }
.floor-field-text { display: grid; gap: 1px; min-width: 0; }
.floor-field-name { font-size: 13px; font-weight: 600; color: var(--ink); }
.floor-field-help { font-size: 11px; color: var(--muted); line-height: 1.35; }
.floor-field .floor-input { width: 7ch; flex: 0 0 auto; }
.floor-field-toggle { cursor: pointer; }
.floor-field-toggle input { width: 20px; height: 20px; accent-color: var(--accent); }

/* ONE LEFT EDGE for the whole sheet. The detail rows had 2px of their own and
   the header 16px, so a field label started 14px left of the title above it
   (operator, 2026-08-13: "the detail page left margin needs some breathing
   room"). The inset lives on the CONTAINER now, once, rather than on each kind
   of row -- which is why they disagreed in the first place. */
#settings-dialog .settings-level { padding-inline: 16px; }
/* The MENU carries no inset of its own: its rows are full-bleed so the divider
   and hover fill reach the card edges, and each row supplies the 16px itself.
   Trying to get there with `width:100%` plus a negative margin bled the left
   side only (100% resolves against the PADDED parent), leaving the name at 17px
   and the value at 49px. */
#settings-dialog .settings-menu { padding-inline: 0; }
/* The hover fill on a menu row should still reach the card edges, so it reads
   as a row rather than a floating strip. */
/* `width: auto`, NOT the inherited `width: 100%`. The negative margin was
   shifting the box left without widening it -- `100%` resolves against the
   PADDED parent, so the row bled 16px on the left and stopped 33px short on the
   right, putting the name at 17px and the value at 49px. A negative margin only
   bleeds the side the box is anchored to. Measured 17/49; now 17/17, matching
   the .floor-field rows one level deeper in the same sheet. */
#settings-dialog .settings-row { padding-inline: 16px; }

/* A select in a settings row, sized and weighted like the figure fields beside
   it so a row with a dropdown and a row with a number read as the same thing.
   16px for the same reason every input here is: under it, iOS zooms on focus. */
.floor-select {
  flex: 0 0 auto; min-width: 0; max-width: 46%;
  padding: 6px 8px; border-radius: var(--radius-sm);
  border: 1px solid var(--line); background: var(--surface); color: var(--ink);
  font: inherit; font-size: 16px; font-weight: 600;
}

/* A settings row that is NOT a drill-through: the rules list carries its own
   Edit / Delete instead of a chevron, so it must not look tappable as a whole. */
.settings-row.is-static { cursor: default; }
/* A RESERVE for the category, so the column lines up down the list (5f-bis).
   Without it the value sits wherever the pattern before it ends, and 21 rules
   put the category at 14 different x. `ch` and not px, per the same rule. */
.settings-row.is-static .settings-row-value {
  min-width: 12ch; text-align: right;
}
.settings-row.is-static:hover { background: none; }
.settings-row.is-static .settings-row-value::after { content: ""; }
.txn-rule-actions { display: flex; align-items: baseline; gap: 12px;
  flex: 0 0 auto; }
/* The rule editor's fields, in the settings dialog's own row rhythm. */
.floor-fields { display: block; }
.floor-input-wide { width: 14ch; }

/* The toast's optional second chance. Reads as a control against the toast's
   own surface without becoming the loudest thing on screen -- it is an offer,
   not an instruction. */
#toast { display: inline-flex; align-items: center; gap: 12px; }
.toast-action {
  flex: 0 0 auto; min-height: 32px; padding: 0 4px; margin: -6px 0;
  border: 0; background: none; cursor: pointer;
  color: var(--accent-line); font: inherit; font-weight: 700;
}
.toast-action:hover, .toast-action:focus-visible { color: var(--ink); }

/* ============================================================================
   MOTION
   ============================================================================
   Zenith had motion tokens (--motion-fast/base/slow, --ease) and used them for
   almost nothing but hover colour: sheets appeared instantly, levels swapped
   with a hard cut, and nothing acknowledged a tap. That is the single thing
   that most makes a web app read as a web page rather than an app (operator,
   2026-08-13: "what options do we have to modernize our app with motions and
   animations").

   FOUR RULES, and they are the whole system:

   1. MOTION EXPLAINS A RELATIONSHIP, it does not decorate. A sheet rises from
      the bottom because that is where it went when it closed. A level slides
      left because it is deeper than the one before it. If a movement does not
      answer "where did this come from", it should not move.
   2. FAST. 120-200ms for anything the finger asked for; 320ms is reserved for
      something arriving on its own. A transition long enough to notice is a
      transition long enough to wait for, and this app is opened forty times a
      day.
   3. TRANSFORM AND OPACITY ONLY. They are composited; width, height, top and
      margin are not, and animating them on a list of sixty transactions drops
      frames on a phone.
   4. IT ALL TURNS OFF. Every rule here is repeated under
      prefers-reduced-motion, which is a system setting people enable because
      motion makes them ill -- not a preference to be clever about.
*/

/* --- The sheet ------------------------------------------------------------
   A KEYFRAME, not a transition with @starting-style.

   The transition version is the tidier code and it failed in the worst
   possible way: `@starting-style` plus `transition-behavior: allow-discrete`
   are not evenly supported, and where they are not, the dialog held its
   STARTING opacity of 0 for ~400ms and then snapped in. Measured here: 0 at
   100ms, 0 at 300ms, 1 at 800ms. A sheet that is invisible for a third of a
   second is far worse than a sheet that simply appears.

   An animation on `[open]` cannot fail that way: if it runs, it eases in; if
   the engine ignores it, the element is at its natural state, which is
   visible. Entry only -- there is no exit animation, because closing instantly
   is honest and the exit is the half that needs the discrete-transition
   support this deliberately avoids. */
dialog[open] { animation: sheet-in var(--motion-base) var(--ease-spring); }
@keyframes sheet-in {
  /* 18px and 0.96, up from 8px and 0.985. The restrained version was
     imperceptible on a phone -- the point of a sheet rising is that you SEE it
     arrive from somewhere. */
  /* 40px and 0.92 -- a THIRD pass at this. 8px was invisible, 18px was still
     "too subtle" (operator, twice). Nudging a value that has already been
     rejected twice is how you spend three builds not fixing something; this is
     a step change instead. A sheet should look like it came from somewhere. */
  from { opacity: 0; transform: translateY(40px) scale(0.92); }
}
dialog[open]::backdrop { animation: backdrop-in var(--motion-base) var(--ease); }
@keyframes backdrop-in { from { opacity: 0; } }

/* --- A level swap ---------------------------------------------------------
   The settings sheet redraws in place, so without motion a level change is a
   hard cut with no direction. Deeper slides in from the right; back does not
   animate, because returning should feel instant rather than narrated. */
.settings-level:not([hidden]), #settings-menu:not([hidden]) {
  animation: level-in var(--motion-base) var(--ease-spring) both;
}
@keyframes level-in {
  from { opacity: 0; transform: translateX(48px); }
  to   { opacity: 1; transform: none; }
}

/* --- A tap has weight -----------------------------------------------------
   The row the finger is on gives, slightly, and springs back. This is the
   cheapest possible "the app noticed" and it costs one composited property. */
/* 0.97 and a tint, up from 0.985 alone. A 1.5% squeeze with no colour change
   is below the threshold of noticing on a moving finger. */
.settings-row:active:not(.is-static),
.floor-summary:active:not(.is-static),
.pie-row:active,
.txn-row[data-open-txn]:active {
  transform: scale(0.955); background: var(--surface-2);
}
.settings-row, .pie-row, .txn-row[data-open-txn] {
  transition: transform var(--motion-fast) var(--ease),
              background var(--motion-fast) var(--ease);
}

/* --- A figure that changed ------------------------------------------------
   Money that moves under you should say so. Added by JS on the element whose
   value differs from the one it replaced; it removes itself. */
.value-changed { animation: value-flash var(--motion-slow) var(--ease); }
/* A wash as well as a colour: the figure itself changing colour is easy to
   miss if you were looking at a different part of the screen, which is exactly
   when you need telling. */
@keyframes value-flash {
  0%   { color: var(--accent-line); background: var(--surface-2); }
  100% { color: inherit; background: transparent; }
}

/* --- The toast ------------------------------------------------------------ */
#toast { transition: opacity var(--motion-base) var(--ease),
                     transform var(--motion-base) var(--ease); }
#toast:not(.visible) { opacity: 0; transform: translateY(6px); }

@media (prefers-reduced-motion: reduce) {
  dialog[open], dialog[open]::backdrop,
  .dashboard-view:not([hidden]) { animation: none; }
  #toast { transition: none; }
  #toast:not(.visible) { transform: none; }
  .settings-level:not([hidden]), #settings-menu:not([hidden]),
  .value-changed { animation: none; }
  .settings-row:active, .floor-summary:active, .pie-row:active,
  .txn-row[data-open-txn]:active { transform: none; }
  .settings-row, .pie-row, .txn-row[data-open-txn] { transition: none; }
}

/* ---- Rideshare: actions on top, weeks grouped by month ---------------------
   The two things you might come to this tab to DO sat under a week list that
   grows a row every week, so they moved further off screen over time. They are
   a pair in one row now, like the budget popout's. */
/* CENTRE, not baseline. The link carries a 44px touch box and the disclosure
   is text-height, so aligning their baselines left the two sitting 13px apart
   vertically -- measured, not eyeballed. */
.rideshare-actions { align-items: center; margin-bottom: var(--space-3); }
/* BOTH CONTROLS THE SAME BOX. Centring cannot align a 20px link against a 55px
   disclosure -- they have to be the same height first. 44px each, which is the
   touch floor anyway, so the row costs nothing extra. */
.rideshare-actions > .link-button,
.rideshare-actions > .rideshare-report > summary {
  min-height: 44px; display: inline-flex; align-items: center; padding: 0;
}
/* THE SEPARATOR AND ITS PADDING BELONGED TO THE OLD POSITION. `.rideshare-report`
   carried `border-top` + `padding-top: 10px` from when it was the last block on
   the tab and needed dividing from the list above it. In the action row that
   border drew a rule floating over one half of the row, and the padding pushed
   its label 10px below the link beside it -- which is why the two still did not
   line up after their boxes were matched. Both cleared here rather than deleted
   from the base rule, because the block still needs them if it is ever moved
   back. */
.rideshare-actions > .rideshare-report {
  align-self: center; border-top: 0; margin-top: 0; padding-top: 0;
}
/* Closed, the disclosure is a peer of the link beside it. Open, it takes a full
   line so the form is not squeezed into half a row. */
.rideshare-actions > .rideshare-report { flex: 0 0 auto; }
.rideshare-actions > .rideshare-report[open] { flex: 1 1 100%; margin-top: 8px; }
.rideshare-report-head {
  cursor: pointer; list-style: none;
  color: var(--accent-line); font-size: 13px; font-weight: 650;
}
.rideshare-report-head::-webkit-details-marker { display: none; }

/* The month rollup, borrowing .txn-month's arrow and rhythm so it reads as the
   same control it is elsewhere in the app. A DISTINCT class from
   `.rideshare-month`, which is already the Monthly History row -- same words,
   different thing. */
/* NO SHRINK. This set the MONTH -- the parent -- smaller than the weeks nested
   inside it, so the hierarchy read backwards: the child looked like the heading
   (operator, 2026-08-15: "The top level should have the same format as the sub
   levels like Aug 2026 in bigger font"). A group heading is never smaller than
   what it groups. */
.rideshare-month-group > summary { font-size: var(--fs-figure); }
.rideshare-month-group > summary > strong { font-variant-numeric: tabular-nums; }


/* The goal and the running average, one line: what a week is SUPPOSED to look
   like, and what one has actually looked like. Wraps on a narrow screen rather
   than squeezing either. */
.rideshare-goal-line {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 10px; flex-wrap: wrap;
}
.rideshare-goal-line > p { margin: 0; }
/* THE EDIT SITS WITH THE FIGURE. `space-between` on three children would have
   parked this in the middle of the row, equidistant from both and attached to
   neither; the auto margin collapses the gap on its left so it reads as an
   affordance ON the goal, and pushes the note to the far edge as before. */
.rideshare-goal-line > .link-button.is-inline {
  margin-right: auto; font-size: var(--fs-eyebrow); font-weight: 600;
  padding: 0; align-self: baseline;
}
.rideshare-goal-line > #rideshare-dialog-note { text-align: right; }

/* The bill name is the way in to correcting it. Styled as the text it already
   was -- a row of twelve underlined names would read as a link farm, and the
   affordance is the row, not the word. */
.bill-edit-open {
  border: 0; background: none; padding: 0; margin: 0; cursor: pointer;
  font: inherit; color: inherit; text-align: left;
}
.bill-edit-open:hover, .bill-edit-open:focus-visible { color: var(--accent-line); }

/* ── HIDE BALANCES ────────────────────────────────────────────────────────
   So the app can be SHOWN to someone without showing them the money
   (operator, 2026-08-14: "add a hide balances button at the top of my app so
   I can share the design with other people").

   THE FIGURES ARE REPLACED, NOT BLURRED. Blur was the first attempt and he
   rejected it -- "I don't like the blur effect. Can we try x's or whatever the
   standard is" -- and the standard, in every banking app that offers this, is
   bullets. app.js swaps each currency token for a fixed-width "$••••" and keeps
   the original on the element, so unmasking is exact rather than waiting for a
   re-render.

   Fixed width matters: masking digit-for-digit would leave "$•,•••" beside
   "$••" and give away the order of magnitude, which is most of what a balance
   tells you.

   `user-select: none` so the mask cannot be swiped and read from the
   clipboard -- the real value is still on the element, in `data-real`. This is
   a control for showing a screen to someone, NOT a security boundary: anyone
   with the session can still read the API. It is labelled "Screen Privacy" for
   exactly that reason. */
.masked-figure {
  user-select: none; -webkit-user-select: none;
  letter-spacing: .06em;
  /* Slightly quieter than a real figure, so a screenshot of masked mode does
     not read as though those rows are the important ones. */
  color: var(--muted);
}
#mask-balances .mask-eye-off { display: none; }
#mask-balances[aria-pressed="true"] .mask-eye { display: none; }
#mask-balances[aria-pressed="true"] .mask-eye-off { display: inline; }
#mask-balances[aria-pressed="true"] { color: var(--accent-line); border-color: var(--accent-line); }

/* The two switches in Settings > Showing Someone. A row rather than a chip:
   each one needs a sentence under it saying what it does, because "hide" is
   ambiguous about whether the data is gone or merely not drawn. */
.privacy-toggle {
  display: flex; align-items: flex-start; gap: var(--space-3);
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--line);
  cursor: pointer;
}
.privacy-toggle:last-of-type { border-bottom: 0; }
.privacy-toggle input { flex: 0 0 auto; margin-top: 2px; width: 20px; height: 20px; accent-color: var(--accent-fill); }
.privacy-toggle-text { display: grid; gap: 3px; }
.privacy-toggle-name { font-weight: 600; }
.privacy-toggle-note { color: var(--muted); font-size: 12px; line-height: 1.45; }
.privacy-note { margin: var(--space-4) 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }

/* A charge that counts against ANOTHER budget. Muted and small: it is a
   footnote on the row, not a second subject -- but it has to be visible,
   because the row is deliberately excluded from the month total above it and
   an unexplained gap between a list and its own sum is the thing this fixes
   (operator, 2026-08-14: "The 2249 on the transactions total does not match
   with a 2155 at the top"). */
.txn-elsewhere {
  margin-left: 6px;
  font-size: 11px;
  font-weight: 600;
  color: var(--muted);
  white-space: nowrap;
  /* SHRINKABLE, AND CLIPPED. Measured 2026-08-15: this span ran x=70..199 in a
     name cell ending at x=132 -- 67px straight through the category chip
     (operator, with a screenshot: "How should we resolve this overlapping?").
     A flex item defaults to min-width:auto, so a nowrap label refuses to shrink
     below its own text and overflows instead. The ellipsis is the honest end:
     the full destination is in the row's detail sheet. */
  min-width: 0; overflow: hidden; text-overflow: ellipsis;
}
/* The cell must contain its children. It was computing to `overflow: visible`,
   which is how an over-wide child came to paint across the next column instead
   of being cut off at the boundary. */
.txn-row > .txn-name { overflow: hidden; }
/* The amount stays legible but reads as not-counted, matching the way a
   settled or excluded figure is dimmed elsewhere. */
.txn-row.is-attributed .txn-amount { opacity: .55; }


/* ── THE CONTRIBUTION TABLE'S FIGURES ─────────────────────────────────────
   SCOPED TO THIS TABLE, and nowhere else. The first version applied one
   treatment to every figure on the Cash Flow view -- the Transfers headline,
   the due captions, the Surplus figures, all of it -- which shrank headline
   numbers he had not asked about (operator, 2026-08-14: "I didn't want you to
   change the font in any other place, but the one I showed you").

   The section headings keep their own weight and size: a figure beside a
   heading is that heading's total and is meant to read as bigger. Only the
   table's own cells are normalised, so the column scans evenly. */
#view-payday .budget-income-table td.number,
/* THE SURPLUS FIGURES MATCH THE CONTRIBUTION TABLE'S, and only those two
   panels (operator, 2026-08-14: "Make surplus font match the colors
   contribution font"). They were 15px/700 -- a heavier treatment than the
   figures they sit under, so the same kind of number read as two kinds down
   the tab. The Transfers card above keeps its own weight; it was not part of
   the ask, and applying this everywhere is exactly what he corrected earlier. */
#view-payday #kpi-budget-net,
#view-payday #kpi-budget-net-year {
  font-size: 14px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* ── HISTORY: range, selected bar, month groups ───────────────────────────
   The range control drives the bars, the ring and the month groups together.
   A segmented row rather than a dropdown: five short options that are switched
   between constantly, where a dropdown would cost a tap to see the choices. */
.history-range {
  display: flex; gap: 4px;
  margin: 0 0 var(--space-3);
}
.history-range .seg {
  flex: 1 1 0;
  min-height: 34px;
  padding: 0 4px;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  background: none;
  color: var(--muted);
  font-size: 12px; font-weight: 700;
  cursor: pointer;
}
.history-range .seg.is-on {
  background: var(--surface-2);
  border-color: var(--accent-line);
  color: var(--ink);
}
.history-range .seg:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

/* A bar is a control now, so it must not inherit the button chrome. */
.spend-bar {
  border: 0; background: none; padding: 0; font: inherit; color: inherit;
  cursor: pointer;
}
.spend-bar:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* WHICH MONTH IS OPEN, marked on the bar as well as on the group. Without it
   the accordion below changes and nothing up here says which bar did it. */
.spend-bar.is-selected .spend-track > span { outline: 2px solid var(--accent-line); outline-offset: 1px; }
.spend-bar.is-selected .spend-label { color: var(--ink); }

.history-months { margin-top: var(--space-4); }
/* A read-only row: no chips, no pointer, because there is nothing to tag from
   here (see index.html on why this list keeps its own state). */
.txn-row.is-static {
  grid-template-columns: auto minmax(0, 1fr) 10ch;
  cursor: default;
}

/* The ring is for the keyboard. See trackInputMode() in app.js: closing a sheet
   restores focus to its opener, the browser calls that keyboard focus, and a
   phone got a green outline around the card it had just returned from. */
/* EVERY focusable thing, not a list of four. The list version failed OPEN --
   the next focusable element added is simply not on it, nothing errors, and the
   first symptom is a bright accent ring on a phone. That is exactly how the
   sheet's close button ended up wearing a 2px accent outline the moment a sheet
   opened, which read as the button being oversized rather than as a focus state
   (operator, 2026-08-15: "The X button is way too big" -- it is 44px, the
   correct touch target; the ring was the problem).
   Same disease as the mask's class list, one layer down: a whitelist guarding a
   default that should be off. Keyboard users lose nothing -- trackInputMode
   drops `is-pointer` the instant Tab or an arrow key is used, and every ring
   comes back. */
/* !important, deliberately, and it is the SECOND time this rule has been beaten.
   It was once a four-selector whitelist, which failed open for anything not on
   the list. Made general -- and then out-specified anyway: `.track.is-openable:
   focus-visible` is three classes against this rule's two-classes-plus-element,
   so the green --focus ring came back on the very cards that open the sheets
   (operator, 2026-08-15: "Sometimes when I close the popout in the accounts tab
   I see a brief green outline on the button I initially clicked", "It only
   happens once in a while" -- WebKit only sometimes treats a dialog restoring
   focus as keyboard-like).
   Listing the offenders would rebuild the whitelist that already failed. This is
   a global statement -- while the last input was a POINTER, nothing shows a
   focus ring -- and a statement that admits no exceptions is what !important is
   actually for. Keyboard users are untouched: `is-pointer` comes off the moment
   Tab or an arrow is pressed, and every ring returns. */
body.is-pointer :focus-visible { outline: none !important; }
/* ...and the chrome some controls add on focus, which is a ring by another
   name. Suppressed only while the last input was a pointer. */
body.is-pointer .icon-button:focus-visible {
  background: none; border-color: transparent; color: var(--muted);
}

/* The rolling figure's own span. Tabular digits are what stop a counting number
   from juddering: without them each frame re-measures as the glyph widths
   change, and the figure jitters sideways while it counts. */
.money-value { font-variant-numeric: tabular-nums; }

/* ── PRESS WEIGHT: a control that sits ON the surface ─────────────────────
   Operator, 2026-08-14: "I do like the idea of buttons having shadows
   underneath them and having giving a 3-D feel without being over-the-top."

   A first attempt scaled the control by 1.5% and he called it correctly --
   "seems to dip only a small amount". Scale cannot read as depth: nothing
   about a slightly smaller rectangle says lower. The SHADOW is the effect. A
   hard bottom edge is the control's own thickness; the soft one is what it
   casts on the surface below. Pressing shortens both and moves the top face
   down by exactly the thickness it lost, so the base stays put -- which is
   what a real button does.

   --press-lift is the only dial. 3px is a control you can feel; 1px is nearly
   flat. The shared implementation and its write-up live in
   /root/tools/ui-effects/ (design-standards 5j). */
.button,
.track.is-openable,
.cash-card.is-openable {
  --press-lift: 3px;
  /* AN EXPLICIT RESTING TRANSFORM. Without it the base value is `none`, and
     animating between `none` and a translate is not the same as animating
     between two translates -- measured, the control stuck at its pressed
     offset for ~400ms after release and then snapped back. The toolkit copy
     has always had this line; the version applied here did not, which is the
     whole difference between a button and a stuck button. */
  transform: translateY(0);
  box-shadow:
    0 var(--press-lift) 0 rgba(0, 0, 0, .38),
    0 calc(var(--press-lift) + 3px) 10px rgba(0, 0, 0, .26),
    var(--raise-edge);
  transition:
    background var(--motion-fast) var(--ease),
    border-color var(--motion-fast) var(--ease),
    transform 130ms var(--ease-spring),
    box-shadow 130ms var(--ease-spring),
    filter 130ms var(--ease);
}
.button.is-pressed,
.track.is-openable.is-pressed,
.cash-card.is-openable.is-pressed {
  transform: translateY(var(--press-lift));
  box-shadow:
    0 0 0 rgba(0, 0, 0, .38),
    0 1px 3px rgba(0, 0, 0, .30),
    var(--raise-edge);
  filter: brightness(0.96);
}
/* The icon-only controls in the topbar are small and sit on the brand bar, so
   they get a shallower lift -- 3px on a 34px square reads as a loose tile. */
.icon-only { --press-lift: 2px; }
@media (prefers-reduced-motion: reduce) {
  .button, .track.is-openable, .cash-card.is-openable { transition:
    background var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease); }
  .button.is-pressed,
  .track.is-openable.is-pressed,
  .cash-card.is-openable.is-pressed { transform: none; filter: none; }
}


/* While a sheet grows out of (or shrinks back into) the control that opened it,
   the generic rise must not also run -- two animations on one element fight,
   and the result reads as a stutter rather than as either effect. */
dialog.is-sheet-animating { animation: none !important; }
/* NO BLUR WHILE IT MOVES. `backdrop-filter: blur(14px)` re-blurs everything
   behind the sheet on every frame, and for a SECOND-LEVEL popout everything
   behind it is a full sheet of charts, a table and an SVG ring -- so the
   History card animated visibly worse than the same motion from a card
   (operator, 2026-08-15: "the animation when opening the history button seemed
   a little choppy... it definitely animates from the top right to bottom left,
   it's just choppy").
   The scrim still darkens during the travel; only the blur waits. Arriving a
   fraction of a second later is imperceptible next to dropped frames, and this
   is the one effect the code already calls the most expensive thing on screen.
   Paired with the will-change promotion in animateSheetOpen: that stops the
   SHEET being re-rastered, this stops the BACKDROP being re-blurred. Both are
   needed -- the first version had only the former and still stuttered here. */
dialog.is-sheet-animating::backdrop {
  backdrop-filter: none; -webkit-backdrop-filter: none;
}

/* The scrim leaves WITH the sheet. `dialog[open]::backdrop` fades in on open and
   had no exit at all, so closing dropped a full-screen 14px blur instantly --
   which reads as the screen flashing rather than the sheet closing to reveal
   what was underneath (operator, 2026-08-14). `forwards` holds it transparent
   for the moment between the animation ending and the dialog actually closing. */
dialog.is-sheet-closing::backdrop {
  /* MATCHED TO THE SHEET, not to a round number. At 280ms against a 520ms
     sheet the scrim was gone with 240ms of travel still to go, so for that
     window you saw straight through to the page while the sheet was still
     mid-flight -- which reads as a dissolve even though nothing is
     crossfading, and is what survived after the sheet's own crossfade was
     removed (operator, 2026-08-15: "The animation still looks the same to me").
     --sheet-ms is written by app.js from SHEET_MS so the two cannot drift; the
     280ms literal here was the same duration expressed twice in two files. */
  animation: backdrop-out var(--sheet-out-ms, 380ms) var(--ease) forwards;
}
@keyframes backdrop-out { to { opacity: 0; } }

/* Placeholder rows while a popout's transactions load. Sized to the real row so
   the sheet's height is settled before it animates -- otherwise the sheet opens
   at header height and grows when the list lands, which reads as two separate
   things happening (operator, 2026-08-14). */
.txn-skeleton { display: grid; gap: 1px; padding: 2px 0; }
.txn-skeleton > span {
  display: block; height: 34px; border-radius: 4px;
  background: linear-gradient(90deg,
    var(--surface-2) 0%, var(--line) 50%, var(--surface-2) 100%);
  background-size: 200% 100%;
  opacity: .5;
  animation: skeleton-sheen 1.4s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .txn-skeleton > span { animation: none; }
}

/* The surviving figure from the removed history summary card: a quiet caption
   over the table it describes, not a KPI cell. */
.history-avg-line {
  margin: var(--space-3) 0 4px; font-size: var(--fs-eyebrow);
  color: var(--muted); font-weight: 600;
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
}
.history-avg-line > strong {
  color: var(--ink); font-size: var(--fs-figure);
  font-variant-numeric: tabular-nums;
}

/* The breakdown's own Clear: appears only while a category is selected, so it
   costs nothing in the normal case. Right-aligned under the legend it undoes. */
.category-pie .pie-clear {
  display: block; margin: 6px 0 0 auto; font-size: var(--fs-eyebrow);
}
.category-pie .pie-clear[hidden] { display: none; }

/* ---------------------------------------------------------------------------
   THE OPENING SCREEN
   Covers the gap between the app being on screen and the app having its data.
   Painted by the markup, so it is up in the first frame; app.js dismisses it on
   the readiness flag, never on a fixed delay.
   --------------------------------------------------------------------------- */
.launch-screen {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: grid;
  place-items: center;
  /* --page, NOT --bg: this app has no --bg token, and an undefined custom
     property invalidates the whole declaration rather than erroring, so the
     overlay was full-screen, centred and completely TRANSPARENT -- the mark
     floating over live account rows. Nothing in the console either way. */
  background: var(--page);
  /* Opacity AND visibility: fading alone leaves an invisible sheet swallowing
     the first tap, which is the worst possible first impression. */
  transition: opacity 460ms cubic-bezier(0.4, 0, 0.2, 1),
              visibility 0s linear 460ms;
}
.launch-screen.is-done { opacity: 0; visibility: hidden; pointer-events: none; }

.launch-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  /* A small settle rather than a zoom. The mark ends at the size and position
     it has in the topbar, so the opening resolves INTO the app rather than
     cutting to it. */
  animation: launch-rise 620ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
.launch-screen .launch-mark { width: 72px; height: 72px; }
.launch-word {
  font-size: 1.45rem;
  font-weight: 650;
  letter-spacing: 0.01em;
  color: var(--ink);
}

@keyframes launch-rise {
  from { opacity: 0; transform: translateY(8px) scale(0.97); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .launch-screen { transition: opacity 1ms linear, visibility 0s linear 1ms; }
  .launch-inner { animation: none; }
}
