/* ===== Design tokens =====
   Two hairline weights, from docs/design/DESIGN.md. `--hairline` is passive
   structure (card borders, row separators) — it organizes without asserting.
   `--hairline-strong` is for edges competing with something active (inputs,
   buttons, totals rows). If a new border needs a third value, that's a signal
   to revisit the system in DESIGN.md, not to reach for another gray — the
   running CSS already drifted to 8+ near-identical grays once before this
   existed. New borders should reference these variables, not hardcode a hex. */
:root {
    --hairline: #E6E9EF;
    --hairline-strong: #CFD5E0;

    /* Surface + text, from DESIGN.md — card-sunken is for wells inside a
       card (grid headers, read-only cells); card-hover is row hover, not a
       button state. mute/action-soft are read here for grid affordances. */
    /* Surfaces. The page is the gray field and cards are white on top of it —
       this was inverted (white page, #F9FAFB cards), which made every card
       read as inset and left the segmented-control tracks invisible against
       the card they sat on. Sunken elements *inside* a card stay gray and
       became correct for free once the two outer layers swapped. */
    --canvas: #F7F8FA;
    --card: #FFFFFF;
    --card-sunken: #F1F3F7;
    --card-hover: #FBFCFE;

    /* Stronger than DESIGN.md's `raised` (0 1px 2px rgba(12,35,64,.04)),
       which was invisible against the canvas. DESIGN.md needs revising to
       match rather than this being special-cased. */
    --raised: 0 1px 3px rgba(12, 35, 64, 0.06);
    /* The one elevation for floating surfaces (dropdown menus, calendar
       popups, tooltips). Dash ships its own two-layer near-black shadow on
       these; this replaces it so a popup reads as the same system as a card,
       one step further off the page. */
    --popover: 0 6px 16px rgba(12, 35, 64, 0.10),
        0 2px 5px rgba(12, 35, 64, 0.06);
    /* Operand ramp — POSITIONAL, never semantic. Its only job is to bind a
       chip in the 10-K formula explainer to the grid cell that chip came
       from: "this number is that number". It is not a category scale and
       carries no verdict, so nothing here may be reused to mean favorable,
       risky, or a kind of expense.

       A bounded exemption, written down the way the benchmark rail's verdict
       hues are. It exists because every other hue in this system already
       means something: the category palette is spoken for by expense
       categories, `action` means "do something", and success/warning/danger
       are verdicts — which is exactly what the six hand-picked pairs this
       replaces were accidentally saying (green read favorable, amber was
       byte-identical to the Cap Ex category hue).

       Four stops, because four is the widest formula on the three 10-K tabs
       (After-Tax Cash Flow). The one row that sums the six expense
       categories uses the category hues instead — those operands really are
       the categories. Anchored on brand-navy and stepping through the
       benchmark blue; no new hue enters the system at full chroma. */
    --operand-1: #0C2340;
    --operand-1-soft: #E8EDF6;
    --operand-2: #1C5480;
    --operand-2-soft: #E6F0F9;
    --operand-3: #5B3E86;
    --operand-3-soft: #EFE9F7;
    --operand-4: #7A3F5E;
    --operand-4-soft: #F3EAF0;

    --ink: #0C2340;
    /* Was #8A94A6, which is 3.06:1 on card and 2.88:1 on canvas — below
       WCAG AA (4.5) for small text. Darkened along its own hue (218.6°) and
       saturation (0.136) until it cleared: 4.78:1 on card, 4.50:1 on canvas.
       The result lands within a hair of #6B7280, which the stylesheet had
       independently reached for 20 times — the tell that this is the value
       --mute should always have had. Every quiet label in the app got
       darker on 2026-08-05, and that was the point. */
    --mute: #687388;
    /* body and faint were specified in DESIGN.md from the start but never
       wired. Their absence is the direct cause of the ad-hoc text grays
       across .rt-*, .tk-*, .gate-* and friends. */
    --body: #475569;
    /* faint is 2.04:1 — it CANNOT carry readable text, so it doesn't get to.
       Its job is non-text only: dotted borders, dividers, decorative glyphs,
       and disabled foregrounds (WCAG exempts disabled controls). If a label
       a user is meant to read looks too loud in --mute, the answer is a
       smaller size or less weight, never this token. Disabled text used to
       reach for --hairline-strong, which is a border token wearing the wrong
       hat; that moved here 2026-08-05. */
    --faint: #AEB6C4;

    /* Action is indigo — see DESIGN.md. Chosen over a navy-adjacent blue that
       didn't separate from brand-navy, and over teal, which collides with
       success green. Teal survives only as the timeline's range fill, where
       it is never adjacent to a semantic chip. */
    --action: #4F46E5;
    --action-hover: #4338CA;
    --action-soft: #EEF2FF;

    /* Semantic — the verdict scale. Google Material, chosen 2026-08-05; the
       reasoning, the ratios and the role rule are in
       docs/specs/verdict-colors.md. Read it before changing a value here.

       Flat vs. -soft/-deep is no longer a judgment call. The tier IS the
       role, and the role is decided by what the pixel does:

         flat   = FILL  — shapes carrying the verdict: track segments, chart
                          markers, bars, icons beside deep text. Floor 3:1.
         -deep  = TEXT  — any word or number stating a verdict, on every page
                          at every size. Floor 4.5:1. This is the rule that
                          stops one page saying #15803D while the next says
                          #16A34A for the same fact.
         -soft  = TINT  — backgrounds a verdict word sits on. Google designed
                          these as pairs for the -deep tier; never mix a tint
                          from one weight with text from another.

       The yellow fill is a deliberate, documented exception at 1.93:1: no
       yellow that still reads as yellow clears 3:1 (to pass it must become
       amber #E37400 or brown).

       2026-08-20: that exception now covers TEXT as well. --warning-deep was
       #B06000 (4.65 on white) and is now #F8AB02 (1.94). Evan's call, on the
       grounds that the deep tier "turns out as brown in the real world" and
       that "the math contrast doesn't work out in the real world" — beauty
       chosen over accessibility, explicitly and on the record. The -deep tier
       no longer darkens the warning hue; it is the same yellow as --warning.
       So there is no longer a contrast-safe warning text color in this file.
       Reasoning and what it would take to reverse:
       docs/specs/warning-deep-recolor.md. Do not "restore" #B06000 as a
       correctness fix — it was removed knowingly, not by accident.

       warning-soft no longer runs hot — Google's yellow 50 is calmer than
       the Tailwind orange it replaced, so the old "reserve for rare, serious
       cases" caveat is retired. */
    --success: #1E8E3E;       /* green 600 — 4.21 */
    --success-soft: #E6F4EA;  /* green 50 */
    --success-deep: #137333;  /* green 800 — 5.95 */
    --danger: #D93025;        /* red 600 — 4.77 */
    --danger-soft: #FCE8E6;   /* red 50 */
    --danger-deep: #C5221F;   /* red 700 — 5.80 */
    --warning: #F9AB00;       /* yellow 600 — 1.93, fills only, see above */
    --warning-soft: #FEF7E0;  /* yellow 50 */
    /* Was #EAB308 — the benchmark slider's exact yellow, kept by explicit
       request. But every one of this token's six consumers is a `color:`
       rule, and at 1.92:1 it was the worst contrast in the app: the name
       promised "deep" while the value was *lighter* than --warning, so it
       kept getting reached for as a text color. The name was the bug.
       #EAB308 is retired outright — the fill yellow is --warning above.

       Then #B06000 (orange 800, 4.65) from that fix until 2026-08-20, when it
       was replaced on look. The name is now misleading again in the other
       direction: "deep" describes the tier's role, not its value. Kept
       because renaming it means touching all twelve consumers for no visual
       change. See the note above the tokens. */
    --warning-deep: #F8AB02;  /* 1.94 — a fill yellow used as text, on purpose */
}

/* ===== Brand fonts =====
   Self-hosted, SIL OFL — free for commercial use, no attribution required in
   the running app. D-DIN is a signage/instrument face: reserved for headline
/* ===== Design-system scope =====
   `.ds-page` is the shared scope for every themed page. It replaced
   `.expenses-page` on 2026-08-02 (170 selectors) when the Expenses pilot
   rolled out to a second page: extending each selector per page would have
   grown all 170 rules again on page three.

   Add the class to a page's root container to opt it in. Keep page-specific
   classes (`.expenses-page`, etc.) for rules that must NOT generalize.
   ============================================================ */
   figures and page titles only (see .ds-page rules below), never for
   anything that stacks in a column — it has no tabular-numeral variant, so a
   money column set in it saws left-right. Geist covers everything else and
   carries real tabular numerals via `font-variant-numeric: tabular-nums`. */
@font-face {
    font-family: "D-DIN";
    src: url("fonts/D-DIN.woff2") format("woff2");
    font-weight: 400;
    font-display: swap;
}
@font-face {
    font-family: "D-DIN";
    src: url("fonts/D-DIN-Bold.woff2") format("woff2");
    font-weight: 700;
    font-display: swap;
}
@font-face {
    font-family: "Geist";
    src: url("fonts/Geist[wght].ttf") format("truetype-variations");
    font-weight: 100 900;
    font-display: swap;
}

/* ===== Global Reset ===== */
* {
    box-sizing: border-box;
}

body {
    margin: 0;
    padding: 0;
    font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        sans-serif;
    background-color: var(--canvas);
}

/* ===== Expenses page — typography pilot =====
   Scoped to .ds-page only, per the design-system rollout plan: approve
   a component here before it spreads to the rest of the app. */
.ds-page {
    font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        sans-serif;
}

.ds-page h2,
.ds-page .thermo-metric,
.ds-page .kpi-card-value {
    font-family: "D-DIN", "Geist", sans-serif;
}

/* Stat-card type scale — matched to the thermometer card.
   These need the .ds-page prefix to win: `.page-container h4` (0,1,1) and
   `.page-container p` (0,1,1) out-specify the bare `.kpi-card-value` (0,1,0) and
   `.kpi-card-subtitle` (0,1,0) further down this file, so the card's own sizes
   were never actually applying — the value rendered at 1.1rem instead of 1.25rem
   and the subtitle at 0.95rem instead of 0.68rem, which is why a subtitle could
   out-shout the label above it. */
.ds-page .kpi-card-value {
    font-size: 1.9rem;
    font-weight: 700;
    line-height: 2.05rem;
    letter-spacing: -0.4px;
    color: var(--ink);
    margin-bottom: 0;
}

/* _kpi_label_row() hard-codes minHeight: 3.4rem inline while .thermo-head is
   1.9rem — a 24px difference that pushed every stat-card figure below its
   neighbouring thermometer figure. Overridden here rather than fixed in
   components/kpi_card.py because that component is shared with the other pages,
   which are not part of this pilot. !important is required to beat the inline
   style. Fold this into the component when the rollout goes site-wide. */
.ds-page .kpi-card-label {
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.6px;
    color: var(--mute);
    min-height: 1.9rem !important;
}

.ds-page .kpi-card-subtitle {
    font-size: 0.78rem;
    font-weight: 500;
    color: var(--mute);
    margin-top: 3px;
}

/* AG Grid v33 sets its font on .ag-root-wrapper from --ag-font-family, so a
   font-family on .ag-theme-alpine is inherited-over by the grid's own rule.
   Setting the variable is the supported override. */
.ds-page .ag-theme-alpine {
    --ag-font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI",
        Roboto, sans-serif;
    font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        sans-serif;
}
.ds-page .ag-theme-alpine .ag-cell {
    font-variant-numeric: tabular-nums;
    /* Cell text. Never declared until 2026-08-04, so every grid in the app
       rendered its data in Alpine's #181d1f — a gap in the Data grid spec, not
       drift: it named the header label's colour and the row separators and
       never named the cells.

       Set as a real property, not via --ag-data-color. That variable is what
       the docs point at and it resolves correctly on the element (measured),
       but nothing in v33's Alpine CSS consumes it — no rule matches .ag-cell
       for `color` at all, so cells inherit from .ag-root-wrapper, which takes
       --ag-foreground-color. Setting *that* would work and would also repaint
       grid chrome derived from it, so it isn't the fix either. Inline
       per-cell colours (cellStyle) still win, which is correct — that's how
       the 10-K signal row and the Expenses amount cells paint themselves. */
    color: var(--ink);
}

/* ===== Timeline range slicer =====
   Replaces the Lookback + Forecast pill rows. One axis, Today fixed in the
   middle, a teal band spanning the selected range so the *span* is legible
   before any label is read. Stops whose window exceeds the holding period are
   omitted server-side, so the axis ends where the history does.

   The bar's lift is deliberately stronger than the `raised` elevation token
   (0 1px 2px rgba(12,35,64,.04)) — against the #F7F8FA canvas that token was
   invisible. See docs/design/rollout-plan.md; the token needs revising rather
   than this rule being special-cased. */
.ds-page .tl-wrap {
    display: flex;
    align-items: center;
    gap: 14px;
    flex-wrap: wrap;
}
.ds-page .tl-bar {
    display: inline-flex;
    align-items: stretch;
    background: #ffffff;
    border: 1px solid var(--hairline);
    /* 10px here and 6px on .tl-end are one decision, not two: a child inset by
       the 4px padding is concentric with its container only at outer − inset.
       Changing either the padding or this radius without the other puts two
       different curves on the same edge. */
    border-radius: 10px;
    padding: 4px;
    box-shadow: var(--raised);
}
.ds-page .tl-stop {
    padding: 9px 15px;
    font-size: 0.78rem;
    color: var(--mute);
    white-space: nowrap;
    display: flex;
    align-items: center;
    cursor: pointer;
    user-select: none;
    transition: background-color 0.12s ease, color 0.12s ease;
}
.ds-page .tl-stop:hover {
    color: var(--ink);
}
.ds-page .tl-mid {
    background: #ccfbf1;
    color: #0f766e;
}
.ds-page .tl-end {
    background: #0d9488;
    color: #ffffff;
    font-weight: 600;
    border-radius: 6px;
}
.ds-page .tl-now {
    font-weight: 600;
}
/* The two anchors are matched in width rather than sized to their text, so the
   axis reads symmetrical even though "Purchase" and "Payoff" differ by two
   characters. */
.ds-page .tl-stop:first-child,
.ds-page .tl-stop:last-child {
    min-width: 104px;
    justify-content: center;
}
.ds-page .tl-stop.tl-now:not(.tl-mid):not(.tl-end) {
    color: var(--ink);
}
.ds-page .tl-range {
    font-size: 0.78rem;
    color: var(--ink);
    white-space: nowrap;
}
.ds-page .tl-sep {
    width: 1px;
    height: 16px;
    background: var(--hairline);
}
.ds-page .tl-span {
    font-size: 0.78rem;
    color: var(--mute);
    white-space: nowrap;
}

/* ===== Category tabs =====
   Underline tabs, not pills. Unselected tabs carry no border and no fill —
   that is the whole point: seven bordered boxes above the content read as a
   grid of controls, plain text does not. Hue identity lives in the selected
   tab's underline and text. */
.ds-page .cat-tabs {
    display: flex;
    gap: 2px;
    border-bottom: 1px solid var(--hairline);
    /* Defaults; the category callback overrides these inline per selection. */
    --cat-ink: #0C2340;
    --cat-hue: #0C2340;
}
.ds-page .cat-tabs label {
    padding: 9px 14px;
    font-size: 0.82rem;
    color: var(--mute);
    cursor: pointer;
    white-space: nowrap;
    border-bottom: 2px solid transparent;
    margin: 0 !important;
}
.ds-page .cat-tabs label:not(.selected):hover {
    color: var(--ink) !important;
}

/* Hue identity — the selected tab's underline and text carry the category's
   own color, so the tab row teaches the chart legend without a legend. The
   hue arrives as a CSS variable set on the container by a callback, rather
   than seven :has(input[value=...]) rules: Dash's own .dash-options-list-option
   rule wins the `color` property against those, and .selected is the class
   Dash already puts on the chosen label. "All" keeps navy — it isn't a
   category and isn't in the legend. */
.ds-page .cat-tabs label.selected {
    color: var(--cat-ink) !important;
    font-weight: 600;
    border-bottom-color: var(--cat-hue);
}

/* ===== Panel header =====
   Chart title with the controls that scope it sitting to the right. */
.ds-page .panel-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    flex-wrap: wrap;
    margin-bottom: 10px;
}
.ds-page .panel-title {
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--ink);
}
.ds-page .panel-controls {
    display: flex;
    align-items: center;
    gap: 8px;
}
.ds-page .seg-label {
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--mute);
    white-space: nowrap;
    margin-right: 2px;
}
.ds-page .seg-label + .seg-toggle {
    margin-right: 6px;
}
/* The one segmented control. Every mutually-exclusive view switch on this
   page uses it — $/%, CapEx, View, the reserve period, the calendar's Order
   and Color, and Catch-All's mode — replacing five separate implementations
   that each invented their own selected state (teal, near-black, dark slate,
   indigo). Selected is a raised white pill with ink text, never a color:
   a chosen segment is a *state*, and color here would collide with `action`
   (which means "do something") and with the semantic greens and reds.

   Matches both markups: RadioItems render <label> and mark the chosen one
   `.selected`; the calendar's custom control renders <button> and marks it
   `.active`. Inputs are hidden here rather than per call site. */
.ds-page .seg-toggle {
    display: inline-flex;
    align-items: stretch;
    background: var(--card-sunken);
    border-radius: 6px;
    padding: 3px;
    gap: 2px;
}
.ds-page .seg-toggle input {
    display: none;
}
.ds-page .seg-toggle label,
.ds-page .seg-toggle button {
    padding: 4px 11px;
    font: inherit;
    font-size: 0.72rem;
    /* Weight is fixed across states on purpose: bolding the selected option
       widens its text and shoves the rest of the row sideways, which was very
       visible on the calendar's long "Chronological". The raised white pill
       already carries selection without it. */
    font-weight: 500;
    line-height: 1.5;
    color: var(--mute);
    background: transparent;
    border: 0;
    cursor: pointer;
    white-space: nowrap;
    border-radius: 4px;
    margin: 0 !important;
    transition: background-color 0.12s ease, color 0.12s ease;
}
.ds-page .seg-toggle label:hover,
.ds-page .seg-toggle button:hover {
    color: var(--ink);
}
.ds-page .seg-toggle label.selected,
.ds-page .seg-toggle button.active,
.ds-page .seg-toggle .form-check-input:checked + .form-check-label {
    background: var(--card);
    color: var(--ink);
    box-shadow: 0 1px 2px rgba(12, 35, 64, 0.12);
}

/* dbc.RadioItems wraps each option in a .form-check with its own padding and
   inline margin, which would break the track's flex row. dcc.RadioItems and
   the calendar's buttons need none of this. */
.ds-page .seg-toggle .form-check {
    display: flex;
    padding: 0;
    margin: 0 !important;
    min-height: 0;
}

/* Sized down to sit inside a card header alongside a card title. */
.ds-page .seg-toggle-sm label,
.ds-page .seg-toggle-sm button {
    padding: 2px 9px;
    font-size: 0.68rem;
}

/* Catch-All's Simple/Bank switches the whole panel's method rather than one
   card's presentation, so it gets equal-width halves and room to breathe. */
.ds-page .seg-toggle-wide {
    width: 340px;
    padding: 4px;
    gap: 3px;
}
/* The flex child is the .form-check wrapper under dbc, and the label itself
   under dcc — so both have to stretch or the halves come out uneven. */
.ds-page .seg-toggle-wide .form-check,
.ds-page .seg-toggle-wide > label,
.ds-page .seg-toggle-wide > button {
    flex: 1;
}
.ds-page .seg-toggle-wide label,
.ds-page .seg-toggle-wide button {
    flex: 1;
    justify-content: center;
    text-align: center;
    padding: 9px 0;
    font-size: 0.82rem;
}

/* HOA / MGMT ask a yes-no question about the property rather than switching a
   view, so they stay switches — restyled to the system, not converted. */
.ds-page .form-switch .form-check-input {
    width: 34px;
    height: 19px;
    background-color: var(--card-sunken);
    border-color: var(--hairline-strong);
    cursor: pointer;
    box-shadow: none;
}
.ds-page .form-switch .form-check-input:checked {
    background-color: var(--action);
    border-color: var(--action);
}
.ds-page .form-switch .form-check-input:focus {
    box-shadow: 0 0 0 3px var(--action-soft);
    border-color: var(--action);
}
.ds-page .form-switch .form-check-label {
    font-size: 0.85rem;
    color: var(--ink);
    cursor: pointer;
    padding-left: 4px;
}

/* ===== Buttons =====
   Until now every button on this page was raw Bootswatch LUX (app.py loads
   dbc.themes.LUX and nothing here touched `.btn`): uppercase, square-cornered,
   near-black primary. That was never drift — it was an untouched theme.

   This block is CSS-only on purpose. Every `dbc.Button` call in
   pages/expenses.py already encodes its role through `color=` / `outline=`,
   so Bootstrap's own class is the role token and no Python has to change.

   Bootswatch 5.3 splits the two halves of a button, and they have to be
   overridden differently:

   * **Color** is exposed as `--bs-btn-*` custom properties, so setting those
     lets Bootstrap keep resolving hover/active/disabled itself.
   * **Geometry is not.** LUX re-declares `border-radius`, `border-width`,
     `font-size` and `line-height` as *literal* values on `.btn` / `.btn-sm`,
     which beat the variables entirely — a `--bs-btn-border-radius: 6px` that
     computes to `border-radius: 0px` was the first symptom. Geometry
     therefore has to be set as real properties, not vars.

   `.ds-page .btn` is (0,2,0) and beats every bare `.btn-*` rule in the
   theme, which also means `.btn-sm` has to be re-scoped below or this rule's
   sizing would swallow the modifier. */
.ds-page .btn {
    --bs-btn-font-weight: 500;
    /* Disabled is a state we draw ourselves (see the destructive rules), not
       a dimmed version of the enabled one — so no global opacity fade. */
    --bs-btn-disabled-opacity: 1;
    /* Literal, not --bs-btn-*: LUX hard-codes these. 36px tall. */
    border-radius: 6px;
    border-width: 1px;
    padding: 8px 14px;
    font-size: 0.85rem;
    line-height: 1.35;
    font-weight: 500;
    font-family: inherit;
    text-transform: none;
    letter-spacing: 0;
    box-shadow: none;
    transition: background-color 0.12s ease, border-color 0.12s ease,
        color 0.12s ease;
}
.ds-page .btn:disabled,
.ds-page .btn.disabled {
    cursor: not-allowed;
}
/* The in-card variant, 32px — same relationship to the base button that
   .seg-toggle-sm has to .seg-toggle. It is the page's *de facto* standard:
   21 of the 23 dbc.Button calls in pages/expenses.py pass size="sm", because
   almost every button here sits inside a card next to a form row. Same
   specificity as the base rule above, so this has to come after it.

   LUX ships `.btn-sm { font-size: 10px }` as a literal, which is why an
   unstyled sm button rendered at 10px — restate the size here. */
.ds-page .btn-sm {
    border-radius: 6px;
    padding: 6px 12px;
    font-size: 0.78rem;
    line-height: 1.35;
    /* Declared, not left to fall out of the type. Padding + line-height alone
       computed 30.8px against a 32px field, and 32px is the number that puts
       [field][field][Add] on one baseline — which is the entire reason the
       button and the field were sized together. Measured 2026-08-03. */
    min-height: 32px;
}
/* Focus ring per DESIGN.md, replacing Bootstrap's .25rem 50%-alpha halo. */
.ds-page .btn:focus-visible {
    box-shadow: 0 0 0 3px var(--action-soft);
    outline: none;
}

/* Primary — one per logical form/section, not per page. CapEx's entry form,
   its edit modal and Catch-All's two lists each own a local primary. */
.ds-page .btn-primary {
    --bs-btn-color: #fff;
    --bs-btn-bg: var(--action);
    --bs-btn-border-color: var(--action);
    --bs-btn-hover-color: #fff;
    --bs-btn-hover-bg: var(--action-hover);
    --bs-btn-hover-border-color: var(--action-hover);
    --bs-btn-active-color: #fff;
    --bs-btn-active-bg: var(--action-hover);
    --bs-btn-active-border-color: var(--action-hover);
    --bs-btn-disabled-color: var(--mute);
    --bs-btn-disabled-bg: var(--card-sunken);
    --bs-btn-disabled-border-color: var(--hairline-strong);
}

/* Secondary — white field, hairline-strong edge, ink text. Both the filled
   and outline classes land here; the page only uses `outline=True`, but a
   plain color="secondary" shouldn't silently render as LUX gray. */
.ds-page .btn-secondary,
.ds-page .btn-outline-secondary {
    --bs-btn-color: var(--ink);
    --bs-btn-bg: var(--card);
    --bs-btn-border-color: var(--hairline-strong);
    --bs-btn-hover-color: var(--ink);
    --bs-btn-hover-bg: var(--card-sunken);
    --bs-btn-hover-border-color: var(--hairline-strong);
    --bs-btn-active-color: var(--ink);
    --bs-btn-active-bg: var(--card-sunken);
    --bs-btn-active-border-color: var(--hairline-strong);
    --bs-btn-disabled-color: var(--mute);
    --bs-btn-disabled-bg: var(--card);
    --bs-btn-disabled-border-color: var(--hairline);
}

/* Destructive — three distinct states, not one color at three opacities.
   At rest it's an outline: an audit of the running page found at most one
   danger button visible at a time, so a solid red field bought nothing and
   cost a loud red sitting next to Add in three places. The fill is saved for
   hover — the moment of intent, right before the click lands. */
.ds-page .btn-danger,
.ds-page .btn-outline-danger {
    --bs-btn-color: var(--danger);
    --bs-btn-bg: transparent;
    --bs-btn-border-color: var(--danger);
    --bs-btn-hover-color: #fff;
    --bs-btn-hover-bg: var(--danger);
    --bs-btn-hover-border-color: var(--danger);
    --bs-btn-active-color: #fff;
    --bs-btn-active-bg: var(--danger-deep);
    --bs-btn-active-border-color: var(--danger-deep);
    /* Third state. Delete Entry before a row is selected must read as inert
       at a glance, not as a quieter red — a disabled button has no hover to
       explain itself with. */
    --bs-btn-disabled-color: var(--mute);
    --bs-btn-disabled-bg: var(--card);
    --bs-btn-disabled-border-color: var(--hairline-strong);
}
.ds-page .btn-outline-danger:focus-visible,
.ds-page .btn-danger:focus-visible {
    box-shadow: 0 0 0 3px var(--danger-soft);
}

/* Tertiary / ghost — no field, action text, action-soft on hover. */
.ds-page .btn-link {
    --bs-btn-color: var(--action);
    --bs-btn-hover-color: var(--action-hover);
    --bs-btn-active-color: var(--action-hover);
    --bs-btn-disabled-color: var(--mute);
    text-decoration: none;
}
.ds-page .btn-link:hover {
    background: var(--action-soft);
}
/* The disclosure ghost ("Other adjustments") used p-0 to sit flush with the
   text above it, which leaves a hover tint no room to breathe. Padding it and
   pulling it back by the same amount keeps the optical alignment. */
.ds-page .btn-ghost-inline {
    padding: 3px 8px;
    margin-left: -8px;
    border-radius: 6px;
}

/* ===== Fields: inputs, affixes, dropdowns, date pickers =====
   One field geometry for everything you type into or pick from: 32px tall,
   6px radius, white, a hairline-strong edge, and an action border plus
   focus-ring on focus. That matches the 32px `sm` button, so a form row of
   [date picker][dropdown][amount][Add] lines up on one baseline.

   Two surprises worth recording, both found by reading the live DOM rather
   than trusting the docs:

   1. **This app is on Dash 4.x, not 2.x.** `dcc.Dropdown` is no longer
      react-select — it renders a Radix listbox (`.dash-dropdown` trigger,
      `.dash-dropdown-content` popup), and `DatePickerSingle` is no longer
      react-dates (`.dash-datepicker-*`, not `.DateInput_*`). Every selector
      below was read off the running page. The old `.dr-dropdown
      .Select-control` rule further down this file is dead code aimed at
      classes that no longer exist.
   2. **Dash injects its component CSS *after* this file.** Ties on
      specificity therefore go to Dash, not to us — so each rule here carries
      `.ds-page` *and* keeps Dash's own selector depth, staying one
      class ahead rather than relying on source order.

   LUX's own inputs are underline-only (border-width 0 with a bottom rule) on
   a gray field, which is what made these read as unstyled gray slabs. */

/* --- Text and number inputs --- */
.ds-page .form-control,
.ds-page .form-control-sm {
    height: 32px;
    min-height: 32px;
    padding: 5px 10px;
    border: 1px solid var(--hairline-strong);
    border-radius: 6px;
    background-color: var(--card);
    font-size: 0.82rem;
    line-height: 1.35;
    color: var(--ink);
    box-shadow: none;
    transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.ds-page .form-control::placeholder {
    color: var(--mute);
}
.ds-page .form-control:focus {
    border-color: var(--action);
    box-shadow: 0 0 0 3px var(--action-soft);
    background-color: var(--card);
    color: var(--ink);
}
.ds-page .form-control:disabled {
    background-color: var(--card-sunken);
    color: var(--mute);
    cursor: not-allowed;
}
/* Money and rates are figures, not prose — right-aligned and tabular, the
   same rule the data grid follows. */
.ds-page input[type="number"].form-control,
.ds-page .affix-field .form-control {
    text-align: right;
    font-variant-numeric: tabular-nums;
}
.ds-page .form-label {
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--mute);
    margin-bottom: 4px;
}

/* --- Affixes ($ prefix, % suffix) ---
   Replaces two parallel systems: `%` fields borrowed `.value-box`, a 92px
   slider-readout component from Property Setup, while `$` fields were
   unclassed default-size groups — so the same idea rendered at two heights
   with two different addon treatments. `.affix-field` is one class for both
   sides; whether the addon leads or trails is decided by call-site order,
   not by a separate class.

   The affix is *part of* the field, so the border is drawn once around the
   whole group and the seam between addon and input carries none. Focus is
   `:focus-within` on the group for the same reason — focusing the input
   should light the entire control, not the half you happen to be in.

   The unit is a *ghost* — mute text on the field's own ground, no fill and
   no divider. The earlier filled chip made a single control read as two
   glued parts, which is what made the `$` field look like a gray slab.

   Money and percentages then diverge deliberately, because they're used
   differently: a dollar amount is typed once and read (right-aligned, no
   stepper), while a rate is nudged (centred, flanked by `−`/`+`). */
.ds-page .affix-field {
    display: inline-flex;
    align-items: stretch;
    border: 1px solid var(--hairline-strong);
    border-radius: 6px;
    background: var(--card);
    overflow: hidden;
    flex-wrap: nowrap;
    transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.ds-page .affix-field:focus-within {
    border-color: var(--action);
    box-shadow: 0 0 0 3px var(--action-soft);
}
.ds-page .affix-field .form-control,
.ds-page .affix-field .input-group-text {
    border: 0;
    border-radius: 0;
    box-shadow: none;
    height: 30px;
    min-height: 30px;
}
.ds-page .affix-field .form-control:focus {
    box-shadow: none;
}
.ds-page .affix-field .input-group-text {
    background: transparent;
    color: var(--mute);
    font-size: 0.78rem;
    font-weight: 400;
    padding: 0 8px;
    display: flex;
    align-items: center;
}
/* Native spinners are suppressed on every affix field: dollars get no
   stepper at all, and percentages get the flanking pair below. Chrome's own
   arrows are ~10px, hover-only, and land between the value and the unit —
   a position nobody chose, it's just where the browser puts them. */
.ds-page .affix-field input[type="number"]::-webkit-outer-spin-button,
.ds-page .affix-field input[type="number"]::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}
.ds-page .affix-field input[type="number"] {
    -moz-appearance: textfield;
}

/* Money: the `$` leads, the figure is right-aligned and tabular. */
.ds-page .affix-money .form-control {
    text-align: right;
}
.ds-page .affix-money .input-group-text {
    padding-right: 2px;
}

/* Percent: `−` value `%` `+`. The unit stays welded to the number so the
   stepper never interrupts the pair, which was the complaint about the
   native spinners. Buttons are injected by assets/field_stepper.js — see
   that file for why they aren't in the Python.

   Centring here means centring the *ink* — the glyphs `6%` — not the input's
   box. Two earlier attempts got this wrong in the same way: `text-align:
   center` centres the text inside the input, after which the trailing `%`
   pushes the pair right; and a fixed-width right-aligned box centres the box
   while parking the digits against its right edge, which leaves a wide gap
   after `−` that grows and shrinks with the digit count.

   So the input has to shrink to its content.
   The input's width is set inline by field_stepper.js from the measured
   width of its own text, so the box *is* the ink. Two CSS-only attempts
   failed here and both failed the same way — the box centred while the
   glyphs sat at one end of it — so the width is now computed rather than
   guessed. `field-sizing: content` looked like the clean answer but did not
   collapse the box in this flex context.

   Centring is done with `margin-right: auto` on two elements, never
   `margin-left`. Bootstrap's input-group border-collapse rule
   (`.input-group > :not(:first-child):not(.dropdown-menu)…`) chains six
   `:not()`s, which scores (0,7,0) and owns `margin-left` on every child but
   the first — no practical selector here outranks it. `margin-right` it
   never touches, and the leading `−` is `:first-child` so it escapes the
   rule entirely. One auto after `−` and one after the unit splits the free
   space evenly around the pair.

   Note the `input[type="number"]` qualifier. The shared numeric rule above
   is `.ds-page input[type="number"].form-control` — (0,3,1), because
   the attribute selector counts — which quietly outranks a plain
   three-class selector here. Matching its shape is what makes these apply. */
/* `text-align: center`, not `right`: the box is text-sized, so the only
   slack is the caret allowance and the floor on very short values. Centred,
   that slack splits either side instead of pooling on the left and pushing
   the pair off-centre — worth ~6px on a single digit. */
.ds-page .affix-pct input.form-control {
    flex: 0 0 auto;
    width: 46px;
    min-width: 0;
    text-align: center;
    padding: 0;
}
.ds-page .affix-pct .input-group-text {
    flex: 0 0 auto;
    padding: 0 0 0 2px;
    margin-right: auto;
}
.ds-page .affix-pct .affix-step:first-child {
    margin-right: auto;
}
/* Unit-less variant — a plain counter (deposit in months) whose unit is
   spelled out in the text beside it. With no unit element to carry the
   trailing auto margin, the input carries it. */
.ds-page .affix-count input.form-control {
    margin-right: auto;
}
/* Deliberately no resting fill. A `card-sunken` ground was tried first and
   read as two gray slabs bracketing the value — heavier than the control
   deserves, and heavier than DESIGN.md asks for anywhere (it never specified
   this ground at all). The glyphs carry the affordance; the tint arrives on
   hover, at the moment of intent. */
.ds-page .affix-step {
    width: 26px;
    flex: 0 0 26px;
    border: 0;
    padding: 0;
    background: transparent;
    color: var(--mute);
    font-size: 0.9rem;
    line-height: 1;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background-color 0.12s ease, color 0.12s ease;
}
.ds-page .affix-step:hover {
    background: var(--action-soft);
    color: var(--action);
}
.ds-page .affix-step:disabled {
    color: var(--faint);
    cursor: not-allowed;
    background: transparent;
}

/* Percent, slider-linked variant — Property Setup's down payment, closing
   costs, loan rate and amortization readouts. A plain `.affix-pct` always
   gets a stepper because "a rate is nudged, not typed" — but these sit
   beside a slider that already nudges the same value, and two nudge
   affordances on one control is exactly what this system keeps deleting
   elsewhere. `.affix-linked` drops the stepper (excluded from
   assets/field_stepper.js's selector) and right-aligns like money instead
   of centering, since there's no button pair to centre between. Where a
   field has no slider — the tax matrix — it stays plain `.affix-pct` with
   the stepper. */
.ds-page .affix-pct.affix-linked input.form-control {
    width: auto;
    flex: 1 1 auto;
    min-width: 0;
    text-align: right;
    padding: 0;
}
.ds-page .affix-pct.affix-linked .input-group-text {
    flex: 0 0 auto;
    margin-right: 0;
    padding: 0 8px 0 2px;
}

/* Width scale. Replaces four unrelated mechanisms that had grown up
   independently — `.value-box`'s 92px cap, an inline `width: 120px`, a 200px
   cap, and nothing at all — which is why one field bled to the full card
   width while another truncated a two-decimal figure. Width is chosen per
   call site from the longest value the field can actually hold, not from
   the column it happens to sit in.

   `sm` is the *floor* for anything with a stepper: two 26px buttons plus
   the 46px value box need 98px, so `xs` overflows and is for money or plain
   numeric fields only. */
.ds-page .fw-xs { width: 76px; }
.ds-page .fw-sm { width: 116px; }
.ds-page .fw-md { width: 144px; }
.ds-page .fw-lg { width: 190px; }
/* The empty-state amount is the hero of the card it sits in, so it keeps a
   larger figure and a taller frame while using the same affix component —
   the one sanctioned size departure. Width now comes from `fw-lg` like any
   other field; the old 200px cap here was a fourth private width mechanism.
   `display: flex` (not inline-flex) is what lets the auto margins centre it. */
.ds-page .expense-empty-value-box {
    display: flex;
    margin-left: auto;
    margin-right: auto;
}
.ds-page .expense-empty-value-box .form-control,
.ds-page .expense-empty-value-box .input-group-text {
    height: 36px;
    min-height: 36px;
}
.ds-page .expense-empty-value-box .form-control {
    font-size: 1rem;
    font-weight: 600;
}

/* --- Dropdown (Dash 4 / Radix) --- */
.ds-page .dash-dropdown {
    min-height: 32px;
    height: 32px;
    padding: 0 10px;
    border: 1px solid var(--hairline-strong);
    border-radius: 6px;
    background: var(--card);
    font-size: 0.82rem;
    color: var(--ink);
    box-shadow: none;
    transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.ds-page .dash-dropdown:hover {
    border-color: var(--mute);
}
.ds-page .dash-dropdown:focus-visible,
.ds-page .dash-dropdown[data-state="open"] {
    border-color: var(--action);
    box-shadow: 0 0 0 3px var(--action-soft);
    outline: none;
}
.ds-page .dash-dropdown .dash-dropdown-value {
    font-size: 0.82rem;
    color: var(--ink);
}
.ds-page .dash-dropdown .dash-dropdown-placeholder {
    color: var(--mute);
}
.ds-page .dash-dropdown .dash-dropdown-trigger-icon {
    color: var(--mute);
    transition: transform 0.12s ease;
}
.ds-page .dash-dropdown[data-state="open"] .dash-dropdown-trigger-icon {
    transform: rotate(180deg);
}

/* Menu. Dash ships a heavy two-layer drop shadow here; `popover` is the
   system's one elevation for floating surfaces. */
.ds-page .dash-dropdown-content,
.ds-page .dash-datepicker-content {
    border: 1px solid var(--hairline);
    border-radius: 6px;
    background: var(--card);
    box-shadow: var(--popover);
    padding: 4px;
}
.ds-page .dash-dropdown-content .dash-dropdown-option {
    padding: 6px 10px;
    border-radius: 4px;
    font-size: 0.82rem;
    color: var(--ink);
    cursor: pointer;
}
.ds-page .dash-dropdown-content .dash-dropdown-option:hover {
    background: var(--card-hover);
}
/* Selected is the one place `action` appears in the menu — a tint, not a
   fill, so a long list doesn't turn into a block of indigo. */
.ds-page .dash-dropdown-content .dash-dropdown-option.selected {
    background: var(--action-soft);
    color: var(--action-hover);
    font-weight: 500;
}

/* The menu's search box was the one place a *second* accent reached the
   screen: Dash styles it with its own purple, which sat next to our indigo
   focus ring on the trigger directly above it. Same field treatment as
   everything else, same `action` focus.

   It also *overlapped the options* — visible as state names rendering
   straight through the white search field. Two causes, both in Dash's own
   CSS: `.dash-dropdown-content` is the scroll container and the search box
   is `position: sticky` inside it at `z-index: auto`, so the options list —
   a later sibling in DOM order — paints on top of it; and Dash's
   `margin: 8px` leaves a transparent gutter around the box that scrolling
   options show through even where the stacking is right. Raising a z-index
   fixes only the first and leaves the gutter, so the box is made a real
   flex header instead and the options list does its own scrolling. The
   inline `max-height` Radix sets on the content still governs total popup
   height. */
.ds-page .dash-dropdown-content {
    display: flex;
    flex-direction: column;
    overflow: hidden;
}
.ds-page .dash-dropdown-content .dash-dropdown-options {
    flex: 1 1 auto;
    /* Without this a flex child refuses to shrink below its content and
       scrolls the popup instead of itself. */
    min-height: 0;
    overflow-y: auto;
}
.ds-page .dash-dropdown-content .dash-dropdown-search-container {
    position: static;
    flex: 0 0 auto;
    height: 30px;
    border: 1px solid var(--hairline-strong);
    border-radius: 6px;
    background: var(--card);
    margin: 0 0 4px;
    transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.ds-page .dash-dropdown-content .dash-dropdown-search-container:focus-within {
    border-color: var(--action);
    box-shadow: 0 0 0 3px var(--action-soft);
}
.ds-page .dash-dropdown-content .dash-dropdown-search {
    font-size: 0.82rem;
    color: var(--ink);
    background: transparent;
    border: 0;
    outline: none;
}
.ds-page .dash-dropdown-content .dash-dropdown-search::placeholder {
    color: var(--mute);
}

/* --- Date picker ---
   The border lives on the wrapper, not the input, so the field styling and
   the focus state both have to go there.

   `justify-content: space-between` is load-bearing: Dash auto-sizes the
   input to its own text, so with the default packing the chevron came to
   rest wherever the text happened to end — mid-field, with dead space to
   its right. The dropdown already pins its caret to the edge; this makes
   the two controls agree. */
.ds-page .dash-datepicker-input-wrapper {
    height: 32px;
    padding: 0 10px;
    border: 1px solid var(--hairline-strong);
    border-radius: 6px;
    background: var(--card);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.ds-page .dash-datepicker-input-wrapper > svg {
    flex: 0 0 auto;
    color: var(--mute);
}
.ds-page .dash-datepicker-input-wrapper:focus-within {
    border-color: var(--action);
    box-shadow: 0 0 0 3px var(--action-soft);
}
.ds-page .dash-datepicker-input {
    font-size: 0.82rem;
    color: var(--ink);
    background: transparent;
    border: 0;
    outline: none;
}
.ds-page .dash-datepicker-input::placeholder {
    color: var(--mute);
}

/* Calendar popup. Stock Dash renders 16px day figures on a 34px cell with no
   selected treatment of its own worth keeping. */
.ds-page .dash-datepicker-calendar-wrapper {
    --day-size: 32px;
    padding: 4px;
}
.ds-page .dash-datepicker-controls {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 2px 2px 8px;
}
.ds-page .dash-datepicker-month-nav {
    width: 26px;
    height: 26px;
    padding: 0;
    border: 0;
    border-radius: 6px;
    background: transparent;
    color: var(--mute);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}
.ds-page .dash-datepicker-month-nav:hover {
    background: var(--card-sunken);
    color: var(--ink);
}
/* The month select and year stepper sit inside the popup and would otherwise
   inherit the full 32px field treatment, which overwhelms a 254px calendar.
   The year stepper is the second place Dash's own purple accent surfaced —
   it ships unstyled, so it gets the same field treatment as everything
   else rather than a third look. */
.ds-page .dash-datepicker-controls .dash-dropdown,
.ds-page .dash-datepicker-controls .dash-input {
    height: 28px;
    min-height: 28px;
    font-size: 0.78rem;
}
.ds-page .dash-datepicker-controls .dash-input-container {
    height: 28px;
    border: 1px solid var(--hairline-strong);
    border-radius: 6px;
    background: var(--card);
    transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.ds-page .dash-datepicker-controls .dash-input-container:focus-within {
    border-color: var(--action);
    box-shadow: 0 0 0 3px var(--action-soft);
}
.ds-page .dash-datepicker-controls .dash-input-element {
    font-size: 0.78rem;
    color: var(--ink);
    background: transparent;
    border: 0;
    text-align: center;
    font-variant-numeric: tabular-nums;
}
.ds-page .dash-datepicker-controls .dash-input-stepper {
    color: var(--mute);
    background: transparent;
    border: 0;
    cursor: pointer;
}
.ds-page .dash-datepicker-controls .dash-input-stepper:hover {
    color: var(--action);
}
.ds-page .dash-datepicker-calendar {
    border-collapse: separate;
    border-spacing: 1px;
}
/* Weekday header: the same eyebrow the grid header uses. */
.ds-page .dash-datepicker-calendar thead th {
    font-size: 0.66rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--mute);
    padding-bottom: 4px;
}
.ds-page .dash-datepicker-calendar td {
    height: 30px;
    border-radius: 6px;
    font-size: 0.8rem;
    color: var(--ink);
    text-align: center;
    vertical-align: middle;
    cursor: pointer;
    font-variant-numeric: tabular-nums;
}
.ds-page .dash-datepicker-calendar td:hover {
    background: var(--card-sunken);
}
/* Days from the neighbouring month stay legible but clearly secondary —
   dropping them out entirely makes the grid hard to scan. */
.ds-page .dash-datepicker-calendar td.dash-datepicker-calendar-date-outside {
    color: var(--mute);
    opacity: 0.6;
}
.ds-page .dash-datepicker-calendar td.dash-datepicker-calendar-date-selected {
    background: var(--action);
    color: #fff;
    font-weight: 600;
}
.ds-page .dash-datepicker-calendar td.dash-datepicker-calendar-date-selected:hover {
    background: var(--action-hover);
}
.ds-page .dash-datepicker-calendar td.dash-datepicker-calendar-date-highlighted {
    background: var(--action-soft);
    color: var(--action-hover);
}
.ds-page .dash-datepicker-calendar td.dash-datepicker-calendar-date-disabled,
.ds-page .dash-datepicker-calendar td[aria-disabled="true"] {
    color: var(--mute);
    opacity: 0.4;
    cursor: not-allowed;
    background: transparent;
}

/* ===== Horizontal slider (plain dcc.Slider) =====
   Property Setup's four sliders (down payment, closing costs, loan rate,
   amortization) — Dash 4's Radix primitives again (`dash-slider-*`), the
   same markup family the vertical benchmark rail uses, but unscoped here so
   it's the default for any plain horizontal Slider on a themed page. The
   rail's own `.ds-page .benchmark-slider-layer .dash-slider-*` rules stay
   more specific (3 classes vs. 2) and win regardless of source order, so
   this doesn't touch it.

   Dash's own thumb/track geometry (16px thumb, 4px track) was already
   right — only the colour needed to move off Dash's default purple onto
   the system's tokens. */
.ds-page .dash-slider-track {
    background-color: var(--hairline-strong);
}
.ds-page .dash-slider-range {
    background-color: var(--action);
}
.ds-page .dash-slider-thumb {
    background-color: var(--card);
    border-color: var(--action);
    box-shadow: var(--raised);
    cursor: grab;
}
.ds-page .dash-slider-thumb:hover,
.ds-page .dash-slider-thumb:focus-visible {
    box-shadow: 0 0 0 4px var(--action-soft);
}
.ds-page .dash-slider-thumb:active {
    cursor: grabbing;
}
.ds-page .dash-slider-mark {
    color: var(--mute);
    font-size: 0.72rem;
}
.ds-page .dash-slider-dot {
    background-color: var(--card);
    border-color: var(--hairline-strong);
}
.ds-page .dash-slider-tooltip {
    background: var(--ink);
    color: #fff;
    box-shadow: var(--popover);
}

/* Note: `.fin-date-input` is a browser-native `type="date"` field, not
   `DatePickerSingle` — it needs no rule of its own here. `dbc.Input` renders
   it with the base `.form-control` class, so it already picked up the
   32px/6px/hairline-strong geometry from `.ds-page .form-control` above the
   moment `.ds-page` landed on the page root. */

/* ===== Note =====
   One component for every inline message on the page, replacing three
   bespoke ones (`dbc.Alert color="info"`, `.capex-tip`, `.ca-mode-warn`).
   Tone is the only thing that varies, and it maps to *why the message
   exists*, not to how important it feels:

   - `neutral`  — states a constraint or an assumption. The default.
   - `action`   — suggests something the reader can do.
   - `warning`  — flags a real risk of a wrong number.
   - `danger`   — something is broken. Nothing on Expenses uses it yet;
                  it exists so the next person doesn't invent a fourth thing.

   The rule this replaces: the read-only rollup notice was a full-bleed
   saturated blue block. Blue is `action` here, and `action` means "do
   something" — spending it on an explanation is the same category error as
   letting Dash's purple through. Explanations get no colour at all.

   Only `action` carries a field, because it's the only tone offering the
   reader a next step and the only one that has earned the weight. Warning
   follows DESIGN.md's existing rule — routine callouts stay neutral with
   warning-coloured text rather than an orange field, which "runs hot". */
.ds-page .note {
    display: flex;
    align-items: flex-start;
    gap: 7px;
    font-size: 0.78rem;
    line-height: 1.45;
    color: var(--mute);
    margin: 0 0 10px;
}
.ds-page .note-icon {
    flex: 0 0 auto;
    line-height: 1.35;
    font-size: 0.8rem;
    color: var(--mute);
}
.ds-page .note-text {
    flex: 1 1 auto;
    min-width: 0;
}
/* Dismiss sits at the far end and stays quiet until reached for — it's an
   escape hatch, not a call to action. */
.ds-page .note-dismiss {
    flex: 0 0 auto;
    margin-left: auto;
    background: transparent;
    border: 0;
    padding: 0 2px;
    font-size: 0.8rem;
    line-height: 1;
    color: var(--mute);
    cursor: pointer;
    border-radius: 4px;
}
.ds-page .note-dismiss:hover {
    color: var(--ink);
    background: var(--card-sunken);
}

/* Action — the one tone with a field, since it offers a next step. */
.ds-page .note-action {
    align-items: center;
    background: var(--action-soft);
    border-radius: 6px;
    padding: 8px 10px;
    color: var(--action-hover);
}
.ds-page .note-action .note-icon {
    color: var(--action);
}
.ds-page .note-badge {
    flex: 0 0 auto;
    background: var(--action);
    color: #fff;
    font-size: 0.62rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    border-radius: 999px;
    padding: 2px 8px;
}

/* Warning — neutral ground, warning ink. Per DESIGN.md, `warning-soft`
   runs hot and is reserved for rare, genuinely serious cases. */
.ds-page .note-warning {
    color: var(--warning-deep);
}
.ds-page .note-warning .note-icon {
    color: var(--warning);
}

.ds-page .note-danger {
    color: var(--danger-deep);
}
.ds-page .note-danger .note-icon {
    color: var(--danger);
}

/* Data grid: 36px rows, hairline row separators (never vertical gridlines —
   columns separate by alignment), card-hover on hover, action-soft on
   selection. Alpine is configured through its own custom properties rather
   than fighting its selectors.

   The header carries no fill. DESIGN.md line 511 specs `card-sunken` here,
   but a flat tint doesn't read as sunken — it just reads as a gray band, and
   with a hairline bottom border the header already separates cleanly. Revisit
   only if the grid needs a stronger header on a denser page. */
.ds-page .ag-theme-alpine {
    --ag-header-background-color: transparent;
    --ag-header-column-separator-display: none;
    --ag-row-height: 36px;
    --ag-header-height: 36px;
    --ag-font-size: 13px;
    --ag-borders: solid;
    --ag-border-color: var(--hairline);
    --ag-row-border-color: var(--hairline);
    --ag-cell-horizontal-border: none;
    --ag-row-hover-color: var(--card-hover);
    --ag-selected-row-background-color: var(--action-soft);
}
.ds-page .ag-theme-alpine .ag-header-cell-label {
    color: var(--mute);
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.6px;
    text-transform: uppercase;
}

/* Read-only cells carry no resting fill. Editable is the *minority* in these
   grids (Amount alone in most), so tinting everything else turned the table
   gray. Hover on the genuinely-editable cells carries the affordance instead
   — .ro-cell exists only to exclude the rest from that hover, and is applied
   via cellClassRules (cellClass loses to `type: "numericColumn"`). */
.ds-page .ag-theme-alpine .ag-cell:not(.ro-cell):not(.ag-cell-inline-editing):hover {
    background-color: var(--action-soft);
    cursor: text;
}

/* Columns auto-size to their content, so on a wide screen the last column
   stops short of the grid's right edge — leaving row separators hanging in
   white space. AG Grid sets these container widths inline from the summed
   column widths; min-width overrides that without touching the columns
   themselves, and stays inert when the columns genuinely overflow. */
.ds-page .ag-theme-alpine .ag-center-cols-container,
.ds-page .ag-theme-alpine .ag-header-container {
    min-width: 100%;
}

/* Plotly writes font-family as an inline style on every SVG <text>, defaulting
   to Open Sans, so only !important reaches it. Doing it here rather than adding
   a font to six separate update_layout() calls keeps the pilot in one place and
   trivially reversible. */
.ds-page .js-plotly-plot text {
    font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        sans-serif !important;
    /* DESIGN.md makes tabular numerals mandatory on chart axes. Plotly has no
       layout property for this, and it writes an inline `font-variant: normal`
       shorthand on every <text> — which resets the longhand — so this needs
       !important to land, same as the family above. Verified in the live DOM:
       without it, computed font-variant-numeric stayed `normal`. */
    font-variant-numeric: tabular-nums !important;
}

/* ===== App Container ===== */
.app-container {
    display: flex;
    min-height: 100vh;
}

/* ===== Sidebar =====
   A real flex item, for the same reason the AI pane is one (see the block at
   .ai-chat-pane). This was `position: fixed` with `margin-left: 240px` on
   .content-area standing in for it — but a fixed element is out of flow, so
   that margin reserved nothing and merely pushed the row 240px wider than
   #app-container. With the pane added on the right the row overflowed the
   viewport by ~190px and the pane's own collapse button was laid out
   off-screen, which is what made the pane look impossible to close a second
   time (2026-08-13: measured, not inferred — the button was always in the DOM
   at its correct size).

   `sticky` + `100vh` + `align-self: flex-start` keeps the pinned-while-the-
   page-scrolls behavior the fixed version had. The nav flyout is unaffected:
   it is `position: fixed` and sticky creates no containing block for that,
   so its `left: 232px` still lands against a sidebar that still starts at 0. */
.sidebar {
    width: 240px;
    min-width: 240px;
    flex: none;
    background-color: var(--canvas);
    color: var(--body);
    border-right: 1px solid var(--hairline);
    padding: 0;
    position: sticky;
    top: 0;
    align-self: flex-start;
    height: 100vh;
    overflow-y: hidden;
    z-index: 100;
    display: flex;
    flex-direction: column;
}

/* Scrolls independently between the frozen brand header and footer */
.sidebar-scroll {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
}

.sidebar-brand {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 20px 20px 12px 20px;
    flex-shrink: 0;
}

.sidebar-title {
    font-size: 1.25rem;
    font-weight: 700;
    color: var(--ink);
    letter-spacing: 0.02em;
}

.sidebar-divider {
    border-color: var(--hairline);
    margin: 0 16px 8px 16px;
}

.sidebar-nav {
    display: flex;
    flex-direction: column;
    padding: 0 8px;
}

/* ===== Live financial summary ===== */
.sidebar-live-link {
    display: block;
    text-decoration: none !important;
    padding: 0 12px;
    margin-top: 6px;
    margin-bottom: 18px;
}
/* Spec: Claude Design project ce8a7e98 (PeakCard.dc.html). Colors, weights and
   tracking are copied verbatim; sizes are the design's ratios rescaled — the
   component is authored on a 380px card and this sidebar is 216px, where the
   literal px values overflow. The 40px hero is the anchor and every other size
   is its design ratio against that. */
.sidebar-live-inner {
    background: var(--card);
    border: 1px solid var(--hairline);
    box-shadow: var(--raised);
    border-radius: 13px;
    padding: 17px 16px;
    transition: background-color 0.15s ease, border-color 0.15s ease;
}
.sidebar-live-link:hover .sidebar-live-inner {
    background: var(--card-hover);
    border-color: var(--faint);
}
.live-idle { color: var(--mute); font-size: 0.75rem; line-height: 1.4; }

/* Sub-captions — AT PEAK IN / IF SOLD TODAY (16px of 40 in the spec) */
.live-cap {
    font-size: 0.66rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--mute);
}
/* Section headers — AHEAD OF S&P / BEHIND S&P (22px of 40 in the spec) */
.live-cap-lg {
    font-size: 0.9rem;
    letter-spacing: 0.06em;
    line-height: 1.15;
    color: var(--mute);
}
.live-cap-row { line-height: 1; }
.live-cap-year { font-size: 0.6rem; font-weight: 600; color: var(--mute); }
.live-cap-spaced { display: block; margin-top: 0; }
.live-muted { font-size: 0.8rem; color: var(--mute); margin-top: 2px; }

/* "AT PEAK IN  [17 yrs · 2043]" */
.live-peak-row {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-top: 2px;
}
/* At 216px sidebar width the caption + pill only fit on one line if the caption
   can't wrap and the slack between them is reclaimed (tighter tracking, gap and
   pill padding). */
.live-peak-row .live-cap {
    white-space: nowrap;
    letter-spacing: 0.04em;
    flex: 0 0 auto;
}
.live-peak-row .live-pill {
    padding: 4px 9px;
    letter-spacing: 0;
}
.live-pill {
    background: var(--card-sunken);
    color: var(--ink);
    border-radius: 999px;
    padding: 5px 12px;
    font-size: 0.66rem;
    font-weight: 600;
    color: var(--ink);
    white-space: nowrap;
}

/* Peak IRR comparison, fenced by a 1px rule above and below */
.live-irr-block {
    margin: 13px 0;
    padding: 13px 0;
    border-top: 1px solid var(--hairline);
    border-bottom: 1px solid var(--hairline);
}
.live-irr-row {
    display: flex;
    align-items: center;
    gap: 8px;
}
/* "PEAK / IRR" — same flex-column + gap as .live-irr-col, so this line gap is
   identical to the value/sub-label gap below, not just visually close. */
.live-irr-label {
    display: flex;
    flex-direction: column;
    gap: 2px;
    flex-shrink: 0;
}
.live-irr-label-line {
    font-size: 0.825rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    line-height: 1;
    color: var(--mute);
    white-space: nowrap;
}
/* S&P and Prop. sit close together as one comparison unit — no "vs" divider,
   so the gap between them is tight while the label/flash keep their own
   breathing room via the row's larger gap. */
.live-irr-pair {
    display: flex;
    align-items: center;
    gap: 5px;
}
/* Each figure stacks its percentage over its label, left-aligned. */
.live-irr-col {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    flex-shrink: 0;
}
.live-irr-val { font-size: 0.825rem; line-height: 1; white-space: nowrap; }
.live-irr-sub {
    font-size: 0.825rem;
    font-weight: 500;
    line-height: 1;
    white-space: nowrap;
}
.live-irr-sp { color: var(--mute); font-weight: 600; }
.live-irr-sub-sp { color: var(--mute); }
.live-irr-prop { color: var(--ink); font-weight: 700; }
.live-irr-sub-prop { color: var(--ink); }
/* The flash gets its own lane on the right so nothing shifts when it fires. */
.live-irr-row .ghost-a, .live-irr-row .ghost-b { margin-left: auto; }

/* IRR change tint — deliberately quieter than the hero background pulse so the
   dollar figures stay the loudest thing in the widget. Distinct keyframe names
   per parity for the same animation-restart reason as the hero pulses. */
@keyframes irrUpA { 0% { color: var(--success); } 100% { color: var(--ink); } }
@keyframes irrUpB { 0% { color: var(--success); } 100% { color: var(--ink); } }
@keyframes irrDownA { 0% { color: var(--danger); } 100% { color: var(--ink); } }
@keyframes irrDownB { 0% { color: var(--danger); } 100% { color: var(--ink); } }
.irr-up-a { animation: irrUpA 2.25s ease; }
.irr-up-b { animation: irrUpB 2.25s ease; }
.irr-down-a { animation: irrDownA 2.25s ease; }
.irr-down-b { animation: irrDownB 2.25s ease; }

/* Hero numbers — the two figures that move on every assumption tweak */
.live-hero-row {
    display: flex;
    align-items: baseline;
    /* Only a floor — margin-left:auto on the flash sets the real spacing. Small
       enough that the widest hero + widest flash still fit the 182px line. */
    gap: 4px;
    flex-wrap: nowrap;
    margin: 6px 0;
}
/* Flash sits to the right of the hero number in its own lane — pushed there
   rather than sharing flow, so the number never shifts when it appears. */
.live-hero-row .ghost-a, .live-hero-row .ghost-b { margin-left: auto; }
.live-hero {
    font-size: 1.65rem;
    font-weight: 700;
    color: var(--ink);
    line-height: 1;
    letter-spacing: -0.01em;
    border-radius: 4px;
    padding: 0 3px;
    margin-left: -3px;
}

/* SELL alert — shown only when the verdict turns (HOLD stays silent).
   Two tones, set by `signal_display`, not by the word SELL: "Past its peak"
   still leads the index (warning), "S&P now ahead" does not (danger). Danger
   is the base so a missing tone class fails loud rather than quiet.
   Tiers per the scale: soft = the field, flat = the 1px shape, deep = the words.
   The border was rgba(220, 38, 38, 0.35) — Tailwind red-600, a survivor of the
   palette the verdict pass retired. */
.live-alert {
    display: flex;
    flex-direction: column;
    gap: 2px;
    background: var(--danger-soft);
    border: 1px solid var(--danger);
    border-radius: 6px;
    padding: 5px 8px;
    margin-bottom: 8px;
}
.live-alert-word { color: var(--danger-deep); font-weight: 800; font-size: 0.8rem; letter-spacing: 0.04em; }
/* -deep, not flat: this states a verdict in words, so it reads as text. */
.live-alert-reason { display: block; color: var(--danger-deep); font-size: 0.7rem; }

.live-alert.tone-warning { background: var(--warning-soft); border-color: var(--warning); }
.live-alert.tone-warning .live-alert-word,
.live-alert.tone-warning .live-alert-reason { color: var(--warning-deep); }

/* Directional pulse on change. Two parities (a/b) with DISTINCT keyframe names
   per direction — a changed animation-name is what forces the browser to restart
   the animation, so every change re-fires (a shared name only plays once). */
@keyframes pulseUpA { 0% { background: rgba(22, 163, 74, 0.18); } 100% { background: transparent; } }
@keyframes pulseUpB { 0% { background: rgba(22, 163, 74, 0.18); } 100% { background: transparent; } }
@keyframes pulseDownA { 0% { background: rgba(220, 38, 38, 0.15); } 100% { background: transparent; } }
@keyframes pulseDownB { 0% { background: rgba(220, 38, 38, 0.15); } 100% { background: transparent; } }
.pulse-up-a { animation: pulseUpA 2.25s ease; }
.pulse-up-b { animation: pulseUpB 2.25s ease; }
.pulse-down-a { animation: pulseDownA 2.25s ease; }
.pulse-down-b { animation: pulseDownB 2.25s ease; }

/* Inline +/- delta that fades out (distinct keyframe names per parity, same reason) */
@keyframes ghostFadeA { 0% { opacity: 1; } 60% { opacity: 1; } 100% { opacity: 0; } }
@keyframes ghostFadeB { 0% { opacity: 1; } 60% { opacity: 1; } 100% { opacity: 0; } }
.ghost-a { animation: ghostFadeA 4s ease forwards; }
.ghost-b { animation: ghostFadeB 4s ease forwards; }
.ghost-a, .ghost-b {
    display: inline-block;
    font-size: 0.7rem;
    font-weight: 700;
    /* "−$82.1k" is one token — without these the right-pushed lane gets squeezed
       and the sign breaks onto a second line. */
    white-space: nowrap;
    flex: 0 0 auto;
}
.ghost-up { color: var(--success-deep); }
.ghost-down { color: var(--danger-deep); }

/* ===== Nav Links (Top-Level) ===== */
.nav-link-item {
    color: var(--body) !important;
    padding: 10px 16px !important;
    border-radius: 6px !important;
    margin-bottom: 2px;
    font-size: 0.9rem;
    font-weight: 500;
    transition: background-color 0.15s ease, color 0.15s ease;
    text-decoration: none !important;
}

.nav-link-item:hover {
    color: var(--ink) !important;
    background-color: rgba(12, 35, 64, 0.05) !important;
}

.nav-link-item.active {
    color: var(--ink) !important;
    background-color: var(--card) !important;
    box-shadow: var(--raised);
    font-weight: 600;
}

/* Wraps one top-level link. It exists to carry `data-nav-label` for the rail's
   tooltip — dbc.NavLink refuses unknown props, so the attribute cannot live on
   the link itself — and to anchor that tooltip. */
.nav-item {
    position: relative;
}

/* Every item is now glyph + label, so the row is a flex line in both states
   and the collapsed rail only has to hide the label. */
.nav-link-item {
    display: flex !important;
    align-items: center;
    gap: 12px;
    position: relative;
}

/* The icon slot. Fixed width in both states so the glyph sits at the same x
   whether the label is there or not — collapsing then reads as the label
   sliding away, not the whole row jumping left.

   The SVG is a mask, not a background image or an <img>: masking paints the
   shape in `background-color`, so `currentColor` makes every glyph inherit
   the link's colour and follow the hover and active states with no per-state
   icon. Dash's `html` module also has no SVG elements, so inline was not on
   the table without a new dependency. */
.nav-glyph {
    flex: 0 0 20px;
    width: 20px;
    height: 20px;
    background-color: currentColor;
    opacity: 0.7;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: center;
    mask-position: center;
    -webkit-mask-size: 20px 20px;
    mask-size: 20px 20px;
    transition: opacity 0.15s ease;
}

.nav-link-item:hover .nav-glyph { opacity: 0.9; }
.nav-link-item.active .nav-glyph { opacity: 1; }

.nav-glyph--performance {
    -webkit-mask-image: url("/assets/icons/performance.svg");
    mask-image: url("/assets/icons/performance.svg");
}
.nav-glyph--financials {
    -webkit-mask-image: url("/assets/icons/financials.svg");
    mask-image: url("/assets/icons/financials.svg");
}
.nav-glyph--rental-income {
    -webkit-mask-image: url("/assets/icons/rental-income.svg");
    mask-image: url("/assets/icons/rental-income.svg");
}
.nav-glyph--expenses {
    -webkit-mask-image: url("/assets/icons/expenses.svg");
    mask-image: url("/assets/icons/expenses.svg");
}
.nav-glyph--property-setup {
    -webkit-mask-image: url("/assets/icons/property-setup.svg");
    mask-image: url("/assets/icons/property-setup.svg");
}

.nav-link-item .nav-link-label {
    min-width: 0;
    overflow: hidden;
    white-space: nowrap;
}

/* Parent items with sub-nav: glyph + label left, chevron right.
   `margin-left: auto` rather than `justify-content: space-between`, which
   would spread all three children now that the glyph is a third child and
   push the label into the middle of the row. */
.nav-link-item.has-subnav {
    justify-content: flex-start;
}

/* Points at the flyout. A glyph, never a rotated box — `transform` on nav
   descendants is reset by Dash's injected CSS (CLAUDE.md Gotchas). */
.nav-chevron {
    display: flex;
    align-items: center;
    margin-left: auto;
    font-size: 1.05rem;
    line-height: 1;
    opacity: 0.55;
}

/* Tracks `.is-open`, not `:hover` — the chevron is the parent's share of the
   panel's state, so brightening it on raw hover would light two parents at
   once and would flip it back during the close grace period while the panel
   is still up. `:focus-within` is gone for the same reason as on .nav-flyout:
   a clicked parent link stayed focused and kept its chevron lit. */
.nav-group.is-open .nav-chevron {
    opacity: 0.9;
}

/* ===== Nav flyout =====
   `.sidebar-scroll` is `overflow-y: auto`, which computes `overflow-x` to
   `auto` as well, so an absolutely-positioned panel escaping rightward is
   clipped. `position: fixed` escapes every clipping context; the `top` is
   set per item by assets/sidebar_flyout.js. Left overlaps the 240px sidebar
   by 8px so there is no dead gap for the pointer to cross. */
.nav-group {
    position: relative;
}

/* The one surface the sidebar floats over its neighbours: the sub-item flyout
   and the rail tooltip. Declared once, for both, because they appear side by
   side on the same hover — the tooltip used to be `--ink` on `--canvas` next
   to a white flyout, and sharing the rule is what stops that drifting back.
   Type lives here for the same reason: the tooltip read smaller and bolder than
   the flyout item beside it. `.nav-sub-item` inherits the size and weight from
   `.nav-flyout` instead of restating them, so there is one place to change them. */
.nav-flyout,
.sidebar.is-collapsed .nav-item[data-nav-label]:hover::after,
.sidebar.is-collapsed .nav-item[data-nav-label]:focus-within::after {
    background-color: var(--card);
    border: 1px solid var(--hairline);
    border-radius: 8px;
    box-shadow: var(--popover);
    font-size: 0.82rem;
    font-weight: 400;
}

.nav-flyout {
    position: fixed;
    left: 232px;
    top: 0;
    min-width: 190px;
    padding: 6px;
    display: flex;
    flex-direction: column;
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    /* No transition in the closed state, so closing is instantaneous. Declared
       on the base rule it applied in both directions, and a panel displaced by
       its neighbour opening kept painting for 120ms while the new one was
       already up — two panels visible at once, which is the bug this was
       supposed to fix. The fade-in lives on the open rule below. */
    transition: none;
    z-index: 200;
}

/* `.is-open` is the only thing that opens a panel, and assets/sidebar_flyout.js
   is the only thing that sets it. `:focus-within` used to be here as the
   keyboard path, but the parent link is focusable and clicking it left the
   group focused — so the panel latched open with the pointer elsewhere, and a
   second panel could then be hovered alongside it. Keyboard access now goes
   through the same class, opened on `:focus-visible` so a mouse click on the
   link doesn't count. */
.nav-group.is-open .nav-flyout {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
    transition: opacity 0.12s ease;
}

/* ===== Nav Sub-Items (inside the flyout) ===== */
.nav-sub-item {
    color: var(--body) !important;
    padding: 7px 12px !important;
    white-space: nowrap;
    border-radius: 6px !important;
    margin-bottom: 1px;
    transition: background-color 0.15s ease, color 0.15s ease;
    text-decoration: none !important;
}

.nav-sub-item:hover {
    color: var(--ink) !important;
    background-color: rgba(12, 35, 64, 0.05) !important;
}

.nav-sub-item.active {
    color: var(--ink) !important;
    background-color: var(--card-sunken) !important;
    font-weight: 500;
}

/* ===== Collapsed rail =====
   240px -> 64px, ~176px handed back to .content-area. The class is set by
   assets/nav_collapse.js from localStorage; see docs/specs/nav-collapse.md for
   why the state is not a dcc.Store.

   Not scoped to `.ds-page` — the sidebar is app shell and sits outside it, and
   every rule above here in this section is unscoped for the same reason. The
   `.ds-page` rule is about page content losing a specificity tie to Dash's
   injected CSS, which nothing here is competing with. */
.sidebar.is-collapsed {
    width: 64px;
    min-width: 64px;
    /* `.sidebar` sets `overflow-y: hidden`, and a hidden/visible pair computes
       the *visible* axis to `auto` — so the base rule clips horizontally too,
       and the tooltip was cut off mid-word at the sidebar's edge even with
       `.sidebar-scroll` opened up below. Both levels have to be visible. The
       flyout was unaffected only because `position: fixed` escapes every
       clipping context, which is exactly why it was built that way. */
    overflow: visible;
}

/* The two real losses, and the reason they are acceptable: the checklist is
   work the user is expected to finish while it is open, and the verdict card
   is knowingly given up by the person who collapsed the menu. Neither becomes
   unreachable — the card's target is the rail's first item. */
.sidebar.is-collapsed .sidebar-title,
.sidebar.is-collapsed .sidebar-onboarding-wrap,
.sidebar.is-collapsed .sidebar-live-link,
.sidebar.is-collapsed .nav-link-label,
.sidebar.is-collapsed .nav-chevron,
.sidebar.is-collapsed .save-status-label,
.sidebar.is-collapsed .sidebar-account-name {
    display: none;
}

/* `.sidebar-scroll` is `overflow-y: auto`, which computes `overflow-x` to
   `auto` and would clip the tooltip escaping rightward — the same trap that
   forced the flyout to `position: fixed`. Visible is safe *here specifically*
   because the rail's content is five items and Sign Out with the checklist and
   verdict card gone, which cannot overflow 100vh. If anything is ever added to
   the rail, this is the line that breaks first. */
.sidebar.is-collapsed .sidebar-scroll {
    overflow: visible;
}

.sidebar.is-collapsed .sidebar-brand {
    justify-content: center;
    padding: 20px 8px 12px 8px;
}

.sidebar.is-collapsed .sidebar-nav {
    padding: 0 10px;
}

/* `.nav-item` wraps the items with no sub-menu; `.nav-group` wraps the ones
   with a flyout. The group filled the rail, but the item shrank to 11px of a
   43px track with the leftover 32px showing up as a used `margin-right` — so
   `justify-content: center` below was faithfully centring the glyph inside an
   11px box, and the three plain items sat 16px left of the two parents. The
   rule doing the shrinking is in Dash's injected CSS, which can't be read
   from script (cross-origin) — hence a definite width here rather than a
   counter-rule aimed at whatever it turns out to be. */
.sidebar.is-collapsed .nav-item {
    display: block;
    width: 100%;
    margin-right: 0;
}

.sidebar.is-collapsed .nav-link-item {
    justify-content: center;
    padding: 10px 0 !important;
}

.sidebar.is-collapsed .sidebar-footer {
    align-items: center;
}

.sidebar-account-initial { display: none; }

.sidebar.is-collapsed .sidebar-account-initial {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 26px;
    height: 26px;
    border-radius: 50%;
    background: var(--card-sunken);
    color: var(--body);
    font-size: 0.75rem;
    font-weight: 600;
}

/* The flyout overlaps the rail by 8px, same as it overlaps the 240px menu, so
   there is no dead gap for the pointer to cross. */
.sidebar.is-collapsed .nav-flyout {
    left: 56px;
}

/* Only the rail needs it: expanded, the parent's own label is right there. */
.nav-flyout-header { display: none; }

.sidebar.is-collapsed .nav-flyout-header {
    display: block;
    padding: 6px 12px 8px 12px;
    margin-bottom: 4px;
    border-bottom: 1px solid var(--hairline);
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--mute);
    white-space: nowrap;
}

/* ===== Rail tooltip =====
   In the rail the glyph is the only thing naming an item, so this is required,
   not decoration. Parents are excluded: they open a flyout on the same hover,
   and the header above carries their name instead. */
.sidebar.is-collapsed .nav-item[data-nav-label]:hover::after,
.sidebar.is-collapsed .nav-item[data-nav-label]:focus-within::after {
    content: attr(data-nav-label);
    position: absolute;
    left: calc(100% + 10px);
    top: 50%;
    transform: translateY(-50%);
    padding: 7px 12px;
    color: var(--body);
    white-space: nowrap;
    pointer-events: none;
    z-index: 300;
}

/* ===== Collapse button ===== */
.sidebar-collapse-btn {
    margin-left: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    padding: 0;
    border: 1px solid transparent;
    border-radius: 6px;
    background: transparent;
    color: var(--mute);
    cursor: pointer;
    transition: background-color 0.15s ease, color 0.15s ease;
}

.sidebar.is-collapsed .sidebar-collapse-btn {
    margin-left: 0;
}

.sidebar-collapse-btn:hover {
    background-color: rgba(12, 35, 64, 0.05);
    color: var(--ink);
}

.sidebar-collapse-glyph {
    font-size: 0.95rem;
    line-height: 1;
}

/* ===== Content Area ===== */
/* No margin-left: the sidebar is a flex item now and reserves its own 240px.
   min-width:0 so a wide grid shrinks this column instead of blowing the row
   past the viewport — the default `auto` min-width on a flex item is what
   lets an overflowing child re-create the exact bug the margin caused. */
.content-area {
    padding: 32px 40px;
    flex: 1;
    min-width: 0;
    min-height: 100vh;
    background-color: var(--canvas);
}

/* ===== AI assistant pane =====
   A real flex item, NOT the sidebar's fixed-left pattern — and that difference
   is the whole reason this section exists.

   It was `position: fixed` with `margin-right: 360px` on .content-area
   standing in for the space it occupied. But a fixed element is out of layout,
   so that margin only *simulates* a reservation: any descendant that is
   absolutely positioned, sized in vw, or measured against the viewport by JS
   escapes it and slides underneath the pane. That produced overlap on some
   pages and not others with no pattern anyone could see from the outside
   (Evan, 2026-08-13 — "I cannot find a pattern"), which is the signature of a
   layout that was never actually reserved.

   As a flex item the row reserves the width for real and overlap stops being
   possible rather than being fixed page by page. `sticky` + `100vh` +
   `align-self: flex-start` keeps the pinned-while-the-page-scrolls behavior
   the fixed version had; without the align-self the item stretches to the
   container's full height and sticky has no travel. */
.ai-chat-pane {
    flex: none;
    width: 360px;
    position: sticky;
    top: 0;
    align-self: flex-start;
    height: 100vh;
    z-index: 100;
    display: flex;
    flex-direction: column;
    background-color: var(--card);
    border-left: 1px solid var(--hairline);
}

/* ----- collapsed tab -----
   Always in the DOM, revealed by the collapsed class. It carries the name and
   not just a chevron on purpose: while collapsed this is the assistant's only
   surface, and "the user doesn't know it exists" is the exact problem the pane
   has. A bare mystery strip would make that worse.

   Positioned absolute against a zero-width pane, so it protrudes into the
   page's right-hand whitespace and the content area gets all 360px back. The
   vertical offset is `margin-top` against a known height rather than a
   translate, because `transform` on a button is reset by Dash's injected CSS
   and cannot be won (CLAUDE.md) — which is also why the text stacks in two
   flat lines instead of being rotated. */
.ai-chat-rail {
    display: none;
    position: absolute;
    right: 0;
    /* Toward the top, not against it. This puts the tab within a few pixels of
       where the header's collapse chevron sits when the pane is open, so open
       and close are the same gesture in the same place — the reason Evan asked
       for the corner rather than the middle. */
    top: 12px;
    width: 62px;
    padding: 8px 0 9px;
    gap: 1px;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background-color: var(--card);
    border: 1px solid var(--hairline);
    border-right: none;
    border-radius: 8px 0 0 8px;
    box-shadow: var(--raised);
    cursor: pointer;
    color: var(--mute);
    line-height: 1.15;
}

.ai-chat-rail:hover {
    color: var(--ink);
    background-color: var(--card-hover);
}

/* Two horizontal lines, "Ask" over "Ray I." — the full name, which is the
   point of having one. Stacking is what lets a 62px tab carry it without
   turning the text sideways. */
.ai-chat-rail-label {
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.01em;
    color: var(--ink);
}

.ai-chat-rail-kicker {
    font-size: 9px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

/* Zero width and no border: the pane stops occupying the flex row entirely,
   the content area takes the full width back, and the tab — absolutely
   positioned, so it costs the layout nothing — is all that is left of it. */
.ai-chat-pane--collapsed {
    width: 0;
    border-left: none;
}

/* `!important` on this one list: chat.js writes the panel and composer
   display inline to switch tabs, and an inline style beats any rule here.
   Collapsed has to win over the tab state — the pane is zero width, so a
   panel left visible would paint outside it. */
.ai-chat-pane--collapsed .ai-chat-header,
.ai-chat-pane--collapsed .ai-chat-tabs,
.ai-chat-pane--collapsed .ai-chat-advice,
.ai-chat-pane--collapsed .ai-chat-log,
.ai-chat-pane--collapsed .ai-chat-composer {
    display: none !important;
}

.ai-chat-pane--collapsed .ai-chat-rail {
    display: flex;
    flex-direction: column;
}

/* ----- header -----
   A quiet label, not a title bar: at 13px/600 ink it competed with the page's
   own headings and read as a second app parked alongside the first. The pane
   should recede until it has something to say. */
.ai-chat-header {
    flex: none;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 16px 20px;
    border-bottom: 1px solid var(--hairline);
}

.ai-chat-title {
    font-size: 12px;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--mute);
}

.ai-chat-collapse {
    flex: none;
    width: 24px;
    height: 24px;
    padding: 0;
    font-size: 16px;
    line-height: 1;
    color: var(--mute);
    background: none;
    border: none;
    border-radius: 6px;
    cursor: pointer;
}

.ai-chat-collapse:hover {
    color: var(--ink);
    background-color: var(--card-sunken);
}

.ai-chat-log {
    flex: 1;
    overflow-y: auto;
    padding: 16px 20px;
    display: flex;
    flex-direction: column;
    gap: 12px;
}

/* Bubbles. User is a plain right-aligned line; assistant carries the
   rendered markdown and reads as page prose, not chrome. */
/* Tabular numerals, for the same reason the grids and KPI cards carry them:
   the assistant quotes figures straight out of those grids, and $1,240 in
   proportional digits beside $1,240 in tabular reads as a different
   application quoting a different number. */
.ai-chat-msg {
    font-size: 14px;
    line-height: 1.5;
    color: var(--body);
    font-variant-numeric: tabular-nums;
}

.ai-chat-msg--user {
    align-self: flex-end;
    max-width: 85%;
    padding: 8px 12px;
    border-radius: 10px;
    background-color: var(--action-soft);
    color: var(--ink);
    white-space: pre-wrap;
}

.ai-chat-msg--assistant {
    align-self: flex-start;
    max-width: 100%;
}

.ai-chat-msg--assistant p:first-child {
    margin-top: 0;
}

.ai-chat-msg--assistant p:last-child {
    margin-bottom: 0;
}

.ai-chat-msg--assistant code {
    font-family: "Geist Mono", ui-monospace, monospace;
    background-color: var(--card-sunken);
    padding: 1px 5px;
    border-radius: 4px;
    font-size: 13px;
}

.ai-chat-msg--assistant pre {
    background-color: var(--card-sunken);
    padding: 10px 12px;
    border-radius: 8px;
    overflow-x: auto;
}

.ai-chat-msg--assistant pre code {
    background: none;
    padding: 0;
}

/* Three-dot pulse while a stream has committed no tokens yet. */
.ai-chat-msg--waiting::after {
    content: "";
    display: inline-block;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background-color: var(--faint);
    animation: ai-chat-pulse 1s ease-in-out infinite;
}

@keyframes ai-chat-pulse {
    0%, 100% { opacity: 0.3; }
    50% { opacity: 1; }
}

/* Seeded questions. Shown only over an empty log (assets/chat.js), so they
   never compete with an answer. Left-aligned full-width rows rather than
   chips: these are sentences, and a chip that wraps to three lines reads as a
   broken chip. `--card-sunken` field, no border until hover — at rest they
   should read as an offer, not as three buttons demanding a decision. */
.ai-chat-seeds {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin-top: auto;
}

.ai-chat-seeds-title {
    margin: 0 0 2px;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--faint);
}

.ds-page .ai-chat-seed {
    display: block;
    width: 100%;
    text-align: left;
    font-size: 13px;
    line-height: 1.4;
    color: var(--body);
    background-color: var(--card-sunken);
    border: 1px solid transparent;
    border-radius: 8px;
    padding: 8px 10px;
    cursor: pointer;
}

.ds-page .ai-chat-seed:hover {
    border-color: var(--action);
    color: var(--ink);
}

.ds-page .ai-chat-seed:focus-visible {
    outline: 2px solid var(--action);
    outline-offset: 2px;
}

/* ===== The two tabs =====
   Advice is the deterministic audit, Ask is the transcript. Underline, not
   pills: a pill pair at the top of a 360px pane reads as a segmented control
   for filtering something, and these switch what the pane *is*.

   Everything interactive here is scoped to `.ds-page` — Dash injects its own
   component CSS after this file, so an unscoped button rule loses the
   specificity tie and computes to nothing (CLAUDE.md). */
.ai-chat-tabs {
    flex: none;
    display: flex;
    gap: 4px;
    padding: 0 20px;
    border-bottom: 1px solid var(--hairline);
}

.ds-page .ai-chat-tab {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 13px;
    line-height: 1.2;
    color: var(--mute);
    background: none;
    border: none;
    /* On the element, not the container: the selected tab's underline has to
       sit on the same pixel row as the strip's own bottom hairline, so it
       reads as one line with a lit segment rather than two stacked rules. */
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
    padding: 10px 6px;
    cursor: pointer;
}

.ds-page .ai-chat-tab:hover {
    color: var(--ink);
}

.ds-page .ai-chat-tab[aria-selected="true"] {
    color: var(--ink);
    font-weight: 600;
    border-bottom-color: var(--action);
}

.ds-page .ai-chat-tab:focus-visible {
    outline: 2px solid var(--action);
    outline-offset: -2px;
}

/* The count of fired findings. chat.js writes "" at zero, so `:empty` is what
   hides it — a badge reading "0" is a worse empty state than no badge, and
   this way the rule and the renderer can't disagree about which it is. */
.ai-chat-tab-count {
    font-size: 10px;
    font-weight: 600;
    line-height: 1;
    color: var(--danger-deep);
    background-color: var(--danger-soft);
    border-radius: 999px;
    padding: 3px 6px;
}

.ai-chat-tab-count:empty {
    display: none;
}

/* ===== The Advice panel =====
   Same box as the log: it is the other half of the same pane, and a different
   padding or scroll behaviour on each tab makes the strip feel like it swaps
   two applications rather than two views. */
.ai-chat-advice {
    flex: 1;
    overflow-y: auto;
    padding: 16px 20px;
    display: flex;
    flex-direction: column;
    gap: 12px;
    font-variant-numeric: tabular-nums;
}

/* The verdict line (Phase 2) and the strengths section (Phase 4's bands) ship
   as real, empty elements so that adding their content later is a render
   change and not a layout change. `:empty` keeps them from spending a gap in
   the flex column while they have nothing to say. */
.ai-advice-verdict:empty,
.ai-advice-strengths:empty {
    display: none;
}

/* §5.3's coverage statement: one line, not two. The lead carries the count of
   what was checked because that is the sentence that buys the rest of the
   panel its credibility. */
.ai-advice-coverage {
    margin: 0;
    font-size: 12px;
    line-height: 1.5;
    color: var(--mute);
}

.ai-advice-coverage-lead {
    color: var(--ink);
    font-weight: 600;
}

/* ----- one finding -----
   The rank column is the point of the card's layout. The list is sorted by
   tier and then by dollars, and until the numbers went in nothing on screen
   said so — three tier chips read as three buckets to choose between rather
   than a queue to work down (Evan, 2026-08-26). */
.ai-advice-card {
    display: flex;
    gap: 10px;
    border: 1px solid var(--hairline);
    border-radius: 10px;
    padding: 10px 12px;
    background-color: var(--card);
}

.ai-advice-rank {
    flex: none;
    min-width: 12px;
    font-size: 13px;
    font-weight: 600;
    line-height: 1.5;
    color: var(--faint);
}

.ai-advice-body {
    min-width: 0;
    flex: 1;
}

.ai-advice-tier {
    display: inline-block;
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    line-height: 1;
    border-radius: 6px;
    padding: 4px 6px;
    color: var(--ink);
    background-color: var(--card-sunken);
}

.ai-advice-card--fix .ai-advice-tier {
    color: var(--danger-deep);
    background-color: var(--danger-soft);
}

/* Ink on the yellow, not `--warning-deep`: that token is a fill yellow used
   as text by explicit decision (1.94:1), so there is no contrast-safe warning
   text colour in this file — see the note above the tokens. The tint carries
   the tier; the ink carries the words. */
.ai-advice-card--protect .ai-advice-tier {
    color: var(--ink);
    background-color: var(--warning-soft);
}

.ai-advice-card--improve .ai-advice-tier {
    color: var(--action);
    background-color: var(--action-soft);
}

.ai-advice-headline {
    margin: 8px 0 0;
    font-size: 13px;
    line-height: 1.45;
    color: var(--body);
}

/* The written advice, Phase 3. Absent until then — the card has no rule that
   reserves space for it. */
.ai-advice-prose {
    margin: 6px 0 0;
    font-size: 12px;
    line-height: 1.5;
    color: var(--mute);
}

.ai-advice-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
    margin-top: 10px;
}

/* Text actions, not buttons: two bordered controls per card, stacked down a
   narrow pane, is more chrome than the card is worth. Fix leads in the action
   colour, Ask stays quiet beside it. */
.ds-page .ai-advice-fix,
.ds-page .ai-advice-ask {
    font-size: 12px;
    line-height: 1.3;
    background: none;
    border: none;
    padding: 0;
    cursor: pointer;
}

.ds-page .ai-advice-fix {
    color: var(--action);
    font-weight: 600;
}

.ds-page .ai-advice-ask {
    color: var(--mute);
}

.ds-page .ai-advice-fix:hover,
.ds-page .ai-advice-ask:hover {
    text-decoration: underline;
}

.ds-page .ai-advice-ask:hover {
    color: var(--ink);
}

.ds-page .ai-advice-fix:focus-visible,
.ds-page .ai-advice-ask:focus-visible {
    outline: 2px solid var(--action);
    outline-offset: 2px;
    border-radius: 4px;
}

/* ----- the clean list -----
   Collapsed under the findings (§5.4). It is the coverage claim's receipt:
   nobody opens it twice, and a panel that listed twelve passing checks inline
   would bury the three that matter. */
.ai-advice-clean {
    font-size: 12px;
    color: var(--mute);
}

.ds-page .ai-advice-clean > summary {
    cursor: pointer;
    padding: 2px 0;
}

.ds-page .ai-advice-clean > summary:focus-visible {
    outline: 2px solid var(--action);
    outline-offset: 2px;
}

.ai-advice-clean-line {
    margin: 6px 0 0 16px;
    line-height: 1.4;
    color: var(--faint);
}

/* Empty states: the no-deal Advice panel, and an Ask tab nobody has typed
   into yet. One line each. The seed strip lives in the first of them and
   nowhere else now, so it sits under its line rather than being pushed to the
   bottom of the panel the way it was at the foot of the log. */
.ai-advice-empty,
.ai-chat-empty {
    margin: 0;
    font-size: 13px;
    line-height: 1.5;
    color: var(--mute);
}

.ai-chat-advice .ai-chat-seeds {
    margin-top: 4px;
}

/* Deep links (Evan, 2026-08-18): underlined accent text inside the sentence,
   not a chip. The pane is narrow enough that a bordered pill would break the
   line it sits in, and the link is a continuation of the prose — "your cap
   rate peaks in 2031" with two of those words clickable. */
.ai-chat-msg--assistant .ai-chat-link {
    color: var(--action);
    text-decoration: underline;
    text-underline-offset: 2px;
    text-decoration-thickness: 1px;
    cursor: pointer;
}

.ai-chat-msg--assistant .ai-chat-link:hover {
    text-decoration-thickness: 2px;
}

.ai-chat-msg--assistant .ai-chat-link:focus-visible {
    outline: 2px solid var(--action);
    outline-offset: 2px;
    border-radius: 2px;
}

/* Arrival flash. Scrolling to a KPI row answers "where" but not "which one",
   so the target names itself for a moment on landing. Outline rather than
   border: it paints outside the box and so cannot reflow the page it lands
   on. Listed twice because the targets live inside .ds-page, where an
   unscoped rule loses the specificity tie and computes to nothing. */
.ai-chat-target-flash,
.ds-page .ai-chat-target-flash {
    animation: ai-chat-target-flash 1.2s ease-out;
    border-radius: 6px;
}

@keyframes ai-chat-target-flash {
    0%   { outline: 2px solid var(--action); outline-offset: 4px; }
    70%  { outline: 2px solid var(--action); outline-offset: 4px; }
    100% { outline: 2px solid transparent;   outline-offset: 4px; }
}

/* A pulse that chases the eye is the wrong trade for anyone who asked the OS
   not to animate; the outline still appears, it just doesn't fade in place. */
@media (prefers-reduced-motion: reduce) {
    .ai-chat-target-flash,
    .ds-page .ai-chat-target-flash {
        animation: none;
        outline: 2px solid var(--action);
        outline-offset: 4px;
    }
}

.ai-chat-error {
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 13px;
    color: var(--danger-deep);
    background-color: var(--danger-soft);
    border-radius: 8px;
    padding: 8px 12px;
}

.ai-chat-retry {
    flex: none;
    border: none;
    background: none;
    color: var(--danger-deep);
    font-weight: 600;
    font-size: 13px;
    text-decoration: underline;
    cursor: pointer;
    padding: 0;
}

/* Budget note. Deliberately not styled as a warning — it is information, and
   a red box would make a normal month's usage read as a fault. */
.ai-chat-status {
    align-self: center;
    font-size: 12px;
    color: var(--mute);
}

/* The scenario status line. Left-aligned under the answer, not centred like
   the budget note: it belongs to the turn being written, not to the
   conversation. The pulse is the only thing on screen saying the pane is
   still working, since the waiting bubble stands down while it is up. */
.ai-chat-tool {
    align-self: flex-start;
    font-style: italic;
    animation: ai-chat-pulse 1.4s ease-in-out infinite;
}

.ai-chat-composer {
    flex: none;
    display: flex;
    /* Bottom-aligned so a grown input pushes upward rather than dragging the
       Send button down with it — the button stays beside the last line. */
    align-items: flex-end;
    gap: 8px;
    padding: 12px 16px;
    border-top: 1px solid var(--hairline);
}

/* Composer geometry follows `.ds-page .btn-sm`: 6px radius, 32px tall. The
   pane had invented 8px, which made it the only place in the app with a third
   control radius — the exact drift the two-hairline note at the top of this
   file exists to prevent.

   Text stays 14px rather than the button's 0.78rem: what the user types is a
   message, and it should match the message it becomes two lines above. Only
   the geometry is shared, not the type.

   ONE element now, not two. This was a dcc.Input until 2026-08-18 — a wrapper
   div carrying the className around an <input class="dash-input-element">
   carrying the id, which is why the old rules here styled two elements. It is
   a dcc.Textarea now, which renders the bare <textarea> with both the class
   and the id on it, so the control and the field are the same box and the
   nested rule is gone. The DOM-walking lesson survives the change: measure
   the running app, don't infer the shape from the component name.

   `.ds-page` scope for the same reason as everywhere else: Dash's component
   CSS lands after this stylesheet, so a bare class loses the tie. */
.ds-page .ai-chat-input {
    flex: 1;
    min-width: 0;
    padding: 6px 12px;
    font-size: 14px;
    /* Explicit, because fitInput() measures lineHeight to decide how tall five
       lines is. A computed "normal" parses as NaN and the cap silently falls
       back to a guess. */
    line-height: 20px;
    font-family: inherit;
    color: var(--ink);
    background-color: var(--card);
    border: 1px solid var(--hairline-strong);
    border-radius: 6px;
    outline: none;
    /* The composer grows the pane's bottom edge instead of the field growing
       into the transcript — align to the bottom so the Send button stays with
       the last line. */
    align-self: flex-end;
    /* JS owns the height; these are the floor and the ceiling it works
       between. resize:none because a drag handle in a fixed-width pane can
       only make things worse. */
    min-height: 34px;
    resize: none;
    overflow-y: hidden;
}

.ds-page .ai-chat-input:disabled {
    color: var(--faint);
    background-color: var(--card-sunken);
}


/* Not a dbc.Button, so `.ds-page .btn-sm` never reaches it — the geometry is
   restated here rather than the markup changed, because this button's states
   are driven by chat.js (Send/Stop) and not by Bootstrap's variants. */
.ai-chat-send {
    flex: none;
    min-height: 32px;
    padding: 6px 12px;
    font-size: 0.78rem;
    line-height: 1.35;
    font-weight: 500;
    color: #FFFFFF;
    background-color: var(--action);
    border: none;
    border-radius: 6px;
    cursor: pointer;
    transition: background-color 0.12s ease;
}

.ai-chat-send:hover {
    background-color: var(--action-hover);
}

/* The system's ring from DESIGN.md, not the browser's outline — the pane was
   the only interactive surface in the app still showing the default. */
/* `:focus-visible` on the input like everything else since 2026-08-18. It
   needed `:focus-within` while it was a dcc.Input, because the element
   carrying the class was a wrapper div that never receives focus itself; the
   textarea that replaced it takes focus directly. */
.ds-page .ai-chat-input:focus-visible,
.ds-page .ai-chat-send:focus-visible,
.ds-page .ai-chat-collapse:focus-visible,
.ds-page .ai-chat-rail:focus-visible {
    outline: none;
    box-shadow: 0 0 0 3px var(--action-soft);
}

.ai-chat-send--stop {
    background-color: var(--danger-deep);
}

/* ===== Onboarding gate (blur + unlock overlay) ===== */

/* Blur the analysis charts underneath the gate. pointer-events off so the
   dimmed content can't be interacted with while locked. */
.content-area.content-gated > * {
    filter: blur(6px);
    pointer-events: none;
    user-select: none;
}

.onboarding-gate {
    position: fixed;
    top: 0;
    left: 240px;
    right: 0;
    bottom: 0;
    z-index: 500;
    background: rgba(255, 255, 255, 0.55);
    display: flex;
    align-items: flex-start;
    justify-content: center;
    padding: 80px 24px;
    overflow-y: auto;
}

.gate-overlay-inner {
    width: 100%;
    max-width: 440px;
}

.gate-card {
    background: var(--card);
    border: 1px solid var(--hairline);
    border-radius: 14px;
    box-shadow: 0 12px 40px rgba(12, 35, 64, 0.10);
    padding: 28px 28px 24px;
}

.gate-title {
    font-size: 1.35rem;
    font-weight: 700;
    color: var(--ink);
    text-transform: none;
    margin-bottom: 6px;
}

.gate-subtitle {
    font-size: 0.9rem;
    color: var(--body);
    line-height: 1.5;
    margin-bottom: 18px;
}

.gate-progress-track {
    height: 6px;
    background: var(--card-sunken);
    border-radius: 99px;
    overflow: hidden;
    margin-bottom: 6px;
}

.gate-progress-fill {
    height: 100%;
    background: var(--action);
    border-radius: 99px;
    transition: width 0.3s ease;
}

.gate-progress-label {
    font-size: 0.78rem;
    color: var(--mute);
    margin-bottom: 16px;
}

.gate-item-list {
    display: flex;
    flex-direction: column;
    gap: 2px;
}

/* Fine-tuning (collapsed) inside the gate card — mirrors the sidebar checklist */
.gate-tuning-group {
    margin-top: 14px;
    border-top: 1px solid var(--hairline);
    padding-top: 10px;
}
.gate-group-summary {
    cursor: pointer;
    list-style: none;
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 0.82rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--mute);
    border-radius: 6px;
}
/* <summary> is natively focusable and nothing styled it, so keyboard focus
   drew the browser's default ring — a heavy blue box around the row. */
.gate-group-summary:focus-visible,
.onboarding-group-summary:focus-visible {
    outline: 2px solid var(--action);
    outline-offset: 2px;
}
.gate-group-summary::-webkit-details-marker { display: none; }
.gate-group-summary::before {
    content: "▼";
    font-size: 0.72rem;
    color: var(--faint);
}
.gate-tuning-group[open] > .gate-group-summary::before,
.gate-essentials-group[open] > .gate-group-summary::before {
    content: "▲";
}

.gate-essentials-group {
    margin-top: 6px;
}
.gate-essentials-group .gate-item-list { margin-top: 4px; }
.gate-tuning-group .gate-item-list { margin-top: 4px; }

.gate-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 4px;
    border-top: 1px solid var(--hairline);
}
.gate-item:first-child {
    border-top: none;
}

.gate-item-icon {
    font-size: 0.95rem;
    width: 16px;
    text-align: center;
    flex-shrink: 0;
}
.gate-icon-done { color: var(--success-deep); }
.gate-icon-empty { color: var(--faint); }
.gate-icon-default { color: var(--faint); }

.gate-item-label {
    font-size: 0.92rem;
    color: var(--ink);
    flex: 1;
}
.gate-item-done .gate-item-label { color: var(--mute); }

.gate-item-add {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--action) !important;
    text-decoration: none !important;
    padding: 5px 10px;
    border-radius: 6px;
}
.gate-item-add:hover { background: var(--action-soft); }

.gate-item-add-primary {
    color: #ffffff !important;
    background: var(--action);
    padding: 5px 14px;
}
.gate-item-add-primary:hover { background: var(--action-hover); }

.gate-item-done {
    font-size: 0.8rem;
    color: var(--success-deep);
    font-weight: 600;
}

/* ===== Page Container ===== */
.page-container {
    max-width: 1200px;
}

.page-container h2 {
    color: var(--ink);
    font-weight: 700;
    font-size: 1.75rem;
    margin-bottom: 8px;
}

.page-container h4 {
    color: var(--body);
    font-weight: 600;
    font-size: 1.1rem;
    margin-bottom: 16px;
}

.page-container p {
    color: var(--mute);
    font-size: 0.95rem;
}

/* ===== Hide Dash default dev tools ribbon in production ===== */
._dash-loading {
    display: none;
}

/* ===== Property Setup Page ===== */

.page-subtitle {
    color: var(--mute);
    font-size: 0.95rem;
    margin-bottom: 28px;
}

.setup-section {
    background-color: var(--card);
    border: 1px solid var(--hairline);
    border-radius: 14px;
    box-shadow: var(--raised);
    padding: 24px 28px;
    margin-bottom: 24px;
}

.section-heading {
    color: var(--ink);
    font-weight: 600;
    font-size: 1rem;
    margin-bottom: 20px;
    padding-bottom: 10px;
    border-bottom: 1px solid var(--hairline);
}

.section-subtext {
    color: var(--mute);
    font-size: 0.82rem;
    margin-top: -12px;
    margin-bottom: 18px;
}

.form-label {
    color: var(--body);
    font-size: 0.88rem;
    font-weight: 500;
    margin-bottom: 0;
    display: flex;
    align-items: center;
    gap: 6px;
}

/* ===== Performance assumptions row =====
   Flex, not a 12-column grid: each control takes only the width it needs, so
   five fit comfortably and the labels never wrap. The boxes themselves are
   `.affix-field affix-pct fw-sm` (they were `.value-box` until the Capital
   Performance pass) rather than a parallel style — a bespoke max-width let
   the "%" addon wrap onto its own line. */

.assumption-row {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    column-gap: 20px;
    row-gap: 14px;
}
/* Natural width only — without this the cells stretch to fill and push the
   last two onto a second line. */
.assumption-cell {
    flex: 0 0 auto;
}
.assumption-cell label,
.assumption-cell .form-label {
    white-space: nowrap;
    font-size: 0.8rem;
}
/* The addon must sit beside the input, never below it. */
.assumption-cell .affix-field {
    flex-wrap: nowrap;
}

/* ===== Segmented Toggle (e.g. Tax View: Pre-tax / After-tax) ===== */

/* Bootstrap's form-check-inline puts a 1rem margin between the options, which
   reads as two separate buttons rather than one segmented control. */
.segmented-toggle .form-check-inline {
    margin-right: 0;
}
.segmented-toggle .btn {
    white-space: nowrap;
    border: 1px solid var(--hairline-strong) !important;
    background-color: #ffffff;
    color: var(--body);
    font-weight: 600;
    font-size: 0.78rem;
    letter-spacing: 0.02em;
    padding: 6px 16px;
}

.segmented-toggle .btn:hover {
    background-color: var(--card-sunken);
}

.segmented-toggle .btn-check:checked + .btn {
    background-color: var(--ink);
    border-color: var(--ink) !important;
    color: #ffffff;
}

.segmented-toggle .btn-check:checked + .btn:hover {
    background-color: var(--ink);
}

/* ===== Info Icon (ⓘ + dbc.Tooltip) =====
   One spelling now, shared by Expenses and Property Setup's tax matrix —
   collapsed from three: this class, a CSS-only hover tooltip
   (`.custom-tooltip-wrapper`/`.custom-tooltip-content`, removed 2026-08-03
   as dead code once Property Setup's only caller turned out to pass no
   args), and a private `.tax-info-icon` that differed from this in nothing
   but name. */

.info-icon {
    color: var(--mute);
    font-size: 0.8rem;
    cursor: default;
    vertical-align: middle;
    transition: color 0.15s ease;
    user-select: none;
}

/* ===== Dropdowns (Dash dcc.Dropdown override) =====
   Removed 2026-08-02. Two rules here targeted `.Select-control` and
   `.Select-menu-outer` — react-select classes from Dash 2's dropdown. This
   app is on Dash 4, where dcc.Dropdown renders a Radix listbox
   (`.dash-dropdown`), so neither selector had matched anything for some
   time; deleting them is a visual no-op on every page. The `.dr-dropdown`
   class itself is still applied in property_setup.py and expenses.py and is
   left in place — it's a usable hook once the rollout reaches those pages.
   The live dropdown styling is in the Fields block near the top of this
   file, scoped to .ds-page. */

/* ===== Nominal Badge ===== */

.badge-nominal {
    font-size: 0.7rem;
    font-weight: 500;
    vertical-align: middle;
    background-color: #6366f1 !important;
}

/* ===== Auto-calc hint beneath rent field ===== */

.form-hint {
    color: var(--mute);
    font-size: 0.78rem;
    font-style: italic;
}

/* ===== Slider tooltip bubbles — uniform width ===== */

.rc-slider-tooltip-inner {
    min-width: 52px !important;
    text-align: center !important;
}

/* ===== Date Slicer Pills ===== */

.slicer-group {
    display: flex;
    align-items: center;
    gap: 6px;
}

.slicer-label {
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--mute);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    white-space: nowrap;
}

.slicer-pills {
    display: inline-flex !important;
    border: 1px solid var(--hairline-strong);
    border-radius: 999px;
    overflow: hidden;
    background: #ffffff;
}

.slicer-pills label {
    /* Padding lives on .dash-options-list-option-text (below), not here — a
       tooltip target must be an element whose own box covers the full pill,
       and content-box sizing on a child can never reach a parent's padding. */
    padding: 0 !important;
    font-size: 0.82rem !important;
    font-weight: 500;
    color: var(--mute);
    cursor: pointer;
    border-right: 1px solid var(--hairline);
    user-select: none;
    white-space: nowrap;
    transition: background-color 0.15s ease, color 0.15s ease;
    /* Dash 4 adds margin-right via .dash-radioitems-inline — zero it out so
       teal fill reaches both edges of every pill slot with no white gaps. */
    margin: 0 !important;
}

.slicer-pills label:last-child {
    border-right: none;
}

.slicer-pills label:has(input:checked) {
    background-color: #0d9488;
    color: #ffffff;
}

.slicer-pills label:hover:not(:has(input:checked)):not(:has(input:disabled)) {
    background-color: var(--card-sunken);
}

/* Lookback windows longer than the holding period are dead (same range as
   Since Purchase), so they're disabled + grayed with a hover note. */
.slicer-pills label:has(input:disabled) {
    color: var(--faint);
    cursor: not-allowed;
    background-color: var(--canvas);
}

.slicer-pills label:has(input:disabled) span {
    cursor: not-allowed;
}

/* The generated .dash-options-list-option-text span only wraps the text
   glyphs; a tooltip targeting it (or a native title attribute) only fires
   over the letters themselves. The pill's visual padding is moved onto this
   span (see .slicer-pills label above) so the span's own box IS the full
   pill, and hover works anywhere on it. */
.slicer-pills .dash-options-list-option-text {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 5px 12px;
    height: 100%;
    box-sizing: border-box;
}

.date-range-text {
    font-size: 0.85rem;
    font-weight: 500;
    color: var(--body);
    white-space: nowrap;
}

/* ===== KPI Cards ===== */

.kpi-card {
    border: 1px solid var(--hairline);
    border-radius: 8px;
    background: var(--card);
    box-shadow: var(--raised);
}

/* ===== KPI cards as the chart's tab bar =====
   These four cards *are* the hero chart's control, and nothing said so. They
   now take the underline-tab pattern the Expenses sub-tabs already use: a
   hairline under the whole row, and the selected card carrying a navy bar on
   it. Same idea, one level up — the rule that makes a row of plain elements
   read as tabs is the shared baseline, not a border on each element.

   The hairline sits below the row with a gap, so it never touches a card. */
.ds-page .kpi-tab-row {
    padding-bottom: 12px;
    border-bottom: 1px solid var(--hairline);
}

.ds-page .kpi-card-wrapper {
    padding: 4px;
    border-radius: 12px;
    position: relative;
    height: 100%;
}

.ds-page .kpi-card-wrapper .kpi-card {
    height: 100%;
}

/* ── The lift belongs to the tab bar, not to every KPI card ──
   Scoped to `.kpi-tab-row` (Property Performance's row, and the only one)
   because the lift is an *affordance*: it says pick me up, and Property
   Performance's cards are the hero chart's control, so it's telling the
   truth there. Rental Income's three cards read the same values and do
   nothing when clicked — the lift there promised an interaction that
   doesn't exist, which is worse than no cue at all. `cursor: pointer`
   moves with it for the same reason. Corrected 2026-08-03 on Evan's
   review; the lift shipped unscoped when the tab bar was built. */
.ds-page .kpi-tab-row .kpi-card-wrapper {
    cursor: pointer;
    /* Transform and shadow, not background — the lift is what reads as
       "pick me up", and animating background alone read as a passive tint. */
    transition: transform .16s ease, box-shadow .16s ease;
}

.ds-page .kpi-tab-row .kpi-card-wrapper .kpi-card {
    transition: border-color .16s ease;
}

.ds-page .kpi-tab-row .kpi-card-wrapper:hover {
    transform: translateY(-3px);
    box-shadow: 0 6px 16px rgba(12, 35, 64, .10);
}

.ds-page .kpi-tab-row .kpi-card-wrapper:hover .kpi-card {
    border-color: var(--hairline-strong);
}

.ds-page .kpi-tab-row .kpi-card-wrapper:active {
    transform: translateY(-1px);
}

/* Selected: the underline does most of the work, so the card itself only
   needs a nudge — a held lift and a firmer edge, no fill. A fill was tried
   (`#eff6ff`) and is gone: a blue wash on a card whose whole job is to show a
   verdict colour competed with the verdict. */
.ds-page .kpi-tab-row .kpi-card-wrapper.active {
    transform: translateY(-2px);
    box-shadow: 0 4px 12px rgba(12, 35, 64, .10);
}

.ds-page .kpi-tab-row .kpi-card-wrapper.active .kpi-card {
    border-color: var(--hairline-strong);
}

/* The tab bar itself. Sits on the row's hairline, 3px of brand navy, and is
   inset slightly so it reads as belonging to the card rather than spanning
   the full gutter. */
.ds-page .kpi-tab-row .kpi-card-wrapper.active::after {
    content: "";
    position: absolute;
    left: 4px;
    right: 4px;
    bottom: -13px;
    height: 3px;
    background: var(--ink);
    border-radius: 2px 2px 0 0;
}

/* ===== "How are these calculated?" trigger + tray ===== */
.ds-page .kpi-explainer-trigger {
    display: flex;
    justify-content: flex-end;
    margin-bottom: 6px;
}

/* Date slicer sharing its row with the KPI explainer trigger. Stacked, the
   trigger cost 38px before the cards (measured 2026-08-06: 16px slicer
   margin + a 32px row + 6px) to show 16.2px of right-aligned ink — the
   `btn-sm` min-height is most of that row and none of it is used on the
   left. `flex-end` sits the link on the timeline's bottom edge rather than
   its centre, so it reads as attached to the control above the cards.
   The 32px min-height stays: it's the touch target, not decoration. */
.ds-page .pp-slicer-row {
    display: flex;
    align-items: flex-end;
    gap: 1rem;
    flex-wrap: wrap;
}

/* The trigger is 164px, so on a narrow container it would take a third of the
   timeline. `min-width` + `wrap` sends it back to its own row there, where a
   32px band is cheaper than a squeezed date control. */
.ds-page .pp-slicer-row .pp-slicer-fill {
    flex: 1;
    min-width: 420px;
}

.ds-page .pp-slicer-row .kpi-explainer-trigger {
    flex: none;
    margin-bottom: 0;
}

.ds-page .kpi-explainer-btn {
    text-decoration: none;
    display: inline-flex;
    align-items: center;
    gap: 4px;
}

/* Disclosure chevron. Drawn as a CSS mask over an inline SVG, not a text
   glyph: the previous marker was `⌄` (U+2304 DOWN ARROWHEAD), which is thin
   by design and sits on the text baseline, so it read as a comma rather than
   an affordance. A mask takes its colour from `background-color`, so
   `currentColor` still inherits the button's colour and its hover state.

   Sized in `em` so it tracks the trigger's font-size, and `vertical-align:
   middle` puts it on the optical centre -- that works whether the trigger is
   a plain inline button (`.btn-ghost-inline`) or an inline-flex row
   (`.kpi-explainer-btn`), where align-items takes over and this is ignored.

   No rotation. A rotate-on-open chevron was built and dropped: the rule
   matched the element and still computed to identity, with and without
   `!important` -- something injected resets `transform` on button
   descendants. Open and closed are two separate mask images, which needs no
   cascade to cooperate. */
.ds-page .disclosure-chevron {
    display: inline-block;
    width: 0.78em;
    height: 0.78em;
    flex: 0 0 auto;
    vertical-align: middle;
    background-color: currentColor;
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") center / contain no-repeat;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") center / contain no-repeat;
}

.ds-page .disclosure-chevron--up {
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='18 15 12 9 6 15'/%3E%3C/svg%3E");
    mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='18 15 12 9 6 15'/%3E%3C/svg%3E");
}

.ds-page .kpi-explainer-tray {
    margin-bottom: 10px;
}

.ds-page .kpi-explainer-box {
    background: var(--card-sunken);
    border-radius: 8px;
    padding: 10px 12px;
    height: 100%;
}

.ds-page .kpi-explainer-title {
    font-size: 0.78rem;
    font-weight: 500;
    color: var(--ink);
    margin-bottom: 3px;
}

.ds-page .kpi-explainer-formula {
    font-size: 0.72rem;
    display: block;
    margin-bottom: 4px;
    color: var(--body);
    background: none;
    padding: 0;
}

.ds-page .kpi-explainer-box small {
    font-size: 0.72rem;
    color: var(--body);
    line-height: 1.45;
}

.kpi-card-label {
    font-size: 0.72rem;
    color: var(--mute);
    margin-bottom: 2px;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.03em;
}

.kpi-card-value {
    font-size: 1.25rem;
    font-weight: 700;
    color: var(--ink);
    margin-bottom: 4px;
}

.kpi-sparkline {
    margin: 0 -8px;
}

/* The KPI row stays four across and shrinks instead of wrapping (Evan,
   2026-08-18).

   Two things made it wrap, and they fed each other. A flex item's default
   `min-width: auto` means a column can never be narrower than its contents,
   and plotly writes an inline pixel width onto its own containers — so the
   sparkline's last drawn width was a floor the column could not go below.
   Wrapping then handed the surviving columns *more* width, the next draw
   baked that in, and each open/close of the assistant pane ratcheted the row
   one step further: four across, then two, then one. `!important` is required
   because the widths plotly sets are inline. */
.ds-page .row > div:has(.kpi-card-wrapper) {
    min-width: 0;
}

.kpi-sparkline .js-plotly-plot,
.kpi-sparkline .svg-container,
.kpi-sparkline .main-svg {
    width: 100% !important;
    max-width: 100% !important;
}

/* Status border — REMOVED 2026-08-01. The pastel left rail restated the verdict
   the thermometer pill already gives, so it was a second semantic cue competing
   with category color on the same card. The classes are kept as no-ops because
   ~10 call sites build `kpi-border-{status}` class strings; retiring the rule
   here avoids touching every one of them. */

.kpi-border-great,
.kpi-border-good,
.kpi-border-poor,
.kpi-border-neutral {
    border-left: 1px solid var(--hairline);
}

/* Stat card — delta line + inline share bar */

.kpi-stat-delta {
    font-size: 0.72rem;
    font-weight: 600;
    margin-top: -2px;
    margin-bottom: 4px;
}

/* Verdict TEXT — the -deep tier, never the flat one. These two read directly
   beside the sidebar's summary verdict on Capital Performance, and the flat
   token is what made them a different green from it. */
.kpi-delta-ok    { color: var(--success-deep); }
.kpi-delta-alert { color: var(--danger-deep); }
.kpi-delta-neutral { color: var(--mute); }

.kpi-stat-bar {
    height: 12px;
    background: var(--card-sunken);
    border-radius: 6px;
    overflow: hidden;
    /* Lands on the same baseline as .thermo-track (143px from card top) so the
       bar reads as one row across the card grid.
       CAUTION: this offset is the remainder after the label, value and subtitle
       above it — change .kpi-card-label min-height, .kpi-card-value font-size,
       or .kpi-card-subtitle metrics and this number must be re-measured, or the
       bars silently drift apart again. */
    margin-top: 35px;
}

.kpi-stat-bar-fill {
    height: 100%;
    background: #64748b;
    border-radius: 6px;
    transition: width 0.3s ease;
}

/* Benchmark bar — fill on a scale sized to fit both the value and the
   benchmark tick, so overshoot stays visible instead of clamping at full. */

.kpi-bench-track {
    position: relative;
    height: 12px;
    background: var(--card-sunken);
    border-radius: 6px;
    margin-top: 8px;
}

.kpi-bench-fill {
    height: 100%;
    border-radius: 6px;
    transition: width 0.3s ease;
}

/* Slate rather than near-black, with a white ring: the share bar's fill is now
   navy, and a dark marker sitting on top of a dark fill vanished once the fill
   ran past the benchmark. The ring keeps the tick readable over either ground,
   and slate keeps the benchmark subordinate to the value it is a reference for. */
.kpi-bench-marker {
    position: absolute;
    top: -3px;
    bottom: -3px;
    width: 2px;
    background: #64748b;
    border-radius: 1px;
    box-shadow: 0 0 0 2px #ffffff;
}

.kpi-bench-caption {
    position: relative;
    height: 0.95rem;
    margin-top: 4px;
}

.kpi-bench-caption span {
    position: absolute;
    transform: translateX(-50%);
    white-space: nowrap;
    color: var(--mute);
    font-size: 0.68rem;
    font-weight: 600;
}

/* Ranked mini-bar list (Where the money goes) */

.kpi-rank-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 0;
    font-size: 0.72rem;
}

.kpi-rank-name {
    flex: 0 0 68px;
    color: var(--body);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.kpi-rank-track {
    flex: 1 1 auto;
    height: 10px;
    background: var(--card-sunken);
    border-radius: 5px;
    overflow: hidden;
}

.kpi-rank-fill {
    height: 100%;
    border-radius: 5px;
}

.kpi-rank-val {
    flex: 0 0 auto;
    min-width: 46px;
    text-align: right;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
}

/* Empty state for the ranked list — shown until a category has two years of
   actuals, in place of a full column of blank tracks. */
.kpi-rank-empty {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    text-align: center;
    min-height: 130px;
    padding: 0 6px;
}

.kpi-rank-empty-title {
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--mute);
    margin-bottom: 4px;
}

.kpi-rank-empty-sub {
    font-size: 0.7rem;
    color: var(--mute);
    line-height: 1.45;
    max-width: 210px;
}

/* Horizontal 100% composition bar + itemized legend (Expense Composition) */

/* Composition card — the per-row bar carries share *and* category colour, so
   there is no separate legend dot. Deliberately mirrors .kpi-rank-* above: the
   two cards sit side by side and should read as one family. */
.kpi-comp-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 8px;
}

.kpi-comp-legend {
    display: flex;
    flex-direction: column;
}

.kpi-comp-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 0;
    font-size: 0.72rem;
}

.kpi-comp-name {
    flex: 0 0 68px;
    color: var(--body);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.kpi-comp-track {
    flex: 1 1 auto;
    height: 10px;
    background: var(--card-sunken);
    border-radius: 5px;
    overflow: hidden;
}

.kpi-comp-fill {
    height: 100%;
    border-radius: 5px;
}

.kpi-comp-val {
    flex: 0 0 auto;
    min-width: 46px;
    text-align: right;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
}

/* $/% switcher — the reserve card's segmented control, shrunk to sit in a card
   header rather than a footer. */
/* Rounded on/off switches (HOA / MGMT tab toggles) */
#hoa-enabled-switch.form-check-input,
#mgmt-enabled-switch.form-check-input {
    border-radius: 2em !important;
}

/* Suggested Reserve headline: % on the left, $ conversion on the right */

.kpi-suggested-headline {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 8px;
}

.kpi-suggested-dollar {
    font-size: 0.9rem;
    font-weight: 600;
    color: var(--mute);
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

/* Compare bars (suggested vs current reserve) */

.kpi-compare-row {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-top: 6px;
    font-size: 0.72rem;
}

.kpi-compare-label {
    flex: 0 0 76px;
    color: var(--mute);
    white-space: nowrap;   /* keep "Reserve /mo" on one line in both views */
}

.kpi-compare-track {
    flex: 1 1 auto;
    height: 10px;
    background: var(--card-sunken);
    border-radius: 5px;
    overflow: hidden;
}

.kpi-compare-fill {
    height: 100%;
    border-radius: 5px;
    background: #f59e0b;
}

/* Suggested Reserve — label line + bar row ($ · % / yr) */

.kpi-reserve-rowlabel {
    color: var(--mute);
    font-size: 0.72rem;
    font-weight: 500;
    margin-top: 8px;
    margin-bottom: 3px;
}

.kpi-reserve-barrow {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 0.72rem;
}

.kpi-reserve-barrow .kpi-compare-track {
    flex: 1 1 auto;
}

.kpi-reserve-amt {
    color: var(--body);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.kpi-reserve-dot {
    color: var(--faint);
    font-weight: 700;
}

.kpi-reserve-pct {
    color: var(--mute);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* In-card CapEx toggle (segmented) */

/* Pin the toggle footer to the bottom of the card body so the CapEx and
   Reserve-period toggles line up across cards of equal height. */
.kpi-card > .card-body {
    display: flex;
    flex-direction: column;
}

/* Keep every expense KPI card the same height across sub-tabs so switching
   tabs doesn't shift the chart/table below. Scoped to the expenses KPI row so
   the Dashboard cards are unaffected. */
#expense-kpi-row .kpi-card {
    min-height: 268px;
}

.kpi-card-footer {
    margin-top: auto;
    padding-top: 8px;
    border-top: 1px solid var(--card-sunken);
}

/* Signal card — icon + message */

.kpi-signal-message {
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--ink);
    line-height: 1.3;
}

.kpi-signal-icon { font-size: 1.05rem; }

/* Verdict TEXT — the -deep tier. `good` was #ca8a04, a fourth yellow that
   existed only here and put two different yellows for the same verdict on
   one screen (sidebar #eab308 vs this). Both are now --warning-deep. */
.kpi-signal-great { color: var(--success-deep); }
.kpi-signal-good  { color: var(--warning-deep); }
.kpi-signal-poor  { color: var(--danger-deep); }
.kpi-signal-neutral { color: var(--mute); }

/* CapEx treatment toggle above the KPI row */

/* Status badge — folded into the shared `.chip` 2026-08-05. The card's pill
   now renders `.chip .chip-success|warning|danger .kpi-status-chip`; the
   tone comes from the tokens and the placement from `.kpi-status-chip`
   below. The old hardcoded hexes disagreed with the tokens (#16a34a against
   success-deep #15803D, #fefce8/#ca8a04 against the warning pair), so this
   fold is also a small colour correction — on the Dashboard only, which is
   the sole caller of create_kpi_card. */

/* ===== KPI Card Subtitle ===== */

.kpi-card-subtitle {
    font-size: 0.68rem;
    color: var(--mute);
    margin-top: -2px;
    margin-bottom: 4px;
    font-weight: 500;
}


/* TTM tooltip — pure CSS hover.

   Deliberately NOT the nav rail's tooltip style, checked 2026-08-20 and left
   alone. The nav's shared surface rule exists to make one *menu* look like one
   menu; that both surfaces happen to be a hover `::after` is a coincidence of
   implementation, not a shared species. This one explains hovered text on a KPI
   card (its only caller is `components/kpi_card.py:521`, the subtitle hint) and
   wants to read as a transient annotation over a card, which is what the dark
   `--ink` fill buys. Folding it into the nav's white/8px/0.82rem surface would
   make it look like a menu that floated onto the wrong page.

   This and the rail tooltip are the app's only `::after` tooltips — grepped,
   not assumed.
   If a third appears, that is the moment to build a real tooltip system; two
   with different jobs do not need one. */
.ttm-tooltip {
    position: relative;
    display: inline;
    cursor: help;
    border-bottom: 1px dotted var(--mute);
}

.ttm-tooltip::after {
    content: "Trailing 12 Months";
    position: absolute;
    bottom: 100%;
    left: 50%;
    transform: translateX(-50%);
    background-color: var(--ink);
    color: var(--hairline);
    font-size: 0.7rem;
    padding: 4px 8px;
    border-radius: 4px;
    white-space: nowrap;
    display: none;
    z-index: 999;
    margin-bottom: 4px;
    pointer-events: none;
}

.ttm-tooltip:hover::after {
    display: block;
}

/* ===== Benchmark Threshold Inputs (gradient bar) ===== */

/* The benchmark editor is a vertical rail inside the chart card, flanking
   the plot area — not the separate gray card below it that it used to be.
   Its height and top offset come from hero_chart's PLOT_AREA_PX /
   PLOT_AREA_TOP_PX as inline styles, so the track spans exactly the plot
   area and a handle sits at the height of the rule it controls. */
.ds-page .hero-chart-header {
    display: flex;
    align-items: baseline;
    gap: 5px;
}

/* Rail plus its Reset, stacked. Reset is parked under the track it resets
   rather than in the card's top-right, where it was a long way from the
   thing it acts on. */
/* The column is exactly the rail's width and the Reset button is taken out of
   flow. In flow it was the widest child, so it set the column width and shoved
   the whole chart ~38px right for a control that only needed to sit under the
   track. Absolute keeps it visually below the rail while contributing no
   width, and `top: 100%` puts it under the rail's real height rather than a
   guessed offset. */
.ds-page .benchmark-rail-col {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    flex: 0 0 auto;
    width: 76px;
}

/* Centred on the track, not on the column: the track is pinned to the rail's
   right edge, so centring on the column would sit it left of what it resets.
   The track's centre is 4.5px in from the right, hence the 50% + offset. */
.ds-page .benchmark-rail-col .benchmark-reset-btn {
    position: absolute;
    top: 100%;
    right: 4.5px;
    transform: translateX(50%);
    margin-top: 8px;
    text-decoration: none;
    white-space: nowrap;
    /* Centring makes it overhang the 76px column on both sides; without this
       the graph, which comes later in the flex row, paints over the overhang
       and clips the label mid-word. */
    z-index: 2;
}

.ds-page .hero-chart-body {
    display: flex;
    align-items: flex-start;
}

.ds-page .hero-chart-graph {
    flex: 1 1 auto;
    /* Without this a flex child refuses to shrink below its content width
       and the graph pushes the rail off the card on narrow viewports. */
    min-width: 0;
}

/* `flex: none`, not a basis. The rail's height is an inline style measured
   from the plot area, and it now sits inside a flex *column* — a `flex`
   basis there sets the main axis, which is height, and silently overrode
   336px with 66. Width is set on its own property for the same reason. */
.ds-page .benchmark-rail {
    position: relative;
    flex: none;
    width: 76px;
}

.benchmark-label-group {
    display: flex;
    align-items: center;
    gap: 6px;
}

.benchmark-label {
    display: inline;
    font-size: 0.82rem;
    font-weight: 400;
    color: var(--mute);
    letter-spacing: 0.02em;
    white-space: nowrap;
}

.benchmark-label-prefix {
    font-weight: 800;
    font-size: 0.92rem;
    color: var(--body);
}

.benchmark-info-icon {
    font-size: 0.85rem;
}

/* Geometry only — colour and hover come from the `.btn-link` /
   `.btn-ghost-inline` button rules it now carries. */
.benchmark-reset-btn {
    font-size: 0.78rem;
    letter-spacing: 0.02em;
}

/* The gradient track pins to the rail's right edge, hard against the chart's
   y-tick labels; the chips sit to its left. */
.ds-page .benchmark-bar,
.ds-page .benchmark-bar-track {
    position: absolute;
    top: 0;
    bottom: 0;
    right: 0;
    width: 9px;
    border-radius: 6px;
}

/* Draggable handles: a RangeSlider overlaid on the gradient bar. Dash 4's
   RangeSlider renders as Radix primitives (dash-slider-*), not the old
   rc-slider markup — track/range are forced transparent so only the
   gradient bar shows, and the thumbs are restyled as thin vertical ticks
   instead of Dash's default purple circles. */
/* Overhangs the rail by exactly half a thumb at each end. A Radix thumb
   travels over (track height - thumb height), not the full track, so with a
   flush layer the handles spanned 333px against the plot area's 336 and drifted
   ~1px against their own rules — measured as 232.87px between thumbs vs
   234.97px between the rules, a ratio of 0.99106 against a predicted
   333/336 = 0.99107. Growing the layer by one thumb-height makes the travel
   equal the plot area.

   These offsets are half the thumb height and MUST track it: they were
   -1.5px for a 3px tick and are -10px for the 20px pill. `_THUMB_PX` in
   components/benchmark_inputs.py is the other half of the same pair. */
.ds-page .benchmark-slider-layer {
    position: absolute;
    top: -10px;
    bottom: -10px;
    right: 0;
    width: 9px;
    display: flex;
    justify-content: center;
    /* Above the gradient so the thumbs stay grabbable. */
    z-index: 2;
}

/* Radix gives the root its own width (20px measured) regardless of the
   wrapper, and the thumb centres on the *root*. With the root wider than the
   9px track the handles sat 5.5px to its right — measured as track centre
   370.5 against thumb centre 376. Pinning the root to the track width puts
   the bar half on either side of the colour. */
.ds-page .benchmark-slider-layer .dash-slider-root {
    justify-content: center;
    height: 100%;
    width: 9px;
    min-width: 0;
}

.ds-page .benchmark-slider-layer .dash-slider-track,
.ds-page .benchmark-slider-layer .dash-slider-range {
    background: transparent !important;
}

/* A white pill with a coloured edge, not the dark tick this shipped with.
   The tick read as a printed mark on the track; a raised white capsule reads
   as something you pick up, which is the whole job of a handle. Each takes
   the colour of the rule it controls — the upper one bounds Good, the lower
   one bounds Bad. */
.ds-page .benchmark-slider-layer .dash-slider-thumb {
    height: 20px;
    width: 30px;
    border-radius: 10px;
    background: var(--card);
    border: 2px solid var(--body) !important;
    box-shadow: var(--raised, 0 1px 3px rgba(12, 35, 64, .06));
    cursor: grab;
}

/* Keyed off Dash's own `-1`/`-2` classes, not `:nth-of-type` — Radix wraps
   each thumb in its own span, so both thumbs are the first of their type in
   their own parent and `:nth-of-type(1)` matched both.

   `-1` is the low handle, `-2` the high one. For a higher-is-better KPI the
   low handle bounds Bad; for a lower-is-better one (OER) that inverts, and
   `on_benchmark_slider` puts `benchmark-rail-inverted` on the rail to swap
   them. Without that, OER would quietly render a green Bad edge.

   **Both handles are one neutral colour, deliberately.** Colouring each one
   to match its own rule was built and abandoned: Dash's slider re-resolves
   per-thumb borders in a way the stylesheet can't reliably take back. With
   `!important` and the value routed through a custom property that verifiably
   resolved to green *on the thumb itself*, the computed border still came
   back red — and the mapping additionally has to invert for a
   lower-is-better KPI, so a wrong answer there is silent rather than obvious.

   Not much is lost. The threshold rules and the value labels already carry
   the semantics twice, and a handle's affordance comes from its shape — the
   raised white capsule — not from its hue. Re-open only with a mechanism that
   doesn't depend on winning a cascade fight against a vendor component. */

.ds-page .benchmark-slider-layer .dash-slider-thumb:hover,
.ds-page .benchmark-slider-layer .dash-slider-thumb:focus-visible {
    box-shadow: 0 0 0 4px rgba(12, 35, 64, .10);
}

.ds-page .benchmark-slider-layer .dash-slider-thumb:active {
    cursor: grabbing;
}

/* Read-only value boxes, pinned beside their handle rather than under it —
   the vertical layout is what frees them from floating below the track. */
/* Clear of the handle, which is 30px wide and centred on the 9px track, so it
   reaches ~15px past the track's left edge. */
.ds-page .benchmark-chip {
    position: absolute;
    right: 26px;
    transform: translateY(50%);
    pointer-events: none;
}

/* Flat coloured text, no field and no dot. DESIGN.md reserves the chip for a
   standalone status *word*; these are two numeric readouts in a column, read
   as a set, and the handle beside each one already supplies the affordance —
   the border and the dot were both restating it. */
.ds-page .benchmark-value-box {
    font-size: 0.9rem;
    font-weight: 500;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* Each label takes the colour of the rule it controls. `good` is the Bad
   boundary and so is red — it wore `warning` amber until 2026-08-02, which
   put an amber dot next to a red rule for the same threshold. */
.ds-page .benchmark-value-box-good { color: var(--danger-deep); }
.ds-page .benchmark-value-box-great { color: var(--success-deep); }

/* ===== Expense Controls Row ===== */

.expense-controls-row {
    padding: 8px 0;
}

/* ===== Expense Empty State (first-time entry) ===== */

/* Wraps the hero chart + content area so the empty-state form can float on
   top of the chart (blurred behind it) instead of requiring a scroll past
   the chart to reach a form stacked below. */
.expense-chart-content-wrap {
    position: relative;
}

.expense-chart-blurred {
    filter: blur(6px);
    pointer-events: none;
    user-select: none;
    /* Guarantees a backdrop tall enough to centre the guided card in, so the
       card sits in the same spot whether the tab's chart is full, sparse, or
       (Catch-All, empty) rendering nothing at all. */
    min-height: 380px;
}

.expense-content-overlay {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 5;
}
.expense-content-overlay .expense-empty-wrap {
    padding: 0;
}
.expense-content-overlay .expense-empty-card {
    background-color: #ffffff;
    box-shadow: 0 8px 28px rgba(15, 23, 42, 0.18);
}

/* CapEx onboarding — two numbered steps, built from the same form controls as
   the real logging form so the real one is familiar the second time. */
.capex-ob-lede {
    font-size: 0.85rem;
    color: var(--mute);
    margin-bottom: 18px;
}

.capex-ob-step {
    border: 1px dashed var(--hairline-strong);
    border-radius: 12px;
    background-color: var(--canvas);
    padding: 20px 22px;
    margin-bottom: 16px;
}

/* Step 2 before the reserve is accepted — visibly next, not yet actionable. */
.capex-ob-step-waiting {
    opacity: 0.55;
}

.capex-ob-head {
    display: flex;
    align-items: center;
    gap: 9px;
    margin-bottom: 6px;
}

.capex-ob-num {
    flex: 0 0 22px;
    height: 22px;
    border-radius: 999px;
    background: var(--ink);
    color: #fff;
    font-size: 0.72rem;
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: center;
}

.capex-ob-title {
    font-size: 0.98rem;
    font-weight: 600;
    color: var(--ink);
}

.capex-ob-done {
    font-size: 0.72rem;
    font-weight: 700;
    color: var(--success-deep);
}

.capex-ob-desc {
    font-size: 0.82rem;
    color: var(--mute);
    line-height: 1.5;
    margin-bottom: 14px;
    max-width: 620px;
}

/* "$2,500  =  [0.5] %" — the figure leads, the rate explains it. */
.capex-ob-figure-row {
    display: flex;
    align-items: flex-end;
    gap: 18px;
    flex-wrap: wrap;
    margin-bottom: 14px;
}

.capex-ob-figure-label {
    font-size: 0.72rem;
    font-weight: 600;
    color: var(--mute);
}

.capex-ob-figure {
    font-size: 1.7rem;
    font-weight: 700;
    color: var(--ink);
    line-height: 1.15;
}

.capex-ob-eq {
    font-size: 1.1rem;
    color: var(--mute);
    padding-bottom: 4px;
}

.expense-empty-wrap {
    display: flex;
    justify-content: center;
    padding: 40px 0;
}

.expense-empty-card {
    width: 100%;
    max-width: 360px;
    text-align: center;
    padding: 28px 24px;
    border: 1px dashed var(--hairline-strong);
    border-radius: 12px;
    background-color: var(--canvas);
}

.expense-empty-title {
    font-size: 1.05rem;
    font-weight: 600;
    color: var(--ink);
    margin-bottom: 4px;
}

.expense-empty-desc {
    font-size: 0.85rem;
    color: var(--mute);
    margin-bottom: 16px;
}

.expense-empty-value-box {
    max-width: 200px;
    margin: 0 auto;
}

.expense-empty-input {
    text-align: center;
    font-weight: 600;
    font-size: 1rem;
}

.expense-empty-hint {
    font-size: 0.76rem;
    color: var(--mute);
    margin: 10px 0 4px;
}

/* ===== Login Page ===== */

.login-page {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    background-color: var(--canvas);
}

.login-card {
    background-color: var(--card);
    border: 1px solid var(--hairline);
    border-radius: 14px;
    padding: 40px 36px;
    width: 100%;
    max-width: 380px;
    box-shadow: 0 12px 40px rgba(12, 35, 64, 0.10);
}

.login-brand {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 6px;
}

.login-brand-name {
    font-size: 1.4rem;
    font-weight: 700;
    color: var(--ink);
    letter-spacing: 0.02em;
}

.login-tagline {
    color: var(--mute);
    font-size: 0.82rem;
    margin-bottom: 0;
}

.login-divider {
    border-color: var(--hairline);
    margin: 20px 0 24px 0;
}

/* Auth scale — a documented exception to the 32px field, see DESIGN.md.
   That height exists so a [date picker][dropdown][amount][Add] row lands on
   one baseline; this card has no adjacent control to align to, so the
   constraint that produced it isn't present. Everything else about the field
   (radius, border, focus ring, placeholder) comes from .ds-page unchanged.

   .login-page.ds-page is (0,3,0) so it beats the (0,2,0) base rule outright
   rather than relying on being later in the file. */
.login-page.ds-page .form-control {
    height: 40px;
    min-height: 40px;
    padding: 8px 12px;
    font-size: 0.9rem;
}

.login-page.ds-page .login-btn {
    min-height: 40px;
    font-size: 0.9rem;
    font-weight: 600;
}

/* Show/hide password. The button sits inside the field rather than in an
   InputGroup so the field keeps its own radius and focus ring. The input's
   right padding reserves the button's width. */
.login-password-field {
    position: relative;
}

.login-page.ds-page .login-password-field .form-control {
    padding-right: 60px;
}

.login-page.ds-page .login-password-toggle {
    position: absolute;
    top: 0;
    right: 0;
    height: 40px;
    padding: 0 12px;
    border: none;
    background: none;
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--mute);
    cursor: pointer;
}

.login-page.ds-page .login-password-toggle:hover {
    color: var(--ink);
}

.login-error {
    margin-top: 10px;
    font-size: 0.84rem;
    color: var(--danger);
    min-height: 20px;
}

/* ===== Sidebar Setup Checklist ===== */

.sidebar-onboarding-wrap {
    position: relative;
    padding: 0 8px 8px;
}
/* When the checklist is hidden (gated pages) the wrap holds only empty
   placeholders — collapse it so it doesn't leave a gap under the brand. */
.sidebar-onboarding-wrap:not(:has(.onboarding-panel)):not(:has(.onboarding-toast)) {
    padding: 0;
}

.onboarding-panel {
    background: var(--card);
    border: 1px solid var(--hairline);
    border-radius: 10px;
    padding: 10px 10px 22px;
    margin-bottom: 4px;
}

.onboarding-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 6px;
}

.onboarding-title {
    font-size: 0.82rem;
    font-weight: 700;
    color: var(--ink);
}

.onboarding-count {
    font-size: 0.72rem;
    color: var(--mute);
}

.onboarding-progress-track {
    height: 4px;
    background: var(--card-sunken);
    border-radius: 99px;
    overflow: hidden;
    margin-bottom: 10px;
}

.onboarding-progress-fill {
    height: 100%;
    background: var(--action);
    border-radius: 99px;
    transition: width 0.3s ease;
}

.onboarding-group-label {
    font-size: 0.62rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--mute);
    margin: 6px 0 3px;
}

/* Fine-tuning collapses by default via native <details> — no JS needed */
.onboarding-tuning-group {
    margin: 6px 0 0;
}

.onboarding-group-summary {
    margin: 0;
    cursor: pointer;
    list-style: none;
    display: flex;
    align-items: center;
    gap: 4px;
}
.onboarding-group-summary::-webkit-details-marker { display: none; }
.onboarding-group-summary::before {
    content: "▼";
    font-size: 0.66rem;
}
.onboarding-tuning-group[open] > .onboarding-group-summary::before,
.onboarding-essentials-group[open] > .onboarding-group-summary::before {
    content: "▲";
}

.onboarding-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 3px 0;
}

.onboarding-item-icon {
    font-size: 0.78rem;
    width: 14px;
    text-align: center;
    flex-shrink: 0;
}
.onboarding-icon-done { color: var(--success-deep); }
.onboarding-icon-empty { color: var(--action); }
.onboarding-icon-default { color: var(--faint); }

.onboarding-item-label {
    font-size: 0.76rem;
    color: var(--body);
    flex: 1;
}
.onboarding-item-done .onboarding-item-label { color: var(--mute); }

.onboarding-item-add {
    font-size: 0.7rem;
    color: var(--action) !important;
    text-decoration: none !important;
    font-weight: 600;
}
.onboarding-item-add:hover { color: var(--action-hover) !important; }

/* Anchored to the checklist card's bottom-right corner, inset just inside
   its border so it reads as part of the card rather than a floating link.
   The offset accounts for .sidebar-onboarding-wrap's own bottom padding
   (8px) plus .onboarding-panel's margin-bottom (4px) — those sit *outside*
   the panel's border, so a naive small offset lands the button below the
   card instead of inside it. */
.onboarding-dismiss-btn {
    position: absolute;
    right: 16px;
    bottom: 18px;
    background: var(--card-sunken);
    border: none;
    border-radius: 5px;
    color: var(--mute);
    font-size: 0.65rem;
    padding: 2px 7px;
    cursor: pointer;
    width: auto;
    text-align: right;
}
.onboarding-dismiss-btn:hover { color: var(--ink); background: var(--hairline); }

.onboarding-toast {
    font-size: 0.74rem;
    color: var(--body);
    padding: 10px 12px;
    margin-bottom: 12px;
    background: var(--card);
    border: 1px solid var(--hairline);
    border-radius: 10px;
}

/* Now a dbc.Button(outline=True, color="secondary") — .ds-page .btn handles
   color and geometry. Only the spacing this class originally carried
   survives here. */
.onboarding-restore-btn {
    margin-bottom: 12px;
}

/* ===== Sidebar Footer (Sign Out) ===== */

.sidebar-footer {
    flex-shrink: 0;
    margin-top: 0;
    padding: 8px 8px 16px 8px;
}

/* Autosave status — pinned above the account divider, always visible. */
.sidebar-save-status {
    display: flex;
    align-items: center;
    gap: 7px;
    padding: 4px 16px 10px;
    font-size: 0.8rem;
    color: var(--mute);
}

.save-status-icon {
    width: 15px;
    height: 15px;
    flex-shrink: 0;
    background-color: currentColor;
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25'/%3E%3Cpolyline points='8 16 11 19 16 12'/%3E%3C/svg%3E") center / contain no-repeat;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25'/%3E%3Cpolyline points='8 16 11 19 16 12'/%3E%3C/svg%3E") center / contain no-repeat;
}

.save-status-label {
    line-height: 1;
}

.sidebar-save-status.saved-pulse {
    animation: savePulse 1.1s ease;
}

@keyframes savePulse {
    0%   { color: var(--success-deep); }
    35%  { color: var(--success-deep); }
    100% { color: var(--mute); }
}

/* Wrapper is now just the rule above the account menu — the padding moved onto
   the trigger button, which needs to be the full hover target. */
.sidebar-account {
    color: var(--body);
    font-size: 0.9rem;
    padding-top: 6px;
    margin-top: 4px;
    border-top: 1px solid var(--hairline);
}

/* Opens the account flyout. A button, so it needs the browser's own styling
   stripped before `.nav-link-item` can dress it like every other nav row. */
.sidebar-account-trigger {
    width: 100%;
    background: transparent;
    border: none;
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.sidebar-account-trigger .sidebar-account-name {
    overflow: hidden;
    white-space: nowrap;
}

.sidebar.is-collapsed .sidebar-account-trigger {
    justify-content: center;
    padding: 10px 0 !important;
}

.sidebar-signout {
    color: var(--mute) !important;
    font-size: 0.84rem !important;
}

/* Capital Performance — time metric strip below the hero chart */
/* Footer band inside the chart card: full-width divider bleeds to the card
   edges (cancelling the card's 24px/28px padding) so the strip reads as one
   object with the graph above it. */
.time-strip-footer {
    border-top: 1px solid var(--hairline);
    margin: 18px -28px -24px;
    padding: 14px 28px 4px;
}

.time-strip {
    display: flex;
    align-items: stretch;
    gap: 22px;
}

.time-strip-col {
    display: flex;
    flex-direction: column;
    gap: 4px;
    flex: 0 0 auto;
}

/* Fixed to each column's worst-case content (e.g. "24 years ago",
   "−$50.4k/yr") so the strip never reflows while scrubbing the chart. */
.time-strip-col-viewing  { width: 70px; }
.time-strip-col-holding  { width: 90px; }
.time-strip-col-horizon  { width: 110px; }
.time-strip-col-value    { width: 100px; }

.time-strip-label {
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    /* mute, not the old #9ca3af — and `mute` rather than `faint` because this
       is a label, per the Ink amendment made on measured contrast. */
    color: var(--mute);
    white-space: nowrap;
}

.time-strip-value {
    font-size: 1.05rem;
    font-weight: 700;
    color: var(--ink);
    line-height: 1.2;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.time-strip-dot {
    display: inline-block;
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background-color: var(--ink);
    margin-right: 8px;
    vertical-align: middle;
}

/* Genuinely-absent data (the purchase year has no per-year rate yet), not a
   label — so `faint` rather than `mute`. */
.time-strip-empty {
    color: var(--faint);
}

.time-strip-divider {
    width: 1px;
    background: var(--hairline);
    margin: 2px 0;
}

/* Unify hover info boxes: dbc.Tooltip is dark by default, dbc.Popover (used for
   richer content — links, multi-line text) is light by default. Both are used
   for the same "ⓘ" hover pattern across KPI cards and inputs, so the popover
   is re-themed to match the tooltip's dark style rather than leaving the two
   inconsistent. */
.popover {
    --bs-popover-bg: var(--ink);
    --bs-popover-border-color: var(--ink);
    --bs-popover-header-bg: var(--ink);
    --bs-popover-header-color: var(--card-sunken);
    --bs-popover-body-color: var(--hairline);
    --bs-popover-arrow-border: var(--ink);
}

.popover .popover-body a {
    color: #a5b4fc;
}

/* Hide dcc.Slider's built-in numeric input box — we supply our own value boxes */
.dash-slider-container .dash-input-container,
.dash-slider-container input.dash-range-slider-input {
    display: none !important;
}

/* ===== Property Setup — financing row layout ===== */

/* Zebra banding (rgba(15,23,42,.04)/.015 alternating) was here to give rows
   some separation, since nothing else did. Removed per DESIGN.md's
   whitespace philosophy — density comes from removing chrome, not adding
   it — in favour of a hairline plus the padding the rows already had.
   Ships together with the derived-figure change above: a wash under a
   figure that just stopped being a gray box would have re-created the same
   ambiguity in a different color. */
.fin-row {
    display: flex;
    align-items: center;
    gap: 20px;
    padding: 16px 2px;
    border-bottom: 1px solid var(--hairline);
}
.fin-row:last-child {
    border-bottom: none;
}
.fin-row-label {
    flex: 0 0 150px;
    font-size: 0.88rem;
    font-weight: 500;
    color: var(--ink);
}
.fin-row-mid {
    flex: 1 1 auto;
    min-width: 0;
    padding: 0 6px;
}
.fin-row-right {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    gap: 12px;
}

/* Fixed-width slot for the %/yr readout; always present (empty for rows without one)
   so the equation columns align at the same X across all rows. */
.fin-val-slot {
    flex: 0 0 96px;
    display: flex;
    align-items: center;
    justify-content: flex-end;
}

/* Sized to hold 9 characters — `9,999,999` measures 63px at 13.12px tabular
   Geist, and 116px leaves 75px of text box after the border, the `$` affix
   and the input's own padding. Longer values scroll rather than widen the
   column. This was 150px, which fit `999,999,999` and read as a field with
   nothing in it most of the time; 116px is `fw-sm` off the approved width
   scale rather than a fourth private number. */
.ds-page .fin-affix-dollar,
.ds-page .fin-derived {
    width: 116px;
}
/* Typed figures stay regular weight. They were 600 — heavier than the sum they
   feed, which inverted the hierarchy: the inputs shouted and the total (500,
   below) whispered. Weight now marks the result, not the raw entries. */
/* A computed figure, not a field — see _fin_derived in property_setup.py.
   No border, no fill: the affordance a box gives is exactly what a number
   nobody can type shouldn't claim.

   `padding-right` matches the field's 10px input padding plus its 1px
   border, so the *figures* line up down the column, not just the boxes.
   Measured: without it the derived text sat 11px right of the field text
   while both boxes shared an edge — the box edge is a proxy, the digits are
   the thing being read. */
.ds-page .fin-derived {
    display: flex;
    justify-content: flex-end;
    gap: 3px;
    padding-right: 11px;
    font-size: 0.82rem;
    font-variant-numeric: tabular-nums;
    color: var(--body);
}
.ds-page .fin-derived-affix {
    color: var(--faint);
}
/* The one bold figure in the block — it's the answer the row computes, and now
   the only thing carrying weight (the inputs above dropped to regular). */
.ds-page .fin-eq-sum-row .fin-derived {
    color: var(--ink);
    font-weight: 600;
}
/* The `$` takes the sum's weight and colour too. Left faint, it read as a
   stray glyph sitting apart from the total rather than part of it. */
.ds-page .fin-eq-sum-row .fin-derived-affix {
    color: var(--ink);
    font-weight: 600;
}

/* ===== Property Setup — financing controls ===== */

/* `.value-box` / `.value-box-input` lived here — a 92px readout box with a
   gray `#e9ecef` addon, the older of two spellings of the affix field. It was
   retired on Property Setup (2026-08-03) and its last three callers, Rental
   Income's forecast-config row, moved to `.affix-field` on 2026-08-03. The
   92px cap is one of the four width mechanisms the `fw-*` scale replaced —
   see the Width scale note in the affix section above. */

/* Vertical loan + down = purchase equation (down payment row) */
.fin-equation-stack {
    display: flex;
    flex-direction: column;
    gap: 6px;
}
.fin-eq-row {
    display: flex;
    align-items: center;
    gap: 0;
}
.fin-eq-op {
    flex: 0 0 20px;
    text-align: center;
    color: var(--faint);
    font-weight: 700;
}
.fin-eq-cap {
    flex: 0 0 100px;
    margin-left: 8px;
    font-size: 0.72rem;
    color: var(--mute);
    white-space: nowrap;
    overflow: hidden;
}
.fin-eq-sum-row {
    padding-top: 7px;
    border-top: 1px solid var(--hairline-strong);
    margin-top: 2px;
}

/* Purchase Date sits on the left of the Purchase Price row */
.fin-date-left {
    flex: 1 1 auto;
    display: flex;
    align-items: center;
    gap: 12px;
    min-width: 0;
}
.fin-date-label {
    font-size: 0.88rem;
    font-weight: 500;
    color: var(--ink);
    margin: 0;
    white-space: nowrap;
}
.fin-date-input {
    max-width: 180px;
}
.fin-price-label {
    font-size: 0.88rem;
    font-weight: 500;
    color: var(--ink);
    white-space: nowrap;
}

/* Property Configuration — units evenly span the full box width */
.unit-line {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    align-items: end;
    gap: 24px;
    margin-top: 8px;
}
.unit-cell {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    min-width: 0;
}
/* Size and weight deliberately unchanged — these are a centred label row over
   a 5-column grid, not the eyebrow treatment `.form-label` gives the Tax
   Profile's left-aligned rows. Only the colours move onto tokens. */
.ds-page .unit-line-label,
.ds-page .unit-cell .unit-grid-label,
.ds-page .unit-cell label {
    font-size: 0.82rem;
    font-weight: 500;
    color: var(--body);
    margin: 0;
    white-space: nowrap;
}
.ds-page .unit-cell label.disabled {
    color: var(--faint);
}
/* The redesign distributes width via the even grid columns, not by bloating
   any single control — each stays a modest size, centered in its column. */
.unit-count-dd {
    width: 180px;
}
.unit-name-input {
    width: 110px;
    text-align: center;
}

/* Tax Assumptions — Federal/State matrix; value columns flex to fill the box */
.ds-page .tax-grid {
    display: grid;
    grid-template-columns: max-content 1fr 1fr;
    column-gap: 16px;
    row-gap: 0;
    align-items: center;
}
/* Federal/State headers and value cells center within their flexible column */
.ds-page .tax-grid-head:not(:first-child),
.ds-page .tax-grid > .tax-cell {
    justify-self: center;
}
.ds-page .tax-grid-head {
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--mute);
    text-align: center;
    padding-bottom: 6px;
}
/* Hairline between rows — nothing separated the five label/cell rows before,
   so the matrix read as one undifferentiated block. Header row is exempt
   (its own class, no rule here reaches it). */
.ds-page .tax-grid-label,
.ds-page .tax-grid > .tax-cell {
    border-top: 1px solid var(--hairline);
    padding: 9px 0;
}
.ds-page .tax-grid-label {
    font-size: 0.85rem;
    font-weight: 500;
    color: var(--body);
    margin: 0;
    white-space: nowrap;
}
.ds-page .tax-cell {
    display: flex;
    align-items: center;
    gap: 6px;
}

/* Reserves space for the 100%-down explainer (a .note) so it appearing and
   disappearing doesn't nudge the slider above it. */
.ds-page .slider-note-slot {
    margin-top: 6px;
    min-height: 1.2rem;
}

/* ── 10-K statement grid ──────────────────────────────────── */
/* Six row kinds, where DESIGN.md's Data grid spec names two. The four colour
   fields are gone: the total wore #fef3c7/#f59e0b, which is the Cap Ex
   category hue on a page that has no categories, and the returns block wore
   #eef2ff/#4338ca — action-soft and action-hover spent as decoration across a
   full-width band, the exact thing the Catch-All hue change was made to stop.
   Emphasis is weight and rule; recession is colour; hierarchy is indent. */
.ds-page .tk-statement-grid .tk-label-cell {
    font-weight: 500;
    color: var(--ink);
}
/* Subtotal and total are the treatment the Data grid spec has always
   specified and never had a grid to apply to: card-sunken field,
   hairline-strong rule, heavier type. They differ only by the total's second
   rule underneath and one weight step. */
.ds-page .tk-statement-grid .ag-row.tk-row-subtotal {
    font-weight: 500;
    background: var(--card-sunken);
    border-top: 1px solid var(--hairline-strong);
}
.ds-page .tk-statement-grid .ag-row.tk-row-total {
    font-weight: 600;
    background: var(--card-sunken);
    border-top: 1px solid var(--hairline-strong);
    border-bottom: 1px solid var(--hairline-strong);
}
/* Returns are a different *unit*, not a higher importance — the % already
   says so. One hairline opens the block like a section; no field. */
.ds-page .tk-statement-grid .ag-row.tk-row-return {
    border-top: 1px solid var(--hairline);
}
/* One channel, not three. This was mute-ish colour AND italic AND 0.82rem,
   all saying "context"; the system says that with colour.

   Targets the cells, not the row. Cell text now takes `color` from a rule on
   .ag-cell itself, and a direct rule on the element beats inherited colour
   whatever the row's specificity — so a row-level colour that worked before
   the ink fix would silently compute to ink now. */
.ds-page .tk-statement-grid .ag-row.tk-row-driver .ag-cell {
    color: var(--mute);
}
/* The value here is a word, not a number, so it renders as a chip from
   cellStyle (see CELL_STYLE in ten_k_grid.py) — same rule as Capital
   Performance's Signal card putting its verdict in the value slot. */
.ds-page .tk-statement-grid .ag-row.tk-row-signal {
    font-weight: 500;
}
/* Blank separator between major sections — no border, no hover, not clickable. */
.tk-statement-grid .ag-row.tk-row-spacer,
.tk-statement-grid .ag-row.tk-row-spacer .ag-cell {
    background: transparent !important;
    border: none !important;
    cursor: default !important;
}
/* Indent is the whole channel — hierarchy is position. */
.ds-page .tk-statement-grid .ag-row.tk-row-child .tk-label-cell {
    padding-left: 26px;
    font-weight: 400;
    color: var(--body);
}
/* Clickable, but not editable — the system's third cell state.
   The shared grid rule makes hover the entire edit affordance: action-soft
   plus `cursor: text`. Only the cursor was ever the false channel here.
   `action` means "do something", and opening a cell's formula is doing
   something; `text` specifically means "type here", and nothing in this grid
   accepts typing. So the tint stays at full strength — a quieter hover was
   tried in the mock and rejected, because the explainer is only discoverable
   if the cell visibly invites the click — and the cursor tells the truth.

   Every cell carries `ro-cell` (set in ten_k_grid.to_grid), which is what
   excludes it from the shared rule; this is not a specificity override.
   Spacer rows keep their !important opt-out below and stay inert. */
.ds-page .tk-statement-grid .ag-cell {
    cursor: pointer;
}
.ds-page .tk-statement-grid .ag-cell:hover {
    background-color: var(--action-soft);
}
/* The click instruction, kept on screen once an equation replaces the idle
   state. Plain .field-caption (variant J) — this is a resting hint under
   content, which is exactly what that class was added for.

   Sticky, not just appended. The box is a fixed 172px and overflows for the
   longer explanations (measured 272px of content on a sum row), so a hint at
   the end of the flow scrolls out of sight in exactly the cases where there
   is most to read. `inherit` keeps its ground on whatever the box's own
   background is, so it stays correct when the box moves onto `card`. */
.ds-page .tk-explainer-hint {
    position: sticky;
    bottom: 0;
    margin-top: 10px;
    padding-top: 6px;
    background: inherit;
}
/* ── Investment disclaimer box ─────────────────────────────────── */
/* Left as its own block rather than folded into note(): note is a one- or
   two-line footnote, and this is a six-item list with its own heading — the
   same reason Vercel's project banner was rejected for the note component.
   Retokenised in place; the 3px left rule goes, since a full border and a
   heavier left edge were two ways of saying the same thing. */
.ds-page .tk-disclaimer {
    margin-top: 24px;
    padding: 14px 18px;
    border: 1px solid var(--hairline);
    border-radius: 14px;
    background: var(--card-sunken);
}
.ds-page .tk-disc-heading {
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--mute);
    margin-bottom: 8px;
}
.ds-page .tk-disc-list {
    margin: 0;
    padding-left: 18px;
    list-style: disc;
}
.ds-page .tk-disc-list li {
    font-size: 0.75rem;
    color: var(--body);
    margin-bottom: 4px;
    line-height: 1.5;
}
.ds-page .tk-disc-title {
    font-weight: 500;
    color: var(--ink);
}
/* ── Marking a column ────────────────────────────────
   Two jobs, and a header mark only does the first: *which* column this is,
   and *keep your eye in it* down thirty rows. The current-year marker was a
   7% teal wash down the full column. It did the second job, and cost a
   full-bleed tint that mixed with every row kind underneath — a subtotal's
   card-sunken crossing a tinted column makes a third colour at the
   intersection — and the teal was off-token besides.

   A bracket does both. Vertical rules on the column's two edges are a
   continuous eye-track at a fraction of the area, and because they sit on the
   edges rather than under the numbers, every row kind reads through them
   unchanged. The header takes the tab-underline pattern one axis over.

   Deliberate carve-out from "never full vertical gridlines — columns are
   separated by alignment". What that rule prohibits is *every* column being
   boxed, which turns a table into a spreadsheet. One bracketed column out of
   thirty is legible precisely because no other column has one.

   Borders go on the cells so they stack into one continuous line down the
   column; AG Grid sizes cells border-box, so this must not move the column. */
.ds-page .tk-statement-grid .ag-cell.tk-col-today,
.ds-page .tk-statement-grid .ag-header-cell.tk-col-today-header {
    border-left: 1px solid var(--hairline-strong);
    border-right: 1px solid var(--hairline-strong);
}
.ds-page .tk-statement-grid .ag-header-cell.tk-col-today-header {
    border-bottom: 2px solid var(--ink);
}
.ds-page .tk-statement-grid .ag-header-cell.tk-col-today-header .ag-header-cell-label {
    color: var(--ink);
}

/* The optimal-sell year takes the header mark and no bracket. Today is the
   anchor you scan against constantly; the peak year is a one-time finding,
   and two bracketed columns would compete for the same channel.

   No star. There was never one in the column — `.tk-legend`/`.tk-legend-star`
   were CSS with no Python caller, deleted here, and the ★ that did reach a
   user lived in this column's header tooltip, removed in ten_k_grid.py. */
.ds-page .tk-statement-grid .ag-header-cell.tk-col-sell-header .ag-header-cell-label {
    color: var(--warning-deep);
    font-weight: 700;
}

/* ── 10-K sticky header + formula explainer (Phase 3) ─────────── */
.ds-page .tk-sticky-header {
    position: sticky;
    top: 0;
    z-index: 10;
    background: var(--canvas);
    padding: 8px 0 10px;
    border-bottom: 1px solid var(--hairline);
    margin-bottom: 8px;
}
.ds-page .tk-header-flex {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    gap: 20px;
    flex-wrap: wrap;
}
/* The controls column needs a floor, or the sort button wraps to two lines
   and the row stops sitting on one baseline — measured 47.67px against the
   32px the button is specified at. */
.ds-page .tk-header-controls {
    flex: 1 1 380px;
    min-width: 340px;
}
/* A card, at the system's card radius. Was a 10px-radius #fafafa panel —
   the same 10px drift `.setup-section` carried until it was corrected. */
.ds-page .tk-explainer {
    flex: 1 1 420px;
    min-width: 360px;
    max-width: 680px;
    height: 172px;             /* fixed — never resizes with the equation */
    position: relative;
    text-align: left;          /* equations & detail are left-justified */
    border: 1px solid var(--hairline);
    border-radius: 14px;
    padding: 12px 16px 16px;
    background: var(--card);
    box-shadow: var(--raised);
    overflow-y: auto;          /* scroll long explanations instead of clipping */
}
.ds-page .tk-explainer-head {
    position: absolute;        /* pinned to the top-right corner (equation shown) */
    top: 10px;
    right: 16px;
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--mute);
}
/* Idle state — title + prompt centered both ways */
.tk-explainer-idle {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 12px;
    text-align: center;
}
.ds-page .tk-explainer-head-center {
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--mute);
}
.ds-page .tk-idle-hint {
    color: var(--mute);
    font-size: 0.9rem;
}
/* Type here is the equation stack (Property Setup variant D) one size up —
   operands in `body`, the sum in `ink` at 500, captions `mute`, operators
   `faint`. It had grown its own spellings of all four. */
.ds-page .tk-eq-title { font-weight: 500; color: var(--ink); }
.ds-page .tk-eq-year { color: var(--mute); font-weight: 500; }
.ds-page .tk-eq-formula { color: var(--body); font-size: 0.8rem; margin-bottom: 6px; }
.tk-eq-line {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 2px;
    font-size: 0.95rem;
    font-variant-numeric: tabular-nums;
}
.ds-page .tk-eq-chip {
    padding: 2px 8px;
    border-radius: 6px;
    font-weight: 500;
}
.ds-page .tk-eq-operand-1 { background: var(--operand-1-soft); color: var(--operand-1); }
.ds-page .tk-eq-operand-2 { background: var(--operand-2-soft); color: var(--operand-2); }
.ds-page .tk-eq-operand-3 { background: var(--operand-3-soft); color: var(--operand-3); }
.ds-page .tk-eq-operand-4 { background: var(--operand-4-soft); color: var(--operand-4); }
.ds-page .tk-eq-op { color: var(--faint); font-weight: 500; }
.ds-page .tk-eq-result { font-weight: 500; color: var(--ink); }
/* Italic dropped: colour already says "this is context", and the two
   together were the same doubling the driver rows had. */
.ds-page .tk-eq-muted { color: var(--mute); }
.ds-page .tk-eq-source {
    margin-top: 6px;
    font-size: 0.75rem;
    color: var(--mute);
}
.ds-page .tk-eq-link {
    display: inline-block;
    margin-top: 6px;
    font-size: 0.75rem;
    font-weight: 500;
    color: var(--action);
}
.ds-page .tk-eq-link:hover { text-decoration: underline; }
.ds-page .tk-eq-warn {
    margin-top: 8px;
    font-size: 0.8rem;
    font-weight: 500;
    color: var(--warning-deep);
}

/* CSS fraction — numerator stacked over a bar over denominator */
.tk-frac {
    display: inline-flex;
    flex-direction: column;
    align-items: center;
    vertical-align: middle;
    margin: 0 4px;
}
.tk-frac-num {
    display: inline-flex;
    align-items: center;
    gap: 2px;
    padding: 0 6px 3px;
}
.tk-frac-den {
    display: inline-flex;
    align-items: center;
    gap: 2px;
    padding: 3px 6px 0;
    /* The equation stack's sum rule, same weight and token. It was 2px
       #374151, a heavier line than the system draws anywhere. */
    border-top: 1px solid var(--hairline-strong);
}

/* ===== Rent Health & Target (Property Performance bottom section) ===== */
.rt-section {
    margin-top: 2.5rem;
    padding-top: 1.75rem;
    border-top: 1px solid var(--hairline);
}

.rt-section-title {
    font-size: 1.15rem;
    font-weight: 600;
    color: var(--ink);
    margin-bottom: 0.15rem;
}

/* Scoped one class ahead deliberately: `.page-container p` is (0,1,1) and
   out-specifies a bare `.rt-section-subtitle` (0,1,0), so the token silently
   lost and this kept rendering the old #6B7280. Same failure mode as Dash's
   late-injected CSS — win on specificity, never on source order. */
.ds-page .rt-section-subtitle {
    font-size: 0.9rem;
    color: var(--body);
    margin-bottom: 1.25rem;
}

.rt-panel {
    background: #ffffff;
    border: 1px solid var(--hairline);
    border-radius: 12px;
    padding: 1rem 1.15rem;
    height: 100%;
}

.rt-panel-label {
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--mute);
    margin-bottom: 0.1rem;
}

.rt-panel-title {
    font-size: 1rem;
    font-weight: 500;
    color: var(--ink);
    margin-bottom: 0.75rem;
}

.rt-legend {
    display: flex;
    flex-wrap: wrap;
    gap: 14px;
    margin-bottom: 0.35rem;
}

.rt-legend-item {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 0.78rem;
    color: var(--body);
}

.rt-swatch {
    display: inline-block;
    width: 12px;
    height: 2.5px;
    border-radius: 2px;
}

.rt-swatch-rent { background: #2a78d6; }
.rt-swatch-expense { background: #D85A30; }
.rt-swatch-required { background: #2a78d6; }
.rt-swatch-breakeven { background: #888780; }
.rt-swatch-target { background: var(--success); }

.rt-chart-note {
    font-size: 0.72rem;
    line-height: 1.35;
    color: var(--mute);
    margin-top: 0.25rem;
}

.rt-cards-row {
    display: grid;
    gap: 10px;
    margin-top: 0.5rem;
}

.rt-cards-2 { grid-template-columns: 1fr 1fr; }
.rt-cards-3 { grid-template-columns: 1fr 1fr 1fr; }

.rt-target-badge {
    display: inline-flex;
    align-items: center;
    padding: 6px 16px;
    font-size: 0.82rem;
    font-weight: 500;
    /* Was blue-700 on blue-50, which read as `action`. Nothing here is
       clickable — it's the *result* of dragging the CoC slider — and Target is
       the favorable value, so it takes `success`. */
    color: var(--success-deep);
    background: var(--success-soft, #F0FDF4);
    border-radius: 999px;
    white-space: nowrap;
}

.rt-card {
    background: var(--card-sunken);
    border-radius: 8px;
    padding: 0.6rem 0.75rem;
    text-align: center;
}

.rt-card-accent {
    background: var(--success-soft, #F0FDF4);
}

.rt-card-label {
    font-size: 0.72rem;
    color: var(--body);
    margin-bottom: 0.1rem;
}

.rt-card-accent .rt-card-label {
    color: var(--success-deep);
}

.rt-card-value {
    font-size: 1.05rem;
    font-weight: 600;
    color: var(--ink);
}

.rt-card-accent .rt-card-value {
    color: var(--success-deep);
}

.rt-sensitivity-wrap {
    /* `card` white, not `card-sunken`. This block sits on the page canvas, and
       DESIGN.md reserves card-sunken for wells *inside* a card — used on canvas
       it just reads as a darker rectangle. White + hairline makes it its own
       object, which is what the border was for. */
    background: var(--card);
    border: 1px solid var(--hairline);
    border-radius: 12px;
    padding: 0.9rem 1.1rem;
    margin-top: 1rem;
}

.rt-sensitivity-intro {
    font-size: 0.85rem;
    color: var(--body);
    margin-bottom: 0.85rem;
}

.rt-sensitivity-table {
    width: 100%;
    table-layout: fixed;
    border-collapse: collapse;
    font-size: 0.8rem;
    text-align: center;
}

.rt-sensitivity-table th,
.rt-sensitivity-table td {
    padding: 5px 10px;
}

/* The tinted Break-even and Target columns are full-bleed fills, so at 4px
   padding the tint ran right up against the row label's descenders. The label
   column gets its own gutter that the fills can't cross. */
/* The gutter has to come from column width, not padding. The column was
   width:100px with nowrap while the longest label ("Cash-on-cash") measures
   107px, so it overflowed to exactly the tinted cell's left edge. Padding made
   the content box narrower and pushed the overflow further in. */
.rt-sensitivity-table .rt-row-label {
    width: 128px;
    padding-right: 14px;
}

.rt-row-label {
    text-align: left !important;
    color: var(--body);
    width: 100px;
    font-weight: 500;
    white-space: nowrap;
}

.rt-col-head {
    font-weight: 400;
    color: var(--mute);
}

.rt-col-current {
    font-weight: 600;
    color: var(--ink);
}

.rt-col-breakeven {
    background: var(--warning-soft);
    font-weight: 500;
}

.rt-col-target {
    background: var(--success-soft, #F0FDF4);
    color: var(--success-deep);
    font-weight: 600;
}

.rt-col-head.rt-col-breakeven { color: var(--warning-deep); }

.rt-coc-up { color: var(--success-deep); }
.rt-coc-down { color: var(--danger-deep); }


/* ===== CapEx replacement calendar (Phase A) ============================== */
.capex-cal { border: 1px solid var(--hairline); border-radius: 12px; overflow: hidden;
  background: #fff; }
.capex-cal-scroll { max-height: 640px; overflow: auto; }
.capex-cal-head { display: flex; position: sticky; top: 0; z-index: 6;
  background: #fff; min-width: max-content; border-bottom: 1px solid var(--hairline-strong); }
.capex-cal-corner { position: sticky; left: 0; z-index: 8; background: #fff; }
.capex-cal-gutter { width: 96px; flex: 0 0 96px; position: sticky; left: 0;
  z-index: 4; background: #fff; border-right: 1px solid var(--hairline-strong); }
.capex-cal-gutterhead { padding: 8px; font-size: 10.5px; text-transform: uppercase;
  letter-spacing: .05em; color: var(--mute); font-weight: 600; display: flex;
  justify-content: space-between; }
.capex-cal-cols { display: flex; }
.capex-cal-colhead { width: 104px; flex: 0 0 104px; padding: 8px;
  border-right: 1px solid var(--hairline); text-align: left; min-height: 66px;
  display: flex; flex-direction: column; justify-content: space-between; }
.capex-cal-colhead > :first-child { align-self: flex-start; }
.capex-cal-colhead > :last-child { align-self: flex-start; margin-top: auto; }
.capex-cal-cat { font-size: 12.5px; font-weight: 600; line-height: 1.15; color: #1e293b; }
.capex-cal-unitplain { font-size: 11px; color: var(--mute); font-weight: 600; margin-top: 5px; }
.capex-cal-pill { display: inline-block; font-size: 11px; font-weight: 600;
  padding: 2px 9px; border-radius: 20px; color: #fff; }
.capex-cal-catpill { font-size: 12px; line-height: 1.15; }
.capex-cal-gridrow { display: flex; min-width: max-content; }
.capex-cal-col { width: 104px; flex: 0 0 104px; border-right: 1px solid var(--hairline); }
.capex-cal-lane { position: relative; }
.capex-cal-yearcell { position: relative; overflow: hidden; border-bottom: 1px solid var(--hairline);
  display: flex; align-items: flex-start; justify-content: space-between;
  padding: 2px 8px 0 8px; font-size: 11px; font-variant-numeric: tabular-nums; }
.capex-cal-yr { color: #475569; }
.capex-cal-amt { font-size: 10.5px; color: var(--mute); position: relative; z-index: 2; }
/* Reserve inputs integrated at the bottom of the CapEx chart card */
.capex-reserve-controls {
    display: flex;
    align-items: center;
    gap: 10px;
    flex-wrap: wrap;
    margin-top: 14px;
    padding-top: 16px;
    border-top: 1px solid var(--hairline);
}

.capex-cal-yearcell.dense .capex-cal-amt { color: #f59e0b; font-weight: 700; }
.capex-cal-yearcell.current { background: #fff5e6; }
.capex-cal-yearcell.current .capex-cal-yr { color: #f59e0b; font-weight: 700; }
.capex-cal-spendbar { position: absolute; right: 0; bottom: 3px; height: 4px;
  background: #f59e0b; opacity: .45; border-radius: 2px; z-index: 1; }
.capex-cal-curband { position: absolute; left: 0; right: 0; background: #fff5e6; z-index: 0; }
/* Click-to-latch year highlight (one at a time). The rule is pointer-events:none
   and nothing changes block geometry, so drag/extend targets are unaffected. */
.capex-cal-yearcell[data-year] { cursor: pointer; }
.capex-cal-yearcell.capex-cal-yearsel { background: #eef2ff; }
.capex-cal-yearcell.capex-cal-yearsel .capex-cal-yr { color: #4f46e5; font-weight: 700; }
.capex-cal-selline { position: absolute; left: 0; right: 0; height: 2px;
  background: #4f46e5; pointer-events: none; z-index: 5; }
/* Hover preview of the rule a click would commit — dashed and faint so it reads
   as "not yet", and pointer-events:none so it never eats a click on a block. */
.capex-cal-hovline { position: absolute; left: 0; right: 0; height: 0;
  border-top: 2px dashed rgba(79, 70, 229, 0.45); pointer-events: none;
  z-index: 4; display: none; }
.capex-cal-yearcell.capex-cal-yearhover { background: #f5f6ff; }
.capex-cal-yearcell.capex-cal-yearhover .capex-cal-yr { color: #4f46e5; }

/* ===== Field caption — variant J (approved 2026-08-03) =====
   Explanatory text under a field, at rest. The Fields section specs an
   *error* caption in `danger-deep` and nothing else, so every resting hint in
   the app had grown its own class — this one was `.rent-suggest-caption` at
   `#6b7280`, a gray the token set didn't contain then (it became `--mute`
   on 2026-08-05). One class now carries
   both, so the error state is a tone on the caption rather than the only
   text-below-a-field the system knows about. */
.ds-page .field-caption {
    font-size: 0.75rem;
    line-height: 1.4;
    color: var(--mute);
    margin-top: 6px;
}
.ds-page .field-caption-error {
    color: var(--danger-deep);
}

/* The year-rail hint now uses the shared .note component (tone=action,
   badge, dismissible). Its five bespoke rules were retired 2026-08-02. */
/* Blocks outside the latched year invert: white fill, own color as ink + border.
   --blk-fill is set from the block's data-fill (the true hue, not a faded rgba). */
.capex-cal-blk.capex-cal-dim {
    background: #ffffff !important;
    color: var(--blk-fill) !important;
    border-color: var(--blk-fill) !important;
    text-shadow: none;
}
.capex-cal-blk { position: absolute; left: 5px; right: 5px; border-radius: 6px;
  overflow: hidden; padding: 5px 7px 4px; font-size: 10.5px; line-height: 1.25;
  display: flex; flex-direction: column; justify-content: space-between; }
.capex-cal-blk.future { color: #fff; }
.capex-cal-blk.active { color: #1e293b; }
.capex-cal-blk.b-over { border: 3.5px solid #0f172a; }
.capex-cal-blk.b-dash { border: 2.5px dashed #334155; }
.capex-cal-blk.b-solid { border: 1px solid rgba(15,23,42,.14); }
.capex-cal-btop { display: flex; flex-direction: column; }
.capex-cal-bt { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.capex-cal-bprice { font-weight: 600; opacity: .9; }
.capex-cal-bbot { display: flex; flex-direction: column; }
.capex-cal-byr { font-variant-numeric: tabular-nums; font-weight: 600; }
.capex-cal-blife, .capex-cal-bact { opacity: .82; font-size: 10px; }
.capex-cal-bact { font-weight: 600; opacity: .95; }

/* ----- CapEx calendar toolbar + legend (prototype styling) --------------- */
.capex-cal-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 14px;
  padding: 10px 12px; background: var(--canvas); border: 1px solid var(--hairline);
  border-radius: 10px; margin-bottom: 12px; }
.capex-cal-tgroup { display: flex; align-items: center; gap: 8px; }
.capex-cal-tlabel { font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
  color: var(--mute); font-weight: 600; }
/* Icon role. Stays bespoke (a 30px square is not a text button) but tracks the
   6px control radius and the surface tokens. */
.capex-zoom-btn.btn { width: 30px; height: 30px; padding: 0; border-radius: 6px;
  border: 1px solid var(--hairline-strong); background: var(--card); color: var(--ink); font-size: 16px;
  line-height: 1; margin-left: 6px; box-shadow: none; }
.capex-zoom-btn.btn:hover { background: var(--card-sunken); color: var(--ink); }
.capex-cal-legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 0 2px 12px;
  font-size: 12px; color: #475569; }
.capex-cal-legitem { display: inline-flex; align-items: center; gap: 6px; }
.capex-cal-sw { width: 26px; height: 13px; border-radius: 3px; background: var(--card-sunken);
  display: inline-block; box-sizing: border-box; }
.capex-cal-sw.sw-dash { border: 1.5px dashed #334155; }
.capex-cal-sw.sw-solid { border: 1px solid var(--hairline-strong); }
.capex-cal-sw.sw-over { border: 2.5px solid #0f172a; }
.capex-cal-legnote { color: var(--mute); }

.capex-cal-blk { cursor: pointer; transition: box-shadow .12s ease, transform .12s ease; }
.capex-cal-blk:hover { box-shadow: 0 2px 8px rgba(15,23,42,.22); z-index: 3; }

.capex-cal-blk { user-select: none; -webkit-user-select: none; }
.capex-cal-blk:hover { cursor: grab; }

/* Repositioning the whole bar — a bigger, more obvious lift than hover. */
.capex-cal-blk.capex-cal-moving {
    box-shadow: 0 12px 28px rgba(15,23,42,.4);
    transform: scale(1.05);
    z-index: 6;
    cursor: grabbing;
}

/* ----- CapEx "Not planned" strip (under the calendar) ------------------- */
.np-strip {
    display: flex;
    align-items: flex-start;
    gap: 14px;
    flex-wrap: wrap;
    margin-top: 14px;
    padding-top: 16px;
    border-top: 1px solid var(--hairline);
}
.np-strip-label {
    flex: 0 0 auto;
    line-height: 26px;   /* == chip height, so it centers on the first chip row */
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: #b45309;
    white-space: nowrap;
}
.np-chip-row {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    flex: 1 1 auto;
}
.np-chip {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 5px 9px;
    background: #fef3c7;
    border: 1px dashed #d97706;
    border-radius: 8px;
}
.np-chip-name {
    font-size: 0.8rem;
    font-weight: 600;
    color: #b45309;
    white-space: nowrap;
}
.np-chip-badges { display: inline-flex; gap: 5px; }
.np-chip-badge {
    font-size: 0.72rem;
    font-weight: 600;
    color: #92400e;
    background: #ffffff;
    border: 1px solid #fcd34d;
    border-radius: 5px;
    padding: 1px 7px;
    cursor: pointer;
    transition: background .1s ease, border-color .1s ease;
}
.np-chip-badge:hover { background: #d97706; border-color: #d97706; color: #ffffff; }
.np-chip-more {
    font-size: 0.72rem;
    font-weight: 600;
    color: #92400e;
    padding: 1px 4px;
    align-self: center;
}

/* ----- CapEx calendar: resize handle + legend row 2 --------------------- */
.capex-cal-resize { position: absolute; left: 0; right: 0; bottom: 0; height: 9px;
  cursor: ns-resize; z-index: 4; }
.capex-cal-legend.capex-cal-legend-color { margin-top: 6px; }

/* ----- CapEx calendar: re-sort (refresh) button ------------------------- */
.capex-cal-refresh.btn { font-size: 12px; padding: 5px 12px; border-radius: 6px;
  border: 1px solid #fca5a5; background: #fef2f2; color: #b91c1c;
  box-shadow: none; white-space: nowrap; font-weight: 600; }
.capex-cal-refresh.btn:hover { background: #fee2e2; color: #991b1b;
  border-color: #f87171; }

/* ---- Catch-All method toggle (Simple vs Advanced) ---- */
.ca-mode-bar { display: flex; align-items: center; justify-content: space-between;
  gap: 1.5rem; }
.ca-mode-copy { max-width: 70%; }
.ca-mode-title { font-weight: 700; font-size: 0.95rem; color: var(--ink);
  margin-bottom: 3px; }
.ca-mode-desc { font-size: 0.8rem; color: var(--mute); line-height: 1.4; }
/* Stacked rather than inline: at 340px the wide control is too big to sit
   beside its label without the label reading as a stray word. */
.ca-mode-switch { display: flex; flex-direction: column; align-items: flex-start;
  gap: 6px; flex-shrink: 0; }
.ca-mode-switch-label { font-size: 0.78rem; font-weight: 600; color: var(--mute);
  text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
/* The caveat itself is now .note (tone=warning); this keeps only the spacing
   that positions it under the copy it qualifies. */
.ca-mode-warn { margin-top: 6px; }

/* ===== Tax Profile — heading row with inline action ===== */

/* The hairline normally belongs to .section-heading. When a button shares the
   header line, the border moves up to this wrapper so both sit above it. */
.section-heading-row {
    display: flex;
    align-items: center;
    gap: 14px;
    margin-bottom: 20px;
    padding-bottom: 10px;
    border-bottom: 1px solid var(--hairline);
}

.section-heading-row .section-heading {
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: none;
}

/* margin-left rather than justify-content, so the button stays pinned right
   even if the header later gains a third element (badge, subtext). Now a
   dbc.Button(outline=True, color="secondary") — .ds-page .btn handles the
   fill, this class is a pure positioning hook. */
.section-heading-row .tax-reset-btn {
    margin-left: auto;
}

/* Reserve three lines so switching states never resizes the panel. Notes are
   capped at ~140 chars (see config/state_tax.py) to fit this box. */
#setup-tax-notes {
    min-height: 3.4rem;
}

/* ===== KPI band card (thermometer replacement) ===== */
.thermo-head {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 10px;
    min-height: 1.9rem;
}

.thermo-eyebrow,
.thermo-scope {
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.6px;
    color: var(--mute);
}

.thermo-eyebrow {
    text-transform: uppercase;
}

.thermo-scope {
    white-space: nowrap;
    flex: none;
}

.thermo-numrow {
    display: flex;
    align-items: baseline;
    gap: 8px;
    margin-top: 2px;
}

.thermo-metric {
    font-size: 1.9rem;
    font-weight: 700;
    line-height: 2.05rem;
    letter-spacing: -0.4px;
    font-variant-numeric: tabular-nums;
    color: var(--ink);
}

.thermo-vs {
    font-size: 0.78rem;
    color: var(--mute);
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

/* Tightened against the headline; the breathing room is spent below instead,
   so the pill's tail clears the actual marker rather than hiding behind it. */
.thermo {
    position: relative;
    margin-top: 1.05rem;
}

.thermo-pillrow {
    position: relative;
    height: 34px;
}

.thermo-pill {
    position: absolute;
    top: 0;
    transform: translateX(-50%);
    border-radius: 999px;
    padding: 5px 15px;
    font-size: 0.85rem;
    font-weight: 700;
    line-height: 1.15rem;
    white-space: nowrap;
}

.thermo-tail {
    position: absolute;
    bottom: 2px;
    transform: translateX(-50%);
    width: 0;
    height: 0;
    border-left: 6px solid transparent;
    border-right: 6px solid transparent;
}

/* Tint tier: a verdict word on a background. The -soft/-deep pairing is not
   interchangeable — Google designed each tint against its own 800 text. The
   tail is the pill's pointer, so it takes the pill's tint, never the fill. */
.thermo-pill--good { background: var(--success-soft); color: var(--success-deep); }
.thermo-pill--okay { background: var(--warning-soft); color: var(--warning-deep); }
.thermo-pill--bad  { background: var(--danger-soft);  color: var(--danger-deep); }

.thermo-tail--good { border-top: 6px solid var(--success-soft); }
.thermo-tail--okay { border-top: 6px solid var(--warning-soft); }
.thermo-tail--bad  { border-top: 6px solid var(--danger-soft); }

/* 10px of clearance below the tail so the actual marker's white ring can
   never overlap the tail tip. */
.thermo-track {
    position: relative;
    height: 6px;
    margin-top: 10px;
}

.thermo-seg {
    position: absolute;
    top: 0;
    height: 6px;
    border-radius: 999px;
}

/* Fill tier: a shape carrying the verdict, so these are the flat tokens, not
   the -deep text values. Held a hand-copy of the benchmark slider's Tailwind
   500 trio (#22c55e/#eab308/#ef4444, _BENCH_* in pages/property_performance.py)
   — a second palette that made the thermometer and the slider draw the same
   good/okay/bad scale in visibly different greens, yellows and reds. Both now
   resolve through :root. The yellow is the documented 1.93:1 exception, legible
   here because the segment is bounded by red and green. */
.thermo-seg--good { background: var(--success); }
.thermo-seg--okay { background: var(--warning); }
.thermo-seg--bad  { background: var(--danger); }

.thermo-mark-actual {
    position: absolute;
    top: 50%;
    width: 15px;
    height: 15px;
    border-radius: 999px;
    background: #ffffff;
    border: 3px solid var(--ink);
    transform: translate(-50%, -50%);
    box-shadow: 0 0 0 2px #ffffff;
    z-index: 3;
}

.thermo-mark-actual--projected {
    border-style: dashed;
    border-color: var(--body);
}

.thermo-mark-assumption {
    position: absolute;
    top: 50%;
    width: 3px;
    height: 15px;
    border-radius: 1.5px;
    background: #1a3e7a;
    transform: translate(-50%, -50%);
    box-shadow: 0 0 0 2px #ffffff;
    z-index: 2;
}

.thermo-axis {
    position: relative;
    height: 15px;
    margin-top: 8px;
}

.thermo-axlab {
    position: absolute;
    transform: translateX(-50%);
    font-size: 0.6875rem;
    line-height: 15px;
    color: var(--mute);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.thermo-legend {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 20px;
    margin-top: 14px;
}

.thermo-key {
    display: flex;
    align-items: center;
    gap: 7px;
}

.thermo-key-actual {
    width: 12px;
    height: 12px;
    border-radius: 999px;
    background: #ffffff;
    border: 2.5px solid var(--ink);
    flex: none;
}

.thermo-key-actual--projected {
    border-style: dashed;
    border-color: var(--body);
}

.thermo-key-assumption {
    width: 3px;
    height: 13px;
    border-radius: 1.5px;
    background: #1a3e7a;
    flex: none;
}

.thermo-key-label {
    font-size: 0.78rem;
    font-weight: 600;
    color: #1a3e7a;
    line-height: 13px;
}

/* ===== Page lede =====
   Replaces the page H2 on themed pages. The sidebar already names and
   highlights the current page, so a heading that repeats it is decoration —
   the lede carries what the nav label can't say, which is what the page is
   for. Sized between `body` and `section-title` so it reads as an opening
   line, not as a heading. */
/* 72ch, not the 68 this shipped with. Property Setup's lede is the longest
   of the three at 68.5ch, so it wrapped a single trailing word onto a second
   line — measured at 686px against a 680px cap. The other two ledes are
   36.5ch and 42.6ch, so raising the cap changes nothing for them, and 72ch
   is still inside the readable measure. */
.ds-page .page-lede {
    font-size: 0.95rem;
    line-height: 1.5;
    color: var(--body);
    margin: 0 0 24px;
    max-width: 72ch;
}

/* Lede sharing its row with a disclosure trigger. Stacked, the two cost 62px
   before the content below (measured 2026-08-05: 24px lede margin + a 32px
   `btn-sm` row + its 6px). The button carries 12.5px of ink and sits
   right-aligned, so most of that 62px was empty space under the lede's left
   edge. Baseline-aligned so the button's label sits on the lede's line, not
   centered against it. Opt-in by wrapper — a page whose lede runs long should
   keep stacking rather than squeeze the trigger. */
.ds-page .page-lede-row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    margin-bottom: 16px;
}

.ds-page .page-lede-row .page-lede,
.ds-page .page-lede-row .kpi-explainer-trigger {
    margin-bottom: 0;
}

/* Chart card header — the chart's name as an HTML `section-title`, not a
   Plotly in-plot title. Plotly titles collide with the frame's top-left
   legend, and DESIGN.md puts the name in the card header anyway. */
.ds-page .chart-card-title {
    font-size: 15px;
    font-weight: 600;
    color: var(--ink, #0C2340);
    padding: 16px 20px 0;
    margin: 0;
}

/* KPI verdict pill — pinned to the card's top-right corner rather than sitting
   under the sparkline. It's a verdict on the whole card, so it belongs beside
   the label, not at the end of the reading order. The card body gets relative
   positioning to anchor it. */
.ds-page .kpi-card .card-body {
    position: relative;
}

/* Placement only, and deliberately not keyed off `.chip` — a chip standing
   in for a card's *value* rides the metric baseline and must not be pulled
   into the corner. */
.ds-page .kpi-card .kpi-status-chip {
    position: absolute;
    top: 14px;
    right: 16px;
    margin: 0;
    align-self: auto;
}

/* ===== Semantic chip — the shared -soft/-deep status badge =====
   Built 2026-08-02. rollout-plan's open item said to build this the first
   time a *second* badge was needed, not before; Capital Performance needs
   two (the Signal card's verdict and the Home value confidence pill), which
   is the trigger.

   `.kpi-status-badge` was the older private implementation of the same
   pattern, on hardcoded hexes rather than tokens. It was folded in on
   2026-08-05. The note here used to say the fold would repaint Expenses and
   Property Performance; it does not. `create_kpi_card` is called only from
   pages/dashboard.py — Expenses and Rental Income render
   `create_thermometer_card`, whose pill is `.thermo-pill`. */
.ds-page .chip {
    display: inline-block;
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    padding: 3px 10px;
    border-radius: 999px;
    white-space: nowrap;
    /* .kpi-card > .card-body is a column flexbox, so a chip stretches to the
       card's full width without this — same trap .kpi-status-badge hit. */
    align-self: flex-start;
}

.ds-page .chip-success { background: var(--success-soft); color: var(--success-deep); }
.ds-page .chip-warning { background: var(--warning-soft); color: var(--warning-deep); }
.ds-page .chip-danger  { background: var(--danger-soft);  color: var(--danger-deep); }
.ds-page .chip-neutral { background: var(--card-sunken);  color: var(--body); }

/* A chip standing in for a card's *value* rides the metric baseline rather
   than reading as a footnote below it. Sized off .kpi-card-value's own
   margins so the row's cards stay aligned whichever form they take. */
.ds-page .kpi-card-value-chip {
    margin: 0 0 4px;
    font-size: 0.9rem;
    padding: 4px 12px;
}

/* ===== Capital Performance — stat card row =====
   Six equal-width cards (Signal + five figures) and, below them, a matching
   row of five breakdown paragraphs. Both rows were built from inline style
   dicts repeated per cell; the widths are a layout fact about the row, so
   they live here instead.

   The two rows share one class deliberately — the breakdown text has to line
   up under the card it explains, and giving each row its own rule is how they
   drift apart. */
.ds-page .perf-stat-row {
    display: flex;
    gap: 1rem;
    align-items: stretch;
}

/* #perf-stat-cards and #perf-breakdown-content are callback targets holding
   five children each. display:contents promotes those children to direct flex
   items of the row, so all six columns share one split and one gap — nesting
   them in their own box gave Signal a different width from the rest. */
.ds-page #perf-stat-cards,
.ds-page #perf-breakdown-content {
    display: contents;
}

/* Both levels have to be listed. `display: contents` promotes the five
   wrappers into the row's *flex* tree, but CSS combinators match the **DOM**
   tree, so `.perf-stat-row > div` never reaches them — measured, not assumed:
   with only the `>` rule the six cards came out 37 / 168 / 117 / 157 / 164 /
   133px instead of six equal columns, Signal collapsing to its 37px because
   it was the one flexible item beside five content-sized ones. */
.ds-page .perf-stat-row > div,
.ds-page #perf-stat-cards > div,
.ds-page #perf-breakdown-content > div {
    flex: 1 1 0;
    min-width: 0;
    display: flex;
}

/* A flex item doesn't stretch along the main axis by default, so without this
   each card shrinks to its own number's length and the boxes resize on every
   hover. */
.ds-page .perf-stat-row .kpi-card {
    width: 100%;
}

/* The Pre/After-Tax state moved out of the figure (a Plotly paper annotation
   at x=1, y=1.06, i.e. chart chrome doing a card header's job) and into the
   card header beside the title. */
.ds-page .chart-card-tag {
    margin-left: auto;
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--mute);
}

/* Each valuation method carries a 3px rule in its own line's colour rather
   than a coloured heading — category and series colour never colour text, and
   a swatch ties the box to its line the way a legend dot does. */
.ds-page .tri-method-swatch {
    height: 3px;
    width: 28px;
    border-radius: 2px;
    margin-bottom: 8px;
}

/* The caveat sat tight under the three boxes and read as a fourth column's
   orphaned text. It is a footnote *about the set*, so it needs room to
   separate from it. */
.ds-page .tri-method-note {
    margin-top: 14px;
}

/* Two-line sub-lines inside a stat card broke as one long line plus an orphan
   word. `balance` splits them evenly. No reserved height: the cards are
   `h-100` so they're already equal, and the sub sits *below* the value, so a
   one-line sub next to a two-line one shifts nothing that reads as misaligned
   — reserving two lines everywhere would just add dead space to four cards to
   tidy one. */
.ds-page .perf-stat-row .kpi-card-subtitle {
    text-wrap: balance;
}

/* Explanatory prose: the breakdown drawer, the valuation-method explainer.
   Was `{fontSize: 0.85rem, color: #4b5563}` inline in four places — #4b5563
   is off-token and `body` is the settled prose colour. Scoped one class ahead
   of `.page-container p`, which is (0,1,1) and used to hardcode #6b7280 —
   it reads `--mute` since the 2026-08-05 retrofit. */
.ds-page .perf-prose {
    font-size: 0.85rem;
    line-height: 1.45;
    color: var(--body);
}

/* The chip carries a dot when it sits outside a card and has no label
   column to anchor it — the Home value confidence pill. */
.ds-page .chip-dot {
    display: inline-block;
    width: 7px;
    height: 7px;
    border-radius: 50%;
    margin-right: 7px;
    vertical-align: baseline;
    background: currentColor;
}

/* ===== Rent roll — variant H (approved 2026-08-03) =====
   A vacancy gap is an observation the app derived, not a lease the user
   entered. It used to be a normal row that borrowed the Tenant column and
   turned it red, so a gap rendered as a tenant named "Vacant" — it sorted,
   aligned to the money columns and read as a record. It is now a full-width
   row, which has no columns to pose with.

   Ground is `card-sunken`, the same well the surface model already uses for
   things sitting *inside* a card. The label is `mute` italic; the one figure
   the row genuinely has — the rent the gap cost — is `danger`, because that
   number is the reason the row earns a line at all.

   **The cost sits next to its label, not right-aligned to the money column**,
   and this was measured rather than chosen. A full-width row lives in
   `.ag-full-width-container`, which is sized to the *viewport*; the columns
   live in `.ag-center-cols-container`, which is sized to their total width
   and scrolls independently. The two were 757.38px and 755px in the same
   frame, and the gap between them changes with the window. So there is no
   stable right edge to align to: too narrow and the money column is scrolled
   out of sight, too wide and the row extends past every column. Right-
   aligning would have looked correct at exactly one window width. The row has
   no columns — which is the whole point of variant H — so it reads as one
   sentence instead of pretending to a grid it left. */
.ds-page .rent-roll .ag-full-width-row .rr-gap {
    display: flex;
    align-items: center;
    gap: 6px;
    height: 100%;
    padding: 0 12px;
    background: var(--card-sunken);
    border-bottom: 1px solid var(--hairline);
}
.ds-page .rent-roll .rr-gap-label {
    color: var(--mute);
    font-style: italic;
    font-size: 0.82rem;
}
.ds-page .rent-roll .rr-gap-cost {
    color: var(--danger);
    font-size: 0.82rem;
    font-variant-numeric: tabular-nums;
}

/* The no-rows overlay was an inline `#9ca3af`, one of the 26 instances of that
   hex the text-token retrofit will collect. Given a class so this one is
   already on `mute` when that sweep arrives. */
.ds-page .grid-empty {
    color: var(--mute);
    font-size: 0.85rem;
}

/* Links inside a dbc.Tooltip / dbc.Popover body. Deliberately NOT scoped to
   .ds-page: dbc portals these to <body>, outside the page container, so a
   scoped rule matches nothing. The class name is unique, so there is no
   specificity tie to lose. Was an inline #6366f1 — a near-miss of --action
   and the same off-token indigo the hero chart carried. */
.tt-link {
    color: var(--action);
    text-decoration: underline;
}
.tt-link:hover {
    color: var(--action-hover);
}
