/* SG Property Research — futuristic console. Dark is the default identity; light is a full
   first-class token set (html[data-theme="light"]), toggled from the header and persisted.
   Every colour used anywhere lives in a token — no raw hex below the theme blocks — so the
   whole page, SVG charts included, re-themes without a re-render.

   CHART palette: two slots only (blue, orange), each pair validated all-pairs with
   scripts/validate_palette.js against its own surface:
     dark  #3987e5 / #d95926 on #10131a — CVD dE 26.8, normal dE 31.8, contrast >= 3:1
     light #2a78d6 / #eb6834 on #f7f9fc — CVD dE 24.7, normal dE 33.6, contrast >= 3:1
   Two is enough: every chart here is single-series. Do not add a slot without re-running the
   validator in BOTH modes.

   STATUS colours are genuinely reserved: --ok / --warn / --critical are NOT in the chart palette
   and never appear on a data mark, in either mode. (They were byte-identical to chart slots until
   2026-08-22; volume bars were painted in the same green that means "no losses".) */

/* ---------- fonts: SELF-HOSTED, and that is a constraint rather than a preference ----------
   The gateway serves this host under the `strict` header profile, whose CSP is `default-src
   'self'`. A Google Fonts <link> would be refused by the browser — the stylesheet from
   fonts.googleapis.com and the woff2 from fonts.gstatic.com are both cross-origin — and the page
   would silently fall back to the system stack while appearing to ask for something better. It is
   also a LAN-capable runtime that must not reach out. So the files are here.

   WHERE THEY ARE: `tools/webapp/web/fonts/`, served by webserver/server.js like any other static
   file (its MIME map gained `.woff2` for this). Four files, ~82KB total, downloaded 2026-09-05
   from Google Fonts' css2 API, which serves the VARIABLE build — one file per family per subset
   covering the whole weight axis, so 400/500/600 cost no more bytes than 400 alone.
     manrope-latin.woff2 / manrope-latin-ext.woff2               Manrope, wght 400–700
     jetbrains-mono-latin.woff2 / jetbrains-mono-latin-ext.woff2 JetBrains Mono, wght 400–500
   Both are SIL OFL 1.1; the licence texts sit beside them as OFL-Manrope.txt and
   OFL-JetBrainsMono.txt. The `latin-ext` face is not optional decoration: it is the subset that
   carries U+2020 †, which the band tables use to mark a band the source published more than once.

   `font-display: swap` on purpose. Text must be readable while ~82KB loads; the fallback stack in
   --sans / --mono below is what paints first, and it is the stack the console used until today.  */
@font-face {
  font-family: "Manrope";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/fonts/manrope-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
                 U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
                 U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Manrope";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/fonts/manrope-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
                 U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
                 U+FEFF, U+FFFD;
}
@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 400 500;
  font-display: swap;
  src: url("/fonts/jetbrains-mono-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
                 U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
                 U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 400 500;
  font-display: swap;
  src: url("/fonts/jetbrains-mono-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
                 U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
                 U+FEFF, U+FFFD;
}

:root {
  color-scheme: dark;

  --surface-0: #0a0c11;
  --surface-1: #10131a;   /* chart surface — the validated one */
  --surface-2: #161a23;
  --surface-3: #1e2430;
  --line:      #262e3d;
  --line-soft: #1b212b;

  --text-primary:   #e8ecf4;
  --text-secondary: #9aa6ba;
  --text-muted:     #8b98b5;   /* 6.4:1 on surface-1 — was #64708a at 3.7:1, below AA at 10px */

  --accent:      #4dd8e6;   /* UI chrome only — never a data mark */
  --accent-dim:  #1d5f6a;
  --accent-bg:   #0f1c20;
  --accent-soft: rgba(77,216,230,.08);
  --focus-ring:  rgba(77,216,230,.12);

  /* chart slots */
  --series-1: #3987e5;
  --series-2: #d95926;

  /* reserved status — never a series */
  --ok:       #199e70;  --ok-line:       #17503c;  --ok-bg:       #0d1f18;
  --warn:     #c98500;  --warn-line:     #4a3a1a;  --warn-bg:     #201a0d;
  --critical: #e66767;  --critical-line: #5a2b2b;  --critical-bg: #200f0f;

  --row-hover: #141a24;
  --shadow:    rgba(0,0,0,.55);

  /* search highlight (2026-10-22). An ACCENT tint, not a status colour and not a chart slot: a
     match is neither good nor bad news. The text keeps --text-primary on top of a translucent
     tint, so the contrast is the surface's own; the underline carries it for anyone who cannot
     see the tint at all. */
  --mark-bg:   rgba(77,216,230,.24);
  --mark-line: #4dd8e6;

  /* JetBrains Mono / Manrope moved to the FRONT of each stack on 2026-09-05, when they became
     self-hosted @font-face families above. The rest of each stack is unchanged and is what paints
     during `font-display: swap` and on any browser that refuses the woff2. Note the old --mono
     named "JetBrains Mono" *after* ui-monospace, so it only ever resolved if the reader happened
     to have it installed locally — it was a wish, not a dependency. */
  --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --sans: "Manrope", ui-sans-serif, system-ui, "Inter", "Segoe UI", Roboto, sans-serif;
}

/* Light: same roles, stepped for a near-white surface. Chart slots are the reference palette's
   light column; status steps are darker for text contrast and byte-distinct from every slot. */
html[data-theme="light"] {
  color-scheme: light;

  --surface-0: #eef1f6;
  --surface-1: #f7f9fc;   /* chart surface — the validated one */
  --surface-2: #ffffff;
  --surface-3: #e7ebf2;
  --line:      #d3dae4;
  --line-soft: #e4e9f0;

  --text-primary:   #141c2a;
  --text-secondary: #46536c;
  --text-muted:     #5a6a86;   /* 5.2:1 on surface-1 at 10px mono */

  --accent:      #0d7789;
  --accent-dim:  #79b9c4;
  --accent-bg:   #e4f3f6;
  --accent-soft: rgba(13,119,137,.10);
  --focus-ring:  rgba(13,119,137,.18);

  --series-1: #2a78d6;
  --series-2: #eb6834;

  --ok:       #0e7a52;  --ok-line:       #b3dcc9;  --ok-bg:       #e6f5ee;
  --warn:     #8a5f00;  --warn-line:     #e2d1a3;  --warn-bg:     #f8f0da;
  --critical: #b93838;  --critical-line: #e7bcbc;  --critical-bg: #fbeaea;

  --row-hover: #eef2f7;
  --shadow:    rgba(30,42,60,.18);

  --mark-bg:   rgba(13,119,137,.16);
  --mark-line: #0d7789;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  background: var(--surface-0);
  color: var(--text-primary);
  font-family: var(--sans);
  font-size: 14px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}
/* Faint grid — atmosphere only, sits far below the data layer. */
body::before {
  content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0;
  background-image: linear-gradient(var(--line-soft) 1px, transparent 1px),
                    linear-gradient(90deg, var(--line-soft) 1px, transparent 1px);
  background-size: 56px 56px;
  opacity: .35;
  mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, #000 30%, transparent 100%);
}
#app { position: relative; z-index: 1; }

/* ---------- chrome ----------
   TWO ROWS since 2026-09-05 (docs/design/handoff-2026-09/README.md, "Global chrome"). Row 1 is the
   identity of the deployment and the five GROUPS; row 2 is the active group's pages plus the two
   things that are about the page you are on — the band chip and the feedback button. The header
   itself is the sticky element, so both rows stick together.

   EVERY COLOUR HERE IS AN EXISTING TOKEN. The spec quotes hex values; where one differs from the
   token, the token wins (the spec says so itself), so nothing below names a colour directly. */
header.top {
  border-bottom: 1px solid var(--line);
  background: linear-gradient(180deg, var(--surface-2), var(--surface-0));
  position: sticky; top: 0; z-index: 20;
}
/* THE ROW WRAPS, AND IT WRAPS AT ITS OWN CONTENT WIDTH RATHER THAN AT A NUMBER SOMEBODY TYPED.
   Written first as `height: 52px` with no wrap and a `@media (max-width: 700px)` escape, which
   RENDERED WRONG and passed every assertion: below about 996px the right cluster could not fit,
   `justify-content: flex-end` laid it out from the right edge leftwards, and the search field
   painted straight over the ADMIN group tab. Nothing caught it — the tab was still `offsetParent
   !== null`, the body still did not scroll sideways, and only the screenshot showed it. (AGENTS.md
   says twice that this is the thing screenshots catch and assertions do not; this is the third.)

   The fix is `min-width: min-content` on the right cluster below. That clamps its hypothetical
   size to what it actually needs — search at its 60px floor, plus identity and theme, which never
   shrink — so the flex container wraps the cluster onto a second line at exactly the width where
   it would otherwise have started overlapping, whatever the signed-in name happens to be. No
   breakpoint to keep in step with the content. */
.toprow {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px 24px;
  min-height: 52px; padding: 0 24px; max-width: 1500px; margin: 0 auto;
}
.brand { display: flex; align-items: baseline; gap: 10px; flex: none; }
.brand b { font-size: 14px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; }
.brand .tick { color: var(--accent); font-family: var(--mono); font-size: 10.5px; letter-spacing: .2em; }

/* ---- row 1: the group tabs ----
   Full row height with a 2px underline on the active one, which is what distinguishes a GROUP (a
   section of the console you are inside) from a PAGE link in row 2 (a pill you can be on). They
   are <a>s, so middle-click and screen readers both work; app.js intercepts the click to reach the
   first page of the group the session can actually see. */
nav.groups { display: flex; gap: 2px; align-self: stretch; flex: none; }
nav.groups .grouptab {
  display: flex; align-items: center; padding: 0 14px;
  font-family: var(--mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-secondary); text-decoration: none;
  border-bottom: 2px solid transparent;
}
nav.groups .grouptab:hover { color: var(--text-primary); background: var(--surface-3); }
nav.groups .grouptab.on { color: var(--accent); border-bottom-color: var(--accent); }
/* `display` IS ON THE ELEMENT, so the UA stylesheet's `[hidden] { display: none }` loses unless it
   is restated here. This is the .mspop trap from further down this file, and it is the reason a
   hidden Admin tab would otherwise still render. */
nav.groups .grouptab[hidden] { display: none; }

/* ---- row 1: the right cluster ----
   The search field is the only thing here that may shrink (240px down to 60px); identity and the
   theme control are `flex: none` because a name that truncates to "Sea…" and a theme control whose
   second half falls off the row are both worse than a narrow search box. */
.topright {
  margin-left: auto; display: flex; align-items: center; gap: 8px;
  flex: 1 1 240px; min-width: min-content; justify-content: flex-end;
}
.navsearch {
  display: flex; align-items: center; gap: 8px;
  background: var(--surface-1); border: 1px solid var(--line); border-radius: 4px;
  height: 30px; padding: 0 10px;
  flex: 1 1 60px; min-width: 60px; max-width: 240px;
  color: var(--text-muted); font-family: var(--mono); font-size: 11.5px;
}
.navsearch[hidden] { display: none; }
.navsearch:focus-within { border-color: var(--accent-dim); box-shadow: 0 0 0 2px var(--focus-ring); }
.navsearch .mag { opacity: .8; flex: none; }
/* Overrides the shared `input` rule: no border, no min-width (which would refuse to shrink to
   60px), and `width: 0` so flex-basis rather than the intrinsic size decides how wide it is. */
.navsearch input {
  border: 0; background: transparent; padding: 0; min-width: 0; width: 0; flex: 1 1 auto;
  font-family: var(--mono); font-size: 11.5px; color: var(--text-primary);
}
.navsearch input:focus, .navsearch input:focus-visible { outline: none; box-shadow: none; }

/* ---- row 1: the theme segmented control ----
   Replaces a single button whose LABEL WAS ITS CURRENT STATE while its ACTION was the other one.
   Two buttons, one lit: the state is which is lit, the action is the one that is not. */
.themeseg {
  display: flex; flex: none; align-items: stretch;
  border: 1px solid var(--line); border-radius: 4px; overflow: hidden;
}
.themeseg button {
  border: 0; border-radius: 0; background: transparent; color: var(--text-secondary);
  padding: 6px 10px; font-size: 10.5px; letter-spacing: .08em; white-space: nowrap;
}
.themeseg button + button { border-left: 1px solid var(--line); }
.themeseg button:hover { color: var(--text-primary); background: var(--surface-3); }
.themeseg button.on, .themeseg button.on:hover { color: var(--accent); background: var(--accent-bg); }

/* ---- row 2: the contextual sub-nav ---- */
.subrow { border-top: 1px solid var(--line-soft); background: var(--surface-1); }
.subrow-in {
  display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
  min-height: 38px; padding: 6px 24px; max-width: 1500px; margin: 0 auto;
}
nav.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
nav.tabs a {
  font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-secondary); text-decoration: none;
  padding: 5px 11px; border: 1px solid transparent; border-radius: 3px;
}
nav.tabs a:hover { color: var(--text-primary); background: var(--surface-3); }
nav.tabs a.on {
  color: var(--accent); border-color: var(--accent-dim); background: var(--accent-bg);
  box-shadow: inset 0 0 12px var(--accent-soft);
}
.subright { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.bandchip {
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase;
  color: var(--accent); border: 1px solid var(--accent-dim); background: var(--accent-bg);
  border-radius: 3px; padding: 3px 8px; white-space: nowrap;
}
.bandchip[hidden] { display: none; }
#filefb { padding: 4px 10px; font-size: 10.5px; letter-spacing: .08em; }

main { padding: 22px 24px 32px; max-width: 1500px; margin: 0 auto; }

/* ---- the standing footer ----
   CLAUDE.md requires every report to carry "not financial advice", an as-of date and a scope. A
   console answering the same questions owes the same three, on every page rather than on the ones
   somebody remembered. The as-of date is fetched by app.js; it is never typed into this file. */
footer.foot {
  display: flex; gap: 18px; flex-wrap: wrap;
  border-top: 1px solid var(--line-soft); padding: 14px 24px;
  max-width: 1500px; margin: 0 auto;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .06em; color: var(--text-muted);
}
footer.foot .ver { margin-left: auto; }

h2.sec {
  font-family: var(--mono); font-size: 11px; letter-spacing: .18em; text-transform: uppercase;
  color: var(--text-secondary); margin: 26px 0 12px; display: flex; align-items: center; gap: 10px;
  /* WRAPS since phase 4, because a heading may now carry a control (`Ranking depth`). At 390px
     the heading, the rule and a select do not fit on one line and the page scrolled sideways —
     found by looking at the 390px screenshot, not by any assertion. A heading with no control
     has one child and never wraps, so nothing else moved. */
  flex-wrap: wrap;
}
h2.sec::after { content: ""; flex: 1; height: 1px; background: var(--line); }
p.note { color: var(--text-muted); font-size: 12.5px; margin: 6px 0 0; max-width: 78ch; }

/* ---------- panels & tiles ---------- */
.panel {
  background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 6px; padding: 16px;
  /* The basic-information table is nowrap; at phone widths it must scroll inside the panel,
     not drag the whole page sideways (468px of body scroll at 390px before this). */
  overflow-x: auto;
}
.grid { display: grid; gap: 14px; }
/* `.tiles` carries its OWN gap rather than changing `.grid` for everyone: `.grid` is also the
   two-up chart grid and the project page's chart columns, where 14px is right. (Phase 4.) */
.tiles { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
.tile { background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: 14px 16px; }
.tile .k {
  font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--text-muted);
}
.tile .v { font-family: var(--mono); font-size: 27px; font-variant-numeric: tabular-nums; margin-top: 6px; }
.tile .sub { font-size: 11.5px; color: var(--text-secondary); margin-top: 3px; }
.tile.warnish { border-color: var(--warn-line); }
.tile.warnish .v { color: var(--warn); }

/* ---------- tables ---------- */
/* Height-capped so the sticky header actually engages — an x-only scroll container never
   scrolls vertically, so `th { position: sticky }` was dead code at 222 rows. */
.tablewrap { overflow: auto; max-height: 75vh; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-2); }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th {
  font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-muted); text-align: left; font-weight: 500;
  padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap;
  position: sticky; top: 0; background: var(--surface-3);
}
td { padding: 9px 12px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: var(--row-hover); }
td.num, th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
td.dim { color: var(--text-muted); }
a.plain { color: var(--accent); text-decoration: none; }
a.plain:hover { text-decoration: underline; }

/* ---------- status badges: icon + label, never colour alone ---------- */
.badge {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .06em;
  padding: 3px 8px; border-radius: 3px; border: 1px solid;
  white-space: nowrap;
}
.badge.ok       { color: var(--ok);       border-color: var(--ok-line);       background: var(--ok-bg); }
.badge.warn     { color: var(--warn);     border-color: var(--warn-line);     background: var(--warn-bg); }
.badge.critical { color: var(--critical); border-color: var(--critical-line); background: var(--critical-bg); }

/* ---------- controls ---------- */
.filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin-bottom: 14px; }
.field { display: flex; flex-direction: column; gap: 5px; }
.field label {
  font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-muted);
}
input, select {
  background: var(--surface-1); color: var(--text-primary);
  border: 1px solid var(--line); border-radius: 3px; padding: 7px 9px;
  font-family: var(--mono); font-size: 12.5px; min-width: 120px;
}
input:focus, select:focus {
  outline: none; border-color: var(--accent-dim);
  box-shadow: 0 0 0 2px var(--focus-ring);
}
/* a.btn is the link-that-looks-like-a-button — a real <a> so it routes and focuses correctly.
   (The old markup nested <button> inside <a>, which is invalid HTML and double-tabbed.) */
button, a.btn {
  display: inline-block;
  background: var(--surface-3); color: var(--text-primary);
  border: 1px solid var(--line); border-radius: 3px; padding: 8px 15px;
  font-family: var(--mono); font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase;
  text-decoration: none; cursor: pointer;
}
button:hover, a.btn:hover { border-color: var(--accent-dim); color: var(--accent); }
button.ghost, a.btn.ghost { background: transparent; }
/* Keyboard focus is visible everywhere; the input rule above only suppresses the mouse case. */
a:focus-visible, button:focus-visible, summary:focus-visible,
input:focus-visible, select:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 1px;
}

/* ---------- shared table kit ---------- */
/* One list view = filter bar, count + pager, scrolling table, pager. The table scrolls inside
   .tablewrap; the page body must never scroll sideways, so nothing here sets a min-width. */
.listview { margin-bottom: 8px; }
/* A select sizes itself to its longest option, and "D19 · Hougang / Punggol / Sengkang" or a
   project name would otherwise throw a 500px control into a nine-field bar. The native dropdown
   still shows the full label. */
.filters select { max-width: 200px; }

/* ---------- multi-select filter ---------- */
/* A button that opens a panel of checkboxes. Every colour here is a theme token, so light and
   dark are the same rule — there is no second palette to keep in step. The panel is absolutely
   positioned so opening one does not reflow the bar under the user's pointer, and .field gets
   `position: relative` only in this case so the panel anchors to its own field. */
.field.wide { position: relative; }
.msbtn {
  display: flex; align-items: center; gap: 8px; justify-content: space-between;
  min-width: 150px; max-width: 220px; padding: 7px 9px;
  font-size: 12px; letter-spacing: 0; text-transform: none;
  background: var(--surface-1);
}
/* The label can be a district name or a project name; it truncates rather than widening a bar
   that already carries a dozen fields. The full selection is in the panel and in the URL. */
.msbtn [data-ms-sum] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msbtn .arrow { color: var(--text-muted); flex: none; }
/* A filter that is DOING something looks different from one that is not. Without this the only
   cue that five districts are selected is text inside a closed panel. */
.msbtn.on { color: var(--accent); border-color: var(--accent-dim); background: var(--accent-bg); }
.msbtn.on .arrow { color: var(--accent); }
/* `display` IS WRITTEN ON THE :not([hidden]) SELECTOR, NOT ON .mspop, and that is not a style
   preference. `hidden` is an attribute whose effect (`display: none`) comes from the USER-AGENT
   stylesheet, so ANY author rule setting `display` on the same element beats it and the element
   stays visible while every script, test and accessibility tree agrees it is hidden. The first
   version of this rule said `display: flex` on `.mspop` and every panel on the page rendered
   permanently open — through a render check whose assertions all passed, because they read the
   ATTRIBUTE. The screenshot is what caught it. */
.mspop {
  position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 240px;
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 4px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
  max-height: 320px;
}
.mspop[hidden] { display: none; }
.mspop:not([hidden]) { display: flex; flex-direction: column; }
.mssearch { margin: 8px 8px 4px; min-width: 0; }
/* The project picker carries every project in the catalogue, so the list scrolls inside the
   panel and the panel never grows past the viewport. */
.msopts { overflow-y: auto; padding: 6px 4px; flex: 1 1 auto; }
.msopt {
  display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 3px;
  font-family: var(--mono); font-size: 12px; color: var(--text-primary); cursor: pointer;
}
.msopt:hover { background: var(--surface-3); }
.msopt input { min-width: 0; width: 13px; height: 13px; accent-color: var(--accent); cursor: pointer; }
.msopt span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msfoot {
  display: flex; gap: 8px; justify-content: space-between;
  padding: 7px 8px; border-top: 1px solid var(--line);
}
.msfoot button { padding: 5px 10px; font-size: 10.5px; }
/* THE OMISSION WARNING IS ITS OWN ROW, not a line inside the field. Inside the field it made
   that field taller, and `.filters` aligns on flex-end, so the whole control jumped a line above
   its neighbours the moment a warning appeared — a filter bar that reflows when you tick a box.
   `flex-basis: 100%` in a wrapping flex row puts it on a line of its own, under everything. */
.msomit {
  flex: 0 0 100%; order: 99; margin: 2px 0 0;
  font-size: 11.5px; line-height: 1.4; color: var(--warn);
}
.listview .note { margin: 0 0 10px; }
.lvbar {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  margin: 0 0 8px; min-height: 30px;
}
.lvbar.foot { margin: 8px 0 0; justify-content: flex-end; }
.lvcount {
  font-family: var(--mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
  color: var(--text-muted);
}
.pager { display: flex; align-items: center; gap: 4px; margin-left: auto; flex-wrap: wrap; }
.pgbtn {
  padding: 4px 10px; font-size: 12px; letter-spacing: 0; line-height: 1.2; min-width: 30px;
}
.pgbtn[disabled] { opacity: .35; cursor: default; }
.pgbtn[disabled]:hover { border-color: var(--line); color: var(--text-primary); }
.pgnum {
  font-family: var(--mono); font-size: 11.5px; color: var(--text-secondary);
  font-variant-numeric: tabular-nums; padding: 0 6px;
}
.pgsize {
  display: flex; align-items: center; gap: 6px; margin-left: 8px;
  font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-muted);
}
.pgsize select { min-width: 0; padding: 4px 6px; font-size: 12px; }

/* Sort control lives inside the th so the whole header cell keeps its sticky background. */
th .sortbtn {
  background: none; border: 0; padding: 0; margin: 0; cursor: pointer;
  font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit;
  display: inline-flex; align-items: center; gap: 5px;
}
th .sortbtn:hover { color: var(--accent); border-color: transparent; }
th .sortbtn.on { color: var(--accent); }
th .sortbtn .arrow { font-size: 9px; opacity: .5; }
th .sortbtn.on .arrow { opacity: 1; }
th.num .sortbtn { flex-direction: row-reverse; }

/* Expansion: a chevron column, and a full-width detail row underneath the row it belongs to. */
th.expcol, td.expcol { width: 26px; padding-left: 10px; padding-right: 0; }
.expbtn {
  background: none; border: 0; padding: 0 2px; cursor: pointer; line-height: 1;
  font-size: 14px; color: var(--text-muted); font-family: var(--mono);
}
.expbtn:hover { color: var(--accent); border-color: transparent; }
tbody tr.open { background: var(--row-hover); }
tr.detail > td { background: var(--surface-3); padding: 12px 16px; white-space: normal; }
tr.detail:hover > td { background: var(--surface-3); }
td.wrapcell { white-space: normal; max-width: 280px; }

/* Detail panel: a definition list, two columns where there is room. */
.dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 4px 24px; margin: 0; }
.dl > div { display: flex; gap: 10px; align-items: baseline; padding: 3px 0; border-bottom: 1px solid var(--line-soft); }
.dl dt {
  font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-muted); flex: 0 0 42%;
}
.dl dd { margin: 0; font-size: 12.5px; flex: 1; min-width: 0; overflow-wrap: anywhere; }

/* Screen-reader-only label for the chevron column header. */
.vh {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

/* ---------- charts ---------- */
.chartcard { background: var(--surface-1); border: 1px solid var(--line); border-radius: 6px; padding: 14px 16px 8px; }
.chartcard h3 { margin: 0 0 2px; font-size: 13px; font-weight: 600; }
.chartcard .cap { color: var(--text-muted); font-size: 11.5px; margin: 0 0 10px; }
svg { display: block; width: 100%; overflow: visible; }
.axis text { font-family: var(--mono); font-size: 10px; fill: var(--text-muted); }
.axis line, .axis path { stroke: var(--line); }
.gridline { stroke: var(--line-soft); }
.legend { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 10px; }
.legend .item { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--text-secondary); }
.legend .swatch { width: 11px; height: 3px; border-radius: 2px; }

/* Companion data table — every chart ships its data as text, collapsed by default. */
.datatable { margin: 10px 0 6px; }
.datatable summary {
  cursor: pointer; font-family: var(--mono); font-size: 10.5px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--text-muted);
}
.datatable summary:hover { color: var(--accent); }
.datatable .tablewrap { margin-top: 8px; max-height: 320px; }

/* Tooltip: crosshair companion for line charts, per-mark for bars/dots. */
#tip {
  position: fixed; pointer-events: none; z-index: 100; opacity: 0;
  background: var(--surface-3); border: 1px solid var(--line); border-radius: 4px;
  padding: 8px 10px; font-size: 12px; box-shadow: 0 8px 26px var(--shadow);
  transition: opacity .09s; max-width: 260px;
}
#tip .t { font-family: var(--mono); font-size: 10px; letter-spacing: .1em;
          text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; }
#tip .r { display: flex; align-items: center; gap: 7px; }
#tip .r .sw { width: 9px; height: 9px; border-radius: 2px; flex: none; }
#tip .r .v { margin-left: auto; font-family: var(--mono); font-variant-numeric: tabular-nums; }

.loading { color: var(--text-muted); font-family: var(--mono); font-size: 12px; padding: 40px 0; text-align: center; }
.err { color: var(--critical); font-family: var(--mono); font-size: 12.5px;
       border: 1px solid var(--critical-line); background: var(--critical-bg); padding: 12px 14px; border-radius: 5px; }
.pill { font-family: var(--mono); font-size: 10.5px; color: var(--text-muted);
        border: 1px solid var(--line); border-radius: 999px; padding: 2px 9px; }

/* ---------- identity ---------- */
/* No new colours: everything below reuses the token set, so light and dark are peers here for
   free and there is nothing extra to re-validate. */
/* `flex: none` and `nowrap`: the spec is explicit that the search field shrinks and identity does
   not. A name truncated to "Sea…" beside a tenant truncated to "Lok F…" is a header that has
   stopped saying who you are signed in as, which is the one thing this block is for. */
.who { display: flex; align-items: center; gap: 10px; flex: none; white-space: nowrap; }
.whoami {
  display: flex; flex-direction: column; line-height: 1.2;
  font-family: var(--mono); font-size: 11px; color: var(--text-primary);
  padding: 0 4px; text-align: right;
}
.who #logout { padding: 5px 10px; font-size: 10.5px; letter-spacing: .08em; }
.whoami .whotenant {
  font-size: 9.5px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-muted);
}

/* The sign-in and set-password screens. One column, capped, centred — a form is not a dashboard. */
.authcard {
  max-width: 420px; margin: 8vh auto 0;
  background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 6px; padding: 26px 26px 30px;
  box-shadow: 0 18px 40px -28px var(--shadow);
}
.authtitle {
  font-family: var(--mono); font-size: 13px; letter-spacing: .18em; text-transform: uppercase;
  color: var(--text-primary); margin: 0 0 10px;
}
.authcard .cap { margin: 0 0 16px; }
.authcard form { display: flex; flex-direction: column; gap: 14px; }
.authcard .field input { min-width: 0; width: 100%; box-sizing: border-box; }
.authcard button { width: 100%; }
.autherr {
  font-family: var(--mono); font-size: 11.5px; color: var(--critical);
  border: 1px solid var(--critical-line); background: var(--critical-bg);
  padding: 9px 11px; border-radius: 4px;
}

/* ---------- feedback → roadmap (Phase 4) ---------- */
/* Everything here is scoped to its own classes rather than widening an existing selector: the five
   market views are reviewed and rendered, and a Phase 4 style that leaked into them would be a
   regression nobody was looking for. */

/* A small uppercase label above a block of text. Same visual role as .tile .k, standalone. */
.k {
  font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-muted); margin-bottom: 5px;
}
/* User-authored free text. pre-wrap so a paragraph the author typed survives as they typed it;
   overflow-wrap so a pasted URL cannot push the page sideways. Never innerHTML'd raw — every
   caller escapes first, and this element is the reason a <br> is not needed to do so. */
.prose {
  white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; line-height: 1.55;
  color: var(--text-primary); margin: 0;
}
.prose.dim { color: var(--text-muted); }

.fbform { max-width: 900px; margin-bottom: 18px; }
.fbform h3 { margin: 0 0 4px; font-size: 13px; font-weight: 600; }
.fbform .field { margin: 12px 0; }
.fbform .field input, .fbform textarea { width: 100%; box-sizing: border-box; }
.fbform .cap, .askcard .cap, .sxscol .cap, .listview .cap, main > .cap {
  color: var(--text-muted); font-size: 11.5px; margin: 6px 0 0;
}
textarea {
  background: var(--surface-1); color: var(--text-primary);
  border: 1px solid var(--line); border-radius: 3px; padding: 8px 9px;
  font-family: var(--sans); font-size: 13px; line-height: 1.5; resize: vertical;
}
textarea:focus {
  outline: none; border-color: var(--accent-dim); box-shadow: 0 0 0 2px var(--focus-ring);
}

.askcard {
  border: 1px solid var(--line); border-left: 3px solid var(--accent-dim);
  background: var(--surface-1); border-radius: 4px; padding: 11px 13px; margin-bottom: 12px;
}
.adden {
  border-left: 2px solid var(--line); padding: 6px 0 6px 11px; margin: 8px 0 0 6px;
}

/* THE SIDE-BY-SIDE (design §2D). Three equal columns so the ask, the standard and the evidence
   are in one field of view; below 900px they stack in the same order, which keeps the argument
   readable even though it stops being literally side by side. */
.sxs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 12px 0; }
.sxscol {
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px;
  padding: 12px 14px; min-width: 0;
}
.sxscol:nth-child(2) { border-color: var(--accent-dim); background: var(--accent-bg); }

.rmtitle { margin: 0 0 4px; font-size: 17px; font-weight: 600; }

.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 14px; }
.actmsg { font-family: var(--mono); font-size: 11.5px; color: var(--text-muted); max-width: 60ch; }
.actmsg.good { color: var(--ok); }
.actmsg.bad  { color: var(--critical); }

@media (max-width: 900px) {
  .sxs { grid-template-columns: minmax(0, 1fr); }
}

/* WHERE THE HEADER WRAPS IS NOT DECIDED HERE — `.toprow`'s `min-width: min-content` on the right
   cluster decides it, at the width where the cluster would otherwise start overlapping the group
   tabs (about 1,000px with the seeded name, and it moves with the name). This block only pays for
   the wrapped state: vertical padding, so a two-line header is not flush against its own edges,
   and a narrower gutter. The rule that used to live here was written for the ONE-row nav — it set
   `nav.tabs { width: 100% }` to give twenty-three links their own line — and none of it applies. */
@media (max-width: 1000px) {
  .toprow { padding: 8px 24px; }
  nav.groups { align-self: auto; }
  nav.groups .grouptab { padding: 6px 12px; }
}
@media (max-width: 700px) {
  .toprow { padding: 8px 14px; gap: 8px 14px; }
  nav.groups .grouptab { padding: 6px 9px; }
  .subrow-in { padding: 6px 14px; }
  main { padding: 14px; }
  footer.foot { padding: 14px; gap: 10px; }
  footer.foot .ver { margin-left: 0; }
  .authcard { margin-top: 4vh; padding: 20px; }
}
/* A phone. The right cluster's own floor — 60px of search, plus an identity block and a theme
   control that the spec says must never shrink — is about 406px wide with the seeded name, so
   below that it has to wrap INTERNALLY or the body scrolls sideways (measured: 1px at 390px, and
   more with a longer name). Wrapping it keeps the spec's rule intact: nothing here is made
   smaller, the pieces are only stacked. Same defect the old 800px block was written for, one
   layout generation later. */
@media (max-width: 520px) {
  .topright { flex-wrap: wrap; justify-content: flex-start; row-gap: 8px; }
  .navsearch { flex: 1 1 100%; max-width: none; }
}

/* v16 -- the federated sign-in link on the login card. Styled as a button but it IS an <a>: the
   CSP is script-src 'self', so this has to be a plain navigation rather than a scripted control.
   Both themes come free because every colour here is a token. */
.oauthbtn {
  display: block;
  text-align: center;
  padding: 10px 12px;
  margin: 0 0 18px;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--panel);
  color: var(--fg);
  text-decoration: none;
  font-weight: 600;
}
.oauthbtn:hover { border-color: var(--accent); color: var(--accent); }
.oauthbtn[hidden] { display: none; }

/* A DISABLED BUTTON MUST LOOK DISABLED. The pager (.pgbtn) already had this; ordinary form
   buttons did not, and the sign-in configuration page is where that first mattered: with TLS
   off the Save button is genuinely disabled, and rendering it identically to an enabled one
   means a person clicks it and nothing happens, with no explanation attached to the control.
   Caught by LOOKING at the screenshot, not by an assertion — the render check reported
   `is disabled: true` and `opacity: 1` in the same breath. */
button[disabled], button:disabled {
  opacity: .4;
  cursor: not-allowed;
}
button[disabled]:hover, button:disabled:hover { border-color: var(--line); }

/* v17 -- three rules the console has been writing markup against for months with nothing behind
   them. Found while building the PDPA pages, which lean on all three heavily enough that the
   absence showed: `<span class="dim">` appears about twenty times across the audit, news, GLS and
   ingest pages and had NO rule at all (only `td.dim` and `.prose.dim` existed), so a muted
   annotation rendered at full contrast beside the text it was meant to sit behind. `.badge.dim`
   and `.pill.warn` are the same shape of gap. Fixed here rather than worked around on the new
   pages, because a class that silently does nothing is worse on the old ones — nobody looking at
   those screens has any reason to suspect it. Tokens only, so both themes come free. */
.dim { color: var(--text-muted); }
.badge.dim { color: var(--text-muted); border-color: var(--line); }
.pill.warn { color: var(--warn); border-color: var(--warn-line); background: var(--warn-bg); }

/* v17 -- a table whose header spans a group. The PDPA policy registry renders one table per
   database table with the table name as a full-width first header row; without this it reads as
   a column heading rather than a group label. */
thead tr:first-child th[colspan] {
  text-align: left; font-family: var(--mono); letter-spacing: .04em;
  color: var(--text-secondary); background: var(--surface-2);
}

/* v19 / Phase 7b -- `.cap` had no rule in three of the places the console already used it, which
   is the same shape of gap v17 found for `.dim`: the selector list at line ~464 covers `.fbform`,
   `.askcard`, `.sxscol`, `.listview` and a DIRECT CHILD of main, and nothing else. The Constants
   page has been rendering `<div class="cap">` inside table cells since v18 with nothing behind
   it, so a caption meant to sit behind its row rendered at full contrast and full size. The
   Afford page leans on all three positions heavily enough that fixing it here was cheaper than
   inventing a fourth class. Tokens only, so both themes come free. */
td .cap, .tile .cap, .filters .cap {
  display: block; color: var(--text-muted); font-size: 11.5px; line-height: 1.45;
  margin: 3px 0 0; font-family: var(--sans); letter-spacing: 0;
}

/* ================================================================================================
   REDESIGN PHASE 2 -- the page header pattern, the disclosure, and the figure grammar.
   Every value below is an existing token. Where the handoff spec named a raw hex or a radius that
   the token set already answers, THE TOKEN WINS (README.md "Fidelity" says so itself).
   ============================================================================================== */

/* ---- the page header: title, purpose, provenance ----
   One block above every page, painted from a declarative table in app.js rather than by each view,
   so "every page" is a property of the router and not of thirty separate remembering. The chips are
   MONO on purpose: they are provenance, and provenance on this console has always been mono. */
.pagehead {
  max-width: 1500px; margin: 0 auto; padding: 20px 24px 0;
  display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: flex-start;
}
.pagehead[hidden] { display: none; }
.pagehead .phmain { flex: 1 1 420px; min-width: 0; }
.pagehead .phcrumb {
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--text-muted); margin: 0 0 6px;
}
.pagehead h1 {
  font-size: 22px; font-weight: 500; letter-spacing: -.01em; margin: 0;
  color: var(--text-primary); display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
}
.pagehead h1.big { font-size: 24px; }
.pagehead .purpose {
  font-size: 13.5px; color: var(--text-secondary); margin: 6px 0 0; max-width: 72ch;
  line-height: 1.5;
}
/* The provenance strip. `margin-left:auto` puts it on the right at full width and it wraps under
   the purpose below ~1,000px rather than squeezing the sentence to three words a line. */
.provenance {
  display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start;
  margin-left: auto; padding-top: 4px;
}
.pchip {
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase;
  color: var(--text-secondary); background: var(--surface-2);
  border: 1px solid var(--line); border-radius: 3px; padding: 3px 8px; white-space: nowrap;
}
.pchip.accent { color: var(--accent); border-color: var(--accent-dim); background: var(--accent-bg); }
.pchip.warn   { color: var(--warn);   border-color: var(--warn-line);   background: var(--warn-bg); }
/* main's own top padding would double the gap once a header sits above it. */
.pagehead:not([hidden]) + main { padding-top: 14px; }

/* ---- the disclosure ----
   The long epistemic notes are the most valuable copy in this product and NONE of them is
   shortened here: they move inside a native <details>, closed by default, state not persisted.
   `.disclose` deliberately does not reuse `.datatable`, which is a chart's companion table and
   carries a different meaning. */
.disclose {
  border: 1px solid var(--line); border-left: 3px solid var(--accent-dim);
  background: var(--surface-1); border-radius: 4px; margin: 12px 0 0; max-width: 96ch;
}
.disclose.warn { border-left-color: var(--warn-line); }
.disclose > summary {
  cursor: pointer; list-style: none;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-secondary); padding: 9px 14px;
}
.disclose > summary::-webkit-details-marker { display: none; }
.disclose > summary:hover { color: var(--accent); }
.disclose > summary .tw { display: inline-block; width: 1em; }
.disclose[open] > summary .tw { transform: rotate(90deg); }
.disclose .dbody {
  padding: 0 14px 12px; font-size: 12.5px; line-height: 1.55; color: var(--text-secondary);
  max-width: 82ch;
}
.disclose .dbody p { margin: 0 0 8px; }
.disclose .dbody p:last-child { margin-bottom: 0; }
/* EVERY `code` WRAPS, INCLUDING MID-TOKEN, AND THIS IS A CLASS OF BUG RATHER THAN ONE PAGE.
   Found 2026-09-05 in the first full live sweep: `#/loan` prints its provenance as
   `<code>https://eservices.mas.gov.sg/statistics/dir/domesticinterest</code>`, which is 588px of
   unbreakable text -- a URL offers no break opportunity and `overflow-wrap: normal` will not make
   one -- so at 390px the whole page scrolled sideways by 216px.

   This console states provenance in `code` on nearly every page: source URLs, `app_regulatory_rule`,
   `app_regulatory_constant`, `depends_on_refused_input`, `regime_rates_not_recorded`. Any of them
   is long enough to do this on a phone, so the fix belongs on the element and not on the one page
   that happened to be measured. `anywhere` rather than `break-word` because these are identifiers
   with no spaces at all -- there is nothing else to break on.

   Fixture renders never saw it: the fixture's provenance strings were short. */
code { overflow-wrap: anywhere; }
.disclose .dbody code { font-family: var(--mono); font-size: 11.5px; }

/* ---- the figure grammar ----
   ONE renderer, five absent states, each with one glyph, one word and one colour. The word is
   ALWAYS in the document, never only in a `title=` -- a phone has no hover, and 61 explanations
   were living where a phone could never read them. */
.fig { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
td.num .fig, th.num .fig { justify-content: flex-end; }
.fig .figv { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.figmark {
  display: inline-flex; align-items: baseline; gap: 5px;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .06em;
  border: 1px solid var(--line); border-radius: 3px; padding: 2px 7px;
  color: var(--text-muted); background: var(--surface-2); white-space: nowrap;
}
.figmark.warn     { color: var(--warn);     border-color: var(--warn-line);     background: var(--warn-bg); }
.figmark.critical { color: var(--critical); border-color: var(--critical-line); background: var(--critical-bg); }
/* The instance-specific truth, INLINE beside the mark rather than in a tooltip: which band, which
   field, how many contracts. This is the half a phone could never see before. */
/* Capped, and left-aligned even inside a right-aligned numeric cell: a sentence set ragged-left
   against a right edge in a 90px column wraps to one word a line. Found by looking at the
   screenshot — the ungated Compare row was four lines tall before this. */
.figdetail { font-size: 11.5px; color: var(--text-muted); white-space: normal;
             max-width: 26ch; line-height: 1.35; }
td.num .figdetail, .tile .figdetail { text-align: left; }
/* A refusal renders WHOLE. Never a glyph in a cell, never a truncation, never a title attribute
   standing in for the message -- the messages are written to tell a person what would clear it. */
.figwhole {
  display: block; white-space: normal; max-width: 46ch;
  font-size: 12px; line-height: 1.45; color: var(--text-secondary); margin-top: 4px;
}
td.num .figwhole { text-align: right; margin-left: auto; }
.figlegend { width: auto; font-size: 12.5px; }
.figlegend td { white-space: normal; padding: 6px 12px 6px 0; border-bottom: 0; vertical-align: top; }
.figlegend td:first-child { white-space: nowrap; }

/* ================================================================================================
   REDESIGN PHASE 3 -- the Compare page: band selector, two-tier filters, active chips, grouped
   headers and the merged sample-size cell.
   EVERY VALUE BELOW IS AN EXISTING TOKEN. Where the handoff named a raw hex or a radius the token
   set already answers, THE TOKEN WINS (README.md "Fidelity" says so itself).
   ============================================================================================== */

/* ---- the four regions of the filter kit ----
   Four stacked rows, not one bar with everything in it. Each keeps its own margin small so the
   kit as a whole occupies the space the single bar used to. */
.filterkit { margin-bottom: 14px; }
.filterkit .filters { margin-bottom: 10px; }
.filterkit .filters:last-of-type { margin-bottom: 0; }
/* `display` IS WRITTEN ON THE :not([hidden]) SELECTOR, exactly as .mspop does it, and for the
   same reason: `hidden`'s effect comes from the USER-AGENT stylesheet, so any author rule setting
   `display` on the same element beats it and the row stays visible while every script, test and
   accessibility tree agrees it is hidden. That bug shipped once here already. */
.filterkit .filters[hidden], .chipbar[hidden], .lvchip[hidden] { display: none; }
.filters.segbar:empty { display: none; margin: 0; }

/* ---- the band, as a segmented control ---- */
.field.segfield { flex-direction: row; align-items: center; gap: 12px; flex-wrap: wrap; }
.seglbl {
  font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-muted);
}
/* WRAPS, AND THAT IS A LIVE BUG FIX RATHER THAN A PREFERENCE. Phase 3 built this control
   reasoning about "a six-option control" -- which is what the FIXTURE had. `bandSegOptions` is
   built from the facet query, so production serves every distinct band the source publishes, and
   the control came out 2,608px wide inside a 1,440px viewport: `inline-flex` with no wrap sizes
   to content, so the whole Compare page scrolled sideways by 1,192px. Every fixture render across
   three phases called it green, because the fixture never had enough bands.
   Found 2026-09-05 on the first render against a LIVE server with a real session.
   `max-width: 100%` is the belt to the wrap's braces: it stops the box being wider than its
   column even if some future option list defeats wrapping. */
.segctl {
  display: inline-flex; flex-wrap: wrap; max-width: 100%;
  border: 1px solid var(--line); border-radius: 4px; overflow: hidden;
}
.segbtn {
  border: 0; border-right: 1px solid var(--line); border-radius: 0;
  background: transparent; color: var(--text-secondary);
  padding: 7px 12px; font-size: 10.5px; letter-spacing: .08em;
}
.segbtn:last-child { border-right: 0; }
.segbtn:hover { color: var(--accent); }
.segbtn.on {
  color: var(--accent); background: var(--accent-bg);
  box-shadow: inset 0 0 12px var(--accent-soft);
}
.seghint { margin: 0; max-width: 48ch; }

/* ---- tier two: the drawer ---- */
.filters.drawer {
  padding: 12px 14px; border: 1px solid var(--line); border-left: 3px solid var(--accent-dim);
  border-radius: 4px; background: var(--surface-1);
}
[data-role="more"].on { color: var(--accent); border-color: var(--accent-dim); background: var(--accent-bg); }
.mcount { opacity: .7; }

/* ---- the active-filter chips ---- */
.chipbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 12px; }
.chiplbl {
  font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-muted);
}
.lvchip {
  display: inline-flex; align-items: center; gap: 4px;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em;
  color: var(--accent); border: 1px solid var(--accent-dim); background: var(--accent-bg);
  border-radius: 3px; padding: 3px 3px 3px 8px; max-width: 42ch;
}
.lvchip [data-chip-txt] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chipx {
  background: transparent; border: 1px solid transparent; border-radius: 3px;
  color: inherit; padding: 0 4px; font-size: 13px; line-height: 1.1; letter-spacing: 0;
}
.chipx:hover { color: var(--critical); border-color: var(--critical-line); }

/* ---- grouped table headers ----
   Two sticky rows. The group row sticks at the top of the scroll container and the column row
   sticks directly under it, so scrolling 300 rows never separates a figure from the statement of
   what KIND of evidence it is. Height is pinned rather than left to the padding, because the
   column row's `top` has to equal it exactly or a row bleeds through the gap. */
thead tr.grouprow th {
  top: 0; z-index: 3; height: 26px; padding: 0 12px;
  font-size: 9.5px; letter-spacing: .14em; text-align: left;
  color: var(--text-secondary); background: var(--surface-2);
  border-bottom: 1px solid var(--line-soft);
}
thead tr.grouprow + tr.colrow th { top: 26px; z-index: 2; }
th.gb { border-left: 1px solid var(--line); }
td.gb { border-left: 1px solid var(--line-soft); }

/* A second sort key in the same header cell — the district, which now lives under the project
   name in the body and would otherwise have lost its ordering entirely. */
.sort2 { display: block; margin-top: 3px; }
.sort2 .sortbtn { font-size: 9.5px; opacity: .8; }
/* The district under the project name. A block inside a nowrap cell is fine; it is the second
   line of one identity, not a second column. */
.rowsub { display: block; margin-top: 2px; font-size: 11px; color: var(--text-muted); }

/* ---- `n rent / sale`: one column, two counts, neither readable as the other ----
   The WORDS are in the document, not only in the header and not only in a title: a phone has no
   hover, and the whole point of the merge is that a reader cannot take the sale count for the
   rent's. The thin outline sits on top of phase 2's `△ thin` mark, never instead of it — colour
   is the third cue here, never the only one. */
.nsplit { display: inline-flex; align-items: baseline; gap: 10px; justify-content: flex-end; }
.nhalf {
  display: inline-flex; align-items: baseline; gap: 5px;
  padding: 1px 5px; border: 1px solid transparent; border-radius: 3px;
}
.nhalf .fig { flex-wrap: nowrap; }
.nlab {
  font-family: var(--mono); font-size: 9.5px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-muted);
}
.nhalf.thin { border-color: var(--warn-line); background: var(--warn-bg); }
.nhalf.thin .figv { color: var(--warn); }

/* ---- the expanded row, in three columns ----
   PINNED TO THE SCROLL CONTAINER, NOT TO THE TABLE, and this was found by LOOKING at the
   screenshot rather than by any assertion. A detail row spans every column, so on a table wider
   than its wrapper -- which Compare is the moment the resale columns come on -- the third column
   of the panel sits off the right-hand edge and a reader has to scroll sideways to finish a
   sentence they did not know had a second half. `sticky; left: 0` with a bounded width keeps the
   whole panel in view at every horizontal scroll position; the width tracks main's own gutter and
   max-width so it never claims more room than the page has. */
.expcols {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px 24px;
  position: sticky; left: 0; width: min(calc(100vw - 80px), 1450px); max-width: 100%;
}
.expcols > section { min-width: 0; }
.expcols h4 {
  margin: 0 0 8px; font-family: var(--mono); font-size: 10px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--text-muted); font-weight: 500;
}
.expcols .dl { grid-template-columns: 1fr; }

/* ==============================================================================================
   REDESIGN PHASE 4 — OVERVIEW, TRANSACTIONS, RENTALS
   docs/design/handoff-2026-09/README.md, "Overview" and "Transactions".
   Same posture as phases 1-3: every value here is a TOKEN, and where the spec names a number the
   existing token set already answers, THE TOKEN WINS (README.md "Fidelity" says so itself).
   ============================================================================================== */

/* ---- the coverage bar ----
   4px on a --surface-3 track with a --warn fill. It carries NO information the caption beside it
   does not already state in words — status is never colour alone on this console — and its width
   and that caption are the SAME expression in coverageTile(), never two roundings of one ratio. */
.covbar {
  height: 4px; border-radius: 2px; background: var(--surface-3);
  margin: 8px 0 6px; overflow: hidden;
}
.covbar > span { display: block; height: 100%; background: var(--warn); border-radius: 2px; }
/* The denominator of `412 / 1,080`. It was an inline style; it is a class now so the two coverage
   tiles and this one are described in one place. */
.tile .v .den { font-size: 14px; color: var(--text-muted); }

/* The tiles grid — `minmax(200px, 1fr)` and its own 12px gap — is declared with `.tile` above,
   beside the rules it belongs with, rather than overridden down here.
   Two chart cards side by side, wrapping to one column below ~860px. */
/* `min(420px, 100%)` and not a bare 420px: a bare minimum larger than the column itself makes the
   GRID wider than the page, and the whole body scrolls sideways at 390px. Seen on the phone-width
   screenshot; no probe here would have said a word about it. */
.grid.charts2 { grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); }

/* ---- a chart card's unit / scope label ----
   Right-aligned in the h3 row. h3 becomes a flex row, which for a card with no `scope` renders
   exactly as it did: one text child in a flex container is one text child. */
.chartcard h3 { display: flex; align-items: baseline; gap: 12px; }
.chartcard h3 .unit {
  margin-left: auto; flex: none;
  font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
  font-weight: 400; color: var(--text-muted);
}
/* At phone width the TITLE wraps to three lines and the unit label, baseline-aligned to the
   first of them, lands in the MIDDLE of the sentence: "Transactions TOP 30 OF 1,232 over five
   years". Read off the 390px screenshot — no probe would have called that anything but green.
   Below 620px the two stack instead. */
@media (max-width: 620px) {
  .chartcard h3 { flex-direction: column; align-items: flex-start; gap: 3px; }
  .chartcard h3 .unit { margin-left: 0; }
}

/* ---- a hash control ON the section heading row ----
   `h2.sec::after` is the 1px rule and it eats the remaining width. `order: 1` puts the control
   AFTER the rule, so the rule still draws between the heading and the control instead of running
   past both. Nothing here applies to a hashControl that did not ask for `inline`. */
.hcinline { display: flex; align-items: center; gap: 8px; order: 1; flex: none; margin-left: auto; }
.hcinline label {
  font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-muted);
}
.hcinline select { min-width: 0; padding: 5px 7px; font-size: 11.5px; }
/* h2.sec sets `margin: 26px 0 12px`; a control in the row makes it taller than a bare heading, so
   the heading text keeps its own line-height rather than being stretched by the select. */
h2.sec > span { flex: none; }

/* ---- the min-max pair ----
   ONE field, ONE label, two number inputs and an en dash. The inputs are narrow on purpose: a
   price bound is at most eight digits and two 120px boxes would push the rest of the bar onto a
   second line at 1,024px. */
.field.pairfield .pairctl { display: flex; align-items: center; gap: 6px; }
.field.pairfield input { min-width: 0; width: 104px; }
.pairsep { color: var(--text-muted); font-family: var(--mono); font-size: 12.5px; }
/* Chrome/Safari draw spinners inside a 104px number box and eat a digit of it. */
.field.pairfield input::-webkit-outer-spin-button,
.field.pairfield input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.field.pairfield input { -moz-appearance: textfield; appearance: textfield; }

/* ---- the count and pager, ON the filter row ----
   `spec.inlineBar` MOVES the one .lvbar into the filter row; it does not build a second one. The
   top pager is made COMPACT HERE, IN CSS, and nowhere else — there is exactly one pagerHtml() and
   the foot pager keeps « », the page count and the rows-per-page select, so nothing this hides is
   unreachable. Hiding rather than not-rendering is what keeps the two pagers from drifting. */
.lvbar.inline {
  margin: 0 0 0 auto; min-height: 0; gap: 10px; flex: none; align-self: flex-end;
  padding-bottom: 1px;
}
.lvbar.inline [data-nav="first"], .lvbar.inline [data-nav="last"], .lvbar.inline .pgsize {
  display: none;
}
.lvbar.inline .pager { margin-left: 0; }
/* Below ~700px the filter row is already stacking; let the bar take a full line of its own rather
   than squeezing the count and the pager into a 60px column. */
@media (max-width: 700px) {
  .lvbar.inline { margin-left: 0; flex: 0 0 100%; }
}

/* ==============================================================================================
   REDESIGN PHASE 5 — PROJECT DETAIL, AFFORD, LOAN, HOLDINGS
   docs/design/handoff-2026-09/README.md, "Project detail", "Afford" and "Holdings".
   Same posture as phases 1-4: every colour is an existing TOKEN, and where the spec quotes a hex
   the token wins (README.md "Fidelity" says so itself). This block is LAYOUT — nothing here
   changes what a figure says, only where it sits.
   ============================================================================================== */

/* ---- the two-column body ----
   One class, three pages, two shapes: `analysis + prail` on the project page and
   `formcol + results` on Afford and Loan. Both are the same flex row with the same wrap, so the
   responsive behaviour is described once. `align-items: flex-start` is load-bearing: the default
   `stretch` makes both children the height of the taller one, and `position: sticky` on a child
   that is already as tall as its container never sticks to anything. */
.twocol { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; }
.twocol > * { min-width: 0; }              /* a flex item's min-width:auto is what overflows a row */
.twocol > .analysis { flex: 1 1 560px; }
.twocol > .results  { flex: 1 1 520px; }
.twocol > .prail    { flex: 1 1 300px; max-width: 380px; }
.twocol > .formcol  { flex: 1 1 320px; max-width: 380px; }

/* THE STICKY COLUMNS, AND THE `max-height` IS NOT DECORATION. `position: sticky; top: 104px` pins
   the TOP of the column; a column TALLER than the viewport therefore has a bottom that can never
   be scrolled to while it is pinned. The rail carries a confirmation ledger of unbounded length,
   so it is given its own scroll rather than a silently unreachable tail. 104px is the spec's
   offset and it clears both header rows (52 + 38) with the page gutter. */
@media (min-width: 1001px) {
  .twocol > .prail, .twocol > .formcol {
    position: sticky; top: 104px;
    max-height: calc(100vh - 120px); overflow-y: auto; overscroll-behavior: contain;
    /* A rail taller than the viewport has its own scroll, so the scrollbar has to be VISIBLE and
       has to have room: an overlay scrollbar that appears only while scrolling makes a clipped
       card look like a broken one. `stable` reserves the gutter so nothing shifts when it shows. */
    scrollbar-width: thin; scrollbar-gutter: stable;
  }
}
/* Below the spec's ~1,000px wrap point the two columns are stacked, one above the other, and a
   stacked column that stuck to the top of the viewport would sit on top of the page it belongs
   to. Nothing is sticky and nothing is scroll-capped there. */

/* ---- a rail card ---- */
.railcard {
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 4px;
  padding: 14px 16px; margin: 0 0 12px;
}
.railcard:last-child { margin-bottom: 0; }
.railcard > .kicker {
  font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--text-muted); margin: 0 0 10px;
}
.railcard p { margin: 0 0 8px; font-size: 12.5px; color: var(--text-secondary); }
.railcard p:last-child { margin-bottom: 0; }
/* The rail is 380px at its widest, so the two-column definition list has to be one column: `.dl`
   asks for `minmax(280px, 1fr)` and would otherwise overflow its own card. Same override
   `.expcols .dl` already makes for the expanded-row panels. */
.prail .dl { grid-template-columns: 1fr; }
.prail .dl dd { font-family: var(--mono); font-size: 12px; }
/* A caption UNDER a mono value is prose, not a value. Without this the Holdings rail set two
   sentences of explanation in JetBrains Mono at 12px, which reads as data. Seen on the screenshot. */
.prail .dl dd .cap { font-family: var(--sans); }
/* The asset-class panel keeps its own markup and moves into the rail; it is a card there rather
   than a panel, so the panel chrome is dropped instead of nested inside the card's. */
.railcard.aspanel > .panel {
  background: none; border: 0; border-radius: 0; padding: 0; overflow-x: visible;
}
.railcard.aspanel h3 {
  margin: 0 0 10px;
  font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
  font-weight: 400; color: var(--text-muted);
}
/* A compact table inside a rail card: no wrap cap, no scroll box, and it may break lines — a
   380px column has no room for the console's default nowrap cells. */
/* `table-layout: fixed` IS THE WHOLE POINT AND IT IS NOT A PREFERENCE. An auto-layout table is at
   least as wide as its own min-content, and a rail table's third column carries a figure mark
   (`◇ none in evidence`, `the reference`) that does not break — so at 390px, where the rail is
   full width, the table measured 433px inside a 362px column and dragged the whole PAGE sideways
   by 74px. Measured on the 390px screenshot, dark and light; no assertion on this page said a
   word about it. Fixed layout plus `overflow-wrap: anywhere` makes the columns obey the rail. */
.railtable { font-size: 12px; table-layout: fixed; width: 100%; }
.railtable td { padding: 6px 0; white-space: normal; vertical-align: top; overflow-wrap: anywhere; }
.railtable td:first-child { width: 52%; }
.railtable td.num { padding-left: 8px; }
/* `.figmark` is `white-space: nowrap` everywhere else, and rightly — `◇ none in evidence` reads
   as one mark, not as two words. In a rail column 80px wide it is 139px of unbreakable text and
   it took the page sideways with it at 390px. Relaxed HERE ONLY: the mark wraps to two lines and
   stays one bordered box, so the vocabulary is unchanged and only the line breaking moves. */
.railtable .figmark { white-space: normal; }
.railtable tr:last-child td { border-bottom: 0; }
.railtable .meta { display: block; margin-top: 2px; font-size: 11px; color: var(--text-muted); }
.railtable tr.self td { font-weight: 600; }
/* THE SELECTED ROW, AND `.selrow` HAD NO RULE AT ALL until now — the Holdings table has been
   emitting the class since v21 and it has never drawn anything. It does now, and it is icon-free
   colour on a NAVIGATION row rather than on a data mark, which is the one place this console
   allows colour to carry the signal on its own. */
.railtable tr.selrow td { background: var(--accent-bg); }
.railtable tr.selrow td:first-child { box-shadow: inset 2px 0 0 var(--accent-dim); padding-left: 8px; }

/* A rail card that is warning about something. Same treatment `.tile.warnish` gets, so a card and
   a tile do not disagree about what a warning edge looks like. */
.railcard.warnish { border-color: var(--warn-line); }
.railcard .bigv {
  font-family: var(--mono); font-size: 20px; color: var(--warn); margin: 0 0 4px;
}
.railcard:not(.warnish) .bigv { color: var(--text-primary); }

/* ---- the project page's own tiles ----
   The global `.tiles` is `minmax(200px, 1fr)`, which is what Overview's spec asks for; the
   project page's spec asks for 170px and Afford's envelope for 190px. Three different numbers in
   three different specs, so each page names its own rather than one of them moving the global and
   silently resizing the other two (phase 4 carried finding 9). */
.tiles.pj  { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
.tiles.env { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
/* The spec puts the project and afford tile VALUES at 24px against the 26-27px elsewhere. */
.tiles.pj .tile .v, .tiles.env .tile .v { font-size: 24px; }

/* ---- the warning banner ----
   Afford and Loan state their weakest authority ONCE, here, at the top. It is a --warn-bg strip
   with a badge that sits on --surface-2 so the badge reads as a badge rather than as a hole in
   the strip. */
.warnbanner {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px;
  background: var(--warn-bg); border: 1px solid var(--warn-line); border-radius: 4px;
  padding: 12px 14px; margin: 0 0 14px;
  font-size: 12.5px; color: var(--text-secondary);
}
.warnbanner .badge { background: var(--surface-2); flex: none; }
.warnbanner p { margin: 0; flex: 1 1 380px; min-width: 0; }
.warnbanner .cd { font-family: var(--mono); color: var(--warn); }
.warnbanner a { flex: none; }

/* ---- the scenario form, as a card ----
   Grouped fields on a --surface-2 card, each field a `label | control` grid so twelve of them
   read as a form rather than as a wrapped filter bar. `.filters` is reused (the markup is
   unchanged) and re-laid-out here. */
.formcard {
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 4px;
  padding: 14px 16px;
}
.formcard .filters { flex-direction: column; align-items: stretch; gap: 10px; margin-bottom: 0; }
.formcard .fgroup {
  font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--text-muted); margin: 6px 0 0; padding-top: 10px; border-top: 1px solid var(--line-soft);
}
.formcard .filters > .fgroup:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
.formcard .field {
  display: grid; grid-template-columns: 1fr 150px; align-items: center; gap: 4px 10px;
}
.formcard .field label {
  font-family: var(--sans); font-size: 12.5px; letter-spacing: 0; text-transform: none;
  color: var(--text-secondary);
}
.formcard .field input, .formcard .field select {
  min-width: 0; width: 100%; max-width: none;
}
.formcard .field .cap { grid-column: 1 / -1; margin: 0; }
/* The primary action: --surface-3 fill, an --accent-dim edge and the inset glow the sub-nav's
   active link uses. It is the only button on the card, so it is not competing with a ghost. */
.formcard button[type="submit"] {
  width: 100%; margin-top: 12px;
  border-color: var(--accent-dim); color: var(--accent);
  box-shadow: inset 0 0 12px var(--accent-soft);
}
.formcard .foot { margin: 10px 0 0; font-size: 11.5px; }
/* A button that sits INSIDE a sentence has to read as part of the sentence — it is a control
   because it acts, not because it looks like a chunk of chrome in the middle of a paragraph.
   Scoped to this card on purpose: `.linkish` is used in four other places where the button
   chrome is right, and a global rule would restyle all of them. */
.formcard .foot button.linkish {
  display: inline; background: none; border: 0; padding: 0;
  font-family: inherit; font-size: inherit; letter-spacing: 0; text-transform: none;
  color: var(--accent); cursor: pointer;
}
.formcard .foot button.linkish:hover { text-decoration: underline; }
@media (max-width: 420px) {
  .formcard .field { grid-template-columns: 1fr; }
}

/* ---- a link ON the section heading row (`Open in search →`) ----
   Same mechanism as `.hcinline`: `h2.sec::after` is the 1px rule with `flex: 1`, so `order: 1`
   puts the link AFTER the rule and the rule still draws between the heading and the link. */
.sechref {
  order: 1; flex: none; margin-left: auto;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase;
}

/* ---- a section heading's own counter (Holdings' `n refused`) ----
   It is DERIVED from the cards rendered beside it, never typed — the same rule phase 4's
   `More filters · n` follows — and this rule only says what it looks like. */
.seccount { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; flex: none; }
.seccount.critical { color: var(--critical); }

/* ---- Holdings: the primary button that opens the form ---- */
.headrow { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0 0 6px; }
.headrow .spacer { flex: 1 1 auto; }
button.primary {
  border-color: var(--accent-dim); color: var(--accent);
  box-shadow: inset 0 0 12px var(--accent-soft);
}
/* The selected-holding card. `--accent` kicker, the name at 20px, and a four-column definition
   list whose values are mono and 18px — the spec's shape, in tokens. */
.selcard {
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 4px;
  padding: 14px 16px; margin: 0 0 12px;
  display: flex; flex-wrap: wrap; gap: 14px 24px; align-items: flex-start;
}
.selcard > .head { flex: 1 1 260px; min-width: 0; }
.selcard .kicker {
  font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--accent); margin: 0 0 4px;
}
.selcard h3 { margin: 0; font-size: 20px; font-weight: 500; letter-spacing: -.01em; }
.selcard .meta { margin: 4px 0 0; font-size: 12.5px; color: var(--text-secondary); }
.selcard .acts { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 10px; }
.selcard .acts button.rm:hover { color: var(--critical); border-color: var(--critical-line); }
.selcard > .facts {
  flex: 2 1 380px; min-width: 0;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px 18px;
}
.selcard .facts .k {
  font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--text-muted);
}
.selcard .facts .v { font-family: var(--mono); font-size: 18px; margin-top: 3px; }

/* A refusal card gets the --critical left edge the spec asks for. `.askcard` already carries the
   border, the radius and the --surface-1 fill; this is the one thing that is different about a
   refusal, and it is stated once. */
/* A REFUSAL CARD LOOKS THE SAME ON ALL THREE PAGES THAT HAVE ONE. `.askcard` already carries the
   border, the radius and the --surface-1 fill; the --critical left edge is the one thing that is
   different about a refusal, and it is declared once. The spec asks for it on Holdings; Afford
   and Loan print the identical card from the identical engine messages, and one thing that looks
   like two things is how a reader stops recognising either. */
.askcard.refusal { border-left: 3px solid var(--critical-line); }

/* The half-year series table's two sale-type groups reuse the Compare page's EXISTING group-row
   chrome — `thead tr.grouprow` and the `.gb` boundary — so `New sale` and `Resale` read as two
   groups of (median PSF, n) rather than as four columns in a row. Nothing new is declared for it
   here on purpose: a second definition of a group header is how the two come to disagree. */

/* ---------- the performance leaderboard (v28, 2026-10-22) ----------
   Tokens only, both themes from the same rules. No chart, so no palette slot: the ranking is a
   table per tile, which a screen reader reads as one and a phone can scroll. Scores are signed
   text (+/−), never colour — status colours stay reserved for status, and a rank is not one. */
.perfmeta { margin-bottom: 4px; }
.perfmeta .v.perfstate { font-size: 20px; }
p.note.perfcap { max-width: 110ch; }
.perfgrid { display: grid; gap: 14px; margin-top: 12px; }
.perfrow {
  display: grid; gap: 12px; align-items: start;
  grid-template-columns: 86px repeat(4, minmax(0, 1fr));
}
.perfrowlab {
  font-family: var(--mono); font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--text-secondary); padding-top: 12px;
}
.perftile {
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px;
  padding: 10px 12px; min-width: 0;
}
.perftile.empty { border-style: dashed; }
.perftile h3 {
  margin: 0 0 8px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  font-family: var(--mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
  font-weight: 500; color: var(--text-primary, var(--text-secondary));
}
.perftile .cap { font-size: 12px; color: var(--text-muted); margin: 0; }
table.perftab { font-size: 12.5px; table-layout: fixed; }
table.perftab th, table.perftab td { padding: 5px 6px; position: static; }
table.perftab td { white-space: normal; vertical-align: top; }
table.perftab th:first-child, table.perftab td:first-child { width: 26px; }
table.perftab th.num:nth-last-child(2), table.perftab td.num:nth-last-child(2) { width: 58px; }
table.perftab th:last-child, table.perftab td:last-child { width: 40px; }
.prail table.perftab th:first-child, .prail table.perftab td:first-child { width: 44px; }
.prail table.perftab th:last-child, .prail table.perftab td:last-child { width: auto; }
table.perftab td.pname a { overflow-wrap: anywhere; }
table.perftab .psub { font-size: 10.5px; color: var(--text-muted); margin-top: 2px; line-height: 1.35; }
table.perftab .badge { font-size: 9.5px; padding: 1px 5px; margin-left: 4px; }
h3.perfh3 {
  margin: 0 0 10px; display: flex; align-items: center; gap: 8px;
  font-family: var(--mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
  font-weight: 500; color: var(--text-secondary);
}
@media (max-width: 1200px) {
  .perfrow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .perfrowlab { grid-column: 1 / -1; padding-top: 4px; }
}
@media (max-width: 620px) {
  .perfrow { grid-template-columns: minmax(0, 1fr); }
}
/* The heading is longer than a 390px row: let its text wrap rather than push the page sideways. */
h2.sec.perfsec > span { flex: 0 1 auto; min-width: 0; }

/* ---------- global search (2026-10-22) ----------
   <mark class="hl"> is written by web/highlight.js only. Theme tokens above; nothing raw here. */
mark.hl {
  background: var(--mark-bg);
  color: var(--text-primary);
  border-radius: 2px;
  box-shadow: inset 0 -1px 0 var(--mark-line);
  padding: 0 1px;
}
.searchpage { display: flex; gap: 8px; align-items: center; margin: 4px 0 6px; flex-wrap: wrap; }
.searchpage input {
  flex: 1 1 320px; min-width: 0; max-width: 640px;
  background: var(--surface-2); color: var(--text-primary);
  border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; font: inherit;
}
.searchpage input:focus-visible { outline: none; border-color: var(--accent-dim); box-shadow: 0 0 0 2px var(--focus-ring); }
.sgroup { margin-bottom: 18px; }
.sgroup .tablewrap { margin-top: 4px; }
