/* ==========================================================================
   Finapp — tokens, shell, charts.

   Colours come from the validated data-viz reference palette: the three series
   are categorical slots 1–3 (blue / orange / aqua), which clear the all-pairs
   CVD and normal-vision floors in both modes. Light aqua sits below 3:1 on the
   light surface, so every chart also ships a direct end-label and a table view.
   ========================================================================== */

:root {
  color-scheme: light;

  --plane: #f9f9f7;
  --surface-1: #fcfcfb;
  --surface-2: #f2f1ec;
  --text-primary: #0b0b0b;
  --text-secondary: #52514e;
  --text-muted: #898781;
  --grid-line: #e1e0d9;
  --axis-line: #c3c2b7;
  --border: rgba(11, 11, 11, 0.10);
  --border-strong: rgba(11, 11, 11, 0.18);
  --shadow: 0 1px 2px rgba(11, 11, 11, 0.05), 0 8px 24px rgba(11, 11, 11, 0.06);

  --accent: #2a78d6;
  /* Fill for solid controls that carry white text: the accent itself only
     reaches 4.42:1 against white, which is below AA for this text size. */
  --accent-solid: #256abf;
  --series-income: #2a78d6;   /* categorical slot 1 */
  --series-expenses: #eb6834; /* categorical slot 2 */
  --series-net: #1baf7a;      /* categorical slot 3 */
  /* A fourth series the palette has no slot for: no documented step clears the
     all-pairs floors beside these three in dark mode, so this purple was found
     by searching OKLCH space against the validator — it passes every gate in
     both modes (worst all-pairs CVD ΔE 9.2, normal-vision 20.9). */
  --series-invested: #703879;
  /* A fifth, the same way. The strict gate is what makes it hard: a colour may
     merely reach the CVD *floor* and still validate, and the shipped four all
     clear the higher *target*, so the search kept that bar. Only the magenta
     band clears it beside these four while staying legible on both surfaces. */
  --series-worth: #b9448e;

  /* Comparing strategies puts several lines on one plot, so colour carries
     identity there. These are the four the palette can seat with every pair
     distinguishable, in both themes — which is why four is the ceiling. */
  --strategy-1: #2a78d6;
  --strategy-2: #eb6834;
  --strategy-3: #1baf7a;
  --strategy-4: #703879;

  --status-good: #006300;     /* success text step — AA on the light surface */
  --status-critical: #d03b3b;

  /* Where a plan came from, marked on its tab. Deliberately *outside* the
     categorical palette and deliberately muted: colour in this app already
     means series on the flow cards and strategy in the comparison, and a third
     meaning must not be mistakable for either. A vivid dot would read as a
     legend swatch saying which line in the chart a plan is; a low-chroma
     leading edge reads as chrome, which is what it is. The hues come from the
     two bands the palette leaves empty — neutral and teal — plus an ochre far
     enough from the expenses orange in both lightness and chroma. */
  --origin-own: #7d7b74;      /* yours: the common case, so the quietest */
  --origin-default: #8f7233;  /* the worked example, not your work */
  --origin-shared: #2b7f8f;   /* somebody handed you this */
  /* The same teal, a step deeper — deeper rather than lighter because a
     lighter one reached only 2.5:1 against the tab and a mark nobody can see is
     not a mark. Dark mode steps the other way for the same reason. */
  --origin-edited: #1d616e;   /* the same, gone on since it arrived */

  /* --- type ---------------------------------------------------------------

     Nine sizes, where the file had thirteen: 11, 11.5, 12, 12.5, 13, 13.5, 14,
     14.5, 15, 16, 17, 22 and 64. Six of those were half-pixel neighbours of
     another, and half a pixel is not a step — at this size it moves no glyph a
     reader can see, it only makes two sizes that look identical impossible to
     tell apart in a diff. Each pair is one step here, snapped to the integer,
     so every collapse made the text smaller or left it alone and nothing on the
     page could newly outgrow its box. 15 went the same way for the same reason:
     a section's name is told from the prose under it by weight, letter-spacing
     and position, never by the pixel that used to be between them.

     A 1px ladder from 11 to 17 rather than a ratio, because a ratio at this end
     lands between pixels and this app is dense — every reading on the page is a
     figure beside a name, and the names have to stack without fighting. The
     ratio starts above the body size, where the figures are.

     16px is not a taste: under it a phone zooms the page in on focus and does
     not zoom back out, so every box you type in wears --type-md and nothing
     smaller. */
  --type-3xs: 11px;  /* a mark inside a drawing: axis ticks, flow names on a phone */
  --type-2xs: 12px;  /* what annotates a figure: derived lines, captions, legends, the footer */
  --type-xs: 13px;   /* secondary reading: hints, chips, small pills, a table's body */
  --type-sm: 14px;   /* what you press, scan or are told: buttons, tabs, card and section names */
  --type-md: 16px;   /* the document, and every box you type in */
  --type-lg: 17px;   /* the title over the one dialog */
  --type-xl: 22px;   /* a tile's answer */
  --type-2xl: clamp(40px, 8vw, 56px);  /* the one figure the whole page is about */
  --type-3xl: 64px;  /* the same figure, on a shell 3,000px wide */

  /* --- space --------------------------------------------------------------

     Eleven steps: a 2px grid to 12, a 4px grid to 24, an 8px grid above that.
     The grid gets coarser as the distances get longer because that is the only
     range in which a reader can see the difference — 2px between a label and
     its figure is a relationship, 2px between two panels is nothing at all.

     The scale governs gap, and the padding and margin that hold content off its
     own edges. It does not govern measurements: a control's height, the room
     reserved for a unit printed inside a box, the height of the pinned bar. A
     handful of those stay literal and each says why where it stands. */
  --space-3xs: 2px;
  --space-2xs: 4px;
  --space-xs: 6px;
  --space-sm: 8px;
  --space-md: 10px;
  --space-lg: 12px;
  --space-xl: 16px;
  --space-2xl: 20px;
  --space-3xl: 24px;
  --space-4xl: 32px;
  --space-5xl: 40px;

  --radius: 14px;
  --radius-sm: 9px;
  --font: system-ui, -apple-system, "Segoe UI", sans-serif;

  /* One shell width in one place: the header, the page, the pinned strategy
     row and the footer all wear it, and they have to move together or the
     app bar stops lining up with the page under it. */
  --shell: 1120px;
  /* Wide enough for two 140px cells of an open field row, and no wider: at
     1440 it leaves the readings 986px, which is where the comparison table
     stops scrolling sideways with three plans and six tiles. With four plans
     and nine it does neither — the table wants 1,075px and the tiles 1,320 —
     which is what the wide tiers below are for. */
  --rail: 354px;
  /* The right-hand column, and zero until the shell is wide enough to seat one
     without taking the room the cards need. 1,180px is not a taste: it is the
     narrowest column in which the comparison table stops scrolling sideways,
     measured with four plans in both languages. English needs 1,076; French
     needs 1,160, because every column head in it is longer and the row heads
     are `white-space: nowrap` so a plan's name cannot give way. Sizing this on
     the English table would have shipped a dock that fixes the scroll in one
     language and moves it in the other. */
  --dock: 0px;
}

@media (prefers-color-scheme: dark) {
  :root:where(:not([data-theme="light"])) {
    color-scheme: dark;

    --plane: #0d0d0d;
    --surface-1: #1a1a19;
    --surface-2: #232322;
    --text-primary: #ffffff;
    --text-secondary: #c3c2b7;
    --text-muted: #898781;
    --grid-line: #2c2c2a;
    --axis-line: #383835;
    --border: rgba(255, 255, 255, 0.10);
    --border-strong: rgba(255, 255, 255, 0.20);
    --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);

    --accent: #3987e5;
    --series-income: #3987e5;
    --series-expenses: #d95926;
    --series-net: #199e70;
    --series-invested: #9f3dae;
    --series-worth: #e557b1;

    --strategy-1: #3987e5;
    --strategy-2: #d95926;
    --strategy-3: #199e70;
    --strategy-4: #9f3dae;

    --status-good: #0ca30c;
    --status-critical: #e66767;

    --origin-own: #918f86;
    --origin-default: #c2a05a;
    --origin-shared: #55b4c8;
    --origin-edited: #2f7b89;
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;

  --plane: #0d0d0d;
  --surface-1: #1a1a19;
  --surface-2: #232322;
  --text-primary: #ffffff;
  --text-secondary: #c3c2b7;
  --text-muted: #898781;
  --grid-line: #2c2c2a;
  --axis-line: #383835;
  --border: rgba(255, 255, 255, 0.10);
  --border-strong: rgba(255, 255, 255, 0.20);
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);

  --accent: #3987e5;
  --series-income: #3987e5;
  --series-expenses: #d95926;
  --series-net: #199e70;
  --series-invested: #9f3dae;
  --series-worth: #e557b1;

  --strategy-1: #3987e5;
  --strategy-2: #d95926;
  --strategy-3: #199e70;
  --strategy-4: #9f3dae;

  --status-good: #0ca30c;
  --status-critical: #e66767;

  --origin-own: #918f86;
  --origin-default: #c2a05a;
  --origin-shared: #55b4c8;
  --origin-edited: #2f7b89;
}

/* ------------------------------------------------------------------- base */

*, *::before, *::after { box-sizing: border-box; }

/* Components below set display: grid/flex/inline-flex, which would otherwise
   out-specify the UA rule for [hidden] and leave hidden elements on screen. */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--plane);
  color: var(--text-primary);
  font-family: var(--font);
  font-size: var(--type-md);
  line-height: 1.5;
  min-height: 100svh;
  padding-bottom: env(safe-area-inset-bottom);
}

h1, h2, h3 { line-height: 1.25; margin: 0; }
p { margin: 0; }

button, input, output { font: inherit; color: inherit; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 4px;
}

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  left: 8px; top: -48px;
  z-index: 10;
  padding: var(--space-sm) var(--space-lg);
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  text-decoration: none;
  transition: top 120ms ease;
}
.skip-link:focus { top: 8px; }

/* --------------------------------------------------------------- app bar */

.app-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-xl);
  max-width: var(--shell);
  margin: 0 auto;
  padding: var(--space-xl) var(--space-2xl) var(--space-xs);
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  font-weight: 600;
  letter-spacing: -0.01em;
}

.brand-mark {
  width: 22px; height: 22px;
  fill: none;
  stroke: var(--accent);
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.brand-mark circle { fill: var(--accent); stroke: none; }

/* Wrapping, because the row grew a sixth button. Undo appears at the moment
   something has just been thrown away, which is the worst moment for the page
   to start scrolling sideways — and it does: at 320px the sixth button pushed
   the document 34px wider in English and 76px in French, where every one of
   the labels is longer. */
.app-bar-actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); }

/* The receipt for a press of Undo. It takes the whole line under the buttons
   rather than a place among them: it is a sentence and they are words, and a
   sentence in that row would be the thing that broke it. */
.undo-said {
  flex-basis: 100%;
  min-width: 0;
  text-align: right;
}

.ghost-button {
  min-height: 36px;
  padding: var(--space-xs) var(--space-lg);
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--text-secondary);
  font-size: var(--type-sm);
  cursor: pointer;
}
.ghost-button:hover { border-color: var(--border-strong); color: var(--text-primary); }
.ghost-button[disabled] { opacity: 0.6; cursor: default; }

/* ----------------------------------------------------------------- layout */

main {
  max-width: var(--shell);
  margin: 0 auto;
  padding: var(--space-md) var(--space-2xl) var(--space-5xl);
  display: grid;
  gap: var(--space-2xl);
}

.panel {
  /* Grid and flex items default to min-width: auto, which lets the intrinsic
     width of a text input inflate every ancestor and push the page wider than
     the screen. Every container down to the row opts out. */
  min-width: 0;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: var(--space-2xl);
  display: grid;
  gap: var(--space-xl);
}

.panel h2 {
  font-size: var(--type-sm);
  font-weight: 600;
  letter-spacing: -0.01em;
}

/* The panel is a container so the field rows can be laid out against the width
   they actually have rather than the window's. Docked as a rail on a 1440px
   screen it is 312px wide, which is a phone-shaped space no width query can
   see. */
.inputs-panel {
  container-type: inline-size;
  container-name: inputs;
}

/* Everything above the fold: the heading with the button that folds, and the
   strategy switcher, which must stay out of the folded region — it is how you
   change plan, and changing plan with the fields away is exactly what a folded
   form is for. */
.panel-head {
  display: grid;
  gap: var(--space-lg);
  min-width: 0;
}

.panel-title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-lg);
  min-width: 0;
}
.panel-title h2 { min-width: 0; }

.rail-toggle { flex: 0 0 auto; }
/* The chevron says which way the form would go, and turns rather than swaps:
   it is one control in two states, not two controls. */
.rail-toggle .action-icon { transition: transform 140ms ease; }
body[data-rail="closed"] .rail-toggle .action-icon { transform: rotate(-90deg); }

.panel-body {
  display: grid;
  gap: var(--space-xl);
  min-width: 0;
}

/* The three paragraphs that explain the form rather than answer anything.
   A <details> rather than a class the app toggles: the browser has this
   behaviour, it is keyboard-operable and announced without a line of script,
   and its state is the reader's own. */
.panel-notes,
.section-notes { min-width: 0; }
.panel-notes > summary,
.section-notes > summary {
  padding: var(--space-xs) 0;
  font-size: var(--type-xs);
  color: var(--text-secondary);
  cursor: pointer;
}
.panel-notes > summary:hover,
.section-notes > summary:hover { color: var(--text-primary); }
.panel-notes > * + *,
.section-notes > * + * { margin-top: var(--space-sm); }
/* The paragraphs inside carry the section's own note margins, which were sized
   to stand between blocks rather than inside one. */
.section-notes > .section-note,
.section-notes > .scale-note { margin: 0; }

/* The left column: the plan's own fields, and under them the assumptions every
   plan is read through. One column so the grid sees one item; two panels so the
   scope of each is visible without reading a word. */
.rail {
  display: grid;
  gap: var(--space-2xl);
  min-width: 0;
  /* The fields take what is left and the assumptions take what they need. */
  align-content: start;
}

/* The whole panel is the disclosure, so its heading is the thing you press.
   `display: flex` would take the marker away from a <summary>, so the h2 is
   made inline instead and the marker keeps its place. */
.assumptions-fold > summary {
  cursor: pointer;
  padding: var(--space-3xs) 0;
}
.assumptions-fold > summary h2 { display: inline; }
.assumptions-fold > summary:hover h2 { color: var(--text-secondary); }
.assumptions-fold > * + * { margin-top: var(--space-lg); }

/* Side by side where there is room, stacked where there is not. Below the
   breakpoint this panel is the width of the page and the two groups read as the
   row they used to be; docked in a 312px rail they become a short list. auto-fit
   rather than a query because the wrap is the whole rule. */
.assumption-groups {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  gap: var(--space-lg) var(--space-2xl);
  min-width: 0;
}

/* A control and the box it reveals belong to each other: read as one row of
   five, that was only ever legible because the row was 986px wide. */
.assumption-group {
  display: grid;
  gap: var(--space-sm);
  min-width: 0;
}
.assumptions-panel .money-filter { min-width: 0; }

/* Everything that is a reading rather than a control. Stacked exactly as it
   was when these were children of <main> — same gap, same order — because
   above the breakpoint it becomes the second column and a grid item cannot be
   sticky against siblings it does not have. */
.output {
  display: grid;
  gap: var(--space-2xl);
  min-width: 0;
}
/* These sections have never had to opt out of `min-width: auto`, because the
   column has never been narrower than the window. It is now. */
.output > * { min-width: 0; }

/* The comparison dock: the same stack, the same gap, and in one column it lands
   exactly where its two sections used to sit — last, under the flow diagram.
   Everything that makes it a *column* is in the wide blocks below; here it is
   only a box.

   It has no fold of its own. Both sections inside it already hide themselves
   when there is nothing to compare and nothing to rank, and `render` copies
   that verdict onto <body> so this rule and the grid can read it: a column that
   reserves 1,180px for two hidden sections is 1,180px the cards could have had.
   A button would have added a third state to persist and a folded gutter that
   shows nothing — the form's gutter at least keeps the chevron that brings it
   back. */
.dock {
  display: grid;
  gap: var(--space-2xl);
  min-width: 0;
  align-content: start;
}
.dock > * { min-width: 0; }
body[data-dock="off"] .dock { display: none; }

/* ------------------------------------------------------------------ the rail

   Past this width there is room to stand the form beside the readings instead
   of above them, which is what the space is for: a wide screen spent on one
   column puts the first figure below the fold and leaves 500px of margin
   either side of it. */
@media (min-width: 1180px) {
  :root { --shell: 1400px; }

  /* minmax(0, 1fr), never a bare 1fr: a bare 1fr is minmax(auto, 1fr) and
     floors at the column's min-content, which a wide table or a long name
     would then push past the screen.

     Named areas rather than auto-placement, because there are three boxes now
     and only two of them are columns here: the comparison dock rides under the
     readings until the shell is wide enough to stand it beside them. The rail
     is given an area that spans both rows for one reason — a sticky grid item
     travels the height of its own grid area and no further, so a rail parked in
     row one alone would stop dead where the readings end and leave the dock to
     scroll past it. */
  main {
    grid-template-columns: var(--rail) minmax(0, 1fr);
    grid-template-areas:
      "rail output"
      "rail dock";
    align-items: start;
  }
  .rail { grid-area: rail; }
  .output { grid-area: output; }
  .dock { grid-area: dock; }
  /* Nothing to compare: the second row goes with the box, gap and all. */
  body[data-dock="off"] main { grid-template-areas: "rail output"; }

  /* The column stays where it is while the readings scroll past it, so changing
     a number and watching the curve move is one movement rather than two. */
  .rail {
    position: sticky;
    top: 12px;
    max-height: calc(100vh - 24px);
    /* The fields take what is left over and scroll inside it; the assumptions
       take the height they need and stay in view at the bottom. `minmax(0, …)`
       because a bare `1fr` floors at min-content, and a form's min-content is
       the whole form — which would push the assumptions off the bottom of a
       column that is capped at the height of the window. */
    /* The fields take what is left over and the assumptions take what they
       need — but the assumptions are capped at under half the column, and
       scroll inside it past that. Uncapped, a 641px laptop window with the
       assumptions open and both toggles on gave the fields 134px, and their
       own sticky head is 180: every row was pinned under it, unreachable by
       any amount of scrolling. Measured before this line existed. */
    grid-template-rows: minmax(0, 1fr) minmax(0, 45%);
  }

  /* The scrollport is the panel rather than the column, and that is not a
     detail: the pinned bar at the top of the window decides it is needed by
     watching the switcher with an IntersectionObserver, and an observer with a
     null root clips against ancestor overflow. Kept here, the head can never
     leave the box that scrolls it, so the switcher is never "off screen" with
     the window still at the top. Were the column the scrollport, a long enough
     form would scroll its own head out of view and raise the pinned copy over
     the app bar for no reason. */
  .inputs-panel {
    overflow-y: auto;
    overscroll-behavior: contain;
    /* Handed to the head below, which is what has to cover it. */
    padding-top: 0;
  }

  /* The switcher holds its place while the fields scroll under it. Not
     cosmetic: the pinned bar at the top of the window decides it is needed
     with an IntersectionObserver, and an observer with a null root clips
     against ancestor overflow — so a strategy bar scrolled out of a scrolling
     rail reads as "scrolled out of view" and would raise the pinned copy over
     the app bar with the window still at the top. Sticky, it never leaves. */
  .inputs-panel .panel-head {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--surface-1);
    /* A scrollport's padding is inside it and scrolls with everything else, so
       the panel's own --space-2xl sat above a head pinned at `top: 0` — a strip that
       rows slid up through. Measured at a 900px window: a colour key and the
       three action buttons of a row that had already gone, floating over the
       panel's top edge. So the head holds that padding instead of the panel
       (which gives it up, above), and what it pins against is the top of the
       box rather than the top of the text inside it. */
    padding-top: var(--space-2xl);
    padding-bottom: var(--space-lg);
    margin-bottom: calc(-1 * var(--space-2xs));
  }

  /* Folded, the rail keeps its place in the grid as a gutter still holding the
     one button that brings it back. The readings widen once — 986 to 1288 —
     and that is all the movement there is: the way back is exactly where the
     way out was, and nothing floats over the figures for the reader to scroll
     them under. The page comes out at 3,446 against 3,458 open, which is the
     honest measure of how little of it the form ever was. */
  body[data-rail="closed"] main { grid-template-columns: 52px minmax(0, 1fr); }
  body[data-rail="closed"] .rail {
    position: static;
    max-height: none;
    grid-template-rows: auto;
  }
  /* Folded to a gutter there is room for one button and nothing else, and the
     assumptions are not what the button brings back first. */
  body[data-rail="closed"] .assumptions-panel { display: none; }
  .assumptions-panel { overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
  body[data-rail="closed"] .inputs-panel {
    overflow: visible;
    padding: var(--space-sm) var(--space-2xs);
  }
  body[data-rail="closed"] .inputs-panel > :not(.panel-head),
  body[data-rail="closed"] .panel-head > :not(.panel-title),
  body[data-rail="closed"] .panel-title > :not(.rail-toggle) { display: none; }
  body[data-rail="closed"] .panel-head {
    position: static;
    /* Nothing is pinned here and nothing scrolls under it, so the head hands
       the padding back — kept, it would push the chevron 22px down a gutter
       that is only padded by 8. */
    padding-top: 0;
    padding-bottom: 0;
    margin-bottom: 0;
  }

  /* The fold is the only thing on this layout that takes the switcher off the
     screen, so the pinned copy earns its place here — and the page makes room
     for it rather than letting it stand on the app bar. */
  /* And only when that bar can exist: it shows with more than one plan, which
     is exactly when the comparison is docked. Folding a single plan's form
     used to leave 48px of empty band above the app bar for a bar that never
     came. */
  body[data-rail="closed"][data-dock="on"] { padding-top: 48px; }
}

/* -------------------------------------------------------- the wide shell

   Three sizes of screen above the rail's breakpoint, and each one is a claim
   about what the extra width is worth rather than a round number.

   The measurements this ladder was built from, at 1440 today: every section in
   the readings is already at its full height by the time the column is 986px
   wide, and stays there to 7680. Width on its own buys 60px of page — 1.8%.
   What buys page height is how many things stand beside each other: four cards
   across instead of two is 375px, and moving the comparison out of the stack is
   another 950. So the shell grows in order to *seat columns*, and stops the
   moment there is no further column worth seating.

   Two floors govern the whole ladder. The comparison table stops scrolling
   sideways at 1,076px with four plans in English and 1,160 in French, measured
   — that is the one hard width requirement in the app, and it is why the dock
   starts at 1,180 rather than anywhere narrower. And a flow card wants about 320px before its end-labels start being
   dropped and about 560 before the plot goes flat, since the plot is 220px tall
   at every width there has ever been (`chart.js`, PLOT_HEIGHT + TOP_PAD +
   X_AXIS_BAND). Cards are counted against those two numbers, not eyeballed. */

/* ---- 1600: the readings finally clear the comparison table.

   The rail's breakpoint costs the readings 314px — 1,080 at a 1179px window,
   766 at 1180 — and until here the shell's 1400px cap never gave it back. It
   does now: 1,138px at 1600 is the first reading column in this app's history
   wide enough for four plans of comparison without a sideways scroll.

   `100vw` minus 48 rather than minus 40, because `100vw` counts the classic
   scrollbar on a desktop Chrome and the difference has to be paid out of the
   margin rather than out of the page. */
@media (min-width: 1600px) {
  /* 2,600 rather than the 1,960 this stopped at when the dock stood up at
     2400: the dock waits for 3000 now, so this regime — one column of readings
     with the comparison full-width beneath it — has to cover everything up to
     there. Pinned at 1,960 it left a 2560px screen using 77% of its width and
     the flow cards two across at 765px, which is a 3.3:1 plot: the smear this
     ladder exists to prevent, in the one band nobody was looking at. */
  :root { --shell: min(2600px, calc(100vw - 48px)); }

  /* The rest of the measure caps (see the note beside `.section-note`): from
     here the reading column is wider than any this app has had, and a sentence
     that grows with it is the one part of the page a wider screen makes
     worse. */
  .section-note,
  .money-note,
  .hint,
  #rank-note,
  #charts-note,
  #compare-note { max-width: 68ch; }
}

/* ---- 3000: the dock stands up.

   Not 2400. At 2400 the arithmetic works only if the cards are handed 738px —
   less than the 986 they have on a 1440 laptop — and a tier that makes the
   readings NARROWER than the width below it is the complaint this whole ladder
   answers, wearing a third column. Measured at 2400 before this moved: the
   reading column 738 against 986 at 1440, the flow cards down to two at 441px,
   the summary tiles broken onto two lines with one strangled on the second, and
   the projection slider 58% shorter than the laptop's.

   3000 is where the third column is free. 354 rail + 1,338 of cards + 1,180 of
   comparison + 40 of gaps + 40 of padding is 2,952, which is what a 3000px
   window has once the margin is paid — and 1,338 is four cards across, so the
   readings gain a column at the same moment the comparison gets one. Below this
   the dock would have to take the cards' room to get its own, and a comparison
   table that fits at the cost of a chart column is not a trade this app should
   make silently. Above it, the two halves of the reading — one plan in depth,
   and the plans against each other — finally stand side by side, which is what
   the eye was doing by scrolling before. */
@media (min-width: 3000px) {
  :root {
    --shell: min(3400px, calc(100vw - 48px));
    --dock: 1180px;
  }

  /* One plan has no comparison and no ranking, so it has no dock — and with no
     dock the shell has 1,180px of readings fewer to hold. It gets narrower to
     match rather than handing the whole width to the cards: four flow cards at
     990px each is not four readings, it is four pancakes with a 220px plot in
     them. The app is as wide as it has things to put side by side. */
  body[data-dock="off"] { --shell: min(2560px, calc(100vw - 48px)); }

  /* The ranking on its own is a list of bars, and it has never wanted more than
     this. Sized for the comparison it would be an 820px reading in a 1,180px
     column — and at the tier below, in a 1,940px one. */
  body[data-dock="rank"] { --dock: 820px; }

  body:not([data-dock="off"]) main {
    grid-template-columns: var(--rail) minmax(0, 1fr) var(--dock);
    grid-template-areas: "rail output dock";
  }

  /* The fold is written once, up at 1180, as a two-column grid — and a grid
     with a third column has to say so again here or the rule above simply
     out-orders it. It did: the chevron turned, `data-rail` said closed, the
     panel emptied itself, and the column stayed 354px wide. The fold was inert
     on exactly the screens this ladder was built for. */
  body[data-rail="closed"]:not([data-dock="off"]) main {
    grid-template-columns: 52px minmax(0, 1fr) var(--dock);
  }

  /* Deliberately NOT sticky, and deliberately not a scrollport of its own.
     The rail is both because it holds controls you work while watching the
     figures move; this holds figures, and a reading that has to be scrolled
     out from behind its own edge is worse than one that simply scrolls with
     the page.

     Measured before it was taken out: capped at the window, the dock hid 97px
     of the ranking on a 3440x900 screen and 277px on a 2560x720 one — an
     ultrawide driven from a laptop, which is not an exotic machine. Sticky
     without the cap is no better: a box taller than the window sticks at once
     and holds its own foot below the fold. */

  /* The one figure the whole page is about, on a screen where 56px is a
     footnote. The clamp's 8vw term died above a 700px window years ago. */
  .hero-value { font-size: var(--type-3xl); }
}

/* ---- 4220: the dock takes the comparison chart beside its table.

   1,940 = a 760px card, a gap, and the 1,160 the table wants. It is the last
   column this layout has to give, and it is worth giving: the chart and the
   table are the same ten figures drawn and written, and read side by side you
   can put a finger on a line and read its row. Stacked, as they are everywhere
   narrower, the table is always a scroll below the chart it explains.

   4220 rather than the first width the three columns would fit, because
   widening the dock by 760px takes 760 from the cards, and the cards must not
   lose a column to buy it: the readings are 1,520px at the top of the tier
   below, and 4220 is where they are that wide again with the wider dock in
   place. A layout that gives with one hand at 3600 and takes with the other is
   worse than one that waits.

   4400 is where the shell stops for good. Past it the rail and the far edge of
   the table are already 4.4m apart — further than a reader holds in one glance
   at any sane viewing distance — and nothing in this app reads better for being
   wider, only for being beside something. What is left over is margin, and
   margin is the honest thing to do with width that has run out of uses. */
@media (min-width: 4220px) {
  :root { --shell: min(4400px, calc(100vw - 48px)); }

  /* Only when the comparison is actually in the box: 1,940 is a card plus the
     table beside it, and a lone ranking has neither. */
  body[data-dock="on"] { --dock: 1940px; }

  #compare {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1160px);
    align-items: start;
  }
  #compare > h2,
  #compare > .section-note,
  #compare > .filter-row { grid-column: 1 / -1; }
  #compare > .compare-table { margin-top: 0; }
}

/* ---- 3300: the flow diagram comes up beside the targets.

   Not a split of the readings into two columns — the cards, the totals and the
   horizon all still span, and the targets still sit directly under the cards
   they draw their rules on. It is one row of two: the targets on the left where
   they have always been, the flow diagram on the right, which is the one pair
   in the reading order joined by nothing stronger than "the cards are above us
   both". It is worth 253px of page, measured at 3440 and at 5120 in both plans
   — the height of the shorter of the two, which is all a row of two can ever
   save. It does not get the page inside a 1440px viewport: 1,867 becomes 1,614
   and 1,440 is still 174 short. What it does is stop the targets from being one
   short row with 900px of nothing beside it.

   If a plan has no target, the left half of that one row is empty plane. These
   sections carry no surface of their own — only the cards inside them do — so
   it reads as space rather than as a hole. */
@media (min-width: 3300px) {
  /* The form has been in phone mode on every screen this app has ever run on.
     The rail is 354px, so the panel's content box is 312 — under the 558px
     `@container inputs` threshold — and it has been since the rail was built.
     Measured on a 5120px screen before this line existed: the plan switcher
     scrolling 277px sideways inside a 312px box, field names clipped to 133px,
     and 1,600px of empty screen beside it.

     620 is the first rail whose content box (620 − 40 of padding − 2 of border
     = 578) clears that threshold, so the row layout the container rules were
     written for finally arrives. Filed here rather than with the dock at 2400
     because it has to be paid for: at the bottom of that tier the shell is
     2,352px, and a 620px rail would leave the cards 472px — one column. Here
     the shell is 3,252 at its narrowest and the readings keep 1,372, which is
     still four cards across. */
  :root { --rail: 620px; }
}

/* ---- 3560: the targets and the flow diagram come up side by side.

   Not an even split, and not at 3300 where the rail widens. The flow diagram
   reserves its label gutters from its own width — `min(190, max(58, w * 0.22))`
   in sankey.js — so it stops gaining room for names at about 864px and starts
   losing them below it. An even split of a 1,444px reading column gives it 712,
   and at 712 it draws "Everyday c… 240,000": a diagram whose whole job is to
   say where money goes, no longer saying whose.

   1.6fr against 1fr, and only from 3560, is where both are satisfied at once:
   the flow gets 923px at the narrowest window in this tier and 1,070 at the
   widest, and the targets row keeps 577 — past the 560 at which it stops
   changing. Below 3560 they stack, exactly as they do today. */
@media (min-width: 3560px) {
  .output {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
    /* Each of the two takes the height it needs rather than the row's, so a
       short one does not stretch into a tall box with nothing in the bottom
       of it. */
    align-items: start;
  }
  .output > * { grid-column: 1 / -1; }
  #milestones { grid-column: 1; }
  #sankey { grid-column: 2; }
}

/* --------------------------------------------------------------- strategies */

/* A grid item's `min-width: auto` keeps it from shrinking under its own
   content, which is the same opt-out `.panel` documents — and the bar needed it
   the moment its tabs became a sideways scroller on a phone: without it the
   scroller's full width pushed the whole panel past the screen. */
#strategies { min-width: 0; }

.strategy-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
}

.strategy-tabs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-xs);
  min-width: 0;
}

.strategy-tab-wrap { display: contents; }

/* The active tab is the name box: one name, one place on screen. */
.strategy-tab,
.strategy-name {
  min-height: 36px;
  padding: var(--space-xs) var(--space-lg);
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--text-secondary);
  font-size: var(--type-sm);
  cursor: pointer;
}
.strategy-tab:hover { border-color: var(--border-strong); color: var(--text-primary); }

/* Where the plan came from, as a leading edge on its own tab. An inset shadow
   rather than a border or a child element: the active tab is an <input>, which
   takes neither a pseudo-element nor a child, and the mark has to look the same
   on both halves of a tab that is a button in one state and a box in the other.
   The pill's own radius curves it into a crescent, which is the point — it
   reads as an edge of the tab rather than as a swatch beside it. */
.strategy-tab[data-origin],
.strategy-name[data-origin],
.strategy-jump-tab[data-origin] {
  /* The pill's own inline padding plus the 4 the shadow paints over, said as
     the sum it is so the two cannot drift apart. */
  padding-left: calc(var(--space-lg) + 4px);
  box-shadow: inset 4px 0 0 var(--origin-own);
}

.strategy-tab[data-origin="default"],
.strategy-name[data-origin="default"],
.strategy-jump-tab[data-origin="default"] { box-shadow: inset 4px 0 0 var(--origin-default); }

.strategy-tab[data-origin="shared"],
.strategy-name[data-origin="shared"],
.strategy-jump-tab[data-origin="shared"] { box-shadow: inset 4px 0 0 var(--origin-shared); }

/* Shared, and no longer what arrived. Another step of the same colour rather
   than a new one — it is a variant of a state, not a fourth thing — and broken
   into a dash, so the difference survives a reader who cannot tell two teals
   apart, and survives print and forced colours. Both steps clear 3:1 against
   the tab in both themes, which is the floor for a mark that is not text. */
.strategy-tab[data-origin="shared-edited"],
.strategy-name[data-origin="shared-edited"],
.strategy-jump-tab[data-origin="shared-edited"] {
  box-shadow: inset 4px 0 0 var(--origin-edited);
  background-image: repeating-linear-gradient(
    var(--origin-shared) 0 6px,
    transparent 6px 11px
  );
  background-size: 4px 100%;
  background-repeat: repeat-y;
  background-position: left top;
}

.strategy-name {
  background: var(--surface-2);
  border-color: var(--border-strong);
  color: var(--text-primary);
  font-weight: 600;
  cursor: text;
  min-width: 0;
}

.strategy-actions { display: flex; gap: var(--space-3xs); }

/* Fixed, not sticky: a sticky bar keeps its place in the flow, so revealing one
   while the reader is halfway down the page shoves everything they are looking
   at down by its height. This costs a strip of the viewport and moves nothing.
   It appears only once the bar in the form has scrolled away, so the two are
   never both on screen. */
/* Whatever the reader jumps to — the skip link, an anchor — lands below the
   fixed bar rather than under it. The bar is 44px plus the notch inset. */
:target,
main section,
.chart-card { scroll-margin-top: calc(56px + env(safe-area-inset-top, 0px)); }

.strategy-jump {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 20;
  display: flex;
  justify-content: center;
  /* Installed on a phone this sits under the notch, so the inset is the top one. */
  padding: calc(var(--space-xs) + env(safe-area-inset-top, 0px)) var(--space-lg) var(--space-xs);
  /* Opaque, not a translucent pane: the page scrolls underneath it, and any
     transparency puts a moving line of body text behind the names. */
  background: var(--plane);
  border-bottom: 1px solid var(--border);
}

.strategy-jump-tabs {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  width: 100%;
  max-width: var(--shell);
  overflow-x: auto;
  scrollbar-width: none;
  /* Room for a focus ring inside a scrollport, not spacing — see the twins in
     the strategies section. */
  padding: 1px;
}
.strategy-jump-tabs::-webkit-scrollbar { display: none; }

.strategy-jump-tab {
  flex: none;
  /* 42vw was a phone measurement and it never stopped growing: 3,226px a tab on
     a 7680px screen. The px term is what holds above a 670px window. */
  max-width: min(42vw, 280px);
  min-height: 32px;
  padding: var(--space-2xs) var(--space-lg);
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--text-secondary);
  font-size: var(--type-xs);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}
.strategy-jump-tab:hover { border-color: var(--border-strong); color: var(--text-primary); }
/* The one you are on is marked, never dropped — a row that loses the name you
   are reading is a row that moves under your finger. */
.strategy-jump-tab[aria-current="true"] {
  background: var(--surface-2);
  border-color: var(--border-strong);
  color: var(--text-primary);
  font-weight: 600;
  cursor: default;
}

.add-strategy {
  min-height: 36px;
  padding: var(--space-xs) var(--space-lg);
  background: none;
  border: 1px dashed var(--border-strong);
  border-radius: 999px;
  color: var(--text-secondary);
  font-size: var(--type-sm);
  cursor: pointer;
}
.add-strategy:hover:not([disabled]) { border-color: var(--text-muted); color: var(--text-primary); }
.add-strategy[disabled] { opacity: 0.45; cursor: default; }

/* The twin of the strategy half of the `@media (max-width: 640px)` block in the
   field-list section, for the one case a width query cannot see: a narrow
   column of form on a wide screen, which is what this panel becomes when it is
   docked as a rail. 558px is what the panel's content box measures inside a
   640px window, so every window keeps exactly the layout it had.

   Both exist on purpose. Container queries want Chrome 105 / Firefox 110 /
   Safari 16 and this app's floor is Chrome 88 / Firefox 98 / Safari 15.4, so
   the media query is the floor-safe baseline a real phone must keep, and this
   is the tidying a browser either understands or ignores. They never fight: a
   phone is never inside a wide container, and a 312px rail is never inside a
   640px window. Do not fold one into the other. */
@container inputs (max-width: 558px) {
  .strategy-tabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    /* Not a step: a scrollport clips its own children, and a tab at the edge
       would lose the outer pixel of its focus ring without this. */
    padding: 1px;
  }
  .strategy-tabs::-webkit-scrollbar { display: none; }
  .strategy-tab-wrap { display: contents; }
  .strategy-tab,
  .strategy-name { flex: none; }
}

/* --------------------------------------------------------------- comparing */

.compare-table { max-height: none; margin-top: var(--space-2xs); }
/* Sticky and unwrapped: the table scrolls sideways on a phone, and a name is
   what makes a row of figures mean anything — scroll the numbers out from under
   it and you are reading an anonymous row. Unwrapped because a flex item
   shrinks to its longest word, so "Buy now, on a loan" came out four lines
   tall, one word per line, in every row. */
.compare-table th[scope="row"] {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  font-weight: 600;
  position: sticky;
  left: 0;
  white-space: nowrap;
  background: var(--surface-1);
}
.compare-table tr.is-active th[scope="row"] { background: var(--surface-2); }
.compare-table tr.is-active { background: var(--surface-2); }
/* Where you are, said in something other than a 1.1:1 tint: the same ink strip
   the jump tabs and the changelog use to mark it. */
.compare-table tr.is-active th[scope="row"] {
  font-weight: 700;
  box-shadow: inset 3px 0 0 var(--accent);
}

/* Blockified for free inside `.compare-table`'s flex row header, but the flow
   table's header is a plain cell — and width and height do not apply to an
   inline box, so without this the swatch is there and paints nothing. */
.row-key {
  display: inline-block;
  width: 10px;
  height: 3px;
  border-radius: 2px;
  background: var(--series);
  flex: none;
}

.compare-table .is-ahead { color: var(--status-good); }
.compare-table .is-behind { color: var(--status-critical); }
/* Not `--text-muted`: at 12–13px it reaches only 3.5:1 on the light surface,
   under the 4.5:1 AA floor for text this size. The token stays where it is
   decorative — borders, the crosshair — and reads as text in the secondary ink. */
.compare-table .is-baseline { color: var(--text-secondary); }

/* ------------------------------------------------------------- field list */

.field-list { display: grid; gap: var(--space-lg); min-width: 0; }
.field-rows { display: grid; gap: var(--space-sm); min-width: 0; }

.field-row {
  display: flex;
  align-items: stretch;
  gap: var(--space-sm);
  min-width: 0;
}

/* Everything but the colour key stacks: the head, the line derived from it,
   and — while the row is open — the controls the head cannot carry. */
.field-body {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
}

/* A key in the row's own series colour, so a line here ties to a chart below.
   It repeats what the select already says — direction never rides on colour. */
.field-row::before {
  content: "";
  flex: 0 0 3px;
  min-height: 44px;
  border-radius: 2px;
  background: var(--series-expenses);
}
.field-row[data-direction="income"]::before { background: var(--series-income); }
/* A thing you own moves no cash in either direction, so it wears neither
   colour: the model calls an asset income for bookkeeping, and the stripe was
   the only thing on the row saying so. */
.field-row[data-direction="none"]::before { background: var(--border-strong); }

.field-row input,
.field-row select {
  min-width: 0;
  min-height: 44px;
  padding: var(--space-sm) var(--space-lg);
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--type-md);
}
.field-row input:hover,
.field-row select:hover { border-color: var(--text-muted); }

/* The bases that used to be here are gone with the row that needed them: the
   name and the amount are laid out by `.field-head` below, and everything else
   is a grid item, to which a flex base says nothing at all. */
.field-name {
  font-weight: 600;
  letter-spacing: -0.01em;
}

/* The unit rides inside the box, so a rate reads as "5.9 % a year" rather than
   as a bare number whose meaning lives in a screen-reader label. */
.field-unit { position: relative; display: flex; min-width: 0; }
.field-unit input {
  flex: 1 1 auto;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.field-unit .unit-short { display: none; }

.field-unit .unit {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  font-size: var(--type-2xs);
  color: var(--text-secondary);
  pointer-events: none;
}
.field-unit-rate input { padding-right: 62px; }
.field-unit-term input { padding-right: 56px; }
.field-unit-fees input { padding-right: 54px; }

/* When a field runs. The word leads — "from month 6" is a phrase, and a unit
   trailing the number would read as "6 from month". Same box, mirrored.
   `.field-window` itself is laid out with the rest of the fold, further down. */
.field-when { position: relative; display: flex; min-width: 0; }
.field-when input {
  flex: 1 1 auto;
  text-align: right;
  font-variant-numeric: tabular-nums;
  padding-left: 46px;
}
.field-when-to input { padding-left: 36px; }
.field-when .unit-full { display: none; }
.field-when .when-word {
  position: absolute;
  left: 12px;
  top: 50%;
  transform: translateY(-50%);
  font-size: var(--type-2xs);
  color: var(--text-secondary);
  pointer-events: none;
}

/* A 320px screen spends 40 of its pixels on the shell's own margins, which is
   an eighth of the page. Below 380 they come down — and the header wraps rather
   than pushing its last button off the edge, which is what it did at 320 once
   the buttons were sized for a thumb. */
@media (max-width: 380px) {
  main { padding-left: var(--space-lg); padding-right: var(--space-lg); }
  .app-bar { padding-left: var(--space-lg); padding-right: var(--space-lg); }
  .panel { padding: var(--space-xl); }
}

.app-bar { flex-wrap: wrap; }

@media (max-width: 640px) {
  /* Wrapping put three plans on three full-width lines — the better part of
     three hundred pixels of a phone screen spent before the first field. They
     scroll sideways instead, which is what the bar pinned to the top of the
     window already does with the same tabs.

     `display: contents` on the wrap has to go with it: a scroller needs real
     children to lay out, and the name box is a sibling of its own tab.

     These four have a twin in an `@container` block at the end of the
     strategies section, for the same reason the four below have one — see the
     comment there. */
  .strategy-tabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    /* Not a step: a scrollport clips its own children, and a tab at the edge
       would lose the outer pixel of its focus ring without this. */
    padding: 1px;
  }
  .strategy-tabs::-webkit-scrollbar { display: none; }
  .strategy-tab-wrap { display: contents; }
  .strategy-tab,
  .strategy-name { flex: none; }

  /* Half a row leaves ~40px for the number once the unit is in the box, so the
     unit says "%" rather than "% a year" — the label still spells it out.

     These four have a twin in an `@container` block at the end of this
     section, and the duplication is deliberate. A container query needs
     Chrome 105 / Firefox 110 / Safari 16, and this app's floor is Chrome 88 /
     Firefox 98 / Safari 15.4 — so on a browser at the floor a phone would be
     left with no compact treatment at all, which is a break rather than the
     graceful degradation the app promises. This block is the floor-safe
     baseline; the container twin is for the one case a width query cannot see,
     a narrow column on a wide screen. They never fight: a phone is never
     inside a wide container, and a narrow column is never inside a 640px
     window. Do not "simplify" either of them away. */
  .field-unit .unit-full { display: none; }
  .field-unit .unit-short { display: inline; }
  .field-unit-rate input { padding-right: 30px; }
  .field-unit-term input { padding-right: 46px; }
  .field-unit-fees input { padding-right: 40px; }
}

/* One line under the head, doing one of two jobs: with the row open it is the
   arithmetic the controls above it produce, and with the row shut it is the
   description of what the fold is holding. Both are secondary to the two boxes
   above, and both are read as figures, so both want the same ink. */
.field-derived {
  margin: 0;
  font-size: var(--type-2xs);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

.field-amount {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  text-align: right;
  /* No spinner: stepping money by one is useless, and the buttons cost ~20px
     that a right-aligned number needs — a clipped amount hides its leading
     digits, which is worse than any layout compromise. Arrow keys still work. */
  appearance: textfield;
}
/* Scoped to out-specify `.field-row input` above, which sets the padding
   shorthand. The value is right-aligned, so its left padding is decoration —
   trimming it is what lets a seven-digit amount fit on a phone. */
.field-row .field-amount { padding-left: var(--space-sm); }

.field-amount::-webkit-outer-spin-button,
.field-amount::-webkit-inner-spin-button {
  appearance: none;
  margin: 0;
}

.field-row input::placeholder {
  color: var(--text-muted);
  font-weight: 400;
  opacity: 1;
}

/* Three buttons in a row. Where that row sits inside the fold is settled with
   the rest of the grid, further down. */
.field-actions {
  display: flex;
  gap: var(--space-3xs);
}

.icon-button {
  display: grid;
  place-items: center;
  width: 40px;
  /* Matches the control height, so bottom-aligning changes nothing on a row
     that fits on one line — and it is a kinder touch target. */
  height: 44px;
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  cursor: pointer;
}
.icon-button:hover:not([disabled]) {
  background: var(--surface-2);
  border-color: var(--border);
  color: var(--text-primary);
}
.icon-button[disabled],
.add-field[disabled] {
  opacity: 0.45;
  cursor: default;
}

.action-icon {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.add-field {
  justify-self: start;
  min-height: 40px;
  padding: var(--space-sm) var(--space-lg);
  background: none;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  font-size: var(--type-sm);
  font-weight: 500;
  cursor: pointer;
}
.add-field:hover:not([disabled]) {
  border-color: var(--text-muted);
  color: var(--text-primary);
}

.fields-empty {
  font-size: var(--type-xs);
  color: var(--text-secondary);
}

.hint {
  font-size: var(--type-xs);
  color: var(--text-secondary);
}

.currency-note { color: var(--text-secondary); font-size: var(--type-2xs); }

/* What sets a month: the box, or a target it waits on. The picker is hidden
   outright until some target has a name, so a reader who never names one never
   meets it — and when one is chosen the box gives way to the month it resolved
   to, since a number nobody typed is a reading rather than a control.
   Filed here rather than with the milestones, where these two spent their first
   release: they are built by field-list.js, and nothing between here and there
   touches them, so the move changes nothing but where the next reader looks. */
.field-at {
  min-width: 0;
  max-width: 148px;
}

.field-at-said {
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
  white-space: nowrap;
}

/* A shut row says who it is: a name, a number, and one line of prose carrying
   everything the two boxes cannot. Open, the boxes come back and that line goes
   back to being the arithmetic. Everything from here to the end of the section
   is that arrangement, and it comes last so it out-orders the base rules above
   without any of them needing an override written against it. */
.field-head { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; }
/* The name takes all the slack: it runs to 60 characters and the amount does
   not. The name needs the explicit `min-width: 0` — a flex item floors at its
   own min-content otherwise, and a long name would push the amount off the row.
   The amount is the other side of that bargain, and it does not shrink at all.
   It is right-aligned, so a box a few pixels too narrow does not truncate at
   the end where a reader would notice: it scrolls the leading digit out of
   sight, and a hundred thousand is read as ten. 108px is what eight digits with room to spare measure here — eight tabular
   figures of about 10.2px, plus the 8 and 12 of padding either side and 2 of
   border, and a little slack for the ninth the caret sits on.9px
   tabular figure, plus the 8 and 12 of padding either side and 2 of border. A
   name that runs out of room truncates instead, which is what a name does, and
   the row's own aria-label still carries it whole. */
.field-head .field-name { flex: 1 1 120px; min-width: 0; }
.field-head .field-amount { flex: 0 0 108px; }
.field-toggle { flex: 0 0 auto; }
.field-toggle .action-icon { transition: transform 140ms ease; }
.field-row.is-open .field-toggle .action-icon { transform: rotate(180deg); }
.field-row:not(.is-open) .field-detail { display: none; }

/* A grid, not the wrapping flex row it replaces. Flex bases round every control
   up to a line of its own in a column this narrow, where a grid cell simply
   holds what it is given; and a control the kind does not offer is `hidden` and
   vacates its cell rather than leaving a gap where it used to be. */
.field-detail {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: var(--space-sm);
  align-items: center;
  min-width: 0;
}
/* A grid item floors at its own min-content, and a select floors at its longest
   option — which in French is longer than the cell. One rule rather than one
   per control, so a control added later inherits it. */
.field-detail > * { min-width: 0; }

/* `auto-fill`, not `auto-fit`, and this is the one place in the file where the
   difference shows. `auto-fit` collapses the tracks nothing landed in, so a
   kind with a single month box — an asset, a one-off — would stretch that box
   across the whole line and put its word and its number a hand's width apart.
   `auto-fill` leaves the empty tracks standing, so a lone box is one cell wide
   and a "from" and a "to" still pair up on a phone, which is what they did
   before this row learned to fold. */
.field-window {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: var(--space-sm);
  min-width: 0;
}
/* A month box was sized for a world without the picker, and beside one the
   select came out around 30px — an arrow with nothing to read. So it takes the
   whole line while the picker is there, and the picker is only there once some
   target has a name. */
.field-when.is-picking { grid-column: 1 / -1; }
.field-when .field-at { flex: 0 1 148px; min-width: 84px; }
/* The line is the picker's, not the box's: taking the whole width is what gives
   the picker room to say a target's name, and letting the box then swallow the
   slack would put a four-digit month at one end of a hand's width of white. */
.field-when.is-picking input { flex: 0 1 148px; }
/* Once a target is chosen the number box goes and the picker is all that is
   left, so the leading word comes out of its overlay and stands in the line:
   "from · Deposit ready · not yet" rather than a word printed across a name.
   It was legible only as long as the picker was too narrow to say anything. */
.field-when.is-said { gap: var(--space-xs); align-items: center; }
.field-when.is-said .when-word { position: static; transform: none; }

/* Last in the fold and at its end, where the row's own tab order leaves them:
   these three act on the whole field, so they come after everything they would
   act on. */
.field-detail .field-actions {
  grid-column: 1 / -1;
  justify-self: end;
  align-self: center;
}

/* The twin of the `@media (max-width: 640px)` block above, for the one case a
   width query cannot see: a narrow column of form on a wide screen, which is
   what the panel becomes when it is docked as a rail. 558px is what that
   panel's content box measures inside a 640px window, so every window keeps
   exactly the layout it had.

   Both blocks exist on purpose. The media query is the floor-safe baseline —
   container queries want Chrome 105 / Firefox 110 / Safari 16 and this app's
   floor is Chrome 88 / Firefox 98 / Safari 15.4 — and this one is inert until
   the browser understands it, which is precisely what makes it safe to add.
   They never fight: a phone is never inside a wide container, and a narrow
   column is never inside a 640px window. Do not fold one into the other. */
@container inputs (max-width: 558px) {
  /* Half a row leaves ~40px for the number once the unit is in the box, so the
     unit says "%" rather than "% a year" — the label still spells it out. */
  .field-unit .unit-full { display: none; }
  .field-unit .unit-short { display: inline; }
  .field-unit-rate input { padding-right: 30px; }
  .field-unit-term input { padding-right: 46px; }
  .field-unit-fees input { padding-right: 40px; }
}
/* Two cells stop being two: 140 + 8 + 140 needs 288. Below that the fold is one
   column.

   `minmax(0, 1fr)` and not a bare `1fr`, which is `minmax(auto, 1fr)` and
   floors the track at the widest min-content in it. That is safe today only
   because every one of these cells happens to opt out of `min-width: auto`
   somewhere else; written this way it is safe because it says so.

   The amount used to give back twelve pixels here, and those twelve are
   exactly the ones a seven-digit figure needs — see the rule that sets it. */
@container inputs (max-width: 300px) {
  .field-detail,
  .field-window { grid-template-columns: minmax(0, 1fr); }
  /* See the media twin below for why the name takes the line here. */
  .field-head { flex-wrap: wrap; }
  .field-head .field-name { flex: 1 1 100%; }
  .field-toggle { margin-left: auto; }
}

/* The floor-safe twin of the two rules above, for a real phone rather than a
   rail. 358px of window is where this panel's content box crosses 300.

   A head this narrow cannot hold a name, a seven-digit amount and a 44px
   target on one line: something has to give, and until now it was the name,
   down to about five characters — "Mortgage" and "The house" both truncated on
   the plan the app ships with. The amount cannot be the one to give, because it
   is right-aligned and loses its LEADING digit, which reads as a smaller number
   rather than as a clipped one. So the name takes the line and the other two
   share the next. It costs a row about 36px on the smallest phones and nothing
   at any other width. */
@media (max-width: 358px) {
  .field-head { flex-wrap: wrap; }
  .field-head .field-name { flex: 1 1 100%; }
  .field-toggle { margin-left: auto; }
}

/* ---------------------------------------------------------------- summary */

.summary { gap: var(--space-2xl); }

.hero { display: grid; gap: var(--space-2xs); justify-items: start; }

.hero-label {
  font-size: var(--type-xs);
  font-weight: 500;
  color: var(--text-secondary);
  text-transform: none;
}

.hero-value {
  font-size: var(--type-2xl);
  font-weight: 650;
  letter-spacing: -0.03em;
  line-height: 1.05;
}

/* With nothing entered, the figures are placeholders — a full-weight dash at
   display size reads as a redaction bar. */
.summary.is-empty .hero-value,
.summary.is-empty .stat-tile dd {
  color: var(--text-muted);
  font-weight: 400;
}

.status-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  margin-top: var(--space-3xs);
  font-size: var(--type-xs);
  font-weight: 500;
}
.status-chip.is-good { color: var(--status-good); }
.status-chip.is-critical { color: var(--status-critical); }

.chip-icon {
  width: 16px; height: 16px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.status-chip.is-good .chip-icon { fill: currentColor; stroke-width: 1; }

.kpi-row {
  display: grid;
  gap: var(--space-lg);
  /* 140px, and it is a ceiling as much as a floor. Four pixels is not a taste:
     at 148 a 360px phone drops to one tile a line, and at 148 the six tiles
     stop fitting on one line in the 986px reading column, which costs 78px of
     page at the width most people are at. 140 is the widest value that keeps
     both — measured at 320, 360, 390, 986, 1440 and 2560. What 136 did not do
     was fit the labels: "Kept per month, on average" and "Things you own are worth" wrapped
     to two lines anywhere under about 150px, so the row's height went *up* as
     the window widened — 321px at a 660px window, 362 at 680, because 680
     bought a fourth track and the fourth track was still too narrow to unwrap
     the words in it. A floor the labels fit ends that: the row only ever gets
     shorter as the column grows. */
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  margin: 0;
  padding-top: var(--space-2xs);
  border-top: 1px solid var(--border);
}

.stat-tile { display: grid; gap: var(--space-3xs); padding-top: var(--space-lg); }

.stat-tile dt {
  font-size: var(--type-xs);
  color: var(--text-secondary);
}

.stat-tile dd {
  margin: 0;
  font-size: var(--type-xl);
  font-weight: 600;
  letter-spacing: -0.02em;
}

/* ------------------------------------------------------------- filter row */

.filter-row {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: var(--space-lg) var(--space-3xl);
  padding: 0 var(--space-3xs);
}

.filter { display: grid; gap: var(--space-xs); min-width: 260px; flex: 1 1 260px; }

.filter label {
  font-size: var(--type-sm);
  font-weight: 500;
  color: var(--text-secondary);
}

/* Wraps, because the slider's own 140px floor plus the readout beside it comes
   to 299px — three more than a 320px phone has once the shell's padding is
   taken out, which is exactly the three pixels the page used to scroll
   sideways by. */
.range-line { display: flex; align-items: center; gap: var(--space-lg); flex-wrap: wrap; }

.range-line input[type="range"] {
  flex: 1 1 auto;
  min-width: 140px;
  accent-color: var(--accent);
  height: 24px;
}

.range-line #months-readout {
  font-size: var(--type-xs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  color: var(--text-secondary);
}

.presets { display: flex; flex-wrap: wrap; gap: var(--space-xs); }

.preset {
  min-height: 34px;
  padding: var(--space-2xs) var(--space-lg);
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--text-secondary);
  font-size: var(--type-xs);
  cursor: pointer;
}
.preset:hover { border-color: var(--border-strong); color: var(--text-primary); }
.preset[aria-pressed="true"] {
  background: var(--accent-solid);
  border-color: var(--accent-solid);
  color: #ffffff;
  font-weight: 600;
}

/* --------------------------------------------------------------- charts */

.charts-section { display: grid; gap: var(--space-md); }

.charts-section h2 {
  font-size: var(--type-sm);
  font-weight: 600;
  letter-spacing: -0.01em;
}

/* Everything that is a sentence rather than a figure is capped at a measure.
   Nothing in the readings had one, and at 986px — the widest reading column the
   app has ever had — the note under the cards was already running 146
   characters to the line against a comfortable 45–90. A column that grows to
   2,100px without this makes the drawings better and the writing worse, which
   is not a wider app, only a bigger one. `ch` rather than px so it holds at each
   of the three sizes these are set in, and so it follows the font the browser
   is actually using.

   In two halves, because they cost different things. What sits inside a closed
   disclosure costs nothing to cap at any width — it is not on the page until
   somebody opens it — and that is where the worst of it was: the note on how
   the cards are scaled runs 146 characters to the line at 986px and 438 at
   3,000. What is on the page all the time is capped only from 1600 up, where
   the column first outgrows anything a reading column in this app has been:
   capping those at 1440 as well would buy a better measure for a line of page
   height at the width most people are actually at, and that is not a trade
   worth making silently. Below 1600 every line of this app is where it was. */
.scale-note,
.panel-notes p,
.section-notes p { max-width: 72ch; }

.section-note { font-size: var(--type-sm); color: var(--text-secondary); min-height: 1.5em; }

.scale-note { font-size: var(--type-2xs); color: var(--text-secondary); }

/* The money row sits under the horizon row and carries one toggle plus, once
   it is on, the assumption behind it. Its own row so the horizon presets keep
   their line on a narrow screen. */
.money-row {
  margin-top: calc(-1 * var(--space-2xs));
}

.money-filter {
  flex: 0 0 auto;
}

.money-filter .range-line {
  gap: var(--space-xs);
}

.money-filter input {
  /* Wide enough for "12.5", no wider: it is a rate, not an amount. */
  width: 5.5ch;
  text-align: right;
  padding: var(--space-xs) var(--space-sm);
  font: inherit;
  color: inherit;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}

.money-filter .rate-unit {
  color: var(--text-muted);
  font-size: var(--type-sm);
}

.money-note {
  margin: calc(-1 * var(--space-3xs)) 0 var(--space-3xs);
}

/* A pressed link reads as a state, not a hover: the field is joined to its
   counterparts and stays that way until someone says otherwise. */
.icon-button[aria-pressed="true"] {
  color: var(--accent);
  background: color-mix(in oklab, var(--accent) 12%, transparent);
}

.chart-grid {
  display: grid;
  gap: var(--space-xl);
  /* min() keeps the floor from outgrowing the viewport itself: a bare 320px
     minimum makes the grid wider than a 320px screen and scrolls the page. */
  grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
  margin: var(--space-2xs) 0 var(--space-3xs);
}

/* Four cards read better two-by-two than as three and a lonely fourth. */
@media (min-width: 720px) {
  .chart-grid[data-count="4"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* ---- how many cards across, at the widths where the answer changes.

   These are viewport queries with the arithmetic already done, not guesses at
   the column. At every width below they are filed, the shell, the rail and the
   dock are all fixed by the blocks in the layout section, so the reading column
   is a known number: four 320px cards and their three 16px gaps need 1,328px,
   which the column reaches at a 1830px window on the two-column shell and 3030
   on the three-column one. Five need 1,664 — reached at 3370 only with the
   dock away; docked, the 620px rail keeps the column under 1,664 until 4352.

   The rail folding is the one thing a width query cannot see — and it does not
   have to. Folding only ever *widens* the column — by 302px, or 568 once the
   rail is 620 — so a count chosen
   with the rail open is never wrong with it shut. The `[data-rail="closed"]`
   rules are here to take the wider column earlier, not to keep it correct, and
   where both apply they agree.

   Five cards go three across rather than four: four would leave the fifth alone
   on a line of its own, which is the same reason the block above exists. */
@media (min-width: 1600px) and (max-width: 2999px) {
  body[data-rail="closed"] .chart-grid[data-count="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  body[data-rail="closed"] .chart-grid[data-count="5"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 1830px) and (max-width: 2999px) {
  .chart-grid[data-count="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .chart-grid[data-count="5"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 2720px) and (max-width: 4219px) {
  body[data-rail="closed"] .chart-grid[data-count="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  body[data-rail="closed"] .chart-grid[data-count="5"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 3000px) and (max-width: 4219px) {
  .chart-grid[data-count="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .chart-grid[data-count="5"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* Five across, from the two widths the readings first reach 1,664px: with the
   rail shut on the three-column shell, and with it open once the shell has
   stopped growing. */
@media (min-width: 3060px) and (max-width: 4219px) {
  body[data-rail="closed"] .chart-grid[data-count="5"] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
/* Only WITHOUT the comparison docked. The 1,664 five cards need was worked
   out against a 354px rail, but from 3300 the rail is 620, and with the
   1,180px dock beside it the reading column is 1,442 at 3370 and never more
   than 1,520 in this tier. Five across in 1,442 is 276px cards — under the
   320px floor this ladder names for itself — and chart.js drops every end
   label. Measured: 3369 -> three across at 470px, labels 6/6; 3370 -> five
   at 275.6px, labels 0/6. With the dock away, or the rail folded (its own
   rule above), the column really does reach 1,664 here. */
@media (min-width: 3370px) {
  body:not([data-dock="on"]) .chart-grid[data-count="5"] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
@media (min-width: 4220px) {
  .chart-grid[data-count="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  body:not([data-dock="on"]) .chart-grid[data-count="5"] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
  /* Said outright rather than left to auto-fit, which at 1,532px seats four
     and strands the fifth on a line of its own. */
  body[data-dock="on"] .chart-grid[data-count="5"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* With the 1,940px dock the column is W − 2,868, and 1,664 arrives at 4352. */
@media (min-width: 4352px) {
  body[data-dock="on"] .chart-grid[data-count="5"] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}

/* ---- and how wide a card is allowed to get.

   `auto-fit` collapses the tracks it has no cards for and hands the room to the
   ones it has, which is right on a phone and wrong on a 4000px shell: three
   cards would take 700px each. The plot inside them is 220px tall whatever the
   width, so a card past about 560 is not a bigger reading, it is a flatter one
   — and the same five-card scale that makes the flow cards comparable makes a
   6:1 pancake beside a 2:1 card a genuinely harder comparison. So the grid is
   capped by how many cards are in it, and the leftover goes to the margin.

   Filed as a max-width on the grid rather than on the card so the cards keep
   sharing one geometry: `chart.js` reserves the same end-label width across
   siblings, and cards of different widths in one row break that. */
@media (min-width: 1600px) {
  .chart-grid[data-count="1"] { max-width: 560px; }
  .chart-grid[data-count="2"] { max-width: 1136px; }
  .chart-grid[data-count="3"] { max-width: 1712px; }
  /* These two are never reached with the dock standing — the readings stop at
     2,106px — but a plan with no dock has the whole shell, and without them
     four cards would be 990px each. */
  .chart-grid[data-count="4"] { max-width: 2288px; }
  .chart-grid[data-count="5"] { max-width: 2864px; }

  /* One card carrying one line per plan, so it is allowed a little more width
     than a flow card before it flattens — but only a little. Left where the
     cards are rather than centred: the table under it starts at the same edge,
     and two readings of one thing should share a left margin. */
  #compare-chart { max-width: 760px; }

  /* The flow diagram is the one drawing in the app that genuinely wants width —
     and only to a point. Its label gutters hit their 190px cap at a card width
     of 864 (`sankey.js`, the Math.min in `draw`), so past that every extra pixel
     goes into the ribbon run and nothing into the names: 2.9 to 1 at the width
     it ships at today, 8.2 to 1 at 2,786. Its height is a constant, so the only
     way to keep the ribbons diagonal is to stop widening it. */
  #sankey-mount { max-width: 1200px; }

  /* Capped as well as floored. The slider inside is `flex: 1 1 auto` with no
     ceiling, so an uncapped filter handed it every spare pixel: 428px of track
     at a 986px column, 2,242 at 2,800. Six hundred steps spread over two metres
     is not a better control than six hundred over four hundred pixels, and the
     chips beside it are how anybody actually picks a horizon. */
  .filter { max-width: 520px; }

  /* Capped at a length a bar is still comparable at. The name is 240px and the
     figure is its own width, so every surplus pixel went to the track: 659px at
     a 986 column, 2,673 at 3,000. Two and a half metres of bar beside a 240px
     name is the same failure the row's own comment warns about from the other
     side. */
  .rank-list { max-width: 820px; }
}

.chart-card {
  margin: 0;
  padding: var(--space-xl) var(--space-xl) var(--space-lg);
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  display: grid;
  gap: var(--space-md);
  align-content: start;
  min-width: 0;
}

.chart-head { display: grid; gap: var(--space-3xs); }

.chart-title {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  font-size: var(--type-sm);
  font-weight: 600;
  letter-spacing: -0.01em;
}
.chart-desc {
  font-size: var(--type-2xs);
  color: var(--text-secondary);
  /* Two lines' worth, so a longer translation doesn't push one plot out of
     alignment with the small multiples beside it. */
  min-height: 3em;
}

.chart-plot { position: relative; min-width: 0; }

.chart-svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
  touch-action: pan-y;
}
.chart-svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.grid-line, .axis-line, .axis-zero, .crosshair {
  stroke-width: 1;
  shape-rendering: crispEdges;
}
.grid-line { stroke: var(--grid-line); }
.axis-line, .axis-zero { stroke: var(--axis-line); }
.crosshair { stroke: var(--text-muted); }

.tick-label {
  fill: var(--text-secondary);
  font-size: var(--type-3xs);
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------------ the flow */

.sankey-card { margin: var(--space-2xs) 0 var(--space-3xs); }
.sankey-plot { min-height: 340px; }
.sankey-svg { width: 100%; display: block; overflow: visible; }

/* A ribbon is a wash, never a block. It is the largest painted area in the app
   and carries no information its width does not already carry, so it stays well
   under the ink of the marks it connects. */
.sankey-ribbon {
  fill-opacity: 0.16;
  /* A hairline in the surface colour, invisible against it and visible exactly
     where two ribbons meet at the pool — so neighbouring flows never fuse into
     one shape. */
  stroke: var(--surface-1);
  stroke-width: 1;
  transition: fill-opacity 120ms ease;
}
.sankey-ribbon:hover { fill-opacity: 0.34; }

/* The node itself is small, so it may be solid: it is what gives each flow a
   definite edge where the ribbons meet. */
.sankey-node { stroke: none; }

/* Nodes and names paint above the ribbons, so without this the pointer crossing
   one stops reaching the ribbon underneath — and the tooltip freezes showing
   whatever it last said. They are labels, not targets. */
.sankey-nodes,
.sankey-labels { pointer-events: none; }

/* Text wears text tokens, never the series colour — the node beside it carries
   the identity. */
.sankey-label {
  font-size: var(--type-2xs);
  fill: var(--text-primary);
  dominant-baseline: auto;
}
.sankey-amount { fill: var(--text-secondary); font-variant-numeric: tabular-nums; }

@media (max-width: 640px) {
  /* Names and amounts stop fitting the gutters long before the ribbons stop
     being readable, so the phone gets the shape and the table gets the rest. */
  .sankey-label { font-size: var(--type-3xs); }
  .sankey-amount { display: none; }
}

/* A band is context, not a mark: it must read as "somewhere in here" without
   ever competing with the line it belongs to. Its fill is set inline, like the
   area's, so both follow whichever series they belong to. */
.series-band { fill-opacity: 0.14; stroke: none; }

.series-area { fill: var(--series); fill-opacity: 0.1; stroke: none; }

/* Dashed, thinner, and no end dot: a reference reads as the thing being
   compared against, not as a second category competing for attention. */
.series-line.is-reference {
  stroke-width: 1.5;
  stroke-dasharray: 5 4;
  opacity: 0.85;
}

.series-line {
  fill: none;
  stroke: var(--series);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.end-dot, .focus-dot { fill: var(--series); }
.end-ring, .focus-ring { fill: var(--surface-1); }

.end-label {
  fill: var(--text-primary);
  font-size: var(--type-2xs);
  font-weight: 600;
}

.chart-empty {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 0 var(--space-3xl);
  text-align: center;
  font-size: var(--type-xs);
  color: var(--text-secondary);
}

.chart-tip {
  position: absolute;
  z-index: 2;
  transform: translate(-50%, calc(-100% - 14px));
  display: grid;
  gap: var(--space-3xs);
  min-width: 96px;
  padding: var(--space-xs) var(--space-md);
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow);
  pointer-events: none;
  white-space: nowrap;
}

/* A legend whenever there are two or more series: identity never rides on
   colour alone. One series needs none — the title already names it. */
.chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2xs) var(--space-lg);
  margin: calc(-1 * var(--space-3xs)) 0 0;
  padding: 0;
  list-style: none;
}

.legend-item {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  font-size: var(--type-2xs);
  color: var(--text-secondary);
}

.legend-key {
  width: 14px;
  height: 2px;
  border-radius: 1px;
  background: var(--series);
}

.chart-tip-rows { display: grid; gap: var(--space-3xs); }

.chart-tip-row {
  display: grid;
  /* minmax(0, 1fr), the same rule every other grid in this file keeps: a bare
     1fr floors at min-content, and the label in the middle is a plan's name. */
  grid-template-columns: 14px minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-sm);
}

.chart-tip-row .chart-tip-key {
  width: 14px;
  height: 2px;
  border-radius: 1px;
  background: var(--series);
}

.chart-tip-label {
  font-size: var(--type-2xs);
  color: var(--text-secondary);
  white-space: nowrap;
}

.chart-tip-value {
  font-size: var(--type-sm);
  font-weight: 650;
  letter-spacing: -0.01em;
}

.chart-tip-month { font-size: var(--type-3xs); color: var(--text-secondary); }

/* ----------------------------------------------------------- table view */

.table-toggle {
  justify-self: start;
  padding: var(--space-2xs) 0;
  background: none;
  border: none;
  color: var(--text-secondary);
  font-size: var(--type-xs);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.table-toggle:hover { color: var(--text-primary); }

.table-wrap { max-height: 240px; overflow: auto; border-top: 1px solid var(--border); }

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--type-xs);
  font-variant-numeric: tabular-nums;
}
.data-table caption {
  padding: var(--space-sm) 0 var(--space-xs);
  text-align: left;
  font-size: var(--type-2xs);
  color: var(--text-secondary);
}
.data-table th, .data-table td {
  padding: var(--space-2xs) var(--space-sm);
  text-align: left;
  border-bottom: 1px solid var(--grid-line);
  font-weight: 400;
}
.data-table thead th {
  position: sticky;
  top: 0;
  background: var(--surface-1);
  font-weight: 600;
  color: var(--text-secondary);
}
.data-table .num { text-align: right; }

/* --------------------------------------------------------------- ranking */

/*
 * A ranked list rather than a chart, which is the whole reason this reading is
 * affordable: five series colours is the ceiling and every one of them is
 * spoken for, so a sixth quantity had to be drawn in something the palette does
 * not have to give. Neutral, dashed and muted chrome is free.
 */
.rank-list {
  display: grid;
  gap: var(--space-xs);
  margin: 0;
  padding: 0;
  list-style: none;
  min-width: 0;
}

/* Name, bar, figure. The name is capped rather than given a share of the row:
   these are short labels, and a proportional column would push the bars into
   the right-hand third of a wide screen, where the one thing they are for —
   being compared with each other — is hardest to do. Both flexible tracks floor
   at nothing rather than at their own content, since a grid item's default
   `min-width: auto` is what pushes a page sideways. */
.rank-row {
  display: grid;
  grid-template-columns: minmax(0, 240px) minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-md);
  min-width: 0;
  font-size: var(--type-xs);
}

/* One line, ellipsed: a field can be named up to sixty characters and the
   figure beside it is the thing being read. */
.rank-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--text-secondary);
}

.rank-track {
  min-width: 0;
  height: 8px;
  border-radius: 999px;
  background: var(--surface-2);
}

/* Deliberately not `--accent`: that colour means focus, a link, the brand, and
   lending it a quantitative encoding is how a palette starts drifting. A swing
   is a size, not a category, so it is drawn in the same ink as the muted text
   around it. */
.rank-bar {
  /* Blockified on purpose: width does not apply to an inline box, so without
     this every bar in the list is drawn at nothing wide. */
  display: block;
  height: 100%;
  border-radius: 999px;
  background: color-mix(in oklab, var(--text-muted) 80%, transparent);
}

.rank-value {
  min-width: 0;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* A 320px screen has no room for three columns: the name takes its own line and
   the bar and the figure share the next, which keeps the bars comparable to
   each other — the only thing they are for. */
@media (max-width: 420px) {
  .rank-row {
    grid-template-columns: minmax(0, 1fr) auto;
    row-gap: var(--space-3xs);
  }
  .rank-name { grid-column: 1 / -1; }
}

/* ------------------------------------------------------------ milestones */

/*
 * A target is a rule and a label, not a series — which is the whole reason it
 * costs nothing. Five series colours and four strategy colours is the ceiling
 * and every one of them is spoken for, so this is drawn in the same neutral,
 * dashed chrome the "paid in" reference line wears, and for the same reason: a
 * reference is not a category.
 *
 * It is also dashed rather than solid because it is not a reading of anything.
 * A solid vertical line at 1px reads as part of the plot; a dashed one in the
 * muted ink reads as an annotation over it, which is what it is.
 */
.milestone-rule {
  stroke: var(--text-muted);
  stroke-width: 1;
  stroke-dasharray: 3 4;
  shape-rendering: crispEdges;
}

.milestone-list { display: grid; gap: var(--space-lg); min-width: 0; }
.milestone-rows { display: grid; gap: var(--space-md); min-width: 0; }

/* The question on one line, its answer under it — the shape a field row takes
   for what a loan works out to, because it is the same relationship. */
.milestone-row {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  min-width: 0;
}

/* Wraps as a group, so a narrow screen breaks the row rather than squeezing
   the two boxes into nothing. */
.milestone-main {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
}

.milestone-row input,
.milestone-row select {
  min-width: 0;
  min-height: 44px;
  padding: var(--space-sm) var(--space-lg);
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--type-md);
}
.milestone-row input:hover,
.milestone-row select:hover { border-color: var(--text-muted); }

/* Wide enough for the longest of the eight names in either language without
   the select silently truncating its own label. */
.milestone-metric { flex: 1 1 180px; max-width: 280px; }

/* The name that makes a target waitable. Wider than the figure beside it,
   because a name is read and a figure is scanned, and `min-width: 0` because it
   sits in a flex row that must not be pushed sideways by a long one. */
/* The one note in the app that reports a failure rather than a fact, so it
   carries the colour the app already reserves for one — and says so in words
   too, since the colour is a second cue and never the only one. */
.section-note.is-warning { color: var(--status-critical); }

/* Every other cell in this row is capped; this one was not, so it took all the
   surplus — 836px at a 1400 shell, 2,236 at 2,800, for a field that stops at
   forty characters. */
.milestone-name {
  flex: 2 1 160px;
  min-width: 0;
  /* Twice the 220px the amount beside it gets, which is the proportion the
     `flex: 2` was already asking for — it just had no ceiling to ask within. */
  max-width: 440px;
}

.milestone-amount {
  flex: 1 1 120px;
  max-width: 220px;
  /* The value is right-aligned, so its left padding is decoration — trimming
     it is what lets a seven-digit target fit on a phone. */
  padding-left: var(--space-sm);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.milestone-actions { flex: 0 0 auto; display: flex; gap: var(--space-3xs); }

/* The answer, in the same ink and the same size as everything else the app
   derives rather than asks for. */
.milestone-said {
  margin: 0;
  min-width: 0;
  font-size: var(--type-2xs);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* The same question asked backwards, which only a target the plan misses grows.
   Left as a block rather than made a flex column: the answer under it is a live
   region and so has to stay in the page whether it holds anything yet or not,
   and a flex gap would hold six pixels open under every unanswered ask. Its own
   margin does the spacing instead, and an empty answer gives that up. */
.milestone-ask {
  min-width: 0;
  margin-top: var(--space-3xs);
}

/* Wraps as a group, the way the boxes above it do: on a phone the picker takes
   the line and the button drops under it rather than both being squeezed. */
.milestone-asking {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
}

/* Wide, because an option names a field *and* which of its figures — "The
   house — the amount" is most of a line before the reader has renamed
   anything. */
.milestone-choice { flex: 1 1 240px; max-width: 400px; }

/* The one thing on the row the reader asked for outright, so it is in the
   reading ink rather than the derived grey the line above it wears. */
.milestone-answer {
  margin: var(--space-xs) 0 0;
  min-width: 0;
  font-size: var(--type-2xs);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
/* Still in the page, so that filling it is a change to a live region a screen
   reader is already watching — but taking up none of it. */
.milestone-answer:empty { margin-top: 0; }

/* ----------------------------------------------------------- footer/toast */

.app-footer {
  max-width: var(--shell);
  margin: 0 auto;
  padding: 0 var(--space-2xl) var(--space-4xl);
  font-size: var(--type-2xs);
  color: var(--text-secondary);
}

.toast {
  position: fixed;
  left: 50%;
  bottom: calc(18px + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 20;
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  padding: var(--space-md) var(--space-lg) var(--space-md) var(--space-xl);
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  box-shadow: var(--shadow);
  font-size: var(--type-sm);
}

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: 1ms !important; animation-duration: 1ms !important; }
}

/* ------------------------------------------------------------------- about */

/* Where `<dialog>` is unsupported there is no UA rule hiding a closed one, and
   `showModal` throws, so the whole panel — the button that throws every plan
   away included — would sit in the page from first paint. */
.about:not([open]) { display: none; }

.about {
  width: min(560px, calc(100vw - 32px));
  max-height: min(72vh, 720px);
  padding: var(--space-2xl);
  background: var(--surface-1);
  color: var(--text-primary);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: auto;
  overscroll-behavior: contain;
}
.about::backdrop { background: rgba(11, 11, 11, 0.45); }

/* A dialog is a surface like a panel, and the narrow rung that takes a panel's
   padding down two steps had left it out. It has to sit *here*, after the rule
   it overrides, rather than up in that rung with the others: `.about`'s own
   padding is declared below that block, so a copy of this inside it lost the
   cascade and did nothing at all — which is how it first shipped. The eight
   pixels it gives back on a 320px phone are eight of measure, and measure is
   what decides how many lines a paragraph of template note takes. */
@media (max-width: 380px) {
  .about { padding: var(--space-xl); }
}

.about-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-lg);
  margin-bottom: var(--space-lg);
}
.about-head h2 { margin: 0; font-size: var(--type-lg); }
.about h3 {
  margin: var(--space-2xl) 0 var(--space-sm);
  font-size: var(--type-xs);
  font-weight: 600;
  color: var(--text-secondary);
}

/* The four facts as pairs, so a narrow phone stacks them rather than
   squeezing a branch name into a column it cannot hold. */
.about-build {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: var(--space-sm) var(--space-xl);
  margin: 0;
}
.about-build div { min-width: 0; }
.about-build dt { font-size: var(--type-2xs); color: var(--text-secondary); }
.about-build dd {
  margin: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--type-xs);
  /* A branch name is one long token; let it break rather than overflow. */
  overflow-wrap: anywhere;
}

/* The caveat belongs with the facts it qualifies, not crowded against them. */
#about-note { margin: var(--space-md) 0 0; }

.about-log { margin: 0; padding: 0; list-style: none; }

.about-release {
  padding: var(--space-md) 0;
  border-top: 1px solid var(--grid-line);
}
.about-release:first-child { border-top: none; }
/* The one being run, marked in ink rather than colour alone: the strip is a
   second cue, not the only one. */
.about-release[aria-current="true"] {
  /* Out by a step and back by the same step: the 3px rule is drawn in the
     room the negative margin opened, so the text never moves. */
  margin-left: calc(-1 * var(--space-lg));
  padding-left: calc(var(--space-lg) - 3px);
  border-left: 3px solid var(--accent);
}

.about-release-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-sm);
  margin: 0 0 var(--space-3xs);
}
.about-version { font-weight: 600; font-size: var(--type-xs); }
.about-when { font-size: var(--type-2xs); color: var(--text-secondary); }
.about-commit-ref {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--type-2xs);
  color: var(--text-secondary);
}
.about-what { margin: 0; font-size: var(--type-xs); color: var(--text-secondary); }

.about-update,
.about-reset {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  flex-wrap: wrap;
  margin-top: var(--space-xl);
  padding-top: var(--space-xl);
  border-top: 1px solid var(--grid-line);
}
.about-update .hint,
.about-reset .hint { margin: 0; flex: 1 1 220px; }

.about-confirm {
  margin-top: var(--space-xl);
  padding: var(--space-lg) var(--space-lg);
  background: var(--surface-2);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
}
.about-confirm p { margin: 0 0 var(--space-md); font-size: var(--type-xs); }
.about-confirm-actions { display: flex; gap: var(--space-sm); flex-wrap: wrap; }

/* The link being handed over. Monospace because it is an address rather than
   prose, and read to check it, not for pleasure; `text-overflow` rather than a
   wrap because a link long enough to wrap tells the reader nothing extra, and
   the box is selected on opening so the whole of it is copied either way. */
.share-link {
  width: 100%;
  margin-top: var(--space-lg);
  padding: var(--space-md) var(--space-lg);
  font: inherit;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--type-xs);
  color: var(--text-primary);
  background: var(--surface-2);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  text-overflow: ellipsis;
}

.share-actions {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  flex-wrap: wrap;
  margin-top: var(--space-lg);
}

.share-actions .hint { margin: 0; }

/* The one button in the app that throws something away. Named in words on the
   button itself, so the colour is a second cue and never the only one. */
.ghost-button.is-grave {
  border-color: var(--status-critical);
  color: var(--status-critical);
}
.ghost-button.is-grave:hover {
  background: var(--status-critical);
  border-color: var(--status-critical);
  color: var(--surface-1);
}

/* ------------------------------------------------------- a finger, not a mouse */

/*
 * A pointer that cannot hover cannot aim either. Every control the app offers
 * is comfortable at 36 or 40px under a mouse and a nuisance under a thumb, so
 * on a coarse pointer they all reach 44px — the figure both Apple and Android
 * publish, and the one the icon buttons were already built to.
 *
 * Keyed on the pointer rather than the width: a phone held in landscape is
 * still a phone, and a touchscreen laptop is still touched. Nothing here
 * changes what the app looks like under a mouse.
 */

/* --------------------------------------------------------------- projects

   The project switch is the panel's heading, and everything about it is chosen
   so that it can never be mistaken for the strategy tabs two lines below.

   The tabs are pills, in a row, of equal weight, one of them selected, and one
   of them a text box. This is a single left-aligned control carrying the weight
   of the heading it replaced, in a **field-shaped box with a caret in its own
   compartment** — which is this app's other mark for "a list opens here", worn
   by every <select> in the form below. (It is not the fold button's chevron and
   never was: that one is stroked, 20px and out at the panel's edge, and
   `project-switch.js` says at length why two marks that could be mistaken for
   each other would be worse than none.)

   Shape is what tells the two switchers apart, and it is doing real work. A
   stadium means *a peer you switch between*, and there is a row of them below.
   A 9px rectangle with a hairline outline means *a control that takes a value*,
   and every input and select in the panel is one. So the project switch is the
   second kind and never the first: same radius as the boxes under it, same
   `--border-strong` hairline, and a divided caret cell no field wears — because
   this one opens a sheet rather than a keyboard.

   It never takes a strategy colour, never a swatch and never an origin edge:
   four strategy colours are spoken for by the curves in the comparison, and a
   fifth mark up here would be read as a fifth legend entry. Its only colour is
   text and chrome. */

.project-heading {
  /* Takes the room the fold button leaves rather than only its own content's:
     a project's name is what tells the reader which figures these are, so it
     gets every pixel there is before it has to truncate. */
  flex: 1 1 auto;
  min-width: 0;
  /* The heading is the button. `display: contents` would take the h2 out of the
     box tree and with it the accessible name the section is labelled by in a
     couple of engines, so it stays a box and simply carries no styling of its
     own beyond letting the button fill it. */
  display: flex;
  min-height: 34px;
  align-items: center;
}

.project-switch {
  display: inline-flex;
  align-items: center;
  /* 6 before the rule and 8 after it, which is not a slip: the caret needs the
     room to sit off the box's edge, the name does not, and the two pixels are
     the difference between the upgrade store's heading fitting a 390px phone
     and ending in an ellipsis. */
  gap: var(--space-xs);
  min-width: 0;
  /* The box hangs into the panel's own padding so that the *name* still starts
     on the column every label, hint and heading below it starts on. Aligning
     the box edge instead would put the one word that says which figures these
     are eight pixels right of everything under it. The panel has 16px of
     padding at its narrowest, so the hang never reaches the panel's edge, and
     the focus ring at 2px offset does not either.

     The max-width takes those same eight pixels back as measure, because they
     are room the button really has: hanging left and stopping at 100% would
     leave the fold button's gap eight pixels wider than it was drawn and spend
     them on nothing. Measured on the upgrade store, where the switch carries
     the panel's own heading — "What comes in and goes out" comes to 263.6px in
     a 312px rail, which fits in the 268 this gives it and not in the 260 the
     heading alone is. */
  max-width: calc(100% + var(--space-sm));
  margin-left: calc(-1 * var(--space-sm));
  padding: var(--space-2xs) var(--space-sm);
  /* The chrome of a control that takes a value, which is what the form below is
     made of: the same fill, the same hairline and the same 9px corner as every
     input and select in it. Transparent-until-hover was the whole of the
     reported fault — 14px/600 near-black on nothing reads as a heading, because
     that is exactly what it looks like, and a reader cannot hover what they
     have not noticed. */
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  color: inherit;
  font: inherit;
  font-weight: 600;
  letter-spacing: -0.01em;
  text-align: left;
  cursor: pointer;
}
.project-switch-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The caret's own cell, divided from the name by a hairline and running the
   full height of the box. That division is the part that says *a list*: a box
   with a mark loose inside it is a button, a box with a mark behind a rule is
   the thing a <select> looks like on every platform this app runs on. It is
   also why the mark can stay small — it no longer has to carry the whole cue by
   itself. Stretched rather than centred, so a 44px box under a thumb gets a
   44px rule and not a 10px one floating in it. */
.project-switch-mark {
  display: flex;
  flex: none;
  align-items: center;
  align-self: stretch;
  padding-left: var(--space-sm);
  border-left: 1px solid var(--border);
}
/* Solid and 10px against the fold button's stroked 20px chevron on the same
   line: two marks that could be mistaken for each other would be worse than
   none, and the 8px this was could not be seen at all — `--text-muted` at that
   size was the only thing on screen saying the heading was pressable. */
.project-switch .project-caret {
  flex: none;
  width: 10px;
  height: 10px;
  fill: var(--text-secondary);
  transition: fill 140ms ease;
}
/* Hover confirms rather than announces, now that the resting state says it:
   the same step an input takes when the pointer is over it. */
.project-switch:hover {
  border-color: var(--text-muted);
  background: var(--surface-2);
}
.project-switch:hover .project-caret { fill: var(--text-primary); }

/* Pinned, it is a label first and a control second: the row beside it is what
   the reader came for, so this shrinks to the smaller type of that row and
   keeps only as much width as a name needs. */
.strategy-jump-lead {
  display: flex;
  align-items: center;
  flex: none;
  min-width: 0;
  /* The rule is the whole of the separation, and it is doing real work: it says
     the button on its left and the pills on its right are two different kinds
     of thing rather than one row of six. */
  padding-right: var(--space-sm);
  margin-right: var(--space-sm);
  border-right: 1px solid var(--border);
}
/* Pinned, the box comes off again, and that is measured rather than taste. The
   fault this fixes is a control standing alone above a form, where nothing
   around it is pressable and only its own chrome can say that it is; in the
   pinned bar it stands in a row of controls, behind a rule, with four pills
   beside it, and the row is the cue. What a box would cost there is width: the
   button is capped at `min(30vw, 220px)` and full at every width below about
   730px, so a border and a divided caret cell come out of the name — 11 of the
   117px a 390px phone gives it, which is the difference between "Acheter u…"
   and "Achete…". The name is the only thing saying which question these plans
   answer, so it keeps the pixels. The caret is the same mark in both places. */
.project-switch.is-pinned {
  margin-left: 0;
  padding: var(--space-2xs) var(--space-xs);
  background: none;
  border-color: transparent;
  /* Measured, and the vw term is the one that matters. At 42vw the lead took
     159 of a 390px bar and left the three names 197px to scroll in, against
     366 before it existed — one and a bit names visible instead of two and a
     bit. At 30vw it takes 130 and leaves 226. The name truncates, and that is
     the right thing to spend: "Acheter u…" is enough to know which question
     these plans answer, and the pills beside it are what the reader came to
     press. */
  max-width: min(30vw, 220px);
  font-size: var(--type-xs);
  color: var(--text-secondary);
}
.project-switch.is-pinned .project-switch-mark {
  padding-left: 0;
  border-left: 0;
}
/* Restated because `.is-pinned` has just turned the shared hover off, and the
   two selectors weigh the same — the pinned copy still has to answer a pointer,
   in the lighter chrome the bar is drawn in. */
.project-switch.is-pinned:hover {
  background: var(--surface-2);
  border-color: var(--border);
  color: var(--text-primary);
}

/* Of the three dialogs this one is the only one whose content *grows*: the
   project list gets a row per project, and the template shelf under it gets a
   row every time the app learns a new subject. Both numbers here are measured
   against that.

   **88vh, not the 72 the other two take.** 72 is desktop-dialog sizing, and on
   a 720px-tall phone it left 101px of dimmed page above and below while putting
   the second template below the fold — 518px of box around 836px of sheet. 88
   leaves 43px a side, which is still plainly a card over a dimmed page, and it
   is the difference between a reader seeing one template and seeing two. What
   it costs is backdrop to dismiss by tapping; the Close button is the real
   exit and is 44px under a thumb.

   **900px, not the About panel's 720.** That cap is for a changelog with no end
   to it. This sheet's content is bounded, and measured at the 560px the dialog
   is wide: two templates and one project come to 576 in English, and a third
   template takes that to 731 and two projects with three templates to 791. 720
   would put the sheet on a scroll the day a third template lands even on a
   desktop. 900 clears that and still leaves a 1440-tall screen 540px of page
   behind. It never bites below an 1,023px-tall window, where 88vh is the
   smaller of the two. */
#projects { max-height: min(88vh, 900px); }

/* The sheet. Rows rather than pills, because the choice here is a list you read
   down and not a row you flick along — and because the row carries what a name
   alone cannot: how many plans are in it, and how far they run. */
.project-list {
  display: grid;
  gap: var(--space-xs);
  margin: var(--space-lg) 0 0;
  padding: 0;
  list-style: none;
}

.project-row {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
  padding: var(--space-2xs);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
}
.project-row[data-open="true"] {
  background: var(--surface-2);
  border-color: var(--border);
}

.project-open,
.project-list .project-name {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 40px;
  padding: var(--space-xs) var(--space-md);
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  font-size: var(--type-sm);
  text-align: left;
  cursor: pointer;
}
.project-open:hover { background: var(--surface-2); color: var(--text-primary); }
/* The open project *is* its name box, exactly as the active strategy tab is. */
.project-list .project-name {
  background: var(--surface-1);
  border-color: var(--border-strong);
  color: var(--text-primary);
  font-weight: 600;
  cursor: text;
}

.project-count {
  flex: none;
  color: var(--text-muted);
  font-size: var(--type-2xs);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.project-add { margin-top: var(--space-lg); }

/* ---------------------------------------------------------- the templates */

/* Under the blank one, and separated by a rule rather than by space alone: the
   two are not alternatives of the same weight. "Start another project" is the
   fast path and keeps the button shape it always had; a template is a second
   thought, for a reader who does not yet know which questions the subject has.
   The note under each is hint type and always visible — it is where what the
   template contains is said, and that has to be read before the button is
   pressed rather than discovered after.

   The shelf is a *list that grows*, and that is what shapes the rest of it. A
   second template took the shelf to 521px in French inside a 516px dialog on a
   320px phone — taller, on its own, than the whole sheet it sits in — and put
   the second button below the fold with nothing on screen saying it was there.
   A third makes that arithmetic final. So the shelf is built to read as a list
   of n rather than to fit: a head that counts, one claim said once for all of
   them, and a hairline between rows so that the rule arriving at the bottom
   edge is itself the "more below". */
.template-shelf {
  margin-top: var(--space-xl);
  padding-top: var(--space-lg);
  border-top: 1px solid var(--border);
}

/* The shelf's own name, in the shape the About panel's sub-headings already
   take: the same 13px as the prose around it, told from it by weight and
   position rather than by a size of its own. */
.template-head { margin-bottom: var(--space-md); }
.template-from {
  margin: 0;
  font-size: var(--type-xs);
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--text-secondary);
}

/* The one claim true of every template, said once above every button. It stays
   at 13px with the rest of the reading and is deliberately *not* stepped down
   to the annotation size on a phone: it is the sentence that has to be read
   before anything is pressed, and shrinking it would be the one saving on this
   sheet that costs the thing the sheet is careful about. */
.template-claim { margin: var(--space-2xs) 0 0; }

.template-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* A hairline between rows, the lighter `--grid-line` rather than the `--border`
   that opens the shelf: that one says "a new section", this one says "another
   of the same". It is what makes two prose blocks read as two items, and it is
   the cue that survives the fold — a rule at the bottom edge of the sheet says
   there is a row under it in a way that 10px of nothing never did. */
.template-row + .template-row {
  margin-top: var(--space-sm);
  padding-top: var(--space-sm);
  border-top: 1px solid var(--grid-line);
}

.template-start {
  display: block;
  width: 100%;
  min-width: 0;
  text-align: left;
}

.template-note {
  margin: var(--space-2xs) var(--space-3xs) 0;
}

@media (pointer: coarse) {
  .ghost-button,
  .preset,
  .strategy-tab,
  .strategy-name,
  .add-strategy,
  .add-field,
  /* 32px until now, because it was the switcher of last resort. Folding the
     rail on a touchscreen laptop makes it the only one on screen. */
  .strategy-jump-tab,
  .table-toggle {
    min-height: 44px;
  }

  /* Padding rather than min-height: a <summary> given a height has to be made a
     flex box to centre its text, and a <summary> made a flex box loses the
     disclosure triangle the browser draws for it. 13px a side takes a 13px line
     to 45.5, which is the first whole pixel over the floor. */
  .panel-notes > summary,
  .section-notes > summary,
  .assumptions-fold > summary { padding: 13px 0; }

  .icon-button { width: 44px; }
  /* ...except where it is a flex item, which is where that width is only a
     *basis*. The project row is a flex container and shrank this one to 34px —
     the stylesheet said 44 and a thumb got 34, which is the worst way for a
     touch target to be wrong, because nothing on screen or in the CSS admits
     it. The name box beside it carries `min-width: 0`, so refusing to shrink
     here costs the name its last ten pixels of measure and nothing else. */
  .project-remove { flex: none; }

  /* The project switch is a heading you press, so it takes no height of its own
     on a mouse — under a thumb it has to. Padding rather than min-height, so
     the name still sits on the heading's own baseline and the row does not grow
     a step the pointer layout does not have. */
  .project-switch {
    min-height: 44px;
    padding-top: var(--space-sm);
    padding-bottom: var(--space-sm);
  }
  .project-switch.is-pinned { min-height: 36px; }
  .project-open,
  .project-list .project-name { min-height: 44px; }
  .template-start { min-height: 44px; }

  /* Wide as well as tall: "In" is a two-letter chip, and 44 is a square. */
  .preset { min-width: 44px; }

  /* The boxes in the filter row are controls like any other. */
  .field-at,
  .milestone-name,
  .money-filter input,
  .filter input[type="text"],
  .filter input[type="number"] { min-height: 44px; }

  /* A 24px-tall slider is a 24px-tall target: the thumb is easy enough to grab
     but the track around it is not, and the track is most of what a thumb hits. */
  .range-line input[type="range"] { height: 44px; }

  /* Under 16px a phone zooms the page in on focus and does not zoom back out,
     which leaves the reader panned somewhere they did not ask to be. Every
     other box in the app already clears it; the strategy name did not — and
     neither did the box holding a share link, which is focused and selected the
     moment the panel opens, so it would zoom before the reader touched it. */
  .strategy-name,
  .share-link { font-size: var(--type-md); }

  .share-link { min-height: 44px; }

  /* A link is a target too, and this one is the first thing a keyboard or
     switch user reaches. */
  .skip-link { min-height: 44px; display: flex; align-items: center; }
}
