/* ============ Design tokens ============ */
:root {
  --blue: #2563eb; --blue-dark: #1d4ed8; --blue-soft: #eff6ff;
  --green: #16a34a; --green-soft: #dcfce7;
  --red: #dc2626; --red-soft: #fee2e2;
  --amber: #d97706; --amber-soft: #fef3c7;
  --ink: #111827; --ink-2: #374151; --ink-3: #6b7280;
  --line: #e5e7eb; --line-2: #eef0f3; --bg: #f4f5f7; --card: #fff;
  /* Crosshair wash for the hovered row/column. A shade LIGHTER than the row-hover blue: the two
     overlap at the cell under the pointer, and that intersection should read as the strongest
     point of the cross, not the same flat tone as the arms. */
  --hl-col: #f1f5ff; --hl-row: #f4f7ff;
  --radius: 10px; --shadow-sm: 0 1px 2px rgba(0,0,0,.05); --shadow: 0 4px 16px rgba(0,0,0,.08);
  --shadow-lg: -8px 0 32px rgba(0,0,0,.12);
  /* Chart-only colours. zoomchart.js reads these off :root rather than hard-coding literals,
     so the chart stays on-palette with everything else. --efund-fill is deliberately an ALPHA
     of --efund, not a solid tint: the gridlines are painted before the band, so a solid
     fill would bury them. The band has to let them through. */
  /* --year-line marks Jan 1 — the turn of the calendar year. Full blue, not the old #7dd3fc
     sky tint: at 1px dashed the light shade was barely there, and these are a primary landmark. */
  /* --debt-line is the MINIMUM-PAYMENT debt series. Deliberately NOT --red: that red is the app's
     alarm colour (Dead Broke marker, negative cells, delete buttons), and the debt line is
     context, not a warning. A faded brick red reads as the same family one step back. Faded again
     2026-07-29 (#e08b8b → #eaadad, same hue and saturation, lightness 71% → 80%) so the accelerated
     line beside it is unmistakably the foreground one.
     --debt-line-accel is that second series: what is owed once Excess is paid against principal.
     A crimson-leaning red — the same family, but saturated where the other is washed out, because
     this is the line the user is being asked to act on. Distinct from --red (#dc2626, which is only
     ever a marker dot, never a stroke) by hue rather than lightness. */
  /* --invest-line is the INVESTMENT series: what the plan builds once every debt is dead. Blue by
     the user's own prototype, and the one strong blue on the chart — which is why --year-line gave
     its #2563eb up for a neutral grey on 2026-08-01. Two blues, one a line of money and the other a
     calendar landmark, is a question the reader should not have to answer. The Jan-1 verticals lose
     nothing by going grey: they are dashed, vertical and evenly spaced, which is already all the
     identity a gridline needs. */
  --year-line: #9ca3af; --invest-line: #2563eb;
  --efund: #ffc000; --efund-fill: rgba(255, 192, 0, 0.18);
  --debt-line: #eaadad; --debt-line-accel: #e11d3c;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--ink); -webkit-font-smoothing: antialiased; }
button { font-family: inherit; cursor: pointer; }
input, select { font-family: inherit; }

/* ============ Buttons ============ */
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 8px; border: 1px solid transparent; font-size: 13px; font-weight: 600; background: #f3f4f6; color: var(--ink-2); transition: background .12s; }
.btn:hover { background: #e9eaed; }
.btn.primary { background: var(--blue); color: #fff; } .btn.primary:hover { background: var(--blue-dark); }
.btn.danger { background: var(--red); color: #fff; } .btn.danger:hover { background: #b91c1c; }
.btn.ghost { background: transparent; border-color: var(--line); } .btn.ghost:hover { background: #f3f4f6; }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.icon-btn { width: 30px; height: 30px; border: none; background: #f3f4f6; border-radius: 7px; display: grid; place-items: center; color: var(--ink-2); font-size: 15px; }
.icon-btn:hover { background: #e5e7eb; }
.icon-btn.sm { width: 24px; height: 24px; font-size: 13px; }
.icon-btn.sm.danger { color: var(--red); } .icon-btn.sm.danger:hover { background: var(--red-soft); }
/* Link icon (Asset/Income/Expense/Debt tables): muted gray with no saved link, dark once one is set. */
.icon-btn.sm.link-btn { background: transparent; color: var(--ink-3); }
.icon-btn.sm.link-btn:hover { background: #f3f4f6; color: var(--ink-2); }
.icon-btn.sm.link-btn.linked { color: var(--ink); }
.icon-btn.sm.link-btn.linked:hover { background: #e5e7eb; }
.link-cell { display: inline-flex; align-items: center; gap: 0; }
/* Tighter than the standard .sm icon-btn — this column holds up to two icons and must stay compact. */
.link-cell .icon-btn { width: 18px; height: 18px; border-radius: 5px; }
/* Shortcut arrow that appears beside the link icon once a URL is saved — opens it in a new tab directly. */
.icon-btn.sm.link-visit-btn { background: transparent; color: var(--blue); }
.icon-btn.sm.link-visit-btn:hover { background: var(--blue-soft); }
/* The link column itself: minimal padding on both sides so it sits snug against the name column. */
.grid-table td.link-td { padding-left: 4px; padding-right: 2px; }

/* ============================================================
   THE APP SHELL — ONE LAYOUT AT EVERY WIDTH (2026-09-05)

   This was the phone's layout from 2026-08-07 (it still carries that block's original notes, now
   below) and desktop's from today. The page does not scroll: body is a flex column, every link
   down to the scroll box carries `min-height: 0`, and the records box is the ONLY vertical scroll
   owner. Desktop ran the old page-scroll model until now, which is where all four of the reported
   symptoms came from — a gap under the chart, a card floating on a board, totals stranded below the
   fold, and a horizontal scrollbar at the bottom of a long page instead of above those totals.
   None of them are separable from the scroll model, so the model is what changed.

   `min-height: 0` on every link is the load-bearing part: without it a flex item refuses to shrink
   below its content and the chain breaks silently at the first table taller than the screen.

   100dvh, not 100vh: on a phone with a retracting toolbar, 100vh is the LARGEST viewport, so the
   bottom of the shell sat under the browser chrome until you scrolled.
   ============================================================ */
html, body { height: 100dvh; overflow: hidden; }
body { display: flex; flex-direction: column; }
.nda-reminder { flex: 0 0 auto; }
#app { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
#board-host { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }

/* ============ Header ============ */
/* NOT sticky any more (2026-09-05): there is no page scroll left to pin against, and a sticky
   element inside a fixed-height flex column pins to nothing. It is simply the first row of the
   shell. position: relative is load-bearing and easy to miss — .topbar-resize is absolutely
   positioned and used to anchor to #hdr's sticky containing block; without a positioned ancestor it
   escapes to the viewport and parks itself at the bottom of the screen. */
#hdr { flex: 0 0 auto; position: relative; z-index: 30; }
.topbar {
  background: var(--card);
  border-bottom: 1px solid var(--line); box-shadow: var(--shadow-sm);
  padding: 10px 20px 14px;
  position: relative;
}
.brand { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; }
/* THE REAL LOGO, NOT A "$" IN A BOX (user, 2026-09-07). This was a blue rounded square with a white
   dollar sign in it — a placeholder that had outlived its purpose, since the actual Debt Compass
   mark has shipped in this folder the whole time as favicon.png (64px, RGBA, already downscaled from
   HBIMD/dc-logo-small.png — see the note in index.html). Reusing that file rather than adding a
   second copy: it is already in the deploy allow-list and already fetched for the tab icon, so the
   header mark costs nothing extra to load.
   NO BACKGROUND AND NO RADIUS, deliberately. The logo carries its own blue, and sitting it on
   var(--blue) would have hidden half of it; the PNG's alpha channel means it needs no plate at all.
   The 26px footprint is kept exactly so the header does not reflow, and width/height are also set as
   HTML attributes so nothing shifts in the moment before the image decodes. */
.brand-mark { width: 26px; height: 26px; flex: 0 0 auto; display: block; object-fit: contain; }
.brand-name { font-weight: 700; font-size: 14px; }
.hdr-grid { display: flex; flex-direction: column; gap: 12px; }
/* gap: 0 SINCE 2026-09-05 (user: "a ton of space on the left and right of div.hdr-resize"). A 16px
   row gap lands on BOTH sides of each 9px splitter, so 41px of dead space sat between the chart and
   Key Metrics and the handle read as floating in a trench rather than as the edge between two
   panels. The splitters are the separators now; the rail keeps its own margin below. */
.hdr-top-row { display: flex; gap: 0; align-items: stretch; }

.chart-panel { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.chart-title { font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Filters are one row, always: the title gives up space before they wrap. */
.range-filters { display: flex; gap: 3px; flex-wrap: nowrap; flex: 0 0 auto; }
.rf { border: 1px solid var(--line); background: #fff; color: var(--ink-3); font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 6px; flex: 0 0 auto; white-space: nowrap; }
.rf:hover { background: #f3f4f6; }
.rf.on { background: var(--blue); color: #fff; border-color: var(--blue); }
/* The from-scratch chart (zoomchart.js). Inline height (set by the resize drag) is the flex-basis;
   the box then grows to fill whatever the right-hand column is tall, so no dead space opens up
   under the chart. */
.zc-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 4px; flex: 0 0 auto; }
/* The 50dvh cap is what protects the table (user, 2026-09-05). The page no longer scrolls, so the
   chart and the records share a fixed amount of screen: dragged to its old 600px maximum on a short
   laptop, the chart left the card below it a row or two. settings.chartHeight is never rewritten —
   it is clamped only on screens too short to honour it, so the same window on a big monitor still
   opens at the height you dragged. */
.zc-box { height: 185px; width: 100%; position: relative; flex: 1 1 auto; max-height: 50dvh; }
/* grab/grabbing is the whole affordance for drag-to-scroll (2026-08-03) — there is no scrollbar and
   no arrow button to give it away, so without a cursor change the gesture is invisible.
   `.zc-panning` is toggled by the drag handlers, not `:active`: the pointer leaves the canvas
   constantly while panning at a bound, and :active would drop the cursor the moment it did. */
/* touch-action: none is what lets one finger scrub the tooltip and two fingers pan — the browser
   would otherwise claim both gestures for scrolling and pinch-zoom. THE COST, stated plainly: a
   vertical swipe STARTED ON THE CHART will not scroll the page. On a phone the chart is a
   full-width 220px band, so that is a real dead zone; there is content above and below it to start
   the swipe from. `pan-y` would keep page scroll but makes two-finger pan unreliable. */
.zc-box canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
.zc-box canvas.zc-panning { cursor: grabbing; }
/* Touch-gesture hint: mobile only. Desktop has a cursor and hover, which say the same thing. */
.zc-touch-hint { display: none; }

/* Right column of the header: KPI tiles, the setup controls, then the payoff schedule.
   flex-basis is set inline from settings.hdrRightWidth (the x resize handle drives it);
   the 268px here is only the pre-JS default.

   align-self:stretch (was `start` until 2026-08-03) is what lets the payoff grid below fill the
   leftover height, so the column's bottom lines up with the chart's and its visible row count
   tracks the chart's resizable height for free. `start` was there to keep the As-of label flush
   with the top of .chart-panel — that label moved into the Assets card on 2026-07-18, so the
   reason had already gone. */
/* A ROW OF TWO COLUMNS SINCE 2026-09-05 (user), not a stack of three tables. Key Metrics + Setup on
   the left at their own dragged width; the schedule alone on the right, full height.

   THE REASON IT IS WORTH THE CHANGE is not only the layout. Stacked, the schedule got whatever the
   two tables above it left over — which on a 720px-high screen was its 130px floor and nothing
   more, forever. Side by side it gets the whole column height, and .hdr-right's own natural height
   drops from ~449px to ~300px, which is what finally lets the chart's drag handle add rows to it
   (see the height cap below). */
/* NO WIDTH OF ITS OWN (2026-09-05). Once each splitter sizes the panel to ITS OWN right, this box
   is exactly the sum of its two children and nothing drags it directly — `flex: 0 0 auto` is that
   statement. The chart (`flex: 1 1 auto`) is what gives up or takes back the difference. */
.hdr-right { flex: 0 0 auto; min-width: 0; max-width: 100%; overflow: hidden; align-self: stretch; display: flex; gap: 0; }
/* Its flex-basis is dragged by the `m` handle; overflow-y is its own safety valve, so that a tall
   Key Metrics (one row per debt) scrolls HERE rather than making the whole header taller and
   pushing the records card off the screen. That containment is what allows the cap below to rise. */
/* min-width from the SAME measurement, so the drag stops where the text stops fitting instead of
   ellipsing a label (user: "never chop the results"). Falls back to the hard floor before the first
   measurement lands. */
.hdr-metrics { flex: 0 0 257px; min-width: var(--kpi-min-w, 190px); display: flex; flex-direction: column; gap: 10px; overflow-y: auto; overflow-x: hidden; }
/* Takes everything the metrics column does not. min-width: 0 is load-bearing: without it the flex
   automatic minimum size holds this column at the schedule table's min-content width and the drag
   handle stops short. */
/* Floored at what its own four columns need: 32 + 78 + 96 + a 15px scrollbar gutter = 221, plus
   ~90px before a debt name stops being distinguishable. */
.hdr-payoff { flex: 0 0 323px; min-width: 311px; display: flex; flex-direction: column; }
/* The divider between them. Same 9px target and the same grip mark as .hdr-resize, deliberately —
   it is the same gesture, one level in. */
.hdr-msplit { flex: 0 0 auto; width: 9px; align-self: stretch; cursor: ew-resize; display: flex; align-items: center; justify-content: center; }
.hdr-msplit::after { content: ''; width: 4px; height: 46px; border-radius: 2px; background: var(--line); transition: background .12s; }
.hdr-msplit:hover::after { background: var(--ink-3); }

/* ---------- desktop rail (2026-08-26) ----------
   Third column of .hdr-top-row, left of the chart. align-items:stretch on that row (above) is what
   gives it the chart's own height for free — .rail-tools is pushed to the bottom of whatever that
   height turns out to be by justify-content:space-between, with no fixed height of its own to keep
   in step with the chart's. */
.desktop-rail { flex: 0 0 180px; margin-right: 14px; min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow: hidden; }
.rail-tabs { display: flex; flex-direction: column; padding: 6px; gap: 2px; overflow-y: auto; }
.rail-item { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border: none; border-left: 3px solid transparent; background: none; border-radius: 7px; color: var(--ink-2); font: inherit; font-size: 12.5px; font-weight: 600; text-align: left; cursor: pointer; }
.rail-item:hover { background: #f3f4f6; }
/* Same blue-accent language .mtab.on already uses on the phone (color + a border toward the
   pointing edge), turned sideways for a vertical menu — a left border reads as "this one" the way
   mobile's top border does on a horizontal bar. */
.rail-item.on { color: var(--blue); background: var(--blue-soft); border-left-color: var(--blue); }
.rail-item svg { flex: 0 0 auto; }
/* TWO ROWS, WRITTEN NOT WRAPPED (owner, 2026-09-09). This used to be one flex-wrap row of up to
   seven controls that broke wherever the rail's width happened to run out — so the split moved
   with the zoom level, the scrollbar and the longest sync label ("Saved — website not updated").
   Two explicit rows put the break where it was designed: status + undo + redo, then the cog and
   Sign out. Nothing here wraps any more, because nothing here has to. */
.rail-tools { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px; border-top: 1px solid var(--line); }
.rail-tools-row { display: flex; align-items: center; justify-content: center; gap: 6px; }
.rail-tools .icon-btn.sm:disabled { opacity: .35; cursor: default; pointer-events: none; }

/* ---------- the table + its totals bar (2026-08-26, rebuilt 2026-09-05) ----------
   .grid-col wraps a table and its .grid-foot sibling so the two are one thing. It carries the shell
   chain THROUGH to .grid-wrap, and that is not optional: it was introduced after the phone's shell
   was written, so for four cards (Income, Fixed, Variable, Debts) it sat between .panel and
   .grid-wrap as a plain block — which made .grid-wrap's `flex/min-height` inert and left those
   tables clipped by .panel's overflow:hidden instead of scrolling inside it.

   It was `width: max-content` so the footer could never be wider than the columns it totals. That
   is the opposite of what is wanted now (user, 2026-09-05: the totals span the screen), so it fills
   the panel and the table keeps its own content width inside it. */
.grid-col { width: 100%; max-width: 100%; display: flex; flex-direction: column; min-height: 0; flex: 0 1 auto; }
/* THE TOTALS BAR — one definition, every width, every card (2026-09-05). It is a SIBLING of the
   scroll box, never a <tfoot> inside the table: a <tfoot> row is exactly as wide as the table, so
   it scrolled sideways with the columns and could land off the right edge of the screen ("Next 30
   Days $6,620" cut off). A plain block sibling has no columns to stay aligned with, so it spans the
   card, sits below the scroll box, and is un-scrollable by construction — no sticky needed and
   nothing to clip. `flex: 0 0 auto` is what stops .grid-wrap's flexing from squeezing it.
   Where it ENDS UP follows from that and nothing else: under the last row when the rows fit, on the
   bottom edge of the window when they do not. */
.grid-foot { flex: 0 0 auto; padding: 7px 14px; text-align: right; font-weight: 700;
             font-size: 12.5px; background: var(--card); border-top: 2px solid var(--line); }
/* TWO JOBS ON ONE BAR (2026-09-05). The Debts footer carries a footnote as well as a total: the
   asterisk on an unpayable debt's name needs something to point at, and this bar is already the
   card's last line at every width. Flex only when the note is actually present (`:has`), so a card
   with nothing to footnote keeps the plain right-aligned total every other card shows — the totals
   must not drift left just because this one card COULD have a note. */
.grid-foot:has(.foot-note) { display: flex; align-items: baseline; justify-content: space-between; gap: 18px; }
/* The note is prose, so it drops the bar's bold/tabular treatment and takes the muted weight Chart
   Data's own footer note already uses. `min-width: 0` lets it give room to the total rather than
   pushing it off the right edge, which is the exact failure the <tfoot> rewrite existed to end. */
.foot-note { flex: 1 1 auto; min-width: 0; text-align: left; font-weight: 500; font-size: 11.5px;
             line-height: 1.45; color: var(--ink-3); }
.foot-note .req { margin-right: 3px; }
/* As-of date, now a row in Setup (owner, 2026-09-09 — it was in the Assets panel head for a month).
   The date field and its italic "X days old" tag (blank when As-of = today) sit together in the
   value cell, right-justified with every other figure in that table.
   THE FLEX IS ON A SPAN INSIDE THE CELL, never on the <td>: `display: flex` on a table cell drops
   it out of table layout. This file has learned that rule once already. */
.asof-cell { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
.asof-head-input { font-size: 12px; font-weight: 700; color: var(--blue); background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; font-variant-numeric: tabular-nums; }
.asof-head-input:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
.asof-age { font-style: italic; font-size: 10.5px; font-weight: 400; color: var(--ink-3); white-space: nowrap; }
.asof-age:empty { display: none; }
/* Payoff-strategy picker, now the Strategy row in Setup (owner, 2026-09-09 — it was in the Debts
   panel head). The <select> keeps its own class so the app and the widget can put different
   controls in the same row: a dropdown here, four radios on the widget. */
/* A strategy NAME is neither a number nor a date, so the right-justify standing rule never applied
   to it — it used to borrow `.setup-cell`, which right-aligns because it styles the Setup metric
   table. Left-aligned here, with its own caret drawn in so it reads as a dropdown at a glance
   rather than as a bordered label you have to discover is clickable. */
.payoff-head-select {
  font-size: 12px; font-weight: 600; color: var(--ink-2); background: #fff;
  border: 1px solid var(--line); border-radius: 6px; padding: 3px 24px 3px 8px;
  text-align: left; cursor: pointer;
  appearance: none; -webkit-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5' fill='none' stroke='%236b7280' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 7px center; background-size: 9px 6px;
}
.payoff-head-select:hover { border-color: var(--ink-3); }
.payoff-head-select:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
/* Strategy spans BOTH columns of Setup, so it draws its own label/control split. The value column
   is sized to its figures, and a five-item dropdown does not fit in it. demo.css has the widget's
   copy of this rule for the radio version; the two are scoped apart on purpose — same row, two
   different controls. Flex on the span inside the cell, never on the <td>. */
.setup-strategy-row, .setup-wide-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.setup-strategy-row > span:first-child, .setup-wide-row > span:first-child { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
/* Two classes, one rule, and they are separate on purpose: demo.css restyles .setup-strategy-row
   for the widget's radio version and must not catch the As-of row, which the widget never draws. */
/* Key Metrics — a dense ledger, not tiles. Label left, figure right, one line each. */
.kpi-table { width: 100%; table-layout: fixed; border-collapse: collapse; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; font-variant-numeric: tabular-nums; }
/* Sentence case (2026-09-12), matching the modals. This one rule heads Key Metrics, Setup, the
   Accelerated Payoff Schedule / Accelerated Investments column and every Settings table, so all of
   them change together and stay in step. */
.kpi-table thead th { background: var(--amber-soft); font-size: 12px; font-weight: 700; color: var(--ink-2); padding: 4px 8px; text-align: center; border-bottom: 1px solid var(--line); }
.kpi-table td { padding: 4px 9px; font-size: 11px; border-bottom: 1px solid var(--line-2); overflow: hidden; }
.kpi-table tbody tr:last-child td { border-bottom: none; }
.kpi-table tbody tr:nth-child(even) { background: #fafbfc; }
/* THE SPLIT COMES FROM A <colgroup> (2026-09-05), not from these cells. `.kpi-table` is
   table-layout:fixed and fixed layout reads its widths from the FIRST ROW — a single
   `<th colspan="2">` in both of these tables — so it fell back to two EQUAL columns and every width
   set here was ignored. --kpi-v-w is measured from the rendered text of BOTH tables and set on
   .hdr-metrics by applyKpiColWidths(), which is what makes Key Metrics and Setup agree on where the
   column ends. The fallback keeps the old 50/50 for the first paint, before anything is measured. */
.kpi-table col.kpi-col-v { width: var(--kpi-v-w, 50%); }
.kpi-lbl { color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kpi-v { text-align: right; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--ink); }
.kpi-v.pos { color: var(--green); } .kpi-v.neg { color: var(--red); }
/* Investment Schedule's amount column — same money leaving the cash line as a debt payment, but
   coloured to agree with the chart's own Investment Value line rather than reading as a loss. */
.kpi-v.inv { color: var(--invest-line); }

/* ============ Main stack ============ */
/* (`[data-panel="goals"] { display: none }` lived here from ~mid-July until 2026-09-05. Its own
   comment set the condition for its removal — "Goals stays hidden until something computes from it"
   — and that condition is now met: every goal carries a Date Achieved read off the investment line,
   and the chart labels them. `payoff` came back the same way on 2026-07-30, for the same reason.

   Worth knowing it was doing more than it looked like. The desktop rail already out-specified it
   (2026-08-26), so Goals had been visible on desktop and invisible on the phone — one rule producing
   two different products. Deleting it is what lets the phone's tab bar show Goals at all.) */
.stack { max-width: 1180px; margin: 0 auto; padding: 18px 20px 80px; display: flex; flex-direction: column; gap: 14px; }
.panel { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow: hidden; }
.panel-head { display: flex; align-items: center; justify-content: space-between; padding: 11px 14px; border-bottom: 1px solid var(--line); }
.panel-title { font-size: 14px; font-weight: 700; }
.panel-sub { font-weight: 500; font-size: 12px; color: var(--ink-3); margin-left: 6px; }
.panel-body { padding: 14px; font-size: 13px; color: var(--ink-2); }
.empty-panel { color: var(--ink-3); font-size: 12.5px; line-height: 1.5; background: #fafbfc; }
.notes-textarea { width: 100%; min-height: 110px; background: #fff; border: 1px solid var(--line); border-radius: 6px; color: var(--ink); padding: 10px 12px; font-size: 13px; font-family: inherit; line-height: 1.5; resize: vertical; transition: border-color .15s, box-shadow .15s; }
.notes-textarea:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }

/* Notes: formatting bar + editable area. The card is 420px until the corner grip says otherwise;
   the editor's own height is what that grip drags (data-resize-h), which is why Notes is the one
   card that grows vertically. */
.notes-body { width: 420px; max-width: 100%; padding-top: 10px; }
.panel.sized .notes-body { width: auto; }
.notes-bar { display: flex; gap: 3px; margin-bottom: 7px; }
.note-tool { min-width: 26px; height: 26px; padding: 0 6px; background: #fff; border: 1px solid var(--line); border-radius: 5px; color: var(--ink-2); font-size: 12px; font-family: inherit; line-height: 1; cursor: pointer; transition: border-color .12s, background .12s, color .12s; }
.note-tool:hover { border-color: var(--blue); color: var(--blue); background: var(--blue-soft); }
.note-tool sub { font-size: 8px; }
.notes-editor { min-height: 110px; max-height: 70vh; overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: 6px; color: var(--ink); padding: 10px 12px; font-size: 13px; line-height: 1.5; transition: border-color .15s, box-shadow .15s; }
.notes-editor:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
/* Placeholder for a contenteditable: :empty misses the <br> a browser leaves behind, so the text
   is driven by the same "is there anything here" test the editor itself uses. */
.notes-editor:empty::before { content: attr(data-placeholder); color: var(--ink-3); }
.notes-editor p { margin: 0 0 6px; }
.notes-editor p:last-child { margin-bottom: 0; }
.notes-editor ul, .notes-editor ol { margin: 0 0 6px; padding-left: 22px; }
.notes-editor li { margin: 1px 0; }
/* Indent levels. A blockquote is what the browser produces for an indent outside a list, so it is
   styled as plain indentation here — no quote rule, no italics: the user asked to indent a note,
   not to quote something. Nesting is unlimited; each level steps in by the same amount. */
.notes-editor blockquote { margin: 0 0 6px; padding-left: 22px; border: none; }
.notes-editor blockquote:last-child { margin-bottom: 0; }
/* Nested lists cycle their markers so three levels deep is still readable at a glance. */
.notes-editor ul ul, .notes-editor ol ol, .notes-editor ul ol, .notes-editor ol ul { margin-bottom: 0; }
.notes-editor ul { list-style-type: disc; }
.notes-editor ul ul { list-style-type: circle; }
.notes-editor ul ul ul { list-style-type: square; }
/* "Unsaved" tag beside the Save/Cancel pair — the card's own reminder that it is holding a draft. */
.notes-dirty { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--red); align-self: center; margin-right: 2px; }

/* assumptions bar */
.assumptions { padding: 12px 14px; }
.asm-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; }
.asm { display: flex; flex-direction: column; gap: 3px; }
.asm label { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; color: var(--ink-3); }
.asm-input { border: 1px solid var(--line); border-radius: 7px; padding: 6px 8px; font-size: 12.5px; background: #fff; color: var(--ink); }
.asm-input:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
.asm-pair { display: flex; gap: 4px; } .asm-pair .asm-input { width: 54px; }

/* ============ Dense grids ============ */
.grid-wrap { overflow-x: auto; }
.grid-table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
/* position: relative so the name column's drag strip can sit on this cell's right edge (see
   .col-grip). Harmless on every other header — nothing else is positioned inside one. */
/* Sentence case (2026-09-12, owner: "match everything") — same as the modals and the header panels,
   so the board uses one heading style. 11.5px because caps at 10.5px was visually larger than the
   same words in lower case at 10.5px. */
.grid-table thead th { position: relative; text-align: left; font-size: 11.5px; color: var(--ink-3); font-weight: 600; padding: 7px 8px; border-bottom: 1px solid var(--line); background: #fafbfc; white-space: nowrap; }
/* ── The name column's own width handle (user, 2026-09-05) ──
   A strip on the right edge of the ONE header the user gets to size — Asset / Source / Fixed
   Expense / Variable Expense / Debt / Goal. Invisible until hovered, because a visible divider on
   one header out of eight would read as a fault in the table rather than as a control; the
   col-resize cursor plus a blue hairline on approach is the whole affordance, the same way the
   card's corner grip works.
   touch-action: none hands us the gesture instead of letting the card scroll under the finger —
   the same line .row-grip and .panel-resize both need for the same reason. */
.col-grip { position: absolute; top: 0; right: 0; width: 9px; height: 100%; cursor: col-resize;
            touch-action: none; user-select: none; }
.col-grip::after { content: ''; position: absolute; right: 3px; top: 5px; bottom: 5px; width: 2px;
                   border-radius: 1px; background: transparent; transition: background .12s; }
.col-grip:hover::after { background: var(--blue); }
/* THE FLOOR ON HOW NARROW THE COLUMN CAN GO. An <input> with no `size` attribute carries a ~174px
   intrinsic width, which in an auto-layout table is a hard minimum for the whole column — it is why
   every name column is the same width today whatever the names in it. min-width: 0 lets the drag
   (and a plain fit-to-content) go below that; the input still fills whatever the cell ends up. */
.grid-table td:has(.cell-text) .cell-text { min-width: 0; }
.grid-table th.right, .grid-table td.right { text-align: right; }
/* Sortable column headers: clickable, with a ▲/▼ on the active sort column. */
.grid-table th.sortable { cursor: pointer; user-select: none; }
.grid-table th.sortable:hover { color: var(--ink-2); background: #f2f5fb; }
.grid-table th.sorted { color: var(--blue); }
.sort-caret { font-size: 9px; margin-left: 3px; color: var(--blue); }
/* nowrap holds the row at 30px. Body cells used to inherit white-space:normal, which was harmless
   only because the table was always exactly as wide as its content — once a resized card can make
   a column narrower than what's in it, a long name would wrap and `height` would act as a minimum
   rather than a fixed row height. Rows are a ledger; they stay one line. */
.grid-table tbody td { padding: 1px 6px; border-bottom: 1px solid var(--line-2); vertical-align: middle; height: 30px; white-space: nowrap; }
.grid-table tbody tr:nth-child(even) td { background: #fcfcfd; }
.grid-table tbody tr:hover td { background: var(--hl-row); }
.grid-table tr.inactive td { opacity: .5; }
/* The action cell stays a real table-cell — as `display:flex` it was outside table layout entirely
   and contributed no width, which skewed how the surplus from a resized card got shared out. The
   flex row moved to a wrapper inside it. */
.grid-table td.act { text-align: right; white-space: nowrap; }
.grid-table td.act > * { vertical-align: middle; }
.act-btns { display: flex; align-items: center; gap: 4px; justify-content: flex-end; }
.grid-table td.ctr { text-align: center; }
/* (The `.grid-table tfoot` rules lived here until 2026-09-05. No grid table has a <tfoot> any more:
   Assets was the last one, and its totals moved out to the shared .grid-foot bar with the rest.
   .payoff-mini keeps its own tfoot and its own rule — that is a header-column table, not a card.) */
/* The figures ON that bar: label + amount, repeated. inline-block, NOT flex, because these are also
   read inside a table cell in the payment-stream summaries — flex on a <td> drops the cell out of
   table layout. The bar is already text-align:right, so the group hugs the right edge. */
.foot-fig { display: inline-block; margin-left: 20px; white-space: nowrap; font-variant-numeric: tabular-nums; }
.foot-fig-l { color: var(--ink-3); font-weight: 600; margin-right: 6px; }
.muted { color: var(--ink-3); }

/* editable cells */
.cell { width: 100%; border: 1px solid transparent; background: transparent; border-radius: 6px; padding: 5px 7px; font-size: 12.5px; color: var(--ink); transition: border-color .1s, background .1s; }
.cell:hover { background: #eef1f5; }
.cell:focus { outline: none; border-color: var(--blue); background: #fff; box-shadow: 0 0 0 2px var(--blue-soft); }
.cell-select { cursor: pointer; }
/* Numbers and dates read right-aligned everywhere; only free text stays left. */
.cell-date { max-width: 140px; text-align: right; }
/* The wrapper IS the field: one bordered box with the $/% glyph inline inside it.
   The box is min-width'd to hold 7 figures ($9,999,999.99); the input inside is sized to its
   own content (the `size` attribute, kept live by autoSize()) and the pair is right-aligned as
   a group. That is what puts the glyph against the leading digit instead of stranding it at the
   opposite end of a fixed-width field. Any `width` here would defeat the auto-sizing. */
.money-box { display: inline-flex; align-items: center; gap: 1px; justify-content: flex-end; min-width: 14ch; }
/* field-sizing does the real work: it sizes the input to its text exactly. The `size`
   attribute (kept live by autoSize()) is the fallback for browsers without it — approximate,
   because `size` buys ~1.5x the characters it promises, but far better than the 20-char
   default. min-width keeps an empty field from collapsing to nothing. */
.money-box > input { field-sizing: content; width: auto; min-width: 1ch; flex: 0 0 auto; text-align: right; padding: 5px 0; border: none; background: transparent; font-variant-numeric: tabular-nums; }
.money-box > input:hover { background: transparent; }
.money-box > input:focus { border: none; background: transparent; box-shadow: none; outline: none; }
.money-box > .cur { flex: 0 0 auto; color: var(--ink-3); }
.money-box.suffix { min-width: 7ch; }
.money-box.suffix::after { content: '%'; flex: 0 0 auto; color: var(--ink-3); }
/* A money box is only as wide as its content once it passes min-width, so the cell has to be
   what right-aligns it — otherwise a short APR ("5.5%") and a long one ("23.49%") would hang
   their glyphs at different x and the column would read ragged. Same intent as td.right; keyed
   off the box so the ~12 call sites don't each have to remember to ask for it. */
.grid-table td:has(.money-box) { text-align: right; }
/* The stable wrapper errWrap always emits: lets the red-flag state toggle in place (no re-render). */
.cell-wrap { position: relative; display: block; }
.grid-table td:has(.money-box) .cell-wrap { display: flex; justify-content: flex-end; }
/* Click-anywhere-to-edit: the whole cell reads as editable, no dead padding to miss. */
.grid-table td:has(.cell-text), .grid-table td:has(.money-box), .money-box { cursor: text; }
.grid-table td:has(.cell-select), .grid-table td:has(.cell-date), .grid-table td:has(.cell-check) { cursor: pointer; }
/* grid variant: borderless until hover/focus, so a table of them reads as plain numbers */
.cell-money { border: 1px solid transparent; border-radius: 6px; padding: 0 7px; transition: border-color .1s, background .1s, box-shadow .1s; }
.cell-money:hover { background: #eef1f5; }
.cell-money:focus-within { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 2px var(--blue-soft); }
.cell-money > .cur, .cell-money.suffix::after { font-size: 12px; }
.cell-check { display: grid; place-items: center; }
.cell-check input { width: 16px; height: 16px; accent-color: var(--blue); cursor: pointer; }

.sched-btn { border: 1px dashed var(--line); background: #fff; color: var(--blue-dark); font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 6px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sched-btn:hover { background: var(--blue-soft); border-style: solid; border-color: var(--blue); }
/* Next-payment reads as plain text but opens the recurrence modal on click. */
.next-pay { background: none; border: none; padding: 4px 2px; font: inherit; color: var(--ink); cursor: pointer; border-radius: 5px; font-variant-numeric: tabular-nums; }
.next-pay:hover { background: var(--blue-soft); color: var(--blue-dark); }

/* ============ Toast ============ */
.toast-host { position: fixed; top: 16px; right: 16px; z-index: 80; display: flex; flex-direction: column; gap: 10px; }
.toast { background: var(--ink); color: #fff; padding: 11px 15px; border-radius: 9px; font-size: 13px; box-shadow: var(--shadow); animation: slidein .2s ease; max-width: 340px; }
.toast.ok { background: var(--green); } .toast.err { background: var(--red); }
@keyframes slidein { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }

/* ============ Backups modal ============ */
.backup-note { margin-bottom: 12px !important; }
.backup-toolbar { display: flex; margin-bottom: 12px; }
.backup-reason { display: inline-block; font-size: 11px; font-weight: 600; color: var(--ink-3); background: var(--line); padding: 2px 8px; border-radius: 999px; }

/* ============ Modal ============ */
.modal-host { position: fixed; inset: 0; z-index: 70; display: none; place-items: center; padding: 20px; }
.modal-host.open { display: grid; }
.modal-host::before { content: ''; position: absolute; inset: 0; background: rgba(15,23,42,.4); backdrop-filter: blur(4px); }
.modal { position: relative; background: #fff; border-radius: 14px; width: 420px; max-width: 100%; padding: 20px; box-shadow: var(--shadow); animation: pop .15s ease; }
@keyframes pop { from { transform: scale(.97); opacity: 0; } to { transform: none; opacity: 1; } }
.modal h3 { font-size: 16px; margin-bottom: 8px; }
.modal p { color: var(--ink-3); font-size: 13px; line-height: 1.5; margin-bottom: 18px; }
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.modal-head h3 { margin: 0; }
.modal-actions { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.modal-actions .btn:not(.ghost) { margin-left: auto; }
.modal-actions .btn.ghost + .btn.ghost { margin-left: 0; }

/* ── Settings modal (cog) ──
   Wider than the 420px default because the Viewing section carries a person's card, and taller than
   the viewport on a laptop once the roster is in it — so the BODY scrolls and the head and the
   Close button stay put. Not routed through the `:has(.sched-body)` rule further down: that one
   zeroes the modal's padding for the framed header/footer modals, and this one wants ordinary
   modal padding with only its middle scrolling. */
.settings-modal { width: 680px; display: flex; flex-direction: column; max-height: 86dvh; }
.settings-body { overflow-y: auto; margin: 0 -4px; padding: 0 4px; }
/* TWO COLUMNS FOR THE TWO SMALL SECTIONS (owner, 2026-09-09). Display is two rows plus its preview
   strip and the agreement is one row; stacked, they were two short tables with a column of empty
   modal beside them. `1fr 1fr` rather than auto tracks: these are two peers, and a grid that sizes
   to content would make the wider one look like the important one.
   `align-items: start` so the shorter column does not stretch its table to match the taller. */
.settings-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; align-items: start; }
/* The admin band, set apart by a rule rather than by a heavier box: everything above it is anybody's
   and everything below it is the owner's, which is a boundary worth drawing once. */
.settings-admin { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 2px; }
.settings-band-head { font-size: 12px; font-weight: 700; color: var(--ink-2); margin-bottom: 8px; }
/* One column on anything narrow — two 300px columns of dropdowns is worse than one of 600. */
@media (max-width: 760px) {
  .settings-modal { width: 520px; }
  .settings-cols { grid-template-columns: 1fr; gap: 0; }
}
/* Each section is a .kpi-table (see app.js's note on why). This only adds the gap between them and
   lets the right-hand column give its control room to breathe, since a <select> is taller than the
   computed figure a KPI row normally holds. */
.settings-table { margin-bottom: 14px; }
.settings-table td { padding: 7px 9px; }
.settings-table .kpi-v { overflow: visible; }
/* .kpi-lbl is nowrap+ellipsis because a KPI row is a fixed-width column of short metric names.
   These labels are short sentences ("Projection horizon (years)"), and a clipped question is worse
   than a two-line one — so here they wrap instead. */
.settings-table .kpi-lbl { white-space: normal; overflow: visible; text-overflow: clip; }
.settings-select {
  width: 100%; max-width: 200px; background: #fff; border: 1px solid var(--line); border-radius: 6px;
  color: var(--ink); padding: 5px 7px; font-size: 12px; font-family: inherit; font-weight: 600;
  transition: border-color .15s, box-shadow .15s;
}
.settings-select:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
/* Your Data and the administrator's board actions: one line per row, never wrapped (owner, 2026-09-12). */
.settings-table.settings-nowrap { table-layout: auto; }
.settings-table.settings-nowrap .kpi-lbl { white-space: nowrap; }
.settings-table.settings-nowrap .kpi-v { width: 1%; white-space: nowrap; }
/* THE SPLIT IS SET BY A <colgroup>, NOT BY td:first-child.
   .kpi-table is `table-layout: fixed`, and fixed layout takes its column widths from the FIRST
   ROW — which here is the section head, a single `<th colspan="2">`. That gives it nothing to go
   on, so it falls back to two equal columns and every width set on a later `td` is ignored. A
   `<col>` is the one thing fixed layout does read. (Not flex on the cells: `display: flex` on a
   `<td>` drops it out of table layout altogether.) */
.settings-table col.settings-col-lbl { width: 46%; }
/* A phone leaves the modal ~315px wide, which is not enough for "COP — Colombian peso" beside its
   label. Give the control the larger share. A select whose SELECTED label is clipped is still
   perfectly usable — the open list shows every option in full — provided what survives the clip
   identifies the choice, which is why the CODE is written first in these labels. */
@media (max-width: 700px) {
  .settings-table col.settings-col-lbl { width: 34%; }
  .settings-table td { padding: 7px 6px; }
  .settings-select { max-width: none; padding-left: 5px; padding-right: 4px; }
}
/* THE ONE LOUD THING IN THIS BOX, and deliberately so: the two pickers above it are abstract
   ("COP", "d/m/yyyy") and this is the same choice made concrete, in the exact formatters every
   figure on the board runs through. Tabular figures so switching currency doesn't make it jump. */
.settings-preview {
  margin: -6px 0 18px; padding: 9px 11px; border-radius: 8px;
  background: var(--amber-soft); border: 1px solid rgba(217, 119, 6, .25);
  color: var(--ink); font-size: 15px; font-weight: 700; text-align: center;
  font-variant-numeric: tabular-nums; letter-spacing: .01em;
}
.settings-preview-sep { color: var(--amber); font-weight: 400; margin: 0 6px; }
/* .settings-danger is gone (2026-09-09). Reset and Delete were loose buttons under their sections,
   each with a sentence beside it; both are ordinary rows of the Your account table now, so the
   warning lives in the row's ⓘ and in the typed confirmation that follows. One shape for every
   control in this box. */

/* ── Viewing (the user switcher) ──
   The dropdown answers "who exists". This card answers "does this person need a call", which is
   the question the section is actually for — so it carries the facts that decide it: what they owe,
   whether they have been back, and whether they ever really started. */
.settings-select-wide { max-width: none; }
.view-load { font-size: 11.5px; color: var(--ink-3); font-weight: 600; }
.view-load.err { color: var(--warn-ink, #8a5a00); font-weight: 500; }

.view-card {
  display: flex; gap: 0; margin: -6px 0 16px; overflow: hidden;
  border: 1px solid var(--line); border-radius: 8px; background: #fff;
}
/* STATE AS A RAIL, NOT AS A BADGE. A badge in the corner is something you have to go and look at;
   a 4px stripe down the edge is read before the text is, which is the whole job — this is scanned,
   not studied. Same four colours as the text tag on the right of the card's head row, so the two
   cannot say different things. */
.view-rail { flex: 0 0 4px; background: var(--ink-3); }
.view-rail[data-state="active"] { background: var(--green); }
.view-rail[data-state="stale"]  { background: var(--amber); }
.view-rail[data-state="empty"]  { background: var(--ink-3); }
.view-rail[data-state="never"]  { background: var(--line); }
.view-rail[data-state="sample"] { background: var(--blue); }
.view-body { flex: 1 1 auto; min-width: 0; padding: 10px 12px; }

.view-head { display: flex; align-items: center; gap: 9px; }
.view-avatar { width: 34px; height: 34px; border-radius: 50%; flex: 0 0 auto; object-fit: cover; background: var(--line-2); }
.view-avatar-fallback {
  display: grid; place-items: center; background: var(--blue-soft);
  color: var(--blue); font-weight: 800; font-size: 15px;
}
.view-id { flex: 1 1 auto; min-width: 0; }
.view-name { font-size: 13.5px; font-weight: 700; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.view-email { font-size: 11px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.view-state {
  flex: 0 0 auto; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
  padding: 2px 7px; border-radius: 999px; background: var(--line-2); color: var(--ink-3);
}
.view-state[data-state="active"] { background: var(--green-soft); color: #15803d; }
.view-state[data-state="stale"]  { background: var(--amber-soft); color: var(--warn-ink, #8a5a00); }

/* Their situation, in their own currency — the board behind this modal is already in it. */
.view-figs { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 9px 0 2px; }
.view-fig { font-size: 12px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
.view-fig b { font-size: 14px; color: var(--ink); }
.view-flat { font-size: 12px; color: var(--ink-3); font-style: italic; }

.view-meta { display: grid; grid-template-columns: auto 1fr; gap: 1px 10px; margin-top: 8px; font-size: 11px; }
.view-meta dt { color: var(--ink-3); }
.view-meta dd { color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
.warn-ink { color: var(--warn-ink, #8a5a00); font-weight: 600; }

.view-card.sample .view-sub { font-size: 11.5px; line-height: 1.45; color: var(--ink-3); margin-top: 3px; }

/* ── The "this is not your data" bar ──
   Borrows the confidentiality reminder's shape (top of body, full width, amber) because that is
   already this app's vocabulary for "a standing fact about the state you are in". Amber for a
   viewed user, blue for the sample: one of them is somebody's real money and the other is not, and
   that difference should be visible before the sentence is read. Not dismissible — see app.js. */
.viewing-bar {
  display: flex; align-items: center; gap: 12px; padding: 7px 14px;
  background: var(--amber-soft); border-bottom: 2px solid var(--amber);
  font-size: 12px; color: var(--ink-2);
}
.viewing-bar.sample { background: var(--blue-soft); border-bottom-color: var(--blue); }
.viewing-bar span { flex: 1; min-width: 0; }
.viewing-bar b { color: var(--ink); }
.viewing-bar .btn { flex: 0 0 auto; }
@media (max-width: 700px) {
  .viewing-bar { align-items: flex-start; flex-wrap: wrap; gap: 6px; font-size: 11.5px; }
}

/* ── Schedule modal — GTD spec structure, MVP palette ──
   Built once and shown/hidden by JS; sections below are always in the DOM. */
/* wider than the 460px it was: Repeat + interval + Due date now share a line, as does Ends */
/* 580 not 540: the header carries three fields now (name + amount + next payment date). */
.sched-modal { width: 580px; padding: 0; overflow: hidden; }
/* A loan states more terms than a recurrence does — the sub-tables need the room.
   680px → 620px (2026-09-12). MEASURED off a real flat-rate debt: the widest row in the form is a
   schedule row (mode select + value + "Changes on" date + four icons ≈ 420px), and the four
   panels were each giving their contents 610px. Every row therefore ended around two-thirds
   across and the rest was ragged emptiness down the right edge — the "so much white space".
   620px leaves ~110px of headroom over the measured content.
   An INDEXED rate row is the wide case ("Prime 6.75% + margin = 9.25% APR" plus a reprice button
   and an anchor date). `.sl-value` is flex-wrap:wrap, so that row wraps inside its own cell rather
   than breaking the layout — and it was already wrapping at 680px. */
.sched-modal.debt-modal { width: 620px; }
.sched-header { padding: 14px 22px 10px; border-bottom: 1px solid var(--line); }
.sched-eyebrow { font-size: 12px; font-weight: 700; color: var(--ink-2); margin-bottom: 8px; }
/* "Was a fixed expense — $1,200.00, Monthly on the 1st" (2026-09-05). It sits between the eyebrow
   and the fields it explains, because the question it answers is "why do these boxes already have
   numbers in them". Deliberately plain text rather than a tinted callout: .var-preview lower down
   is the tinted block on this form, and two of them would compete for the same glance. */
.sched-from { margin: -2px 0 10px; font-size: 12px; line-height: 1.45; color: var(--ink-2); }
.sched-from b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
.sched-record { display: flex; gap: 10px; align-items: flex-end; }
.sched-rec-field { flex: 1 1 auto; min-width: 0; }
/* Widened from 130/138px (2026-09-12). Those bases were set to the INPUTS' needs and ignored the
   LABELS above them: "Balance (on 9/12/2026)" and "Next payment date ⓘ" are both wider than the
   field they name, so each wrapped to two lines — and the ⓘ dropped onto a third — while the name
   field sat on 380px to hold "Store Card". The name is still the flexible one; these two now just
   ask for the width their own labels occupy. */
.sched-rec-amt { flex: 0 0 152px; }
/* The third header field. Fixed basis so the name keeps whatever is left rather than the date
   collapsing — a date input has a hard intrinsic width and won't shrink gracefully. */
.sched-rec-due { flex: 0 0 164px; }
.sched-rec-due .modal-input { width: 100%; }
.sched-rec-field.bad .modal-input, .sched-rec-field.bad .modal-money { border-color: var(--red); }
.sched-err { margin-top: 8px; font-size: 12px; color: var(--red); }
/* required-field marker on a label */
.req { color: var(--red); font-weight: 700; }
.sched-body { max-height: 60vh; overflow-y: auto; padding: 12px 22px 0; }

/* SENTENCE CASE, APP-WIDE (2026-09-12, owner: "make sure they're not all in caps"). Caps with
   letter-spacing made every field label read as a section heading — nine of them in the loan form
   alone — so a form of four panels looked like nine. First fixed inside .sched-modal only; widened
   the same day to every modal so the link editor, Settings and the rest stop disagreeing with it. */
.modal-label { display: block; font-size: 11px; font-weight: 600; color: var(--ink-3); margin-bottom: 6px; }
/* A panel's own heading outranks the column headings inside it, which caps was doing by accident
   and now has to do on purpose — otherwise "Rate" and "Mode" weigh the same. */
.sched-modal .sched-mode-block > .modal-label { font-size: 12.5px; font-weight: 700; color: var(--ink-2); }
.modal-field { margin-bottom: 14px; }
.modal-input { width: 100%; background: #fff; border: 1px solid var(--line); border-radius: 6px; color: var(--ink); padding: 8px 11px; font-size: 13px; font-family: inherit; transition: border-color .15s, box-shadow .15s; }
.modal-input:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
.modal-input.inline { width: auto; padding: 2px 8px; font-size: 12px; }
/* THE TYPED CONFIRMATION (confirmTypedAsync) - two paragraphs, an instruction, a field, then the
   buttons. `.modal p`'s 18px bottom margin is right for a dialog whose text ends at the buttons and
   far too much for one where three blocks run together, so the stack is tightened here and the 18px
   is spent once, under the input, where the gap actually belongs. */
.type-confirm .modal-p { margin-bottom: 10px; }
.type-confirm .modal-p p:last-child { margin-bottom: 0; }
.type-confirm .modal-input { margin-bottom: 18px; letter-spacing: .08em; }
/* THE BUTTON HAS TO LOOK REFUSED, not merely be refused. It is `disabled` until the word matches,
   and a full-strength red "Delete everything" that silently does nothing when pressed teaches the
   reader that the dialog is broken rather than that they have not typed the word yet. There is no
   app-wide .btn:disabled rule to inherit — the two that exist are scoped to icon buttons — so this
   one is written here, beside the only dialog that needs it. */
.type-confirm .btn:disabled { opacity: .4; cursor: not-allowed; }
/* Link editor: the URL field with its copy button parked on the right. The button is a fixed
   square so the field keeps every pixel the row doesn't need. */
.link-row { display: flex; align-items: stretch; gap: 6px; }
.link-row .modal-input { flex: 1 1 auto; min-width: 0; }
.link-copy { flex: 0 0 auto; width: 34px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink-3); }
.link-copy:hover { border-color: var(--blue); color: var(--blue); }
/* Ends-on / ends-after inputs: live (clickable) even when their radio isn't picked, just muted.
   Focusing one selects its radio (wireSchedule), so there's no "click the radio first" step. */
.modal-input.inactive { opacity: .5; }
.modal-input[type=date], .modal-input[type=number] { text-align: right; font-variant-numeric: tabular-nums; }
/* Modal variant of .money-box — same contract as .cell-money, taller to match .modal-input. */
.modal-money { background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 0 9px; transition: border-color .15s, box-shadow .15s; }
.modal-money:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
.modal-money > .cur { font-size: 12px; }
.modal-money > .modal-input { padding: 8px 0; }

.sched-preset-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 5px; }
.sched-preset-week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.sched-preset { padding: 9px 10px; border-radius: 8px; border: 1.5px solid var(--line); background: transparent; color: var(--ink-3); font-size: 12px; font-weight: 600; letter-spacing: .01em; line-height: 1.2; text-align: center; transition: background .12s, border-color .12s, color .12s, transform .1s; }
.sched-preset:hover { background: var(--blue-soft); border-color: var(--blue); color: var(--ink); transform: translateY(-1px); }
.sched-preset:active { transform: translateY(0); }
.sched-preset.active { background: var(--blue); border-color: var(--blue); color: #fff; }
.sched-week-btn { display: flex; flex-direction: column; align-items: center; gap: 1px; padding: 6px 2px; }
.sched-week-top { font-size: 8px; font-weight: 400; letter-spacing: .08em; opacity: .55; }
.sched-week-day { font-size: 12px; font-weight: 700; letter-spacing: .04em; }
.sched-preset.active .sched-week-top { opacity: 1; }

.sched-divider { border: none; border-top: 1px solid var(--line); margin: 0 0 16px; }
.sched-row { display: flex; align-items: center; gap: 8px; }
/* Repeat + "every N units" + Due date share one line; the pattern blocks fall below it. */
.sched-repeat-row { display: flex; align-items: flex-end; gap: 14px; flex-wrap: wrap; }
/* The Billing box pairs two groups of DIFFERENT heights (a select vs. a pair of checkboxes).
   Bottom-aligning them, as the row above does, dropped the "Additional terms" label a line below
   "Interest method" so the two headings didn't line up and the checkboxes looked like strays. */
.sched-terms-row { align-items: flex-start; gap: 28px; }
/* Detail fields only exist when their box is ticked, so the margin belongs to the fields — on the
   row it would reserve a gap under an empty container. */
.sched-mod-details { align-items: flex-start; gap: 28px; }
.sched-mod-details .sched-group { margin-top: 8px; }
.sched-group { display: flex; flex-direction: column; }
.sched-group > .modal-label { margin-bottom: 6px; }
/* Sized by content, never by leftover space. A select left at width:auto is already exactly
   as wide as its longest option ("Does not repeat") — which is the ask, and it re-measures on
   zoom for free. It used to be flex:1 1 auto over a width:100% basis, so it ate the whole
   modal to render the word "Daily". */
.modal-input.fit { width: auto; flex: 0 0 auto; }
.sched-interval { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.sched-text { font-size: 13px; color: var(--ink-3); white-space: nowrap; }
.sched-num { width: 58px; flex: 0 0 auto; }
/* .sched-after alone (0-1-0) LOST to .modal-input.inline's width:auto (0-2-0), so this field
   fell back to the browser's default number-input width — ~158px to type "10" into. Qualifying
   it with .modal-input takes the specificity to 0-2-0 and source order settles it. */
.modal-input.sched-after { width: 62px; }
.sched-hint { font-size: 11px; color: var(--ink-3); margin-top: 6px; }

.sched-shortcuts { display: flex; gap: 6px; margin-bottom: 8px; }
.sched-shortcut { padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: transparent; color: var(--ink-3); font-size: 12px; transition: background .12s, border-color .12s, color .12s; }
.sched-shortcut:hover { border-color: var(--blue); color: var(--ink); }
.sched-shortcut.active { background: var(--blue); border-color: var(--blue); color: #fff; }
.sched-pills { display: flex; gap: 6px; flex-wrap: wrap; }
.modal-field.bad .sched-pill { border-color: var(--red); }
.sched-pill { padding: 5px 11px; border-radius: 6px; border: 2px solid var(--line); background: transparent; color: var(--ink-3); font-size: 12px; font-weight: 500; transition: background .15s, border-color .15s, color .15s; }
.sched-pill:hover { border-color: var(--blue); color: var(--ink); }
.sched-pill.active { background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 600; }

.sched-radios { display: flex; flex-direction: column; gap: 6px; margin-top: 2px; }
/* Ends is three short choices — they belong on one line, not stacked three deep. */
.sched-radios.row { flex-direction: row; align-items: center; flex-wrap: wrap; gap: 6px 16px; }
.sched-radio { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--ink-2); }
.sched-radio input[type=radio] { accent-color: var(--blue); flex: 0 0 auto; }

.sched-footer { padding: 10px 22px 14px; display: flex; justify-content: flex-end; align-items: center; gap: 8px; }
.btn-clear { margin-right: auto; padding: 8px 16px; border-radius: 6px; border: 1px solid var(--line); background: transparent; color: var(--red); font-size: 13px; font-weight: 500; }
.btn-clear:hover { background: var(--red-soft); border-color: var(--red); }
.btn-ghost { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--line); background: transparent; color: var(--ink-3); font-size: 13px; font-weight: 500; }
.btn-ghost:hover { color: var(--ink); border-color: var(--blue); background: var(--blue-soft); }
.btn-primary { padding: 8px 20px; border-radius: 6px; border: 1px solid transparent; background: var(--blue); color: #fff; font-size: 13px; font-weight: 500; }
.btn-primary:hover { background: var(--blue-dark); }

/* ============================================================
   Round 2 — header Setup column, cog, info, draggable cards
   ============================================================ */
/* .topbar-actions was removed on 2026-09-06 — its four controls now sit in .rail-tools with the
   other three (see railHtml()). The disabled-button rule that lived here moved up beside that
   selector; nothing else it carried had anywhere left to apply. */
/* Where the data is: quiet grey while it is only a local save, green once the account has it, amber
   while it does not. `:empty` collapses the gap as well as the text — an empty span between flex
   items still costs its own `gap`, so an idle header would sit 6px further left than a busy one. */
.sync-state { font-size: 11px; font-weight: 600; color: var(--ink-3); white-space: nowrap; margin-right: 2px; }
.sync-state:empty { display: none; }
.sync-state.ok { color: var(--green); }
.sync-state.warn { color: var(--amber); }
.cog { width: 30px; height: 30px; border: 1px solid var(--line); background: #fff; border-radius: 8px; font-size: 15px; color: var(--ink-3); display: grid; place-items: center; }
.cog:hover { background: #f3f4f6; color: var(--ink); }
/* P% button — same chip as the gear, wider for its two glyphs. Opens the app-wide Prime schedule. */
.prime-btn { width: auto; padding: 0 8px; font-size: 12px; font-weight: 700; letter-spacing: .02em; }
/* Prime schedule modal: date left, rate right (right-justify rule for numbers/dates in tables). */
.prime-table { width: 100%; margin: 4px 0 10px; }
.prime-table th { font-size: 11px; font-weight: 600; color: var(--ink-3); text-align: left; padding-bottom: 4px; }
.prime-table th.right, .prime-table td.right { text-align: right; }
.prime-table td { padding: 3px 6px 3px 0; }
.prime-table .prime-date-input { text-align: right; }
/* Add-a-row and Pull-latest sit on one line under the table. */
.prime-tools { display: flex; gap: 8px; }
.prime-tools .btn-ghost.sm { font-size: 11.5px; padding: 4px 10px; }
.prime-tools .btn-ghost:disabled { opacity: .55; cursor: default; }
.prime-rate-input { font-variant-numeric: tabular-nums; }
/* Where the rates on screen came from. The `warn` state is the one that matters: it is the only
   visible sign that the shared table could not be reached and the app is pricing variable-rate
   loans off its built-in fallback copy. Styled to be noticed, not to be dismissed. */
.prime-source { font-size: 11.5px; line-height: 1.45; margin: 0 0 10px; padding: 7px 9px; border-radius: 6px; }
.prime-source.ok { color: var(--ink-3); background: var(--bg-2, rgba(127, 127, 127, .07)); }
.prime-source.warn { color: var(--warn-ink, #8a5a00); background: var(--warn-bg, rgba(214, 158, 46, .13)); border: 1px solid var(--warn-bd, rgba(214, 158, 46, .35)); }
/* The read-only table every non-admin sees. Dates and rates BOTH right-justified, per the app-wide
   rule — no inputs here, so the alignment has to be asked for on the cells directly. */
.prime-table.readonly td { padding: 4px 6px 4px 0; font-variant-numeric: tabular-nums; }
.prime-table.readonly td.right, .prime-table.readonly th.right { text-align: right; }
.prime-table.readonly th:first-child, .prime-table.readonly td:first-child { text-align: right; }

/* topbar bottom-edge resize handle (drag to grow/shrink the chart) */
/* CONTAINED, not overhanging (2026-09-05 on desktop; the phone did this in August for its own
   reason). It used to hang 5px below the header so it straddled the edge — which was invisible
   while a 18px board gap sat under it, and lands on top of the panel's title bar now that the
   board is flush against the header. It sits inside .topbar's own 14px bottom padding instead. */
.topbar-resize { position: absolute; left: 0; right: 0; bottom: 0; height: 11px; cursor: ns-resize; z-index: 31; display: flex; align-items: center; justify-content: center; }
.topbar-resize::after { content: ''; width: 46px; height: 4px; border-radius: 2px; background: var(--line); transition: background .12s; }
.topbar-resize:hover::after { background: var(--ink-3); }
/* the other one: vertical handle between the chart and the metrics panel (drag to widen it) */
.hdr-resize { flex: 0 0 auto; width: 9px; align-self: stretch; cursor: ew-resize; display: flex; align-items: center; justify-content: center; }
.hdr-resize::after { content: ''; width: 4px; height: 46px; border-radius: 2px; background: var(--line); transition: background .12s; }
.hdr-resize:hover::after { background: var(--ink-3); }

/* Setup is the KPI table's twin (see setupPanel): same bordered card, but the right column holds
   editable, borderless, right-justified fields so each row reads like a metric until clicked.
   A little top margin separates it from the KPI table stacked above it. */
.setup-table { margin-top: 8px; }
.setup-table .kpi-v { padding-top: 3px; padding-bottom: 3px; }
.setup-table .kpi-v:focus-within { background: var(--blue-soft); }
.setup-cell { border: none; background: transparent; text-align: right; font: inherit; font-size: 11px; font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; padding: 1px 0; max-width: 100%; }
.setup-cell:focus { outline: none; }
select.setup-cell { cursor: pointer; text-align: right; text-align-last: right; }
/* Borderless money box for Setup — the glyph still hugs the digits, just no frame around it. */
.money-box.setup-cell-money { border: none; background: transparent; min-width: 0; padding: 0; }
.setup-cell-money > .cur, .setup-cell-money.suffix::after { font-size: 11px; color: var(--ink-3); font-weight: 700; }

/* ── Accelerated Payoff Schedule, header column (user, 2026-08-03) ──
   The third table on .kpi-table, under Key Metrics and Setup, and the one that absorbs the slack.

   THREE CHILDREN, ONE BORDER (user, 2026-08-04). The box is now a flex COLUMN holding a fixed
   title, a scrolling list and a fixed total; it used to be a single scroller holding one table
   whose ends were pinned with position:sticky. The border, the radius and the clipping moved up
   here from .kpi-table so the three tables read as one bordered card instead of three stacked
   ones — which is why .payoff-mini below gives its own border back.

   HOW THE ROW COUNT SIZES ITSELF. `flex: 1 1 0` + `overflow-y: auto` on the MIDDLE child: it asks
   for NO height of its own and takes whatever the two tables above it and its own two siblings
   leave over, and the height it ends up with is the row count. Nothing counts rows anywhere — drag
   the chart taller and more appear.

   THE FLEX-BASIS OF 0 IS THE LOAD-BEARING PART, not `auto`. `.chart-panel`'s .zc-box is `flex: 1 1
   auto`, so the chart GROWS to whatever the right-hand column is tall (a deliberate rule — see the
   note there — so no dead space opens under the chart). That means this box's requested height
   feeds straight back into the header's height: at `auto` it asks for the whole payment list, and a
   plan with forty payments would stretch the header to a thousand pixels rather than scrolling. At
   0 it asks for nothing, the header's height is set by the chart and the two tables above, and this
   box scrolls inside whatever is left.

   THE FLOOR IS THREE ROWS, not one: header 31 + 3 × 24 + footer 24 ≈ 130. Measured, not guessed —
   at a bare 84px a long plan rendered as a single row with a scrollbar beside it, which is a
   peephole rather than a list. The floor only binds when the space genuinely is not there (a long
   plan under a short chart), and in that case it pushes the header down by ~46px rather than
   shipping a control nobody can read. */
.payoff-mini-box { flex: 1 1 0; min-height: 130px; display: flex; flex-direction: column;
  border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
  /* ...and a query container, so the # column can answer to THIS box's width rather than the
     viewport's. The box is resized by dragging .hdr-resize, which sets flex-basis directly and
     never re-renders, so a JS-side decision about which columns to emit would go stale mid-drag.
     A container query is re-evaluated by the browser on every frame of that drag for free. */
  container-type: inline-size; }
/* Only the middle child scrolls. The other two are `flex: none` so a long list can never squeeze
   the title or the total out of existence — the whole point of moving them outside the scroller. */
.payoff-mini-head, .payoff-mini-foot { flex: none; }
.payoff-mini-scroll { flex: 1 1 0; overflow-y: auto; }
/* THE GUTTER IS WHY THE TOTAL LINES UP. Three separate tables cannot see each other's widths; they
   share one colgroup (PAYOFF_COLS in app.js) under table-layout:fixed, which is enough only while
   all three are the same width. The moment the list is long enough to scroll, the middle child
   loses ~15px to a scrollbar and its amount column slides left of the total that sums it.
   `scrollbar-gutter: stable` on all three reserves that space unconditionally — on a scroll
   container it is reserved whether or not the content actually overflows, which is exactly the
   property needed here, so the fixed ends declare `overflow-y: auto` purely to become scroll
   containers and qualify for it. They have nothing to scroll. */
.payoff-mini-head, .payoff-mini-scroll, .payoff-mini-foot { scrollbar-gutter: stable; }
.payoff-mini-head, .payoff-mini-foot { overflow-y: auto; }
/* The border, radius and clipping live on .payoff-mini-box now (see above) — inherited from
   .kpi-table they would draw a full card around each of the three tables. */
.payoff-mini { border: none; border-radius: 0; }
/* table-layout stays `fixed` (inherited from .kpi-table). The three numeric columns are pinned to
   what they actually hold at 11px — a 3-digit #, m/d/yyyy and -$00,000.00 — and the name takes
   everything left over, which is what makes its ellipsis mean "genuinely too narrow" rather than
   "always". An earlier pass used table-layout:auto with `max-width: 0` on the name; that forced the
   column to its minimum at EVERY width, so "AMEX Credit Card" was clipped even in a 620px column.
   RE-MEASURED 2026-09-05, because the previous figures were ~1px from clipping and the owner was
   the one who found out. Measured at the real 11px tabular font with the real 6px+6px cell padding,
   text width first and padding added second — which is the step the old pass skipped when it
   re-derived 74px down to 70 after the padding changed:
     "382"           18 + 12 = 30   → pc-i 32   (was 26: three digits did not fit either)
     "12/28/2025"    57 + 12 = 69   → pc-d 78   (was 70, i.e. ONE pixel of headroom — it clipped
                                                 the moment a font rendered a hair wider, which is
                                                 what "the date is cut off" was)
     "+$500,347.40"  75 + 12 = 87   → pc-a 96   (88 left ONE pixel again, and this is the FOOTER
                                                 total, which is always the largest figure in the
                                                 column — a seven-figure plan clipped it outright)
     "$1,658,320.82" 67 + 12 = 79   → the Investments view's flexible last column, which gets the
                                      remainder and so is never the one that gives way.
   The three numeric columns are pinned; the DEBT NAME takes what is left and is the only column
   that ever ellipses, which is the whole contract (user: "the only one that should be cut off is
   the name of the debt"). Every figure above has ~9px of headroom now, not one. */
.payoff-mini .pc-i { width: 32px; }
.payoff-mini .pc-d { width: 78px; }
.payoff-mini .pc-a { width: 96px; }
/* The running balance in the Investments view. Right-justified like every other figure, and in the
   same blue as the amounts above it — it is the same money, counted a different way. */
.payoff-mini-cum { color: var(--invest-line); }
/* THE # IS THE COLUMN THAT YIELDS (user, 2026-08-04, and measured). The header column drags down to
   200px, which leaves the table 186px — and 26 + 70 + 88 is 184 of it, so with four columns the
   name collapses to two pixels and the list stops saying WHICH debt each payment is against. That
   is a worse list than one without a counter on it.
   Below 260px the counter folds to zero width instead, handing the name back its full 28px — the
   same room it had before the # column existed, so the narrow end is no worse than it ever was. The
   default column is 268px, so this only ever bites on a deliberate drag inward. `overflow: hidden`
   on .kpi-table td is what lets a 0-width cell simply vanish rather than push its digits out. */
@container (max-width: 260px) {
  .payoff-mini .pc-i { width: 0; }
  .payoff-mini td.payoff-mini-i { padding-left: 0; padding-right: 0; }
}
/* The title keeps .kpi-table's header fill; the total earns the same one, so the two ends read as a
   matched pair framing the list. No z-index and no opaque background needed any more — neither one
   overlaps a row now, which is the difference between structure and position:sticky. */
.payoff-mini tfoot td { background: var(--amber-soft); border-top: 1px solid var(--line); border-bottom: none; font-weight: 800; }
/* Tighter side padding than its two sibling tables: this one carries four columns in a 200px
   minimum where they carry two, and 6px buys back 24px across the row — the difference between the
   name column having somewhere to go and not. */
.payoff-mini td { padding-left: 6px; padding-right: 6px; }
/* Right-justified like every other number in the app, and muted: it is a counter, not a figure. */
.payoff-mini-i { text-align: right; color: var(--ink-3); }
.payoff-mini-d { white-space: nowrap; color: var(--ink-2); }
.payoff-mini-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The payment that retires a debt (user, 2026-08-04): date and name go green and bold, in place of
   the "paid off" pill this list carried on the board — a pill that in a 200px column was wider than
   the name beside it. Colour costs no width. The amount stays red; see the note at its call site. */
.payoff-mini td.payoff-kill { color: var(--green); font-weight: 800; }
/* A GOAL MET, on the investments view (2026-09-06). The payoff view's twin of this is a green cell
   in a row that is already there; this one is a row of its own, because that view has a name column
   to colour and this one has two figure columns and nowhere to put a name. See its call site.
   It reads as a MILESTONE, not a deposit: full width, green on a green wash, indented past the
   `#` column so the eye still runs down the deposit numbers uninterrupted. The tick and the target
   sit at the two ends, so a reader scanning the column of dates sees the band, not a wall of text.
   `white-space: normal` on purpose — this is the one row here allowed to wrap rather than ellipse,
   since a goal name that has been cut off is a milestone nobody can identify. */
.payoff-goal-row td.payoff-goal { padding: 5px 6px 5px 26px; background: var(--green-soft); color: var(--green); border-top: 1px solid var(--line); white-space: normal; line-height: 1.35; position: relative; }
.payoff-goal-tick { position: absolute; left: 8px; font-weight: 800; }
/* Right-justified like every figure in this app, and pushed to the far end of the row so it lines
   up with the Cumulative column it sits under rather than trailing the sentence. */
.payoff-goal-amt { float: right; font-weight: 700; margin-left: 8px; }
.payoff-mini-empty { white-space: normal; line-height: 1.45; padding: 7px 9px; color: var(--ink-3); }
/* The copy button rides in the header cell, which is centred text — pull it out to the right edge
   without turning the <th> into a flex container. */
.payoff-mini thead .icon-btn { float: right; margin: -2px -4px 0 0; }
/* Mobile column-header mode (payoffMini(true), see there): thead th is centred by default, which
   reads wrong for the # and Add'l Payment columns — they align under right-justified data below. */
.payoff-mini thead th.right { text-align: right; }

/* info icon — hover/focus shows a custom positioned popover (.tooltip-pop) */
/* text-transform:none — uppercase containers (asof label, table headers) were turning the i into I. */
.info { display: inline-grid; place-items: center; width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--ink-3); color: var(--ink-3); font-size: 9px; font-weight: 700; font-style: normal; text-transform: none; font-family: Georgia, 'Times New Roman', serif; cursor: default; vertical-align: middle; line-height: 1; user-select: none; }
.info:hover, .info:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }

.tooltip-pop { position: fixed; z-index: 95; max-width: 240px; background: var(--ink); color: #fff; font-size: 12px; line-height: 1.4; padding: 7px 10px; border-radius: 7px; box-shadow: var(--shadow); pointer-events: none; opacity: 0; transform: translateY(-2px); transition: opacity .1s, transform .1s; }
.tooltip-pop.show { opacity: 1; transform: none; }

/* THE BOARD IS THE REST OF THE SHELL (2026-09-05). It was a scrolling stack of content-width
   draggable cards until the rail made "one card at a time" the truth on desktop, and the phone's
   tab bar had already made it the truth there. So it is now one column holding one card, at both
   widths: it takes whatever height the header leaves and hands it to that card.

   `display: contents` on .board-row is what makes this work with renderBoard() untouched: it still
   emits rows of cards, but a row stops being a layout box, so the ONE visible panel becomes a
   direct flex child of .board. Without it every hidden row is still a flex item competing for
   height. .board-row has no background or border, so nothing is lost by dissolving it.

   No padding: the card is the board now, not something sitting on it. (The phone adds an 8px side
   cushion of its own — see rule 3 in its block — because a table flush against the glass puts the
   link icons and the Active checkbox out of thumb reach.) */
.board { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
         max-width: none; margin: 0; padding: 0; gap: 0; overflow: hidden; }
.board-row { display: contents; }
/* Full width, not content width. A card sized to its own table was the point when the board held
   several of them side by side; with one card filling the board, `width: max-content` only meant
   the totals bar underneath could never be wider than the columns it totals. The TABLE stays
   content-width (.grid-table below) — it is the card that fills the row.
   min-width:0 disables the flex automatic minimum size, or the item would refuse to shrink below
   the table's min-content width and the cap would do nothing. */
.panel { width: 100%; max-width: 100%; min-width: 0; flex: 0 0 auto; position: relative; }
/* WRAPS RATHER THAN SQUEEZES (2026-09-05). Chart Data's head carries a title, a copy button, the
   explainer toggle and eleven filter pills; the pills are `flex: 0 0 auto` and never give, so on a
   900px window the only thing that could yield was the button — which turned into a four-line
   sliver, and past that the last pills were clipped by .panel's overflow:hidden. A second line is
   the honest answer, and it is what the phone's head has always done. */
.panel-head { justify-content: flex-start; gap: 10px; flex-wrap: wrap; row-gap: 6px; }
.qa-legend-btn { white-space: nowrap; }
/* Wraps for the same reason .panel-head does (2026-09-05): eleven un-shrinkable filter pills are
   wider than a 900px card, and .panel is overflow:hidden — so what did not fit was not scrolled or
   stacked, it was simply gone, All and None included. */
.panel-actions { display: flex; gap: 6px; flex-wrap: wrap; row-gap: 6px; }
/* Collapse: hides every direct child but the head, so the card shrinks to just its title bar.
   The chevron itself lives in the head and is unaffected. */
.panel-collapse-btn { font-size: 11px; line-height: 1; }
.panel.collapsed > *:not(.panel-head) { display: none; }
/* THE ONE SCROLL OWNER (2026-09-05 on desktop; the phone's rule 4 since August). Both axes, and no
   height of its own: it takes whatever the shell has left after the header, the panel head and the
   totals bar, which is why there is no magic number here. Its horizontal scrollbar therefore renders
   at ITS bottom edge — directly above the totals — rather than at the bottom of a long page.
   `0 1 auto`, not `1 1 auto`: a short table must END where its rows end, with the board showing
   below it; only a table too tall for the window is squeezed, and that is when its totals land on
   the bottom edge of the screen. */
.grid-wrap { flex: 0 1 auto; min-height: 0; overflow: auto; }
.grid-table { width: auto; }
/* The column headings hold still while the rows scroll under them. This was a phone-only rule until
   the desktop table became a scroll box of its own; without it, row 400 of Chart Data has no
   headings and every figure in it is unlabelled. NO `background` here, deliberately: the phone's
   version set var(--card) and hoisting it whitened the header strip's own #fafbfc tint. A sticky
   element only needs SOME opaque background and the th already has one. */
.grid-table thead th { position: sticky; top: 0; z-index: 10; }
.empty-panel { width: 300px; }

/* Corner resize grip. It sits in the card's bottom padding strip, NOT over the last table row —
   the Active checkbox lives in that corner and the grip would otherwise eat its clicks. Width-only
   on every card (ew-resize); the Notes card is the one that also drags taller (nwse-resize). */
.panel { padding-bottom: 12px; }
/* 22×20 rather than 18×16: this corner is both the resize drag AND the double-click that refits the
   card, and a double-click is a far less forgiving gesture than a drag — the second click has to
   land on the same few pixels as the first. Still small enough to clear the Active checkbox that
   shares the corner. */
.panel-resize { position: absolute; right: 0; bottom: 0; width: 22px; height: 20px; cursor: ew-resize; z-index: 6; touch-action: none; }
.panel-resize.axis-xy { cursor: nwse-resize; }
.panel-resize::after { content: ''; position: absolute; right: 4px; bottom: 3px; width: 7px; height: 7px; border-right: 2px solid var(--line); border-bottom: 2px solid var(--line); border-bottom-right-radius: 2px; transition: border-color .12s; }
.panel-resize:hover::after { border-color: var(--blue); }
/* Once a card carries a dragged width its table can be wider than the card, and .panel is
   overflow:hidden — so the table gets its own horizontal scroll rather than being cut off. The
   bottom padding keeps an inline validation message under the last row off the scrollbar. */
.panel.sized .grid-wrap { overflow-x: auto; padding-bottom: 8px; }
/* THE POINT OF DRAGGING A CARD WIDER: the columns take the extra room. Without this the table
   stayed at `width:auto` (content width) and every pixel gained became dead space to the right of
   it. Auto layout — deliberately not `table-layout:fixed`, which would ignore content and hand
   every column an equal share, cramping the numeric columns to widen the name. Dragging NARROWER
   than the content still works: a width:100% table can't go below its min-content width, so the
   overflow-x above catches it and the row height never changes either way. */
.panel.sized .grid-table { width: 100%; }
/* The name column is the one that should absorb the slack — it's the only free text in the row,
   and the numeric columns are already exactly as wide as the figures they hold. */
.panel.sized .grid-table td:has(.cell-text) { width: 100%; }
.panel.sized .empty-panel { width: auto; }

/* (The [data-panel="payoff"] block lived here until 2026-08-03 — a 393px scroll cap, sticky
    head/foot, and a 52ch note width, all for a board card that no longer exists. The Accelerated
    Payoff Schedule is now .payoff-mini in the header column; see that block below.) */

/* drag handle + states */
.grip { cursor: grab; color: var(--ink-3); font-size: 14px; line-height: 1; padding: 0 2px; user-select: none; }
.grip:active { cursor: grabbing; }
.panel.dragging { opacity: .45; }
.panel.drop-left::before, .panel.drop-right::before, .panel.drop-above::before, .panel.drop-below::before { content: ''; position: absolute; background: var(--blue); z-index: 5; border-radius: 2px; }
.panel.drop-left::before { left: 0; top: 0; bottom: 0; width: 4px; }
.panel.drop-right::before { right: 0; top: 0; bottom: 0; width: 4px; }
.panel.drop-above::before { left: 0; right: 0; top: 0; height: 4px; }
.panel.drop-below::before { left: 0; right: 0; bottom: 0; height: 4px; }

/* debt payoff-order rank + row drag grip */
.payoff-rank { font-weight: 700; color: var(--ink-3); white-space: nowrap; }
/* touch-action: none hands US the gesture instead of scrolling the page — without it a finger drag
   on the grip scrolls and the row never moves. Confined to the grip itself, so dragging from any
   other cell still scrolls normally. Same trick .panel-resize already uses. */
.row-grip { cursor: grab; color: var(--ink-3); font-size: 12px; margin-right: 3px; user-select: none; touch-action: none; display: inline-block; }
.row-grip:active { cursor: grabbing; }
/* Row drag states apply to every ordered grid (debts, income, expenses), not just debts. */
tr[data-row-coll].row-dragging { opacity: .45; }
tr[data-row-coll].row-drop-above { box-shadow: inset 0 2px 0 var(--blue); }
tr[data-row-coll].row-drop-below { box-shadow: inset 0 -2px 0 var(--blue); }

/* (`.grid-table tbody tr.pays-off td` — a green hairline under the retiring payment — and
   `.paid-off-tag` — the green "PAID OFF" pill beside its name — lived here until 2026-08-04. Both
   dressed the Accelerated Payoff Schedule while it was a board card on .grid-table. That card was
   retired on 2026-08-03 and nothing had emitted either rule since; they were dead, not merely
   unused. The marker they stood for is back as `.payoff-mini td.payoff-kill` in the header column's
   block above — green and bold on the date and the name, which is the form that fits 200px.) */

/* editing-state visuals — strong border stays until the record is saved or cancelled */
.panel.editing { border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue), 0 0 0 5px var(--blue-soft); }
.grid-table tbody tr.row-editing td { background: var(--blue-soft); }

/* ---------- THE DEBT THAT NEVER PAYS OFF (user, 2026-09-05) ----------
   A minimum that never exceeds its own interest makes the balance grow forever, which is what puts
   ∞ in Key Metrics' Time Saved and Interest Saved. The card used to give no sign of it: the row
   looked exactly like a debt clearing next spring, so the ∞ two feet away read as a broken figure
   rather than as a finding about a specific loan.

   A WASH, NOT A FLAT FILL. `--red-soft` (#fee2e2) at full strength reads as a SELECTED row — it is
   the same weight as .row-editing's blue — and this is a standing condition, not a selection. 7%
   red is enough to pick the row out while scrolling and still let the zebra stripe and the text
   through. The alarm colour proper stays where it already lives: the asterisk and the Dead Broke
   marker.

   DECLARED AFTER THE ZEBRA RULE, and that is the load-bearing part: `tbody tr:nth-child(even) td`
   and this selector have the same specificity, so on the stripe rows the later one wins and on the
   others it is the only one — which is why the tint is even, and why moving this block above the
   stripe would tint exactly half the row set. */
.grid-table tbody tr.debt-perpetual td { background: rgba(220, 38, 38, .07); }
/* Hover still has to answer, or the row reads as disabled rather than flagged. */
.grid-table tbody tr.debt-perpetual:hover td { background: rgba(220, 38, 38, .13); }
/* An inactive debt is excluded from every computation, so it has no payoff finding to report and
   never carries the tint — but if the two ever met, "excluded" is the stronger statement. */
.grid-table tbody tr.debt-perpetual.inactive td { background: transparent; }
/* The asterisk rides in the RANK cell, beside the number (user, 2026-09-05). It spent one day in
   the name cell, which needed a flex on .cell-wrap to keep it level with the input — machinery for
   a mark that has nothing to do with the field it was sitting in, and it read as annotating the
   text you were typing. Beside the row number it annotates the ROW, which is what a footnote marker
   does. Those two .cell-wrap rules are gone with it; nothing else wanted them. */
.debt-star { margin-left: 2px; cursor: default; }
/* (No rule for td.pmts-left. It needs right-alignment and tabular figures, and .grid-table already
   sets both — the `right` class on the cell and font-variant-numeric on the table. The class stays
   on the <td> as a hook, unstyled on purpose rather than carrying a redeclaration that would look
   load-bearing to the next reader.) */

/* Round 4 — draft edit-lock: save/cancel buttons, validation shake + inline error */
.icon-btn.sm.ok { color: var(--green); }
.icon-btn.sm.ok:hover { background: var(--green-soft); }
.has-error { position: relative; }
.has-error .cell, .has-error .cell-money { border-color: var(--red); }
.field-err-msg { position: absolute; left: 0; top: 100%; font-size: 10px; color: var(--red); background: var(--card); padding: 1px 4px; border-radius: 4px; white-space: nowrap; z-index: 3; }
@keyframes shake { 10%, 90% { transform: translateX(-1px); } 20%, 80% { transform: translateX(2px); } 30%, 50%, 70% { transform: translateX(-4px); } 40%, 60% { transform: translateX(4px); } }
.shake { animation: shake .4s; }

/* ── Loan-terms sub-tables ──
   `.adv-table` is all that outlived the Advanced modal: the Debt modal's APR / min-payment /
   deferral lists still use it as their base (see `.sl-table` below for the overrides). The rest
   of the `.adv-*` / `.amort-*` rules went with the modal and viewer they styled. */
.adv-table { border-collapse: collapse; font-size: 12.5px; margin-bottom: 6px; }

/* ── Payment-stream viewer ── */
/* Width hugs the table (max-content) rather than a fixed 720px, so the modal is only as wide
   as its data; capped so a long recurrence description can't blow it out. */
.stream-modal { width: max-content; max-width: min(92vw, 640px); min-width: 380px; padding: 18px 20px; }
.stream-summary { display: grid; grid-template-columns: auto auto; gap: 4px 22px; margin: 8px 0 14px; padding: 12px 14px; background: var(--bg-2, #f6f7f9); border-radius: 10px; font-size: 12.5px; }
.stream-summary > div { display: contents; }
.stream-summary dt { color: var(--ink-3); }
.stream-summary dd { font-weight: 600; }
.stream-summary dd.num { font-variant-numeric: tabular-nums; }
.stream-summary .right { text-align: right; }
.stream-summary.neg dd.num { color: var(--red); }
/* ~10 rows before scrolling, never the full page height. */
.stream-scroll { max-height: 340px; overflow: auto; border: 1px solid var(--line, #d8dce3); border-radius: 8px; }
.stream-table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.stream-table th { position: sticky; top: 0; background: #fff; text-align: left; font-weight: 500; color: var(--ink-3); font-size: 11.5px; padding: 5px 12px; border-bottom: 1px solid var(--line, #d8dce3); }
.stream-table td { padding: 3px 12px; }
.stream-table td.right, .stream-table th.right { text-align: right; }
.stream-table tbody tr:nth-child(even) { background: var(--bg-2, #f6f7f9); }
.stream-table td.neg { color: var(--red); }
/* A debt stream carries eight columns, not four — it needs more than a content-fit width. */
.stream-modal.stream-wide { max-width: min(96vw, 900px); }
.stream-table tr.muted td { color: var(--ink-3); }
.stream-table tr.stream-balloon td { font-weight: 600; }
/* The accelerated half of the stream: one rule marks where the minimum-only schedule ends and the
   accelerated one begins, and the headers carry the crimson of the line they belong to. */
.stream-table th.stream-sep, .stream-table td.stream-sep { border-left: 2px solid var(--line, #d8dce3); }
.stream-table th.stream-accel { color: var(--debt-line-accel); }
.stream-table .qa-zero { color: var(--ink-3); opacity: .45; }
.stream-tag { font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-3); background: var(--line-2); border-radius: 4px; padding: 1px 4px; margin-left: 4px; }
.stream-never { color: var(--red); font-weight: 600; }

/* ── Debt modal: the two mode-per-row schedules (rate + minimum payment) ──
   Each is a record list, always visible — one component rendered twice. The box groups a schedule
   with its own add button so the loan form reads as a few labelled panels rather than a wall of
   fields. `.sched-mode-block` is also what the Loan-terms and Recurrence boxes use. */
.sched-mode-block { border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; }
.sched-mode-block.bad { border-color: var(--red); }
/* Sub-sections nested inside a box (the recurrence patterns under Payment Frequency). The last one
   loses its margin so the box doesn't carry dead space at the bottom. */
.sched-sub { margin-top: 8px; }
.sched-mode-block > .modal-field:last-child, .debt-modal .sched-mode-block > .modal-field:last-child { margin-bottom: 0; }
/* The row's value cell. "Prime 6.75% + [margin] = 9.25% APR, reprice Monthly" reads left to right
   as a sentence, which is the whole reason the parts are inline rather than stacked in a grid.
   NOTE the flex lives on a DIV inside the <td>, not on the <td>. `display:flex` on a table cell
   takes it out of table layout: it stops sharing the row's height, stops vertically aligning with
   its siblings, and its contents drift off the row — which is exactly how the APR ended up
   hovering above its own Mode dropdown. Same mistake, same fix, as `td.act` in the grids. */
.sl-value { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 13px; }
.sl-value b { font-variant-numeric: tabular-nums; color: var(--ink); }
.sl-value .sched-text { color: var(--ink-2); white-space: nowrap; }
.sl-value .sched-hint.inline { margin-top: 0; }
/* Money fits 999,999.99 with its $; percent fits ##.#### with its %. Both are min-widths — the
   input inside is content-sized (see .money-box), so the glyph still hugs the digits. */
.sl-money { min-width: 11ch; }
.sl-money.sl-pct { min-width: 8ch; }
.sl-money > input { font-size: 12.5px; }
/* The reprice anchor sits inside the value sentence, not in the boundary column — it is this row's
   own "last reset to Prime" date, not the date the row stops applying. */
.sl-anchor { flex: 0 0 auto; }
.sl-reprice { padding: 2px 7px; font-size: 11.5px; }
/* The final row's boundary cell: there is no end date, and saying so beats an empty cell that
   reads as a field someone forgot to fill. */
.sl-open-end { color: var(--ink-3); cursor: default; }
/* Mode is the leftmost column and decides what the rest of the row contains, so it never wraps
   away from its info icon. */
.sl-mode { white-space: nowrap; }
.sl-mode .info { margin-left: 2px; }
/* Row actions: four small buttons on ONE line. `.icon-btn` is `display:grid`, i.e. block-level —
   without a flex row to lay them out they each claimed their own line and the cell became a
   vertical stack of icons down the right edge of the box. */
.sl-acts { white-space: nowrap; }
.sl-acts-in { display: flex; align-items: center; gap: 1px; justify-content: flex-end; }
.sl-acts .icon-btn.sm { width: 20px; height: 20px; font-size: 10px; background: transparent; color: var(--ink-3); }
.sl-acts .icon-btn.sm:hover:not(:disabled) { background: #eef1f5; color: var(--ink); }
.sl-acts .icon-btn.sm.danger:hover:not(:disabled) { background: var(--red-soft); color: var(--red); }
.sl-acts .icon-btn.sm:disabled { opacity: .25; cursor: default; }
/* Cadence picker: the shared recurrence modal, opened over the loan form for one question. */
.cadence-modal { max-width: 460px; }
.cadence-modal .modal-p { margin-bottom: 4px; }
/* CONTENT WIDTH, NOT FULL WIDTH. Stretching this table to 100% of the box spread three or four
   short controls into opposite corners with acres between them — the fields stopped reading as one
   row and started reading as scattered pieces. Sized to its content, a row is a phrase you read
   left to right. */
/* ADD BUTTON BESIDE ROW 1, NOT BELOW THE TABLE (2026-09-12). It used to sit under the table on a
   line of its own, costing ~28px of height per schedule and leaving the panel's whole right-hand
   half empty. As a flex row the button takes that empty space instead, and the two schedules stop
   each carrying an orphan line. `align-items: flex-start` keeps it pinned to the top when a
   schedule grows to several rows, so it stays beside the FIRST record rather than drifting down. */
.sl-group { display: flex; align-items: flex-start; gap: 10px; }
.sl-group .sl-table { width: auto; margin-top: 2px; }
/* Column gap cut 18px → 12px (2026-09-12). 18px on four columns put a visible void between the
   "Changes on" date and the four row icons, so the icons read as a detached cluster floating in
   the panel rather than as the end of the row they act on. */
.sl-table th { text-align: left; font-weight: 500; color: var(--ink-3); font-size: 11px; padding: 0 12px 3px 0; white-space: nowrap; }
.sl-table th:last-child, .sl-table td:last-child { padding-right: 0; }
.sl-table td { padding: 3px 12px 3px 0; vertical-align: middle; white-space: nowrap; }
.sl-table input { font: inherit; font-size: 12.5px; padding: 3px 6px; border: 1px solid var(--line); border-radius: 6px; width: 100%; }
/* THE MODE SELECT SET THE ROW HEIGHT (2026-09-12). It carries `class="modal-input fit"`, so it was
   taking .modal-input's 8px/11px padding at 13px — ~35px tall — while every input beside it in the
   same row was ~26px from the rule above. The tallest cell wins, so all four schedule rows were
   9px taller than their contents needed. Matched to the inputs it sits with. */
.sl-table select { font-size: 12.5px; padding: 3px 6px; }
/* A BORDER, BECAUSE IT IS A FIELD (2026-09-12). `.money-box` carries no border and its input is
   explicitly `border: none` — right in the grids, where the money box IS the cell. In a schedule
   row it sits beside a date input that does get a border from `.sl-table input`, so the APR and
   Min payment read as fixed text while the date beside them read as editable. Same box, same
   border, same radius as its neighbour. */
.sl-table .money-box { border: 1px solid var(--line); border-radius: 6px; padding: 0 6px; background: #fff; }
.sl-table .money-box:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
.sl-table input.num { text-align: right; font-variant-numeric: tabular-nums; }
/* An input INSIDE a money box is not a field of its own — the box is the field, and the input is
   sized to its own text so the $ / % ends up against the digits. `.sl-table input` above ties with
   `.money-box > input` on specificity (0-1-1 each) and wins on source order, which stretched these
   to 100% and pushed the glyphs clean outside the border, each input carrying a second border of
   its own. Qualifying with the ancestor (0-2-1) settles it. */
.sl-table .money-box > input {
  width: auto; field-sizing: content; min-width: 1ch;
  border: none; background: transparent; padding: 4px 0;
}
/* STANDING RULE: every number and date in every table is right-justified, app-wide.
   Dates size to their content — a date input stretched to a column's full width was the
   "way too wide" complaint. 8.4em fits MM/DD/YYYY plus the picker icon. */
.sl-table input[type=date] { width: 8.4em; text-align: right; font-variant-numeric: tabular-nums; }
.modal-input.date-fit { width: 8.4em; }
/* Money fields fit 7 figures (9,999,999.99 ≈ 12ch) — matches .money-box's 14ch contract. */
.modal-input.sched-num.money-mask { width: 110px; }
/* Tighter vertical rhythm: a loan form states many terms; 14px gaps between every field made
   it scroll for no reason. */
.sched-modal .modal-field { margin-bottom: 8px; }
/* PAYMENT FREQUENCY FIRST (2026-09-12, owner request). Reordered with flex `order` rather than by
   moving markup, so renderSchedule, wireSchedule and syncSchedule are untouched. Debt modal only:
   the other record modals have presets and a divider in the body that must keep their order. */
.debt-modal .sched-body { display: flex; flex-direction: column; }
.debt-modal #block-recurrence { order: -1; }
/* Beside row 1: the offset clears the column-heading row so the button lines up with the first
   RECORD, not with the headings. nowrap so "+ add a payment change" never breaks in two. */
.sl-group .btn-ghost.sm { font-size: 11.5px; padding: 3px 8px; margin-top: 21px; white-space: nowrap; flex: 0 0 auto; }
/* Grid cell stand-in for a value with no single number to type — a formula, or one that changes
   on a date. It is a BUTTON, not dead text: it shows what's in force on the As-Of date and opens
   that schedule in the loan form. Right-justified like every other number (standing rule). */
.muted-cell { color: var(--ink-3); font-style: italic; font-size: 12px; }
.grid-table td:has(.sched-open-cell) { text-align: right; }
.sched-open-cell { background: none; border: 1px solid transparent; border-radius: 6px; padding: 3px 5px; font: inherit; font-size: 12.5px; color: var(--ink-2); font-variant-numeric: tabular-nums; text-align: right; border-bottom-style: dashed; border-bottom-color: var(--line); }
.sched-open-cell:hover { background: var(--blue-soft); border-color: var(--blue); color: var(--blue-dark); }
/* Totals bar pinned below the scroll, not inside it. */
.stream-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; padding-top: 8px; border-top: 2px solid var(--line, #d8dce3); font-weight: 600; font-size: 13px; }
.stream-foot-total { font-variant-numeric: tabular-nums; }
.stream-foot-total.neg { color: var(--red); }

/* ── Chart Data — the chart's raw daily inputs, one row per day, one column per record ──
   Everything right-justified (standing rule); money out red; zero-days muted so real
   payments pop out of the grid. */
/* Scrolls in BOTH directions: the card is capped to the board's width (see .panel), so this box is
   what the extra columns scroll inside. The pinned Date column and sticky header row below both
   anchor to this box, so they keep working as it scrolls.
   NO MORE 400px CAP (2026-09-05). That number was a stand-in for a bounded card, on a page that
   scrolled: in a 900px window it left the app's densest table showing fourteen rows with the
   explanation paragraphs and half a screen of nothing under it. The shell bounds the card now, so
   the box takes what is left and the virtualizer — which reads this box's height at runtime — puts
   as many rows in it as fit. */
.qa-main { flex: 0 1 auto; min-height: 0; display: flex; flex-direction: column; position: relative; }
.qa-scroll { flex: 0 1 auto; min-height: 0; overflow: auto; border-top: 1px solid var(--line); }
/* THE COLUMN EXPLAINER, over the table it explains (user, 2026-09-05 — it used to be six paragraphs
   in the footer). Absolute inside .qa-main, so it covers exactly the table area: the title bar with
   its own toggle stays visible above, and the count bar stays visible below, which is what makes it
   read as a sheet laid over the data rather than a different screen. */
.qa-legend { position: absolute; inset: 0; z-index: 12; overflow: auto;
             background: var(--card); border-top: 1px solid var(--line); }
.qa-legend-head { position: sticky; top: 0; display: flex; align-items: center; justify-content: space-between;
                  gap: 10px; padding: 8px 12px; background: var(--bg-2, #f6f7f9);
                  border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 700; color: var(--ink-2); }
/* Reference prose, so it gets a reading measure and real line spacing — it was 11px grey run the
   full width of the card, which at 1,800px is a line nobody finishes. */
.qa-legend-body { max-width: 78ch; padding: 12px 14px; font-size: 12px; line-height: 1.6; color: var(--ink-2); }
.qa-legend-body b { color: var(--ink); }
.qa-legend-body br { display: block; content: ''; margin-top: 9px; }
/* table-layout:fixed is deliberate HERE and nowhere else. The panel grids stay auto (see the note
   by .panel.sized .grid-table) because their columns hold text of unpredictable width. Every cell in
   THIS table is a right-aligned money figure, and its widths are computed from the header — so fixed
   layout costs nothing in fidelity and buys two things that matter: the browser stops measuring
   ~179,000 cells to size the columns, and the columns cannot re-measure as virtualized rows scroll
   through, which would otherwise make the whole table shimmer. */
.qa-table { border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; table-layout: fixed; }
/* Fixed row height is what lets a row index map to a pixel offset by multiplication — the spacer
   heights, the scroll position and the visible window all depend on this number matching QA_ROW_H. */
.qa-table tbody tr { height: 22px; }
.qa-table tbody tr.qa-spacer { background: none; }
.qa-table td { overflow: hidden; text-overflow: ellipsis; }
/* THREE STICKY HEADER ROWS. Each row pins at its own offset, and those offsets are literal pixel
   sums — so the header cell height is fixed here and the three tops are 0 / 1× / 2× that height.
   A cell that spans rows (rowspan 3 on the left block, rowspan 2 on Cash Flow and Excess) belongs to
   the row it is declared in, so it pins correctly from the same rules. */
.qa-table th { position: sticky; z-index: 2; background: #fafbfc; text-align: center; font-weight: 600; color: var(--ink-2, var(--ink)); font-size: 11.5px; padding: 3px 10px; height: 24px; border-bottom: 1px solid var(--line); }
.qa-table thead tr:nth-child(1) th { top: 0; }
.qa-table thead tr:nth-child(2) th { top: 24px; }
.qa-table thead tr:nth-child(3) th { top: 48px; }
.qa-table thead tr:nth-child(3) th { border-bottom: 2px solid var(--line); }
/* Group headers: the two spanning rows read as structure, not as more column names — heavier and
   larger for row 1, quieter for row 2, so the eye goes Payments → Income → [1] Dolce. Row 1 was
   caps and letterspaced until 2026-09-12; sentence case now like every other heading, with size and
   the darker fill carrying the rank instead. */
.qa-table th.qa-g1 { font-size: 12px; font-weight: 700; color: var(--ink-2); background: #eef1f6; }
.qa-table th.qa-g2 { font-size: 11px; font-weight: 700; color: var(--ink-2); background: #f4f6fa; }
.qa-table td { padding: 2px 12px; text-align: right; }
/* Striping is keyed off a class baked into each row, not :nth-child — with only a window of rows in
   the DOM, nth-child counts spacers and window position, so the stripes would crawl as you scroll. */
.qa-table tbody tr.qa-r1 { background: var(--bg-2, #f6f7f9); }
/* Row half of the crosshair. The pinned Date cell carries its own background, so it is named
   explicitly or the row highlight would stop dead at the frozen column. Spacer rows stand in for
   thousands of rows that are not there — highlighting one would be highlighting nothing. */
.qa-table tbody tr:hover:not(.qa-spacer) td,
.qa-table tbody tr:hover:not(.qa-spacer) td.qa-date { background: var(--hl-row); }
.qa-table td.neg { color: var(--red); }
.qa-table .qa-zero { color: var(--ink-3); opacity: .45; }
/* Date column pinned on the left so horizontal scroll never loses the row's anchor. */
.qa-table th.qa-date, .qa-table td.qa-date { position: sticky; left: 0; background: #fafbfc; z-index: 1; }
/* The Date header is frozen on BOTH axes at once, so it has to outrank every other header cell or
   the group rows would slide underneath it and then over it as the table scrolls sideways. */
.qa-table th.qa-date { z-index: 6; }
.qa-table tbody tr.qa-r0 td.qa-date { background: #fff; }
.qa-kind { display: block; font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-3); }
.qa-table th.qa-debts .qa-kind, .qa-table th.qa-expenses .qa-kind { color: var(--red); }
.qa-table th.qa-income .qa-kind { color: var(--green); }
/* The accelerated block carries the crimson of the line it audits, so a column and the stroke it
   explains are found by the same colour. */
.qa-table th.qa-accel .qa-kind { color: var(--debt-line-accel); }
/* A schedule sub-column repeats its debt's name so the block stays readable after horizontal
   scrolling has pushed the first column away — dimmed, because the repeat is orientation, not news. */
.qa-table th.qa-sub .qa-kind { opacity: .5; }
/* (.qa-foot is gone as of 2026-09-05: its count line is now this card's .grid-foot bar, and its
   six paragraphs are the .qa-legend overlay above. The bar is quieter than the other cards' totals
   because it counts rows rather than money — same bar, same place, not shouting a figure it isn't.) */
.grid-foot.c-qa { font-weight: 600; font-size: 11.5px; color: var(--ink-3); text-align: left; }
/* Chart Data: liquid-balance topline + computed-totals columns (separated by a left rule) */
.qa-topline { flex: 0 0 auto; font-size: 12px; color: var(--ink-2, var(--ink)); padding: 6px 10px; font-variant-numeric: tabular-nums; }
.qa-table th.qa-tot .qa-kind { color: var(--blue); }
.qa-table th.qa-sep, .qa-table td.qa-sep { border-left: 1px solid var(--line); }
/* pressed state for header toggle buttons (Hide Inactive) */
.btn.ghost.on { background: var(--blue); color: #fff; border-color: var(--blue); }
.btn.ghost.on:hover { background: var(--blue); }

/* ── SIGN-IN GATE + confidentiality agreement (2026-08-04) ──────────────────────────────────────
   Full-viewport, and the app is not merely hidden behind it — it has not been rendered at all
   (see gate.js). So this needs no z-index war with anything: while a gate screen is on screen,
   #app is genuinely empty. */
.gate { position: fixed; inset: 0; display: grid; place-items: center; padding: 24px;
  background: var(--bg); overflow-y: auto; }
.gate-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: var(--shadow); padding: 28px 30px; width: 100%; max-width: 400px; text-align: center; }
/* The agreement needs room to be READ, not just acknowledged — a legal document in a 400px column
   is a document designed not to be read. */
.gate-card-wide { max-width: 620px; text-align: left; }
.gate-brand { display: flex; align-items: center; justify-content: center; gap: 9px; margin-bottom: 18px; }
.gate-title { margin: 0 0 4px; font-size: 18px; color: var(--ink); }
.gate-lede { margin: 0 0 18px; color: var(--ink-2); font-size: 13px; }
/* Same amber used by .nda-reminder — this is a standing notice, not an error, so it does not
   borrow .gate-err's red. It exists to make one thing obvious: this is not your first time here,
   the words changed and that's why you're being asked again. */
.gate-changed-note { margin: 0 0 14px; padding: 8px 12px; background: var(--amber-soft);
  border: 1px solid var(--amber); border-radius: var(--radius); color: var(--ink); font-size: 12.5px;
  line-height: 1.5; }
.gate-err { margin: 12px 0 0; color: var(--red); font-size: 12px; line-height: 1.5; }
.gate-fine { margin: 16px 0 0; color: var(--ink-3); font-size: 11px; line-height: 1.5; }
/* Google's brand guidelines: their mark on a white button, never recoloured. */
.gate-google { display: inline-flex; align-items: center; justify-content: center; gap: 9px; width: 100%;
  background: #fff; color: var(--ink-2); border: 1px solid var(--line); font-weight: 600; padding: 10px 16px; }
.gate-google:hover { background: #f8f9fa; border-color: var(--ink-3); }

/* THE SCROLL BOX IS THE POINT. A fixed height with the text scrolling inside it is what makes the
   document readable and the Agree button reachable without a 3,000px page — and it keeps the two
   controls (tick, then button) permanently in view beneath it, so consent is never off-screen. */
.gate-doc { max-height: 46vh; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px;
  padding: 16px 18px; background: #fcfcfd; font-size: 12.5px; line-height: 1.6; color: var(--ink-2); }
.gate-doc h1, .gate-doc h2, .gate-doc h3 { color: var(--ink); margin: 16px 0 6px; line-height: 1.3; }
.gate-doc h1 { font-size: 16px; } .gate-doc h2 { font-size: 14px; } .gate-doc h3 { font-size: 13px; }
.gate-doc h1:first-child, .gate-doc h2:first-child { margin-top: 0; }
.gate-doc p { margin: 0 0 10px; }
.gate-doc ul { margin: 0 0 10px; padding-left: 20px; }
.gate-doc li { margin-bottom: 4px; }
.gate-doc hr { border: none; border-top: 1px solid var(--line); margin: 14px 0; }
.gate-doc strong { color: var(--ink); }

.gate-agree { display: flex; align-items: flex-start; gap: 9px; margin: 16px 0 0;
  font-size: 12.5px; color: var(--ink-2); cursor: pointer; line-height: 1.5; }
.gate-agree input { margin-top: 2px; flex: none; }
.gate-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }

/* The standing reminder, after they are in. First element in the body, so it pushes the pinned
   header down rather than covering it. */
.nda-reminder { display: flex; align-items: center; gap: 10px; padding: 7px 14px;
  background: var(--amber-soft); border-bottom: 1px solid var(--amber);
  font-size: 12px; color: var(--ink-2); }
.nda-reminder span { flex: 1; }
.nda-reminder .linkish { background: none; border: none; padding: 0; cursor: pointer;
  color: var(--blue); font-size: 12px; font-weight: 600; text-decoration: underline; }
/* Wider than the 420px .modal default, and it needs the `width` as well as the `max-width` — a
   max-width alone never widened anything, so this box had been sitting at 420px for a document with
   17 numbered sections in it. */
.nda-modal { width: 640px; max-width: 100%; }
.nda-loading { color: var(--ink-3); font-size: 12.5px; margin: 6px 0 0; }

/* ── THE ACCEPTANCE RECORD (2026-08-22) ──
   The block above the agreement text, and the reason this modal is a record rather than a viewer.
   Everything in it was already in nda_acceptances and shown nowhere: who is bound, when they agreed,
   and which version is on screen. The download belongs HERE, beside those three facts, rather than
   next to Close — what you are keeping is the record, not the page.
   No new colours: the same --line / --ink-3 / .btn.primary the rest of the app is built from. */
.nda-record { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap;
  border: 1px solid var(--line); border-radius: 8px; padding: 11px 13px; margin: 4px 0 12px;
  background: #fcfcfd; }
/* Two columns, label then value, so the three facts read as a column of answers rather than a
   sentence. Right-aligned labels would fight the app's number rule; these are words, not figures. */
.nda-record-facts { flex: 1 1 220px; display: grid; grid-template-columns: auto minmax(0, 1fr);
  gap: 4px 14px; margin: 0; min-width: 0; align-items: baseline; }
.nda-record-facts dt { font-size: 10px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase;
  color: var(--ink-3); white-space: nowrap; }
.nda-record-facts dd { margin: 0; font-size: 12.5px; color: var(--ink); overflow-wrap: anywhere; }
/* The fingerprint is machine text and is read character by character when it is read at all, so it
   gets a monospace face and the dimmer ink — it is evidence, not a headline. */
.nda-record-facts dd.fingerprint { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 11.5px; color: var(--ink-2); letter-spacing: .02em; }
.nda-dl { flex: 0 0 auto; }
@media (max-width: 700px) {
  /* Stacked, and the button goes full width — a 100px button floated beside a wrapped email is the
     one arrangement that reads as an afterthought. */
  .nda-record { align-items: stretch; }
  .nda-dl { width: 100%; }
}

/* ============================================================
   MOBILE  (user, 2026-08-04)

   Nothing here is a phone-specific redesign — it is the same board, told to stop assuming a
   desktop's width. Every rule below removes an assumption rather than adding a layer, which is why
   there is one breakpoint and no second stylesheet to keep in step with the first.

   700px, not 480: the failures below start the moment the header's two columns stop fitting side by
   side, which happens on a small tablet in portrait long before it happens on a phone.
   ============================================================ */
@media (max-width: 700px) {

  /* 1 — THE CHART HAD NO WIDTH AT ALL. The header row is `chart | grip | metrics` with the metrics
     column at a fixed `flex: 0 0 268px` and the chart at `flex: 1 1 auto; min-width: 0`. On a 375px
     screen the fixed column plus the gaps already exceed the row, so the only flexible item was
     shrunk to exactly 0 — measured 0px wide, canvas and all. The chart, which is the point of the
     app, was not small on a phone; it was absent. Stacking is the whole fix. */
  .hdr-top-row { flex-direction: column; gap: 12px; }
  .hdr-right { flex: 1 1 auto !important; width: 100%; }
  /* Stacking turns `flex: 1 1 auto` from a WIDTH instruction into a HEIGHT one, so the chart grew
     to fill the column — and the height it grew to is itself a desktop decision: .zc-box carries an
     inline height dragged from the resize handle (427px on this store), which is over half a phone
     screen before the metrics beneath it are counted. Same reasoning as the card widths below: a
     size chosen on a laptop is not a size that travels, so the phone overrides it outright.
     220px keeps the shape of the curve readable while leaving the board within one scroll. */
  .chart-panel { flex: 0 0 auto; }
  /* THE HEIGHT OVERRIDE IS GONE (user, 2026-08-07): the chart is now resizable on the phone too,
     and `height: 220px !important` beat the inline height the drag handle writes, so the drag did
     nothing. The stored height is honoured and capped at half the screen instead — a value dragged
     on a laptop still cannot eat a phone, but the handle works on both. */
  /* 38dvh, tightened from 50vh: under the fixed shell #hdr is `flex: 0 0 auto`, so every pixel the
     chart takes comes straight out of the record list. At 50% the list could be squeezed to a
     couple of rows on a short phone. dvh, not vh, for the same retracting-toolbar reason as the
     shell itself. */
  .zc-box { flex: 0 0 auto; max-height: 38dvh; }
  /* The chart-height handle is a pointer drag already; it just needs the browser to stop claiming
     the gesture for scrolling, same as .row-grip. Taller than 11px so a fingertip can find it. */
  /* CONTAINED, not overhanging. Desktop hangs this 11px below .topbar so it straddles the edge —
     under the fixed shell that 11px pushed the document past 100dvh (measured 823 of 812), which
     with `overflow: hidden` is content silently cut off rather than a scrollbar. It sits inside the
     topbar's own 12px bottom padding instead, which also closes the gap to .panel-head to ~0. */
  .topbar-resize { touch-action: none; height: 16px; bottom: 0; }
  .zc-touch-hint { display: block; font-size: 10.5px; color: var(--ink-3); text-align: center; padding: 3px 0 1px; }
  /* Drag-to-resize the split is meaningless once the two are stacked, and it is a 9px pointer
     target on a touch screen either way. */
  .hdr-resize { display: none; }

  /* ============================================================
     2 — THE APP SHELL IS STRUCTURAL, NOT PINNED (user, 2026-08-07)

     This replaced four independent sticky/fixed layers — .nda-reminder z-40, #hdr z-30,
     .panel-head z-12, .mtabs z-60 — that each pinned themselves to the viewport and knew nothing
     about the others. Two of them were simply wrong:

       • .nda-reminder is a child of BODY, inserted before #app (gate.js), so it and #hdr were
         SIBLINGS both pinned to top: 0. Neither could know the other's height, so the topbar slid
         underneath the reminder bar. No z-index fixes that; they were fighting for the same pixel.
       • .panel-head is a sibling of .grid-wrap, not inside it, so its `top: 0` anchored to the PAGE
         rather than to the card. It only appeared to work while the page was what scrolled.

     THE SHELL ITSELF MOVED TO THE BASE STYLESHEET ON 2026-09-05 (see "THE APP SHELL" up there) —
     desktop runs the same layout now, for the same reasons, so the html/body/#app/#board-host
     rules that used to sit here are gone rather than duplicated. What is left below is genuinely
     phone-shaped: a header that stacks and wraps, and a board with a thumb cushion.
     ============================================================ */
  .topbar { padding: 8px 12px 12px; display: flex; flex-wrap: wrap; align-items: center; }
  /* The brand is the topbar's only child since 2026-09-06 — .topbar-actions is gone and its
     buttons are in .rail-tools. The `order` pair that used to live here existed solely to stop
     those two colliding on a narrow header, so it went with them; `order: 1` is kept because
     .hdr-grid below still declares `order: 3` and a mix of ordered and unordered flex items puts
     the unordered one first. */
  .brand { order: 1; flex: 1 1 auto; margin-bottom: 0; }
  /* min-width: 0 is load-bearing. A flex item defaults to min-width: auto, which refuses to shrink
     below its content — and the content here is a 310px run of range buttons, so the header forced
     the whole document 448px wide on a 375px screen and the page scrolled sideways. */
  .hdr-grid { order: 3; flex: 1 1 100%; min-width: 0; }
  .hdr-top-row, .chart-panel, .zc-head { min-width: 0; }
  /* The range buttons wrap onto a second line rather than pushing the page wider. */
  .zc-head { flex-wrap: wrap; row-gap: 4px; }
  .range-filters { flex-wrap: wrap; }

  /* 3 — ONE CARD, FILLING WHAT IS LEFT. The board's flex chain and `display: contents` on
     .board-row are base rules now (2026-09-05); what stays here is the phone's own two.

     8px side padding (not 0): this is the tap-target cushion rule 9 used to cancel out with a
     negative margin on .panel, which is what put the first/last table columns flush against the
     physical screen edge — link icons and the ⠿ row-grip unreachable on the left, the ACTIVE
     column crowded on the right (user, 2026-08-21). The chrome-removal rule 9 still does (border/
     radius/shadow) doesn't need the edge-to-edge width to work; only the cushion does.

     !important on the width: a card dragged to 520px on a laptop carries that INLINE width onto the
     phone, and an inline style beats any selector without it. */
  .board { padding: 0 8px; }
  .panel { width: 100% !important; max-width: 100%; }
  .panel-head { flex-wrap: wrap; row-gap: 6px; flex: 0 0 auto; }
  .panel-actions { flex-wrap: wrap; }

  /* 4 — TABLES SCROLL RATHER THAN BEING CUT OFF, and this is the one that made the app unusable
     rather than merely ugly. .panel is overflow:hidden and .grid-wrap is overflow:visible, a pairing
     that is exactly right when the card sizes to its own table. Capped at the screen width instead,
     the same pairing silently CLIPS: the Assets table measured 432px inside a 327px card, so four
     columns simply did not exist as far as a reader could tell — no scrollbar, no clue they were
     there. The wide Chart Data table already had this treatment (.qa-scroll); every other table now
     gets it too. */
  /* Both axes, and it is THE scroll owner (a base rule since 2026-09-05). Two things stay phone-only.
     `flex: 1 1 auto` rather than the base `0 1 auto`: the phone has a TAB BAR under the board, and
     the totals belong directly above it whether the card holds three rows or forty — a bar floating
     mid-screen with grey below it reads as an unfinished card on a 375px screen. On desktop there
     is nothing below the board, so the bar sits under the last row instead. */
  .grid-col, .grid-wrap { flex: 1 1 auto; max-height: none; -webkit-overflow-scrolling: touch; }

  /* 5 — POINTER-ONLY AFFORDANCES. The corner grip is a 22×20 drag that also answers to a
     double-click; neither gesture survives a fingertip, and it sits in the same corner as the
     Active checkbox, so on touch it is purely a way to miss the checkbox. */
  .panel-resize { display: none; }

  /* 6 — MODALS FIT THE SCREEN, AND READ AS DIALOGS RATHER THAN PAGES (user, 2026-08-07).

     They fit — at 92vh with 10px of host padding they filled the screen and looked like a
     navigation, not a decision. Everything below is the same shrink applied consistently: the
     desktop values (20px padding, 16px headings, 18px paragraph margins) are laid out for a 420px
     box on a large monitor and simply travel badly.

     THE BODY SCROLLS, NOT THE MODAL, for anything with a header and a footer. Same reasoning as
     the app shell above: a Done button that scrolls out of reach is the failure this prevents. */
  .modal-host { padding: 16px; }

  /* Single-block modals (confirm, dev settings, link editor) scroll themselves — there is no
     chrome to hold still. */
  .modal { width: 100%; max-width: 100%; max-height: 80dvh; overflow-y: auto;
           padding: 14px; border-radius: 12px; }
  .modal h3 { font-size: 14.5px; margin-bottom: 6px; }
  .modal p { font-size: 12.5px; line-height: 1.45; margin-bottom: 12px; }
  .modal-field { margin-bottom: 10px; }
  .modal-label { font-size: 10.5px; margin-bottom: 4px; }
  .modal-actions, .gate-actions { flex-wrap: wrap; gap: 8px; margin-top: 12px; }
  .sched-hint, .modal-p, .prime-source { font-size: 11px; }

  /* Header / body / footer modals: chrome fixed, middle scrolls.

     SELECTED BY STRUCTURE (`:has(.sched-body)`), not by a list of class names. A list was wrong
     twice over: it swept in .prime-modal and .nda-modal, which have no .sched-body — so
     `overflow: hidden` would have CLIPPED them instead of letting them scroll — and it missed the
     `padding: 0` those framed modals need, leaving .sched-modal with 14px of its own padding on
     top of the padding its header, body and footer already carry. Matching on the scrolling part
     itself means the rule cannot drift from the markup. */
  .modal:has(.sched-body) {
    width: 100%; max-width: 100%; max-height: 80dvh; padding: 0;
    display: flex; flex-direction: column; overflow: hidden; border-radius: 12px;
  }
  .sched-header, .sched-footer { flex: 0 0 auto; }
  .sched-header { padding: 12px 14px 10px; }
  .sched-footer { padding: 10px 14px 12px; gap: 6px; }
  .sched-body { flex: 1 1 auto; min-height: 0; max-height: none; overflow-y: auto; padding: 12px 14px 0; }
  .sched-record { flex-wrap: wrap; gap: 8px; }

  /* Controls: smaller, but min-height holds the floor at 32px — padding alone landed them at 31,
     and a tap target that shrinks with its font is how "compact" becomes "fiddly". */
  /* 8px vertical, not 7 — min-height alone left them at 31.04px, because a flex item under
     `align-items: center` is sized by its content and the min never became the used height. */
  .btn { padding: 8px 12px; font-size: 12.5px; min-height: 32px; }
  .btn-ghost, .btn-primary, .btn-clear { padding: 8px 14px; font-size: 12.5px; min-height: 32px; }
  .sched-preset { padding: 7px 6px; font-size: 11.5px; }
  .sched-pill, .sched-shortcut { padding: 5px 10px; font-size: 11.5px; }
  .modal-input { padding: 6px 9px; }
  .modal-money > .modal-input { padding: 6px 0; }

  /* ⚠ THE ONE THING THAT DOES NOT SHRINK, and it is not a style choice. Below 16px, iOS Safari
     zooms the page on focus and does not zoom back. That was already bad; under the fixed shell
     above it is worse, because body is `overflow: hidden` and there is no way to scroll back to
     what you were typing. The compacting comes from padding, headings, labels and buttons instead
     — which is where the bulk actually was. The only alternative is maximum-scale=1 on the
     viewport meta, which kills pinch-zoom app-wide. Not worth it. */
  .modal input, .modal select, .modal textarea { font-size: 16px; }

  /* 7 — the confidentiality bar wraps instead of squeezing its buttons off the edge. It no longer
     needs `position: sticky` — it is the first flex child of a non-scrolling body, so it is fixed
     by construction, and being fixed by construction is what stopped the topbar sliding under it. */
  .nda-reminder { flex-wrap: wrap; row-gap: 4px; }

  /* ============================================================
     8 — ONE VIEW AT A TIME (2026-08-07)

     The board renders every panel exactly as it does on desktop; these rules decide which one is
     on screen, keyed off [data-mtab-active] on #app. No DOM is moved, so there is no second layout
     to keep in step and renderBoard()/updateKpis() are untouched.

     THE ATTRIBUTE IS NOT `data-mtab`, and the difference is load-bearing (2026-08-22). The tab
     BUTTONS carry `data-mtab`; this container carries the ACTIVE one. While both were spelled the
     same, app.js's delegated click handler — which finds a tab button with closest('[data-mtab]')
     — matched #app instead on every click anywhere in the app, and returned before it reached the
     header buttons. Do not rename this back to match the buttons.
     ============================================================ */

  /* Hide every panel, then show back the one whose key matches the active tab. Written as
     hide-all-then-reveal rather than eight :not() chains so adding a panel needs no edit here. */
  /* The revealed panel IS the column: it takes the height .board has left and hands it to
     .grid-wrap. `flex-direction: column` is load-bearing and not decoration — .panel has no
     display of its own, so a bare `display: flex` makes it a ROW and lays head, table and footer
     out side by side (this exact bug squeezed the table wrap to 127px of a 351px card). */
  #app[data-mtab-active] .panel { display: none; }
  /* ONE rule, no panel named — the phone's copy of the same fix made for the desktop rail below.
     This enumerated eight keys and had to be edited in lockstep with MTABS; syncMobilePanel() in
     app.js now marks the active card with .mpanel-on instead. */
  #app[data-mtab-active] .panel.mpanel-on {
    display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0;
  }

  /* Chart Data is hidden on mobile outright: a 3-header-row grid with a sticky date column is not
     readable at 375px, and it is a diagnostic rather than something you act on. Desktop keeps it. */
  .panel[data-panel="qa"] { display: none !important; }

  /* THE HEADER HOLDS NOTHING ON MOBILE ANY MORE (revised 2026-08-21). Setup used to be the one
     tab still shown inside .hdr-right, above the topbar resize handle, while every other tab
     rendered below it as a normal board panel — a special case left over from before Setup had
     somewhere else to go. It now has its own .mpanel[data-panel="setup"] card (mobilePanels()),
     so .hdr-right is unconditionally hidden here like the rest of the desktop header column. */
  .hdr-right { display: none; }
  /* THE RAIL IS NO LONGER HIDDEN OUTRIGHT HERE (2026-09-06), and the reason is not cosmetic.
     `.desktop-rail { display: none }` was correct while the rail held only a tab list, because the
     phone's own .mtabs bar at the bottom of the shell already does that job. It stopped being
     correct the moment .topbar-actions was folded into .rail-tools: Sign out, Undo, Redo and the
     sync label live in that row now and exist nowhere else in the app, so hiding the rail on a
     phone would leave no way to sign out at all.
     So the TAB LIST goes and the TOOL ROW stays. `display: contents` drops the rail's own box —
     its card background, border, radius and the 180px column width are all desktop furniture that
     would look like a stray panel here — and promotes .rail-tools to a flex item of .hdr-top-row,
     where it sits as a plain wrapped run of buttons above the chart. */
  .desktop-rail { display: contents; }
  .rail-tabs { display: none; }
  /* BACK TO ONE LINE ON A PHONE. The two rows exist to stop a 180px rail breaking where it likes
     (see .rail-tools); here the row is as wide as the screen and there is no column to widen, so
     the two groups sit side by side and the header stays one line tall. */
  .rail-tools { border-top: none; padding: 6px 0 2px; flex-direction: row; flex-wrap: wrap; justify-content: flex-end; }

  /* The mobile-only Key Metrics / Payoff / Setup cards. Their bodies are the scroll owner, exactly
     as .grid-wrap is for a table card. */
  .mpanel-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
  .kpi-table thead th { position: sticky; top: 0; z-index: 10; background: var(--card); }

  /* KEY METRICS: one title, and room for the values (user, 2026-08-21). kpiTiles()'s own <thead>
     ("KEY METRICS") is desktop's only title for this table — desktop has no .panel-head around it.
     The mobile card wraps the same table in a real panel-head titled "Key Metrics", which turned
     the thead into a second, redundant title only here. Hidden by CSS rather than removed from
     kpiTiles() itself, so desktop (which still needs it) is untouched.
     table-layout:fixed (inherited from .kpi-table) splits the two columns evenly by default, which
     left large dollar figures ellipsis-clipped ($73,490…) with room to spare on the label side — a
     label ellipsizes far more gracefully than a dollar amount does, so the split favors the value
     column instead, on this card only. */
  .mpanel[data-panel="metrics"] .kpi-table thead { display: none; }
  .mpanel[data-panel="metrics"] .kpi-table .kpi-lbl { width: 38%; }
  .mpanel[data-panel="metrics"] .kpi-table .kpi-v { width: 62%; }
  /* payoffMini() already emits head / scroll / foot as three sibling tables — the exact shape this
     needs — so it takes a flex container and nothing else. */
  .mpanel[data-panel="payoff"] .payoff-mini-box { display: flex; flex-direction: column; overflow: hidden; }
  .mpanel[data-panel="payoff"] .payoff-mini-scroll { flex: 1 1 auto; min-height: 0; }

  /* 9 — STOP LOOKING LIKE A CARD, BUT KEEP A CUSHION (revised 2026-08-21). No border, no radius,
     no shadow: on a phone the chrome is a frame around the only thing on screen, costing width to
     say nothing. This USED to also go full-bleed (calc(100% + 24px), margin: 0 -12px, cancelling
     .board's padding) so the card touched both screen edges — which put the first column (link
     icons, the ⠿ row-grip used to manually sort debts) and the last column (the ACTIVE checkbox)
     flush against the glass, unreachable and clipped-looking. .board's 8px side padding (rule 3)
     is the cushion now; .panel goes back to the plain width:100% rule 3 already gives it, so
     nothing here needs to fight that padding. Only the chrome removal survives. */
  .panel { border: none; border-radius: 0; box-shadow: none; }
  .panel .grip { display: none; }
  .panel-head { padding: 8px 12px; }

  /* 10 — AUTO-FIT COLUMNS + HORIZONTAL SCROLL. Rows are already white-space: nowrap, so columns
     size to their content for free — but three desktop rules fight it and all three must go.
     .panel.sized is the trap: a card resized on a laptop carries `.sized` and an inline width into
     the phone layout. The width is beaten by rule 3's !important; these table rules are not, and
     they are exactly what defeats auto-fit. */
  .grid-table { width: max-content !important; min-width: 100%; }
  .panel.sized .grid-table { width: max-content !important; }
  .panel.sized .grid-table td:has(.cell-text) { width: auto !important; }

  /* 11 — THE TAB BAR. No longer `position: fixed` — it is the last flex child of #app, below the
     scroll box, so nothing can pass behind it BY CONSTRUCTION rather than by z-index. Still
     horizontally scrollable: eight readable labels do not fit 375px, and setMobileTab() scrolls
     the active one into view. */
  .mtabs {
    flex: 0 0 auto;
    display: flex; align-items: stretch; overflow-x: auto; overflow-y: hidden;
    background: var(--bg-1, #fff); border-top: 1px solid var(--line, rgba(127,127,127,.25));
    scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch;
    padding-bottom: env(safe-area-inset-bottom);
    scrollbar-width: none;
  }
  .mtabs::-webkit-scrollbar { display: none; }
  /* CENTRED, both ways. justify-content was flex-start, which top-aligned every label — so a
     two-line "Key Metrics" sat at a different height from a one-line "Debts" and the row read as
     misaligned. Centring is the actual fix; the wrap was working already.
     word-break: normal (not break-word) stops "Metrics" being split mid-word; overflow-wrap still
     handles a label with no space in it. */
  /* flex: 1 0 66px, not 0 0 auto (user, 2026-08-21 — "panel narrower than the screen should
     stretch"). .mtabs itself already spans full width (it's a direct flex child of #app, which
     stretches it on the cross axis by default) — the bug was the ROW OF BUTTONS inside it: eight
     tabs at a flat 66px total 528px, and on anything wider than that (a big phone, a small tablet
     still under the 700px breakpoint) they sat left-aligned with dead space to their right instead
     of filling the bar. grow:1 lets them expand to fill any extra room; shrink:0 keeps them from
     ever going the other way and shrinking below 66px — that's what .mtabs's `overflow-x: auto`
     needs to still kick in and scroll on a normal phone, exactly as before. */
  .mtab {
    flex: 1 0 66px; scroll-snap-align: center;
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
    padding: 8px 4px; background: none; border: none; cursor: pointer;
    font-size: 10px; line-height: 1.15; font-weight: 600;
    color: var(--ink-3); border-top: 2px solid transparent;
  }
  .mtab span { white-space: normal; text-align: center; overflow-wrap: break-word; word-break: normal; }
  /* A hairline between buttons, so eight of them read as eight and not as one grey strip. */
  .mtab + .mtab { box-shadow: inset 1px 0 0 var(--line, rgba(127,127,127,.25)); }
  /* Active state is ONE colour change, which is why the icons stroke currentColor. */
  .mtab.on { color: var(--blue); border-top-color: var(--blue); }

  /* 12 — WHAT STAYS PINNED INSIDE THE SCROLL BOX.
     .panel-head is no longer sticky: it is a flex sibling ABOVE .grid-wrap in a panel that does not
     scroll, so it holds still on its own. The sticky column header that used to live here is a base
     rule now (2026-09-05) — desktop's table scrolls inside the card too, so it needs the same
     headings. And the `.grid-table tfoot td` rule that pinned Asset Totals is gone with the <tfoot>
     it pinned: every card's totals are a .grid-foot sibling of the scroll box now, which needs no
     sticky at all (see the base definition). Nothing in a grid table is position:sticky except its
     own header. */

  /* 13 — NOTES fills what is left and scrolls inside itself. The inline height from the corner
     resize grip is a DESKTOP measurement and has to be overridden, hence !important. */
  .panel-body.notes-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
  .notes-bar { flex: 0 0 auto; }
  .notes-editor { flex: 1 1 auto; min-height: 0; height: auto !important; max-height: none; overflow: auto; }

  /* 14 — the collapse chevron goes. Collapsing the one card that is on screen leaves a phone
     showing a title bar and nothing else, and the tab bar already does the job it was for:
     switching away from a card you are done with. */
  .panel-collapse-btn { display: none; }
}

/* ============================================================
   DESKTOP GUARDS — the mobile-only markup that renderApp()/renderBoard() emit unconditionally.

   All three are rendered at every viewport width because a render function that branched on screen
   size would need re-running on resize, and would be a second layout to keep in step. They are
   confined here instead, in one place, so "no desktop change" is enforced by three rules rather
   than trusted to discipline.
   ============================================================ */
@media (min-width: 701px) {
  /* WHAT ACTUALLY SETS THE HEADER'S HEIGHT, and why the chart cap alone was not enough (measured,
     2026-09-05). .hdr-top-row is align-items:stretch, so the row is as tall as its TALLEST column —
     and that is never the chart: it is this one, whose three tables ask for 202 + 89 + 130 + gaps =
     ~449px whatever the window is. On a 1280×720 laptop that left the header at 508px and the
     records card at 212px, i.e. a column header, a totals bar and no rows at all. Capping .zc-box
     cannot fix it; the chart only ever grows to match this column.

     46dvh, so the header can never take more than about half the screen. On a tall monitor the cap
     is above the natural height and nothing moves at all; on a short one this column scrolls (the
     Key Metrics table stays at the top, the payoff list is a scroll away) instead of pushing the
     records off the bottom of the screen. overflow-x stays hidden — that was this box's original
     rule and the drag-resize depends on it. */
  /* RAISED TO MATCH THE CHART'S OWN CAP, AND MOVED OFF THIS BOX (2026-09-05). The 46dvh above was
     doing two jobs: stopping this column from setting a huge header height, and scrolling it when
     it could not fit. The first job is gone — split into two columns, this column asks ~300px
     rather than ~449, and the schedule beside it contributes only its 130px floor because its
     flex-basis is 0. The second job moved to .hdr-metrics, which scrolls on its own.
     What is left is a ceiling equal to .zc-box's 50dvh, so the two agree: drag the chart to its
     maximum and this column follows it exactly, which is what makes the schedule gain rows. A cap
     BELOW the chart's would have meant the column scrolling internally while the chart beside it
     stood a hand taller — the thing that made the drag feel broken in the first place. */
  .hdr-right { max-height: 50dvh; }
  .mtabs { display: none; }
  /* Key Metrics + Payoff + Setup already exist in .hdr-right on desktop; these are the mobile
     copies (mobilePanels()). */
  .mpanel { display: none; }
  /* (`.grid-foot { display: none }` lived here — the totals bar was a mobile-only feature in
     August, then a desktop rule fought this one back off in the block below. It is one bar at both
     widths now, defined once in the base stylesheet, so there is nothing left to guard.) */
}

/* ============================================================
   DESKTOP — ONE COLLECTION AT A TIME (2026-08-26)

   Mirrors the mobile block's own "8 — ONE VIEW AT A TIME" section above, keyed off its own
   [data-dpanel-active] attribute rather than [data-mtab-active] — see the comment beside DTABS in
   app.js for why the two stay separate rather than sharing one attribute. Written the same way for
   the same reason: hide every panel, then show back only the one whose key matches.
   ============================================================ */
@media (min-width: 701px) {
  #app[data-dpanel-active] .panel { display: none; }
  /* ONE rule, no panel named. This used to enumerate all seven keys, because CSS cannot compare
     .panel's own data-panel against #app's data-dpanel-active — which meant every new card had to
     be registered here too, in a file nobody opens when adding a collection. Variable Expenses was
     missed on the first try and rendered perfectly, invisibly. syncDesktopPanel() in app.js now
     marks the active card with .dpanel-on, so this list can never fall behind PANELS again. */
  #app[data-dpanel-active] .panel.dpanel-on {
    display: flex; flex-direction: column;
    /* THE CARD IS THE BOARD (user, 2026-09-05). It takes what the shell has left and hands it down
       to .grid-wrap — `0 1 auto` for the same reason .grid-wrap uses it: a short table ends where
       its rows end, and only a table too tall for the window gets squeezed. */
    flex: 0 1 auto; min-height: 0;
    /* !important, and this is why: a card dragged wider before the rail shipped carries an INLINE
       width forever (settings.panelSize is still stored, just no longer read here), and an inline
       style beats any selector. Without it, a stale pixel width from a laptop in July — not the
       window — still decides how wide today's full-width card is. */
    width: 100% !important; max-width: 100%;
    /* No border, no radius, no shadow: chrome drawn around the only thing on screen frames it as a
       card sitting ON the board when it IS the board (user, 2026-09-05) — the same three lines the
       phone has run since August. The surface stays; that is what the rows sit on. The 12px bottom
       padding goes with the corner grip it existed for (hidden below), and taking it away is what
       closes the gap between the last row and the totals. */
    border: none; border-radius: 0; box-shadow: none; padding-bottom: 0;
  }
  /* NOTES IS THE ONE CARD THAT SHOULD TAKE THE ROOM (2026-09-05). Every other card ends where its
     rows end, which is right — a three-row Income card should not stretch a table to fill a screen.
     Notes has one elastic thing in it, and a 110px writing box stranded in the top inch of an empty
     board is the "unfinished card" look this whole change is about removing.
     (height:auto !important overrides the inline px the corner grip may have left behind — that
     grip is hidden here, so its measurement can no longer be corrected by the user.)

     FULL WIDTH, NOT A READING MEASURE (user, 2026-09-05, reversing the same day's decision).
     This carried `width: min(820px, 100%)` on the typographic argument that 1,400px lines are hard
     to write and harder to re-read. The owner asked for the opposite in as many words — the box
     should fit the width of the screen — and it is their writing surface, so the measure argument
     does not get to outrank it. Deliberately RECORDED rather than deleted: the 820px cap looks like
     an obvious improvement to anyone reading this file cold, and it has already been reverted once. */
  #app[data-dpanel-active] .panel.dpanel-on[data-panel="notes"] { flex: 1 1 auto; }
  #app[data-dpanel-active] .notes-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
                                         width: 100%; }
  #app[data-dpanel-active] .notes-bar { flex: 0 0 auto; }
  #app[data-dpanel-active] .notes-editor { flex: 1 1 auto; min-height: 0; height: auto !important; max-height: none; }
  /* A card resized on a laptop BEFORE this redesign carries .sized + a dragged width forever
     (panelSize is never cleared by this change, only stopped being read here) — without this, that
     old width would force the table to exactly the old card's size instead of the new full-width
     one. Higher specificity than `.panel.sized .grid-table { width: 100% }` above, so it wins
     without !important.

     BOTH HALVES, and the second one is the one that actually stretched the table (user, 2026-09-05:
     "Assets, Income and Debts span all the way across... Fixed and Variable expenses, notes and
     goals do not"). `.panel.sized .grid-table td:has(.cell-text) { width: 100% }` gives the name
     column a percentage width, and in an AUTO-layout table a percentage cell drags the whole table
     out to fill its container — so neutralising the table's own width above did nothing while this
     one still applied. The split had nothing to do with the cards: the three that stretched are the
     three with an old dragged width in settings.panelSize, and the four that did not are the ones
     that were never resized. The phone block has carried both lines since August; desktop had one.
     Now every card is content-width unless its own column width says otherwise (see .col-grip). */
  #app[data-dpanel-active] .panel.sized .grid-table { width: auto; }
  #app[data-dpanel-active] .panel.sized .grid-table td:has(.cell-text) { width: auto; }
  /* Dragging a whole card, resizing one, or collapsing one all stop meaning anything once only one
     is ever on screen — there is nothing else to arrange against. Hidden, not removed: movePanel(),
     panelSizeOf() and collapsedOf() are untouched, and store.settings.layoutOrder/panelSize/collapsed
     stay exactly as they were, simply unread here (see the note beside DTABS in app.js). */
  #app[data-dpanel-active] .panel .grip,
  #app[data-dpanel-active] .panel .panel-resize,
  #app[data-dpanel-active] .panel .panel-collapse-btn { display: none; }

  /* (The desktop .grid-foot rule lived here from 2026-08-26 until 2026-09-05: `display: block` to
     undo a guard, plus `position: sticky; bottom: 0` to fake a pinned bar against the page's own
     scroll. Both were standing in for a shell that did not exist yet. The bar now has ONE
     definition, in the base stylesheet, and needs no sticky at all — it is a flex sibling below the
     scroll box, so it cannot scroll away.) */
}

/* ============================================================
   VARIABLE EXPENSES (2026-08-28)
   A monthly budget spread across every day of the month. Everything here is deliberately small:
   the card reuses the grid, the modal reuses the schedule shell, and only the handful of things
   that genuinely have no existing equivalent are styled below.
   ============================================================ */

/* Chart Data's Variable group reads AMBER where the fixed-expense group reads red. Not decoration:
   both are money leaving, and the difference the colour carries is the one this whole feature is
   about — red is committed (rent does not negotiate), amber is controlled (groceries do). It is the
   same one-step-across-the-family relationship --debt-line and --debt-line-accel already use. */
.qa-table th.qa-varexpenses .qa-kind { color: var(--amber); }

/* A row whose budget is not currently spending — not started, already ended, or not filled in yet.
   Muted rather than hidden: the record still exists and is still yours, it just has no rate to
   quote today, and a blank cell would read as a rendering fault instead of as a state. */
.var-rule-muted { color: var(--ink-3); opacity: .7; }

/* THE MODAL'S LIVE ARITHMETIC. Shares .stream-summary's card treatment on purpose — both answer
   "what does this actually come to", and the reader should recognise the second from the first. */
.var-preview {
  margin: 2px 0 12px; padding: 10px 12px;
  background: var(--bg-2, #f6f7f9); border-radius: 8px;
  font-size: 12.5px; color: var(--ink-2); font-variant-numeric: tabular-nums;
}
.var-preview-line b { color: var(--ink); font-weight: 700; }
.var-preview-note { margin-top: 5px; color: var(--ink-3); font-size: 11.5px; }

/* The per-option figure sitting after each radio label. Not bold and not coloured — it is evidence
   for the choice beside it, and giving it the same weight as the option would make the two compete
   for the same glance. Wraps under the label on a narrow modal rather than forcing a scrollbar. */
.var-opt-note { font-style: normal; color: var(--ink-3); font-size: 11.5px; }
.sched-radio .sched-text { display: inline; }

/* The month boundary in the daily-spread stream — the row the whole table exists to make findable,
   because it is where the pennies are proved to add up to the budget. Ruled above and weighted, so
   scrolling a year of daily rows still reads as twelve blocks rather than one column of numbers. */
.stream-table tr.stream-subtotal td {
  border-top: 1px solid var(--line);
  font-weight: 700; color: var(--ink);
  background: var(--bg-2, #f6f7f9);
}
