@import url('inter.css');

/* ═══════════════════════════════════════════════════════════════════════════
   Airo — shared design system
   ───────────────────────────────────────────────────────────────────────────
   One source of truth for every page. The tokens are lifted from the original
   ui/index.html so the product still looks like itself; what changed:

     * the palette lived in three diverging copies (index / dashboard / upload).
       It lives here now — edit once.
     * dark mode was class-only, so a user on a dark OS got a white screen until
       they found the toggle. It now honours prefers-color-scheme AND keeps the
       explicit toggle, which wins in both directions.
     * focus-visible, reduced-motion and contrast were unspecified.

   Theme resolution order (later wins):
       :root                       light palette
       @media (prefers-color-scheme: dark) :root:not([data-theme=light])
       [data-theme=dark]           explicit choice
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── tokens ─────────────────────────────────────────────────────────────────
   The visual language is a REPORT SURFACE, not an admin form. That is one
   structural decision and everything else follows from it:

     THE CANVAS IS GREY AND THE VISUALS ARE WHITE.

   This is the single most recognisable trait of a BI report, and it was the one
   thing missing. `--bg-app` and `--bg-card` were BOTH #ffffff, so a card existed
   only because of a 1px border — which is exactly why the product read as a
   settings page with tables on it. Now a visual container is a white sheet
   sitting on a grey canvas, and it needs no border at all to be a thing.

   The rest of the pass follows from the same idea:

     * TIGHTER GEOMETRY. Radii were 7/10/13/18px — consumer-app curvature. A
       report visual is 3–8px. An 18px radius on a data table looks like a
       greetings card.
     * DENSER TYPE. A report is read by someone comparing figures, so the body
       drops to 13px and the chrome to 11–12px, while the DISPLAY figures get
       bigger. That contrast — small chrome, large numbers — is what makes a
       dense screen feel authoritative rather than cramped.
     * ELEVATION MEANS INTERACTIVE. At rest a visual has no shadow, only its
       surface. A shadow appears when something is hovered, dragged or floating
       above the canvas (a menu, a modal). Shadows on everything is why a page of
       cards looks like a pile.
     * AZURE, NOT INDIGO. The accent moved to a deep azure: it reads as an
       analytics tool rather than a SaaS dashboard template, and it clears 4.5:1
       on all three light surfaces (worst 6.30) where the old indigo was chosen
       by eye.

   Every colour below was computed, not picked. Ink, accent and status were
   checked with WCAG contrast against EVERY surface they land on — worst cases
   are in the comments. The categorical series ramp was re-validated with the
   dataviz validator against the new card surfaces and passes in both modes.

   Theme resolution order (later wins):
       :root                       light
       @media (prefers-color-scheme: dark) :root:not([data-theme=light])
       [data-theme=dark]           explicit choice
   ─────────────────────────────────────────────────────────────────────────── */

/* ── light ─────────────────────────────────────────────────────────────────── */
:root{
  /* Azure. Worst text contrast 6.30:1 across canvas/card/panel; white on it
     6.99:1, so a filled button label is legible too. */
  --color-accent:#0b57b0; --color-accent-dark:#094a94; --color-accent-2:#0f62c4;
  --color-accent-soft:rgba(11,87,176,.07); --color-accent-soft-strong:rgba(11,87,176,.13);
  --color-accent-on:#ffffff;

  /* Status. Each clears 4.5:1 on both the card and the canvas (worst 4.78,
     warning). Reserved for state — never reused as "series 9". */
  --color-success:#0a6c4d; --color-success-soft:rgba(10,108,77,.10);
  --color-danger:#b42318;  --color-danger-soft:rgba(180,35,24,.09);
  --color-warning:#93610a; --color-warning-soft:rgba(147,97,10,.11);
  --color-info:#0f62c4;    --color-info-soft:rgba(15,98,196,.09);

  /* SURFACES — the report stack, back to front:
       panel   the chrome bars and side panes, furthest back
       app     the report CANVAS that visuals sit on
       card    a visual container, the only white
       alt     an inset inside a visual (a table header, a well)
     card-vs-canvas is a 1.11 luminance step: visible as a sheet, quiet enough
     that twelve of them do not stripe. */
  --bg-panel:#f7f8fa;
  --bg-app:#f2f3f5;
  --bg-page:#f2f3f5;
  --bg-card:#ffffff; --bg-surface:#ffffff;
  --bg-surface-alt:#eef0f3;
  --bg-sidebar:#f7f8fa; --bg-header:rgba(247,248,250,.86);
  --bg-composer:#ffffff;
  --bg-hover:rgba(13,22,33,.045); --bg-active:rgba(11,87,176,.08);

  /* A visual's edge is a hairline one step off its own surface, not a line
     drawn on top of it. --border-strong is for something you type into. */
  --border-color:#e1e4e8; --border-strong:#c9ced6; --scrollbar-thumb:#bcc3cc;

  /* Worst-case ratios across all four surfaces: 15.81 / 8.91 / 5.33 / 3.30.
     `faint` is held to the 3:1 large-text floor and is only ever used at
     >=16px or bold, or as a non-essential label. */
  --text-primary:#0f1720; --text-secondary:#39424e;
  --text-muted:#586374; --text-faint:#7b8494;

  --bubble-user-bg:#eef1f5; --bubble-user-fg:#1a222c; --bubble-user-border:#dfe4ea;
  --sql-bg:#f7f8fa; --sql-border:#e1e4e8; --sql-fg:#0b3f80;

  /* ── categorical series ramp ──────────────────────────────────────────
     Assign IN FIXED ORDER, never cycled, and bind a slot to the ENTITY — so a
     filter that drops a series never repaints the survivors. Both modes are
     SELECTED, not flipped: the dark column is the same eight hues re-stepped
     for a dark surface.

     Re-validated against the surfaces above (#ffffff light / #1c1f25 dark) with
     the dataviz validator after the surface change: all checks pass in both
     modes — worst adjacent CVD ΔE 9.1 light / 8.4 dark (>=8), worst adjacent
     normal-vision ΔE 19.6 / 19.3 (>=15). Slots 3, 4 and 5 sit under 3:1 on the
     white card, so light-mode charts owe RELIEF: direct labels at <=4 series and
     the per-tile table view, both built into the dashboard tiles. Re-run the
     validator if you touch a hex — the ORDERING is the colourblind-safety
     mechanism, not decoration. */
  --series-1:#2a78d6; --series-2:#eb6834; --series-3:#1baf7a; --series-4:#eda100;
  --series-5:#e87ba4; --series-6:#008300; --series-7:#4a3aa7; --series-8:#e34948;
  /* Single-series magnitude uses the sequential blue ramp, light -> dark. */
  --seq-1:#cde2fb; --seq-2:#9ec5f4; --seq-3:#5598e7; --seq-4:#2a78d6; --seq-5:#184f95;
  /* Chart chrome. A gridline is a hairline one step off the surface, never
     dashed, and never darker than the data. */
  --chart-grid:rgba(96,106,122,.18); --chart-axis:#c9ced6;
  --status-good:#0ca30c; --status-warn:#fab219;
  --status-serious:#ec835a; --status-critical:#d03b3b;

  /* GEOMETRY. Report-tool curvature, not consumer-app. sm is for a control, md
     for a visual container, lg for a floating surface. There is deliberately no
     18px step any more. */
  --radius-sm:4px; --radius-md:6px; --radius-lg:10px; --radius-xl:14px;
  --radius-pill:999px;

  /* ELEVATION MEANS INTERACTIVE.
     A visual at rest has NO shadow — its white surface against the grey canvas
     is what makes it a sheet. A shadow appears only when something is lifted:
     hovered, dragged, or floating (a menu, a modal, the command palette). Two
     parts each — a tight contact shadow for the edge and a wide soft one for
     the lift, because a single blur reads as a smudge at this scale. */
  --shadow-xs:0 1px 1px rgba(13,22,33,.04);
  --shadow-sm:0 1px 2px rgba(13,22,33,.05), 0 1px 3px rgba(13,22,33,.04);
  --shadow-md:0 1px 2px rgba(13,22,33,.06), 0 3px 10px -2px rgba(13,22,33,.10);
  --shadow-lg:0 2px 4px rgba(13,22,33,.06), 0 10px 24px -6px rgba(13,22,33,.14);
  --shadow-pop:0 3px 6px rgba(13,22,33,.08), 0 20px 44px -12px rgba(13,22,33,.22);

  /* Spacing. Denser at the small end than before: a report packs, and 12px is
     the workhorse gap between visuals rather than 16. */
  --sp-1:4px; --sp-2:6px; --sp-3:10px; --sp-4:14px;
  --sp-5:20px; --sp-6:28px; --sp-7:40px;

  /* TYPE. Small chrome, large figures — the contrast that makes a dense screen
     read as authoritative rather than cramped. */
  --fs-micro:10.5px;   /* uppercase micro-labels, tags */
  --fs-chrome:12px;    /* nav, ribbon, tile titles, table headers */
  --fs-body:13px;      /* body copy, list rows, form controls */
  --fs-lede:14px;      /* a standfirst */
  --fs-h:17px;         /* a section heading in content */
  --fs-title:22px;     /* a page title */
  --fs-figure:30px;    /* a KPI figure */
  --fs-display:38px;   /* the one hero number on a page */

  /* 130ms/200ms was pitched so a dense toolbar never felt laggy, and it
     achieved that by being close to instant — which is the same thing as
     having no transition at all: a hover "snapped", a pane "appeared", and
     nothing in the product read as MOVING. These are the durations at which
     a change is perceived as motion rather than as a repaint, and they are
     still well inside the range where a control feels responsive to a click.
     Everything that transitions in this product reads off these two, so this
     is the one place the feel is set. */
  --transition-fast:.2s; --transition-base:.32s;
  /* Two curves, named for what they are for.
     --ease-out    expo-out: 80% of the distance in the first third, then a
                   settle. For anything ARRIVING — it looks decelerated into
                   place rather than stopped.
     --ease-in-out for something that leaves and comes back (a drawer, a pane).
     `--ease` is an alias kept because existing components reference it. */
  --ease-out:cubic-bezier(.16,1,.3,1);
  --ease-in-out:cubic-bezier(.65,0,.35,1);
  --ease:var(--ease-out);
  --font-sans:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
  --font-mono:ui-monospace,'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',monospace;

  --sidebar-w:248px; --topbar-h:48px; --page-max:1280px;
  color-scheme:light;
}

/* ── dark ───────────────────────────────────────────────────────────────────
   SELECTED, not inverted. The same stack — panel behind canvas behind card —
   re-stepped so a visual is still a lighter sheet on a darker canvas, which is
   what keeps the report reading the same way in both modes. Every ratio below
   was computed against these surfaces, not against black. */
@media (prefers-color-scheme:dark){
  :root:not([data-theme="light"]){
    /* Worst 6.72:1 across the dark surfaces; near-black on it 8.47:1. */
    --color-accent:#6cb2f5; --color-accent-dark:#8cc3f8; --color-accent-2:#56a4f0;
    --color-accent-soft:rgba(108,178,245,.13); --color-accent-soft-strong:rgba(108,178,245,.20);
    --color-accent-on:#0b1017;

    /* Worst 7.17:1 (danger). */
    --color-success:#3fd39b; --color-success-soft:rgba(63,211,155,.13);
    --color-danger:#fd8a7f;  --color-danger-soft:rgba(253,138,127,.13);
    --color-warning:#f5be4a; --color-warning-soft:rgba(245,190,74,.13);
    --color-info:#6cb2f5;    --color-info-soft:rgba(108,178,245,.13);

    /* A near-neutral stack with a faint cool cast. It lets the accent and the
       data colours carry the only real chroma on screen, which is what makes a
       dense report calm rather than lurid. card-vs-canvas is the same 1.11 step
       as in light, so a visual reads as a sheet in both modes. */
    --bg-panel:#0e1013;
    --bg-app:#131519;
    --bg-page:#131519;
    --bg-card:#1c1f25; --bg-surface:#1c1f25;
    --bg-surface-alt:#23262d;
    --bg-sidebar:#0e1013; --bg-header:rgba(14,16,19,.86);
    --bg-composer:#1c1f25;
    --bg-hover:rgba(255,255,255,.055); --bg-active:rgba(108,178,245,.15);

    /* The chevron again, re-stroked for a dark surface. A background-image
       cannot inherit currentColor, so the arrow is a per-theme token — and
       without this the dark select showed a #586374 chevron on #1c1f25,
       which is a 1.5:1 mark: effectively invisible. */
    --select-arrow:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%238d95a3' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M3 4.5 6 7.5l3-3'/%3E%3C/svg%3E");
    /* The chevron again, re-stroked for a dark surface. A background-image
     cannot inherit currentColor, so the arrow is a per-theme token — and
     without this the dark select showed a #586374 chevron on #1c1f25,
     which is a 1.5:1 mark: effectively invisible. */
  --select-arrow:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%238d95a3' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M3 4.5 6 7.5l3-3'/%3E%3C/svg%3E");
  --border-color:#282c33; --border-strong:#3a404a; --scrollbar-thumb:#3f4550;

    /* Worst-case: 12.80 / 8.54 / 5.02 / 3.17. */
    --text-primary:#eaecf0; --text-secondary:#bcc3ce;
    --text-muted:#8d95a3; --text-faint:#6b7382;

    --bubble-user-bg:#1a2534; --bubble-user-fg:#e6ecf4; --bubble-user-border:#26344a;
    --sql-bg:#12161c; --sql-border:#252b34; --sql-fg:#9cc6f5;

    /* The same eight hues re-stepped for the #1c1f25 card, where all eight clear
       3:1 — so dark mode needs no relief rule. */
    --series-1:#3987e5; --series-2:#d95926; --series-3:#199e70; --series-4:#c98500;
    --series-5:#d55181; --series-6:#008300; --series-7:#9085e9; --series-8:#e66767;
    --seq-1:#0d366b; --seq-2:#184f95; --seq-3:#2a78d6; --seq-4:#5598e7; --seq-5:#9ec5f4;
    --chart-grid:rgba(154,168,189,.16); --chart-axis:#3a404a;

    --shadow-xs:0 1px 1px rgba(0,0,0,.32);
    --shadow-sm:0 1px 2px rgba(0,0,0,.38), 0 1px 3px rgba(0,0,0,.24);
    --shadow-md:0 1px 2px rgba(0,0,0,.4), 0 3px 10px -2px rgba(0,0,0,.46);
    --shadow-lg:0 2px 4px rgba(0,0,0,.4), 0 10px 24px -6px rgba(0,0,0,.56);
    --shadow-pop:0 3px 6px rgba(0,0,0,.45), 0 20px 44px -12px rgba(0,0,0,.68);
    color-scheme:dark;
  }
}

/* The explicit choice. Identical declarations to the dark media block above —
   this is the half that must WIN when a user has picked dark on a light OS, and
   the reason both exist: "system" needs the media query, a toggle needs this.
   Generated from the block above rather than hand-copied. */
[data-theme="dark"]{

  /* Worst 6.72:1 across the dark surfaces; near-black on it 8.47:1. */
  --color-accent:#6cb2f5; --color-accent-dark:#8cc3f8; --color-accent-2:#56a4f0;
  --color-accent-soft:rgba(108,178,245,.13); --color-accent-soft-strong:rgba(108,178,245,.20);
  --color-accent-on:#0b1017;

  /* Worst 7.17:1 (danger). */
  --color-success:#3fd39b; --color-success-soft:rgba(63,211,155,.13);
  --color-danger:#fd8a7f;  --color-danger-soft:rgba(253,138,127,.13);
  --color-warning:#f5be4a; --color-warning-soft:rgba(245,190,74,.13);
  --color-info:#6cb2f5;    --color-info-soft:rgba(108,178,245,.13);

  /* A near-neutral stack with a faint cool cast. It lets the accent and the
     data colours carry the only real chroma on screen, which is what makes a
     dense report calm rather than lurid. card-vs-canvas is the same 1.11 step
     as in light, so a visual reads as a sheet in both modes. */
  --bg-panel:#0e1013;
  --bg-app:#131519;
  --bg-page:#131519;
  --bg-card:#1c1f25; --bg-surface:#1c1f25;
  --bg-surface-alt:#23262d;
  --bg-sidebar:#0e1013; --bg-header:rgba(14,16,19,.86);
  --bg-composer:#1c1f25;
  --bg-hover:rgba(255,255,255,.055); --bg-active:rgba(108,178,245,.15);

  --border-color:#282c33; --border-strong:#3a404a; --scrollbar-thumb:#3f4550;

  /* Worst-case: 12.80 / 8.54 / 5.02 / 3.17. */
  --text-primary:#eaecf0; --text-secondary:#bcc3ce;
  --text-muted:#8d95a3; --text-faint:#6b7382;

  --bubble-user-bg:#1a2534; --bubble-user-fg:#e6ecf4; --bubble-user-border:#26344a;
  --sql-bg:#12161c; --sql-border:#252b34; --sql-fg:#9cc6f5;

  /* The same eight hues re-stepped for the #1c1f25 card, where all eight clear
     3:1 — so dark mode needs no relief rule. */
  --series-1:#3987e5; --series-2:#d95926; --series-3:#199e70; --series-4:#c98500;
  --series-5:#d55181; --series-6:#008300; --series-7:#9085e9; --series-8:#e66767;
  --seq-1:#0d366b; --seq-2:#184f95; --seq-3:#2a78d6; --seq-4:#5598e7; --seq-5:#9ec5f4;
  --chart-grid:rgba(154,168,189,.16); --chart-axis:#3a404a;

  --shadow-xs:0 1px 1px rgba(0,0,0,.32);
  --shadow-sm:0 1px 2px rgba(0,0,0,.38), 0 1px 3px rgba(0,0,0,.24);
  --shadow-md:0 1px 2px rgba(0,0,0,.4), 0 3px 10px -2px rgba(0,0,0,.46);
  --shadow-lg:0 2px 4px rgba(0,0,0,.4), 0 10px 24px -6px rgba(0,0,0,.56);
  --shadow-pop:0 3px 6px rgba(0,0,0,.45), 0 20px 44px -12px rgba(0,0,0,.68);
  color-scheme:dark;
}

/* ── base ───────────────────────────────────────────────────────────────── */
*,*::before,*::after{box-sizing:border-box;}
html{-webkit-text-size-adjust:100%;}
body{
  margin:0; background:var(--bg-page); color:var(--text-primary);
  font-family:var(--font-sans); font-size:var(--fs-body); line-height:1.5;
  -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
  /* `tnum` app-wide: this product is columns of figures, and proportional
     numerals make a column of them ragged. Prose is unaffected. */
  font-variant-numeric:tabular-nums;
  font-feature-settings:'cv05' 1;   /* Inter: a single-storey 'l', clearer at 12px */
}
/* Prose wants proportional numerals back — tabular digits inside a sentence look
   spaced out. Only tables, figures and axis ticks want them. */
.prose,.md,.page__lede,.hero__sub{font-variant-numeric:normal;}
h1,h2,h3,h4{margin:0; font-weight:670; letter-spacing:-.016em; text-wrap:balance;}
h1{font-size:var(--fs-title);} h2{font-size:var(--fs-h);}
h3{font-size:14px;} h4{font-size:var(--fs-body);}
p{margin:0;}
a{color:var(--color-accent); text-decoration:none;}
a:hover{text-decoration:underline;}
code,kbd,pre,.mono{font-family:var(--font-mono); font-variant-numeric:tabular-nums;}
img,svg{max-width:100%;}
::selection{background:var(--color-accent-soft-strong);}

/* Keyboard focus was invisible before — this is the single most important a11y fix. */
:focus-visible{outline:2px solid var(--color-accent); outline-offset:2px; border-radius:4px;}
:focus:not(:focus-visible){outline:none;}

/* The `hidden` attribute must actually hide.
   The UA stylesheet's [hidden]{display:none} is a single attribute selector, so
   ANY class rule that sets `display` outranks it — and a component whose class
   sets display:flex/grid stays on screen after `el.hidden = true`. That is not a
   theoretical hazard: it left a "Working…" overlay permanently covering every
   dashboard tile, an empty refresh bar under the filters, and the stop button
   showing next to send. One rule, at the top, so it cannot happen again. */
[hidden]{display:none !important;}

.sr-only{
  position:absolute; width:1px; height:1px; padding:0; margin:-1px;
  overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
}
.skip-link{
  position:absolute; left:-9999px; top:8px; z-index:200;
  background:var(--bg-card); color:var(--text-primary); padding:10px 16px;
  border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:var(--shadow-lg);
}
.skip-link:focus{left:8px;}

@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{animation-duration:.01ms !important; animation-iteration-count:1 !important;
    transition-duration:.01ms !important; scroll-behavior:auto !important;}
}

::-webkit-scrollbar{width:10px; height:10px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb); border-radius:99px; border:2px solid transparent; background-clip:content-box;}

/* ── app shell ────────────────────────────────────────────────────────────
   A flex COLUMN: the chrome, then the body.

     .app
       .chrome        the two chrome rows, ONE sticky box
         .topnav      brand · the six destinations · search · service · identity
         .topbar      breadcrumb · page title · page actions
       .appbody       a flex ROW: the optional rail, then .main

   This replaced a two-track grid (`var(--sidebar-w) 1fr`) whose track 1 was the
   sidebar. Two things that grid made easy to get wrong, and that a column does
   not: a page with no rail left a phantom 260px track, and a chrome row added
   above the grid would have sat outside the `height:100vh` box and pushed the
   whole page down by its own height inside a `body{overflow:hidden}`.

   Why `.chrome` and not two sticky rows: a second `position:sticky` element
   needs `top:` set to the height of the first — a hard-coded 56px, the exact
   magic number that has broken this layout twice. One sticky box, `top:0`, and
   both rows can be any height they like. */
.app{display:flex; flex-direction:column; min-height:100vh;}

.chrome{
  /* ONE row, and it belongs to the PANEL layer — one step behind the canvas, so
     the report surface reads as the thing in front. A chrome bar that is the
     same colour as the canvas is not a bar, it is a margin. */
  flex:none; position:sticky; top:0; z-index:30;
  background:var(--bg-panel); border-bottom:1px solid var(--border-color);
  /* Named for the view transition below: the chrome is the one thing that must
     NOT cross-fade between pages — it is the same object, so it stays put while
     the content behind it changes. */
  view-transition-name:chrome;
}
.appbody{flex:1 1 auto; min-height:0; display:flex; align-items:stretch;}

/* A "bare" page (chat, dashboard, upload) fills the viewport exactly and scrolls
   INSIDE its own regions rather than scrolling the document. Expressed as a flex
   chain from one 100vh ancestor, so the footer lands on the viewport edge no
   matter how tall the chrome turns out to be. `body.is-bare` is set by
   Airo.portal.renderShell when it is asked for a bare page. */
body.is-bare{overflow:hidden;}
body.is-bare .app{height:100vh; min-height:0; overflow:hidden;}
body.is-bare .main{min-height:0;}
/* On a bare page nothing scrolls at the document level, so the chrome has
   nothing to stick to — and a sticky box inside an `overflow:hidden` ancestor
   creates a containing block for no reason. */
body.is-bare .chrome{position:static;}

/* `.main__bare` is the last link in that chain — the content slot renderShell
   builds for `mount({bare:true})`.
   ───────────────────────────────────────────────────────────────────────────
   It lived in chat.css, which ONLY index.html loads — while dashboard.html and
   upload.html also mount bare. So on two of the three bare pages this rule was
   absent entirely: `#main.main__bare` was an unstyled block, `#dashapp{height:
   100%}` resolved against an auto-height parent, and `.canvas{flex:1 1 auto}`
   had no flex parent to be a child of, so its flex properties were ignored.
   That is why the dashboard footer never sat on the viewport edge no matter how
   many times the chain was "fixed" — the fix kept landing in a stylesheet that
   page does not load. A rule the shell depends on belongs with the shell.

   It stays layout-NEUTRAL: height and clipping only. It used to also carry
   `grid-template-rows:1fr auto` — the chat's own row split — which the dashboard
   inherited, pushing its ribbon out of view. A shared container cannot own one
   page's row structure; each page sets its own rows on its own root. */
.main__bare{
  /* flex:1 against the 100vh .main, NOT calc(100vh - a token). The old form
     hard-coded the topbar height, so a two-line topbar left the main column
     shorter than the sidebar and the page tabs floated above a dead band. */
  flex:1 1 auto; min-height:0; position:relative;
  display:flex; flex-direction:column; overflow:hidden;
}

/* ── the contextual rail ──────────────────────────────────────────────────
   Built ONLY when a page passes `rail:'<label>'` to mount(). It used to hold the
   six destinations and be present on every page; with those in the top strip it
   is a per-page list, and five of the seven pages have nothing to put in it.

   It is no longer `position:sticky; height:100vh`. It is a stretched flex child
   of `.appbody`, so it is exactly as tall as the body beside it — which is what
   makes it agree with the content on where the bottom of the page is. The old
   pairing of a 100vh rail with a `calc(100vh - 56px)` content column is the
   disagreement that put the footer in the wrong place. */
.sidebar{
  flex:none; width:var(--sidebar-w); min-width:0;
  background:var(--bg-sidebar); border-right:1px solid var(--border-color);
  display:flex; flex-direction:column; overflow:hidden;
}
/* min-height:0 is what lets the inner list actually scroll rather than growing
   the rail past the viewport. */
.sidebar__scroll{
  flex:1 1 auto; min-height:0; overflow-y:auto; padding:12px 10px;
  display:flex; flex-direction:column;
}
/* The page's own slot in the rail. Two pages use it — index.html for the
   conversations list and reports.html for the module facet — so its base layout
   belongs HERE, with the shell, not in one of those pages' stylesheets. It was
   in chat.css, which reports.html does not load, so the facet list had no top
   margin and no gap: the same class of bug as `.main__bare` above. */
.sidebar__extra{
  margin-top:14px; display:flex; flex-direction:column; min-height:0; gap:8px;
}
.sidebar__label{
  font-size:10.5px; font-weight:700; letter-spacing:.09em; text-transform:uppercase;
  color:var(--text-faint); padding:10px 10px 6px;
}

.nav{display:flex; flex-direction:column; gap:2px;}
.nav__item{
  display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:var(--radius-sm);
  color:var(--text-secondary); font-size:13.8px; font-weight:500; cursor:pointer;
  border:1px solid transparent; background:none; width:100%; text-align:left;
  transition:background var(--transition-fast), color var(--transition-fast);
}
.nav__item:hover{background:var(--bg-hover); color:var(--text-primary); text-decoration:none;}
/* Current destination (the strip / the rail) and "this filter is on" (the
   reports module facet) look the same because they mean the same thing to a
   reader — but they carry different ARIA: aria-current="page" for a location,
   aria-pressed for a toggle. Only one element per document may claim to be
   the current page. */
.nav__item[aria-current="page"],
.nav__item.is-on{background:var(--bg-active); color:var(--color-accent); font-weight:600;}
.nav__item svg{width:17px; height:17px; flex:0 0 auto;}
.nav__badge{
  margin-left:auto; font-size:10.5px; font-weight:700; padding:1px 6px; border-radius:var(--radius-pill);
  background:var(--bg-surface-alt); color:var(--text-muted); border:1px solid var(--border-color);
}

/* `.nav--row` and `.navbar` used to live here: a horizontal nav variant for
   "the two pages that own their own full-bleed layout and cannot host the
   vertical sidebar". Both of those pages are on the shared shell now, so both
   rules had zero consumers — and `.nav--row .nav__item{color:inherit}` was a
   hack for the private per-page palettes that no longer exist. Deleted rather
   than repurposed: the primary nav strip is `.topnav` below, built for the job
   instead of inherited from a workaround. */

/* `flex:1 1 auto` is load-bearing, not decoration.
   ───────────────────────────────────────────────────────────────────────────
   `.main` used to be a GRID TRACK (`1fr`), which sized it for free. It is a flex
   child of `.appbody` now, and a flex child defaults to `flex:0 1 auto` — so it
   shrank to its own content width and left the rest of the window empty: the
   dashboard ribbon stopped mid-screen, `.page{margin:0 auto}` had no spare width
   to centre within so every document page sat hard against the left edge, and
   the reports grid laid out three columns in a third of the viewport.
   One declaration, four pages misaligned. */
.main{
  flex:1 1 auto; min-width:0;
  display:flex; flex-direction:column; background:var(--bg-app);
}
/* ── the content column ─────────────────────────────────────────────────────
   `align-self:center`, not `margin:0 auto`.

   `.page` is a flex item of `.main` (a column), so "centre horizontally" is a
   CROSS-axis question. Auto margins are the fragile way to ask it: with the
   default `align-self:stretch` and a `max-width`, the item was laid out against
   the start of the line and all the remaining space was left on the right — the
   content column hugged the left edge of a 1920px screen and looked broken.
   `align-self:center` says it once and cannot be misread.

   (This whole rule was also, briefly, deleted outright by a bad splice — which
   is what removed the left padding as well and made the section labels run off
   the edge of the screen. The layout gate now asserts it exists.) */
.page{
  align-self:center; width:100%; max-width:var(--page-max); min-width:0;
  padding:var(--sp-5) var(--sp-5) var(--sp-7);
}
.page--wide{max-width:none;}
.page__head{margin-bottom:var(--sp-5);}
.page__lede{color:var(--text-muted); font-size:14px; max-width:74ch; margin-top:5px;}

/* The only free-growth element in the chrome row, and therefore what pins the
   search, actions and identity to the right. */
.topbar__spacer{flex:1 1 auto;}

/* The rail's drawer button: hidden until the rail goes off-canvas. */
.burger{
  display:none; flex:none; border:0; background:none; padding:6px;
  border-radius:var(--radius-sm); color:var(--text-muted); cursor:pointer;
  place-items:center;
}
.burger:hover{background:var(--bg-hover); color:var(--text-primary);}
.burger svg{width:18px; height:18px;}

/* ── the page heading ───────────────────────────────────────────────────────
   Inside the content column, not in the chrome. It was a second full-bleed
   chrome band holding a breadcrumb over a title over a subtitle — which
   repeated the lit nav tab, spent an entire band on one word, and did not line
   up with the column of content it was introducing.

   `.topbar__title` and `.topbar__sub` keep their class names because three page
   scripts reach for them by selector; only their parent and their size changed. */
.pagehead{
  position:relative; margin:0 0 var(--sp-4); padding-left:12px; min-width:0;
}
/* The page's own colour, as a bar beside the whole block — the Meridian motif
   applied to the page itself. A bar rather than an underline because the block
   is two baselines tall and an underline has to negotiate with both. */
.pagehead::before{
  content:''; position:absolute; left:0; top:3px; bottom:4px; width:3px;
  border-radius:2px; background:var(--page-accent, var(--color-accent));
}
.pagehead--none{display:none;}
.pagehead__row{
  display:flex; align-items:baseline; gap:12px; flex-wrap:wrap; min-width:0;
}
.topbar__title{
  font-size:var(--fs-title); font-weight:680; letter-spacing:-.025em;
  line-height:1.2; color:var(--text-primary); margin:0; min-width:0;
}
.topbar__sub{font-size:var(--fs-chrome); color:var(--text-muted); min-width:0;}
/* The trail, when there is one, sits above the title in the same block. */
.pagehead .crumbs{margin-bottom:5px;}
/* A standfirst belongs tight under the title it qualifies, not a full section
   gap away — and Reports and System both put one directly after the heading. */
.pagehead + .page__head{margin-top:calc(var(--sp-5) * -1 + 6px);}

/* ── row 1: the primary navigation strip ────────────────────────────────────
   Each destination carries its own hue (`--hue`, a slot in the validated series
   ramp, stamped by renderNav from Airo.PAGES). The active tab wears it on its
   edge and its icon; an inactive tab reveals it on hover. Interactive controls
   keep the single --color-accent, so "this is clickable" is never a
   colour-matching exercise — the hue is identity, not affordance. */
.topnav{
  display:flex; align-items:center; gap:6px;
  padding:0 16px; min-height:var(--topbar-h);
}
.brand{
  display:flex; align-items:center; gap:9px; flex:none;
  text-decoration:none; color:var(--text-primary);
  padding:3px 10px 3px 0; margin-right:2px; border-radius:var(--radius-sm);
}
.brand:hover{text-decoration:none;}
.brand__mark{
  width:24px; height:24px; border-radius:var(--radius-sm); flex:none;
  display:grid; place-items:center;
  font-weight:700; font-size:12px; color:#fff;
  /* The one gradient in the interface, and it earns its place: it is the mark.
     Everything else is a flat surface. */
  background:linear-gradient(135deg, var(--series-1) 0%, var(--color-accent-2) 52%,
                                     var(--series-7) 100%);
  box-shadow:0 0 0 1px rgba(255,255,255,.14) inset;
}
.brand__name{font-weight:670; font-size:13.5px; letter-spacing:-.02em;}
.brand:focus-visible{outline:2px solid var(--color-accent); outline-offset:2px;}

/* The strip scrolls rather than wraps. Wrapping was what the deleted `.nav--row`
   did, and a nav that grows a second line pushes the whole page down as the
   window narrows. The scrollbar is hidden because a 6-item strip that just fits
   should not show one for three pixels of overflow. */
.topnav__strip{
  flex:0 1 auto; min-width:0; overflow-x:auto; scrollbar-width:none;
  -ms-overflow-style:none;
}
.topnav__strip::-webkit-scrollbar{display:none;}
.nav--top{flex-direction:row; gap:1px; align-items:stretch;}

/* Row-mode item: fixed size, one line, never shrinking. `.nav__item` sets
   `width:100%` for the rail, which is meaningless here, and inherits
   flex-shrink:1, which would squeeze the labels before the strip ever scrolled. */
.nav--top .nav__item{
  width:auto; flex:none; white-space:nowrap; gap:7px;
  padding:0 11px; min-height:var(--topbar-h);
  border-radius:0; position:relative;
  font-size:var(--fs-chrome); font-weight:550;
  color:var(--text-muted);
  transition:color var(--transition-fast), background var(--transition-fast);
}
.nav--top .nav__item:hover{background:var(--bg-hover); color:var(--text-primary);}
.nav--top .nav__item:hover svg{color:var(--hue, var(--color-accent)); opacity:1;}
.nav--top .nav__item[aria-current="page"]{
  background:none; color:var(--text-primary); font-weight:640;
}
.nav--top .nav__item[aria-current="page"] svg{
  color:var(--hue, var(--color-accent)); opacity:1;
}
.nav--top .nav__item[aria-current="page"]:hover{background:var(--bg-hover);}
.nav--top .nav__item svg{width:15px; height:15px;}
.nav--top .nav__item:focus-visible{
  outline:2px solid var(--color-accent); outline-offset:-2px;
}
/* Group headings never appear in the strip — renderNav is asked for a flat list
   via data-nav-flat — but the rule is a backstop so a caller that forgets cannot
   put "WORKSPACE" inline between two tabs. */
.nav--top .nav__grouplabel{display:none;}


/* ── narrow screens ───────────────────────────────────────────────────────
   The nav strip SCROLLS rather than collapsing — six destinations with icons and
   labels is ~590px, which fits a phone in a horizontal swipe and needs no
   hamburger, no drawer and no hidden state. Only the contextual rail goes
   off-canvas, and only on the two pages that have one. */
@media (max-width:1100px){
  /* Labels go first, icons stay: a 6-item icon strip is ~190px and always fits,
     so the strip stops being the thing that scrolls before the page does. */
  .brand__name{display:none;}
}
@media (max-width:900px){
  .sidebar{
    position:fixed; top:0; bottom:0; left:0; z-index:60;
    width:min(86vw,var(--sidebar-w)); height:auto;
    transform:translateX(-100%); transition:transform var(--transition-base);
    box-shadow:var(--shadow-pop);
  }
  .sidebar.is-open{transform:none;}
  .burger{display:grid;}
  .scrim{position:fixed; inset:0; background:rgba(8,11,18,.5); z-index:50;}
  .topnav,.topbar{padding-left:12px; padding-right:12px;}
}
@media (max-width:640px){
  /* Below this the labels cost more than they earn — the strip would scroll on
     every page. Icons only, with the accessible name kept on the element. */
  .nav--top .nav__item span{
    position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0);
    white-space:nowrap;
  }
  .nav--top .nav__item{padding:8px 10px 10px;}
}

/* ── the visual container ───────────────────────────────────────────────────
   THE component the whole visual language turns on: a white sheet sitting on a
   grey canvas. That surface step (1.11 luminance) is what makes it a thing —
   the hairline is a refinement, not the reason you can see it. Which is why:

     * NO shadow at rest. Twelve shadowed cards read as a pile; twelve sheets on
       a canvas read as a report. Elevation is reserved for something actually
       lifted (see .card--tap and the menus).
     * A 6px radius, not 13. A data table with 13px corners looks like a
       greetings card.
     * Denser padding, because a report is read by someone comparing figures. */
.grid{display:grid; gap:var(--sp-3);}
.grid--kpi{grid-template-columns:repeat(auto-fit,minmax(196px,1fr));}
.grid--2{grid-template-columns:repeat(auto-fit,minmax(320px,1fr));}
.grid--3{grid-template-columns:repeat(auto-fit,minmax(256px,1fr));}

.card{
  background:var(--bg-card); border:1px solid var(--border-color);
  border-radius:var(--radius-md); min-width:0;
}
/* The visual header: title left, hover-revealed actions right. Small and
   semibold — it names the visual, it is not a heading competing with it. */
.card__head{
  display:flex; align-items:center; gap:var(--sp-3); padding:11px 13px 0;
}
.card__title{font-size:var(--fs-chrome); font-weight:650; letter-spacing:-.005em;}
.card__sub{font-size:11.5px; color:var(--text-muted);}
.card__body{padding:11px 13px 13px;}
.card__foot{
  padding:9px 13px; border-top:1px solid var(--border-color);
  display:flex; align-items:center; gap:var(--sp-3);
  background:var(--bg-surface-alt); border-radius:0 0 var(--radius-md) var(--radius-md);
}

/* Small chrome, large figure. The label is deliberately tiny and wide; the
   number is deliberately large and tight. That contrast is what makes a KPI read
   as a KPI rather than as a labelled form field. */
.kpi__label{
  display:flex; align-items:baseline; gap:6px; min-width:0;
  font-size:var(--fs-micro); font-weight:650; letter-spacing:.07em;
  text-transform:uppercase; color:var(--text-muted);
}
/* The aggregate, beside the measure name rather than concatenated into it.
   Lower case and unspaced: it is a qualifier, not part of the name. */
.kpi__qual{
  flex:none; text-transform:lowercase; letter-spacing:0; font-weight:500;
  font-size:11px; opacity:.8;
}
.kpi__value{
  font-size:var(--fs-figure); font-weight:700; letter-spacing:-.03em;
  font-variant-numeric:tabular-nums; margin-top:5px; line-height:1.05;
  color:var(--text-primary);
}
.kpi__meta{font-size:11.5px; color:var(--text-muted); margin-top:5px;}
.kpi__delta{font-size:11.5px; font-weight:650;
  font-variant-numeric:tabular-nums;}
.kpi__delta--up{color:var(--color-success);} .kpi__delta--down{color:var(--color-danger);}

/* ── buttons ────────────────────────────────────────────────────────────── */
/* ── controls ───────────────────────────────────────────────────────────────
   ONE HEIGHT, declared once.

   Nothing on a toolbar used to line up, and the reason was structural rather
   than a series of small mistakes: almost no control declared a height. A
   button was 30px because it said so; a segmented option was 28px because of
   its padding; a ribbon button 25px; a chart pill 21px; a filter box 21px. Put
   any two of them in a row and the row has two baselines and three heights.

   So height comes from a token and every control claims it. Padding then only
   decides how wide the thing is, which is what padding should decide.

     --ctl-h     30px   the default: button, input, select, segmented option
     --ctl-h-sm  26px   a dense toolbar (the dashboard ribbon, a panel header)
     --ctl-h-lg  36px   a primary action that should feel like one

   The other half of "aligned" is optical, not metric: an icon and a label in the
   same control must sit on the same centre line. That means `align-items:center`
   (never baseline, which aligns the icon's box bottom to the text baseline and
   drops it 2px), `flex:none` on the icon so a long label cannot squash it, and
   one icon size per control size. */
:root{
  --ctl-h:30px; --ctl-h-sm:26px; --ctl-h-lg:36px;
  /* Slider thumb: the range slider insets its rail by half of this so the
     drawn track matches where a thumb centre can actually go. */
  --slider-thumb:22px;
  /* The select's chevron. A data URI rather than a pseudo-element because a
     <select> cannot reliably carry one, and per-theme rather than currentColor
     because a background-image cannot inherit it. */
  --select-arrow:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23586374' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M3 4.5 6 7.5l3-3'/%3E%3C/svg%3E");
}

/* ── buttons ─────────────────────────────────────────────────────────────────
   Five variants, six states each, and one shared height.

   What "production grade" means here is mostly the STATES. A button that only
   has a rest and a hover is a link with a border: you cannot tell whether your
   click landed, you cannot tell it apart from a label when it is disabled, and
   it disappears for a keyboard user. So each variant declares:

     rest · hover · :active (pressed) · :focus-visible · :disabled · [data-busy]

   Three details that carry most of the quality:

     1. THE PRESS IS REAL. `translateY(1px)` plus a tightened shadow, so the
        button physically goes down under the pointer. It is 60ms and you never
        consciously see it — but a button without it feels like a picture of a
        button.

     2. THE PRIMARY HAS AN EDGE. A 1px inset highlight along its top and a
        slightly darker bottom border. That is what makes a filled button read as
        a raised surface rather than a coloured rectangle, and it is why a flat
        fill looks cheap next to it.

     3. BUSY RESERVES ITS WIDTH. `[data-busy]` swaps the label for a spinner
        WITHOUT changing the box: the label stays in place at zero opacity and
        the spinner is absolutely positioned over it. A button that shrinks when
        clicked makes the whole row jump.

   The focus ring is an `outline` with an offset, never a box-shadow: a shadow
   ring is clipped by any ancestor with `overflow:hidden`, which is most cards,
   and a focus ring you cannot see is the one state that must never fail.       */
.btn{
  position:relative;
  display:inline-flex; align-items:center; justify-content:center; gap:6px;
  height:var(--ctl-h); padding:0 13px;
  border-radius:var(--radius-sm); border:1px solid var(--border-strong);
  background:var(--bg-card); color:var(--text-primary);
  font-family:inherit; font-size:var(--fs-chrome); font-weight:600;
  letter-spacing:-.005em; line-height:1;
  cursor:pointer; white-space:nowrap; flex:none;
  box-shadow:var(--shadow-xs);
  transition:background var(--transition-fast) var(--ease-out),
             border-color var(--transition-fast) var(--ease-out),
             box-shadow var(--transition-fast) var(--ease-out),
             color var(--transition-fast) var(--ease-out),
             transform 60ms var(--ease-out);
}
.btn:hover{
  background:var(--bg-hover); border-color:var(--text-faint);
  box-shadow:var(--shadow-sm);
}
/* The press. 1px is the whole effect — 2px reads as a bounce. */
.btn:active{transform:translateY(1px); box-shadow:none;}
.btn:focus-visible{outline:2px solid var(--color-accent); outline-offset:2px;}
.btn:disabled,.btn[aria-disabled="true"]{
  opacity:.45; cursor:not-allowed; box-shadow:none; transform:none;
}
.btn:disabled:hover,.btn[aria-disabled="true"]:hover{
  background:var(--bg-card); border-color:var(--border-strong);
}
/* `flex:none` on the icon, so a long label squashes the label and not the glyph.
   A 15px icon compressed to 9px is the most obvious sign of a rushed toolbar. */
.btn svg{width:15px; height:15px; flex:none;}
.btn__label{overflow:hidden; text-overflow:ellipsis; min-width:0;}

/* ── primary ────────────────────────────────────────────────────────────────
   The inset highlight is what makes this read as a raised surface. Two layers:
   a 1px light line along the top inside edge, and a slightly darker bottom
   border — the same trick a physical key uses. */
.btn--primary{
  background:var(--color-accent);
  border-color:var(--color-accent);
  color:var(--color-accent-on);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.16),
             0 1px 2px color-mix(in srgb, var(--color-accent) 32%, transparent);
}
.btn--primary:hover{
  background:var(--color-accent-dark); border-color:var(--color-accent-dark);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.18),
             0 2px 6px -1px color-mix(in srgb, var(--color-accent) 42%, transparent);
}
.btn--primary:active{
  transform:translateY(1px);
  box-shadow:inset 0 1px 2px rgba(0,0,0,.18);
}
.btn--primary:disabled,.btn--primary:disabled:hover{
  background:var(--color-accent); border-color:var(--color-accent);
  box-shadow:none;
}

/* ── secondary: the default above ───────────────────────────────────────────
   ── ghost: no chrome until you point at it ─────────────────────────────────
   For a control that lives in a dense row (a toolbar, a card footer) where a
   bordered button per action would be a fence. */
.btn--ghost{
  background:transparent; border-color:transparent;
  color:var(--text-secondary); box-shadow:none;
}
.btn--ghost:hover{
  background:var(--bg-hover); border-color:transparent;
  color:var(--text-primary); box-shadow:none;
}
.btn--ghost:active{background:var(--bg-active); transform:translateY(1px);}

/* ── subtle: a filled but quiet action ─────────────────────────────────────
   Reads as a button at rest (unlike ghost) without competing with the primary.
   The hover tints toward the accent, which is the "this is the safe one to
   click" signal. */
.btn--subtle{
  background:var(--bg-surface-alt); border-color:transparent;
  color:var(--text-secondary); box-shadow:none;
}
.btn--subtle:hover{
  background:var(--color-accent-soft); border-color:var(--color-accent-soft-strong);
  color:var(--color-accent);
}
.btn--subtle:active{transform:translateY(1px);}

/* ── danger ─────────────────────────────────────────────────────────────────
   Outlined at rest, filled only on hover. A destructive action should not be
   the loudest thing on screen until you are actually reaching for it. */
.btn--danger{
  background:transparent; border-color:var(--border-strong);
  color:var(--color-danger); box-shadow:none;
}
.btn--danger:hover{
  background:var(--color-danger); border-color:var(--color-danger);
  color:#fff;
  box-shadow:inset 0 1px 0 rgba(255,255,255,.16),
             0 2px 6px -1px color-mix(in srgb, var(--color-danger) 40%, transparent);
}
.btn--danger:active{transform:translateY(1px); box-shadow:none;}

/* ── sizes ──────────────────────────────────────────────────────────────── */
.btn--sm{height:var(--ctl-h-sm); padding:0 10px; font-size:11.5px; gap:5px;}
.btn--sm svg{width:13px; height:13px;}
.btn--lg{height:var(--ctl-h-lg); padding:0 20px; font-size:var(--fs-body);
  gap:7px;}
.btn--lg svg{width:16px; height:16px;}
/* Square, so an icon button is a square and not a slightly-wide rectangle.
   Height is restated rather than inherited: a rule you have to read three
   selectors up to size is how a 30px icon button ended up beside a 26px one. */
.btn--icon{width:var(--ctl-h); height:var(--ctl-h); padding:0;}
.btn--icon.btn--sm{width:var(--ctl-h-sm); height:var(--ctl-h-sm);}
.btn--icon.btn--lg{width:var(--ctl-h-lg); height:var(--ctl-h-lg);}
.btn--icon svg{width:16px; height:16px;}
/* Fills its container — for a modal footer or a rail, where a button that only
   wraps its label looks stranded. */
.btn--block{width:100%; flex:1 1 auto;}

/* ── busy ───────────────────────────────────────────────────────────────────
   The label stays in the flow at zero opacity, so the button keeps its exact
   width, and the spinner is centred over it. A button that shrinks to "…" when
   clicked makes every button beside it jump. */
.btn[data-busy]{cursor:progress; pointer-events:none;}
.btn[data-busy] > *{opacity:0;}
.btn[data-busy]::after{
  content:''; position:absolute; top:50%; left:50%;
  width:14px; height:14px; margin:-7px 0 0 -7px;
  border-radius:50%; border:2px solid currentColor; border-top-color:transparent;
  animation:btnspin .6s linear infinite;
}
@keyframes btnspin{to{transform:rotate(360deg);}}

/* A row of buttons. `gap` rather than margins, so a conditionally-rendered
   button never leaves a stranded gap where it would have been. */
.btnrow{display:flex; align-items:center; gap:var(--sp-2); flex-wrap:wrap;}
.btnrow--end{justify-content:flex-end;}

@media (prefers-reduced-motion:reduce){
  .btn{transition:background var(--transition-fast), color var(--transition-fast);}
  .btn:active{transform:none;}
  .btn[data-busy]::after{animation:none;}
}

/* ── segmented control ──────────────────────────────────────────────────────
   An inset track with a raised chip for the current option. Distinct from a TAB
   (which is a location) — this is a choice between views of the same thing, and
   the raised-chip treatment is what says so. Its outer height matches a button
   exactly: the track's 2px padding plus a (--ctl-h - 6)px option. */
.seg{
  display:inline-flex; align-items:center; height:var(--ctl-h); padding:2px;
  gap:2px; background:var(--bg-surface-alt); border:1px solid var(--border-color);
  border-radius:var(--radius-sm); flex:none;
}
.seg__opt{
  display:inline-flex; align-items:center; justify-content:center; gap:5px;
  height:calc(var(--ctl-h) - 6px); padding:0 11px;
  border:0; background:none; border-radius:3px; cursor:pointer;
  font-family:inherit; font-size:var(--fs-chrome); font-weight:600;
  line-height:1; color:var(--text-muted); white-space:nowrap;
  transition:background var(--transition-fast), color var(--transition-fast);
}
.seg__opt:hover{color:var(--text-primary);}
.seg__opt svg{width:14px; height:14px; flex:none;}
.seg__opt[aria-pressed="true"],.seg__opt.is-active{
  background:var(--bg-card); color:var(--text-primary);
  box-shadow:var(--shadow-xs);
}
.seg__opt:focus-visible{outline:2px solid var(--color-accent); outline-offset:-1px;}
.seg--sm{height:var(--ctl-h-sm);}
.seg--sm .seg__opt{height:calc(var(--ctl-h-sm) - 6px); padding:0 9px;
  font-size:11.5px;}

/* ── tabs ───────────────────────────────────────────────────────────────────
   A TAB is a location; the current one wears the 2px accent edge (the Meridian
   motif) rather than a fill. One component, used by the rail panes and anywhere
   else that needs underline tabs — before this, three places each invented their
   own and none of them agreed on height. */
.tabs{
  display:flex; align-items:stretch; gap:2px; flex:none;
  border-bottom:1px solid var(--border-color);
}
.tab{
  position:relative; display:inline-flex; align-items:center; justify-content:center;
  gap:6px; height:34px; padding:0 12px;
  border:0; background:none; cursor:pointer;
  font-family:inherit; font-size:var(--fs-chrome); font-weight:600;
  line-height:1; color:var(--text-muted); white-space:nowrap;
  transition:color var(--transition-fast), background var(--transition-fast);
}
.tab:hover{color:var(--text-primary); background:var(--bg-hover);}
.tab svg{width:14px; height:14px; flex:none;}
.tab[aria-selected="true"],.tab.is-active{color:var(--text-primary);}
.tab[aria-selected="true"]::after,.tab.is-active::after{
  content:''; position:absolute; left:0; right:0; bottom:-1px; height:2px;
  border-radius:2px 2px 0 0; background:var(--color-accent);
}
.tab:focus-visible{outline:2px solid var(--color-accent); outline-offset:-2px;}
.tab__n{
  flex:none; font-size:10px; font-weight:700; line-height:1; padding:2px 5px;
  border-radius:var(--radius-pill); background:var(--bg-surface-alt);
  color:var(--text-faint);
}
.tab.is-active .tab__n,.tab[aria-selected="true"] .tab__n{
  background:var(--color-accent-soft); color:var(--color-accent);
}

/* ── forms ──────────────────────────────────────────────────────────────── */
.field{display:flex; flex-direction:column; gap:5px; min-width:0;}
.label{
  display:flex; align-items:center; gap:5px;
  font-size:11.5px; font-weight:650; color:var(--text-secondary);
}
.label__req{color:var(--color-danger); font-weight:700;}
.hint{font-size:11.5px; color:var(--text-muted); line-height:1.45;}
.input,.select,.textarea{
  width:100%; min-width:0; height:var(--ctl-h); padding:0 10px;
  border-radius:var(--radius-sm);
  border:1px solid var(--border-strong); background:var(--bg-card);
  color:var(--text-primary);
  font-family:inherit; font-size:var(--fs-body); line-height:1;
  transition:border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.input::placeholder,.textarea::placeholder{color:var(--text-faint);}
.input:hover:not(:focus),.select:hover:not(:focus),
.textarea:hover:not(:focus){border-color:var(--text-faint);}
.input:focus,.select:focus,.textarea:focus{
  border-color:var(--color-accent); outline:none;
  box-shadow:0 0 0 3px var(--color-accent-soft);
}
.textarea{resize:vertical; min-height:76px; height:auto; padding:8px 10px;
  line-height:1.5;}
.input[aria-invalid="true"],.select[aria-invalid="true"]{
  border-color:var(--color-danger);
}
.input[aria-invalid="true"]:focus{box-shadow:0 0 0 3px var(--color-danger-soft);}
.input:disabled,.select:disabled,.textarea:disabled{
  background:var(--bg-surface-alt); color:var(--text-faint); cursor:not-allowed;
}
/* A number field's spinners are noise in a report tool, and clicking one by
   accident silently changes a figure. */
.input[type="number"]{appearance:textfield; -moz-appearance:textfield;}
.input[type="number"]::-webkit-inner-spin-button,
.input[type="number"]::-webkit-outer-spin-button{appearance:none; margin:0;}

/* ── dropdowns ───────────────────────────────────────────────────────────────
   Two different things wear the word, and they get different treatments:

     A NATIVE <select> — a choice from a fixed list. The option list itself is
     drawn by the OS and cannot be styled, so the work is all on the closed
     control: suppress the platform arrow, draw one, and give it the same six
     states as a button. Kept native deliberately — a hand-built listbox means
     re-implementing type-ahead, Home/End, PageUp/Down, screen-reader semantics
     and mobile pickers, and one 95%-correct listbox is worse than a native one.

     A POPOVER MENU (.menu) — a list of ACTIONS. This one is ours end to end,
     and it is the app's real dropdown: the tile menus, the identity menu, the
     column menus. It gets the full treatment below.                          */

/* ── the closed select ───────────────────────────────────────────────────── */
.select{
  appearance:none; -webkit-appearance:none; -moz-appearance:none;
  padding-right:30px;
  background-image:var(--select-arrow);
  background-repeat:no-repeat;
  background-position:right 10px center;
  background-size:12px 12px;
  cursor:pointer;
  /* Truncate the chosen option rather than pushing the control wide: a long
     ledger name in a slicer used to stretch the whole scope bar. */
  text-overflow:ellipsis;
  box-shadow:var(--shadow-xs);
  transition:border-color var(--transition-fast), box-shadow var(--transition-fast),
             background-color var(--transition-fast);
}
.select:hover:not(:disabled):not(:focus){
  border-color:var(--text-faint); background-color:var(--bg-hover);
}
.select:focus{
  border-color:var(--color-accent); outline:none;
  box-shadow:0 0 0 3px var(--color-accent-soft);
}
.select:disabled{cursor:not-allowed; box-shadow:none;}
/* An option list is drawn by the OS, so the one thing that CAN be set — its
   colours — is set, or a dark theme gets a white dropdown. */
.select option{background:var(--bg-card); color:var(--text-primary);}
.select--auto{width:auto; min-width:8ch;}
/* A compact select for a dense row — the Ask topbar's model and row-limit
   pickers sit beside each other and would otherwise be full --ctl-h controls in
   a 52px chrome. Long-hand padding is deliberate: the `padding` SHORTHAND wipes
   .select's padding-right, which is the gap the chevron occupies, and the
   selected option then reads straight through the arrow. That is exactly what
   an inline style.padding on these two pickers used to do. */
.select--sm{
  height:var(--ctl-h-sm); padding:0 27px 0 9px; font-size:11.5px;
  background-position:right 9px center; background-size:11px 11px;
}

/* ── slider ─────────────────────────────────────────────────────────────────
   How much data feeds one plot. A chart of 1,791 daily points is honest and
   unreadable at the same time; this is the control that says "show me thirty".
   Track and thumb are drawn per-engine because a range input cannot be styled
   through one shared pseudo-element. */
.slider{
  -webkit-appearance:none; appearance:none;
  height:var(--ctl-h-sm); width:100%; min-width:64px;
  background:transparent; cursor:pointer; padding:0;
}
.slider::-webkit-slider-runnable-track{
  height:4px; border-radius:2px; background:var(--border-strong);
}
.slider::-moz-range-track{height:4px; border-radius:2px; background:var(--border-strong);}
/* THE THUMB IS THE HIT TARGET, so it is sized as one. It was a 12px dot —
   half the 24px minimum a pointer target is meant to be, and on the two-handle
   range those dots are the ONLY targets, because the overlaid inputs ignore
   pointer events everywhere else. Reported as "i have to put more effort to
   point on to it", which is exactly what an undersized target feels like.

   The border counts as part of the thumb's box for hit-testing, so a
   transparent one buys target area without enlarging the visible dot: 22px of
   target, 14px of ink. background-clip keeps the fill inside the padding box —
   without it the accent floods the whole 22px and the dot looks like a blob. */
.slider::-webkit-slider-thumb{
  -webkit-appearance:none; appearance:none;
  width:var(--slider-thumb); height:var(--slider-thumb);
  margin-top:calc((4px - var(--slider-thumb)) / 2); box-sizing:border-box;
  border-radius:50%; border:4px solid transparent;
  background:var(--color-accent); background-clip:padding-box;
  transition:transform var(--transition-fast), background-color var(--transition-fast);
}
.slider::-moz-range-thumb{
  width:var(--slider-thumb); height:var(--slider-thumb); box-sizing:border-box;
  border-radius:50%; border:4px solid transparent;
  background:var(--color-accent); background-clip:padding-box;
  transition:transform var(--transition-fast), background-color var(--transition-fast);
}
/* Grows under the pointer and while dragging, so it reads as grabbable and
   stays visible under the fingertip once grabbed. */
.slider:hover::-webkit-slider-thumb{transform:scale(1.15);}
.slider:hover::-moz-range-thumb{transform:scale(1.15);}
.slider:active::-webkit-slider-thumb{transform:scale(1.25); background-color:var(--color-accent-dark);}
.slider:active::-moz-range-thumb{transform:scale(1.25); background-color:var(--color-accent-dark);}
.slider:disabled{cursor:not-allowed; opacity:.5;}
/* On the focus ring: outline follows the INPUT's box (the full track), which
   says nothing about which handle has focus, so the ring goes on the thumb. */
.slider:focus-visible{outline:none;}
.slider:focus-visible::-webkit-slider-thumb{
  box-shadow:0 0 0 3px var(--color-accent-soft-strong);
}
.slider:focus-visible::-moz-range-thumb{
  box-shadow:0 0 0 3px var(--color-accent-soft-strong);
}
@media (prefers-reduced-motion:reduce){
  .slider::-webkit-slider-thumb,.slider::-moz-range-thumb{transition:none;}
  .slider:hover::-webkit-slider-thumb,.slider:active::-webkit-slider-thumb,
  .slider:hover::-moz-range-thumb,.slider:active::-moz-range-thumb{transform:none;}
}

/* A select with a leading icon or a label baked in — for a scope bar, where
   "Period: This quarter" in one control beats a separate label plus a select
   that together take 40% more width. */
.selectwrap{
  position:relative; display:inline-flex; align-items:center; min-width:0;
}
.selectwrap__icon{
  position:absolute; left:9px; top:50%; transform:translateY(-50%);
  width:14px; height:14px; pointer-events:none; color:var(--text-faint);
  transition:color var(--transition-fast);
}
.selectwrap:focus-within .selectwrap__icon{color:var(--color-accent);}
.selectwrap .select{padding-left:29px;}

/* ── badges / trust chips ───────────────────────────────────────────────────
   The product distinguishes a curated report, a code-verified metric and
   model-generated SQL. That distinction is the core trust signal, so it gets a
   dedicated component rather than ad-hoc colouring. */
.badge{
  display:inline-flex; align-items:center; gap:4px; padding:1px 7px;
  border-radius:var(--radius-sm); font-size:10.5px; font-weight:700;
  letter-spacing:.03em; border:1px solid transparent; white-space:nowrap;
}
.badge svg{width:11px; height:11px;}
.badge--verified{background:var(--color-success-soft); color:var(--color-success);
  border-color:color-mix(in srgb,var(--color-success) 30%,transparent);}
.badge--report{background:var(--color-info-soft); color:var(--color-info);
  border-color:color-mix(in srgb,var(--color-info) 30%,transparent);}
.badge--generated{background:var(--color-warning-soft); color:var(--color-warning);
  border-color:color-mix(in srgb,var(--color-warning) 32%,transparent);}
.badge--neutral{background:var(--bg-surface-alt); color:var(--text-muted); border-color:var(--border-color);}
.badge--danger{background:var(--color-danger-soft); color:var(--color-danger);
  border-color:color-mix(in srgb,var(--color-danger) 30%,transparent);}

/* ── tables ─────────────────────────────────────────────────────────────── */
/* The data grid. Rows are 30px, not 38: a report is read by scanning a column
   of figures, and the fewer rows fit on screen the more scrolling that scan
   costs. The header is an INSET (one step back), not a bolder version of a row,
   so it reads as chrome and stays put when the body scrolls under it. */
/* ── tables ───────────────────────────────────────────────────────────────
   A query result gets the structure a data tool converges on, and the parts
   are stacked in the order a reader needs them:

     .tabletools    filter · row count · Copy · Expand
     .tablesort     the sort, in words: "Sorted by Item cost, highest first."
     .tablescroll   the ONLY scrolling element

   That last one is the structural point, and it is why .tablewrap no longer
   scrolls. `overflow-x:auto` used to sit on the wrapper, so anything the
   wrapper contained scrolled sideways with the columns — a toolbar and a
   caption included. Splitting the scroll into its own child keeps them still.

   (`overflow-x:auto` also computes `overflow-y` to `auto`, which is what makes
   the sticky header work: it needs a scrolling ancestor. Moving the scroll to
   .tablescroll keeps that — the header now sticks to .tablescroll — and the
   Expand overlay is what finally gives it a height to stick within.) */
.tablewrap{
  border:1px solid var(--border-color); border-radius:var(--radius-md);
  background:var(--bg-card); overflow:hidden;
}
.tablescroll{overflow:auto;}

.tabletools{
  display:flex; align-items:center; gap:var(--sp-3);
  padding:7px 9px; border-bottom:1px solid var(--border-color);
  background:var(--bg-card);
}
.tabletools__gap{flex:1 1 auto;}

/* The filter. An icon inside the field rather than a label beside it: the
   placeholder already says what it does, and a toolbar has no room for both. */
.tablefilter{
  position:relative; display:flex; align-items:center; gap:6px;
  flex:0 1 260px; min-width:120px;
  height:var(--ctl-h-sm); padding:0 6px 0 8px;
  background:var(--bg-app); border:1px solid var(--border-color);
  border-radius:var(--radius-sm);
  transition:border-color var(--transition-fast) var(--ease-out),
             box-shadow var(--transition-fast) var(--ease-out);
}
.tablefilter:focus-within{
  border-color:var(--color-accent); background:var(--bg-card);
  box-shadow:0 0 0 3px var(--color-accent-soft);
}
.tablefilter__icon{width:13px; height:13px; flex:none; color:var(--text-faint);}
.tablefilter:focus-within .tablefilter__icon{color:var(--color-accent);}
.tablefilter input{
  flex:1 1 auto; min-width:0; border:0; background:none; outline:none;
  font:inherit; font-size:var(--fs-chrome); color:var(--text-primary);
}
.tablefilter input::placeholder{color:var(--text-faint);}
.tablefilter__clear{
  flex:none; width:18px; height:18px; border:0; border-radius:var(--radius-sm);
  background:var(--bg-hover); color:var(--text-muted); cursor:pointer;
  font-size:13px; line-height:1; padding:0;
}
.tablefilter__clear:hover{background:var(--border-color); color:var(--text-primary);}

.tablecount{
  font-size:var(--fs-micro); color:var(--text-muted); white-space:nowrap;
  font-variant-numeric:tabular-nums;
}
/* A truncated result is the one row count worth colouring. "10 rows" over a
   4,213-row match is not a small inaccuracy, it is the difference between a
   total and a sample, and it is the only place on the page that says so. */
.tablecount.is-partial{
  color:var(--color-warning); font-weight:650; cursor:help;
}

.tablesort{
  padding:6px 11px; border-bottom:1px solid var(--border-color);
  font-size:var(--fs-micro); color:var(--text-faint);
  background:var(--bg-card);
}

table.data{
  border-collapse:separate; border-spacing:0; width:100%;
  font-size:var(--fs-chrome);
}
/* A virtualised table needs every row the same height, because the spacer rows
   above and below the window are sized as (row count x row height). `fixed`
   also stops the engine measuring every cell to resolve column widths, which
   on a 36k-row result was most of a 9.6 s layout. */
table.data--virtual{table-layout:fixed;}
/* `fixed` measures nothing, so with no floor of its own a wide result (7-8
   EBS columns, an 1800px modal) divides the container evenly and comes out
   under 100px a column — "OUTSTANDING AMOUNT" cannot read in that width at
   any truncation setting, ellipsis or not. min-width is what `fixed` still
   honours as a per-column floor even though it plays no part in `fixed`'s own
   distribution math, so the table grows PAST 100% instead of compressing
   columns below it — the same "wide table scrolls sideways" behaviour a
   non-virtualised result already gets for free from content-based auto
   layout (table.data--wide's sticky first column exists for exactly this
   case). Truncation below stays as the safety net for a genuinely long label,
   not the first line of defence. */
table.data--virtual th,table.data--virtual td{min-width:140px;}
/* `th` as well as `td`, and that omission was a real bug: with `table-layout:
   fixed` a column no longer widens to fit its header, and `thead th` already
   sets `white-space:nowrap`, so a long header had nowhere to go and simply
   spilled out of its cell. Because headers are `position:sticky` (z-index 2, and
   3 for a sticky first column) the overflow painted ON TOP of the next header —
   "TRANSACTION NUMBER" over "TRANSACTION DATE" rendered as
   "TRANSACTION NUMBERSACTION D". The data cells were clipped correctly the whole
   time, which is what made it look like a header-only mystery. */
table.data--virtual td{
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
/* The header clips too, but the ellipsis goes on the LABEL, not the cell. The
   sort glyph is a `::after` on the th, so it is the last inline content in the
   box — clipping the cell would truncate the ARROW and leave the text, removing
   the only signal that the column is sortable. Truncating the label instead
   shrinks the words and keeps the affordance. */
table.data--virtual th{overflow:hidden;}
table.data--virtual th .th__label{
  display:inline-block; max-width:100%;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  vertical-align:bottom;
}
/* The spacers are structure, not data: no padding, no border, nothing to paint. */
table.data .vspacer td{padding:0; border:0;}
table.data th,table.data td{
  padding:6px 11px; text-align:left; border-bottom:1px solid var(--border-color);
  vertical-align:top;
}
table.data thead th{
  position:sticky; top:0; z-index:2; background:var(--bg-surface-alt);
  font-size:11px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
  color:var(--text-muted); white-space:nowrap; cursor:default;
}
/* The sort affordance is present on EVERY sortable column, unsorted included.
   Without the resting glyph a reader has no way to know a header is clickable
   until they try one — and the arrow appearing on click then reads as the
   table having changed shape. It is faint at rest and full strength when it is
   the column actually driving the order. */
table.data thead th[data-sortable]{cursor:pointer; user-select:none;}
table.data thead th[data-sortable] .th__label{display:inline-block;}
table.data thead th[data-sortable]::after{
  content:"\2195"; margin-left:5px; font-size:9px; opacity:.32;
  font-weight:400; letter-spacing:0;
}
table.data thead th[aria-sort="ascending"]::after{content:"\2191"; opacity:1;}
table.data thead th[aria-sort="descending"]::after{content:"\2193"; opacity:1;}
table.data thead th[data-sortable]:hover::after{opacity:.75;}
table.data thead th[aria-sort="ascending"],
table.data thead th[aria-sort="descending"]{color:var(--text-primary);}
table.data thead th[data-sortable]:focus-visible{
  outline:2px solid var(--color-accent); outline-offset:-2px;
}

/* A column a visual above the table is actually reading from — the axis and
   the plotted measure(s) — marked the way the rest of the app marks "the
   current thing": a 2px accent edge, kept to the header cell so it reads as
   a flag rather than a fill running the length of the column. The body
   stays plain — this says "the chart reads this", not "look here". */
table.data thead th.is-plotted{
  color:var(--color-accent);
  background:color-mix(in srgb, var(--color-accent) 10%, var(--bg-surface-alt));
  box-shadow:inset 0 -2px 0 var(--color-accent);
}

/* Zebra. It is the reason a wide table is readable at all: with eight columns
   the eye loses its row between one side and the other, and a stripe is what
   carries it across. Subtle enough that the hover state still reads as a
   change — 2.5% ink, against a 4.5% hover. */
table.data tbody tr:nth-child(even) td{
  background:color-mix(in srgb, var(--text-primary) 2.5%, transparent);
}
table.data tbody tr:last-child td{border-bottom:0;}
table.data tbody tr:hover td{background:var(--bg-hover);}

table.data td.num,table.data th.num{
  text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap;
}
table.data td.num{font-family:var(--font-mono);}
/* A negative is a direction, not a smaller number. The accounting convention,
   and the reason a financial-impact column can be scanned rather than read. */
table.data td.num.is-neg{color:var(--color-danger);}
table.data td.null{color:var(--text-faint); font-style:italic;}
table.data td.tableempty{
  padding:22px 12px; text-align:center; color:var(--text-muted);
}

/* The identity column stays put. Past five columns the table scrolls sideways,
   and the first column is what every other cell in the row is ABOUT — letting
   it leave the viewport leaves numbers belonging to nothing.

   The background must be OPAQUE and must match whatever is underneath, which is
   why the stripe and hover are restated here rather than inherited: a
   transparent sticky cell shows the columns sliding beneath it. */
table.data--wide thead th:first-child,
table.data--wide tbody td:first-child{
  position:sticky; left:0; z-index:1;
  background:var(--bg-card);
  box-shadow:1px 0 0 var(--border-color);
}
table.data--wide thead th:first-child{z-index:3; background:var(--bg-surface-alt);}
table.data--wide tbody tr:nth-child(even) td:first-child{
  background:color-mix(in srgb, var(--text-primary) 2.5%, var(--bg-card));
}
table.data--wide tbody tr:hover td:first-child{
  background:color-mix(in srgb, var(--bg-hover) 100%, var(--bg-card));
}

.table-note{
  font-size:12px; color:var(--text-muted); padding:8px 12px;
  border-top:1px solid var(--border-color); background:var(--bg-surface-alt);
}

/* Expand. The one place the sticky header earns its keep: a height to stick
   inside. Everything else about it is the same renderer. */
.tableexpand{
  position:fixed; inset:0; z-index:110; display:grid; place-items:center;
  padding:var(--sp-5); background:rgba(8,11,18,.5);
  animation:fadein 140ms var(--ease-out) backwards;
}
.tableexpand__panel{
  display:flex; flex-direction:column; min-height:0;
  width:min(1180px, 96vw); max-height:88vh;
  background:var(--bg-card); border:1px solid var(--border-color);
  border-radius:var(--radius-lg); box-shadow:var(--shadow-pop);
  overflow:hidden;
}
.tableexpand__head{
  display:flex; align-items:center; gap:var(--sp-3); flex:none;
  padding:11px 14px; border-bottom:1px solid var(--border-color);
}
.tableexpand__title{
  flex:1 1 auto; min-width:0; font-size:var(--fs-lede); font-weight:650;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.tableexpand__body{
  flex:1 1 auto; min-height:0; padding:var(--sp-4); overflow:hidden;
  display:flex; flex-direction:column;
}
.tableexpand__body>.tablewrap{
  flex:1 1 auto; min-height:0; display:flex; flex-direction:column;
}
.tableexpand__body .tablescroll{flex:1 1 auto; min-height:0;}
@keyframes fadein{from{opacity:0;} to{opacity:1;}}

@media (prefers-reduced-motion:reduce){
  .tableexpand{animation:none;}
  .tablefilter{transition:none;}
}

/* ── callouts ───────────────────────────────────────────────────────────── */
.note{
  border:1px solid var(--border-color); border-left:3px solid var(--text-faint);
  background:var(--bg-surface-alt); border-radius:var(--radius-md);
  padding:11px 14px; font-size:13.2px; color:var(--text-secondary);
}
.note strong{color:var(--text-primary);}
.note--info{border-left-color:var(--color-info); background:var(--color-info-soft);}
.note--warn{border-left-color:var(--color-warning); background:var(--color-warning-soft);}
.note--danger{border-left-color:var(--color-danger); background:var(--color-danger-soft);}
.note--ok{border-left-color:var(--color-success); background:var(--color-success-soft);}

/* ── SQL / code ─────────────────────────────────────────────────────────── */
.sql{
  background:var(--sql-bg); border:1px solid var(--sql-border); border-radius:var(--radius-md);
  padding:11px 13px; font-family:var(--font-mono); font-size:12.4px; line-height:1.6;
  color:var(--text-primary); overflow-x:auto; white-space:pre; tab-size:2;
}
.disclosure{border:1px solid var(--border-color); border-radius:var(--radius-md); background:var(--bg-surface-alt);}
.disclosure>summary{
  cursor:pointer; padding:8px 12px; font-size:12.6px; font-weight:600; color:var(--text-secondary);
  list-style:none; display:flex; align-items:center; gap:7px;
}
.disclosure>summary::-webkit-details-marker{display:none;}
.disclosure>summary::before{content:"▸"; font-size:10px; transition:transform var(--transition-fast);}
.disclosure[open]>summary::before{transform:rotate(90deg);}
.disclosure__body{padding:0 12px 12px;}

/* ── chat ─────────────────────────────────────────────────────────────────
   What used to be here: a whole `.thread` / `.msg__*` / `.composer__*` chat
   component, left behind when the Ask page was rewritten onto chat.css. Every
   one of those sub-classes had zero consumers — but `.thread` was worse than
   dead, it was ACTIVELY WRONG. chat.css uses `.thread` for a *sidebar
   conversation row* (a flex ROW), while this file used it for the *message
   list* (a flex COLUMN). chat.css never set `flex-direction`, so this rule won
   that one property and every conversation row in the rail laid itself out as a
   centred column: title on one line, timestamp beneath it, and a long title
   clipped at BOTH ends because a too-wide centred child overflows symmetrically.

   Two lessons, both acted on: the dead rules are deleted rather than left to
   collide again, and the surviving component was renamed `.convo*` so the two
   are no longer one word apart. `.msg` stays — page-upload and page-analysis
   use it, each with its own stylesheet layered on top.                        */
/* The message row. Its only consumer is page-upload.js, which layers upload.css
   over it — so the ONE property that survives from here is `min-width:0`, and
   that is the one worth keeping (without it a long table inside a message pushes
   the whole column wide). An earlier note here claimed page-analysis.js used it
   too; it does not. */
.msg{display:flex; gap:12px; min-width:0;}
.prose{font-size:14.3px; line-height:1.62; color:var(--text-primary); min-width:0;}
.prose>*+*{margin-top:9px;}
.prose h1,.prose h2,.prose h3{margin-top:15px; font-size:15px;}
.prose ul,.prose ol{margin:0; padding-left:20px; display:flex; flex-direction:column; gap:3px;}
.prose code{background:var(--bg-surface-alt); padding:1px 5px; border-radius:5px; font-size:12.6px;}
.prose pre{background:var(--sql-bg); border:1px solid var(--sql-border); border-radius:var(--radius-md);
  padding:11px 13px; overflow-x:auto;}
.prose pre code{background:none; padding:0;}
.prose table{border-collapse:collapse; width:100%; font-size:13px; display:block; overflow-x:auto;}
.prose th,.prose td{border:1px solid var(--border-color); padding:6px 9px; text-align:left;}
.prose blockquote{border-left:3px solid var(--border-strong); padding-left:12px; color:var(--text-muted);}
.prose a{text-decoration:underline;}

/* `.composer` used to be defined here as a sticky, gradient-masked bar, on the
   stated grounds that "the Ask page and the Upload page both have one". They do
   not: the Ask page's is `.composerbar` (chat.css). The only consumer is
   upload.html, which has its own `.composer` rule — and that rule sets a
   background and padding but never resets `position`, so this base leaked
   `position:sticky; bottom:0` into a `flex:none` row inside an
   `overflow:hidden` column, making it a containing block for nothing.

   Deleted. A shared base with one consumer is not a base.

.chips{display:flex; flex-wrap:wrap; gap:7px;}
.chip{
  padding:6px 11px; border-radius:var(--radius-pill); border:1px solid var(--border-color);
  background:var(--bg-surface); font-size:12.6px; color:var(--text-secondary); cursor:pointer;
  font-family:inherit;
}
.chip:hover{border-color:var(--color-accent); color:var(--color-accent); background:var(--color-accent-soft);}

/* ── progress / skeleton / empty ────────────────────────────────────────── */
.dots{display:inline-flex; gap:4px; align-items:center;}
.dots i{width:5px; height:5px; border-radius:50%; background:var(--text-faint); animation:blink 1.3s infinite;}
.dots i:nth-child(2){animation-delay:.18s;} .dots i:nth-child(3){animation-delay:.36s;}
@keyframes blink{0%,80%,100%{opacity:.25;}40%{opacity:1;}}

.stage{display:flex; align-items:center; gap:9px; font-size:13px; color:var(--text-muted);}
.stage__tick{width:15px; height:15px; flex:0 0 auto;}
.stage--done{color:var(--color-success);}
.bar{height:3px; background:var(--bg-surface-alt); border-radius:99px; overflow:hidden;}
.bar__fill{height:100%; background:var(--color-accent); transition:width var(--transition-base);}
.bar--indeterminate .bar__fill{width:34%; animation:slide 1.5s ease-in-out infinite;}
@keyframes slide{0%{margin-left:-34%;}100%{margin-left:100%;}}

.skel{background:linear-gradient(90deg,var(--bg-surface-alt) 25%,var(--bg-hover) 37%,var(--bg-surface-alt) 63%);
  background-size:400% 100%; animation:shimmer 1.4s infinite; border-radius:6px;}
@keyframes shimmer{0%{background-position:100% 0;}100%{background-position:0 0;}}
.skel--line{height:11px; margin:6px 0;}
.skel--kpi{height:60px;}

.empty{text-align:center; padding:40px 20px; color:var(--text-muted);}
.empty__icon{width:34px; height:34px; margin:0 auto 10px; color:var(--text-faint);}
.empty__title{font-size:14.5px; font-weight:650; color:var(--text-primary); margin-bottom:4px;}

/* ── modal / toast ──────────────────────────────────────────────────────── */
.modal{
  position:fixed; inset:0; z-index:100; display:none; place-items:center;
  background:rgba(8,11,18,.55); padding:20px; backdrop-filter:blur(2px);
}
.modal.is-open{display:grid;}
.modal__panel{
  background:var(--bg-card); border:1px solid var(--border-color); border-radius:var(--radius-lg);
  box-shadow:var(--shadow-pop); width:min(920px,100%); max-height:88vh; display:flex; flex-direction:column;
}
.modal__head{display:flex; align-items:center; gap:10px; padding:14px 16px; border-bottom:1px solid var(--border-color);}
.modal__title{font-size:15px; font-weight:650; flex:1 1 auto;}
.modal__body{padding:16px; overflow:auto;}
.modal__foot{padding:12px 16px; border-top:1px solid var(--border-color); display:flex; gap:8px; justify-content:flex-end;}

.toasts{position:fixed; right:16px; bottom:16px; z-index:120; display:flex; flex-direction:column; gap:8px;
  max-width:min(380px,92vw);}
.toast{
  background:var(--bg-card); border:1px solid var(--border-color); border-left:3px solid var(--text-faint);
  border-radius:var(--radius-md); box-shadow:var(--shadow-lg); padding:11px 13px; font-size:13.2px;
  display:flex; gap:9px; align-items:flex-start;
}
.toast--ok{border-left-color:var(--color-success);}
.toast--warn{border-left-color:var(--color-warning);}
.toast--err{border-left-color:var(--color-danger);}
.toast__msg{flex:1 1 auto; min-width:0;}
.toast__x{background:none; border:0; color:var(--text-faint); cursor:pointer; padding:0; font-size:15px;}

/* ── charts ─────────────────────────────────────────────────────────────── */
/* A chart needs a positioned box of known height — Chart.js with
   `maintainAspectRatio:false` sizes to its parent, and against an auto-height
   parent that resolves to zero.

   The HEIGHT is a modifier, not part of the base. It used to be `height:260px`
   on `.chartbox` itself, which quietly beat every attempt to flex-size one:
   upload.css asks for `.panel .chartbox{flex:1; min-height:170px}` and got a
   rigid 260px box fighting its own flex-grow, because `height` wins over
   `min-height` and `flex-grow` cannot override an explicit height. A base class
   called "box" should not decide how tall the box is. */
.chartbox{position:relative; width:100%; min-height:0;}
.chartbox--fixed{height:260px;}
.chartbox--tall{height:330px;}
.chartbox--short{height:180px;}

/* ── utilities ──────────────────────────────────────────────────────────── */
.row{display:flex; align-items:center; gap:10px;}
.row--wrap{flex-wrap:wrap;}
.col{display:flex; flex-direction:column; gap:10px;}
.spacer{flex:1 1 auto;}
.muted{color:var(--text-muted);} .faint{color:var(--text-faint);}
.small{font-size:12.4px;} .xsmall{font-size:11.5px;}
/* For a single figure inside a sentence — "matched 412 of 500". The colour
   carries emphasis, never the meaning: the sentence already says which is which,
   so this stays legible when the colour does not land. */
.ok-text{color:var(--color-success);} .warn-text{color:var(--color-warning);}
.strong{font-weight:650;}
.nowrap{white-space:nowrap;}
.tnum{font-variant-numeric:tabular-nums;}
/* ── truncation, and how to see what was cut ─────────────────────────────────
   This product is full of long strings it did not choose: Oracle column names,
   supplier names, report names, questions a user typed. Sixteen rules across the
   stylesheets end in `text-overflow:ellipsis`, and until now the ONLY way to see
   what had been cut was the browser's native `title` tooltip — which appears
   after about a second, cannot be styled, is clipped by the window rather than
   repositioned, and on several platforms does not appear at all for a
   non-interactive element. Several truncating labels did not even carry one.

   So: three tools, and a rule about which to reach for.

     1. `.clamp-2` / `.clamp-3` — let it WRAP. Reach for this first. A report
        name in a card has room for two lines, and two full lines beat one
        ellipsised line every time. Truncation is for a row you cannot make
        taller, not a default.

     2. `[data-tip]` — an instant, styled tooltip for when it must be one line.
        Appears in 90ms, sits above the thing, and carries the full text.

     3. `.trunc` — the one-line ellipsis, which now REQUIRES a tooltip: use
        Airo.setLabel(), which sets the text, the title and data-tip together so
        the three cannot drift apart.                                          */

/* Wrap to N lines, then ellipsise. The `min-width:0` matters: a clamped element
   inside a flex row will not shrink below its content without it, and the clamp
   never engages. */
.clamp-2,.clamp-3{
  display:-webkit-box; -webkit-box-orient:vertical; overflow:hidden;
  min-width:0; overflow-wrap:anywhere;
}
.clamp-2{-webkit-line-clamp:2; line-clamp:2;}
.clamp-3{-webkit-line-clamp:3; line-clamp:3;}

/* One-line ellipsis. Paired with a tooltip by Airo.setLabel. */
.trunc{overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0;}

/* ── the tooltip ────────────────────────────────────────────────────────────
   CSS-only: no positioning JS, no library, nothing to keep in sync with a
   scroll. `position:absolute` against the host, which therefore needs a
   containing block — `[data-tip]` gets `position:relative` and that is the only
   thing it changes about the host.

   Deliberate choices:
     * 90ms delay, not 0. Instant tooltips fire while the pointer is merely
       crossing a list, so a row of eight flashes eight bubbles.
     * `white-space:pre-line` and a max-width, so a long value WRAPS in the
       bubble instead of becoming a 900px strip that leaves the window.
     * `pointer-events:none`, so the bubble can never sit between the pointer and
       the thing it describes and eat the click.
     * It survives `overflow:hidden` on an ancestor only if that ancestor is not
       a scroll container. Where it would be clipped — inside a tile body, a
       table cell — the tooltip flips BELOW via `[data-tip-below]`.            */
[data-tip]{position:relative;}
[data-tip]::after{
  content:attr(data-tip);
  position:absolute; left:50%; bottom:calc(100% + 7px);
  transform:translateX(-50%) translateY(3px);
  z-index:80; pointer-events:none;
  max-width:min(42ch, 78vw); width:max-content;
  padding:6px 9px; border-radius:var(--radius-sm);
  background:var(--text-primary); color:var(--bg-card);
  font-size:11.5px; font-weight:500; line-height:1.45; letter-spacing:0;
  text-transform:none; text-align:left; white-space:pre-line;
  overflow-wrap:anywhere;
  box-shadow:var(--shadow-lg);
  opacity:0; visibility:hidden;
  transition:opacity var(--transition-fast) 90ms,
             transform var(--transition-fast) 90ms,
             visibility 0s linear 250ms;
}
/* The pointer. Drawn as a rotated square rather than a border triangle so it
   picks up the same background token and cannot drift in colour. */
[data-tip]::before{
  content:''; position:absolute; left:50%; bottom:calc(100% + 3px);
  width:7px; height:7px; z-index:81; pointer-events:none;
  transform:translateX(-50%) translateY(3px) rotate(45deg);
  background:var(--text-primary);
  opacity:0; visibility:hidden;
  transition:opacity var(--transition-fast) 90ms,
             transform var(--transition-fast) 90ms,
             visibility 0s linear 250ms;
}
[data-tip]:hover::after,[data-tip]:focus-visible::after,
[data-tip]:hover::before,[data-tip]:focus-visible::before{
  opacity:1; visibility:visible;
  transition-delay:90ms, 90ms, 0s;
}
[data-tip]:hover::after,[data-tip]:focus-visible::after{
  transform:translateX(-50%) translateY(0);
}
[data-tip]:hover::before,[data-tip]:focus-visible::before{
  transform:translateX(-50%) translateY(0) rotate(45deg);
}
/* An empty tip is no tip. Guards against `data-tip=""` from a value that turned
   out to be blank, which would otherwise show an empty black rectangle. */
[data-tip=""]::after,[data-tip=""]::before{display:none;}

/* Flipped, for a host near the top of the viewport or inside a clipping box. */
[data-tip][data-tip-below]::after{
  bottom:auto; top:calc(100% + 7px);
  transform:translateX(-50%) translateY(-3px);
}
[data-tip][data-tip-below]::before{
  bottom:auto; top:calc(100% + 3px);
  transform:translateX(-50%) translateY(-3px) rotate(45deg);
}
[data-tip][data-tip-below]:hover::after,
[data-tip][data-tip-below]:focus-visible::after{
  transform:translateX(-50%) translateY(0);
}
[data-tip][data-tip-below]:hover::before,
[data-tip][data-tip-below]:focus-visible::before{
  transform:translateX(-50%) translateY(0) rotate(45deg);
}
/* Left-anchored, for something at the right edge where a centred bubble would
   run off screen. */
[data-tip][data-tip-end]::after{left:auto; right:0; transform:translateY(3px);}
[data-tip][data-tip-end]:hover::after,
[data-tip][data-tip-end]:focus-visible::after{transform:translateY(0);}
[data-tip][data-tip-end]::before{left:auto; right:10px;}

@media (prefers-reduced-motion:reduce){
  [data-tip]::after,[data-tip]::before{transition:none; transform:none;}
  [data-tip]:hover::after,[data-tip]:hover::before{transform:none;}
  [data-tip][data-tip-below]::after,[data-tip][data-tip-below]::before{
    transform:none;
  }
}
/* Touch has no hover, so a tooltip that only appears on hover is invisible
   there. The `title` Airo.setLabel also sets is the fallback, and the long-press
   behaviour is the platform's. Nothing here is the only route to information. */
@media (hover:none){
  [data-tip]::after,[data-tip]::before{display:none;}
}
.hidden{display:none !important;}
.mt0{margin-top:0;} .mt8{margin-top:8px;} .mt14{margin-top:14px;} .mt20{margin-top:20px;}
.scroll-x{overflow-x:auto;}

/* ═══════════════════════════════════════════════════════════════════════════
   PORTAL CHROME
   The pieces the portal shell needs and the original page-per-design did not:
   grouped nav, a collapsible rail, breadcrumbs, an identity menu, a command
   palette. All token-driven, so they theme with everything else.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── grouped nav + collapsible rail ─────────────────────────────────────── */
.nav__grouplabel{
  font-size:10.5px; font-weight:700; letter-spacing:.09em; text-transform:uppercase;
  color:var(--text-faint); padding:14px 10px 5px; user-select:none;
}
.nav__grouplabel:first-child{padding-top:4px;}
.nav--row .nav__grouplabel{display:none;}

/* The `.sidebar__collapse` button and the whole `.app--navcollapsed` block used
   to live here: a control that shrank the rail to a 64px icon column.

   Both are gone with the rail's promotion to a per-page list. The collapse block
   is worth a note because it was a live hazard, not just dead weight: it
   contained `.app--navcollapsed .nav__item span{position:absolute; width:1px;
   …}` — a DESCENDANT selector. Once the nav moved into the top strip, a
   `ebs_nav_collapsed` flag left in localStorage from an earlier visit would have
   sr-only'd the label inside every tab in the strip, with no control anywhere to
   turn it back on. portal.js's retireCollapse() deletes that key on every mount.

/* ── breadcrumbs ────────────────────────────────────────────────────────── */
.crumbs{display:flex; align-items:center; gap:6px; min-width:0; flex-wrap:wrap;}
.crumbs__item{font-size:12px; color:var(--text-muted); text-decoration:none; max-width:26ch;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
a.crumbs__item:hover{color:var(--text-primary); text-decoration:underline;}
.crumbs__item:last-child{color:var(--text-secondary); font-weight:600;}
.crumbs__sep{color:var(--text-faint); font-size:12px;}

/* ── topbar search facade ───────────────────────────────────────────────── */
/* flex:0 1 260px, not flex:1. In the nav row the spacer is the only element that
   should grow — a search box that also grows takes width the nav strip needs,
   so the strip started scrolling while the search sat at 280px. */
.topbar__search{
  display:flex; align-items:center; gap:7px; min-width:0; flex:0 1 260px;
  background:var(--bg-surface-alt); border:1px solid var(--border-color);
  border-radius:var(--radius-pill); padding:5px 11px; color:var(--text-muted);
  cursor:pointer; transition:border-color var(--transition-fast);
}
.topbar__search:hover{border-color:var(--border-strong);}
.topbar__search svg{width:14px; height:14px; flex:none;}
.topbar__search input{
  border:0; background:none; outline:none; font:inherit; font-size:12.5px;
  color:var(--text-secondary); min-width:0; width:100%; cursor:pointer;
}
@media (max-width:820px){.topbar__search{display:none;}}

/* ── menus ──────────────────────────────────────────────────────────────── */
/* ── the popover menu ────────────────────────────────────────────────────────
   Ours end to end, so it gets what a native list cannot have:

     * It ARRIVES. A menu that simply exists on the next frame reads as a
       repaint glitch; 120ms of scale-and-rise from its own top edge reads as
       opening. `transform-origin` is the corner it is anchored to, so it grows
       out of the button rather than out of thin air.
     * A visible KEYBOARD position (`:focus-visible`) distinct from the pointer
       hover, because arrow-keying through a menu where the highlight looks like
       a hover leaves you unsure which one the Enter will hit.
     * ROOM FOR A SHORTCUT on the right of each row, so Ctrl+J does not have to
       live in a tooltip.
     * The danger row tints its whole background on hover, not just its text:
       "Remove" that only turns red on the four characters is easy to click by
       accident. */
.menu{
  position:absolute; z-index:60; min-width:232px; max-width:min(340px, 92vw);
  padding:5px;
  background:var(--bg-card); border:1px solid var(--border-color);
  border-radius:var(--radius-md);
  box-shadow:var(--shadow-pop);
  /* A hairline of light along the top inside edge: it separates the menu from
     its own shadow, which is what stops a floating surface looking smudged. */
  background-image:linear-gradient(var(--bg-surface-alt), var(--bg-surface-alt));
  background-repeat:no-repeat; background-size:100% 1px; background-position:0 0;
  transform-origin:top var(--menu-origin-x, left);
  /* `backwards`: the fill is only needed before the animation starts. Filling
     forwards would pin an animation-origin transform on the menu for as long as
     it is open, which beats any declared transform in the cascade — the trap
     that made every card blink. See #main. */
  animation:menuin 130ms var(--ease-out) backwards;
}
@keyframes menuin{
  from{opacity:0; transform:translateY(-4px) scale(.985);}
  to  {opacity:1; transform:none;}
}
.menu--right{--menu-origin-x:right;}
.menu--up{transform-origin:bottom var(--menu-origin-x, left);}
.menu--up{animation-name:menuinup;}
@keyframes menuinup{
  from{opacity:0; transform:translateY(4px) scale(.985);}
  to  {opacity:1; transform:none;}
}

.menu__head{
  padding:8px 10px 9px; margin:-5px -5px 5px;
  border-bottom:1px solid var(--border-color);
  background:var(--bg-surface-alt);
  border-radius:var(--radius-md) var(--radius-md) 0 0;
}
.menu__label{
  padding:7px 10px 4px;
  font-size:var(--fs-micro); font-weight:700; letter-spacing:.08em;
  text-transform:uppercase; color:var(--text-faint);
}
.menu__row{
  display:flex; align-items:center; justify-content:space-between; gap:10px;
  padding:6px 10px;
}
.menu__item{
  position:relative;
  display:flex; align-items:center; gap:9px; width:100%; text-align:left;
  padding:0 10px; height:32px;
  border:0; background:none; border-radius:var(--radius-sm);
  font:inherit; font-size:var(--fs-body); font-weight:500; line-height:1;
  color:var(--text-primary); cursor:pointer;
  transition:background var(--transition-fast), color var(--transition-fast);
}
.menu__item svg{width:15px; height:15px; flex:none; color:var(--text-faint);
  transition:color var(--transition-fast);}
.menu__item:hover{background:var(--bg-hover);}
.menu__item:hover svg{color:var(--color-accent);}
/* The keyboard position, deliberately DIFFERENT from hover: a filled row plus
   an accent edge on the leading side, so arrow-keying is unambiguous even while
   the pointer happens to be resting somewhere else in the list. */
.menu__item:focus-visible{
  outline:none; background:var(--color-accent-soft); color:var(--text-primary);
}
.menu__item:focus-visible::before{
  content:''; position:absolute; left:0; top:5px; bottom:5px; width:2px;
  border-radius:0 2px 2px 0; background:var(--color-accent);
}
.menu__item[disabled],.menu__item[aria-disabled="true"]{
  color:var(--text-faint); cursor:not-allowed;
}
.menu__item[disabled]:hover{background:none;}
.menu__item[disabled] svg{color:var(--text-faint);}
.menu__text{flex:1 1 auto; min-width:0; overflow:hidden;
  text-overflow:ellipsis; white-space:nowrap;}
/* The shortcut, so it does not have to hide in a tooltip. Tabular so a column of
   them lines up. */
.menu__kbd{
  flex:none; font-family:var(--font-mono); font-size:10.5px;
  color:var(--text-faint); letter-spacing:.02em;
  padding:2px 5px; border-radius:3px; background:var(--bg-surface-alt);
  border:1px solid var(--border-color);
}
.menu__item:hover .menu__kbd{color:var(--text-muted);}
/* A checked row: the tick occupies the icon slot, so checked and unchecked rows
   have their labels on the same left edge. */
.menu__item[aria-checked="true"]{font-weight:650;}
.menu__item[aria-checked="true"] svg{color:var(--color-accent);}
/* The whole row tints, not just the four characters of the word. */
.menu__item--danger{color:var(--color-danger);}
.menu__item--danger svg{color:var(--color-danger);}
.menu__item--danger:hover{background:var(--color-danger-soft);}
.menu__item--danger:hover svg{color:var(--color-danger);}
.menu__item--danger:focus-visible{background:var(--color-danger-soft);}
.menu__item--danger:focus-visible::before{background:var(--color-danger);}
.menu__sep{height:1px; background:var(--border-color); margin:5px 8px;}

@media (prefers-reduced-motion:reduce){
  .menu{animation:none;}
  .select{transition:none;}
}

/* ── identity menu ──────────────────────────────────────────────────────── */
.idmenu{position:relative;}
.idmenu__btn{
  display:flex; align-items:center; gap:8px; max-width:190px;
  padding:4px 9px 4px 4px; border:1px solid transparent; border-radius:var(--radius-pill);
  background:none; font:inherit; cursor:pointer; color:var(--text-secondary);
}
.idmenu__btn:hover{background:var(--bg-hover); border-color:var(--border-color);}
.idmenu__avatar{
  width:26px; height:26px; border-radius:50%; flex:none; display:grid; place-items:center;
  background:var(--color-accent); color:var(--color-accent-on);
  font-size:12px; font-weight:700;
}
.idmenu__who{font-size:12.5px; min-width:0;}
.idmenu__pop{right:0; top:calc(100% + 7px);}
.seg--sm .seg__opt{padding:3px 9px; font-size:11.5px;}

/* ── command palette ────────────────────────────────────────────────────── */
.palette{position:fixed; inset:0; z-index:90; display:grid; place-items:start center;
  padding-top:11vh;}
.palette__scrim{position:absolute; inset:0; background:rgba(9,11,16,.42);
  backdrop-filter:blur(2px);}
.palette__panel{
  position:relative; width:min(620px,92vw); background:var(--bg-card);
  border:1px solid var(--border-color); border-radius:var(--radius-lg);
  box-shadow:var(--shadow-pop); overflow:hidden;
}
.palette__input{
  border:0; border-bottom:1px solid var(--border-color); border-radius:0;
  font-size:14.5px; padding:14px 16px; width:100%;
}
.palette__input:focus{box-shadow:none; border-color:var(--border-color);}
.palette__list{max-height:min(52vh,420px); overflow-y:auto; padding:6px;}
.palette__row{
  display:flex; align-items:center; gap:10px; width:100%; text-align:left;
  padding:8px 10px; border:0; background:none; border-radius:var(--radius-sm);
  font:inherit; font-size:13px; color:var(--text-primary); cursor:pointer;
}
.palette__row.is-active{background:var(--bg-active);}
.palette__kind{
  flex:none; font-size:10px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
  color:var(--text-faint); min-width:46px;
}
.palette__label{min-width:0; flex:1;}
.palette__sub{flex:none;}
.palette__empty{padding:18px 12px; text-align:center;}
.palette__foot{
  padding:8px 14px; border-top:1px solid var(--border-color);
  background:var(--bg-surface-alt); display:flex; gap:8px; flex-wrap:wrap;
}
.kbd{
  display:inline-block; padding:1px 5px; border:1px solid var(--border-color);
  border-bottom-width:2px; border-radius:4px; background:var(--bg-card);
  font-family:var(--font-mono); font-size:10.5px; color:var(--text-secondary);
}

/* ── misc ───────────────────────────────────────────────────────────────── */
.btn__icon{display:inline-grid; place-items:center;}
.btn__icon svg{width:15px; height:15px;}
.stack{display:flex; flex-direction:column; gap:6px;}
.stack--lg{gap:14px;}

/* ═══════════════════════════════════════════════════════════════════════════
   FINISH PASS
   The difference between "styled" and "finished" is almost entirely in the
   states: what a control does under the pointer, when pressed, and when
   focused. These rules add that consistently rather than per component.
   ═══════════════════════════════════════════════════════════════════════════ */

/* The button and input state rules that used to live here are GONE, and their
   absence is deliberate. They were written before the components had proper
   state sets, and because they sat 900 lines further down the file they silently
   overrode the real design: `.btn--primary{box-shadow:var(--shadow-sm)}` here
   beat the inset highlight and accent glow declared up in the button block, so
   the primary button lost the very thing that made it read as raised.

   A component's states belong WITH the component. A second block that patches
   them from a distance is indistinguishable from a bug the moment the component
   is redesigned.

/* The nav marks the current page with an accent rule, not just a fill: a tinted
   background alone is easy to lose against a dark sidebar. */
.nav__item{position:relative;}
.nav__item[aria-current="page"]::before{
  content:''; position:absolute; left:-10px; top:6px; bottom:6px; width:2px;
  border-radius:0 2px 2px 0; background:var(--color-accent);
}
/* Re-anchored for the strip. In the rail the edge is a 2px bar in the left
   gutter (`left:-10px` matches .sidebar__scroll's padding); in a row that same
   rule draws a vertical bar in the gap to the LEFT of the current item,
   overlapping its neighbour. Here it is the bottom edge, landing on the
   chrome's hairline — the same treatment the dashboard page tabs use.

   It must sit AFTER the rule above: `.nav--top .nav__item[aria-current]::before`
   and `.nav__item[aria-current]::before` are equal specificity, so whichever
   comes last wins. Placed earlier in the file (where the rest of the .nav--top
   block lives) it lost, and the strip kept the sidebar's left-gutter bar. */
.nav--top .nav__item[aria-current="page"]::before{
  left:0; right:0; top:auto; bottom:-1px;
  width:auto; height:2px; border-radius:2px 2px 0 0;
}
.nav__item svg{opacity:.72; transition:opacity var(--transition-fast);}
.nav__item:hover svg,.nav__item[aria-current="page"] svg{opacity:1;}

/* ── the report card ────────────────────────────────────────────────────────
   A catalogue of twenty identical grey cards is unscannable, so each card wears
   its Oracle module's hue (`--mod-hue`, from a fixed table in reports.html) as a
   top edge and a badge: "the green ones are GL" becomes a way to navigate, which
   is colour doing work rather than decorating.

   The layout is a COLUMN with the footer pinned, which is the fix for the thing
   that made a grid of these look broken. The cards were auto-height, so three in
   a row ended at three different heights and their Run buttons sat at three
   different y-positions — a row of buttons that does not line up, which is the
   most visible kind of misalignment there is. Now every card in a row is the
   same height (the grid stretches them) and the action row is pushed to the
   bottom of each, so the buttons form a line.

   And the title CLAMPS TO TWO LINES rather than ellipsising at one. An Oracle
   report name is routinely 40–60 characters; "CMRO Visit Instance - Tasks
   Warranty Contracts Report Data Definition" cut to "CMRO Visit Instanc…" tells
   the reader nothing. A card has room for two lines, so it gets two. */
.card--report{
  /* The module hue drives the SHARED card hover language rather than a
     bespoke copy of it: --card-accent is what .card--tap reads. */
  --card-accent:var(--mod-hue, var(--color-accent));
  position:relative; overflow:hidden;
  display:flex; flex-direction:column;      /* footer pins to the bottom */
  transition:border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.card--report::before{
  content:''; position:absolute; left:0; right:0; top:0; height:3px;
  background:var(--mod-hue, var(--color-accent));
}
.card--report:hover{
  border-color:color-mix(in srgb, var(--mod-hue, var(--color-accent)) 45%,
                         var(--border-color));
  box-shadow:var(--shadow-sm);
}
/* The body takes the slack, so the footer is at the bottom of every card in the
   row regardless of how long any one description is. */
.card--report .card__body{flex:1 1 auto; display:flex; flex-direction:column;
  gap:7px; padding-top:13px;}
/* The report name. Two lines, tight, and the ONE thing in the card that is
   full-weight — everything else recedes from it. */
.card__h{
  font-size:14px; font-weight:660; letter-spacing:-.012em; line-height:1.3;
  color:var(--text-primary); margin:0;
}
.card--report .card__foot{
  margin-top:auto; gap:var(--sp-2);
  /* The buttons keep their own size; the row does not stretch them. */
  justify-content:flex-start;
}
/* The badges row: provenance and module, on one line, never wrapping the card
   taller than its neighbours. */
.card--report .row{gap:5px; flex-wrap:wrap; min-width:0;}
/* The module badge wears its own colour; the "Curated" badge stays neutral, so
   provenance and subject never compete. */
.badge--module{
  color:var(--mod-hue, var(--text-muted));
  background:color-mix(in srgb, var(--mod-hue, var(--color-accent)) 12%, transparent);
  border-color:color-mix(in srgb, var(--mod-hue, var(--color-accent)) 30%, transparent);
  font-weight:700;
}
/* The facet list uses the same hue, so a filter and the cards it filters to are
   visibly one thing. */
.nav__item[style*="--mod-hue"]::after{
  content:''; width:6px; height:6px; border-radius:50%; flex:none;
  background:var(--mod-hue); margin-left:2px; order:-1;
}

/* Cards: a hairline plus a whisper of lift, and a hover only where the card is
   actually interactive (.card--tap). A hover effect on a static card invites a
   click that does nothing. */
/* ── the interactive card surface ────────────────────────────────────────────
   ONE hover language for every clickable card in the product.

   There were six card families and each had invented its own: `.card--tap` had
   five hover rules, `.stat` had three of its own with a private `--stat-accent`,
   `.card--report` had one with `--mod-hue`, `.tile` had two, and the upload,
   analysis and chat panels had none at all. So the accent-on-hover the Home
   tiles had was the only one in the product, and every other card was dead flat
   — which is exactly what it looked like.

   `.tapcard` is that language, declared once. A family keeps its own class for
   LAYOUT (`.stat` decides its padding, `.tile` its grid placement) and adds
   `.tapcard` for BEHAVIOUR. Nothing about the hue is per-family either: one
   variable, `--card-accent`, which anything can set — a module colour, a series
   slot, a status — and which falls back to the app accent so a caller that does
   not care never has to know it exists.

   The gesture is three things in the SAME colour, which is what makes it read as
   one movement rather than three effects:

     1. the border tints toward the accent
     2. a soft radial wash rises from the leading corner
     3. the leading edge grows from 0 to 3px

   And a fourth that is not colour: 2px of lift with a shadow, because a card
   that only changes colour on hover does not feel clickable — it feels
   highlighted.                                                              */
.tapcard,.card--tap{
  position:relative; cursor:pointer;
  /* A whisper of elevation at rest — --shadow-xs is a single 1px 4%-alpha line,
     not a drop shadow — so a card reads as a sheet lying on the canvas before
     you touch it. Hover deepens it rather than introducing it, which is what
     makes the lift legible without a colour change doing the work. */
  box-shadow:var(--shadow-xs);
  /* `isolation` gives the wash below a stacking context of its own, so a
     z-index:-1 layer cannot escape the card and tint what is behind it. */
  isolation:isolate;
  transition:border-color var(--transition-fast) var(--ease-out),
             transform var(--transition-fast) var(--ease-out),
             box-shadow var(--transition-fast) var(--ease-out);
}

/* The wash. `border-radius:inherit` matters: the card families disagree about
   their radius, and a square wash inside a rounded card shows its corners. */
.tapcard::after,.card--tap::after{
  content:''; position:absolute; inset:0; z-index:-1; opacity:0;
  border-radius:inherit; pointer-events:none;
  background:radial-gradient(125% 105% at 0% 0%,
    color-mix(in srgb, var(--card-accent, var(--color-accent)) 13%, transparent) 0%,
    transparent 62%);
  transition:opacity var(--transition-base) var(--ease-out);
}

/* THERE IS NO LEADING-EDGE BAR, deliberately.

   There was: a `::before` that grew from 0 to 3px on hover, focus and
   .is-current. Two problems, and the second is why it is gone rather than
   fixed. At rest it meant exactly ONE card in a grid wore a vertical bar (the
   one you last opened) and the others did not, which reads as a rendering fault
   rather than as a mark. And on the dashboard the left edge was already spoken
   for twice over — by .tile.is-selected's inset frame and by the status border.

   The hue already has a mark that every card carries in both places: the dot
   beside the title. One channel, one meaning, present on all of them. What
   hover adds instead is below — a ring on that dot. */

.tapcard:hover,.card--tap:hover{
  border-color:color-mix(in srgb, var(--card-accent, var(--color-accent)) 42%,
                         var(--border-color));
  transform:translateY(-2px);
  box-shadow:var(--shadow-md);
}
.tapcard:hover::after,.card--tap:hover::after{opacity:1;}
/* Pressed: back down to the surface, shadow tightened. The card follows the
   finger rather than staying lifted while being clicked. */
.tapcard:active,.card--tap:active{
  transform:translateY(0); box-shadow:var(--shadow-sm);
}
.tapcard:focus-visible,.card--tap:focus-visible{
  outline:2px solid var(--color-accent); outline-offset:2px;
}
/* A keyboard user gets the same accent as the pointer, so tabbing through a grid
   of cards reads identically to moving a mouse over it. */
.tapcard:focus-visible::after,.card--tap:focus-visible::after{opacity:1;}

/* A card that is CURRENTLY the thing being viewed — the tile you came from, the
   selected visual — keeps the mark permanently rather than borrowing the hover
   state. Without this a card had no way to say "this one" except by looking
   hovered, which is why the Home tile you had just clicked looked stuck. */
.tapcard.is-current,.card--tap.is-current{
  border-color:color-mix(in srgb, var(--card-accent, var(--color-accent)) 55%,
                         var(--border-color));
}
.tapcard.is-current::after,.card--tap.is-current::after{opacity:.55;}

/* THE HUE DOT is the one at-rest colour mark on a dashboard visual. A ring
   rather than a size change: growing the dot would shift the title beside it. */
.tapcard:hover .tile__hue,.card--tap:hover .tile__hue,
.tapcard:focus-visible .tile__hue,
.tapcard.is-current .tile__hue{
  box-shadow:0 0 0 3px color-mix(in srgb,
    var(--card-accent, var(--color-accent)) 24%, transparent);
}
.tile__hue{
  transition:box-shadow var(--transition-base) var(--ease-out);
}

/* A STATIC card gets none of this. An effect on a card that cannot be clicked
   invites a click that does nothing — so this is opt-in, and `.card` alone stays
   flat on purpose. */

@media (prefers-reduced-motion:reduce){
  .tapcard,.card--tap{transition:border-color var(--transition-fast);}
  .tapcard:hover,.card--tap:hover{transform:none;}
  .tapcard::after,.card--tap::after{transition:none;}
}


/* Tables: right-align and tabular-align every numeric column so digits line up
   vertically, which is the whole point of a figure in a column. */
table.data td.num{font-variant-numeric:tabular-nums;}
table.data tbody tr{transition:background var(--transition-fast);}

/* Headings: tighter tracking as size grows, which is what stops a large heading
   looking loose. */
h1{letter-spacing:-.025em;} h2{letter-spacing:-.015em;} h3{letter-spacing:-.01em;}

/* Scrollbars, so an inner scroll does not draw a heavy native bar across a
   panel. */
*{scrollbar-width:thin; scrollbar-color:var(--scrollbar-thumb) transparent;}
::-webkit-scrollbar{width:10px; height:10px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb); border-radius:6px;
  border:3px solid transparent; background-clip:content-box;}
::-webkit-scrollbar-thumb:hover{background:var(--border-strong);
  background-clip:content-box;}

/* Selection picks up the accent instead of the browser default blue. */
::selection{background:var(--color-accent-soft-strong); color:var(--text-primary);}

/* ── motion ─────────────────────────────────────────────────────────────────
   Navigating between the seven pages is a full document load — there is no
   router and no build step — which normally means a white flash and a jump to
   the top. Cross-document view transitions turn it into one animated frame.

   Three things make the difference between "a transition exists" and it
   actually feeling smooth, and only the first is obvious:

     1. THE CURVES. A symmetric ease looks mechanical at this size. The outgoing
        page leaves on a curve that starts fast (it is already gone from the
        user's attention), the incoming one arrives on an expo-out that spends
        most of its time nearly settled. They OVERLAP: the new page starts
        arriving before the old has finished leaving, which is what removes the
        visible seam.

     2. THE BLEND MODE. By default the old and new snapshots sit in an isolated
        group blended with `plus-lighter`, so a cross-fade briefly ADDS the two
        images — mid-transition everything looks washed out and grey, which reads
        as a flicker no matter how good the curves are. `isolation:auto` plus
        normal blending gives a clean dissolve. This is the single biggest win
        and it is invisible in the spec.

     3. NOT MOVING WHAT DID NOT CHANGE. `.chrome` carries a
        `view-transition-name`, so the nav strip is treated as the same object on
        both sides and is not animated at all. Animating chrome that is
        pixel-identical is pure noise.

   Browsers without support ignore the at-rule and navigate as before — no
   polyfill, which matters on a VPN-only deployment with no CDN.             */
@view-transition{navigation:auto;}

/* Take the pair out of the isolated plus-lighter group. Without this the
   dissolve washes through a bright grey midpoint. */
::view-transition-image-pair(root){isolation:auto;}
::view-transition-old(root),
::view-transition-new(root){mix-blend-mode:normal;}

/* The group itself must not be clipped or the incoming page's slide is cut
   off at the old page's height. */
::view-transition-group(root){animation-duration:420ms;}

::view-transition-old(root){
  animation:vt-out 260ms cubic-bezier(.55,0,.85,.35) both;
}
::view-transition-new(root){
  /* Starts at 60ms — while the old page is still fading — so the two overlap
     instead of handing over at a visible seam. Expo-out: 80% of the distance is
     covered in the first third, and the rest is a settle. */
  animation:vt-in 420ms cubic-bezier(.16,1,.3,1) 60ms both;
}
@keyframes vt-out{
  from{opacity:1; transform:translate3d(0,0,0) scale(1);}
  to  {opacity:0; transform:translate3d(0,-6px,0) scale(.995);}
}
@keyframes vt-in{
  from{opacity:0; transform:translate3d(0,12px,0) scale(.994);}
  to  {opacity:1; transform:translate3d(0,0,0) scale(1);}
}

/* The entrance the content plays on its own — for browsers without view
   transitions, and for the first load of a session, so arriving at a page never
   looks like a static paste.

   `backwards`, NOT `both`, and no `will-change` — this line is why hovering one
   card made every card on the page blink:

     * `will-change:opacity, transform` promotes the entire content column to
       its own compositor layer and makes it a stacking context. It was meant to
       last only for the animation, dropped by an `.is-settled` class — but only
       one page ever added that class, and only inside the `.then` of four
       network reads. Everywhere else the hint stayed on for the life of the
       page, so every paint inside #main re-rasterised one enormous layer.

     * `both` = fill-mode forwards as well as backwards, which keeps the final
       keyframe applied FOREVER after the animation ends. #main therefore held
       an animation-origin `transform` permanently and stayed composited even
       after `will-change` came off.

   `backwards` alone does the one thing that is actually needed: hold the FIRST
   keyframe during the delay, so nothing flashes into view before its turn. Once
   the animation finishes it releases the property completely. */
#main{animation:page-in 460ms var(--ease-out) backwards;}
@keyframes page-in{
  from{opacity:0; transform:translate3d(0,10px,0);}
  to  {opacity:1; transform:none;}
}

/* Staggered arrival for a row of cards. Six tiles appearing together read as a
   paste; ~55ms apart reads as a page assembling. Driven by `--i`, set once by
   the page that builds them, so there is no nth-child ladder to keep in step
   with how many cards there happen to be. */
.stagger{
  /* `backwards`, not `both`. `both` also fills FORWARDS, which keeps the final
     keyframe applied for the life of the page — and because CSS animations beat
     normal declarations in the cascade, that filled `transform:none` silently
     defeated .tapcard:hover{transform:translateY(-2px)}. The lift was in the
     stylesheet, asserted by a gate, and never once happened on a staggered
     card. It also kept every card on its own compositor layer forever, which is
     half of why hovering one made all of them blink.

     `backwards` fills only during the delay — which is the whole point of a
     stagger — and releases the moment the animation ends. */
  animation:card-in 520ms var(--ease-out) backwards;
  animation-delay:calc(60ms + var(--i, 0) * 55ms);
}
@keyframes card-in{
  from{opacity:0; transform:translate3d(0,14px,0) scale(.988);}
  to  {opacity:1; transform:none;}
}

/* Anything that grows or lifts on hover uses one curve, so the whole interface
   responds the same way to the pointer. */
.card,.stat,.rowitem,.suggestion,.chip,.card--report{
  transition-timing-function:var(--ease-out);
}

/* All of the above is motion for its own sake, so all of it goes when the user
   has asked for less. The transitions are DISABLED, not shortened: a 1ms
   animation still triggers a repaint and reads as a flash. */
@media (prefers-reduced-motion:reduce){
  @view-transition{navigation:none;}
  ::view-transition-group(root),
  ::view-transition-old(root),
  ::view-transition-new(root){animation:none;}
  #main{animation:none;}
  .stagger{animation:none;}
}

/* ── print ──────────────────────────────────────────────────────────────── */
@media print{
  .chrome,.topnav,.topbar,.sidebar,.composer,.toasts,.btn,.burger,
  .palette,.menu{display:none !important;}
  .app{display:block;}
  .appbody{display:block;}
  .page{padding:0; max-width:none;}
  .card{break-inside:avoid; box-shadow:none;}
  body{background:#fff;}
}

/* ── the chart hover readout ──────────────────────────────────────────────
   Chart.js draws its tooltip onto the CANVAS, which is why the old one looked
   unfinished however it was configured: a canvas box takes no shadow, no real
   radius, no design token and not the product's font. It could only invert
   its colours, so on the dark dashboard it rendered as a hard white slab.

   This is the HTML replacement (charts.js `externalTip`). It is a genuinely
   LIFTED surface — the one case DESIGN.md's "hairlines, not shadows" rule
   makes an exception for — so it takes --shadow-pop, sits on --bg-card, and
   carries a --border-strong hairline that survives when card and tooltip are
   the same colour in dark mode.

   position:fixed and parented to <body>: a dashboard tile is overflow:hidden,
   so a tooltip inside one would be cropped by the very card it describes. */
.charttip{
  position:fixed; z-index:80; top:0; left:0;
  min-width:96px; max-width:min(280px, 60vw);
  padding:9px 11px;
  background:var(--bg-card);
  border:1px solid var(--border-strong);
  border-radius:var(--radius-md);
  box-shadow:var(--shadow-pop);
  pointer-events:none;               /* it must never eat the hover it reports */
  opacity:0; transform:translateY(2px);
  transition:opacity var(--transition-fast) var(--ease-out),
             transform var(--transition-fast) var(--ease-out);
}
.charttip.is-on{opacity:1; transform:none;}

/* The category. Secondary, because the reader is POINTING at it — they have
   the label already and came for the number. */
.charttip__cat{
  font-size:var(--fs-micro); font-weight:650; letter-spacing:.05em;
  text-transform:uppercase; color:var(--text-faint);
  margin-bottom:5px; overflow-wrap:anywhere;
}
.charttip__row{display:flex; align-items:baseline; gap:7px;}
.charttip__row + .charttip__row{margin-top:3px;}

/* A LINE KEY, not a box. At tooltip density a filled swatch is data-weight ink
   doing a label's job — a 10x2px stroke says "this series" and then gets out
   of the way. It is also the only element here allowed to wear a series
   colour: text never does. */
.charttip__key{
  flex:none; width:10px; height:2px; border-radius:1px;
  align-self:center;
}
/* VALUES LEAD. The number is the strong, high-contrast element and the series
   name trails it in muted ink — the legend's hierarchy, deliberately
   inverted. */
.charttip__val{
  font-size:var(--fs-body); font-weight:680; color:var(--text-primary);
  font-variant-numeric:tabular-nums; letter-spacing:-.01em;
}
.charttip__lbl{
  font-size:var(--fs-micro); color:var(--text-muted); overflow-wrap:anywhere;
}
/* The share of the whole, on a part-to-whole chart — the one number a donut
   exists to answer and the old tooltip never gave. */
.charttip__note{
  margin-top:6px; padding-top:6px;
  border-top:1px solid var(--border-color);
  font-size:var(--fs-micro); color:var(--text-muted);
  font-variant-numeric:tabular-nums;
}

@media (prefers-reduced-motion:reduce){
  .charttip{transition:none;}
}

/* ══ Sign-in page ═════════════════════════════════════════════════════════
   The one page with no portal chrome (see the comment in login.html), so it
   is also the one page that has to centre itself in the viewport rather than
   sit in the shell's content column.

   It declares no colours, sizes or radii of its own — every value below is a
   token defined at the top of this file, which is what keeps a chrome-less
   page from becoming a differently-themed page. Both themes and the toggle
   therefore work here for free.                                             */
.loginpage{
  min-height:100vh;
  /* svh where supported: on mobile Safari 100vh is the tallest the viewport
     ever gets, so the card sits behind the URL bar until you scroll. */
  min-height:100svh;
  display:flex; align-items:center; justify-content:center;
  padding:var(--sp-5);
  background:var(--bg-page);
}
.loginpage__card{
  width:100%; max-width:380px;
  padding:var(--sp-6) var(--sp-5) var(--sp-5);
  box-shadow:var(--shadow-md);
  border-radius:var(--radius-lg);
}
/* Reuses .brand__mark / .brand__name from the shell so the wordmark here is
   the same object it is in the nav strip, not a lookalike. */
.loginpage__brand{
  display:flex; align-items:center; gap:var(--sp-2);
  margin-bottom:var(--sp-5);
}
.loginpage__title{
  font-size:var(--fs-title); font-weight:660; letter-spacing:-.02em;
  margin:0 0 var(--sp-2);
}
.loginpage__lede{margin:0 0 var(--sp-5); line-height:1.55;}
.loginpage__form{display:flex; flex-direction:column; gap:var(--sp-4);}
/* The reveal button sits INSIDE the field row rather than floating over the
   input: overlaying it would cover the tail of a long password and collide
   with the browser's own password-manager icon, which occupies that corner. */
.loginpage__pw{display:flex; align-items:center; gap:var(--sp-2);}
.loginpage__pw .input{flex:1 1 auto; min-width:0;}
.loginpage__pwtoggle{flex:none;}
.loginpage__foot{
  margin:var(--sp-5) 0 0; line-height:1.5;
  padding-top:var(--sp-4); border-top:1px solid var(--border-color);
}
