  :root {
    color-scheme: light;
    /* A warm off-white, not near-white. Reported simply as "very bright".
     *
     * The fix is the PALETTE, not a control. A brightness slider duplicates what
     * the OS and the monitor already do better, and a CSS filter over the whole
     * app degrades text rendering and drags the status colours - good, warning,
     * critical - along with it, which are the one set of colours here that carry
     * meaning rather than style. Softening the two surfaces takes the glare out
     * of the default instead of adding a control to compensate for it.
     *
     * Both moved together, keeping the one step between them. Darkening the page
     * alone would have made the near-white cards stand out MORE, which is a
     * starker screen, not a calmer one - and cards are most of what you look at.
     *
     * TWO things could have changed here and only one of them should have.
     * Measured, the first pass darkened the page 6% AND made it four times
     * warmer - R minus B went from 2 to 8, which is properly cream. The
     * darkening is what does the work: it took card-against-page separation from
     * 1.03 to 1.07, so a card reads as a raised object instead of the page
     * reading as one flat white sheet. The warmth was a stylistic change nobody
     * asked for, and on a financial tool a cream cast reads dated next to the
     * near-neutral greys everything else uses.
     *
     * So the lightness is kept to within 0.001 and the warmth goes back to where
     * it started. If this ever gets revisited: change ONE of those at a time,
     * because they are separate decisions that feel like one.
     *
     * Contrast was measured rather than assumed, since every value here is a
     * background something dark sits on. Body text on a card goes 19.17:1 ->
     * 18.84:1 and links 10.03:1 -> 9.86:1; both were far above the 4.5:1 that
     * 12.5px text needs and still are. Nothing else in the palette moved, so the
     * accents, the chart series and the status colours are untouched.
     *
     * Light mode only. The dark palette below is a separate set of variables and
     * is deliberately not touched. */
    /* ---- "Option 1: The Modern Corporate Look" — client-approved palette ----
       Cool slate/navy family replacing the previous warm-grey one, applied as
       a full set rather than piecemeal so the tiers stay one coherent ramp.
       Reverted from a pure-white canvas back to a light grey one: with card
       and page a single step apart (#f8fafc on #ffffff) the border alone had
       to carry the "this is a card" read, and against pure white a 1px
       #e2e8f0 line reads as barely there — the grid structure disappeared.
       Grey canvas + white card restores an actual background-contrast cue,
       same cool slate family as --border/--gridline rather than the old
       warm grey.
       Nudged off that slate family and onto neutral grey — matched to
       --sidenav-bg, which moved here first after the sidebar's own blue lean
       read as "powder blue" spread across 220px. The canvas sits behind
       every screen in the app, at a similar lightness to the rail, so the
       same slight blue cast was going to read the same way the moment the
       two sat side by side — which they always do. --border/--gridline stay
       on the slate family for now: thin lines carry far less of a hue cast
       than a full canvas fill, and this was reported specifically about the
       backgrounds not matching, not about the grid lines.
       #f4f4f4, not --sidenav-bg's own #ececec — measured, not matched blind:
       --text-muted at 4.34:1 against the old #f1f5f9 was already under the
       4.5:1 floor the comment below claims for it (that comment predates
       some earlier change to this token and was never corrected — a separate,
       pre-existing problem this change did not create). #ececec would have
       pulled it to 4.03:1, a real further regression; #f4f4f4 sits within
       0.01 of the original page's lightness, so every contrast figure
       documented against --page below holds exactly as well as it already
       did. --sidenav-bg was brought up to this same value rather than this
       token brought down to match it, for the same reason. */
    --page:           #f4f4f4;
    --surface-1:      #ffffff;
    /* Contrast against --surface-1 17.06:1, against --page 17.85:1. */
    --text-primary:   #0f172a;
    /* Contrast against --surface-1 7.24:1, against --page 7.58:1. */
    --text-secondary: #475569;
    /* One step darker than --text-secondary, for the form-section headings
       only ("General Information" and its siblings) — --text-secondary alone
       read as too light there, and --text-primary was tried and reported as
       too harsh. Contrast against --surface-1 10.7:1, well past the 4.5:1
       floor. */
    --section-title: #334155;
    /* The brief's own suggestion here was #94a3b8 — measured at 2.56:1 against
     * --page, well under the 4.5:1 floor every other token on this page is
     * held to (see the AA-failure history immediately above this line for
     * why that floor exists: #898781 was rejected for exactly this). Used
     * #64748b instead, one step darker in the same Tailwind slate family:
     * 4.76:1 against --page, 4.83:1 against --surface-1 — clears the floor
     * rather than sitting under it, while still reading clearly lighter than
     * --text-secondary. */
    --text-muted:     #64748b;
    /* Own token rather than reusing --text-muted: a placeholder is a hint that
       disappears once you type, not standing content, so it is held to a
       lighter, lower-contrast shade on request (Google's own inputs read the
       same way) rather than the 4.5:1 floor --text-muted is deliberately
       pinned to above. Below that floor on purpose here — do not point this
       at --text-muted or the two will drift back together. */
    --placeholder:    #9aa0a6;
    --gridline:       #e2e8f0;
    --baseline:       #cbd5e1;
    --border:         #e2e8f0;
    /* UI chrome accent: primary buttons, the active nav item, links, focus
       rings. Deliberately its own variable rather than reusing --series-1,
       which is ALSO the Technology chart series - repointing that would
       recolour every chart the moment somebody picked a different accent, and
       chart colour carries meaning here. Overridden per user from Profile ->
       Preferences; this is the fallback and the default. */
    /* White, matching --page — client feedback ("Flawless Hierarchy... Making
       the sidebar background white with slate text completely cleans up the
       left navigation tree"). The separation from the page now comes from the
       existing border-right on .sidebar and from --text-secondary on the nav
       labels, not from a background difference — deliberately, per the brief.
       Dark mode is untouched below; that palette was not part of this pass. */
    --nav-surface: #ffffff;
    /* WHAT A TEXT BOX IS FILLED WITH.
     *
     * Form controls used `--page`, which is the colour BEHIND a card. That is
     * right on the handful of screens where a form sits directly on the page,
     * and wrong everywhere else: on a card (`--surface-1`, #fafaf9) a #f3f2f1
     * input is DARKER than the surface it sits on, by seven values — too close
     * to read as a deliberate recess and too far to look like part of the card.
     * With a 12%-alpha border doing the rest of the work, the control ends up
     * a soft grey smudge next to a solid button. Reported three times on the
     * Workflows record-type select before it was measured.
     *
     * White here, so the control sits a step ABOVE the card and its border only
     * has to draw the edge. In dark mode the convention inverts — a field is
     * darker than the surface, which is what --page already gives — so the two
     * themes point this token at opposite ends rather than sharing a rule. */
    --field-bg: #ffffff;
    /* Slate — matches ACCENTS.Slate in app.js. These are the CSS defaults
       painted before applyAccentPreference() runs on boot; if they disagreed
       with the JS default, first paint would flash the old color for a moment
       on every load. Anyone with a saved accent preference is repainted to
       it immediately after, same as always. Deep navy per "Option 1" —
       white-on-accent measures 10.36:1, comfortably past the 4.5:1 floor. */
    --accent: #1e3a8a;
    /* Overwritten per user by applyAccentPreference; these are the defaults. */
    --accent-strong: #182f6e;
    /* Links are their own colour, a step darker than the accent.
       --accent exists to be a fill — a solid button, an active nav item, a
       series on a chart — where it carries white text and is used once or twice
       per screen. As link text at weight 600 it is repeated down every row of
       every table, and a column of forty saturated blue names reads as a
       warning rather than as a list you can click. It was also the weakest
       contrast in the palette: 4.26:1 on the zebra stripe, under the 4.5
       that 12.5px text needs.

       Two steps down the ramp, not one. --seq-550 (#1c5cab, 6.4:1) fixed the
       contrast failure but was still reported as glaring — which is a different
       complaint from the one that produced it, and a real one: contrast is a
       floor, not a target, and a colour can clear 4.5:1 comfortably while still
       being tiring to read down forty rows. --seq-600 measures 8.1:1 on white
       and keeps every property that made the ramp the right place to look —
       same hue, same family as the chart series, still unmistakably a link. */
    /* Darkened from #184f95 (7.89:1) to 10:1 against the card, at Carter's
       request — light mode only; the dark-mode value below is unchanged.
       Every accent's light link in the ACCENTS table moved to the same 10:1,
       so a firm on Teal or Amber gets the same weight of link as one on Blue
       rather than each palette landing wherever its hue happened to fall.
       Slate was already 11.17:1 and is untouched. Body text is 19.17:1, so a
       link is still clearly lighter than the text around it.

       The new navy accent (#1e3a8a) already measures 9.90:1 against the card
       on its own — no further darkening needed to hit the same ~10:1 target
       the rest of this note describes, so link reuses --accent directly. */
    --link: #1e3a8a;
    --series-1: #2a78d6; /* blue   - Technology */
    --series-2: #eb6834; /* orange - Strategy */
    --series-3: #1baf7a; /* aqua   - Data & Analytics */
    --series-4: #eda100; /* yellow - Change Management */
    /* Same four hues, blended ~35% toward neutral grey, for practice-coded chart
       bars specifically (PRACTICE_COLOR in app.js). Kept separate from
       --series-1..4 rather than dimming those directly: seriesColor() still hands
       out the brighter set to generic per-category charts (e.g. hours by person)
       that were never part of the "too loud" feedback and shouldn't move. */
    --practice-1: #4176b8;
    --practice-2: #be6c4f;
    --practice-3: #379a7c;
    --practice-4: #c0912d;
    --good: #0ca30c;
    --warning: #fab219;
    --serious: #ec835a;
    --critical: #d03b3b;
    --success-text: #006300;
    --seq-100:#cde2fb; --seq-150:#b7d3f6; --seq-200:#9ec5f4; --seq-250:#86b6ef;
    --seq-300:#6da7ec; --seq-350:#5598e7; --seq-400:#3987e5; --seq-450:#2a78d6;
    --seq-500:#256abf; --seq-550:#1c5cab; --seq-600:#184f95; --seq-650:#104281; --seq-700:#0d366b;
    --card-shadow: 0 1px 2px rgba(11,11,11,0.06), 0 1px 1px rgba(11,11,11,0.04);
    /* The company green. Not part of the accent system on purpose - see .brand-mark. */
    --brand-mark: #1a7f4b;

    /* SIDEBAR SURFACE — a light neutral grey, one step off white.
       The rail used to share --nav-surface (pure white) with the header
       above it, which needed a special-cased --baseline border just to
       have a visible seam between two identical whites. A tinted surface
       gives the rail its own boundary without needing that border to do
       the whole job.
       Equal R/G/B on purpose, not a step of --page or --gridline — every
       neutral token elsewhere in this app (--page, --gridline, --baseline)
       is Tailwind's "slate" family, which biases blue by design (their B
       channel always reads a few points above R). Fine at gridline/border
       scale; spread across an entire 220px-wide rail it read as a genuine
       colour — reported as looking powder blue, confirmed on screen — so
       this token deliberately breaks from that family instead of stepping
       further into it.
       Same value as --page, and by request: the two surfaces sit side by
       side on every screen, and leaving this darker than --page once that
       one moved off the slate family too would have swapped one visible
       mismatch for another. Dark mode is untouched (still --nav-surface,
       see below). */
    --sidenav-bg: #f4f4f4;
    /* --baseline, not --border: --border (#e2e8f0) sits only 5-ish RGB units
       from --sidenav-bg and all but disappeared against it. --baseline is
       the app's existing "needs to read as a line regardless of what's
       around it" token (chart baselines, matrix-grid dividers) — the same
       role it played here before the rail had its own tint at all. */
    --sidenav-border: var(--baseline);
  }
  :root[data-theme="dark"] {
    color-scheme: dark;
    --surface-1:      #1a1a19;
    --page:           #0d0d0d;
    --text-primary:   #ffffff;
    --text-secondary: #c3c2b7;
    /* Unchanged from --text-secondary here: the too-light/too-harsh complaint
       was reported against the light theme's white background, which does
       not carry over to a dark one. */
    --section-title: var(--text-secondary);
    --text-muted:     #898781;
    /* Dimmer than dark mode's --text-muted for the same reason the light-mode
       token is lighter than its --text-muted — a placeholder reads as a
       fainter hint than standing muted text in both themes. */
    --placeholder:    #6b6965;
    --gridline:       #2c2c2a;
    --baseline:       #383835;
    --border:         rgba(255,255,255,0.10);
    --nav-surface: #1f1f1d;
    /* Dark mode already differentiates the rail from the page without a
       tint of its own — left pointing at --nav-surface rather than given
       a dark-slate value, since "light monochromatic slate" was a light-
       mode-specific ask. */
    --sidenav-bg: var(--nav-surface);
    --sidenav-border: var(--baseline);
    /* Darker than the card, which is the dark-mode convention and what --page
       already was. See the note on the light-mode token for why this is not one
       shared value. */
    --field-bg: var(--page);
    /* Slate (dark), matching the light-mode default change above. */
    --accent: #6b7688;
    --accent-strong: #4a5568;
    /* Lighter here, for the same reason it is darker in light mode: the job is
       to sit a comfortable step away from the page, not to be the accent.
       Stepped back from #7fb2ec (7.8:1) at the same time as the light-mode one,
       and for the same reason — on a near-black page a pale blue glares rather
       than reads. #6da7ec still measures 6.9:1 against --surface-1. */
    --link: #a3adbd;
    --series-1: #3987e5;
    --series-2: #d95926;
    --series-3: #199e70;
    --series-4: #c98500;
    /* Same muted-toward-grey treatment as light mode, see the comment there. */
    --practice-1: #4881c7;
    --practice-2: #b86141;
    --practice-3: #329175;
    --practice-4: #ad7f26;
    --success-text: #0ca30c;
    --card-shadow: 0 1px 2px rgba(0,0,0,0.3), 0 1px 1px rgba(0,0,0,0.2);
  }
  * { box-sizing: border-box; }
  html, body {
    margin: 0; padding: 0;
    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    background: var(--page);
    color: var(--text-primary);
  }
  body { padding-bottom: 48px; }
  .app-header {
    display:flex; align-items:center; justify-content:space-between;
    padding: 18px 28px; border-bottom: 1px solid var(--border);
    background: var(--surface-1);
  }
  .app-title { display:flex; align-items:center; gap:10px; }
  .app-title h1 { font-size: 18px; font-weight: 700; margin:0; letter-spacing: -0.01em; }
  .app-title .tag { font-size: 12px; color: var(--text-secondary); }
  .header-right { display:flex; align-items:center; gap:16px; }
  /* Your account, and the only thing in this row that is about YOU.
   *
   * It was a span: the avatar and name were inert while a plain "Profile" button beside them did the
   * job, styled exactly like "Log out" and "Dark mode". Three identical words, one of which signs you
   * out. Now the face and the name are the control, and the two verbs beside it keep their plain
   * treatment — which is what makes this the prominent one without adding any colour to a header
   * that is deliberately quiet.
   *
   * A pill rather than a bare button, because a click target needs an edge: without one, hovering
   * lights up a region whose bounds nobody can predict. */
  .user-chip {
    appearance:none; border:1px solid transparent; background:none; cursor:pointer;
    font:inherit; color:var(--text-primary); font-weight:600;
    padding:3px 10px 3px 3px; border-radius:999px;
    transition:background 0.12s ease, border-color 0.12s ease;
  }
  .user-chip:hover, .user-chip:focus-visible {
    background:color-mix(in srgb, var(--accent) 10%, transparent);
    border-color:color-mix(in srgb, var(--accent) 30%, transparent);
    outline:none;
  }
  .as-of { font-size: 12px; color: var(--text-secondary); }
  .theme-toggle {
    border:1px solid var(--border); background:var(--surface-1); color:var(--text-primary);
    border-radius: 8px; padding:6px 10px; font-size:12px; cursor:pointer;
  }
  nav.tabs {
    display:flex; gap:4px; padding: 0 24px; background: var(--surface-1);
    border-bottom: 1px solid var(--border); overflow-x:auto;
  }
  nav.tabs button {
    appearance:none; border:none; background:transparent; color: var(--text-secondary);
    padding: 12px 14px; font-size: 13.5px; font-weight:600; cursor:pointer;
    border-bottom: 2px solid transparent; white-space:nowrap;
  }
  nav.tabs button.active { color: var(--text-primary); border-bottom-color: var(--accent); }
  nav.tabs button:hover { color: var(--text-primary); }
  main { padding: 22px 28px; max-width: 1360px; margin: 0 auto; }
  .view { display:none; }
  .view.active { display:block; }
  /* font-weight 700 (back down from a briefly-tried 800, reported as too
     harsh), 16px bottom margin (up from 12px), and --text-secondary instead
     of the inherited near-black — asked for directly, "a slate grey or
     something", after full black at 800 read as harsh. --text-secondary IS
     a slate (#475569 in light mode) and already has a theme-aware dark-mode
     value, so this needed no new token. The heading read as too close in
     weight to the field labels under it ("Place in the firm" sat right up
     against "Practice"), so the fix is air and a softer color, not a
     heavier or blacker heading. */
  .section-title { font-size: 15px; font-weight:700; margin: 28px 0 16px; color: var(--text-secondary); }
  /* A LITTLE AIR, not none. Zeroed outright, a section's own heading — always
     the first child of its `.form-rail-section` wrapper, by construction —
     ran flush against whatever sits above that wrapper. Usually invisible,
     because the previous section's own field rows already leave some space
     above the next heading; not on the very first section of a record's edit
     form, where the thing directly above is the Save Changes / Cancel row
     moved up into the card head, with nothing between them at all. Asked for
     directly. Short of the 28px default, which was sized for a heading
     following another heading rather than a control row. */
  .section-title:first-child { margin-top: 14px; }
  /* The tuck-up belongs to captions sitting directly under a heading, which is
     what this class was written for. Applied unconditionally it dragged every
     other caption into whatever sat above it — a button row, a table's bottom
     border, a checkbox — so the negative margin is now scoped to the case it
     was meant for and everything else gets ordinary spacing. */
  .subtext { font-size: 12.5px; color: var(--text-secondary); margin: 0 0 14px; }
  h1 + .subtext, h2 + .subtext, h3 + .subtext, h4 + .subtext,
  .section-title + .subtext, .card-head + .subtext { margin-top: -6px; }
  /* Inline with a control (a checkbox label), so no block spacing at all.
     `.bulk-bar` belongs here too: the bar is `align-items:center`, so a caption
     carrying 14px of bottom margin is centred *including that margin* and sits
     visibly higher than the buttons beside it. The count and the buttons are
     one row and should read as one row. */
  label .subtext, .form-actions .subtext, .bulk-bar .subtext { margin: 0; }
  /* Breathing room where a caption follows a row of controls. */
  .form-actions + .subtext,
  .inline-actions + .subtext,
  .form-actions + div > .subtext:first-child,
  .inline-actions + div > .subtext:first-child { margin-top: 10px; }
  /* Room under the tiles.
     The strip has 12px beneath it and nothing above, which is right when it sits
     under a card heading — but on a record dashboard it follows a row of KPI
     tiles, and with no gap the tabs read as attached to the bottom of the tiles
     rather than as the navigation for the panel below them. Same 12px, so the
     strip sits evenly between the two things it separates. */
  .kpi-row {
    display:grid; grid-template-columns: repeat(auto-fit, minmax(190px,1fr)); gap: 12px;
    margin-bottom: 12px;
  }
  .kpi-card {
    background: var(--surface-1); border:1px solid var(--border); border-radius: 12px;
    padding: 14px 16px; box-shadow: var(--card-shadow);
    /* Column, for exactly one reason: DASH_EDITING's toolbar (.kpi-top's
       sibling, appended straight to the card) needs to sit BELOW the
       left/right row as a full-width strip, not squeeze in as a third
       column beside kpi-right. */
    display: flex; flex-direction: column;
  }
  /* The left/right split. align-items:stretch (the flex default) is what
     lets kpi-left's own height reach the bottom of this row — see
     .kpi-left .delta below, which depends on that to anchor itself. flex:1
     stretches kpi-top to fill the card when DASH_EDITING's toolbar is the
     only other child, so the split still reaches the card's real bottom
     edge rather than just the top row's own content height. */
  .kpi-top { display:flex; justify-content:space-between; gap:14px; flex:1 1 auto; }
  .kpi-left { display:flex; flex-direction:column; min-width:0; flex:1 1 auto; }
  /* min-width, not a fixed width: a sparkline is drawn at width:100% of
     whatever holds it (see sparkline()), so an unconstrained kpi-right would
     let a short value like "80" squeeze the trend line down to the width of
     two digits. 110px is roughly the widest value this catalogue renders
     ("$931K") plus its own padding, so every card's sparkline reads at a
     comparable width regardless of what number happens to be short this
     week. */
  /* flex-shrink:1, not 0 -- a record-dashboard KPI tile (Revenue - Actual and
     Planned's "Unbilled", which breaks its total into time/expense/purchase)
     can carry a delta note far wider than the 110px this box was floored at,
     and 0 0 auto refuses to shrink a flex item below its own max-content width
     no matter how narrow its grid track is: the note stayed one unbroken line
     and spilled past the card's own border into its neighbour, wrapping never
     kicked in because nothing ever asked the box to be narrower than the text
     wanted. flex:0 1 auto keeps the 110px FLOOR (short values still hold the
     sparkline steady, per the note above) but lets a wider tile shrink back to
     what its card actually has, which is what finally makes `.delta`'s own
     white-space:normal wrap the note onto more than one line instead of
     forcing the box wider than its card. */
  .kpi-right { display:flex; flex-direction:column; align-items:flex-end; text-align:right; flex:0 1 auto; min-width:110px; }
  .kpi-card .label { font-size: 11.5px; color: var(--text-secondary); font-weight:600; text-transform:uppercase; letter-spacing:.02em; }
  /* white-space:nowrap, not overflow-wrap:anywhere — anywhere let the
     browser break a figure at any character once the tile ran short of
     room, which is how "$980,000.00" split into "$980,000" / ".00" rather
     than either staying whole or genuinely overflowing. A KPI value reads
     as one number; breaking it becomes a second, wrong number for as long
     as the eye takes to notice the second line. min-width on kpi-right
     already gives it a floor to shrink the row to instead. */
  .kpi-card .value { font-size: 26px; font-weight:700; font-variant-numeric: tabular-nums; white-space: nowrap; }
  /* 4px read as crowded against a 26px bold number — client feedback. The
     value is the heaviest thing on the tile, so the line right under it
     needs more air than a label-to-value gap does, not the same or less. */
  .kpi-card .delta { font-size: 12px; margin-top:8px; }
  .kpi-card .delta.good { color: var(--success-text); }
  .kpi-card .delta.bad { color: var(--critical); }
  .kpi-card .delta.neutral { color: var(--text-secondary); }
  /* THE ANCHOR. margin-top:auto eats every pixel kpi-left isn't using above
     it, which is exactly what pins "Click to open →" to the card's own
     bottom edge instead of floating right under the label — the jagged
     baseline Google's own review caught, since every card's label is a
     different length. Only .kpi-left has a .delta at all (the click hint);
     kpi-right's note/trend .delta's keep the ordinary top margin above. */
  .kpi-left .delta { margin-top:auto; padding-top:8px; }
  .card {
    background: var(--surface-1); border:1px solid var(--border); border-radius: 12px;
    padding: 16px 18px; box-shadow: var(--card-shadow); margin-bottom:16px;
  }
  .card-head { display:flex; align-items:center; justify-content:space-between; gap: 12px; margin-bottom:12px; flex-wrap:wrap; }
  /* Chart cards read left-to-right: title, then the controls that qualify it.
     space-between is right for a list card, where the trailing element is a row
     count that belongs at the far edge — and wrong for a chart, where it pushes
     the controls as far from the title as the layout allows. */
  .card-head.left { justify-content: flex-start; }
  .card-head h3 { font-size: 13.5px; margin:0; font-weight:700; }
  .card-head .hint { font-size:11.5px; color:var(--text-muted); }
  /* Overrides the inline margin-bottom:8px filteredList's drawBar() puts on
     its own .controls row (see the note there) — sized for that row sitting
     BELOW the title, separating it from the table beneath. joinFilterRow
     relocates that whole row into the title's own line instead, where the
     margin only adds height to one flex item's box that its siblings (the
     h3, the New-record button) do not carry — align-items:center then
     centers it a few pixels higher than everything beside it, since it is
     centering a taller box. `!important` because it is overriding an inline
     style set fresh on every filter redraw, not a class this could out-rank
     any other way. */
  .card-head .controls { margin-bottom: 0 !important; }
  /* Same bug, same fix, the button-bar sibling of .controls above: a list
     screen's "+ New X" / "+ Bulk X" pair rides in a bare .form-actions,
     which carries its own margin-bottom for when it sits under a form. In
     the card-head's flex row that margin inflates the box being centered on
     one side only, so the buttons sat a few pixels above the title instead
     of level with it — reported directly on Invoice's "+ New Invoice". */
  .card-head .form-actions { margin-bottom: 0 !important; }
  .controls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
  /* Every field in a dashboard panel's filter bar grows to fill its row instead of
     sitting at its own content width — scoped to panelFilterBar's own host
     (`.panel-filter-bar`), not the bare `.controls` a plain list screen's
     search/filter row also wears, which was never the one reported here and
     is fine staying content-width.

     Without this, a panel with many controls (Resource Utilization's eight)
     wraps onto several rows sized however each row's controls happened to
     add up — Period plus From–To alone came to about half a ~1000px row,
     leaving the other half empty while the next row, with four shorter
     selects, filled edge to edge. Reported as the filter rows looking
     uneven. flex-basis 160px is short enough that a select still reads as
     one field rather than a stretched-thin bar; growing rather than a fixed
     width is what lets three fields split a wide row evenly and one alone
     on a narrow row still reach the edge. */
  /* max-width 280px, added after the fact: an unbounded grow gave "Sort" — three
     words, "Highest first" / "Lowest first" / "By name" — the same width as its
     row-mates whenever the row was mostly empty, and on a wide desktop that meant
     a field readable at 140px stretched to 400+. Reported directly as "way too
     wide". 280px is comfortably past what any select here needs (the longest
     realistic option, a role like "Contractor - TBD", or a date pair) without
     handing a short one four times its own content. A capped field can still
     leave a sliver of row unfilled on a very wide screen with very few controls —
     the trade this makes on purpose, since a few px of slack reads as nothing
     next to a control visibly stretched past its own text. */
  .panel-filter-bar > .form-field { flex: 1 1 160px; max-width: 280px; }
  .panel-filter-bar > .form-field select { width: 100%; }
  /* The search box was bare native chrome too — same bug as the <select>
     fix below, just missed in that pass. `input[type=search]` carries its
     own UA default (a "2px inset" grey border, square corners) that never
     got a rule to override it, so every list screen's search box sat next
     to 6-8px-radius buttons wearing a visibly different, older-looking
     control. Same treatment `.form-field input` already gets. */
  .controls input[type="search"],
  .controls input[type="text"] {
    font-size:13px; border:1px solid var(--border); background-color:var(--field-bg);
    color:var(--text-primary); border-radius:8px; padding:7px 9px; font-family:inherit;
  }
  /* Split, because these are two different things wearing one rule.
     A <select> is a FIELD and takes --field-bg with everything else you type
     into. A .seg-toggle is a tab strip — a container of choices, not a control
     you fill in — so it keeps the page colour and reads as a recessed track,
     which is what a segmented control should look like. */
  /* A DROPDOWN THE APP DRAWS, not the one the operating system draws.
   *
   * Every <select> here was bare native chrome: on Windows that is a square
   * grey button with a chunky arrow welded to the right edge, sitting next to
   * inputs and buttons this stylesheet gives an 8px radius and a soft border.
   * The mismatch is the whole of why a row of filters reads as older than the
   * screen around it -- reported exactly that way, about the calendar's filter
   * row, which is the same rule as everywhere else.
   *
   * `appearance:none` plus a chevron drawn as an inline SVG is the standard
   * fix and it keeps the element a REAL <select>: the value, the options, the
   * change event and the searchable panel (see SELECT_PICKER_Z) are all
   * untouched, which matters here more than usual because 49 places read
   * `.options` and 37 build <option> nodes by hand.
   *
   * `background-color`, not the `background` shorthand -- the shorthand resets
   * background-image, so writing it that way would erase the chevron. */
  /* PLACEHOLDERS WERE NEVER STYLED, so every one of them rendered at the
   * browser default of #757575 -- measured at 4.12:1 against --field-bg, under
   * the 4.5:1 AA floor for text this size, and reported simply as text being
   * too faint to read.
   *
   * --text-muted rather than a new colour: it is the token the rest of the app
   * already uses for secondary text, it clears AA on both field backgrounds,
   * and it carries its own dark-mode value so this rule does not need a second
   * copy under the dark block.
   *
   * Still visibly lighter than --text-primary, which is the point of a
   * placeholder: it has to read as a prompt rather than as a value somebody
   * typed. Raising the contrast makes it legible without making it look filled
   * in -- those are different problems and only the first one was reported.
   *
   * ::-moz-placeholder and ::-webkit-input-placeholder are NOT listed with the
   * standard selector. An unrecognised pseudo-element invalidates the WHOLE
   * selector list in CSS, so one vendor prefix the browser does not know would
   * silently drop the rule for every browser -- the failure mode being no
   * change at all, which looks exactly like the edit not having been made. */
  ::placeholder { color: var(--placeholder); opacity: 1; }

  /* A <summary> styled as a button must lose its disclosure triangle, or the
     marker renders INSIDE the button and it reads as broken. Three rules
     because the marker has three implementations: list-style covers Firefox and
     modern Chrome, ::marker the spec, and the -webkit- pseudo-element Safari and
     older Chrome. Set here rather than inline: a pseudo-element cannot be
     written through style.cssText, which is exactly the trap that leaves the
     triangle in place on the one browser nobody tested. */
  summary.add-skill-summary { list-style: none; }
  summary.add-skill-summary::marker { content: ""; }
  summary.add-skill-summary::-webkit-details-marker { display: none; }

  /* THE ROLE PERMISSION GRID.
   *
   * Three ranks of information -- section, capability, explanation -- that all
   * rendered at the inherited 16px in --text-primary, because `.detail` and
   * `.hint` have no base rule outside their own scoped contexts. Nothing was
   * styled wrongly; nothing was styled at all.
   *
   * The sizes below are chosen against each other rather than in isolation. A
   * capability name has to win against its own explanation AND against the
   * section heading above it, so the heading gives up size and takes uppercase
   * and letter-spacing instead -- it stays findable when scanning without
   * competing with the 92 names underneath it.
   *
   * Nothing here goes under 12px, and the explanation keeps --text-secondary
   * (#52514e, 7.6:1 on the card) rather than --text-muted. This screen is how an
   * administrator decides who can see cost rates; it is the wrong place to trade
   * legibility for tidiness, and "smaller" must not become "fainter" as well. */
  .perm-grid .perm-group-head {
    font-size: 11.5px; font-weight: 700; text-transform: uppercase;
    letter-spacing: .05em; color: var(--text-secondary);
    margin: 0 0 5px;
  }
  .perm-grid .perm-name {
    font-size: 13px; font-weight: 600; color: var(--text-primary);
    line-height: 1.35;
  }
  .perm-grid .detail {
    font-size: 12px; font-weight: 400; color: var(--text-secondary);
    line-height: 1.45; margin-top: 1px;
  }
  /* The checkbox shrinks with the text it labels. Left at its default it became
     the largest thing on the row, which put the visual weight on the control
     rather than on what the control grants. */
  .perm-grid input[type="checkbox"] { width: 13px; height: 13px; margin-top: 2px; }

  /* The blank "— choose a client —" / "— none —" entry reads as a lighter,
     unset state rather than a value equal in weight to a real choice. Targets
     the option itself, not the select, because the select's own color has to
     stay --text-primary for whichever real option ends up chosen. */
  select option[value=""] { color: var(--placeholder); }
  select {
    font-size: 12.5px; border:1px solid var(--border);
    background-color: var(--field-bg); color:var(--text-primary);
    border-radius:8px; padding: 5px 9px;
    appearance: none; -webkit-appearance: none; -moz-appearance: none;
    background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2355534e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 9px center;
    background-size: 10px 6px;
    /* Room for the glyph, so a long option label cannot run under it.
     *
     * !important, which is not a decision taken lightly and is the right tool
     * here. This has been reported four times — the staffing grid, the
     * timesheet grid, the retainer picker, the timer, the advanced-filter rows
     * — and every instance has the same cause: a caller writing
     * `style.cssText = 'padding:3px 6px;...'`, which REPLACES the whole inline
     * style and takes this with it. The chevron is painted by the rule below
     * whether or not the space survives, so the text runs under the arrow and
     * reads as a font fault.
     *
     * Chasing the call sites fixes today's and not tomorrow's: the next dense
     * grid to size a select will write the same shorthand. The space the arrow
     * occupies is not something a caller has any business overriding, so it is
     * made unoverridable. A caller that wants a tighter control still gets to
     * set every other padding edge. */
    padding-right: 26px !important;
    /* AND WHAT HAPPENS WHEN THE LABEL IS LONGER THAN THE BOX ANYWAY.
     *
     * Reserving space for the chevron stops text running UNDER it; it does not
     * stop a 60-character option being cut off. A select with appearance:none
     * clips its selected text by default, so the label ended mid-word against
     * the padding and read as a rendering fault rather than as truncation --
     * reported three times, on the staffing grid, the timesheet grid and the
     * retainer picker.
     *
     * An ellipsis is the difference between "text is broken" and "there is
     * more here". The full value is still one click away in the open list, and
     * where a caller has something worth saying it sets the option title. */
    text-overflow: ellipsis;
    white-space: nowrap;
    overflow: hidden;
  }
  /* THE CHEVRON ONLY WHEN THE ROW IS WORTH LOOKING AT.
   *
   * Scoped to table cells, not selects generally — a standalone form select
   * needs its arrow always on, or nothing says it opens a list before the
   * pointer happens to land on it. A DENSE GRID is the opposite case: every
   * row repeats the same handful of editable columns, so a table of thirty
   * purchases painted thirty identical little arrows down each of them,
   * which is chrome that never changes and reads as noise rather than as an
   * affordance. Reported as the table looking "cluttered" and "heavy".
   *
   * Row hover, not select hover — see the row below — because the point is
   * "this row is where your attention is", and a person scanning down the
   * column should not have to land the cursor on the exact control to be
   * told it exists. The same SVG as the base rule above; kept in sync by
   * hand since a CSS custom property would be the one line of indirection
   * in a file that otherwise writes these out. */
  .data-table tbody select,
  .grid-dense tbody select { background-image: none; }
  .data-table tbody tr:hover select,
  .data-table tbody select:focus,
  .grid-dense tbody tr:hover select,
  .grid-dense tbody select:focus {
    background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2355534e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 9px center;
    background-size: 10px 6px;
  }
  /* A LIST BOX IS NOT A DROPDOWN. `multiple` and `size` render an open list with
     no closed state to put a chevron on, so they keep the native control -- a
     floating arrow over a scrolling list would be decoration pointing at
     nothing. */
  select[multiple],
  select[size]:not([size="1"]) {
    appearance: auto; -webkit-appearance: auto; -moz-appearance: auto;
    /* No chevron on an open list, so it reclaims the space -- and needs its own
       !important to outrank the one above rather than lose to it. */
    background-image: none; padding-right: 9px !important;
  }
  /* Matching input[type=date] below: focused, it should look as deliberate. */
  select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
  select:disabled { opacity: .55; }
  /* The glyph is drawn in ink, so dark mode needs the pale one. Swapped rather
     than filtered: `filter: invert(1)` on a <select> would invert its text too. */
  :root[data-theme="dark"] select { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23b9b6ae' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) select { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23b9b6ae' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
  }
  .seg-toggle {
    font-size: 12.5px; border:1px solid var(--border); background:var(--page); color:var(--text-primary);
    border-radius:8px; padding: 5px 9px;
  }
  /* Scrolls sideways rather than wrapping or clipping.
   *
   * The strip used to hold a fixed five entries and always fitted. It now holds
   * one tab per dashboard somebody has built, so its width is user data: a firm
   * with six boards overflows a laptop. Wrapping would put a second row of
   * navigation on the screen - the thing this strip exists to avoid - and
   * clipping would hide the last board with nothing to say it was there.
   *
   * `flex-shrink:0` on the buttons because a flex row's default is to squeeze
   * its children before it ever overflows, which would compress every tab
   * label into an ellipsis instead of scrolling. */
  /* AS WIDE AS ITS TABS, not as wide as the page.
   *
   * `display:flex` on a block-level box stretches to the full width of whatever
   * contains it, so a three-tab strip drew a bordered, backgrounded bar right
   * across the screen with the tabs bunched at the left end. Measured on the
   * Staffing Plan: 1004px of strip holding 363px of tabs, 641px of it empty.
   * Reported as the strip extending too far right, and it read as a container
   * for something that had failed to load.
   *
   * `width:max-content` sizes the box to the tabs themselves. The `max-width`
   * still caps it at the parent, so the scrolling behaviour described above is
   * untouched: a strip with more tabs than fit still reaches 100% and scrolls
   * sideways rather than wrapping. Nothing in a strip is right-aligned -- no
   * auto margins anywhere -- so nothing depended on the extra width. */
  .seg-toggle {
    display:flex; padding:3px; gap:0; background:var(--page);
    width:max-content; overflow-x:auto; max-width:100%; scrollbar-width:thin;
  }
  .seg-toggle > button { flex: 0 0 auto; position: relative; }
  /* A HAIRLINE BETWEEN EVERY TAB, not only between groups.
   *
   * The strip ran on a 3px gap and nothing else, so where one tab ended and
   * the next began was carried entirely by the space between two words — and
   * because tab labels differ in length, that space read as uneven even though
   * every gap was identical. Reported twice as awkward spacing and missing
   * dividers, on the project strip and then across the app.
   *
   * The gap goes to zero and the separation becomes a drawn line instead. That
   * is the swap that fixes the spacing complaint as well as the divider one:
   * with a rule doing the dividing, the remaining rhythm is each tab's own
   * padding, which really is uniform, so the strip finally looks as regular as
   * it always measured.
   *
   * Absolutely positioned rather than a border-left, because a border would
   * add a pixel to the tab's width and shift every label along by one — and
   * because a border cannot be hidden next to the active pill without also
   * changing that tab's geometry. `1.5em` of the strip's own font, matching
   * .seg-gap above, so both kinds of divider track the tab height together.
   *
   * One class, so this reaches every strip in the app at once — project
   * subtabs, the Billing sub-strip, Invoices/Payments, Reports' two rows. */
  .seg-toggle > button + button::before {
    content: '';
    position: absolute;
    left: 0;
    top: 50%;
    transform: translateY(-50%);
    width: 1px;
    height: 1.5em;
    background: var(--border);
  }
  /* No line where it would touch the tinted pill. The active tab is its own
     rounded block even at a lighter fill; a hairline butting into it reads as
     a rendering seam rather than a separator, so the dividers on both sides of
     it stand down and the pill separates by itself. */
  .seg-toggle > button.active::before,
  .seg-toggle > button.active + button::before { display: none; }
  /* Likewise either side of a group divider, which already did the separating. */
  .seg-toggle .seg-gap + button::before { display: none; }
  /* The "add another" control at the end of the strip. Deliberately NOT a tab:
     no active state, no bold, muted until you reach for it — it is the
     affordance for making a destination, not a destination. Sized to the tab
     height so the row keeps one baseline. */
  .seg-toggle .seg-add {
    flex: 0 0 auto; border: none; background: transparent; cursor: pointer;
    color: var(--text-muted); font-size: 15px; font-weight: 600; line-height: 1;
    padding: 5px 11px; border-radius: 6px;
  }
  .seg-toggle .seg-add:hover { background: var(--nav-surface); color: var(--text-primary); }
  .seg-toggle .seg-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
  /* Separates two GROUPS of tabs inside one strip — see segToggle's splitBefore.
     A rule rather than empty space: 10px of nothing would read as a layout slip on a row
     whose other gaps are 3px, whereas a hairline is visibly deliberate. Sized in ems of
     the strip's own font so it tracks the tab height rather than being pinned to a pixel
     count that stops matching if these are ever resized again. */
  .seg-toggle .seg-gap {
    align-self: center;
    width: 1px;
    height: 1.6em;
    margin: 0 7px;
    background: var(--border);
    flex: 0 0 auto;
  }
  /* Sized up from 12px/4px-10px.

     These switch between whole screens - Invoices against Payments, New Invoice
     against Bulk Invoicing - so they are navigation, and they were rendering
     smaller than the body text under them. Now level with .btn at 12.5px, with
     the padding to match, which also gives them a comfortable click target
     rather than a 22px-tall sliver. */
  .seg-toggle button {
    border:none; background:transparent; color:var(--text-secondary); font-size:12.5px; font-weight:600;
    padding: 6px 14px; border-radius:6px; cursor:pointer; white-space:nowrap;
  }
  /* Tint, not a solid fill — matches the sidebar's active nav item, which
     moved to the same treatment for the same reason: a solid accent pill on
     every tab strip in the app, several to a screen, read as louder than
     navigation needed to be. Bold accent-strong text on a light accent wash
     keeps "this is where you are" obvious without the block.

     accent-strong for the text rather than accent: derived from the chosen
     accent so a custom pick moves both the sidebar and every tab strip
     together, same as before. */
  .seg-toggle button.active {
    background: color-mix(in srgb, var(--accent) 14%, transparent);
    color: var(--accent-strong, var(--seq-500));
  }
  /* Hover on an inactive tab hints without competing with the active one. */
  .seg-toggle button:not(.active):hover {
    background: color-mix(in srgb, var(--accent) 10%, transparent);
    color: var(--text-primary);
  }
  /* A SECOND strip, subordinate to the one above it.
   *
   * Reports stacks two: the scope (All / My / Shared / Saved) governs, and the
   * category (All types / Time / Expenses / …) narrows what the first one
   * chose — it is only drawn under All Reports, and changing scope clears it.
   *
   * That hierarchy was expressed as `opacity: 0.92` inline, which is an 8%
   * difference nobody perceives: two identical runs of blue pills, one very
   * slightly fainter. It was read as two equal rows, which is exactly what the
   * code's own comment said it was trying to avoid.
   *
   * So the step down is real. Smaller type, no filled pill on the selected
   * entry — an underline instead, which says "you are here" without claiming
   * the same authority as the strip above. The container loses its own
   * background so it reads as a row of choices rather than a second control. */
  .seg-toggle-sub {
    background: transparent; padding: 0 3px;
  }
  /* Reported as looking too light. The COLOUR was not the problem — these
     inherit --text-secondary from .seg-toggle button, which is 7.94:1 on white
     and comfortably above the 4.5:1 AA threshold. It was the size and weight:
     11.5px at 500 made them the smallest, thinnest text on the screen, sitting
     directly beneath 30px KPI figures, which exaggerates the difference.

     Weight goes to 600, matching the parent strip, and the size up half a point.
     Still smaller than .seg-toggle's 12.5px, so these stay subordinate to the
     strip above them — which is what the `sub` variant exists to express. */
  /* REPORTED A SECOND TIME, and this time it IS the colour.

     The note above concluded the colour was fine and raised the size and weight
     instead, which was the right first move and did help. What it measured was
     --text-secondary against WHITE, at 7.94:1. These sit on the strip, not on
     white, and against #f3f2f1 the same colour measures 7.1:1 — still past AA,
     but the floor of AAA rather than comfortably inside it, at 12px, directly
     beneath body text running at 17.6:1. Passing a threshold and looking right
     next to what surrounds it are different questions, and only the first one
     had been checked.

     Mixed toward --text-primary rather than set to a literal, so it moves with
     the theme: on light this darkens to roughly 11:1, and on dark, where
     primary is white, the same expression lightens — which is the correct
     direction there. Still short of full primary, so the strip stays
     subordinate to the content beneath it, which is what `sub` means. */
  .seg-toggle-sub button {
    font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 0;
    color: color-mix(in srgb, var(--text-primary) 45%, var(--text-secondary));
  }
  .seg-toggle-sub button.active {
    background: transparent; color: var(--accent-strong, var(--seq-500));
    box-shadow: none; font-weight: 600;
    border-bottom: 2px solid var(--accent-strong, var(--seq-500));
  }
  .seg-toggle-sub button:not(.active):hover {
    background: transparent; color: var(--text-primary);
    border-bottom: 2px solid var(--border);
  }
  /* Every entry reserves the underline's height, so selecting one does not
     shift the row down by two pixels. */
  .seg-toggle-sub button { border-bottom: 2px solid transparent; }
  /* Shrink-wrapped, not stretched.
     `width:100%` made every list as wide as its card and handed the leftover out
     between the columns, so a cell holding "40.6" measured 230px and the gaps
     between columns read as a rendering fault. Which columns got the slack
     depended on which happened to be pinned: pin them all and it spreads evenly
     between them; leave one unpinned and that one swallows 400px on its own.
     Both shapes were reported as "too much whitespace", and they are the same
     bug.
     With width:auto there is no slack to hand out — the columns size to their
     contents and what is left sits to the right of the table, where it reads as
     margin. max-width keeps a genuinely wide table inside its card, where
     .scroll-x still scrolls it. */
  /* Sized up from 12.5px/7px-9px.
     width:auto stays — see above; stretching the columns is the bug that
     reasoning exists to avoid. But a two-column table set in 12.5px with 9px
     of padding occupies a corner of a 1400px card and reads as an afterthought,
     which is the same complaint arriving from the other direction. Growing the
     type and the padding makes the table claim its space honestly, without
     handing any column slack it did not ask for. */
  /* Content-width, deliberately — and this has now been wrong in both
     directions, so the reasoning is recorded rather than re-litigated.

     `width:100%` was tried on 2026-08-04 to remove the dead space to the right
     of a narrow table. It worked, and immediately reproduced the older
     complaint it had been changed away from: a three-column approval queue
     spread "Jul 6, 2026", a name and "40.6" across 1300px with ~400px of gap
     between each. Reverted the same session.

     The two complaints are in tension and cannot both be answered by stretching
     the table: space to the right of a short table is idle, but space *inside*
     it is actively harder to read, because the eye has to travel between
     related values. Idle space is the better failure.

     The real fix for a page that looks empty is more information — columns
     somebody chose in Customize View — not the same three columns further
     apart. A table wider than its pane still scrolls, which is why max-width
     stays. */
  table.data-table { width:auto; max-width:100%; border-collapse: collapse; font-size: 14px; }

  /* ...but it fills the CARD, which is a different claim from filling the page.
   *
   * The note above is right that stretching a three-column table across 1300px
   * is worse than the gap it closes, and the `fit-content` rule below is what
   * stops the card being 1300px in the first place. What neither fixed is the
   * space left *inside* the border: a card is sized to its widest child, and
   * that child is almost always the filter row, not the table. Measured on
   * Timesheets → To Approve: card 716px, table 367px — 350px of white space
   * inside the frame, which is the same "border promising a page and delivering
   * a corner" defect at a smaller scale.
   *
   * `width:100%` here is bounded by the card, and the card is still shrink-to-
   * fit, so this can never pull a narrow table across the viewport — the case
   * that was tried and reverted. It only closes the gap between the table and
   * the controls directly above it.
   *
   * It does not break a WIDE table either, and the reason is worth stating: the
   * cells are `white-space:nowrap`, so the table's min-content width is its real
   * content width. `width:100%` therefore acts as a floor, not a ceiling — a
   * table wider than its pane still overflows and still scrolls in the wrapper.
   *
   * Scoped to a table inside a card so a bare table elsewhere is untouched. */
  /* The table hugs its content; the card still fills the pane.
   *
   * Stretching the table was the fix for a card that framed empty space, and it
   * worked — but on a list with few short columns it only moved the emptiness
   * inside the table. Four arrangements were measured and the surplus never
   * shrank, because the surplus is content, not layout: ~940px of pane against
   * ~590px of columns.
   *
   * So it goes after the last column. A gap there reads as margin; the same gap
   * between two columns reads as broken. */
  /* ROUND FOUR, and the first one to name the actual cause.
   *
   * The three arrangements before this each moved one gap somewhere else,
   * because each treated the card and the table as the two things to reconcile.
   * They are not. A card's width is set by its widest child, which is almost
   * always the FILTER ROW; the table's width is set by its content. Nothing
   * relates those two numbers, so they agree only by accident — and both
   * failure modes reported today are the same disagreement seen from opposite
   * ends:
   *
   *   All Timesheets  six filter controls -> card 1420, table 990
   *                   the table stops 430px short of the filters above it
   *   Unsubmitted     two filter controls -> card 728 in a 1044 pane
   *                   the card itself is stranded, 316px
   *
   * Making the table fill the card ties it to the same edge as the filter row
   * that governs the card, so the two cannot drift apart again. The surplus
   * lands in the columns, which is the one place it has not been tried at this
   * scale, and where it does the least harm: these lists are mostly names and
   * clients, and "Fieldstone County Government" reads better with room.
   *
   * The old objection — "three columns of dates across 1300px" — was written
   * when these lists had three columns. They have six or seven now, several of
   * them long text, which is exactly the "more information" the earliest note
   * argued was the real fix. That change has since happened.
   *
   * Wide tables are unaffected: cells are `white-space:nowrap`, so min-content
   * exceeds 100% and the table overflows and scrolls in its wrapper as before —
   * `width:100%` acts as a floor, not a ceiling. */
  .card table.data-table { width:100%; }

  /* A card built around a narrow table hugs it, rather than framing empty space.
   *
   * This is the other half of the width problem, and the half that is actually
   * fixable. Stretching the table was tried and reverted (see above): pulling
   * three columns of dates across 1300px is worse than the gap it closes. The
   * defect was never the table's width — it was a 1300px card drawn around
   * 370px of content, so the border promised a full page of information and
   * delivered a corner of one.
   *
   * `fit-content` sizes the card to its widest child, which is usually the
   * filter row rather than the table itself. That is the honest answer: the
   * card is as wide as the widest thing in it, and the leftover falls outside
   * the border where it reads as page margin instead of as missing data.
   *
   * Excluding cards that contain an `svg`: a chart is sized at `width:100%` of
   * its parent, and 100% of a shrink-to-fit parent is a circular measurement
   * that collapses the chart to nothing. Those cards keep their full width,
   * which is right anyway — a chart wants the room.
   *
   * `max-width:100%` keeps a wide table scrolling inside the pane rather than
   * pushing the page sideways. */
  /* REVERTED to full width, at Carter's direction after seeing it twice.
   *
   * `width: fit-content` was the earlier answer to a card framing empty space,
   * and it worked on its own terms — but it traded a gap inside the border for
   * a bigger one outside it. Measured on Timesheets -> Unsubmitted: pane 989,
   * card 610, table 557. Closing the 53px inside the card left 379px beside it,
   * which is the gap that actually gets noticed, because a card that stops
   * two-thirds of the way across reads as a page that failed to load rather
   * than as a page with margins.
   *
   * The original objection to full width — "pulling three columns of dates
   * across 1300px is worse than the gap it closes" — was about a table that did
   * not fill its card. That is no longer the case: `.card table.data-table`
   * fills the card above, so a full-width card gives a full-width table with
   * columns spread evenly, not three columns marooned at the left edge.
   *
   * Wide tables are unaffected either way; they overflow and scroll in their
   * wrapper as they always did.
   *
   * Kept as a comment rather than deleted because this is the second time the
   * question has been answered and the third answer should know what the first
   * two were. */
  /* The card hugs its content too — and this is the combination that was never
     tried, rather than a fourth guess.
   *
   * `fit-content` was here once and was removed because the card stopped
   * two-thirds across the pane. But at that point the TABLE was stretched
   * inside it, so the card was narrow AND the table did not fill it — two gaps,
   * one inside the other, which is why it read as broken.
   *
   * Now the columns hug (nothing forces a percentage width any more) and the
   * table is exactly as wide as its content. A card sized to that wraps it
   * snugly: the border ends where the data ends, and the space to its right is
   * page background rather than an empty card promising columns that do not
   * exist. That is what a table on any other PSA looks like.
   *
   * `svg` excluded: a chart is width:100% of its parent, and 100% of a
   * shrink-to-fit parent is circular — it collapses to nothing. Charts want the
   * room anyway. */
  /* ...and the card goes back to filling the pane, which is only correct now
   * that the table fills the card.
   *
   * `fit-content` was right to reject a card framing empty space, and wrong in
   * a way that took a wide screen to see: it sized the card to the filter row,
   * so four tabs of ONE screen rendered at four different widths (764 / 728 /
   * 557 / 640), and the card jumped size on every tab click. A stable frame is
   * worth more than a snug one, and with the table now filling the card there
   * is no empty space left inside for the snugness to have been buying. */
  .card:has(table.data-table):not(:has(svg)) { max-width: 100%; }
  .card:has(table.data-table):has(svg) { max-width: 100%; }

  /* A caption must not be what decides how wide a card is.
   *
   * `fit-content` sizes a card to its widest child, and a one-line sentence has
   * no natural wrapping point — so "Approved weeks across everyone — send one
   * back to the queue to correct it..." measured 773px and dragged the border
   * 307px past the last column. The table was the right width the whole time;
   * the prose above it was not.
   *
   * A max-width gives the sentence somewhere to wrap, so it stops driving the
   * measurement. Two lines of caption is normal; a card stretched to fit one
   * line of it is not. Applied only inside a shrink-to-fit card, so captions
   * everywhere else are untouched.
   *
   * `display:block` is not decoration — it is what makes the cap work at all.
   * `.card-head` is a flex container, which blockifies its *direct* children,
   * but the hint sits inside a nested div beside the h3, so it stayed a plain
   * inline span. `max-width` does not apply to a non-replaced inline box, so
   * the 52ch below was silently ignored from the day it was written. That is
   * why capping the caption "did not close it" and the search moved on to the
   * filter row: the rule was right and inert, which looks exactly like a rule
   * that is wrong.
   *
   * Measured on Timesheets -> Approved, the one card the earlier passes left:
   * card 811 -> 557 against a 504px table, a 53px gap that is the card's own
   * padding and the same figure the other three already sat at. */
  .card:has(table.data-table):not(:has(svg)) .card-head .hint,
  .card:has(table.data-table):not(:has(svg)) > .subtext { display: block; max-width: 52ch; }
  /* 12px, down from 18px.
     18 was set to stop a two-column table looking like an afterthought in a
     1400px card, and it did — but it is 36px of gap *per column*, and on a list
     that opens with a tick box, an icon button and a menu button before the
     first real value, that is over a hundred pixels of nothing before anything
     is read. Wide tables paid for it twice, since the columns beyond it are
     pushed off the right edge. */
  table.data-table th, table.data-table td {
    text-align:left; padding: 10px 12px; border-bottom: 1px solid var(--gridline);
    font-variant-numeric: tabular-nums; white-space:nowrap;
  }
  /* Utility columns tighter still. A tick box is 14px of content in a 36px
     gutter; the row-action buttons are the same. These columns are not read,
     they are aimed at, so they want to sit close to each other and close to the
     record they belong to rather than being spaced like data. */
  /* `button:only-child` is NOT in this list, though it was.
     It matched the record's own identity link — the `#134` on an expense report,
     the invoice number — because those are a lone link-btn in their cell. The
     cell then took 6px while its heading kept 12px, so every such column sat
     6px left of the header above it. A tick box and an action cluster are
     recognizable by what they *are*; "a cell containing one button" is a guess
     about what a cell is for, and it guessed wrong on the one column that names
     the row. */
  table.data-table th:has(> input[type="checkbox"]),
  table.data-table td:has(> input[type="checkbox"]),
  table.data-table td:has(> .inline-actions) {
    padding-left: 6px; padding-right: 6px;
  }
  table.data-table th {
    /* Was vertical-align:top, needed while a filter heading was two lines - a
       label above a <select>. The filter is a caret beside the label now, so
       every heading is one line again and there is nothing left to correct. */
    /* Lightened from --text-secondary (client feedback: headers read as too
       heavy against the data). --text-muted is the quieter of the two greys in
       light mode; in dark mode the relationship flips, and --text-muted is
       still the deliberately de-emphasized one there too, so this lightens the
       header in both themes rather than just one. font-weight trimmed to match
       — a bold uppercase heading is loud independent of its color. */
    color: var(--text-muted); font-weight:500; font-size:12.5px; text-transform:uppercase; letter-spacing:.02em;
    /* `--nav-surface`, not `--surface-1`: EVERY list heading, not just the
       editable ones.
     *
     * The grey heading band came from `table.entry-grid thead th`, which is the
     * inline-editing class - so a list you can edit in place had tinted
     * headings and a read-only one did not. Write-Offs and Retainers turned up
     * with white heading bands beside identical-looking screens with grey ones,
     * which reads as a rendering fault rather than as a distinction (and the
     * distinction it drew was not one anybody wanted to see). One token, defined
     * in both themes as "one step from the page", so the band is consistent
     * across the app and follows dark mode without a second definition. */
    cursor:pointer; user-select:none; position: sticky; top:0; background:var(--nav-surface);
    /* Above the body, or hovered rows paint over the headings as they pass
       under. thead comes first in the document, so without this the later
       tbody wins on paint order and the frozen row is only frozen in theory. */
    z-index: 2;
  }
  /* A matrix is read in two directions, so it gets rules in two directions.
   *
   * Every other list in the app is read one row at a time — one record, left to
   * right — and horizontal rules alone are right for that; vertical ones would
   * be ink that helps nothing. A capability matrix is the exception: you trace
   * across a row for one person's profile and down a column to compare people
   * on one skill, and in a grid wide enough to scroll, the eye has no way to
   * hold a column without a line to follow.
   *
   * The employee column's right edge is heavier, because it is the anchor the
   * whole grid is read against and it stays put while the rest scrolls. */
  /* Darker than the rest of the app's rules, on purpose.
     `--gridline` is tuned for a list, where a faint line is a resting place for
     the eye between rows and anything heavier reads as clutter. A matrix is
     read *along* its lines — tracing a row to find a person's level, tracing a
     column to compare people — so the line is doing work rather than staying
     out of the way, and at list weight it disappears against the cells. */
  /* Back to a light rule now that the cells are shaded.
     The heavy line was compensating for flat cells — with nothing but a
     dropdown in each, the eye needed the rule to hold a column. Shaded cells do
     that job themselves and do it better, so a dark rule on top is a second
     grid drawn over the first. Light enough to separate, quiet enough to let
     the shading read as a continuous scale. */
  /* One colour per skill level, indexed by rank — l1 is the lowest rung.
     Six of them, so a firm that adds levels beyond the default four still gets
     distinct fills before the sequence repeats.

     Each fill carries its own text colour rather than inheriting the body's.
     Expert is full-strength amber rather than a tint, and it wants dark text —
     white on it measures about 1.9:1, which is worse than the near-black it
     replaced. Pairing fill and foreground here keeps one from being changed
     without the other being reconsidered; that pairing already earned its keep
     once, when Expert was a dark green needing white. The same values serve
     both themes: these are chips carrying their own contrast, so they do not
     need to follow the page from light to dark. */
  /* Third pass on this rule's weight, and the first to state the actual test.
   *
   * It went heavy (cells were flat and the eye needed the line), then back to
   * `--gridline` once the cells were shaded, on the reasoning that shading
   * separates columns by itself. That is true only where adjacent cells differ.
   * Two people both "Familiar" in neighbouring skills produce two #D8D8D8 cells
   * touching, and at #e1e0d9 the rule between them is invisible — they read as
   * one wide block spanning two skills, which is a misread of the data rather
   * than a blemish.
   *
   * So the test is not "is the rule visible on the page background" but "is it
   * visible between two cells of the SAME fill". This value clears every fill
   * in the scale below, and is still lighter than the heavy rule that was
   * rejected for drawing a second grid over the first. */
  table.matrix-grid {
    --matrix-rule: #a9a79d;
    /* Between the #E0E0E0 that read as an unstyled cell and the #BDBDBD that
       read as heavy. Still clearly a fill against the unfilled "not assessed"
       cells beside it, which is the distinction this rung has to hold. */
    --matrix-l1: #D8D8D8;  --matrix-t1: #0b0b0b;  /* Familiar          */
    --matrix-l2: #FFF9C4;  --matrix-t2: #0b0b0b;  /* Working knowledge */
    --matrix-l3: #C8E6C9;  --matrix-t3: #0b0b0b;  /* Proficient        */
    /* Pink 200. A hue nothing else on the scale uses — l1 grey, l2 lemon,
       l3 green — so Expert separates from every rung at a glance. Not blue,
       which is this app's chrome colour: buttons, links and the unsaved-cell
       ring are all blue, so a grid of blue cells competed with the controls
       sitting in them. Pink has no other job here. */
    --matrix-l4: #F48FB1;  --matrix-t4: #0b0b0b;  /* Expert            */
    /* Was a violet, which now clashes with l4. Teal instead — still distinct
       from every rung above it on a firm running more than four. */
    --matrix-l5: #B2DFDB;  --matrix-t5: #0b0b0b;
    --matrix-l6: #F8F0C9;  --matrix-t6: #0b0b0b;
  }
  table.matrix-grid th, table.matrix-grid td {
    border-right: 1px solid var(--matrix-rule);
    border-bottom: 1px solid var(--matrix-rule);
  }
  table.matrix-grid th:last-child, table.matrix-grid td:last-child { border-right: none; }
  /* A closed top edge, so the grid is a field rather than a run of columns.
     Every other side already resolved: the baseline under the headings, the
     rules between cells, the card's own edge below. The top was the one open
     side, which left the skill headings floating above the grid instead of
     sitting inside it — and on a table that scrolls under sticky headings, an
     open top edge is where the rows visibly pass through nothing.
     `border-top` on the cells rather than the table, because `border-collapse`
     means the table's own border sits outside the sticky headings and scrolls
     away from them. */
  table.matrix-grid thead th { border-top: 1px solid var(--matrix-rule); }
  table.matrix-grid tbody tr:last-child td { border-bottom: none; }
  /* The employee column is the anchor the whole grid is read against and it
     stays put while the rest scrolls, so its edge is heavier again.

     `box-shadow`, not `border-right`. The table is `border-collapse: collapse`,
     where a border on a cell belongs to the table's border *grid* rather than
     to the cell — and a sticky cell paints outside that grid, so the divider
     simply vanished along the frozen column. A shadow is painted by the cell
     itself and travels with it. Same trap `applyLockedColumns` documents. */
  table.matrix-grid th:first-child, table.matrix-grid td:first-child {
    box-shadow: inset -2px 0 0 var(--baseline);
  }
  /* The matrix fills its card, unlike every other table here.
     `width:auto` is right for a list, where columns should be as wide as their
     contents and the leftover is margin. A matrix is a *field* — the filters
     above it run the full width, and a grid shrink-wrapped to three skills left
     a 270px step between the bar and the thing it filters, which reads as two
     unrelated blocks. Filling means the columns share the space evenly, which
     is what a grid should look like anyway. It still scrolls when there are
     enough skills to overflow. */
  table.matrix-grid { width: 100%; min-width: 100%; }
  /* No `overflow: hidden` here, however much the rounded corners want it.
     An ancestor with a non-visible overflow becomes the clipping container for
     any `position: sticky` inside it, so hiding overflow on the table froze the
     headings to a box that scrolls away with the content — sticky in name,
     scrolled off in practice. The corners go unclipped instead; with
     `border-collapse: collapse` the radius barely reads anyway, and a heading
     row that stays put is worth more than 8px of curve. */
  /* The frame is a shadow for the same reason: the sticky heading row and the
     sticky first column both paint over a collapsed border, so the top and left
     edges of the frame disappeared exactly where those two sit. A shadow on the
     table is drawn outside the cells and cannot be covered by them. */
  table.matrix-grid {
    border-radius: 8px;
    box-shadow: 0 0 0 1px var(--baseline);
  }
  /* The heading row needs its own opaque backing and bottom rule, since it now
     floats over the body rather than sitting in the border grid. */
  table.matrix-grid thead th { box-shadow: inset 0 -2px 0 var(--baseline); }
  table.matrix-grid thead th:first-child {
    box-shadow: inset -2px 0 0 var(--baseline), inset 0 -2px 0 var(--baseline);
  }
  /* The heading row is the one place that still wants weight: it separates the
     labels from the field of shaded cells beneath them. */

  /* Matrix body cells only. The dropdown inside fills the cell, so the padding
     belongs to the control — otherwise the shaded area stops short of the
     gridlines and every cell gets a pale inner border it did not ask for.
     Scoped here in CSS rather than set inline in the cell builder, which is
     shared by every table in the app. */
  table.matrix-grid tbody td:not(:first-child) { padding: 0; }
  /* Focus is drawn on the cell, so it survives the borderless control inside.
     `outline` rather than `box-shadow`, for two reasons: an unsaved cell already
     carries an inline box-shadow and an inline style always beats a stylesheet,
     so a focused *edited* cell would have shown no focus ring at all; and the
     select draws its own ring on top by default, which stacked two rounded
     rectangles a couple of pixels apart. One ring, one property each. */
  table.matrix-grid tbody td:focus-within {
    outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 2px;
  }
  table.matrix-grid tbody select:focus,
  table.matrix-grid tbody select:focus-visible { outline: none; box-shadow: none; }

  /* Stacking inside a grid that is sticky on both axes.
     The header row must sit above the frozen name column, and the top-left
     corner above both — otherwise scrolling sideways slides skill names over
     the headings, and scrolling down slides names over the corner. Each needs
     its own opaque background too, or the cells beneath show through. */
  table.matrix-grid thead th { z-index: 3; background: var(--surface-1); }
  table.matrix-grid thead th:first-child { z-index: 4; }
  table.matrix-grid tbody td:first-child { z-index: 2; }

  /* Every row in an editable list stands the same height.
   *
   * An inline-editable list mixes two kinds of cell: an input, and plain text
   * where the row is one the server would refuse to change — an invoiced
   * purchase, a derived total. That is the right call for the *control* (an
   * input you can type into and are then told no is worse than one never
   * offered) but it left the rows two different heights, 56px against 43px,
   * alternating down the list wherever the two kinds interleaved. Reading down
   * a column across a ragged left edge is exactly the work a table exists to
   * remove.
   *
   * `:has()` scopes it to tables that actually contain a control, so a purely
   * read-only list keeps its compact rows rather than every table in the app
   * growing to input height. The height is on the cell, not the row: a `<tr>`
   * ignores it, and setting it on the input would still leave the text cells
   * short.
   *
   * 54px is the input row's own height — a 34px control plus the cell's 10px
   * top and bottom — and `height` on a cell behaves as a minimum, so it lifts
   * the short text rows without capping anything taller. Setting it lower than
   * the natural row height (36px was the first attempt) does nothing at all:
   * the text row's own 43px simply wins.
   */
  table.data-table:has(tbody input, tbody select) tbody td { height: 54px; }
  /* And the controls themselves match each other. A date input and a select
     size differently by default, which is the same raggedness one cell in. */
  table.data-table tbody td > input:not([type="checkbox"]):not([type="radio"]),
  table.data-table tbody td > select { height: 34px; }

  /* Inline-edit fields keep their full box.
     An underline was tried — slicker, and the grid stopped reading as a wall of
     rectangles — but the box is what makes it unambiguous that a cell takes
     typing rather than being a value that happens to be selectable. On a screen
     whose whole purpose is bulk correction, that is the more important of the
     two. Reverted deliberately, not by accident; the per-field min-widths that
     landed alongside it are keepers and stay. */
  /* Except in the matrix, where the control *is* the cell — see the inline
     style in skillsMatrixCard. A fixed 34px here would put the dead strip back. */
  table.matrix-grid tbody td > select { height: 100%; }

  table.data-table th.sortable:hover { color: var(--text-primary); }

  /* The column-header filter caret (headerFilterControl) — invisible until
     its own heading is hovered, so a row of seven or eight filterable
     columns is not seven or eight ▾ glyphs all the time. `.hdr-caret-active`
     (the column currently has a filter applied) is exempted: a filter you
     cannot see is on is the one thing this must never hide, hover or not. */
  table.data-table th .hdr-caret { opacity: 0; transition: opacity .12s; }
  table.data-table th:hover .hdr-caret, table.data-table th .hdr-caret-active { opacity: 1; }

  /* ---- banded report subtotals ----

     A subtotal is not another row of data, so it must not read as one. Three
     signals, because any one alone is too easy to miss on a table of 1,500
     rows: a rule ABOVE it (the line under the rows it adds up), the figures in
     bold, and a tint.

     The DETAIL rows are indented instead of the subtotal, which is the way
     round a reader expects — the summary sits at the outer level and the things
     it summarises sit inside it. Indenting the subtotal would put the total
     further in than the rows it adds up. */
  table.data-table tbody tr.band-detail > td:first-child { padding-left: 26px; }
  table.data-table tbody tr.band-row > td {
    font-weight: 600;
    border-top: 1px solid var(--border);
    background: color-mix(in srgb, var(--accent) 7%, transparent);
  }
  /* The grand total, heavier again — it closes the whole report rather than one
     band, and on a report with sixteen subtotals it has to be findable at a
     glance. A double rule is the accounting convention and costs no height. */
  table.data-table tbody tr.band-total > td {
    border-top: 3px double var(--border);
    background: color-mix(in srgb, var(--accent) 12%, transparent);
  }
  /* Hover does not tint a subtotal — it is not a row you act on, and lighting it
     up on the way past suggests it is. */
  table.data-table tbody tr.band-row:hover > td {
    background: color-mix(in srgb, var(--accent) 7%, transparent);
  }
  table.data-table tbody tr.band-total:hover > td {
    background: color-mix(in srgb, var(--accent) 12%, transparent);
  }
  /* A HEADING OPENS ITS ROWS INSTEAD OF CLOSING THEM, so the signals that say
     "subtotal" have to be taken back off.

     The rule moves to the BOTTOM — a line under a heading joins it to what
     follows, where the same line on top of a subtotal joins it to what came
     before. Keeping border-top here would draw a line above the heading and
     none below it, and the group would read as belonging to the rows above.

     No tint and no bold figures either: there are no figures on this row, and a
     filled band across the table is how a subtotal announces it carries a
     number. This one carries a name, so it is set in the body weight with a
     little space above to separate it from the previous group. */
  table.data-table tbody tr.band-heading > td {
    font-weight: 600;
    border-top: 0;
    border-bottom: 1px solid var(--border);
    background: transparent;
    padding-top: 14px;
  }
  table.data-table tbody tr.band-heading:hover > td { background: transparent; }
  /* The first heading needs no gap — it is already at the top of the table. */
  table.data-table tbody tr.band-heading:first-child > td { padding-top: 8px; }

  /* ONE STEP IN PER BAND, so practice contains manager contains the rows.
     Without this every heading sat at the same margin and the nesting was
     something you had to infer from the order.

     The outermost is heaviest and each level steps in 16px. An inner heading
     also gets LESS air above it than an outer one: the gap is what separates
     one top-level group from the last rows of the previous one, and repeating
     it before every manager would space the report out into unreadable
     stripes. */
  table.data-table tbody tr.band-heading.band-level-0 > td:first-child { padding-left: 8px; }
  table.data-table tbody tr.band-heading.band-level-0 > td { font-weight: 700; }
  table.data-table tbody tr.band-heading.band-level-1 > td:first-child { padding-left: 24px; }
  table.data-table tbody tr.band-heading.band-level-2 > td:first-child { padding-left: 40px; }
  table.data-table tbody tr.band-heading.band-level-3 > td:first-child { padding-left: 56px; }
  table.data-table tbody tr.band-heading.band-level-1 > td,
  table.data-table tbody tr.band-heading.band-level-2 > td,
  table.data-table tbody tr.band-heading.band-level-3 > td { padding-top: 6px; }

  /* Nested SUBTOTALS indent on the same scale as the headings, so a manager
     total reads as sitting inside the practice total that closes after it. The
     outermost is heaviest, matching the headings, and each level steps in. */
  table.data-table tbody tr.band-subtotal.band-level-0 > td:first-child { padding-left: 8px; }
  table.data-table tbody tr.band-subtotal.band-level-0 > td { font-weight: 700; }
  table.data-table tbody tr.band-subtotal.band-level-1 > td:first-child { padding-left: 24px; }
  table.data-table tbody tr.band-subtotal.band-level-2 > td:first-child { padding-left: 40px; }
  table.data-table tbody tr.band-subtotal.band-level-3 > td:first-child { padding-left: 56px; }

  /* Detail sits one step inside the innermost heading, whatever the depth. The
     flat 26px rule above is the subtotal shape, where there is only ever one
     level; these override it for the headings shape. */
  table.data-table tbody tr.band-detail.band-depth-1 > td:first-child { padding-left: 24px; }
  table.data-table tbody tr.band-detail.band-depth-2 > td:first-child { padding-left: 40px; }
  table.data-table tbody tr.band-detail.band-depth-3 > td:first-child { padding-left: 56px; }

  /* ---- hand-dragged column widths ----

     The grip is a 6px strip straddling the right edge of a heading. Invisible
     until the heading is hovered, for the same reason the padlock beside it is:
     a row of sixteen permanent handles is louder than any heading on the table,
     and resizing is a rare act.

     It shows a LINE rather than a pair of arrows. The cursor already says
     col-resize on hover, so the line only has to say where the boundary is —
     which is the thing that is genuinely hard to find on a table whose columns
     are separated by whitespace rather than by rules. */
  /* A PERMANENT RULE BETWEEN HEADINGS, which is the half the note above was
     missing. It correctly diagnosed the problem — a boundary is hard to find on
     a table separated by whitespace — and then answered it with a line that
     only appears once you have already found the boundary and hovered it.
     Reported twice as not being able to resize at all.

     So the heading row now carries its own dividers. They are what says the
     columns HAVE edges; the grip continues to say that this particular edge can
     be dragged.

     BODY CELLS TOO, now — which reverses the note that used to sit here.
     It argued header-only, on the grounds that ruling every cell "turns a dense
     list into a spreadsheet". That was a guess about taste dressed as a
     principle, and it was testable: the inline-editable grids (.entry-grid) have
     ruled their body cells all along, so Projects and Customers looked one way
     and Invoices another. Shown both, the full rules were preferred — and the
     spreadsheet objection is not much of an objection for a table that scrolls
     sideways past a dozen columns, which is exactly when you need to know which
     column you are reading.

     Same --gridline as the headings, which is faint enough that the rules read
     as structure rather than as a grid drawn over the data. */
  /* REVERSED AGAIN, 2026-08-28: back to no vertical rule on the body cells.
     The header keeps its own — Customize View's drag-to-resize grips live on
     that edge and still want a line to grab, and a heading row is one line
     tall regardless of table width. Asked for directly, on the same "reads
     as a spreadsheet" grounds the note above once argued and was talked out
     of; the wide-scrolling-table case that reversed it that time did not
     outweigh it this time. */
  table.data-table thead th { border-right: 1px solid var(--gridline); }
  /* Not on the last real column, and never on the spacer — `.col-spacer` exists
     to read as the margin it is, so a rule on its edge would draw a line down
     the middle of nothing. The same exclusions .entry-grid already carried. */
  table.data-table thead th:last-child,
  table.data-table tbody td.col-spacer,
  table.data-table thead th.col-spacer,
  table.data-table thead th:has(+ .col-spacer) { border-right: 0; }

  table.data-table th .col-resize-grip::after {
    content: ''; position: absolute; top: 22%; bottom: 22%; left: 2px;
    width: 2px; border-radius: 1px; background: var(--border);
    opacity: 0; transition: opacity .12s;
  }
  /* Hovering anywhere on the heading raises the grip above the divider, so the
     draggable edge separates itself from the merely visible ones. */
  table.data-table th:hover .col-resize-grip::after { opacity: 1; }
  table.data-table th .col-resize-grip:hover::after { opacity: 1; background: var(--accent, var(--text-secondary)); }

  /* A column pinned to a hand-dragged width has to CLIP, or it is not pinned.
     Headings and cells are white-space:nowrap, so without this the content
     simply overflows the width somebody dragged to and the neighbouring column
     starts underneath it — the drag would appear to do nothing on exactly the
     columns people drag, the wide ones full of text.

     Marked with a CLASS rather than sniffed out of the style attribute. The
     first version keyed on `th[style*="max-width"]`, which is a string search
     over an inline style — it would match a column that happened to set
     max-width for its own unrelated reasons, and it silently stops matching if
     the property is ever set some other way. A class says what is true. */
  table.data-table th.col-sized { overflow: hidden; text-overflow: ellipsis; }
  table.data-table td.col-sized { overflow: hidden; text-overflow: ellipsis; }

  /* PRINTING A WIDE TABLE, not scrolling one.
   *
   * A dragged-narrow or default column width is a screen-only convenience —
   * it exists because the table can scroll sideways for whatever does not
   * fit. A printed page cannot scroll, so the exact same width that was
   * merely "off to the right, one drag away" on screen becomes "gone,
   * permanently, replaced by an ellipsis" on paper: every text column on a
   * table somebody prints reads as three characters and "...". Reported on
   * the Projects list, where PROJECT/CLIENT/PRACTICE all did this — but the
   * cause is the shared table class every list in the app uses, so this was
   * never going to be a per-screen fix.
   *
   * `white-space: normal` lets a long value WRAP onto a second line inside
   * its cell instead of being clipped, and the `!important` width overrides
   * are needed because the widths in question are inline `style` attributes
   * (hand-dragged or auto-sized in JS), which normally beat any stylesheet
   * rule that is not marked important. `table-layout: auto` lets the browser
   * then size each column to what its content actually needs on the page,
   * rather than holding the on-screen proportions a print layout has no
   * reason to preserve. */
  @media print {
    table.data-table { table-layout: auto; }
    /* Every cell, not just the hand-sized ones — the base rule two blocks up
       sets white-space:nowrap on the whole table, which off-screen just grows
       the table and lets it scroll. A page cannot scroll, so an un-resized
       column full of text would otherwise run off the paper's edge instead of
       wrapping onto a second line the way a resized one now does above. */
    table.data-table th, table.data-table td { white-space: normal; }
    table.data-table th.col-sized, table.data-table td.col-sized {
      overflow: visible; text-overflow: clip; white-space: normal;
      width: auto !important; max-width: none !important;
    }
  }

  table.data-table tbody tr:hover td { background: var(--page); }

  /* The frozen first column of a time-phased grid.
   *
   * This carried `background: var(--card)` inline for as long as it has
   * existed, and there is no `--card` variable — it resolves to nothing, so the
   * column was TRANSPARENT and the week columns slid visibly underneath the
   * names. It went unnoticed because the grid's horizontal scrollbar sat at the
   * bottom of an unbounded box, hundreds of pixels below the screen: nobody
   * could scroll the column sideways, so nothing ever passed under it.
   * `--surface-1` is what `.card` and `.data-table th` actually paint with, and
   * is defined for both themes.
   *
   * As a class rather than an inline style so the hover rule above can still
   * reach it — an inline background wins over any stylesheet, which would leave
   * this one cell unhighlighted while the rest of its row lit up. */
  table.data-table th.frozen-col,
  table.data-table td.frozen-col { background: var(--surface-1); }
  table.data-table tbody tr:hover td.frozen-col { background: var(--page); }
  table.data-table td.num,
  table.data-table th.num { text-align:right; }

  /* Where the slack goes, now that the table fills its card again.
   *
   * `width:100%` shares spare width across every column equally, which gives a
   * two-digit Entries count the same 67px of extra room as a client name.
   * Measured on Rejected Timesheets: 587px of content in a 991px table, and
   * Entries and Hours were handed 124px and 114px to hold four characters.
   *
   * `width:1px` on a nowrap cell is the shrink-to-content idiom: the browser
   * cannot honour 1px because the content will not fit, so the column lands at
   * its minimum and the surplus goes to the columns that can spend it — a name,
   * a client, a rejection reason.
   *
   * A LENGTH, deliberately, not the `width:1%` that caused the original defect.
   * A percentage makes the browser resolve against the containing block and
   * forces the table to claim it; a pixel length carries no such implication.
   * That distinction is the whole lesson of this saga and is why the numeric
   * pin can come back safely while the percentage version cannot. */
  table.data-table th.num { width: 1px; white-space: nowrap; }

  /* A column whose every value is identical carries no information. Set by a
     caller that knows the list has been narrowed to one of something — the
     Schema screen filtered to a single table is the case this exists for.
     Display:none rather than a rebuild, so it follows a filter change without
     the column set having to be reconstructed. */
  .card.hide-first-col table.data-table th:first-child,
  .card.hide-first-col table.data-table td:first-child { display: none; }
  /* Numeric columns hug their content; text columns absorb the slack.
   *
   * The third answer to the whitespace question, and the one that reconciles
   * the first two. Making the card full width moved the gap rather than closing
   * it: `table-layout:auto` shares surplus width evenly, so a three-column
   * table spread 400px across Week Of, Resource and Total Hours and put a
   * canyon between each. That is precisely what the original `fit-content` rule
   * was avoiding, arrived at from the opposite direction.
   *
   * A number has a fixed, known width - nobody needs 200px to read "40.6" - so
   * `width:1%` collapses these to their content and hands everything left over
   * to the columns that can use it, which are the text ones. The result is a
   * full-width card with a full-width table whose columns sit where a person
   * would put them.
   *
   * 1% rather than 0: it is the conventional way to say "as narrow as the
   * content allows" to `table-layout:auto`, which treats a percentage as a
   * request rather than an instruction and gives the column its min-content
   * width. Combined with the `nowrap` above, that is exactly the number. */
  /* The `width: 1%` rules that used to sit here are gone, and the reason is the
     thing that kept this problem alive for six attempts.

     They were added to stop numeric and utility columns absorbing surplus while
     the table was stretched to `width:100%`. But a PERCENTAGE width on any cell
     makes the browser resolve percentages against the containing block, which
     forces the whole table to fill it — so the rule meant to stop stretching was
     itself causing it. Removing `width:100%` from the table had no visible
     effect until these went too; measured, Week of stayed at 403px for a date.

     Nothing replaces them. Cells are `white-space:nowrap`, so with the table at
     `width:auto` every column already takes exactly its content width. The
     hugging those rules were reaching for is the default once nothing is
     fighting it. */


  /* Utility columns hug too, and the header is the wrong thing to ask.
   *
   * The padding rule above matches `th:has(> input[type=checkbox])`, which is
   * correct for padding and useless for width: a tick column's HEADER is empty
   * — the checkboxes are in the body — so the selector never fires on the one
   * cell that would size the column. Measured at 102px for a 14px control,
   * because `table-layout:auto` was handing it a share of the surplus.
   *
   * Sizing from the body cell instead. In auto layout a percentage on any cell
   * is taken as a hint for the whole column, so one `td` is enough and the
   * empty `th` follows it. */

  /* ...and the header cell as well, or the column still takes a share.
   *
   * This is why the `.num` rule worked first time and this one did not: in
   * `table-layout:auto` a width on a `td` is a hint, and the column ends up at
   * max(th preferred, td preferred) plus a slice of the surplus. Constraining
   * the body cell alone leaves the unconstrained `th` free to grow — measured
   * at 102px for a 14px tick box.
   *
   * `th:empty` is the right selector rather than a positional one. A blank
   * heading is precisely what a utility column has — `recordSelectColumn` and
   * the row-action columns are all built with `label: ''` — while a column with
   * a heading is one somebody is meant to read. It needs no maintenance as
   * columns move. */

  /* The wrapper every list table sits in — and the reason frozen headings did
     not work until now.
     `table.data-table th` has carried `position:sticky; top:0` all along, but a
     box with `overflow-x:auto` is a scroll container on *both* axes as far as
     sticky is concerned, and this one never scrolled vertically. So the
     headings dutifully stuck to the top of a box that was already at its top,
     and the page scrolled past them regardless.
     Giving it a height makes it the vertical scroller too, and the headings
     stick to something that actually moves. max-height rather than height, so a
     short list is unaffected — the wrapper is only as tall as its table until
     the table outgrows the screen. The subtracted space is the topbar, the card
     heading and the filter bar above a typical list. */
  .scroll-x { overflow:auto; max-height: calc(100vh - 200px); }

  /* A number cell in a time-phased grid, typed into rather than stepped.
   *
   * The spin buttons are the point of this rule. A number input reserves room
   * for them at its right edge whether or not you ever use them, and that room
   * comes out of the text box — so a right-aligned figure stopped ~20px short
   * of the edge the read-only rows beside it align to, and one column showed
   * the same 24 in two different places depending on whether its row was
   * editable. Nobody nudges a forecast by 0.25 an arrow-click at a time
   * anyway; the field is typed into, and arrow keys still step it.
   *
   * Deliberately NOT applied to every number input in the app — where a value
   * has a natural small step and no column to line up with, the spinners are
   * worth having. This is scoped to the grid cells that have to align. */
  .cell-num::-webkit-outer-spin-button,
  .cell-num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
  .cell-num { -moz-appearance: textfield; appearance: textfield; }
  /* Borderless until touched. A forecast grid has a box like this in every
     editable cell — dozens to a screen — and the browser's own default input
     border drew every one of them at once, reading as a sheet of empty form
     fields rather than a table of numbers. The border still exists, just
     transparent, so hovering or focusing a cell does not shift its neighbours
     by the width of the line appearing. */
  .cell-num { border: 1px solid transparent; background: none; border-radius: 4px; }
  .cell-num:hover { border-color: var(--border); }
  .cell-num:focus { border-color: var(--accent); background: var(--field-bg); outline: none; }
  /* Dialogs scroll themselves and are already short; a second scroller inside
     one traps the wheel and hides the buttons at the bottom. */
  .overlay .scroll-x { max-height: none; overflow-y: visible; }
  .badge {
    display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:700;
    padding: 3px 8px; border-radius: 999px; white-space:nowrap;
  }
  .badge::before { content:''; width:7px; height:7px; border-radius:50%; background:currentColor; }
  .badge.good { color: var(--good); background: color-mix(in srgb, var(--good) 14%, transparent); }
  .badge.warning { color: #9a6a00; background: color-mix(in srgb, var(--warning) 22%, transparent); }
  :root[data-theme="dark"] .badge.warning { color: var(--warning); }
  .badge.critical { color: var(--critical); background: color-mix(in srgb, var(--critical) 14%, transparent); }
  .badge.neutral { color: var(--text-secondary); background: var(--page); }
  .legend { display:flex; gap:14px; flex-wrap:wrap; font-size:12px; color:var(--text-secondary); margin-top:8px; }
  .legend-item { display:flex; align-items:center; gap:6px; }
  .legend-swatch { width:11px; height:11px; border-radius:3px; display:inline-block; }
  .legend-line { width:16px; height:2px; display:inline-block; }
  .tooltip {
    position: fixed; pointer-events:none; background: var(--text-primary); color: var(--surface-1);
    font-size: 11.5px; padding: 6px 9px; border-radius:7px; z-index:1000; display:none;
    box-shadow: 0 4px 14px rgba(0,0,0,0.25); max-width:240px; line-height:1.4;
  }
  :root[data-theme="dark"] .tooltip { background:#2c2c2a; color:var(--text-primary); }
  .grid-2 { display:grid; grid-template-columns: 1.3fr 1fr; gap:16px; }
  .grid-2 > .card { margin-bottom:0; }
  @media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
  /* 6px, up from 4 — a touch softer, closer to the pill-shaped status badges
     beside it without actually becoming one: a value chip ("23.2", "80") and
     a status pill ("Balanced") are different things and read better staying
     visually distinct, but 4px next to a fully-rounded pill was still the
     sharpest corner on the row. */
  .heat-cell { text-align:center !important; border-radius:6px; font-size:11px; }
  .risk-list { display:flex; flex-direction:column; gap:10px; }
  /* Stacked, not the old name-left/stat-right row: at panel width the stat
     block had nowhere to sit but far off to the right of a short name, so this
     puts EAC/Budget and the variance line directly under the name and client
     they belong to instead. Name, meta and stat are three block children in
     source order, so stacking them is just flex-direction:column — no markup
     change needed, only how the row lays them out. */
  .risk-item {
    display:flex; flex-direction:column; align-items:flex-start; gap:4px;
    padding: 10px 12px; border:1px solid var(--border); border-radius:10px; background:var(--page);
  }
  /* No longer a fixed-width column now that the stat sits below rather than
     beside it — full width of the (now narrower) card, truncating with an
     ellipsis same as before. */
  .risk-item-name { width: 100%; min-width: 0; overflow: hidden; }
  .risk-item-name .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .risk-item .name { font-size:13px; font-weight:600; }
  .risk-item .meta { font-size:11.5px; color:var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .risk-item .stat { text-align:left; font-size:12.5px; margin-top: 2px; }
  /* The panel used to run the full content width to make room for the
     side-by-side stat column; stacked rows don't need that width, and a full
     bleed one-column list of short, narrow rows just leaves the right two
     thirds of the card empty. Capped instead of dropped, so it's still a
     comfortable read on a small viewport. */
  .risk-card { max-width: 420px; }
  .footnote { font-size:11px; color:var(--text-muted); margin-top:10px; }
  .practice-filter-row { display:flex; gap:6px; flex-wrap:wrap; }
  .chip-filter {
    border:1px solid var(--border); background:var(--page); color:var(--text-secondary);
    border-radius:999px; padding:4px 11px; font-size:12px; font-weight:600; cursor:pointer;
  }
  .chip-filter.active { background: var(--text-primary); color: var(--surface-1); border-color: var(--text-primary); }
  .bullet-row { display:flex; align-items:center; gap:10px; margin-bottom:10px; }
  /* 190 -> 250px — a project name routinely ran past the old width ("Test
     Project for Generic Forecast..."), and the track/value columns have
     enough of their own room to give some back. Still fixed and still
     truncates on a name that runs past even this, with the full name on
     the title tooltip either way. */
  .bullet-row .bl-label { width:250px; font-size:12px; font-weight:600; flex-shrink:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .bullet-track { flex:1; height:16px; background:var(--page); border-radius:4px; position:relative; overflow:visible; border:1px solid var(--gridline); }
  .bullet-fill { position:absolute; top:0; left:0; height:100%; border-radius:4px 0 0 4px; }
  .bullet-target { position:absolute; top:-3px; width:2px; height:22px; background:var(--text-primary); }
  .bl-value { width:150px; text-align:right; font-size:11.5px; color:var(--text-secondary); flex-shrink:0; font-variant-numeric: tabular-nums;}
  svg text { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
  .axis-label { fill: var(--text-muted); font-size: 10.5px; }
  .grid-line { stroke: var(--gridline); stroke-width:1; }
  .base-line { stroke: var(--baseline); stroke-width:1; }

  /* ---------------- app shell / sidebar ---------------- */
  .app-shell { display:flex; align-items:stretch; min-height:100vh; }
  .sidebar {
    /* --sidenav-bg carries its own tint now, so the seam between the rail
       and .app-header (--surface-1, white) is a real colour difference —
       an ordinary --border reads as a line the way it does everywhere else
       in the app, without needing --baseline's special-cased white-on-white
       treatment. */
    width: 220px; flex-shrink:0; background: var(--sidenav-bg); border-right:1px solid var(--sidenav-border);
    display:flex; flex-direction:column; padding: 16px 10px; position: sticky; top:0; height:100vh; overflow-y:auto;
    /* Above the content, because the Create flyout lives in here.
       position:sticky always makes a stacking context, so everything inside the
       sidebar is stacked as one unit against the rest of the page — and with no
       z-index that unit is ordered by document position, which puts the main
       column on top of it. The flyout's own z-index cannot escape that: it only
       ranks it against its siblings in here. Raising the sidebar itself is what
       lets the panel cover the page, and 30 keeps it under the 1000 that
       tooltips and overlays use. */
    z-index: 30;
  }
  /* ---- collapsing the rail ----

     Reported as wanting the space back, and the screenshot showed why: on a
     four-column form the rightmost fields were already clipped, and the rail is
     220px of that.

     It collapses to a STRIP, not to nothing. A control that hides itself leaves
     no way back except guessing where to click - which is now guaranteed a
     different way: the toggle sits in the header rather than in the rail, so
     collapsing cannot take its own control with it.

     Width is the only thing animated. The nav contents are hidden outright
     rather than faded, because a half-transparent menu during the transition
     reads as a rendering fault, and `visibility` keeps them out of the tab order
     while collapsed — a keyboard user must not tab into a menu they cannot see. */
  .sidebar { transition: width 120ms ease, flex-basis 120ms ease; }
  /* `min-width:0` is what actually makes this work, and it is not optional.
     The sidebar is a flex ITEM, and a flex item's default `min-width:auto` is
     its MIN-CONTENT width — so "Project Templates" and the rest of the menu hold
     the rail open however small a width is asked for. The declared 46px matched
     and was overruled by the automatic minimum, which is a silent no-op:
     the rule is right there in the stylesheet and the element simply does not
     move. */
  /* `flex-basis` as well as `width`, and it is the one that actually does the
     work here. The sidebar is a flex item, and its used main size comes from the
     basis; measured directly in the browser, setting width alone left it at
     220px and adding the basis collapsed it to 46px. Both are set so the
     declaration reads as one intention rather than a lucky property. */
  .nav-collapsed .sidebar {
    width: 46px; flex-basis: 46px; min-width: 0;
    padding-left: 6px; padding-right: 6px;
  }
  /* `display:none`, not `visibility:hidden`. A hidden box still occupies layout,
     so it keeps contributing to that min-content width — and it also stays in
     the tab order, which would let a keyboard land on a menu nobody can see. */
  .nav-collapsed .sidebar-brand,
  .nav-collapsed .side-nav,
  .nav-collapsed .sidebar-version { display: none; }

  /* Nothing to collapse on a narrow screen: the shell already stacks there, and
     a 46px strip above the content would be a bar of nothing. */
  @media (max-width: 900px) {
    .nav-toggle { display: none; }
    /* Restores the base rule exactly, so a collapsed preference carried in from
       a wide screen is a complete no-op here rather than nudging the rail by the
       padding difference. */
    .nav-collapsed .sidebar {
      width: 220px; flex-basis: auto; min-width: auto;
      padding-left: 10px; padding-right: 10px;
    }
    .nav-collapsed .sidebar-brand,
    .nav-collapsed .sidebar-version { display: block; }
    .nav-collapsed .side-nav { display: flex; }
  }

  .sidebar-brand { padding: 6px 10px 16px; }
  .sidebar-brand h1 { font-size:16px; font-weight:700; margin:0; letter-spacing:-0.01em; }
  .sidebar-brand .tag { font-size:11px; color:var(--text-secondary); }

  /* The mark and the words as one lockup, so they stay optically related when
     the sidebar narrows rather than the icon drifting away from its name. */
  .brand-lockup { display:flex; align-items:center; gap:9px; }
  /* min-width:0 so the descriptor wraps inside this column instead of forcing
     the flex row wider than the rail. */
  .brand-words { min-width:0; }
  .sidebar-brand .tag { display:block; margin-top:1px; }

  /* The collapse control sits in the brand's top-right corner, not on a row of
     its own above it.
   *
   * On its own row it pushed the whole brand block down by its own height plus
   * the gap, so the product name started a third of the way down the rail with
   * an empty strip above it - and the control it made room for is one the eye
   * goes to second, after the thing it is collapsing.
   *
   * Positioned rather than reordered, because it must stay OUTSIDE
   * .sidebar-brand in the DOM: collapsing hides the brand, and a toggle inside
   * it would hide itself, leaving no way back. That is why the markup has it as
   * a sibling, and this keeps that true while putting it where it belongs. */
  /* The collapse control goes to the FOOT of the rail, not above the brand.
   *
   * Above it, the toggle took a row of its own and pushed the product name a
   * third of the way down the rail with an empty strip over it. Tucking it into
   * the brand's top-right corner fixed the vertical problem and created a
   * horizontal one: at a 220px rail the name needs 120px, the mark takes 30 and
   * the toggle 31, so "Leprechaun PSA" wrapped to two lines. Something had to
   * leave that row, and between a logo read on every screen and a control
   * pressed once a week, it is not the logo.
   *
   * `order` and `margin-top:auto` rather than moving it in the markup: the
   * sidebar is already a flex column, so this drops it to the bottom without
   * changing the DOM - which keeps it OUTSIDE .sidebar-brand, the thing that
   * lets it survive collapsing, and keeps it early in the tab order where a
   * keyboard user meets it before the whole nav. */
  /* An icon button in the header, beside the page title. See the markup for
     why it is not in the rail any more. */
  .nav-toggle {
    flex: 0 0 auto; margin: 0; padding: 4px 8px; line-height: 1;
    font-size: 15px; color: var(--text-secondary);
    background: transparent; border: 1px solid transparent; border-radius: 7px;
    cursor: pointer;
  }
  .nav-toggle:hover { background: var(--nav-surface); color: var(--text-primary); }
  .nav-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
  /* Nothing above the brand any more, so it starts at the top of the rail. */
  .sidebar-brand { padding-top: 0; }
  /* Sized to the two lines beside it — cap height of the title through to the
     baseline of the tag — so the mark reads as the same weight as the words
     rather than as a bullet in front of them. */
  /* The mark keeps the company's green rather than following --accent.
   *
   * It is a brand asset, not a piece of UI chrome: the hat is green everywhere
   * else the business appears, and a hat that turned blue because somebody
   * picked a different accent in Preferences would be the logo answering to the
   * wrong authority. This is the usual treatment for a logo that predates a
   * design system - the mark holds its own colour, the interface around it stays
   * on the accent.
   *
   * Two values because one green cannot serve both themes: the deep green that
   * reads as brand on an off-white card is nearly invisible on near-black. */
  .brand-mark {
    width:28px; height:28px; flex:0 0 28px;
    color: var(--brand-mark);
    display:block;
  }
  .side-nav { display:flex; flex-direction:column; gap:2px; }
  /* Anchors, not buttons.
     Each nav item carries a real href so the browser can open it in a new tab
     or window; everything about how it looks stays as it was, which is why the
     selectors below list both rather than swapping one for the other. */
  .side-nav a { text-decoration:none; display:block; }
  /* PRIMARY, not secondary.
   *
   * These were `--text-secondary`, a step down from body text, which on a
   * column of destinations reads as de-emphasis somebody chose — closer to
   * "unavailable" than to "the way around this app". The Create flyout beside
   * them renders its entries at full strength, and once the two were visible
   * together the sidebar looked like the muted one. Asked for on that
   * comparison.
   *
   * The GROUP LABELS stay `--text-muted` (see .side-nav-group-label below), and
   * that is what carries the hierarchy: labels recede, destinations do not. It
   * was never the items' job to be quiet — they were only quiet because the
   * headings had nothing else to be quieter than. */
  /* TIGHTER, so more of the rail is above the fold.
   *
   * Timesheets is what most of the firm opens every week and it sat well down a
   * list of screens most of them never touch. Reordering the rail was tried and
   * reverted — moving a group above the ungrouped links files those links under
   * its heading — so the space each row takes is the lever that is left.
   *
   * 8px to 6px on the items and 14px to 10px above a group heading. Two pixels a
   * row over twenty-odd rows and four at each of nine headings is most of a
   * screenful, and nothing moves relative to anything else, so nothing changes
   * meaning. The hit target stays above 30px with the 13px line box. */
  .side-nav button, .side-nav a {
    appearance:none; border:none; background:transparent; color:var(--text-primary); text-align:left;
    /* 14px left, not 10 — see the note on .side-nav-group-label's own
       padding-left. Bumped by the same 4px so item text stays flush with
       heading text; this isn't indentation, it's shared breathing room for
       the chevron that sits in headings' left gutter. */
    padding: 6px 10px 6px 18px; font-size:13px; font-weight:600; cursor:pointer; border-radius:8px;
    white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  }
  /* The colour no longer changes on hover — it was already primary — so the
     background is the whole signal, which is how the Create menu marks its rows
     too. `color` is kept so the rule still wins against anything that darkens
     or lightens a link on hover by default. --baseline, not --gridline: at
     only ~5 RGB units off --sidenav-bg, --gridline barely registered as a
     change on this darker rail — --baseline is the same step this rail's own
     border already reaches for to read as a change regardless of context. */
  .side-nav button:hover, .side-nav a:hover { background: var(--baseline); color:var(--text-primary); }
  /* Tint + left bar, not a solid fill.
     A solid accent block down the sidebar — repeated on every screen, since
     something is always active — read as heavier than an active-state
     indicator needs to be, especially stacked above the tab strip's own solid
     pill for the same accent. Text carries the colour instead (bold
     --accent-strong, same contrast math as the old white-on-fill: measured
     against the tint rather than --nav-surface since that's what it now sits
     on), and the left bar keeps the "you are here" cue at a glance even
     without reading the tint. padding-left drops 3px so the bar's width
     doesn't shift the label against inactive rows. */
  .side-nav button.active, .side-nav a.active {
    /* 20%, not 12% — at 12% the tint read as a plain grey pill,
       indistinguishable from :hover's flat --baseline fill, since accent
       barely lifts a light background before the eye reads it as "just
       another neutral shade" rather than "blue". 20% is what actually
       reads as tinted at a glance. */
    background: color-mix(in srgb, var(--accent) 20%, var(--sidenav-bg));
    color: var(--accent-strong, var(--accent));
    border-left: 3px solid var(--accent-strong, var(--accent));
    /* 15px, not 18 — the 3px border eats into the gap, so this keeps text at
       the same 18px from the row's outer edge as every inactive item. */
    padding-left: 15px;
  }
  /* `--text-muted`, overriding the 4.5:1 floor on purpose.
   *
   * Was `--text-secondary`: measured on the sidebar's own background, muted
   * comes out at 2.91:1, against a 4.5:1 threshold, on the smallest text in
   * the rail at 10.5px. Asked to lighten it back anyway — same call already
   * made for placeholder text elsewhere in the app — so this is deliberately
   * sub-floor. Group labels still read plainly quieter than the destinations
   * below them, which is the job of a group label; legibility on a laptop at
   * an angle is the accepted tradeoff.
   *
   * `.side-nav button.side-nav-group-label` too: a collapsible group's label
   * is a <button>, and `.side-nav button` (below) sets its own color at
   * higher specificity (class+element beats a single class) — so this color
   * was losing to --text-primary on every collapsible heading regardless of
   * what it said. That was true before this edit too: the --text-secondary
   * this replaced never actually rendered on a single group in the rail. */
  .side-nav-group-label, .side-nav button.side-nav-group-label {
    font-size:10.5px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--text-muted);
    /* 18px above, up from 10 — reported as the gap before a new section
       reading the same as the gap between two items inside one, so nothing
       marked where a section actually ended.
       18px left, up from 10 — matched to .side-nav button/a's own bump, so
       heading text stays flush with item text. The chevron sits absolutely
       positioned in this same gutter (see .nav-chevron below); at the old
       10px the 9px-wide glyph left almost no room before the heading text,
       which read as the two touching. 18px gives it real breathing room on
       both sides without indenting headings past where the items below them
       start. */
    padding: 18px 10px 3px 18px;
  }
  /* :first-child alone stopped ever matching once Collapse All shipped --
     addNavCollapseAll() always inserts its row directly before the first
     heading (see app.js), so that heading can no longer BE :first-child of
     #sideNav and silently fell back to the full 18px meant for a heading with
     something above it. The extra selector targets it by what's actually
     true now: the label immediately after the collapse-all row, which is
     exactly the "first heading" case this rule exists for. */
  /* Matched up to the base rule's own specificity (.side-nav
     button.side-nav-group-label, two classes plus an element) -- a plain
     `.side-nav-group-label:first-child` here loses the cascade to that rule's
     18px regardless of which selector actually matches the element, which is
     what let this go unnoticed under the DOM-order bug below for as long as
     it did. */
  .side-nav-group-label:first-child,
  .side-nav button.side-nav-group-label:first-child,
  [data-nav-collapse-all] + .side-nav-group-label,
  .side-nav [data-nav-collapse-all] + button.side-nav-group-label { padding-top:2px; }

  /* A card heading with a button ON the title line, where the card also carries
     a hint underneath. Without the inner row the button is appended after the
     whole title-plus-hint block and lands at the hint's full width away from
     the title it belongs to. */
  .cf-title-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }

  /* COLLAPSIBLE GROUPS.
   *
   * The label becomes the control, so no row is spent on a separate toggle in a
   * rail this narrow. It is a <button> in the markup only after JS upgrades it;
   * these rules restate the label's own look because a button brings a browser
   * default of its own for font, colour, background and alignment.
   *
   * `width:100%` and `text-align:left` keep the click target the full row —
   * a label you have to hit exactly is worse than one that does not collapse. */
  .side-nav-group-label {
    display:block; width:100%; text-align:left;
    background:none; border:0; font-family:inherit; cursor:default;
  }
  button.side-nav-group-label { cursor:pointer; }
  button.side-nav-group-label:hover { color:var(--text-primary); }
  button.side-nav-group-label:focus-visible {
    outline:2px solid var(--accent); outline-offset:-2px; border-radius:4px;
  }
  /* The chevron, rotated rather than swapped for a second glyph, so the two
     states are the same shape at a glance and the transition reads as one
     thing turning.

     IN THE GUTTER, NOT IN THE TEXT FLOW. Inline, it pushed every heading's
     text 14px right of the nav links below it — so "TIME & EXPENSES" no longer
     started where "To Do's" did, and the rail lost the single left edge it had
     before this control existed. Absolute keeps the heading text on that edge
     and puts the chevron in the 18px gutter the label's own padding-left
     reserves — centered in it (left:4px, leaving ~5px on the other side of
     the 9px glyph) rather than flush against the outer edge, so the icon
     reads as sitting in its own space instead of crowding either boundary. */
  .side-nav-group-label { position:relative; }
  /* `bottom`, not `top:50%`. Centering against the label's own 50% put the
     chevron at the midpoint of the WHOLE padded box — fine while padding-top
     and padding-bottom were close (10px/3px), but the 18px top padding added
     above (for section spacing) moved that midpoint well above the text
     line, which still sits flush against the constant 3px bottom padding.
     Anchoring from the bottom instead ties the chevron to the text line
     itself, so it stays put regardless of how much top padding a group has. */
  .side-nav-group-label .nav-chevron {
    position:absolute; left:4px; bottom: 5px;
    display:inline-block; width:9px; line-height:1;
    transition:transform .12s ease; transform:rotate(90deg);
    font-size:9px; opacity:.75;
  }
  .side-nav-group-label[aria-expanded="false"] .nav-chevron { transform:rotate(0deg); }

  /* HIDDEN BY COLLAPSE, and deliberately a CLASS rather than an inline style.
   *
   * `hideEmptyNavGroups()` decides whether to show a heading by reading each
   * item's INLINE style.display — that is how a permission or a firm setting
   * switches an item off. If collapsing also wrote inline display:none, that
   * function would see a group with no visible items and hide the heading too,
   * taking away the only control that could expand it again. A class keeps the
   * two mechanisms from colliding. */
  .side-nav a.nav-collapsed-item { display:none; }
  /* The running version, under the last nav group.
     Muted and small on purpose: it is reference, not navigation, and anything
     louder would read as another link in a column of links. Empty until
     /api/auth/me answers, and an empty div with padding would leave a gap that
     looks like a missing item — so the padding comes with the text. */
  .sidebar-version {
    font-size:10.5px; color:var(--text-muted); letter-spacing:.03em;
    padding: 0 10px; user-select:text;
  }
  .sidebar-version:not(:empty) { padding: 16px 10px 4px; }
  .main-col { flex:1; min-width:0; }
  main { padding: 22px 28px; max-width: 1360px; margin: 0 auto; }

  /* ---------------- forms / buttons ---------------- */
  .btn {
    appearance:none; border:1px solid var(--border); background:var(--page); color:var(--text-primary);
    border-radius:8px; padding:7px 13px; font-size:12.5px; font-weight:600; cursor:pointer;
    /* THE APP'S TYPEFACE, which every button was missing.
     *
     * A `<button>` does not inherit font-family from its ancestors — the UA stylesheet sets its own,
     * which resolves to Arial here — so every button in this app was rendering in Arial while every
     * label, heading and table cell around it used the `system-ui, -apple-system, "Segoe UI"` stack
     * from `body`. Close enough at 12.5px semibold that nobody spotted it directly.
     *
     * Found from the other end: an `a.btn` (pdfLinkBtn) measured 32px tall beside its neighbours'
     * 30px, and the cause was not padding or line-height but the fact that the two were in different
     * FONTS, so `line-height: normal` resolved differently for each. Setting this fixes the visible
     * 2px and the invisible typography at once, and it is why the same row had a history of buttons
     * disagreeing about their height. */
    font-family: inherit;
    /* A label never wraps, and a button never shrinks below it.
       .form-actions is a flex row, so a toolbar with enough buttons on it was
       compressing them past their content width and breaking labels across two
       lines - "Edit Project" rendering as "Edit / Project" at 59px wide, which
       is why some buttons in a row were 34px tall and others 22px. */
    white-space: nowrap;
    flex: 0 0 auto;
  }
  .btn:hover { background: var(--gridline); }

  /* An `a.btn` has to look like the buttons it stands in a row with.
   *
   * `pdfLinkBtn` builds an anchor — it has to, because opening a PDF in a new tab is a navigation,
   * not a click handler — and `.btn` never reset the two things a browser does to an anchor. So
   * Export PDF was the only UNDERLINED control in every action bar it appeared in, and at 32px the
   * only one two pixels taller than its neighbours' 30px, because an inline box takes its height
   * from line-height rather than from padding.
   *
   * Reported as the row looking button-clustered, with a suggestion to move Export PDF elsewhere.
   * Measured, the position was not the problem: it was the only thing in the row that did not look
   * like a button, so the eye read it as something that did not belong there. Fixing what it looks
   * like is the smaller change and the one that holds wherever else a pdfLinkBtn is used — the
   * expense drawer and the invoice screen have the same row.
   *
   * `inline-flex` with centring rather than `display:inline-block`, so the label sits on the same
   * optical centre as a real button's does at any font size. */
  a.btn {
    text-decoration: none;
    display: inline-flex; align-items: center; justify-content: center;
  }

  /* ---------------- global Create menu ---------------- */
  /* No horizontal padding on the wrapper. It had 10px, which stacked on top of
     the button's own inset and started the "+" 10px right of every nav label
     below it — close enough to look like a mistake rather than a margin. */
  .create-menu { position: relative; padding: 0 0 10px; }
  /* Specificity, not just styling. `.side-nav button` is (0,1,1) and beats a
     bare `.btn` (0,1,0) whatever the source order, so this button was silently
     rendering as plain nav text — no background, no border, nav padding. Scoping
     to `.side-nav .create-menu-btn` (0,2,0) is what lets it be a button at all.
     padding-left is 9px against a 1px border, so the glyph lands on the same
     10px as `.side-nav button`'s text. */
  /* Solid, but not accent.
     `.side-nav button.active` uses a solid accent fill to mean "this is the view
     you are on", so filling this in the same colour made it look permanently
     selected, and outlining it in that colour tied it to the same state more
     faintly. The earlier fix was to give it no colour at all — a bordered white
     box, which stopped it being confusing and left it looking like the least
     important thing in the sidebar rather than the one action in it.

     A neutral inverse solves both. It carries the weight of a primary action
     with none of the accent's meaning, and because it is built from
     --text-primary on --surface-1 it flips by itself in dark mode: near-black
     on off-white becomes white on near-black, both the right way round. */
  .side-nav .create-menu-btn {
    display: block; width: 100%; text-align: left;
    padding: 8px 13px 8px 9px; font-size: 13px; font-weight: 600;
    border: 1px solid transparent; border-radius: 8px;
    /* Grey, and quiet.
       Every coloured version of this — black, dark grey, an accent wash — read
       as louder than the active nav item beside it, which is the one thing in
       the sidebar entitled to shout. A light grey surface with ordinary text
       says "button" through its shape and border alone, and lets the blue mean
       exactly one thing again. */
    background: var(--gridline);
    border-color: var(--border);
    color: var(--text-primary); cursor: pointer;
  }
  /* `color` is restated, and that is the point.
     `.side-nav button:hover` sets color:var(--text-primary) for the nav items,
     and this button is a button inside .side-nav — so on hover the label turned
     near-black on a dark fill and disappeared. Setting the background alone was
     not enough; the inherited colour had to be overridden too. */
  /* `color` is restated because `.side-nav button:hover` sets it for the nav
     items, and this is a button inside .side-nav — without it the label follows
     that rule instead of this one. */
  .side-nav .create-menu-btn:hover,
  .side-nav .create-menu-btn[aria-expanded="true"] {
    background: var(--baseline);
    border-color: var(--baseline);
    color: var(--text-primary);
  }
  /* Out to the right, not down.
   *
   * Dropping vertically meant a menu 220px wide and taller than the sidebar,
   * with a scrollbar of its own laid over the navigation — so choosing what to
   * create hid the thing you were navigating away from, and a menu of fifteen
   * items in a 220px column ran off the bottom of shorter screens. Opening
   * sideways puts it over the content area, which is exactly the space a
   * transient menu should borrow, and leaves the whole nav readable behind it.
   *
   * `fixed`, not `absolute`. .sidebar sets overflow-y:auto, and a box with one
   * axis scrollable clips the other too — an absolutely positioned panel at
   * left:100% would have been cut off at the sidebar's edge or, worse, turned
   * the sidebar into a horizontally scrolling strip. Fixed takes it out of that
   * clip entirely; the coordinates come from the button's rect in JS, which also
   * lets it flip back to the left and shrink to fit a short viewport. */
  /* Two columns that pack independently — see the note in buildCreateMenu.
   *
   * Flex, not grid. Grid aligns rows, so a two-entry group beside a four-entry one pads the short
   * side out to match and leaves a hole under it. Two flex children each flow on their own.
   *
   * `flex-wrap` with a min-width is the whole responsive story: the panel flips to the left of the
   * sidebar on a narrow window, and when even that leaves less than two columns' worth the second
   * column wraps under the first rather than being squeezed. No media query needs to know how wide
   * the sidebar is. */
  .create-menu-list {
    position: fixed; z-index: 40;
    /* Wide enough for three 178px columns plus gaps and padding; still clamped
       to the window so a narrow viewport wraps instead of overflowing. */
    width: min(640px, calc(100vw - 24px));
    display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0 10px;
    background: var(--page); border: 1px solid var(--border); border-radius: 8px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.16);
    /* Still capped to the window. Two columns halve the height rather than removing the need: a role
       that can create everything still runs long on a short laptop screen. */
    padding: 6px; overflow-y: auto; max-height: calc(100vh - 16px);
  }
  .create-menu-col { flex: 1 1 178px; min-width: 178px; }
  /* A group and its entries move as one. */
  .create-menu-section { min-width: 0; }
  .create-menu-list[hidden] { display: none; }
  .create-menu-group {
    font-size: 11px; font-weight: 700; text-transform: uppercase;
    letter-spacing: 0.04em; color: var(--text-secondary);
    padding: 7px 8px 3px;
  }
  /* The items are the content; the group labels above them are labels.
     Both were --text-secondary at 600 and 700 — one weight step apart, same
     colour — so nothing separated a heading from the things under it, and
     emboldening the headings further would not have helped: at 10.5px, weight
     is the weakest of the three signals available. Colour is the strongest, and
     it was not being used at all.
     Items go to full-strength text; the headings keep their weight and stay
     secondary, which now reads as recessive rather than as the same thing in a
     smaller size. */
  /* `.side-nav .create-menu-item`, not `.create-menu-item` — the same
     specificity trap the Create button hit. `.side-nav button` is (0,1,1) and
     beats a lone class (0,1,0), so the `color: var(--text-primary)` written
     here was silently losing to the nav's own secondary grey. Measured, not
     assumed: the rule said primary and the computed value was #52514E.
     Two classes (0,2,0) beat one class plus one element. */
  .side-nav .create-menu-item {
    display: block; width: 100%; text-align: left; appearance: none;
    background: none; border: 0; border-radius: 6px; cursor: pointer;
    /* 13px, up from 12.5. These are click targets in a popup rather than rows in the always-on nav,
       and the extra half-pixel costs nothing now the panel is two columns wide. The headings went
       10.5 → 11 with them, so the step between label and content is unchanged — that relationship
       is deliberate and is described below. */
    padding: 6px 8px; font-size: 13px; font-weight: 500; color: var(--text-primary);
    /* These are <a href> now, so the browser's own context menu offers "Open in
       new tab" — which is the whole reason they stopped being <button>s. The
       cost is the default link decoration, which has to go back off: an
       underlined row in a menu reads as a hyperlink in prose, not as an item. */
    text-decoration: none;
    /* LONG LABELS WRAP rather than truncate.
     *
     * `.side-nav button, .side-nav a` sets nowrap + ellipsis, which is right in
     * the sidebar — a fixed-width rail where a clipped word is better than a
     * reflowed column — and wrong in here. These entries match that rule too,
     * so "Project from Template or Project" rendered as "Project from Template
     * or Proj…" in a menu whose entire job is to say what each thing creates.
     * Reported as cut off.
     *
     * Wrapping rather than widening the panel: it is already min(430px, …) in
     * two ~185px columns, and sizing the whole flyout to its single longest
     * label would push the other twenty-four entries further from the button.
     * One entry costing a second line is the cheaper trade.
     *
     * Specificity is why this works at all — (0,2,0) here beats (0,1,1) there. */
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
    line-height: 1.35;
  }
  /* The row under the cursor, unmistakably.
     `--gridline` was already here and is the colour of a table rule — at 4%
     against the menu's own surface it reads as a rendering artefact rather than
     as "this is the one you are about to click". An accent tint says it is
     interactive and matches how the nav marks the item you are on.
     `:focus-visible` too, so keyboard users get the same row highlighted rather
     than a ring that has to be hunted for. */
  .side-nav .create-menu-item:hover,
  .side-nav .create-menu-item:focus-visible {
    background: color-mix(in srgb, var(--accent) 12%, transparent);
    color: var(--text-primary);
    outline: none;
  }

  /* WHEN TO USE THIS: once or twice a screen, for the one thing the screen is
     for. Not on a row.
   *
   * The palette note on --link already argued this about links - a colour
   * "repeated down every row of every table reads as a warning rather than as a
   * list you can click" - and buttons had quietly broken the same rule. The
   * Reports catalogue drew 104 solid-accent Run buttons down one page; Shortcuts
   * gave a Remove on every row the same weight as "+ Add a Shortcut". Reported
   * as the app looking too bright, and the blue was blamed - but #2a78d6 on one
   * button is calm and on a hundred is a wall.
   *
   * A repeated row action is secondary (.btn). A destructive one that needs
   * saying so is .btn-danger. Primary is reserved for the action you came to the
   * screen to take, which by definition there is one of. */
  /* White label, so --accent-strong rather than --accent — measures higher
     contrast for white-on-fill than --accent alone does. */
  .btn-primary { background: var(--accent-strong, var(--accent)); border-color: var(--accent-strong, var(--accent)); color:#fff; }
  .btn-primary:hover { opacity:0.9; background: var(--accent-strong, var(--accent)); }
  /* The tinted twin of .btn-primary, for a standalone toggle sitting next to
     the tab strip (Calendar's "Working days") — same active-state language as
     .seg-toggle button.active and .side-nav button.active, so one toolbar
     doesn't mix a solid fill with a tint for the same kind of "this is on". */
  .btn-toggle-active {
    background: color-mix(in srgb, var(--accent) 14%, var(--page));
    border-color: var(--accent-strong, var(--accent));
    color: var(--accent-strong, var(--accent));
  }
  .btn-toggle-active:hover { background: color-mix(in srgb, var(--accent) 20%, var(--page)); }
  /* There is no separate outline-accent button, and there was one here briefly.
   *
   * The reasoning for it was that a filled accent is sized for one or two per
   * screen and a report list is forty rows. That argument is about accent
   * COLOURED TEXT repeated down a table — see the --link comment above — and it
   * does not carry over to buttons: everywhere else in this app the primary
   * action of a row is filled accent with white text, and a list where it was an
   * outline instead read as a different kind of control. Consistency about what
   * a primary button looks like beats a per-screen judgement about density.
   *
   * Anything that wants the accent uses .btn-primary, which already follows the
   * user's chosen accent through --accent. */
  .btn-sm { padding:4px 9px; font-size:11.5px; border-radius:6px; }
  /* Inside an action row every button is a peer, so they are all one size.

     btn-sm had been applied a call site at a time across ~350 buttons, and the
     result was rows like "Submit for Approval" and "Close" at 30px next to
     "Delete" at 22px — three equally consequential actions rendered as though
     one of them were a footnote. Nothing about Delete makes it smaller than
     Close; it just happened to be written later.

     Fixed here rather than by editing every call site: the rule is about the
     context a button sits in, not about the button, and a stylesheet is where
     that belongs. btn-sm keeps its meaning everywhere else — see the table rule
     below, where it genuinely earns its size. */
  .form-actions .btn-sm,
  .list-actions .btn-sm,
  /* The bulk bar is an action row too. Approve selected and Reject selected are
     the most consequential things on an approval queue and were rendering at
     footnote size beside a checkbox, for no reason other than that the bar was
     written after this rule. */
  .bulk-bar .btn-sm,
  /* A filter bar is an action row of a different shape, and the same argument
     applies with one addition: here the button's neighbours are not other
     buttons but the dropdowns and the search box it sits in line with.

     Clear Filters was btn-sm — 22px tall with a 6px radius — sitting beside
     selects that are 31px with an 8px radius, so it read as squat and slightly
     misaligned on every list screen in the app. It is not a lesser control than
     the Vendor dropdown next to it; it just happened to be written as small.

     Scoped to a button wrapped in a .form-field, which is exactly how these
     bars align a button with the labelled controls around it — so this catches
     every Clear Filters at once, and any future button placed in a filter bar,
     without six call sites having to remember. */
  .controls .form-field .btn-sm,
  .form-field > .btn-sm { padding:7px 13px; font-size:12.5px; border-radius:8px; }
  /* And genuinely small inside a table row, where a full-size button would set
     the height of every row in the grid. */
  table.data-table .btn-sm { padding:4px 9px; font-size:11.5px; border-radius:6px; }
  .btn-danger { color: var(--critical); }
  .btn[disabled] { opacity:0.5; cursor:not-allowed; }
  /* auto-fill, not auto-fit.
     auto-fit collapses the tracks nothing lands in and stretches whatever is
     left to fill the row -- so a tab with two fields on a wide screen drew two
     700px inputs with a chasm between them, and a date picker ended up wider
     than the card's title. auto-fill keeps the empty tracks, so a field is
     about 220px whether it has one neighbour or five, and a sparse tab reads as
     a short form rather than a broken one. */
  .form-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); gap:12px; margin-bottom:14px; }

  /* ---- the section rail on long edit forms ----

     A table of contents down the left of a form that is too long to see whole.
     Replaces the subtab strip these forms used to carry: tabs hid fields, and a
     hidden required control makes the browser refuse to submit *silently*.
     Nothing here is hidden — the rail scrolls, it does not switch.            */
  .form-rail-wrap { display:flex; align-items:flex-start; gap:22px; }
  .form-rail {
    /* Sticky, so the contents stay reachable from anywhere in the form. `top`
       clears the page chrome; `max-height` and its own scroll keep a form with
       a dozen sections from running the rail off the bottom of the screen. */
    /* Sized to its longest entry, between a floor and the old fixed width.
     *
     * This was a flat 178px whatever it held. That is right for the project
     * form, whose sections are sentences — "What the client pays", "What it
     * costs to deliver" — and wrong for every short one: Contacts reads
     * Details / Contact / Address, about 55px of text in a 178px column, so
     * roughly 120px of empty rail sat between the words and the fields, on top
     * of the 22px gutter. Reported as a lot of whitespace between the sidebar
     * and the form, which is exactly what it was.
     *
     * max-content up to 178px, so the long-sectioned forms are unchanged —
     * they were already at or beyond the cap and wrapped there — while a short
     * one shrinks to fit. The floor keeps the narrowest rail from becoming a
     * sliver too small to read as a column of its own. */
    position: sticky; top: 16px;
    flex: 0 0 auto; width: max-content; min-width: 104px; max-width: 178px;
    max-height: calc(100vh - 32px); overflow-y: auto;
    display:flex; flex-direction:column; gap:1px;
    border-left: 2px solid var(--border); padding-left: 2px;
  }
  .form-rail-item {
    background:none; border:none; text-align:left; cursor:pointer;
    font-size:12.5px; font-weight:500; color: var(--text-secondary);
    padding: 7px 10px; border-radius: 0 7px 7px 0; line-height:1.3;
    /* The marker is a bar on the left edge, drawn in the border colour when
       inactive so the item does not shift by 2px when it lights up. */
    box-shadow: inset 2px 0 0 transparent; margin-left:-4px;
  }
  .form-rail-item:hover { background: var(--page); color: var(--text-primary); }
  .form-rail-item.active {
    color: var(--link); font-weight:700; background: var(--page);
    box-shadow: inset 2px 0 0 var(--accent);
  }
  .form-rail-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
  /* min-width:0 or the grid inside refuses to shrink and the flex row overflows
     its card — the standard flex-child trap, and the reason a wide select could
     push the whole form sideways. */
  .form-rail-body { flex: 1 1 auto; min-width: 0; }
  /* Enough room above a section heading to clear the form's action bar.
   *
   * 16px only cleared .form-rail's own sticky offset, which put the heading just
   * below the top of the viewport — and the Save Changes / Cancel bar sits directly
   * above the first section, inside this same column, so it was scrolled off the top
   * and clipped by the browser chrome. Pressing a rail entry to reach a section
   * should not cost you the buttons that commit it.
   *
   * A fixed value rather than measuring the bar: it is one row of buttons on every
   * form that has a rail, so its height does not vary, and a magic number here is
   * cheaper than a resize observer on every one of these forms. */
  .form-rail-section { scroll-margin-top: 64px; }
  /* --section-title: a shade darker than plain --text-secondary, short of
     --text-primary's near-black — see the token's own definition for why. */
  .form-rail-section .section-title { color: var(--section-title); }

  /* Room under the last section so every rail entry can actually reach the top.
     Without it the bottom half of the rail is decorative: once the page is
     scrolled as far as it goes, clicking "Delivery rules" or "Approvers" cannot
     move anything, so the click does nothing and the marker stays on whichever
     section happens to sit at the threshold. Reported as a rail that does not
     work, and it was - for every entry below the fold.

     The trailing space USED to be `min(60vh, 520px)`, to manufacture enough
     scroll room that a SHORT last section could rise past the rail's 140px
     threshold and light its entry. Reported as "too much whitespace", and that
     was right: on a tall window it left a blank half-screen under every long
     form, and it was worst exactly when the last section was small — one field
     followed by 450px of nothing.

     The rail does not need it any more. Its scroll handler now treats reaching
     the bottom of the scroller as "you are in the last section", which is better
     evidence than any threshold and costs no layout at all. What is left is an
     ordinary gap under the last field. */
  .form-rail-body { padding-bottom: 24px; }

  /* Below the point where a 178px rail plus a 220px field column still fit, the
     rail stops being navigation and starts being the reason the form is
     cramped. It becomes a plain heading list above the fields — every section
     is still on the page, which is the property that matters. */
  @media (max-width: 720px) {
    .form-rail-wrap { display:block; }
    .form-rail {
      /* min/max-width are reset, not just width: here the rail is a full-width
         wrapping row of headings, and the column constraints above would cap it
         at 178px and squash the row into a narrow stack. */
      position: static; width: auto; min-width: 0; max-width: none;
      max-height: none; flex-direction: row;
      flex-wrap: wrap; border-left: none; padding-left: 0; margin-bottom: 14px;
      border-bottom: 1px solid var(--border); padding-bottom: 8px;
    }
    .form-rail-item { margin-left:0; border-radius:7px; box-shadow:none; }
    .form-rail-item.active { box-shadow:none; }
  }
  /* 6px, up from 4px, at explicit request — a label sitting right against the
     box it names read as cramped on a form with several rows stacked close
     together. */
  .form-field { display:flex; flex-direction:column; gap:6px; }
  /* 12.5px and a darker grey, up from 11.5px at --text-secondary.
   *
   * Reported as "can barely read the revenue item" — and the field it named was
   * not unusual, it is every label on every form in the app. 11.5px of 600-weight
   * grey is legible when you already know what it says, which is exactly the
   * reader this styling was tuned for and not the one who needs it. A point up
   * and a step darker costs nothing in a grid whose rows are sized by their
   * inputs, and it is the single highest-traffic piece of text here. */
  /* align-self:flex-start, against .form-field's default stretch — a plain-text
     label does not need the fix, but a label whose content IS a help button
     (see formField's link-btn branch) does: a <label> forwards its click to any
     labelable control inside it, including a nested <button>, over its WHOLE
     box — not just where the text sits. Stretched to the cell's full width, that
     turned the blank space beside "Start" into a working part of the help
     button, opening the popup on a click nowhere near the visible link. */
  .form-field label { font-size:12.5px; font-weight:600; color:var(--text-primary); align-self: flex-start; }
  /* A checkbox sits under the left edge of its label, not stretched across the
     cell. `.form-field` is a column flex, so the default `align-items:stretch`
     gave the box the full width of the field and the tick drifted away from the
     word naming it — on a wide form-grid cell that put "Active" at the left and
     its checkbox inches away at the right, reading as two unrelated things. */
  .form-field > input[type="checkbox"],
  .form-field > input[type="radio"] { align-self: flex-start; width: auto; margin: 2px 0 0; }
  /* A checkbox field with no label ABOVE it sits on the same line as its neighbours'
     controls, not at the top of the cell.
   *
     `.form-field` is a column of label-then-control, so a field whose only child is the
     tickbox-and-its-words lands where everybody else's LABEL is — a row too high. On the
     receipt form that floated "Billable to client" above the baseline of the Payment Type
     select beside it, which reads as a misaligned control rather than as a missing label.
     Pushing the single row to the bottom of the cell lines it up with the real controls. */
  .form-field.check-field { justify-content: flex-end; }
  /* A content bar sitting under a document bar gets room between them.
   *
   * The two had 4px of margin each, so 8px total — close enough that the groups read as
   * one wrapped row of nine buttons rather than as two rows meaning two different things.
   * The point of splitting them is that the top acts on the RECORD and the bottom on its
   * ROWS, and that distinction only survives if you can see the join.
   *
   * An explicit class, not an adjacent-sibling selector. `.form-actions + .bulk-bar` was
   * the obvious rule and it matched nothing: on both screens the content bar is the first
   * child of a wrapper div, so it is not a sibling of the bar above it. A selector that
   * silently applies to no element is the stylesheet version of a setting nothing reads.
   *
   * Qualified by the bar classes so it BEATS them. Bare `.stacked-bar` is one class, the
   * same weight as `.form-actions`, which sets margin-top:4px further down this file and so
   * won on order alone — the class was on the element and changed nothing. Specificity
   * rather than moving the rule, because "this works as long as nobody reorders the
   * stylesheet" is not a property worth relying on.
   *
   * `.form-actions` only. A `.bulk-bar` writes its own margin into style.cssText, and an
   * inline declaration beats any stylesheet rule at any specificity — so those bars set the
   * gap inline where they are built, and listing them here would be a selector that cannot
   * take effect. */
  /* 8px, not 12. Enough that the two rows read as two groups — the document actions above,
     the content actions below — and no more than that. At 12 the gap was wide enough to
     start reading as a separator between unrelated sections of the screen, which they are
     not: both rows act on the week that is open. */
  .form-actions.stacked-bar { margin-top: 8px; }
  /* A utility column holds an icon, so it does not need a name's worth of gutter.
   *
   * The timesheet row opens with three of them — a tick box, a delete icon and a row menu —
   * and each was getting the same 14px either side as a column holding "Atlas Capital
   * Partners". Three columns of mostly padding before the first real value, which pushed
   * Date and everything after it to the right.
   *
   * Padding only. This is not another go at per-column WIDTH: sortableTable carries a long
   * note about four attempts at that and why each failed, and padding is the one lever that
   * does not feed into table-layout:auto's surplus sharing. */
  /* CENTRED, because everything in one is a fixed-width glyph.
   *
   * A tight column holds a checkbox, a row menu or a small marker - never text
   * that has to line up with anything. Left-aligned inside a cell padded on
   * both sides, each one sat a few pixels off from the header above it and from
   * the control in the row below, which reads as a wobble down the left edge of
   * every grid. Centring is what makes a column of 13px boxes look like a
   * column. */
  th.tight-col, td.tight-col {
    padding-left: 4px; padding-right: 4px;
    text-align: center;
  }
  /* The control itself, not just the text node around it. */
  td.tight-col > * { margin-left: auto; margin-right: auto; }

  /* The cell that absorbs a table's spare width — see the ROUND FIVE note in sortableTable.
   *
   * `width:100%` on one cell of a `table-layout:auto` table hands it everything left over
   * after the other columns have taken what their content needs, so those columns stop being
   * stretched. Nothing else changes: the table still fills the card, and a table already
   * wider than its container has no surplus, so this collapses to nothing.
   *
   * No padding and no border, so it reads as margin rather than as an empty column somebody
   * forgot to fill — which is the whole difference between this and the `growIdx` attempt
   * that put the gap inside a column with a value in it. */
  th.col-spacer, td.col-spacer {
    width: 100%;
    padding: 0;
    border-left: 0;
    border-right: 0;
  }

  /* ============ .grid-dense: an editable grid, sized for controls ============
   *
   * Set by sortableTable's `dense` opt. The reported problem was having to scroll right on
   * the receipt grid every single time; the cause was measured rather than guessed at, and it
   * was four separate things, none of them a column width:
   *
   *   Measured: 11 columns, table 1409px inside a 1002px panel — over by 407px.
   *     - 264px  td padding, 12px a side on every cell
   *     -  52px  the date input's min-width:172px, for mm/dd/yyyy
   *     -  73px  Client (214px) and Project (159px) selects, sized by their longest option
   *     -  65px  the Billable cell, 78px wide around a 13px checkbox — the HEADING sets that
   *
   * Deliberately NOT another attempt at per-column width. sortableTable carries a long note
   * about four failed goes at that, and the reason they failed applies here too: with
   * table-layout:auto the browser sizes a column from its content, so the way to make a
   * column narrower is to make its CONTENT narrower. Every rule below does that, or changes
   * padding — the one lever that never feeds into surplus sharing. */

  /* 1. The gutter. A control draws its own border, so it does not also need a text column's
     worth of air either side to be told apart from its neighbour. */
  .data-table.grid-dense th,
  .data-table.grid-dense td { padding-left: 6px; padding-right: 6px; }

  /* 2. The date input. 172px is deliberate elsewhere — it makes a date in a grid the same
     width as the same field on a form, which is worth having — but that reasoning is about
     matching a FORM, and this is a grid where the horizontal budget is the scarce thing.
     118px still holds mm/dd/yyyy and the picker glyph. Scoped, so the global stays. */
  .data-table.grid-dense input[type=date] { min-width: 118px; width: 118px; }

  /* Softer date fields on the staffing plan grid specifically -- asked for
     directly: the shared 8px radius reads as a heavy capsule at this
     field's dense, table-row width, and the row's other cells (plain text,
     square selects) don't share that roundness. Scoped rather than
     lowered on the shared input[type=date] rule, which every date field
     in the app — including full-width forms, where 8px was never a
     complaint — draws from. */
  .plan-grid input[type=date] {
    border-radius: 5px;
    border-color: color-mix(in srgb, var(--border) 65%, transparent);
  }

  /* 3. The two long selects. Capped, not fixed: a short option list stays narrow, and a long
     client name truncates instead of setting the width of the column for every other row.
     The full value is still in the control — it is what the select shows when opened. */
  .data-table.grid-dense select { max-width: 150px; }

  /* 4. The checkbox column. `width:1px` on an auto-layout table means "as narrow as the
     content allows", and the content is a 13px box; what was actually holding it open at
     78px was the word "Billable" in the header, so the header has to be allowed to wrap. */
  /* Both centered, not just the cell. The header carries this class too — the
     shared table builder puts cellClass on th and td alike — but only the td
     rule set text-align, so "BILLABLE" stayed left-aligned above a column of
     centered checkboxes. Confirmed with exact measurements: every other
     header/cell pair in this grid lines up pixel-for-pixel; this was the one
     genuine mismatch. */
  /* A little extra breathing room, at explicit request — the header and its
     checkbox already measure aligned to well under a pixel, but a column
     sized to the exact minimum a word needs reads as cramped regardless.
     Padding, not a wider `width`, so the column still shrinks to content on
     an install where "Billable" is renamed shorter. */
  .data-table.grid-dense th.check-col { width: 1px; white-space: normal; text-align: center; padding-left: 18px; padding-right: 18px; }
  .data-table.grid-dense td.check-col { width: 1px; text-align: center; padding-left: 18px; padding-right: 18px; }

  /* ---------------- entry grids ----------------
   *
   * A table you TYPE INTO, as opposed to one you read.
   *
   * Reported as "the whole PSA is very white" — asked about the staffing, time and expense entry
   * screens specifically, and right about those. The low-chrome look is correct for a read-only
   * list and is what every current app of this kind does; those are deliberately untouched here.
   * An entry grid is a different object. Every cell holds a control, you are tracking one row
   * across eight or eleven columns while typing, and with no rules and no active-row cue the
   * calm reads as "which row am I in".
   *
   * Four things, in the order they earn their keep:
   *
   * 1. THE FOCUSED ROW. `:focus-within` is the whole point of this block — the row you are
   *    typing in is tinted and carries an accent bar at its left edge, so on a grid wider than
   *    the screen you can look at the far right column and still know which row you are on.
   *    Nothing else here comes close to being as useful, and it costs no space.
   * 2. Vertical rules. In a reading table these are noise, which is why `.data-table` has only
   *    horizontal ones. Where every cell is a control they say where a cell begins — the thing
   *    the 12px gutter used to do before `.grid-dense` correctly took it away for width.
   * 3. A filled header. The column labels already exist; they just float over the first row.
   * 4. A muted fill on cells that cannot be typed into — a computed total, a capacity, a row
   *    number. Without it a figure the grid worked out looks exactly like a figure you are
   *    expected to supply, which is a worse fault than any of the above and the only one here
   *    that can cause a wrong entry rather than a slow one.
   *
   * `--gridline` and `--nav-surface` rather than new colours: both are defined in each theme and
   * `--nav-surface` is the token that means "one step from the page, in whichever direction that
   * is for this theme" — it goes darker in light mode and lighter in dark. A hard-coded grey
   * would have been right in exactly one of the two.
   *
   * The class is added by sortableTable when a table turns out to contain editable controls, so
   * it follows what a grid IS rather than a flag somebody has to remember — see the note there.
   */
  table.entry-grid thead th {
    background: var(--nav-surface);
    border-bottom: 1px solid var(--baseline);
    /* The label lines up with the VALUE, not with the control's box.
     *
     * Asked about as "shouldn't column headers be centre aligned or something?" — and the answer
     * to that is no: a heading takes its column's alignment, left over text and right over
     * figures, because a centred label over left-aligned data stops belonging to the column it
     * names. But the instinct behind the question was right, because something *was* out.
     *
     * Measured: the header text and the control's outer box both start at the same x, and the
     * control's own text starts 10px further in — its 1px border plus 9px padding. So every
     * label in every entry grid sat ten pixels to the left of the value beneath it, on every
     * column, which is exactly the kind of thing that reads as "off" without being nameable.
     *
     * `text-indent` rather than padding, so it is unaffected by `.grid-dense` cutting the cell
     * gutter from 12px to 6px — the offset being corrected is the control's, not the cell's.
     * `th.num` is deliberately left alone, after two attempts at the mirror correction.
     *
     * A right-aligned heading over a right-aligned control looked like the same 10px problem, and
     * it is not. `padding-right` widens the COLUMN, and the cell below is right-aligned, so the
     * control's right edge travels with the heading — the +10px correction measured out at −6px
     * because both edges moved. `translate` (which does not affect layout) then landed on −6 too,
     * which is what finally gave away the real figure: the uncorrected overhang on a number input
     * is about 4px, not 10, because a spin button sits inside its right padding. Four pixels, and
     * a value that depends on which control the column happens to hold. A nudge tuned to one
     * browser's spinner is worse than no nudge, so this stays as it is. */
    text-indent: 10px;
  }
  table.entry-grid thead th.num { text-indent: 0; }
  /* Same correction, same reasoning, for the opposite case: an action column
     (Print, a row's tick box) holds no bordered control to align against — its
     cell is a bare link or icon sitting flush with the padding, not indented
     by a control's own border and inner padding. The 10px meant to close the
     gap above a control instead opened one, pushing "Print" 10px right of the
     "PDF" link beneath it. */
  table.entry-grid thead th.action-col { text-indent: 0; }
  /* Same exception, opted into per column rather than inferred: a lead
     identifier column (Purchase #) is never inline-editable even though the
     row around it is, so it holds a plain link like an action column does —
     just with a real label and a sort key, which is why isActionColumn does
     not (and should not) catch it automatically. */
  table.entry-grid thead th.no-indent { text-indent: 0; }
  /* Rules between cells, not around the table — the card already draws the outside. */
  table.entry-grid tbody td,
  table.entry-grid thead th { border-right: 1px solid var(--gridline); }
  /* Not on the last real column, and never on the spacer: `.col-spacer` exists to read as the
     margin it is, and a rule on its left edge would draw a line down the middle of nothing. */
  table.entry-grid tbody td:last-child,
  table.entry-grid thead th:last-child,
  table.entry-grid tbody td.col-spacer,
  table.entry-grid thead th.col-spacer,
  table.entry-grid tbody td:has(+ .col-spacer),
  table.entry-grid thead th:has(+ .col-spacer) { border-right: 0; }

  /* The row being typed in.
   *
   * `background` on the cells rather than the row, because `tr` backgrounds sit behind `td`
   * ones and `.data-table tbody tr:hover td` already paints the cell — a row-level rule would
   * simply lose to it. Sticky first columns declare their own opaque background too (they have
   * to, or the scrolled content shows through), so those are named explicitly.
   *
   * A tint of the accent, not a grey: the accent is already what this app uses for "the thing
   * you are on" in the nav rail and on a primary button, and 7% keeps it a wash rather than a
   * highlight fighting the text on top of it. */
  table.entry-grid tbody tr:focus-within td {
    background: color-mix(in srgb, var(--accent) 7%, var(--surface-1));
  }
  /* The left edge marker. `inset` box-shadow rather than a border, so the row does not shift by
     2px the moment it takes focus — a grid that twitches as you tab across it is worse than one
     with no marker at all. */
  table.entry-grid tbody tr:focus-within td:first-child {
    box-shadow: inset 2px 0 0 var(--accent);
  }

  /* A cell the grid fills in. Set by a caller that knows the value is computed — a total, a
     capacity, a row number — so it is visibly not somewhere to type. */
  /* No `text-align` here. Alignment is a property of the COLUMN — `.num` already right-aligns
     figures — and a row number under a left-aligned "#" heading would have been pushed to the
     far side of its own column by a rule about editability. */
  table.entry-grid tbody td.computed-cell {
    /* `--nav-surface`, the same token as the header fill above — not `--page`.
     *
     * `--page` was the first choice and measured as a 3-unit difference in light mode
     * (#f9f9f7 against #fcfcfb), which is the shade `tr:hover` already uses and far too quiet to
     * carry "you cannot type here". Sharing the header's fill also says the right thing: a
     * computed cell and a column heading are both chrome rather than data. */
    background: var(--nav-surface);
    color: var(--text-secondary);
  }
  /* Focus still wins on a computed cell's row, or the highlighted row would break in the middle
     wherever a computed column happens to sit. */
  table.entry-grid tbody tr:focus-within td.computed-cell {
    background: color-mix(in srgb, var(--accent) 7%, var(--nav-surface));
  }

  /* QUIET UNTIL THE ROW IS WANTED, matching the same tr:hover / tr:focus-within
   * split every other row-level rule above already uses — a full grid of
   * permanently bordered, backgrounded selects and inputs reads as a wall of
   * boxes on a row nobody is touching yet. Asked for directly.
   *
   * `border-color`/`background-color`, never the `border`/`background`
   * shorthand — see the note on the base `select` rule above this file's own
   * documented history of that shorthand silently erasing the chevron
   * background-image. Getting that wrong here would be the exact same bug a
   * second time, on the one grid in the app where it would be least visible
   * until someone went looking for a dropdown that had stopped opening.
   *
   * tr:hover REVEALS for a mouse; tr:focus-within is what makes this safe for
   * everyone else — tabbing into a row with a keyboard, or a tap on a touch
   * device that focuses the field before hover ever could. Hover-only would
   * leave a touch user staring at plain text with no sign any of it opens for
   * editing. */
  table.entry-grid tbody select,
  table.entry-grid tbody input:not([type="checkbox"]) {
    border-color: transparent; background-color: transparent;
  }
  table.entry-grid tbody tr:hover select,
  table.entry-grid tbody tr:hover input:not([type="checkbox"]),
  table.entry-grid tbody tr:focus-within select,
  table.entry-grid tbody tr:focus-within input:not([type="checkbox"]) {
    border-color: var(--border); background-color: var(--field-bg);
  }
  /* A computed cell never had a real field in it to begin with, so it has no
     resting state to hide — it must not go transparent along with everything
     else, or "you cannot type here" (see .computed-cell above) vanishes
     exactly when nothing else marks the row as read-only. */
  table.entry-grid tbody td.computed-cell select,
  table.entry-grid tbody td.computed-cell input:not([type="checkbox"]) {
    border-color: var(--border); background-color: transparent;
  }

  /* The date field's calendar glyph, separately — border-color/background-color
     above never touched it, because the icon is a ::-webkit pseudo-element the
     base rule (see input[type=date]::-webkit-calendar-picker-indicator,
     earlier in this file) opacity-fades to 0.45 app-wide, permanently. That
     default is deliberate everywhere else — a form field gets one date input,
     and a permanent hint that it opens a picker is worth the pixel there. On
     an entry-grid row of six or seven date cells stacked in a column, the same
     icon repeated at 0.45 opacity down every row is exactly the "boxes and
     picker glyphs everywhere" this row was just quieted for — asked for
     directly, once the border fix above made the remaining icon obvious as
     the one thing still permanently on. Same reveal triggers as the rest of
     the row, so it stays reachable without a mouse. */
  table.entry-grid tbody input[type="date"]::-webkit-calendar-picker-indicator {
    opacity: 0;
  }
  table.entry-grid tbody tr:hover input[type="date"]::-webkit-calendar-picker-indicator,
  table.entry-grid tbody tr:focus-within input[type="date"]::-webkit-calendar-picker-indicator {
    opacity: 0.45;
  }

  /* `background-color`, NOT the `background` shorthand — and this rule is why
   * that warning is written at the base select rule above.
   *
   * The shorthand resets every background longhand it does not mention,
   * including background-image. So this line erased the chevron on every
   * select inside a .form-field, which is most of the selects in the app, and
   * the `padding: 7px 9px` below erased the 26px of right padding that was
   * reserving room for it. Two properties, one silently undoing the glyph and
   * the other undoing the space kept for it.
   *
   * What that looked like: a dropdown with no visible arrow whose long option
   * text ran to the very edge of the box, or collided with the arrow on the
   * screens where a different rule had restored it. Reported as dropdown text
   * rendering strangely throughout the app, which is exactly what it was — one
   * declaration, on every form. */
  .form-field input, .form-field select, .form-field textarea {
    font-size:13px; border:1px solid var(--border); background-color:var(--field-bg);
    color:var(--text-primary);
    border-radius:8px; padding: 7px 9px; font-family:inherit;
  }
  /* select and input[type=date] already get this exact outline (see their own
     rules elsewhere in this file) — text inputs and textareas never did, so
     focusing "Scenario name" fell back to the browser's raw default ring
     while the field beside it got the app's own. Reported as looking harsh
     and out of place against everything else on the form; it was actually
     inconsistent, not just loud. */
  .form-field input:focus-visible, .form-field textarea:focus-visible,
  .controls input[type="text"]:focus-visible, .controls input[type="search"]:focus-visible {
    outline: 2px solid var(--accent); outline-offset: 1px;
  }
  /* The room for the glyph, restated after the padding above overrode it.
     Not folded into that rule because inputs and textareas have no chevron and
     should not carry the gap. */
  .form-field select { padding-right: 26px; }
  /* An open list box has no closed state and therefore no chevron — the base
     rule makes that exception and it has to survive this one too. */
  .form-field select[multiple],
  .form-field select[size]:not([size="1"]) { padding-right: 9px; }
  .form-field textarea { resize:vertical; min-height:56px; }

  /* Date fields.

     The browser draws these itself, and left alone they read badly next to the
     other controls: the digits are a proportional face, so 07/20/2026 and
     08/23/2026 sit at different widths and the two boxes never line up, and the
     picker glyph is a hard black square that pulls the eye away from the value.

     Tabular figures fix the alignment — every digit takes the same width, so
     dates stack in a column. The glyph is toned down to match the muted icon
     weight used elsewhere and brightens on hover, so it reads as a control
     rather than a blemish. Slightly more letter-spacing because a run of digits
     and slashes is harder to scan than words at the same size. */
  /* A date field looks the same wherever it is.

     Border, padding and font size were scoped to `.form-field input`, so the
     Start and End boxes on a form got the app's styling and the identical
     From/To inputs in the bulk grid - same element, same helper - fell back to
     the browser's default date control. Same widget, two appearances, and the
     bare one is cramped enough that hitting the month segment to type into it
     is fiddly, which reads as "you have to use the calendar icon".

     Styling the element rather than its container fixes both: the grid fields
     now have the same frame and the same room to type in as the form ones. The
     min-width is what actually buys the typing back - all three segments need
     to be comfortably clickable, and a date input squeezed into a table cell is
     not. */
  /* Month pickers get the identical treatment.
     
     These rules were scoped to type=date, so every `input[type=month]` in the
     app — the revenue period on Close a period and Recognize, and anything
     added later — fell back to the browser's default control: a different
     border, a different height and a black picker glyph, sitting beside styled
     inputs on the same card. Same widget class, same problem the date fields
     already had solved, so it is the same fix rather than a second one. */
  .form-field input[type=date],
  .form-field input[type=month],
  input[type=month],
  input[type=date] {
    font-size: 13px;
    border: 1px solid var(--border);
    /* --field-bg with the rest: a date box is a field you type into. */
    background: var(--field-bg);
    color: var(--text-primary);
    border-radius: 8px;
    padding: 7px 9px;
    font-family: inherit;
    /* One width everywhere.
       
       148px was enough to type into but still visibly narrower than the same
       field on a form, so the bulk grid and the single-forecast form above it
       did not look like the same control. A date is a fixed-width thing -
       mm/dd/yyyy plus the picker glyph - so it may as well be sized once and
       be identical in every context. */
    /* 190, not 172. The tabular figures and the extra letter-spacing this rule
       sets below make the digits wider than a default face, and at 172 the last
       characters of mm/dd/yyyy were clipped against the picker glyph on the
       report filter row -- reported as the date range text being cut off. The
       glyph is drawn inside the box, so the width has to carry both. */
    min-width: 190px;
    width: 190px;
    box-sizing: border-box;
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum";
    letter-spacing: 0.02em;
  }
  /* ...but 172px is a floor, not a fixed size, once the field is in a grid.
   *
   * The rule above sizes a date once so it never looks narrower than the same
   * field on a form beside it. In `.form-grid` that inverts: the cells are
   * ~242px and every select fills its cell, so a 172px date left 71px of dead
   * space inside its own cell — a gap that reads as a missing control rather
   * than as padding, because the fields either side of it have none.
   *
   * Filling the cell serves the original intent better than the fixed width
   * did: the point was that a date and a select on one card should look like
   * the same control, and matching the cell is how the selects achieve it.
   * `min-width` still stands, so a date in a tight container — the bulk
   * forecast grid the note above was written for — keeps its floor. */
  .form-grid .form-field input[type=date],
  .form-grid .form-field input[type=month] { width: 100%; }

  input[type=date]::-webkit-datetime-edit,
  input[type=month]::-webkit-datetime-edit { padding: 0; }
  /* An unfilled date input still renders "mm / dd / yyyy" at full text colour,
     which is exactly as loud as a real date. On a form that is harmless; on the
     bulk forecast grid it is ten date fields where the eight blanks shout over
     the two that mean something.

     Muted, the placeholders recede into a hint and the filled dates carry the
     row. The class comes from markDateEmptiness in app.js - there is no
     :placeholder-shown for date inputs and no pseudo-class for "empty", so the
     state has to be handed to the stylesheet. Focus restores full contrast, so
     the field you are actually typing into is never the faint one. */
  input[type=date].date-empty::-webkit-datetime-edit { color: var(--text-muted); }
  input[type=date].date-empty:focus::-webkit-datetime-edit { color: var(--text-primary); }
  input[type=date].date-empty::-webkit-calendar-picker-indicator { opacity: 0.3; }
  /* The separators carry no information — greying them lets the numbers read as
     three groups instead of one run of eight characters. */
  input[type=date]::-webkit-datetime-edit-text,
  input[type=month]::-webkit-datetime-edit-text { color: var(--text-muted); padding: 0 1px; }
  input[type=date]::-webkit-calendar-picker-indicator,
  input[type=month]::-webkit-calendar-picker-indicator {
    opacity: 0.45;
    cursor: pointer;
    padding: 2px;
    border-radius: 4px;
    transition: opacity .12s ease, background-color .12s ease;
  }
  input[type=date]::-webkit-calendar-picker-indicator:hover {
    opacity: 1;
    background: color-mix(in srgb, var(--text-primary) 10%, transparent);
  }
  /* Dark mode inverts the glyph, which ships as a black icon. */
  :root[data-theme="dark"] input[type=date]::-webkit-calendar-picker-indicator { filter: invert(1); }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) input[type=date]::-webkit-calendar-picker-indicator { filter: invert(1); }
  }
  /* Focused, the field should look as deliberate as a text box does. */
  input[type=date]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
  /* Wraps to a second line rather than squeezing. The project toolbar runs to
     ten buttons on a wide install and more on a narrow window, and a row that
     overflows should become two rows, not ten crushed buttons. */
  .form-actions { display:flex; align-items:center; gap:10px; margin-top:4px; flex-wrap:wrap; }
  /* Right-aligned in a dialog, where a two-or-three-button row reads left to
     right ending on the action taken -- Cancel, then the primary button at the
     far right edge. Left as the default (flex-start) everywhere else: many
     .form-actions bars are page toolbars ("+ Add a row", "Select All") that are
     meant to sit at the start of the row they're in, not float to the far side. */
  #appOverlay .form-actions,
  .app-dialog .form-actions { justify-content: flex-end; }
  /* An empty status span takes no room.
   *
   * These bars carry a message span and often a "N rows changed" caption, both empty most
   * of the time — and an empty flex child still collects the 10px gap on each side. On the
   * timesheet week that put 20-odd pixels of nothing between "+ Add a row" and "Select
   * All", which reads as a mis-aligned toolbar rather than as two absent captions.
   *
   * `:empty` matches an element with no children and no text, which is exactly the state
   * these are in before anything is typed — so the space comes back the moment there is
   * something to say, without any JavaScript toggling it. */
  .form-actions > .form-msg:empty,
  .form-actions > .subtext:empty,
  .bulk-bar > .form-msg:empty,
  .bulk-bar > .subtext:empty { display: none; }
  .form-msg { font-size:12px; }
  /* A REFUSAL IS A BLOCK, not a line of small red text.
   *
   * This was 12px red text beside whichever button was pressed, which is why
   * errors were routed to a toast instead -- a sentence explaining that a change
   * order adds fee without adding budget is four lines long and hopeless at that
   * size. But a toast is fixed to the top centre of the viewport, and that is
   * exactly where an open dialog's heading and first fields are: the message
   * covered the form it was about by 68 measured pixels, so you could not read
   * the explanation and see the field it named at the same time.
   *
   * Same treatment .form-msg.warn already uses, for the same reason it uses it:
   * coloured text alone is too quiet to read as a message. With this, an error
   * can stay inside the dialog -- see handleFormSubmit -- where it is beside the
   * thing it is talking about and stays put while you fix it. */
  .form-msg.error {
    color: var(--critical);
    background: color-mix(in srgb, var(--critical) 10%, transparent);
    border: 1px solid color-mix(in srgb, var(--critical) 35%, transparent);
    border-radius: 6px; padding: 8px 10px; line-height: 1.45;
    font-size: 12.5px;
  }
  /* Inside a dialog it is a full-width band above the buttons rather than
     something wedged beside them, because that row is a flex line and a
     four-line message squeezed into it pushes the buttons off the end. */
  #appOverlay .form-actions > .form-msg.error,
  .app-dialog .form-actions > .form-msg.error { flex: 1 1 100%; order: -1; margin: 0 0 4px; }
  .form-msg.success { color: var(--success-text); }
  /* Between success and error: something worth acting on that has not stopped
     anything. The attachment storage cap is the case it was added for — the
     upload went through and an administrator should still know. Amber text on
     its own is too low-contrast to read as a message, so it gets the tinted
     block the badge uses, at message rather than badge weight. */
  .form-msg.warn {
    color:#9a6a00; background: color-mix(in srgb, var(--warning) 16%, transparent);
    border:1px solid color-mix(in srgb, var(--warning) 45%, transparent);
    border-radius:6px; padding:8px 10px; line-height:1.45;
  }
  :root[data-theme="dark"] .form-msg.warn { color: var(--warning); }
  .inline-actions { display:flex; gap:6px; flex-wrap:wrap; }

  /* Row actions in a TABLE CELL do not wrap.
   *
   * `flex-wrap:wrap` is right where this class sits in a card — a long row of buttons in a
   * narrow panel should fold rather than overflow. Inside a table cell it does the opposite
   * of what it looks like it does: table-layout:auto sizes a column to its content, but a
   * wrapping flex box can always shrink, so the browser is free to satisfy an arbitrarily
   * narrow column by stacking the buttons instead of widening it.
   *
   * Measured on Skill Categories: the cell was 67px and the box 55px, where one row needs
   * 113px — so Retire sat on top of Delete, 50px tall, and read as two separate controls
   * belonging to different things.
   *
   * nowrap removes the browser's escape route, so the column has to claim the width its
   * content needs. Where that makes a table wider than its panel, .scroll-x already handles
   * it — a table that scrolls is a known state, whereas buttons stacking in one column looks
   * like a rendering fault. */
  table.data-table td > .inline-actions { flex-wrap: nowrap; }

  /* ---------------- settings ---------------- */
  .setting-row {
    /* flex-start, not space-between. That pinned the control to the row's own
       right edge regardless of how much the text actually needed, so a
       two-line hint and a five-line one both stranded their control the same
       fixed distance away — visibly arbitrary, since the gap tracked the
       row's width rather than the text's. A fixed gap between the two boxes
       means the control now sits a constant distance from whichever text is
       actually there; the max-width below only stops a long hint running the
       full width of a wide card. */
    /* wrap, not nowrap. `.setting-control` refuses to shrink at all
       (flex-shrink:0, below) — right for a dropdown or a checkbox, wrong for
       the 320px-wide project-picklist on PTO, which on a narrower card left
       only the text free to give, and flexbox crushed it down to a couple of
       words per line rather than touch the control. Wrapping drops the
       control to its own line under the text instead, which is what the
       multiselect on that row already looks like when it is the only thing
       in a narrow rail column elsewhere in the app. */
    display:flex; flex-wrap:wrap; align-items:flex-start; justify-content:flex-start; gap:8px 48px;
    padding:12px 0; border-bottom:1px solid var(--border);
    max-width: 900px;
  }
  .setting-row:last-child { border-bottom:none; padding-bottom:0; }
  /* No flex:1 — that grew the box to fill the row (up to the control), which
     is exactly the stretch flex-start above is undoing. Sized to its own
     content instead; `.setting-hint`'s own 64ch cap already stops a long one
     from running too wide. A floor under that, so a stubborn flex-shrink:0
     control (the PTO project picklist) wraps to its own line rather than
     crushing the label into an unreadable column first. */
  .setting-text { min-width:220px; }
  .setting-label { font-size:13px; font-weight:600; }
  .setting-hint { font-size:12px; color:var(--text-secondary); margin-top:3px; line-height:1.45; max-width:64ch; }
  .setting-control { flex-shrink:0; padding-top:2px; }
  /* A DROPDOWN SIZED TO ITS LONGEST OPTION, not to the row.
   *
   * A select grows to fit its widest option, and these draw on real tables —
   * "PTO — Leprechaun Internal" and longer — so the control ran most of the way
   * across the settings page while the sentence explaining it wrapped in a
   * narrow column on the left. Reported as the dropdowns being way too wide.
   *
   * Capped rather than fixed, so a short list ("Monday") still takes only the
   * room it needs. The chosen value ellipsizes if it does not fit, which the
   * base select rule already handles, and the full text is in the open list.
   *
   * Raised from 260 to 360: with the row's own layout fixed to a constant gap
   * instead of stretching the control to the far edge (see .setting-row),
   * that original complaint no longer applies the same way, and 260 was
   * cutting off the longer options some of these lists actually have —
   * "Professional fees only (time, milestones, service fees)" on Charge
   * interest on measures 353px in the list's own font. 360 clears it with a
   * little room rather than exactly matching one option's width. */
  .setting-control select { max-width: 360px; }
  .setting-control input[type=checkbox] { width:16px; height:16px; }
  .setting-control input[type=text], .setting-control input[type=number] {
    font-size:13px; border:1px solid var(--border); background:var(--page); color:var(--text-primary);
    border-radius:8px; padding:6px 9px; font-family:inherit;
  }

  /* ---------------- coming soon / profile ---------------- */
  .coming-soon-card { text-align:center; padding: 48px 24px; }
  .coming-soon-card .icon { font-size:34px; margin-bottom:10px; }
  .coming-soon-card h2 { font-size:16px; margin: 0 0 8px; }
  .coming-soon-card p { font-size:13px; color:var(--text-secondary); max-width:480px; margin: 0 auto 6px; line-height:1.5; }

  /* ---------------- integrations (NetSuite connector) ---------------- */
  .sync-row { display:flex; gap:12px; padding:10px 0; border-bottom:1px solid var(--border); border-radius:8px; }
  .sync-row:last-child { border-bottom:none; }
  /* Reserved for rows that are actually a single clickable/draggable unit —
     Shortcuts is the one list built that way (see the handle/left/acts order
     above it). Other .sync-row users (the report catalogue, integration
     sync logs) are plain read-only rows, so this only lights up where
     hovering means something. Same rule now covers To Do's rows
     (.todo-row) — a different list, same "the whole row opens something"
     shape, so it gets the same tint rather than a look of its own. */
  .shortcut-list .sync-row:hover, .sync-row.todo-row:hover {
    background: color-mix(in srgb, var(--accent) 6%, transparent);
    cursor: pointer;
  }
  .sync-row .dir { font-size:11px; font-weight:700; letter-spacing:0.03em; color:var(--text-secondary); width:64px; flex-shrink:0; padding-top:1px; }
  .sync-row .what .label { font-size:13px; font-weight:600; }
  .sync-row .what .detail { font-size:12px; color:var(--text-secondary); margin-top:2px; line-height:1.45; }

  /* The report catalogue: the same row, at catalogue density.
   *
   * 104 rows in one scroll rather than the dozen `.sync-row` was built for, so
   * the vertical padding comes down and the column gap with it. Scoped to
   * `.report-list` and NOT changed on `.sync-row` itself, because the other
   * fifteen users of that class are short panels where 10px is right - the ask
   * was to reduce this screen without disturbing the other reporting menus.
   *
   * The three-column alignment is untouched. The tick and action spacers exist
   * so a built-in report's name lines up with a saved report's, which has a real
   * checkbox and overflow menu; tightening the gap moves both lists together and
   * keeps them aligned with each other. */
  .report-list .sync-row { padding: 5px 0; gap: 8px; border-bottom: 1px solid var(--gridline); }
  .report-list .sync-row:last-child { border-bottom: none; }
  /* The group heading had a 10px top margin inline, which on a dense list reads
     as a gap in the table rather than as a heading attached to what follows. */
  .report-list .hint { margin-top: 6px !important; margin-bottom: 2px; }
  /* Built-in rows no longer print a description here at all — it moved to a
   * hover ⓘ beside the name (see buildReportList). This class now only ever
   * holds a SAVED report's caption: its filter values and who it's shared
   * with. Usually short, but a report with several filters or a long share
   * list can still run past the card, so the clamp and cap stay as a
   * backstop rather than because this is expected to fire often. */
  .report-list .sync-row .what .detail {
    margin-top: 1px; line-height: 1.4;
    display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical;
    overflow: hidden;
    max-width: 640px;
  }
  /* The name is a control now — see drawReportCatalog. Sized and weighted like
     the heading it replaces, so the list looks the same until you point at it. */
  .report-list .sync-row .what .label .link-btn {
    font-size: 13px; font-weight: 600; padding: 0; line-height: 1.3;
  }
  .report-list .sync-row .what .label .link-btn:hover { text-decoration: underline; }
  .profile-avatar {
    width:56px; height:56px; border-radius:50%; background:var(--accent); color:#fff;
    display:flex; align-items:center; justify-content:center; font-size:20px; font-weight:700; flex-shrink:0;
  }
  /* The picture fills the same circle the initials use, so a user with one and
     a user without sit on the same shape. */
  .profile-avatar img { width:100%; height:100%; border-radius:50%; object-fit:cover; display:block; }
  /* Header variant: the same element at toolbar size. */
  .profile-avatar.sm { width:28px; height:28px; font-size:11px; }
  .profile-head { display:flex; align-items:center; gap:14px; margin-bottom:6px; }
  .profile-head .name { font-size:16px; font-weight:700; }
  .profile-head .role { font-size:12px; color:var(--text-secondary); }
  /* 500, not 600. Bold was doing the same work as the colour — shouting that
     this is clickable — and a table where every name is bold blue has no
     emphasis left to spend on the row that actually matters. */
  .link-btn {
    background:none; border:none; color: var(--link); font-size:12.5px; font-weight:500; cursor:pointer; padding:0;
  }
  .link-btn:hover { text-decoration:underline; }
  /* A neutral icon for a row-level delete, in place of a solid red button
     repeated down every row of a grid — the red boxes were reported as
     overpowering the data next to them. Muted until the row is actually
     being pointed at, which is when the destructive act deserves the
     warning colour. */
  .row-delete-icon {
    background:none; border:none; color: var(--text-secondary); cursor:pointer;
    padding:2px 4px; font-size:15px; line-height:1; border-radius:4px;
  }
  .row-delete-icon:hover { color: var(--critical); background: color-mix(in srgb, var(--critical) 10%, transparent); }
  /* A visual anchor for a text-only toggle, asked for on Staffing Plan's
     Hide/Show Filters specifically — not on .link-btn generally, since
     Clear Filters wears the same class without one. */
  .staffing-filter-toggle::before { content:'\1F50D'; margin-right:4px; font-size:11px; }
  .wbs-row {
    display:flex; align-items:center; justify-content:space-between; gap:12px;
    padding:8px 10px; border-bottom:1px solid var(--border); font-size:12.5px;
  }
  .wbs-row:last-child { border-bottom:none; }
  /* Truncates rather than pushing the dates and budget off the row - the
     View card carries the full name. */
  .wbs-name { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .wbs-meta { display:flex; align-items:center; gap:16px; color:var(--text-secondary); flex-shrink:0; }

  /* ---------------- read-only detail card ---------------- */
  /* THE SHARED SHAPE, unchanged: a two-column label/value list.
   *
   * Three callers append label and value straight into this grid as flat
   * children, and the invoice header draws on the same label and value classes.
   * Widening THIS rule to lay pairs out two-up broke all of them at once —
   * reported as "something happened to the layout of the invoice", and it had.
   * A shared class is the wrong place to answer a question asked about one
   * screen. */
  .detail-grid {
    display:grid; grid-template-columns: minmax(140px, max-content) 1fr;
    gap:6px 18px; align-items:baseline; margin-bottom:12px;
  }
  .detail-label { font-size:11.5px; font-weight:600; color:var(--text-secondary); }
  .detail-value { font-size:13px; color:var(--text-primary); word-break:break-word; }

  /* TWO PAIRS ACROSS AND A SIZE YOU CAN READ, for the record cards detailCard
   * builds and nothing else.
   *
   * A record with a dozen fields rendered as twelve stacked rows in a narrow
   * left rail, running a long way down a mostly empty card — asked for on the
   * PTO request as "a lot of fields that are downward displayed", with the far
   * right of the card doing nothing.
   *
   * `auto-fit` with a min width rather than a hard two columns: the same card
   * opens in a 640px overlay and in a full-width panel, and a fixed two-up would
   * squeeze the narrow case into unreadable columns. It falls back to one pair
   * on a phone.
   *
   * The class is added by detailCard, so every other user of .detail-grid keeps
   * exactly what it had. */
  .detail-grid--pairs {
    /* The air belongs INSIDE a pair, not between pairs.
     *
     * First pass used 320px tracks with a 120px label column and read as too
     * much space. Second pass took the label column down to max-content, which
     * closed that gap and broke something worse: every label sized itself, so
     * "Hours 36" nearly touched while the values across the card lined up on
     * nothing. A column of values that do not align is harder to read than one
     * with too much space in it.
     *
     * 280px tracks pack three across a wide card with less slack at the end of
     * each, and the label floor below is what makes the values line up. */
    /* THREE PAIRS AT MOST, and a cap on how wide the block gets.
     *
     * `1fr` tracks divide the whole card between however many pairs fit, so on a
     * wide screen each pair took a quarter of it: label and value sat together
     * at the left of a track and a void followed before the next one — asked
     * twice about, and right both times.
     *
     * Fixed-width tracks were the first answer and were worse: three 300px
     * tracks do not fit a 913px card once the gaps are counted, so it fell back
     * to two and left a third of the row empty. The width a card gets varies
     * with the screen and with whether it is in an overlay, so tuning tracks to
     * one of them is tuning to none.
     *
     * 1fr with a floor keeps the row filled at whatever width it gets, and the
     * max-width stops it spreading into a fourth column on a wide monitor -
     * which is the state that started this. */
    /* Capped at 340px rather than unbounded 1fr — asked directly, on a card
     * with only two columns' worth of fields, where 1fr stretched each track
     * to roughly half the card and left a wide gap before the second pair
     * that has nothing to do with reading it as a pair. Still fills the row
     * on a wide card with enough pairs to reach the old three-across shape;
     * just stops short of stretching a lone track to fill a card built for
     * three. */
    grid-template-columns: repeat(auto-fit, minmax(260px, 340px));
    gap:9px 20px; margin-bottom:14px;
  }
  /* Each pair is its own two-column grid, so values line up with each other
     rather than every label being sized by the longest one on the card. */
  .detail-grid--pairs .detail-pair {
    /* A FLOOR, so every value starts at the same offset and the card reads as
       columns rather than as scattered pairs — with max-content above it, so a
       long label ("Professional services") widens its own cell instead of
       wrapping. 100px is the smallest that clears the labels these cards use. */
    /* 78px, down from 100, was still not close enough — asked a third time,
       on a card of short labels ("Start", "Type") where 78px plus a 14px gap
       reads as a wide gap before a value five letters could sit right next
       to. Down to 60px and an 8px gap: still enough floor that a short label
       does not feel jammed against its value, and "Employee" (62px, the
       longest short label these cards use) simply grows past it via
       max-content rather than wrapping, same as it always has. */
    display:grid; grid-template-columns: minmax(60px, max-content) 1fr;
    gap:4px 8px; align-items:baseline;
  }
  /* Larger only here. 11.5px is smaller than anything else on a record screen
     that is meant to be read, but it is right for the dense asides elsewhere. */
  .detail-grid--pairs .detail-label { font-size:12.5px; }
  .detail-grid--pairs .detail-value { font-size:14px; }

  /* A heading that spans the row, so a long record can be grouped instead of
     being one undifferentiated list. */
  .detail-grid--pairs .detail-section {
    grid-column: 1 / -1; font-size:12px; font-weight:700; letter-spacing:.04em;
    text-transform:uppercase; color:var(--text-secondary);
    margin:6px 0 0; padding-top:8px; border-top:1px solid var(--border);
  }
  .detail-grid--pairs .detail-section:first-child {
    margin-top:0; padding-top:0; border-top:0;
  }
  /* THE CARD NARROWS, not the grid inside it.
   *
   * Capping the grid at 960 stopped the pairs spreading into a fourth column,
   * and left the card itself full width with an empty right third — a panel
   * sitting in a box a third bigger than its contents. Asked to narrow the card
   * a bit, which is the right half of the pair to move: the grid then fills what
   * it is given and there is no dead space to explain.
   *
   * Scoped to a card that CONTAINS one of these grids, so no other record screen
   * is touched. 1040 rather than 960 because the card has its own padding and
   * the heading, action row and section rules all read better with a little
   * more room than the widest row of pairs strictly needs. */
  .card:has(> .detail-grid--pairs) { max-width: 1040px; }

/* ---------------- account menu ----------------

   Behind the profile chip in the header: Profile, Dark mode, Sign in as… and
   Log out. Three of those were named buttons strung across the header; the chip
   is a better host than the "⋯" that was tried and reverted, because a face and
   a name already read as "me and my account".

   Sized to its contents rather than to a fixed width — four short labels, and a
   200px panel under a chip would be mostly empty. min-width keeps it from
   collapsing to the width of "Profile". */
.account-menu {
  position: fixed;
  z-index: 40;
  min-width: 180px;
  padding: 6px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.16);
}
.account-menu[hidden] { display: none; }
/* The header buttons keep their ids and their listeners when they move in here,
   so this rule has to fully override .theme-toggle rather than sit beside it —
   hence every property being restated instead of inherited. */
.account-menu .account-menu-item {
  appearance: none;
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: 0;
  border-radius: 6px;
  padding: 7px 10px;
  font-size: 13px;
  font-weight: 500;
  color: var(--text-primary);
  cursor: pointer;
  white-space: nowrap;
}
.account-menu .account-menu-item:hover,
.account-menu .account-menu-item:focus-visible {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  outline: none;
}

/* ---------------- form controls stay inside their grid cell ----------------

   A <select> sizes itself to its WIDEST OPTION, and a grid item's default
   `min-width: auto` lets it push its track wider than the container. Together
   those two defaults meant one long option — "Follow the default — Fixed Fee
   Milestone" on the invoice edit form — dragged the whole field past the right
   edge of the card. Reported as bleeding off the page.

   Fixed at the container rather than on that one select: every .form-grid in
   this app is `repeat(auto-fill, minmax(220px,1fr))`, so any select whose
   options run long has always been able to do this, and there are a lot of
   them. `min-width: 0` is the half that actually matters — without it the
   track grows and max-width has nothing to clamp against.

   Date and month inputs already had `width: 100%` for the same reason; this
   generalises it rather than adding a third special case. */
.form-grid > .form-field { min-width: 0; }
.form-grid .form-field select,
.form-grid .form-field input,
.form-grid .form-field textarea { max-width: 100%; }
/* Selects get width too, not just max-width: their intrinsic size is the
   longest option, so clamping alone leaves short-option selects ragged while a
   long-option one sits exactly at the edge. Uniform width reads as a column. */
.form-grid .form-field select { width: 100%; }

/* An inline list-view select with nothing chosen.

   Mirrors `.date-empty` above and exists for the same measured reason: a column
   of unset controls should recede, not shout. A set value renders at full
   strength; only the empty state is muted, so the eye lands on the rows that
   actually say something.

   Colour only — no change to border, height or width, because the control must
   still read as a control you can open. */
select.select-empty { color: var(--placeholder); }
select.select-empty:focus { color: var(--text-primary); }

/* ---------------------------------------------------------------------------
   THE BILLING-RULE PICKERS

   Three multi-selects sit side by side on a billing rule — tasks and phases,
   people, job roles — and a browser's default multi-select is the one control
   that still looks like 1998: an inset bevel, six-pixel rows, a hard system
   blue on the selected line, and a scrollbar the moment there are five things
   to choose from. Beside this app's own inputs it read as a different program.

   So it is styled as a panel of rows rather than left as a list box: the same
   border radius, field background and focus ring as every other input here,
   rows with room to be clicked at, and the selection painted in the app's
   accent instead of the system's.

   `option { padding }` and `option:checked { background }` are honoured in
   Chromium and ignored in Firefox and Safari, which fall back to the platform
   rendering inside a correctly-sized, correctly-bordered box. That is an
   acceptable floor: nothing depends on the colour, the selected rows are still
   marked by the platform, and the chips underneath name every choice in text
   regardless — see drawPicked() in app.js. The linear-gradient is the standard
   workaround for Chromium ignoring a flat background-color on an option. */
select.rule-picker {
  width: 100%;
  /* Taller than the four-row default. A rule is usually scoped to a handful of
     phases or a small team, and having to scroll a box to see six names is what
     made the checkbox version feel cramped in the first place. */
  min-height: 172px;
  padding: 5px;
  border: 1px solid var(--gridline);
  border-radius: 10px;
  background: var(--field-bg);
  color: var(--text-primary);
  font-size: 13px;
  font-family: inherit;
  line-height: 1.45;
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, .03);
  transition: border-color .12s ease, box-shadow .12s ease;
}

select.rule-picker:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(42, 120, 214, .18);
}

select.rule-picker:disabled {
  opacity: .6;
  cursor: default;
}

select.rule-picker option {
  padding: 7px 10px;
  border-radius: 7px;
  margin-bottom: 1px;
  /* The plan is indented with padding spaces to show which phase a task sits
     under, and a proportional font collapses runs of them unevenly. */
  white-space: pre;
}

select.rule-picker option:checked {
  /* --accent-strong, not --accent: white text on the lighter accent measures
     4.42:1, which is under AA for body text. The same reasoning is written out
     at the button rules above. */
  background: linear-gradient(var(--accent-strong), var(--accent-strong));
  color: #fff;
  font-weight: 600;
}

/* A chosen value, named in text under the box it came from, with an × that
   removes just that one. Sized to be a comfortable target rather than a
   decoration — the × is the removal people reach for, because ctrl-clicking a
   multi-select to deselect is the interaction everybody gets wrong. */
.rule-chip {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  max-width: 100%;
  padding: 3px 4px 3px 10px;
  border: 1px solid var(--gridline);
  border-radius: 999px;
  background: var(--surface-1);
  font-size: 12.5px;
  line-height: 1.4;
}

.rule-chip-x {
  border: none;
  background: transparent;
  color: var(--critical);
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  padding: 3px 6px;
  border-radius: 999px;
  cursor: pointer;
}

.rule-chip-x:hover:not(:disabled) { background: var(--nav-surface); }
.rule-chip-x:disabled { opacity: .45; cursor: default; }

/* ---- per-person rate overrides on a billing rule ----

   The one place a rule states money per person. As three loose grid columns on
   the card background it read as leftovers under the picker; boxed and ruled it
   reads as the small table it is, and the rate inputs line up instead of
   drifting with the length of the names beside them. */
.rule-rates {
  display: grid;
  grid-template-columns: 1fr 116px 34px;
  align-items: stretch;
  margin-top: 8px;
  border: 1px solid var(--gridline);
  border-radius: 10px;
  overflow: hidden;
  background: var(--field-bg);
}

.rule-rates-h {
  padding: 6px 10px;
  font-size: 10.5px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .05em;
  color: var(--text-muted);
  background: var(--nav-surface);
  border-bottom: 1px solid var(--gridline);
}

/* A rule between rows rather than around every cell: the columns already line
   up, so vertical rules would draw a grid over three items. */
.rule-rates-c {
  display: flex;
  align-items: center;
  padding: 5px 10px;
  font-size: 12.5px;
  min-width: 0;
  border-bottom: 1px solid var(--gridline);
}
.rule-rates-c.last { border-bottom: 0; }
/* The name column truncates rather than wrapping — a wrapped name would make
   one row twice the height of its neighbours and break the alignment the box
   exists to provide. */
.rule-rates > .rule-rates-c:first-of-type,
.rule-rates-c:nth-child(3n + 1) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; line-height: 24px; }

.rule-rate-input {
  width: 100%;
  font-size: 12.5px;
  padding: 3px 8px;
  text-align: right;
  font-variant-numeric: tabular-nums;
  border-radius: 6px;
}

/* The searchable select panel's rows (openSelectPicker) — a real :hover
   rather than JS-tracked mouseenter/mouseleave with an inline background.
   Scrolling the list under a cursor that never itself moves fires no
   mouseenter/mouseleave at all in some browsers, and under a fast wheel or
   trackpad scroll the events that DO fire can arrive out of order or get
   dropped — either way a row's mouseleave never runs, and its inline
   highlight is stuck grey until the next real pointer move. Reported as
   several rows staying highlighted at once while scrolling to pick a
   client. :hover is computed by the browser on every frame from actual
   hit-testing, so it always agrees with what is really under the pointer,
   scrolled or not — there is no discrete event to miss. */
/* The resting state this class was always meant to own (see the note above,
   "Background is the one property this class owns") — but only :hover was
   ever written, so every row's un-hovered state fell through to the
   browser's own default button background instead of the transparent this
   was supposed to be. On Chromium that default is a flat mid-grey, which is
   what made an untouched picker list look solid grey top to bottom rather
   than a plain list of rows on the white panel behind them. */
.select-picker-row {
  background: none;
}
.select-picker-row:hover {
  background: color-mix(in srgb, var(--text-muted) 12%, transparent);
}
