/* Portfoolio — the app's stylesheet.

   Extracted from views/layout.ejs (two inline blocks, ~570 lines) so the React SPA can link the same
   rules the EJS pages do: parity between the two front ends is only cheap when they paint from one file.
   Page-specific rules stay in their page's own <style> — test/views.test.js guards which classes may
   live where.

   ONE rule intentionally stays inline in layout.ejs: the @view-transition opt-in. With the stylesheet
   linked, a POST→redirect→HTML navigation could commit before the new document's CSS arrived, Chrome
   aborted the transition mid-flight and reported "Transition was aborted because of invalid state.
   ViewTransition opt-in disabled" as a page error (two tax flows failed on it). The opt-in has to be
   synchronous, so it lives in the head. */
  /* Color themes: 8 core vars, swapped by [data-theme]. Light is default. Keep the
     server.js THEMES whitelist in sync with the selectors here. */
  :root, [data-theme="dark"] { --accent: #4f6ef7; --bg: #0f1420; --panel: #1a2130; --border: #2a3242; --text: #e6e9f0; --muted: #8b93a7; --green: #34c77b; --red: #f0564f; --scrim: rgba(0,0,0,0.5); --accent-tint: rgba(79,110,247,0.07); --accent-tint-soft: rgba(79,110,247,0.12); --accent-tint-pane: rgba(79,110,247,0.14); --accent-tint-strong: rgba(79,110,247,0.25); --tip-bg: #141a26; --tip-text: #e8ecf5; --seg-track: #0a0e17; }
  [data-theme="midnight"] { --accent: #6d8bff; --bg: #05070d; --panel: #0d1220; --border: #1c2436; --text: #dfe4ee; --muted: #6e7a89; --green: #34c77b; --red: #f0564f; --scrim: rgba(0,0,0,0.65); --accent-tint: rgba(109,139,255,0.07); --accent-tint-soft: rgba(109,139,255,0.12); --accent-tint-pane: rgba(109,139,255,0.16); --accent-tint-strong: rgba(109,139,255,0.25); --tip-bg: #0a0f1a; --tip-text: #dfe4ee; --seg-track: #05070d; }
  [data-theme="light"] { --accent: #2f5fe0; --bg: #f4f6fa; --panel: #ffffff; --border: #d5dbe6; --text: #3a4353; --muted: #6a7386; --green: #1e9e5f; --red: #d63f38; --scrim: rgba(28,35,51,0.4); --accent-tint: rgba(47,95,224,0.06); --accent-tint-soft: rgba(47,95,224,0.1); --accent-tint-pane: rgba(47,95,224,0.16); --accent-tint-strong: rgba(47,95,224,0.2); --tip-bg: #ffffff; --tip-text: #1c2333; --seg-track: #e6e9ef; }
  [data-theme="sepia"] { --accent: #b07d3f; --bg: #201914; --panel: #2b221a; --border: #41352a; --text: #ece2d4; --muted: #a8987f; --green: #6faf6f; --red: #d96a54; --scrim: rgba(15,10,5,0.55); --accent-tint: rgba(176,125,63,0.09); --accent-tint-soft: rgba(176,125,63,0.15); --accent-tint-pane: rgba(176,125,63,0.2); --accent-tint-strong: rgba(176,125,63,0.3); --tip-bg: #2b221a; --tip-text: #ece2d4; --seg-track: #1b1511; }
  [data-theme="bitcoin"] { --accent: #f7931a; --bg: #16130e; --panel: #211c14; --border: #3a3122; --text: #f0e9dc; --muted: #a89c88; --green: #6faf6f; --red: #d96a54; --scrim: rgba(10,8,4,0.55); --accent-tint: rgba(247,147,26,0.09); --accent-tint-soft: rgba(247,147,26,0.15); --accent-tint-pane: rgba(247,147,26,0.2); --accent-tint-strong: rgba(247,147,26,0.3); --tip-bg: #211c14; --tip-text: #f0e9dc; --seg-track: #120f0a; }
  /* Light themes: the logo PNG has a white face — invert it so it reads on light bg
     (hue-rotate keeps the blue pupils blue instead of orange). */
  /* Light theme only: white logo face needs inverting. Sepia keeps the white face —
     its bg is dark brown, so white reads fine (inverting would give black-on-brown). */
  [data-theme="light"] .brand .logo-eyes, [data-theme="light"] .login-logo .logo-eyes,
  [data-theme="light"] .brand .logo-lockup, [data-theme="light"] .login-logo .logo-lockup { filter: invert(1) hue-rotate(180deg); }
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  /* iPad/iOS: prevent double-tap zoom from swallowing menu/button taps */
  button, .sel-opt, .div-tab, [role="button"] { touch-action: manipulation; }
  html { overflow-y: scroll; scrollbar-width: none; }  /* page bar hidden (scroll still works; gutter strip gone) — table wrappers keep theirs */
  html::-webkit-scrollbar { display: none; }
  body { margin: 0; font-family: -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); }
  html { background: var(--bg); }
  header { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 20px; padding: 12px 24px; background: var(--panel); border-bottom: 1px solid var(--border); flex-wrap: nowrap; }
  header nav { display: flex; align-items: center; gap: 20px; white-space: nowrap; }
  header .brand { font-weight: 700; font-size: 18px; color: var(--accent); text-decoration: none; }
  /* The wordmark's size in the header. It used to be two inline style attributes in layout.ejs and in the
     SPA's Logo; the auth card sizes the same markup from CSS, so an inline height won there too and the
     eyes landed nowhere near the letters. Geometry lives here now. */
  header .brand img { height: 24px; width: auto; display: block; border-radius: 4px; }
  nav { display: flex; align-items: center; }
  nav a { color: var(--muted); text-decoration: none; margin-right: 16px; font-size: 14px; }
  nav a.admin-link { color: var(--muted); }
  nav a.active, nav a:hover { color: var(--text); }
  /* Active-page underline: a 2px accent bar flush with the header's bottom border */
  nav a { position: relative; padding: 4px 0; }
  nav a::after { content: ''; position: absolute; left: 0; right: 0; bottom: -6px; height: 2px; background: var(--accent); transform: scaleX(0); transition: transform 0.15s ease; }
  nav a.active::after { transform: scaleX(1); }
  header { overflow: hidden; }
  header .spacer { flex: 1; }
  /* header shrink: below ~900px hide the client date; the email pill truncates with an
     ellipsis instead of pushing the page wider (page-level horizontal scroll) */
  @media (max-width: 900px) { #nav-date { display: none; } }
  /* The email truncates with an ellipsis rather than widening the page (see header overflow note).
     line-height MUST exceed the font-size here: overflow:hidden clips at the content box, so with
     line-height:1 that box is exactly the 13px font-size and the DESCENDERS of '@' and 'g' are cut
     flat — which reads as a stray underline under "@g". It is font-dependent (the box fits the
     metrics of some fonts and not others), so it showed on some machines only. `line-height: normal`
     is the font-independent fix: it uses each font's OWN ascent+descent, so the box always fits its
     glyphs (a fixed multiplier like 1.4 only covers fonts whose metrics it happens to exceed). It
     keeps the text's baseline EXACTLY where it was — the flex centring moves the taller box up by
     the same amount the half-leading grows — and the pill's 35px min-height is untouched. */
  /* The account menu's rows. The icon sits in a fixed 18px column so the two labels start on the same
     x, and the icon a step behind the label (opacity .85) — the same rule the list menus use. */
  .um-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 14px;
             background: none; border: none; color: var(--text); font: inherit; font-size: 13px;
             text-align: left; text-decoration: none; cursor: pointer;
             /* the global `button { min-height: 35px }` made the Log out button 4px taller than the
                Settings link beside it */ min-height: 0; }
  .um-item svg { flex: 0 0 auto; opacity: .85; }
  .um-icon { width: 18px; display: inline-flex; align-items: center; justify-content: center; }
  /* No row tint here: the menu highlights nothing on hover. Log out is the exception and says so in
     the theme's red — text and icon only, no background. */
  .um-item.danger:hover { color: var(--red); }
  .um-item.danger:hover svg { opacity: 1; }
  #user-menu-btn span:first-child { max-width: 40vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; line-height: normal; }
  #user-menu-btn { max-width: 40vw; min-width: 0; flex-shrink: 1; }
  header > span, header > nav { flex-shrink: 1; min-width: 0; }
  @media (max-width: 640px) {
    nav a { margin-right: 10px; font-size: 13px; }
    header { padding: 10px 12px; gap: 10px; }
  }
  /* wide tables define their own scroll; grids of half-width panels collapse on narrow screens
     so their .table-scroll children get full width instead of a 640px-min squeeze */
  .admin-grid > section { min-width: 0; }
  /* any grid/flex child hosting a scrollable table must be shrinkable, or the grid's
     min-content (the table's min-width) widens the whole page */
  .table-scroll { min-width: 0; }
  section:has(> .table-scroll) { min-width: 0; }
  @media (max-width: 900px) { .admin-grid { grid-template-columns: 1fr !important; } }
  header form { margin: 0; }
  /* font-family: inherit is load-bearing: a button does NOT inherit the page font, so without it every
     button in the app renders in the browser's default (Arial) while the text around it does not. */
  button, .btn { min-height: 35px; box-sizing: border-box; border: 1px solid transparent; background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 9px 14px; font-family: inherit; font-size: 14px; cursor: pointer; text-decoration: none; display: inline-block; }
  button.secondary { background: var(--border); color: var(--text); }
  button.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; padding: 9px; font-size: 13px; font-weight: 500; }
  /* Sizes and shapes for views/components/button.ejs. The component owns geometry so a call site never
     writes padding/height/width itself (37 of 53 buttons had hand-tuned ones). */
  button.btn-sm, .btn.btn-sm { min-height: 28px; padding: 4px 10px; font-size: 13px; }
  button.btn-xs, .btn.btn-xs { min-height: 22px; padding: 1px 6px; font-size: 12px; }
  button.btn-icon, .btn.btn-icon { width: 30px; height: 30px; min-width: 30px; min-height: 0; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
  /* A quiet button is text with no chrome until hover — the arrows and inline "edit" controls. */
  /* Danger is a filled red button — except as a menu row, where it is the red caption. */
  button.btn-danger, .btn.btn-danger { background: var(--red); color: #fff; }
  button.btn-danger.pane-menu-item { background: none; color: var(--red); }
  button:disabled, .btn:disabled, button[disabled] { opacity: .45; cursor: not-allowed; }
  button.btn-quiet, .btn.btn-quiet { background: none; border: 1px solid transparent; color: var(--muted); }
  button.btn-quiet:hover:not(:disabled) { color: var(--text); }
  .btn-block { width: 100%; }
  /* A segmented picker's cell: quiet buttons sharing one bordered row (the feedback widget picker). */
  button.seg-btn, .btn.seg-btn { min-height: 0; padding: 6px 10px; border: none; border-radius: 0; }
  /* A menu row's icon column (views/components/button.ejs menu-row shape): one width, so the captions
     of every row start on the same x. It lived on the list page only, while the home menu used it. */
  .menu-icon { width: 20px; min-width: 20px; display: inline-block; text-align: left; }
  button.ghost:hover { color: var(--text); border-color: var(--text); }
  /* Modal ✕ close buttons: borderless (muted glyph, text brightens on hover) — a bordered
     box around the ✕ read as clutter in the modal corner */
  button.ghost.modal-x { border-color: transparent; padding: 4px 8px; }
  button.ghost.modal-x:hover { color: var(--text); border-color: transparent; }
  /* Modal: THE dialog chrome (views/components/modal.ejs). The overlay is inert until PFModal adds
     .is-open, so no view sets its own display, width or padding; the 20px overlay padding is what
     makes every dialog fit a narrow screen without a per-dialog 92vw calc. Sizes are the only
     variation — sm 400 / md 560 (default) / lg 720 / xl 860. */
  .modal { display: none; position: fixed; inset: 0; z-index: 100; background: var(--scrim); align-items: center; justify-content: center; padding: 20px; }
  .modal.is-open { display: flex; }
  .modal-box { position: relative; width: min(560px, 100%); max-height: 86vh; overflow: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 22px; box-shadow: 0 16px 44px rgba(0, 0, 0, 0.3); }
  .modal-box.sm { width: min(400px, 100%); }
  .modal-box.lg { width: min(720px, 100%); }
  .modal-box.xl { width: min(860px, 100%); }
  .modal-title { margin: 0 0 4px; color: var(--text); }
  .modal-sub { margin: 0 0 12px; }
  .modal-x { position: absolute; top: 10px; right: 10px; }
  .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
  body.modal-open { overflow: hidden; }
  button.ghost.chart-range-active { color: var(--accent); border-color: var(--accent); }
  button.ghost.lot-menu-btn, button.ghost.menu-btn { border-color: transparent; }
  button.ghost.lot-menu-btn { width: 35px; height: 35px; min-width: 35px; padding: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; }
  button.menu-btn { width: 35px; height: 35px; min-width: 35px; padding: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; }
  button.ghost.lot-menu-btn:hover { border-color: var(--muted); }
  button.ghost.btn-accent:hover { color: var(--accent); border-color: var(--accent); }
  /* Standard component: blue-text confirm (text always accent, border joins on hover) */
  button.ghost.btn-accent-text { color: var(--accent); }
  button.ghost.btn-accent-text:hover { border-color: var(--accent); }
  button.ghost.btn-confirm:hover { background: var(--accent); color: #fff; }
  /* Standard confirm-pair buttons: ✕ cancel (red) / ✓ ok (green) — 35×35 square */
  .btn-confirm-x, .btn-confirm-ok { width: 35px; min-width: 35px; height: 35px; padding: 0; font-size: 14px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; }
  .btn-confirm-x, .btn-confirm-ok { background: transparent; color: var(--muted); border-color: var(--border); }
  .btn-confirm-x:hover { color: var(--red); border-color: var(--red); }
  .btn-confirm-ok:hover { color: var(--green); border-color: var(--green); }
  button.btn-confirm-sm.btn-confirm-sm { width: 24px; height: 24px; min-height: 0; font-size: 11px; padding: 0; margin: 0; }
  /* Standard tab component — every tab bar in the app is views/components/tabs.ejs, and these two
     rules are its whole box. A bar's own MARGINS vary by context (page-level vs inside a card) and
     live with the page; the tab's box, type and states live here, once. */
  .tabs { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
  /* A link tab and a button tab are the same chip: without the explicit height a link rendered 30px
     beside a button's 35px (the global `button { min-height }`), and the two bars are the same
     component. inline-flex so the label is centred in the taller box. */
  .tab { display: inline-flex; align-items: center; justify-content: center; min-height: 35px; background: none; color: var(--muted); border: none; border-radius: 8px; padding: 7px 16px; font-size: 14px; cursor: pointer; }
  .tab.active { background: var(--border); color: var(--text); }
  .tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
  /* Segmented: the alternative look for a panel bar — a recessed track with the current entry raised as
     a thumb. Track and thumb are their own theme vars because no pair of the 8 core ones works on both
     surfaces: the bar sits on the page background on Admin (where --bg is invisible) and on a panel card
     in the gallery. Each theme names a track a step darker than the page and a thumb a step lighter than
     the track. */
  /* One flat colour inside: no border and no inset shadow. Both were visible as a second tone — a darker
     1px rim and a gradient band across the track's top 3px — which read as two colours in the groove. */
  /* A segmented bar's trailing controls (Home's ⋯ menu) ride beside the track, not in the groove. */
  .tabs-row { display: flex; align-items: center; gap: 10px; }
  .tabs.tabs-segmented { display: inline-flex; flex-wrap: nowrap; gap: 0; padding: 3px; background: var(--seg-track, var(--bg)); border: none; border-radius: 10px; max-width: 100%; }
  .tabs-segmented .tab { min-height: 30px; padding: 5px 22px; border-radius: 8px; background: none; color: var(--muted); }
  .tabs-segmented .tab:hover { color: var(--text); }
  /* The selected segment wears the same colour as the flat bar's selected tab (--border): one selected
     state across both looks, only the track around it differs. */
  .tabs-segmented .tab.active { background: var(--border); color: var(--text); box-shadow: 0 1px 2px rgba(0, 0, 0, .12); }
  /* `raised: true` on the same bar: the selected segment is the panel colour instead of --border — a
     white thumb on the light themes, the raised panel on the dark ones — with a deeper shadow. */
  .tabs-segmented.tabs-raised .tab.active { background: var(--panel); box-shadow: 0 1px 3px rgba(0, 0, 0, .18), 0 0 0 1px rgba(0, 0, 0, .05); }

  /* The chip is a glyph and its pod is a sentence — neither may inherit the caps of the label or header it
     sits in. The day-change card puts its ⓘ inside .label (uppercase), so its "i" rendered as a capital I
     and the whole tooltip sentence came out in capitals; a tax table header did the same to its chip. */
  .info-tip, .info-tip-content { text-transform: none; }
  .info-tip:hover .info-tip-content, .info-tip:focus-visible .info-tip-content { display: block !important; }
  .info-tip:focus { outline: none; }
  /* The ⓘ glyph is 10px inside its 15px ring, wherever it appears. It was declared on the tax page
     only, so the same markup rendered at the card's text size anywhere else — a gallery example is
     how that surfaced. */
  .tax-icon { font-size: 10px; }
  /* The tax page's own scale. It lives here because its parts travel: the card notes and the ⓘ
     tooltips are partials, and a partial rendered on another page (the gallery) inherited the body
     size instead of these. */
  .tax-body { font-size: 13px; }
  .tax-small { font-size: 12px; }
  .tax-tiny { font-size: 11px; }
  /* The page gutter, in one place. A full-height page inside <main> has to subtract it TWICE
     (the top margin and the bottom one), and the auth pages did not — so their 100vh wrap started
     24px down, ended 24px early, and left 48px of scroll with the card sitting low. */
  :root { --page-gutter: 24px; }
  main { margin: var(--page-gutter) auto; padding: 0 var(--page-gutter); }
  h1 { font-size: 22px; margin: 0 0 16px; } h2 { font-size: 17px; color: var(--muted); }
  /* auto-fit, not repeat(5, 1fr): five fixed columns cannot wrap, so at narrow widths each card
     squeezed to ~62px while .card .value stays nowrap — the values spilled out and the PAGE
     scrolled sideways (measured: 40px at 420px). auto-fit wraps instead, and a 200px minimum
     keeps a value readable, so the row still shows all five across at normal widths. */
  .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 16px 0; }
  .card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px 20px; min-width: 0; }
  .card .value { white-space: nowrap; transform-origin: left center; }
  .card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
  /* A card that carries an ⓘ (the component's `tip` slot) is the tip's containing block. Three pages
     used to pass `style: 'position: relative;'` for this alone — the chip is absolutely positioned in
     the card's corner, so without it the tip escapes to the page's top-right (measured: card static,
     chip at 1375,10 instead of inside the card). Descendant, not child: the day-change ⓘ rides inside
     the label (labelTip) rather than the tip slot. */
  .card:has(.info-tip) { position: relative; }
  /* Room for the chip, so a long label does not run UNDER it: "Yahoo Requests Today" ended 5px past the chip's
     left edge and its last word sat behind the circle. The label wraps instead, and alignCardLabels gives the
     row a common min-height so the figures stay on one line. */
  .card:has(.info-tip) .label { padding-right: 22px; }
/* Icon-only button: the shared primitives. Each page sets its own size (the list rows use 28px, the
   token rows 20px so a live row is the same height as a revoked one). */
.icon-btn { border: none; background: none; padding: 0; cursor: pointer; display: inline-flex;
            align-items: center; justify-content: center; border-radius: 6px; transition: background .12s; }
.icon-btn:hover { background: var(--accent-tint-soft); }
/* The pane's per-row actions are revealed by hovering the row's own ⋮ (the rule lives in list.ejs, which the
   SPA does not load). Without it the pair painted permanently — and in white, because the base `button` rule
   colours text #fff.
   The rest of the pair's definition also lives in list.ejs, and the SPA used to load none of it: two 14px
   glyphs 6px apart in one muted grey, against EJS's two 28px boxes 2px apart with a red trash. Every claim
   here is measured by G8 of scripts/pf-pane-gates.js, which compares both front ends' numbers. */
#root .detail-pane .tx-row .row-actions { display: inline-flex; gap: 2px; align-items: center;
  opacity: 0; pointer-events: none; transition: opacity .15s ease; }
#root .detail-pane .tx-row:has(.lot-menu-btn:hover) .row-actions,
#root .detail-pane .tx-row:has(.row-actions:hover) .row-actions,
#root .detail-pane .tx-row.row-actions-open .row-actions,
#root .detail-pane .tx-row:focus-within .row-actions { opacity: 1; pointer-events: auto; }
#root .detail-pane .tx-row.tx-editing .row-actions { display: none; }
/* The editing row's own box, as views/list.ejs defines it: the cells drop to line-height 0 (the form fields
   carry their own line box), 10px padding top and bottom, a 32px row, and the fields are 12px blocks. Those
   three declarations are what make the page's editor columns measurably narrower than its view row. */
#root .detail-pane tr.tx-editing > td { line-height: 0; font-size: 0; padding-top: 10px !important; padding-bottom: 10px !important; }
#root .detail-pane tr.tx-editing { height: 32px; }
#root .detail-pane tr.tx-editing > td input,
#root .detail-pane tr.tx-editing > td select,
#root .detail-pane tr.tx-editing > td .sel-btn { font-size: 12px; line-height: normal; display: block; }
/* The fields size the columns: views/list.ejs writes `width:100%; box-sizing:border-box; padding:1px 4px;
   margin:0; height:35px` on every editor input, with the numeric three right-aligned. Without the same box the
   SPA's auto layout gave Date/Notes 14px each more than the page and the currency column less. */
#root .detail-pane tr.tx-editing > td input {
  width: 100%; box-sizing: border-box; padding: 1px 4px; margin: 0; height: 35px;
}
/* The price cell is the widest field in the page's editor (currency picker + price, 257px of the row): its own
   width is what pulls the other columns back to 187. The SPA's picker pair is narrower by ~70px, so the table
   spread that slack across Date/Qty/Fees/Notes and every column read ~10px wide. Same floor as the add row. */
#root .detail-pane tr.tx-editing .pf-price-edit { min-width: 234px; }
#root .detail-pane tr.tx-editing > td:nth-child(4) input,
#root .detail-pane tr.tx-editing > td:nth-child(5) input,
#root .detail-pane tr.tx-editing > td:nth-child(6) input { text-align: right; }
#root .detail-pane .tx-row .icon-btn { width: 28px; height: 28px; color: var(--muted);
  background: none; border: none; }
/* `background: none` on the line above is more specific than the shared `.icon-btn:hover` tint, so the pencil
   lost its hover wash — it only darkened its glyph, where the page paints the accent tint behind it. The rule
   below restores exactly that (the trash keeps its own red wash, which is more specific still). */
#root .detail-pane .tx-row .icon-btn:hover { background: var(--accent-tint-soft); }
#root .detail-pane .tx-row .icon-btn.row-edit-btn { color: var(--muted); }
#root .detail-pane .tx-row .icon-btn.row-edit-btn:hover { color: var(--text); }
#root .detail-pane .tx-row .icon-btn.row-del-btn { color: var(--red); }
#root .detail-pane .tx-row .icon-btn.row-del-btn:hover {
  background: color-mix(in srgb, var(--red) 14%, transparent); }
/* The add row's price cell holds a currency picker and a price field, and EJS lets that content size the column
   — 254px at every width. The SPA's two controls are narrower on their own, so the column shrank as the table
   narrowed and every column to its right drifted left of the EJS one (44px at 900px, the pane gate's G18).
   Hold the cell to the width its content earns on the EJS row. */
#root .detail-pane tr.inline-new-tx-row .pf-price-edit { min-width: 234px; }
.label-truncated .label-full { display: none; }
.label-truncated .label-short { display: inline; }
.card .label .label-short, th .label-short { display: none; }
.card.label-truncated .label-full, th.label-truncated .label-full { display: none; }
.card.label-truncated .label-short, th.label-truncated .label-short { display: inline; }

  .card .value { font-size: 22px; font-weight: 700; margin-top: 4px; }
  .pos { color: var(--green); } .neg { color: var(--red); }
  .flexlog-warn { color: #d9a03f; } /* amber: Flex request in progress / busy */
  table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 14px; }
  /* Horizontal scroll confinement: wrap wide tables in .table-scroll — the TABLE scrolls,
     not the page (nav/summary stay put). min-width on the table keeps columns readable
     instead of squeezing; scrollbars appear only when needed. */
  .table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
  /* Safari: with border-collapse:collapse, sticky first-column cells do not paint
     above the cells sliding under them (text overlaps). separate + spacing 0 is
     visually identical here (only bottom borders are used) and fixes the order. */
  .table-scroll table { border-collapse: separate; border-spacing: 0; }
  /* Dark-theme scrollbars: thin, border-colored thumb (lightens on hover), invisible
     track — reads as a floating position indicator, not a gutter. Applies to table
     wrappers AND the page bar so the chrome is coherent. */
  .table-scroll::-webkit-scrollbar { height: 8px; width: 8px; }
  .table-scroll::-webkit-scrollbar-track { background: transparent; }
  .table-scroll::-webkit-scrollbar-thumb {
    background: color-mix(in srgb, var(--muted) 40%, transparent); border-radius: 4px; }
  .table-scroll::-webkit-scrollbar-thumb:hover {
    background: var(--muted); }
  .table-scroll::-webkit-scrollbar-corner { background: transparent; }
  .table-scroll { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
  .table-scroll table { margin: 0; }
  .table-scroll > table { min-width: 640px; }
  /* A table of facts (label / value) is not a data grid: it fits the card it is in, and a 640px floor would
     force a sideways scroll for two short columns. */
  .table-scroll > table.fit { min-width: 0; }
  /* Sticky first column inside a scroll wrapper: ticker stays visible while columns scroll.
     Scoped to the wrapper's OWN table (`> table >`): a pane nested inside an expanded row is a
     DESCENDANT of the outer wrapper, so a bare descendant selector pinned the pane's first column
     and painted it opaque panel inside an already-tinted pane (the Share Lots "Acquired" column
     read as a white lane while every other column showed the pane's tint). Only the table the
     wrapper itself scrolls gets a pinned first cell. */
  .table-scroll > table > thead > tr > th:first-child,
  .table-scroll > table > tbody > tr:not(.detail-row) > td:first-child {
    position: sticky; left: 0; z-index: 2;
    background: var(--panel);
  }
  /* While scrolled: cover the 2-4px sliver right of the sticky edge — right-aligned
     text in later cells ends just past the sticky cell's right edge and its last digit
     would otherwise peek out as the columns slide under. Only applied while actually
     scrolled (sticky is at its natural slot at rest; the strip would just paint a
     phantom band over the next column's padding). */
  .table-scroll.is-scrolled > table > thead > tr > th:first-child,
  .table-scroll.is-scrolled > table > tbody > tr:not(.detail-row) > td:first-child { box-shadow: 6px 0 0 0 var(--panel); }
  /* Expanded detail rows: the colspan td must NOT be sticky (it spans the full width;
     pinning it slides the whole pane over the pinned rows) and its own bg/hover tint
     must paint over the row above it normally. */
  .table-scroll tr.detail-row > td:first-child { position: static; box-shadow: none; }
    .detail-row > td:first-child { box-shadow: inset 3px 0 0 var(--accent) !important; }
  /* Row hover is OPT-IN at BOTH levels: the table marks itself .row-hover and each actionable row
     marks itself .row-action. A row with no action therefore cannot tint even by accident. The whole row
     tints at ONE strength in every state — hover, expanded, and the pane below it — and the sticky
     first cell composites that tint over panel so it stays opaque (later cells slide under it). A table with no row action does not opt in and therefore
     has no hover at all — the old global rule tinted only the first cell, which left the rest of
     the row unhighlighted and made interacting rows look half-painted. */
  .row-hover > tbody > tr.row-action:hover > td,
  .row-hover > tbody > tr.row-action.expanded > td { background: var(--accent-tint-pane); }
  /* The sticky first cell composites the same tint OVER panel instead of taking the flat colour: it has to stay
     opaque (later columns slide under it), and a flat tint would read as a lighter lane than the row it belongs
     to. The gradient is the tint; the panel under it is what keeps the column from bleeding. */
  .row-hover > tbody > tr.row-action:hover > td:first-child,
  .row-hover > tbody > tr.row-action.expanded > td:first-child {
    background: linear-gradient(var(--accent-tint-pane), var(--accent-tint-pane)), var(--panel);
  }
  /* Expanded pane: the CELL paints the tint and the left accent bar (a background on the div starts
     8px below its box), the pane supplies the horizontal inset, and the inner table is not sticky. */
  .pane-row > td { background: var(--accent-tint) !important; box-shadow: inset 3px 0 0 var(--accent); }
  .pane-cell { padding: 0; }
  .pane { padding: 0 14px; background: transparent; box-shadow: none; }
  /* A pane's wrapper is `.pane` by default, but a page can name its own: the dividends history panes use
     `.div-history` / `.div-year-history`, and the list page's ticker panes are `.detail-pane`. The
     strike-through-the-pane rules have to follow the wrapper, or the nested table keeps the sticky first
     column and its panel stripe inside a tinted cell.
     These selectors mirror the sticky rule's own shape (`> table > thead/tbody > tr > th/td`) and add the
     pane's wrapper class, so they carry exactly the same specificity and win on source order — a pane's
     table therefore never paints panel and never grows the 6px cover strip. The ticker pane keeps its pin
     for a layout reason, spelled out below. */
  .pane .table-scroll > table > thead > tr > th:first-child,
  .pane .table-scroll > table > tbody > tr > td:first-child,
  .pane .table-scroll.is-scrolled > table > thead > tr > th:first-child,
  .pane .table-scroll.is-scrolled > table > tbody > tr > td:first-child,
  .div-history .table-scroll > table > thead > tr > th:first-child,
  .div-history .table-scroll > table > tbody > tr > td:first-child,
  .div-history .table-scroll.is-scrolled > table > thead > tr > th:first-child,
  .div-history .table-scroll.is-scrolled > table > tbody > tr > td:first-child,
  .div-year-history .table-scroll > table > thead > tr > th:first-child,
  .div-year-history .table-scroll > table > tbody > tr > td:first-child,
  .div-year-history .table-scroll.is-scrolled > table > thead > tr > th:first-child,
  .div-year-history .table-scroll.is-scrolled > table > tbody > tr > td:first-child { position: static; background: transparent; box-shadow: none; }
  .detail-pane .table-scroll > table > thead > tr > th:first-child,
  .detail-pane .table-scroll > table > tbody > tr > td:first-child,
  .detail-pane .table-scroll.is-scrolled > table > thead > tr > th:first-child,
  .detail-pane .table-scroll.is-scrolled > table > tbody > tr > td:first-child { position: static; background: transparent; box-shadow: none; }
  /* …except the TRANSACTIONS pane, whose first cell has to stay a positioned containing block: its row puts
     the select checkbox there with `position: absolute`. Static dropped every checkbox to the pane's top edge
     instead of its row's first line — invisible in a screenshot, because the column only paints once a row is
     selected. It keeps the pin and gives up only the paint: no panel fill, no 6px strip. The other panes
     (Share Lots, dividends) pin nothing, which is what their columns' tint depends on. */
  .detail-pane[data-pane="transactions"] .table-scroll > table > thead > tr > th:first-child,
  .detail-pane[data-pane="transactions"] .table-scroll > table > tbody > tr > td:first-child,
  .detail-pane[data-pane="transactions"] .table-scroll.is-scrolled > table > thead > tr > th:first-child,
  .detail-pane[data-pane="transactions"] .table-scroll.is-scrolled > table > tbody > tr > td:first-child { position: sticky; left: 0; z-index: 2; background: transparent; box-shadow: none; }
  /* Expansion chevron: owned by the component, not by the pages that use it. It was declared in
     dividends.ejs and list.ejs, so the gallery's generic rows had no rotation at all. */
  .chev { display: inline-flex; align-items: center; }
  .chev svg { transition: transform .15s ease; }
  .row-action.expanded .chev svg { transform: rotate(180deg); }
  @media (prefers-reduced-motion: reduce) { .chev svg { transition: none; } }
  .row-hover > tbody > tr.row-action:hover > td:first-child {
    background: linear-gradient(var(--accent-tint-pane), var(--accent-tint-pane)), var(--panel);
  }
  .row-hover > tbody > tr.row-action.expanded > td:first-child {
    background: linear-gradient(var(--accent-tint-pane), var(--accent-tint-pane)), var(--panel);
  }
  /* No wrapping inside scroll-confined tables: with horizontal scroll available there's
     no reason to fold text — every cell keeps one line and the table grows rightward.
     td/th white-space: nowrap; long content (notes, emails) is handled by the table's
     own min-width + scroll, not by folding. */
  .table-scroll th, .table-scroll td { white-space: nowrap; }
  /* The component's header is separated from its body by one divider, on every table it renders. */
  .data-table thead th { border-bottom: 1px solid var(--border); }
  /* Compact: the selection totals strip's own scale (10px uppercase labels, 2-3px padding), applied
     to the same component so the strip cannot drift from the tables it summarises. */
  .data-table.compact th { font-size: 10px; letter-spacing: 0.05em; text-transform: uppercase; font-weight: 600; padding: 2px 8px; }
  .data-table.compact td { padding: 3px 8px; font-variant-numeric: tabular-nums; }
  /* A table whose cells are prose (job descriptions, paths) opts out: it wraps and the page does
     not gain a horizontal scrollbar just because the component's default is one line per cell. */
  .table-scroll.wrap-cells td { white-space: normal; }
  /* Headers are one line by default, whatever the cells do: a wrapped header ("Realized in
     quarter", "CA EST (9.30%)") reads as two columns. The component opts a table back in with
     headWrap: true, which adds .wrap-head. */
  .table-scroll th { white-space: nowrap; }
  .table-scroll.wrap-head th { white-space: normal; }
  /* Single-line rows: the padding is split 7/9, not 8/8, because the font carries ~1px more
     ascender space than descender — with 8/8 the ink measured 12px above and 9px below (text read
     ~1.5px low) while digits, which have no descenders, were dead centre. 7/9 puts the ink at
     11/10 for words and 11/13 for digits, i.e. it centres what the eye reads as the line.
     A wrapped row is unaffected: alignWrappedRows() pins the row's cells to the top so every first
     line stays put and only the wrapping cell extends downward. */
  .table-scroll td { padding-top: 7px; padding-bottom: 9px; }
  th, td { text-align: left; vertical-align: middle; padding: 8px 10px; }
    /* NOTE: no white-space rules on value cells — '$1.36M' and '(23.55%)' contain no spaces
     so they never split internally by default; the space between them is the break point.
     Adding nowrap here made whole cells unbreakable and overflowed narrow tables. */
  /* Row wrap behavior: rows where NO cell wraps keep vertical centering (middle) so text
     stays centered against tall widgets (⋮ buttons). Rows where a cell DOES wrap switch the
     whole row to top alignment — every other cell keeps its text on the first line (the
     main line) and only the wrapping cell extends downward. Managed by alignWrappedRows(). */
  tbody td { padding-top: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
  th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; }
  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
  form.inline { display: inline; }
  input, select { background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 8px 10px; font-size: 14px; }
  select { appearance: none; -webkit-appearance: none; padding-right: 36px; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' stroke='%238b93a7' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>"); background-repeat: no-repeat; background-position: right 10px center; }
  label { display: block; color: var(--muted); font-size: 12px; margin: 10px 0 4px; }
  /* Standard section panel card — use for any page-level content section */
  .panel-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px 24px; margin-top: 24px; }
  .panel-card > h2:first-child, .panel-card > div:first-child > h2:first-child { margin-top: 4px; }
  /* Standard form grid. Every control inside a .field fills its cell and is pinned to the same
     38px box: text, number, date and the custom select otherwise default to different widths and
     heights (measured: date 38, text 34, .sel-btn 35; widths 110-219). A form opts in with
     <form class="form-grid"> and labels each control <label class="field">. */
  .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px 14px; align-items: end; }
  .field { display: flex; flex-direction: column; gap: 4px; margin: 0; min-width: 0; }
  /* A field's own label: the column supplies the spacing (gap), so the global `label` margin would
     double it. Same type as every other label. */
  .field > label { margin: 0; color: var(--muted); font-size: 12px; }
  .field > label > .req, .req { color: #f87171; }
  /* Stacked fields keep the form's rhythm; a grid supplies its own gap, so its fields opt out. */
  .field + .field { margin-top: 10px; }
  .form-grid > .field { margin-top: 0; }
  .field-hint { margin: 0; }
  .field-error { color: var(--red); }
  .field > input, .field > select, .field > textarea,
  .field > .sel, .field > .sel > .sel-btn { width: 100%; box-sizing: border-box; }
  .field > input, .field > select, .field > .sel > .sel-btn { height: 38px; }
  .field > .sel { display: block; }
  /* A control that shares its field with something beside it (a select and its hint): the row is the
     field's child, and the control takes the space the hint leaves. */
  .field-row { display: flex; align-items: center; gap: 10px; }
  .field-row > .sel { flex: 1 1 auto; min-width: 0; }
  .field-row > .sel > .sel-btn { width: 100%; }

  /* Feedback rows (Settings → Feedback AND Admin → Feedback — the same partial renders both, so these
     cannot live in either page's <style>: scoped to #tab-feedback they left the admin table without a
     pointer cursor or a rotating chevron). */
  tr.fb-row { cursor: pointer; }
  tr.fb-row.expanded .chev svg { transform: rotate(180deg); }
  .fb-detail > .pane-cell { background: rgba(0,0,0,0.15) !important; box-shadow: none !important; padding: 12px 14px; }
  .fb-detail .pane { padding: 0; }

  /* A control that sits inside a sentence — dev-only affordances and prose links, where a 35px box would
     push the line apart. */
  .btn-inline { min-height: 0; height: auto; padding: 2px 8px; border: 1px solid var(--border); border-radius: 6px; display: inline-block; }

  /* Inline code in documentation prose. Declared in the layout because the gallery's API blocks use it
     too — when it lived in api-reference.ejs the gallery's code chips were unstyled. */
  .api-mono { font-family: monospace; font-size: 12px; color: var(--text); }

  /* Price entry is currency-first — the picker says what the number means. The picker belongs to
     Show Details, so the pane hides it (.no-details) and the price field takes the whole cell.
     These live in the layout, not in list.ejs: the markup is owned by partials/ticker-detail.ejs, and a
     class declared in one page's <style> stops existing the moment that partial renders anywhere else. */
  .pf-price-edit { display: flex; gap: 4px; align-items: center; font-size: 13px; line-height: normal; }
  /* the app decorates every <select> into span.sel (native select hidden), so the FLEX ITEM is .sel */
  /* The picker is content-sized, not a fixed 58px: the chevron sits a fixed distance from the button's
     right edge, so a fixed box gives wide codes ("USD") less air than narrow ones ("JPY"). Sized to
     its text, every code keeps the same gap to the arrow. */
  .pf-price-edit .sel { flex: 0 0 auto; min-width: 0; }
  .pf-price-edit .sel .sel-btn { width: auto; height: 35px; box-sizing: border-box; padding: 0 30px 0 8px; border-radius: 6px; }
  .pf-price-edit .pf-price-inp { flex: 1 1 auto; min-width: 0; width: auto; box-sizing: border-box; padding: 1px 4px; margin: 0; height: 35px; }
  .pf-price-edit .pf-fx-inp { flex: 0 0 92px; min-width: 0; box-sizing: border-box; padding: 1px 4px; margin: 0; height: 35px; text-align: right; font-variant-numeric: tabular-nums; }
  /* The currency picker is NOT hidden by Show details: the price cell needs to say what its number means
     whether or not the fees column is showing (the pane's own rule hid it, and the page never applied the
     class before the reader asked, so it was never hidden in practice). */
  /* The Fees column is the other half of what Show details reveals — hidden as a column, so the remaining
     columns keep their width instead of reflowing when the reader toggles it. */
  /* The Fees COLUMN follows Show details — but not the add row's own cell, which stays usable: you cannot
     enter a fee into a hidden field, and that is the documented exception. A closed add row is hidden as a
     row, so its cell reads 'none' too, which is what the pane's "fees start hidden" check expects. */
  /* The pane's table carries the EJS pane's own metrics, not the page table's: 13px text, full bleed to the
     pane's edge (the cell inset comes from the detail row), 7/10/9 cell padding and an 8px bottom margin. The
     SPA was inheriting the 14px/3px-8px page-table numbers inside a 12/14 wrapper, which shifted every column
     right by 46px and squeezed the table by 28. */
  #root .detail-pane table { font-size: 13px; margin-bottom: 8px; }
  #root .detail-pane table > thead > tr > th { padding: 8px 10px; font-size: 12px; }
  #root .detail-pane table > tbody > tr > td { padding: 7px 10px 9px; }
  #root .detail-pane .pane-pagebar { margin: 8px 0 4px; gap: 12px; }
  .detail-pane.no-details th.c-fees,
  .detail-pane.no-details tr.tx-row td.c-fees { display: none; }
  .detail-pane.no-details .inline-new-tx-row.is-hidden .c-fees { display: none; }
  /* Split rows are the other half of Show details: a split is a restatement of the position, not a trade the
     reader asked to see, so it stays out of the way until they ask. */
  .detail-pane.no-details tr.tx-row[data-type="split"] { display: none; }
  /* The add-transaction row is a form, not a reading row: its cells are 4px taller on each edge and its inputs
     match the EJS inline form's own inline styles (35px tall, 2px/4px padding). Both rules are page-local in
     list.ejs, so the SPA gets its own copy — without them the row stood 8px taller than EJS's and the date
     field started at the wrong x. */
  /* While a transaction is being typed, the two gain columns are about other rows: EJS hides them with
     `tx-table-hide-gains` (page-local), which is what makes its add row the same eight columns as its rows. */
  #root .tx-table-hide-gains > thead > tr > th:nth-child(7),
  #root .tx-table-hide-gains > thead > tr > th:nth-child(8),
  #root .tx-table-hide-gains > tbody > tr > td:nth-child(7),
  #root .tx-table-hide-gains > tbody > tr > td:nth-child(8) { display: none; }
  #root .detail-pane tr.inline-new-tx-row > td { vertical-align: middle; padding-top: 4px; padding-bottom: 4px; }
  #root .detail-pane tr.inline-new-tx-row input,
  #root .detail-pane tr.inline-new-tx-row select {
    box-sizing: border-box; margin: 0; height: 35px; padding: 2px 4px; width: 100%;
  }
  /* …except the price field, which sits in the price cell's flex row beside the currency picker and, on the EJS
     row, carries no width of its own: it keeps its intrinsic ~166px, and that intrinsic width is the whole of
     the price column's size. Claiming 100% here shrank the column by 50px and redistributed every other one. */
  #root .detail-pane tr.inline-new-tx-row .pf-price-edit .pf-price-inp { width: auto; }
  /* A required field with nothing in it reads red while the add row is open, exactly as it does on the EJS row
     (views/list.ejs paints every `input:invalid` on the list page). The rule is page-local there, so the SPA
     gets its own copy scoped to #root — its mount — rather than a global one that would newly mark the fields
     of every other page's forms. */
  #root .detail-pane tr.inline-new-tx-row input:invalid { border-color: rgba(248, 113, 113, 0.6) !important; }
  /* The type picker is a decorated select on both sides (the EJS enhancer wraps its native <select>), so it
     keeps the enhancer's shape: 12px text, 35px tall, and a right inset wide enough for the chevron — the
     plain-input rule above had squeezed it to 32px of text and dropped its font back to 14. */
  #root .detail-pane tr.inline-new-tx-row .sel-btn {
    box-sizing: border-box; margin: 0; height: 35px; font-size: 12px; padding: 0 36px 0 10px;
  }
  /* The currency picker is a narrower control than the type picker on the EJS row (0/30/0/8 against 0/36/0/10 —
     the enhancer reserves room per its longest option), and that 8px is what the price column's width is made
     of: with the wider padding the price field was 9px short and the whole row was laid out differently. */
  #root .detail-pane tr.inline-new-tx-row .sel-btn[id^="cur-pick"] { padding: 0 30px 0 8px; }
  /* The add row is hidden with the `hidden` attribute, and this rule makes that stick: a class rule on the row
     (display: table-row) beats the user-agent's [hidden] { display: none }, so the row stayed visible. */
  .inline-new-tx-row.is-hidden { display: none; }
  /* Paging a pane hides its overflow rather than removing it: the pager is a view over the rows, and a
     table that rewrote itself on every page would lose the selection and the editing row. */
  tr.pane-hidden { display: none; }
  /* The bar used to be FIXED here (top 60px, 1200px wide, centred) because it lived inside the pane's own cell,
     where sticky cannot pin. It is <main>'s first child now, exactly as views/list.ejs mounts it, so the page's
     own sticky rule (further down) applies and this workaround is gone. */

  .form-grid > button, .form-actions > button { height: 38px; box-sizing: border-box; }
  .form-grid > button { justify-self: start; }
  .form-actions { display: flex; gap: 10px; grid-column: 1 / -1; }
  .form-actions > a.secondary { display: inline-flex; align-items: center; height: 38px; box-sizing: border-box; }
  .error { background: #3a1f22; border: 1px solid var(--red); color: #ffb4b0; padding: 10px 14px; border-radius: 8px; margin: 12px 0; font-size: 14px; }
  .toast { position: fixed; bottom: 24px; right: 24px; background: var(--green); color: #06130c; padding: 10px 18px; border-radius: 8px; font-weight: 600; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
  /* Send-feedback launchers (admin picks bubble or edge tab in Admin → Feedback):
     bubble = bottom-right accent circle; edge tab = vertical label on the left screen edge.
     The toast lifts above the bubble so "✓ Feedback sent" never lands on top of it. */
  .toast { transition: bottom 0.3s, opacity 0.3s; }
  body.fb-bubble-on .toast.show, body.fb-bubble-on #toast.show { bottom: 82px; }
  #fb-bubble { position: fixed; right: 22px; bottom: 22px; width: 46px; height: 46px; border-radius: 999px;
    background: var(--accent); color: #fff; border: none; cursor: pointer; padding: 0;
    box-shadow: 0 4px 16px var(--scrim); display: flex; align-items: center; justify-content: center; z-index: 80; }
  #fb-bubble:hover { filter: brightness(1.12); }
  #fb-edge-tab { position: fixed; left: 0; top: 55%; transform: translateY(-50%); writing-mode: vertical-rl;
    background: var(--panel); color: var(--muted); border: 1px solid var(--border); border-left: none;
    border-radius: 0 8px 8px 0; padding: 12px 6px; font-size: 11px; letter-spacing: .4px; cursor: pointer; z-index: 80; }
  #fb-edge-tab:hover { color: var(--text); filter: brightness(1.25); }
  .toast.show { opacity: 1; }
  .flag { color: var(--red); font-weight: 600; }
  /* Rows-per-page control, shared by every pager in the app (the transactions pane and the admin
     tables). One definition here so the two cannot drift. */
  .pane-pagebar { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin: 8px 0 4px; }
  .pane-pagesize { display: flex; align-items: center; gap: 6px; }
  .pane-pagesize-btn { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; font-size: 12px; font-family: inherit; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); cursor: pointer; }
  .pane-pagesize-btn:hover { border-color: var(--accent); }
  /* A badge's text is centred by the box, not by font metrics: an inline-block leans on its baseline,
     so a 12px uppercase label in a 20px pill sat ~0.5px high and the pill read as off-centre down the
     row (worst in the users table, where the role pill sits beside 13px digits). inline-flex with an
     explicit height and line-height 1 puts the glyphs at the centre of the pill by construction. */
  /* A badge labels a VALUE, so it takes the row's own size (`.t-dense`, 14px): at 12px it was the table
     HEADER's size, which made the smallest text in the row the value itself and left its letters
     visibly smaller than the digits beside it. */
  /* A badge is inline text with a background, so it sits on the text's baseline: `vertical-align:
     middle` centred the pill's box in the row instead, which left its label 1.5px below the text in
     the cells beside it (the row's ink is not centred in its line box — see the 7/9 cell padding). */
  .tag { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; height: 20px; text-align: center; text-wrap: balance; line-height: 1; vertical-align: baseline; background: var(--border); border-radius: 5px; padding: 0 7px; font-size: 14px; color: var(--muted); }
  /* A badge taller than the text's line box grows its row, which pushes that row's baseline below
     its neighbours' (measured: 20.2px badge in a 16.5px line box -> 33px row becomes 37.2px).
     Shrinking the badge fixes the row but changes the design, so instead the badge keeps its size
     and declares a NEGATIVE vertical margin: the line box sizes to the margin box (16.2px), the
     visible pill still paints at 20.2px and simply overhangs the line by ~2px into the cell's
     padding. Same look, same row height, same baseline. Scoped to any table CELL, not one table
     class: the admin users table has no class and was the last place the badge still grew its row. */
  /* On the baseline the pill's 20px would grow the line box by 4px (and every row with it), so the
     vertical margins cancel it back to the text's own 16px: the pill keeps its size, overhangs the
     line symmetrically, and its label stays level with the cells beside it. */
  td .tag, .holdings-table .tag { margin-top: -2px; margin-bottom: -2px; }
  a { color: var(--accent); text-decoration: none; }
  .muted { color: var(--muted); }
  .env-badge { display: inline-flex; align-items: center; background: #b45309; color: #fff; border-radius: 5px; padding: 2px 8px; font-size: 11px; font-weight: 700; letter-spacing: .06em; cursor: default; }
  .env-badge-fixed { position: fixed; top: 10px; right: 12px; z-index: 9999; margin-left: 0; }
  /* Animated logo: SVG overlay on logo-anim.png; viewBox matches PNG's 1696×215 px so pupil
     sizes are plain integers in the same coordinate space — no percentage-of-container math. */
  .logo-lockup { position: relative; display: inline-flex; }
  /* The wordmark's period was erased from the PNGs and re-drawn as a themed dot —
     positioned as a percentage of the lockup so it scales with the image size. */
  .pf-period { position: absolute; left: 82.3%; width: 1.55%; top: 74.4%; height: 11.2%; background: var(--accent); border-radius: 14%; }
  .logo-eyes { position: relative; display: inline-flex; }
  .logo-eyes .pf-pupil, svg .pf-pupil { fill: var(--accent); }
  .logo-eyes .pf-pupil { transform-box: fill-box; }
  .logo-eyes .pf-lid { transform-box: fill-box; transform-origin: 50% 0%; transform: scaleY(0); transition: transform 100ms ease-in; }
  .logo-eyes .pf-lid.pf-opening { transition: transform 140ms ease-out; }
  .sel { position: relative; display: inline-block; }
  .sel-btn { display: flex; align-items: center; justify-content: space-between; gap: 10px;
    background: transparent; border: 1px solid var(--border); border-radius: 6px; color: var(--text);
    font-size: 14px; padding: 8px 10px; padding-right: 36px; cursor: pointer; min-width: 0;
    position: relative; }
  /* Merged sort segment (.seg wraps the sort dropdown + direction arrow): the GROUP owns the
     border and rounding — .sel-btn inside it goes borderless/square so no inner rounded
     corners or double borders show. overflow stays VISIBLE (no overflow:hidden for corner
     rounding — it clips the open menu to the 35px segment: "not all options, covers arrow").
     The menu anchors to the GROUP (.sel static → .seg relative) at full group width. */
  .seg { overflow: visible; position: relative; }
  .seg .sel { display: inline-flex; position: static; }
  .seg .sel-btn { border: none; border-radius: 0; height: 100%; }
  /* overflow:visible removed the clipping that used to round the RIGHT corners — the arrow
     cell (radius 0) must round itself on the group's right edge or the corners look square. */
  .seg #sort-direction, .seg .seg-dir { border-radius: 0 6px 6px 0; }
  /* A picker's menu is position:fixed (PFOverlay), so a percentage width resolves against the
     VIEWPORT, not the control: `width: 100%` here made the sort dropdown open 1400px wide. Its width
     is set in px from the button when it opens, and the content decides the rest. */
  .seg .sel-menu { min-width: 0; }
  .sel-btn::after { content: ''; position: absolute; right: 12px; top: 50%; width: 12px; height: 8px;
    margin-top: -4px; transform: rotate(0deg); transition: transform 0.18s ease;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' stroke='%238b93a7' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
    background-repeat: no-repeat; background-position: center; }
  .sel.open .sel-btn::after { transform: rotate(180deg); }
  /* No position/top/left here: PFOverlay positions every menu as a fixed overlay at
     open time. A menu that positions itself is only safe while nothing above it clips. */
  /* ── The row-level ⋯ menu ─────────────────────────────────────────────────────
     One action menu for a row's own actions (a holding's Show details/Delete, a connection's sync,
     log, edit and remove). The trigger is a plain .ghost ⋯ - the same control as the page-level
     list menu - and only reads as pressed while its menu is open. A menu choice closes the menu:
     see the data-menu binder, which hides on any .pane-menu-item click. */
  .pane-menu { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; min-width: 196px; padding: 4px 0; box-shadow: 0 8px 30px var(--scrim); }
  .pane-menu-btn[aria-expanded="true"] { background: var(--accent-tint-soft); border-color: var(--accent); color: var(--text); }
  .pane-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 14px; margin: 0; background: none; border: none; color: var(--text); font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
  .pane-menu-item:hover { background: var(--accent-tint); }
  .pane-menu-item svg { flex: 0 0 auto; opacity: .85; }
  .pane-menu-item input[type="checkbox"] { margin: 0; accent-color: var(--accent); }
  .pane-menu-danger { color: var(--red); }
  .sel-menu { display: none; min-width: 0;
    background: var(--panel); border: 1px solid var(--border); border-radius: 8px; z-index: 90;
    box-shadow: 0 8px 30px var(--scrim); padding: 4px 0; max-height: 320px; overflow-y: auto; }
  .sel.open .sel-menu { display: block; }
  .sel-opt { padding: 7px 14px; font-size: 14px; color: var(--text); cursor: pointer; white-space: nowrap; }
  .sel-opt:hover { background: var(--accent-tint-strong); }
  .sel-opt.sel-active { background: var(--accent); color: #fff; }
  /* ── Copy affordance ──────────────────────────────────────────────────────────
     One implementation, like the confirm modal: a `.copy-row` puts the button beside the field, and any
     element with class js-copy copies the element named by its data-copy attribute. The fallback matters
     more than the API here — this app is served over plain HTTP on a LAN, where navigator.clipboard does
     not exist — so the handler selects the element and uses the browser's own copy command instead. */
  .copy-row { display: flex; gap: 8px; align-items: flex-start; }
  .copy-row > input, .copy-row > pre { flex: 1 1 auto; min-width: 0; width: 100%; }
  .copy-row > .ghost, .copy-row > .btn { flex: 0 0 auto; padding: 4px 10px; white-space: nowrap; }
  .copy-row + .copy-row { margin-top: 6px; }
  /* Monospace blocks hold machine text: it scrolls rather than reflowing mid-token. */
  pre.copyable, .api-example { margin: 0; padding: 10px; overflow-x: auto; white-space: pre;
    background: var(--panel); border: 1px solid var(--border); border-radius: 8px; font-size: 12px; }
  /* An absolute size, not 0.95em: em makes the same chip render at a different size in every
     context (12.35px in a 13px paragraph, 15.2px in a 16px one), which is not one style. */
  code { font-family: monospace; font-size: 12px; background: var(--panel); border: 1px solid var(--border);
    border-radius: 4px; padding: 0 3px; }
  /* A timestamp is a fact with a tooltip, not a control: the cursor says so. */
  time[data-utc] { cursor: help; }
  /* ── The type scale ───────────────────────────────────────────────────────────────
     One size per role, named, declared once. Sizes used to be set on individual elements —
     140+ inline font-sizes across three views alone, and fourteen distinct values including
     11.5/12.5/14.5px — so the same kind of text could render at a different size on every
     page and nothing could be changed globally. Declared last on purpose: a class here ties
     with another class rule (0,1,0) and wins on source order, which is what makes it safe to
     put on an element that already carries one. */
  .t-title { font-size: 22px; }   /* a control that IS the page title (the list switcher) */
  .t-h2    { font-size: 17px; }   /* a modal or panel title (the layout's own h2 scale) */
  .t-lead  { font-size: 16px; }   /* intro prose above a card */
  .t-h3    { font-size: 15px; }   /* a sub-heading inside a card */
  .t-dense { font-size: 14px; }   /* dense rows, table bodies, menu items */
  .t-body  { font-size: 13px; }   /* prose, list items, form labels */
  .t-small { font-size: 12px; }   /* captions, small print, table headers */
  .t-tiny  { font-size: 11px; }   /* annotations */
  .t-micro { font-size: 10px; }   /* the info glyph and similar */

  /* ---- the auth card ----------------------------------------------------------------------------
     Login and register, in both front ends. These rules used to live in an inline <style> inside
     views/partials/auth-style.ejs — the only way to style a page that stood alone — until the SPA needed
     the same card. A React login with its own copy of this CSS is the drift this file exists to prevent.
     `.login-card .btn { width: 100% }` is why the SPA's auth buttons use the shared Button component
     rather than a local one. */

  /* main's top+bottom margin, subtracted: this wrap is the whole viewport, so the card centres in
     the VIEWPORT rather than in a box that starts 24px below the top of it. */
  /* Two heights on purpose. `vh` is the LARGEST viewport on a phone, so with the URL bar showing the
     card centres in space that is not on screen and reads as sitting low. `dvh` is the height that
     is actually visible, and browsers that know it override the line above. On desktop the two are
     identical, so this changes nothing there. */
  .login-wrap { min-height: calc(100vh - 2 * var(--page-gutter)); min-height: calc(100dvh - 2 * var(--page-gutter)); display: flex; align-items: center; justify-content: center; padding: 24px; }
  .login-card { width: 100%; max-width: 400px; background: var(--panel); border: 1px solid var(--border);
    border-radius: 14px; padding: 32px; box-shadow: 0 20px 60px var(--scrim); }
  /* Auto-widens for the DEV pill. The 400px floor and the cap are both relative to the
     available width, so a narrow viewport shrinks the card (and with it the logo) instead of
     overflowing it. */
  .login-card.with-dev-badge { width: fit-content; min-width: min(400px, 100%); max-width: 100%; }
  .login-logo { display: flex; justify-content: center; align-items: center; gap: 10px; margin: 0 auto 18px; max-width: 100%; }
  .login-logo .env-badge { font-size: 12px; padding: 3px 10px; flex: 0 0 auto; }
  /* The wrapper is the sizer and owns the logo's aspect ratio, so the SVG overlay (inset:0,
     100%x100%) always maps the 1696x215 viewBox onto a box of the same shape and the eyes stay
     registered to the artwork. Sizing the <img> instead lets the box lose that ratio, and the
     overlay then letterboxes on its own and slides off the letters. */
  .login-logo .logo-eyes { width: calc(44px * 1696 / 215); max-width: 100%; aspect-ratio: 1696 / 215; flex: 0 1 auto; min-width: 0; }
  .login-logo .logo-eyes img { width: 100%; height: 100%; }
  .logo-eyes { position: relative; display: block; }
  .logo-eyes .pf-pupil { transform-box: fill-box; }
  .logo-eyes .pf-lid { transform-box: fill-box; transform-origin: 50% 0%; transform: scaleY(0); transition: transform 100ms ease-in; }
  .logo-eyes .pf-lid.pf-opening { transition: transform 140ms ease-out; }
  .login-title { text-align: center; font-size: 20px; font-weight: 700; margin: 0 0 4px; }
  .login-sub { text-align: center; color: var(--muted); font-size: 13px; margin: 0 0 26px; }
  .login-card label { display: block; font-size: 12px; color: var(--muted); margin: 0 0 6px; letter-spacing: 0.5px; }
  .login-card input { width: 100%; margin-bottom: 16px; }
  .login-card input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-tint-strong); }
  .login-card .btn { width: 100%; height: 40px; font-size: 15px; font-weight: 700; margin-top: 6px; }
  .login-foot { text-align: center; margin: 20px 0 0; font-size: 13px; }
  .login-foot a { color: var(--accent); text-decoration: none; }
  .login-foot a:hover { text-decoration: underline; }

  /* ── Bracket ladders (views/bracket_viz.ejs, and the SPA's BracketViz) ── */
  .vbracket { margin: 0 0 22px; padding-right: 10px; }
  .vbracket-title { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
  .vbracket-sub { margin-top: 3px; }
  .vbracket-head { display: grid; grid-template-columns: 10px minmax(max-content, 1.4fr) minmax(0, 2fr) 110px 70px; gap: 10px;
  font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; padding: 0 0 5px; }
  .vbracket-row { display: grid; grid-template-columns: 10px minmax(max-content, 1.4fr) minmax(0, 2fr) 110px 70px; gap: 10px;
  align-items: center; padding: 3px 0; border-radius: 5px; }
  .vbracket-row:hover { background: var(--accent-tint); }
  .vbracket-row-empty { color: var(--muted); }
  .vbracket-row-empty .vbracket-range { color: var(--muted); }
  .vbracket-row-here { background: var(--accent-tint); }
  /* The bracket column is sized to its WIDEST label — badge included — so the label stays on one line
  beside its bracket and nothing reaches the divider. The bar track is the one that gives up space
  (minmax(0, 2fr)), so a narrow window pinches the bars rather than the text. */
  .vbracket-here { display: inline-block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px;
  border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; color: var(--muted); margin-left: 4px;
  white-space: nowrap; vertical-align: 1px; }
  .vbracket-color { width: 10px; height: 22px; border-radius: 3px; }
  .vbracket-range { font-size: 12px; color: var(--text); white-space: nowrap; }
  .vbracket-barwrap { display: flex; align-items: center; gap: 8px; min-width: 0; }
  .vbracket-bar { height: 18px; border-radius: 4px; min-width: 3px; }
  .vbracket-bar-empty { width: 3px; background: var(--border); min-width: 3px; }
  /* the 0% standard-deduction band: deliberately not a bracket colour, with a rule
  under it marking where the actual brackets begin */
  .vbracket-row-deduction { border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-bottom: 4px; }
  .vbracket-row-deduction .vbracket-range { color: var(--muted); }
  .vbracket-bar-ded { background: var(--muted); opacity: 0.5; }
  .vbracket-note { font-size: 11px; }
  .vbracket-val { font-size: 12px; color: var(--muted); white-space: nowrap; }
  .vbracket-tax { font-size: 12px; color: var(--text); text-align: right; white-space: nowrap; }
  .vbracket-share { font-size: 12px; color: var(--muted); text-align: right; }
  .num { text-align: right; }

/* ── Storybook gallery chrome ─────────────────────────────────────────────────────────────────────
   The previews are the app's own components; only the shell, the sidebar and the section frame are
   gallery-specific. Ported from views/storybook.ejs when the gallery moved into the SPA: two header-nav
   rules leak into any `nav a`, so the sidebar resets both and marks its selection with the tint box. */
#root .sb-shell { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: 24px; align-items: start; }
#root .sb-main { min-width: 0; }
/* One component in the panel at a time: the nav chooses it. A section carries the id its link names. */
#root .sb-section[hidden] { display: none; }
/* A deep link (#sb-field) scrolls the section to the top of the viewport, which is where the sticky header sits. */
#root .sb-section { scroll-margin-top: calc(var(--pf-hdr-h, 52px) + 16px); }
/* The demos are forms, and real forms are narrow: an input stretched across the panel proves the width rule
   and looks nothing like the app. */
#root .sb-demo { max-width: 420px; }
#root .sb-title { margin: 0 0 4px; font-size: 15px; font-weight: 600; }
/* The alignment demo's wrapped cells: table cells are nowrap unless the table opts into .wrap-cells, and this
   preview wants one deliberately wrapping cell per row. */
#root .sb-wrap-demo { max-width: 320px; white-space: normal; }
#root .sb-nav { display: block; position: sticky; top: calc(var(--pf-hdr-h, 52px) + 16px); background: var(--panel);
                border: 1px solid var(--border); border-radius: 10px; padding: 8px; }
#root .sb-nav-group { color: var(--muted); font-size: 11px; font-weight: 600; text-transform: uppercase;
                      letter-spacing: .06em; padding: 8px 10px 4px; }
#root .sb-nav-link { display: flex; align-items: baseline; gap: 10px; padding: 7px 10px; border-radius: 7px;
                     color: var(--text); text-decoration: none; font-size: 13px; line-height: 1.2; }
#root .sb-nav-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* An entry's count sits against the right edge — the reason the tests page's old jump row was useful. */
#root .sb-nav-count { margin-left: auto; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
#root .sb-nav-link.active .sb-nav-count { color: var(--accent); }
/* Two header-nav rules leaked into this sidebar and both showed: `nav a` gave every row the header's 16px right
   margin (the tint box stopped 16px short of its siblings) and `nav a.active::after` painted the header's 2px
   underline under the selected row. The sidebar marks its selection with the tint box alone. */
#root .sb-nav a { margin-right: 0; }
#root .sb-nav a::after { content: none; }
#root .sb-nav-link:hover { background: var(--accent-tint-soft); }
#root .sb-nav-link.active { background: var(--accent-tint-pane); color: var(--accent); font-weight: 600; }
/* The preview's rows behave like the real ones; make that visible and keyboard-reachable. */
#root #sb-expandable tbody > tr.row-action { cursor: pointer; }
#root #sb-expandable tbody > tr.row-action:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* Below the shell's breakpoint the sidebar becomes a horizontal scroller above the panel. This must come AFTER the
   base rules: `#root .sb-nav` inside and outside the query have equal specificity, so an earlier media block lost
   `display` to the later plain rule and the ten links stayed stacked — 290px of nav on a phone, where the page
   has one row of links. */
@media (max-width: 820px) {
  #root .sb-shell { grid-template-columns: minmax(0, 1fr); gap: 12px; }
  #root .sb-nav { position: static; display: flex; align-items: center; gap: 4px; overflow-x: auto; }
  #root .sb-nav-group { padding: 4px 6px; white-space: nowrap; }
  #root .sb-nav-link { white-space: nowrap; }
  /* A wrapping nav (the tests page: fourteen entries with counts) shows every area instead of scrolling a
     screen and a half sideways; the storybook's ten short entries keep the strip. */
  #root .sb-nav[data-wrap="true"] { flex-wrap: wrap; overflow-x: visible; gap: 2px 6px; }
  /* Fourteen entries wrap to five rows as it stands; tighter rows keep the nav from being a third of a phone
     screen before the panel starts. */
  #root .sb-nav[data-wrap="true"] .sb-nav-link { flex: 0 0 auto; padding: 5px 8px; font-size: 12px; gap: 6px; }
  #root .sb-nav[data-wrap="true"] .sb-nav-count { font-size: 11px; }
}

/* The pane's page control row: the size picker then the pager, both against the right edge. */
#root .pane-pagebar { display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin: 0 0 8px; }

/* A wrapping table pins its cells to the TOP: in a row whose cell carries two lines, a vertically centred
   neighbour puts its number halfway down the row, and the reader's eye has to hunt for it. */
.table-scroll.wrap-cells td, .table-scroll.wrap-cells th { vertical-align: top; }

/* The token table's rows are all one height whether or not a row carries the revoke control: a table where
   the actionable rows are visibly taller reads as two different tables. */
#root #tokens-table tbody tr { height: 34px; }

/* The Flex log's card sits 8px under the connection row: flush against the divider it read as part of the row. */
#root .conn-flexlog-panel { margin-top: 1px; padding: 0 0 2px; }

/* The quarterly paid trigger: a summary button that draws a box only once it holds a record. An empty one is a
   ghost label — a bordered 234px box around "Add payment" reads as an empty input, not an action. */
#root .qp-open {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  text-align: left;
  padding: 9px;
  min-height: 35px;
  border-radius: 8px;
}
#root .qp-open.is-filled {
  width: 100%;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
}
#root .qp-open.is-empty {
  width: fit-content;
  border: none;
  background: none;
  padding: 9px 0;
}
#root #quarterly-table td:last-child { vertical-align: top; }
/* views/dashboard.ejs keeps the drag handle and the up/down arrows in the DOM at all times and paints them by
   the table's own class (`.reorder-off` hides them, `.reorder-on` shows the arrows). The SPA toggled them with
   inline styles instead, which is why the handle never appeared in reorder mode once the page's inline <style>
   was dropped — the page's stylesheet is not loaded by the SPA. */
/* The holdings table on the list page carries the same handle/arrow pair (views/list.ejs renders them in every
   ticker cell and shows them only in reorder mode — the SPA rendered neither, so "Reorder tickers" changed state
   and nothing on screen). */
#root .holdings-table.reorder-off .drag-handle { display: none; }
#root .holdings-table.reorder-off .reorder-arrows { display: none !important; }
#root .holdings-table.reorder-on .reorder-arrows { display: inline-flex !important; flex-direction: column; justify-content: center; gap: 0; line-height: 1; }
#root .holdings-table.reorder-on .drag-handle { cursor: grab; }
#root .holdings-table .drag-handle { color: var(--muted); user-select: none; }
#root .holdings-table .arrow-btn { color: var(--muted); background: none; border: none; padding: 0 2px; font-size: 9px; line-height: 1; cursor: pointer; }
#root .holdings-table .arrow-btn:hover { color: var(--text); }
#root #lists-table.reorder-off .drag-handle { display: none; }
#root #lists-table.reorder-off .reorder-arrows { display: none !important; }
#root #lists-table.reorder-on .reorder-arrows { display: inline-flex !important; }
#root #lists-table.reorder-on .drag-handle { cursor: grab; }
/* No .qp-seg / .qp-amount / .qp-date geometry here: views/tax.ejs writes it all INLINE on the popover's own
   elements (26/26/40px segments, a 34px flexing date box, a 92px amount field). This stylesheet's versions of
   those numbers were invented — an 11px date that should be 14, a 38px year, a 78px amount that left the row
   32px short of the popover's right edge. Styling them here meant styling them wrong. */

/* The pay cell is top-aligned, and its line box is sized close to its text: a loose leading pushed the 14px
   first line's ink 4.4px below its box top, so equal padding read as 12.4px above against 10.4px below. */
#root #quarterly-table td.q-pay { vertical-align: top; }
/* The status cell and the paid button read on the table's own 15px line box, as views/tax.ejs's page-local rule
   gives them: an invented 1.1 leading under the status made each row 3px shorter than its twin. */

/* The SPA mounts into #root. Without a height it is only as tall as its content, so a page that centres
   something in the viewport (.login-wrap) centres it in a shorter box and the card reads 24px high. */
#root { min-height: 100vh; min-height: 100dvh; }

/* The auth page centres its card in the viewport. Its own rule subtracts the page gutter, which is right — what
   was wrong is that #root was only as tall as its content, so the wrap sat at the top of a 48px-shorter box and
   the card read 24px high. A flex column makes the wrap stretch to #root's height instead of overriding the
   gutter arithmetic. */
#root:has(> .login-wrap) { display: flex; flex-direction: column; }
#root > .login-wrap { flex: 1 0 auto; }

/* The selection bar. views/list.ejs carries this block in its page-local <style>, which the SPA never loads — so
   the SPA's bar rendered as an unstyled div: no panel, no accent border, no 10px radius, no shadow, 24px higher,
   and (being a flex child of <main>) 152px narrower than the page's. Same declarations, scoped to #root. */
#root #pf-selbar { position: sticky; top: var(--pf-hdr-h, 52px); z-index: 45; display: none;
  margin: 0 0 12px; padding: 6px 12px 8px; background: var(--panel); border: 1px solid var(--accent);
  border-radius: 10px; box-shadow: 0 6px 18px var(--scrim); font-size: 12px; }
#root #pf-selbar.on { display: block; }
#root #pf-selbar .pf-sel-caption { display: flex; align-items: center; gap: 12px; margin-bottom: 2px; }
#root #pf-selbar .pf-sel-title { font-weight: 700; white-space: nowrap; }
#root #pf-selbar .pf-sel-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
#root #pf-sel-table { width: 100%; border-collapse: collapse; margin: 0; }
#root #pf-sel-table th { font-size: 10px; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted);
  font-weight: 600; padding: 2px 8px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
#root #pf-sel-table th.num { text-align: right; }
#root #pf-sel-table td { padding: 3px 8px; font-variant-numeric: tabular-nums; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis; border-bottom: 0; }

/* Selection mode is a COLUMN state, not a different markup: hidden cells keep the table's eleven columns and
   let the last checkbox survive its own change event (an unmounted input never reports "unchecked"). */
/* visibility, not display: the cell keeps the column's width and the row's height, so a row does not change
   shape when selection starts. (display:none also collapsed the cell to zero height, which sent the absolutely
   positioned checkbox to the top of the page.) */
#root .detail-pane table:not(.is-selecting) > thead th.c-select,
#root .detail-pane table:not(.is-selecting) > tbody td.c-select-cell { display: none; }

/* The selection checkbox rides the row's first text line. It is absolutely positioned in its cell (the EJS
   page's own rules, which live in views/list.ejs's inline style block and so never reached the SPA): a native
   inline checkbox sits on the baseline and drifts off the line. */
#root .detail-pane th.c-select,
#root .detail-pane td.c-select-cell { width: 30px; padding: 0 6px 0 9px !important; vertical-align: top !important; }
#root .detail-pane .c-select input[type="checkbox"] {
  appearance: none; -webkit-appearance: none; margin: 0; padding: 0;
  /* On the row's first text line, which the pane MEASURES (`--pf-cb-top`, set from the date cell's own line
     box — views/list.ejs's alignCheckboxes). Centring in the cell (`calc(50% - 7px)`) is right only for a row
     whose date line is centred, and the whole point of the EJS measurement is that it is not: a row with a
     wrapped price cell puts the line ~3px from the cell top, one without puts it ~11px down. */
  position: absolute; top: var(--pf-cb-top, 6px); left: 9px;
  width: 14px; height: 14px; box-sizing: border-box;
  border: 1px solid var(--muted); border-radius: 3px; background-color: transparent;
  background-position: center; background-repeat: no-repeat; background-size: 9px 9px;
  display: inline-block; cursor: pointer;
}
/* The header's checkbox is measured separately: the header row has no date cell to hang off, so the EJS page
   computes `--pf-cb-top-h` from the second header's line box. */
#root .detail-pane thead th.c-select input[type="checkbox"] { top: var(--pf-cb-top-h, 6px); }
#root .detail-pane .c-select input[type="checkbox"]:checked,
#root .detail-pane .c-select input[type="checkbox"]:indeterminate {
  background-color: var(--accent); border-color: var(--accent);
}
/* The mark itself, as views/list.ejs draws it: a background SVG, not a ::after glyph — pseudo-element content
   changes an inline-level control's baseline, which is what made checked boxes sit 1px lower than unchecked ones.
   Without these the SPA's checked box was a plain accent square. */
#root .detail-pane .c-select input[type="checkbox"]:checked {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 6.4l2.4 2.4 4.6-5' fill='none' stroke='%23fff' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
#root .detail-pane .c-select input[type="checkbox"]:indeterminate {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Crect x='2.4' y='5.3' width='7.2' height='1.6' rx='0.4' fill='%23fff'/%3E%3C/svg%3E");
}
/* The header's cell has no padding — the box carries its own 9px inset (views/list.ejs:
   `thead th.c-select { padding: 0 !important }`), otherwise the header box sits 6px right of the row's. */
#root .detail-pane thead th.c-select { padding: 0 !important; }
/* A ticked row is tinted, and the checkbox cell is opaque because the column is sticky: while the table scrolls
   sideways the cells sliding under it must not show through. The tint is layered as an image over the base the
   neighbouring transparent cells composite to, so a selected row is pixel-identical to its neighbours' tint. */
#root .detail-pane table tr.tx-row.selected > td { background: var(--accent-tint-soft) !important; }
#root .detail-row .detail-pane table td.c-select,
#root .detail-row .detail-pane table th.c-select { background-color: var(--bg) !important; }
/* The checkbox column is opaque (it is sticky, so scrolled cells must not show through) — which meant the row's
   hover tint stopped at the date cell: the column read as a flat lane while the rest of the row highlighted. The
   tint is layered as an image over the same base the neighbours composite to, so the cell matches them exactly
   and stays opaque. */
#root .detail-row .detail-pane table tr.tx-row:hover > td.c-select {
  background-color: var(--bg) !important;
  background-image: linear-gradient(var(--accent-tint-pane), var(--accent-tint-pane)) !important;
}
#root .detail-row .detail-pane table tr.tx-row.selected > td.c-select {
  background-color: var(--bg) !important;
  background-image: linear-gradient(var(--accent-tint-soft), var(--accent-tint-soft)) !important;
}

/* ── The API reference (views/api-reference.ejs) ───────────────────────────────────────────────────────
   The EJS page carries this scale in a page-local <style>, which the SPA never loads: every .api-note,
   .api-list and .api-table cell fell through to the 16px body default and the page stood 1192px taller
   than the EJS one. One scale, ported verbatim and scoped to #root. */
#root .api-lede, #root .api-jump, #root .api-note { color: var(--muted); font-size: 13px; }
#root .api-lede { margin: 0 0 14px; }
#root .api-jump { margin: 14px 0 0; }
#root .api-jump a { margin-right: 2px; }
#root .api-note { margin: 0 0 10px; }
#root .api-h3 { margin: 22px 0 8px; font-size: 15px; }
#root .api-h3:first-child { margin-top: 0; }
#root .api-input { font-family: monospace; font-size: 12px; }
#root .api-list { margin: 0 0 10px; padding-left: 18px; font-size: 13px; }
#root .api-table td { font-size: 13px; }
#root .api-table td.api-mono { font-size: 12px; }
#root .api-endpoint { border-top: 1px solid var(--border); padding: 10px 0; }
#root .api-endpoint:first-of-type { border-top: 0; }
#root .api-endpoint p { margin: 0; font-size: 13px; }
#root .api-endpoint .api-endpoint-what { color: var(--muted); font-size: 13px; margin-top: 4px; }
#root .api-chip { display: inline-flex; align-items: baseline; gap: 5px; font-family: monospace; font-size: 12px;
  background: var(--panel); border: 1px solid var(--border); border-radius: 5px; padding: 2px 7px;
  margin: 0 4px 4px 0; }
#root .api-chip.range { border-color: var(--accent); }
#root .api-chip-tag { font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
#root .api-route { border-top: 1px solid var(--border); padding-top: 12px; margin-bottom: 18px; }
#root .api-route:first-of-type { border-top: 0; padding-top: 0; }
#root .api-route-head { margin: 0 0 6px; font-size: 13px; }
#root .api-scope { color: var(--muted); font-size: 12px; margin-left: 6px; }
#root .api-route p { margin: 0 0 10px; font-size: 13px; }
#root .api-route ul { margin: 0 0 10px; padding-left: 18px; color: var(--muted); font-size: 13px; }
#root .api-route li { margin-bottom: 3px; }

/* ── The tax report (views/tax.ejs) ───────────────────────────────────────────────────────────────────
   The EJS page carries this in a page-local <style>, which the SPA never loads: the year picker's 22px, the
   h3, the harvest cards' own box and the quarterly table's 15px line box all fell back to the app defaults,
   which is why the harvest panel stood 63px taller than its twin. Ported verbatim, scoped to #root. */
#root .qp-open { display: flex; flex-direction: column; align-items: flex-start; text-align: left; }
#root .qp-open.is-filled { width: 100%; border: 1px solid var(--border); background: var(--panel); color: var(--text); }
#root .qp-open.is-empty { width: fit-content; border: none; background: none; padding: 0; min-height: 35px; }
#root .tax-year { font-size: 22px; }
#root .tax-h3 { font-size: 15px; }
#root .tax-notice { margin: 10px 0 0; padding: 8px 10px; border-left: 3px solid var(--accent);
  background: var(--accent-tint-soft); border-radius: 6px; color: var(--text); font-size: 13px; line-height: 1.45; }
#root .hv-cards { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 10px; margin: 12px 0 2px; }
#root .hv-cards .card { padding: 10px 12px; border-radius: 8px; }
#root .hv-cards .card .value { font-size: 15px; font-weight: 600; margin-top: 2px; }
#root #quarterly-table tbody td { line-height: 15px; }
/* At narrow widths views/tax.ejs's harvest table (#harvest-balance, a page table) carries 7px of vertical cell
   padding, while the SPA's copy goes through Table's .table-scroll wrapper and picks up its 7/9 rhythm — every
   row came out 2px taller, and the two rows pushed everything below them down 4px. Only the narrow layout
   diverges (the wide one already matched to the pixel), so the correction is scoped the same way. */
@media (max-width: 900px) {
  #root #harvest-balance td { padding-top: 7px; padding-bottom: 7px; }
}
#root #quarterly-table tbody td [data-menu] { line-height: normal; }
#root #harvest-balance { width: 100%; }
#root #harvest-balance th, #root #harvest-balance td { white-space: nowrap; }
#root #harvest-balance td:last-child { white-space: normal; }

/* ── Settings · Access tokens (views/settings.ejs page-local <style>) ─────────────────────────────────
   The page carries these four rules itself, so the SPA never loaded them: the pencil sat flush against the
   label, and the scope chip could wrap. Ported verbatim, scoped to #root. */
#root .tok-label { margin-right: 6px; }
#root .tok-nudge { line-height: 1.1; padding: 1px 6px; font-size: 11px; }
#root #tab-tokens .tag { white-space: nowrap; }
#root #tab-tokens .tag.neg { color: var(--red); }


/* ── Approve a device (views/activate.ejs page-local <style>) ────────────────────────────────────────
   A focused task page: one centred card, capped at 460px so it works on a phone later. Ported verbatim,
   scoped to #root. */
#root .activate-wrap { display: flex; justify-content: center; padding: 28px 0 60px; }
#root .activate-card { width: 100%; max-width: 460px; background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 26px; box-shadow: 0 16px 44px var(--scrim); }
#root .activate-card h1 { margin: 0 0 6px; font-size: 20px; }
#root .activate-card p.lede { margin: 0 0 20px; color: var(--muted); font-size: 13px; }
#root .activate-code { width: 100%; font-family: monospace; font-size: 22px; letter-spacing: 3px; text-align: center; text-transform: uppercase; padding: 12px; }
#root .activate-actions { display: flex; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
#root .activate-actions .btn { flex: 1 1 140px; }
#root .activate-device { border: 1px solid var(--border); border-radius: 10px; padding: 14px; margin: 0 0 4px; }
#root .activate-device .name { font-weight: 700; font-size: 15px; margin: 0 0 4px; }
#root .activate-device .what { margin: 0; color: var(--muted); font-size: 13px; }
#root .activate-err { border: 1px solid var(--red); border-radius: 8px; padding: 10px 12px; margin: 0 0 14px; color: var(--red); font-size: 13px; }
#root .activate-ok { border: 1px solid var(--green); border-radius: 8px; padding: 14px; font-size: 13px; }
#root .activate-hint { margin: 18px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
#root .activate-hint code { font-size: 12px; }
#root .activate-note { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
#root #device-label { width: 100%; }

  /* Menus are portalled to <body>, so these rules are scoped to body rather than #root — an #root rule
     reaches a menu nowhere, and the UA's black 1px ring is what a keyboard reader would see instead.
     The row MenuItem renders when the caller has no class of its own — the same box as .pane-menu-item, so a
     menu in the gallery and a menu on a page are one look. */
  body .menu-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 14px; margin: 0; background: none; border: none; color: var(--text); font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
  body .menu-item:hover, body .menu-item:focus-visible { background: var(--accent-tint); }
  body .menu-item.is-danger { color: var(--red); }
  /* Keyboard focus on a menu row is visible whatever class the caller gave it: a menu navigated by arrows with no
     focus ring is a menu a keyboard reader cannot use. */
  body [role^="menuitem"]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
  /* The check column of a menuitemcheckbox row: fixed width even when empty, so checked and unchecked rows keep
     their labels on one x. */
  body .menu-check { flex: 0 0 14px; width: 14px; text-align: center; color: var(--accent); font-size: 12px; line-height: 1; }
