/* WS Payment Central — admin console stylesheet.
   ============================================================================================================

   ONE FILE, AND EVERY COLOUR IN IT COMES FROM A TOKEN. Nothing here is a literal outside the two :root
   blocks, because tools/check-admin-ui-contrast.mjs reads the PALETTE out of this file and scores 74 pairs
   across both themes. A hardcoded hex in a rule is invisible to that gate, so it is the one thing that can
   drop a pair under threshold with nothing to catch it.

   THEME HAS FIVE STATES NOW, AND THE ATTRIBUTE IS ALWAYS PRESENT. This paragraph used to say there were
   three -- light, dark and an OS default that emitted no attribute at all -- and that model could not carry
   the two themes an operating system knows nothing about. High contrast and brand-coloured are CHOICES, not
   preferences the browser can report, so prefers-color-scheme cannot be the signal for them. theme-boot.js
   resolves "system" against that query and stamps data-theme, data-density and data-reduced-motion on <html>
   before first paint; every scope below is an attribute selector and there is no media query for theme left
   in this file. Light is still the bare :root, unchanged, so it is what a missing attribute would fall back
   to and what the console looks like with nothing stored.

   EVERY TOKEN IS STILL DEFINED ON BARE :root FIRST and only REDEFINED in a theme scope, which is what
   AdminUiSourceTests.The_dark_theme_redefines_only_tokens_the_light_root_already_defines checks -- widened to
   every scope, because a token whose only definition lives in one theme is undefined in the other four.

   ------------------------------------------------------------------------------------------------------------
   VISUAL DIRECTION, REVISED 2026-08-23 FROM A SUPPLIED MOCKUP — AND IT REVERSES TWO DECISIONS THIS FILE
   USED TO ARGUE FOR AT LENGTH. Both reversals are recorded here rather than quietly applied, because the
   arguments against them were reasoned and a future reader will otherwise re-derive them and revert:

     1. A SECTION IS A CARD NOW. This file previously held that "a section is a heading, a rule and its
        content -- not a box on a tray", on the grounds that boxing everything spends elevation on
        containers rather than on content. The mockup boxes them, and it is right for THIS console for a
        reason the old argument missed: almost every screen here is several INDEPENDENT reads stacked
        vertically (accounts, then routing, then history), and a hairline rule does not say where one ends
        and the next begins once a table sits between them. The distinction that mattered -- .section.write
        being visibly a different KIND of thing -- is preserved and strengthened, not lost.

     2. THE RAIL IS A SURFACE NOW. This file previously gave the sidebar no fill beyond a half-step of
        ground, arguing a tinted rail "spends the loudest signal available on navigation, which is what the
        operator looks at least". Still true of a SATURATED rail; the mockup uses a plain white surface
        against a tinted page, which spends no colour at all and reads as chrome by separation rather than
        by hue. The original concern is answered rather than overruled.

   What the mockup could NOT contribute, and why, so nobody re-attempts it:
     - ITS WEBFONTS. font-src is 'self' with no exemption, so Inter and JetBrains Mono would silently not
       load, and The_stylesheet_pulls_in_no_webfont_and_nothing_from_another_origin fails the build. Google
       Fonts additionally needs style-src to admit fonts.googleapis.com -- a third-party origin able to
       inject CSS into a payments admin console, which is a larger concession than the glyphs are worth.
       Self-hosting woff2 would need no CSP change and is the route to take if the exact face is ever wanted.
     - ITS INLINE style="" ATTRIBUTES, everywhere. style-src 'self' governs the attribute form exactly as it
       governs a <style> element, so they are dropped in silence. See index.html rule 4.
     - ITS TRANSLUCENT TOPBAR. tests/AdminUi.e2e computes the focus-ring ratio against the RESOLVED
       background of .topbar, and its parseRgb discards alpha rather than compositing it -- so a
       semi-transparent bar would have the gate scoring a colour that is not what the operator sees. Opaque
       here is a measurement property, not a taste.
     - ITS CARD-TABLE AT NARROW WIDTHS. That transform reads a data-l attribute off every cell, which this
       console's tables do not carry, and it would defeat the tested promise that the TABLE scrolls sideways
       while the PAGE does not.
     - ITS SAVE BAR. There is no draft model here: every write is a form whose submit button is disabled and
       released in a finally, which is separately enforced. A sticky "unsaved changes" bar would be a weight
       nothing applies, and this file's own rule is that such a thing is a rule in a stylesheet rather than
       a distinction on a screen.
   ============================================================================================================ */

/* ---- THE DISPLAY FACE, SELF-HOSTED ------------------------------------------------------------------
   Playfair Display is what the redesign sets every page and card title in, and until now it silently
   rendered as Georgia: `font-src` is 'self' with no exemption, so a face from fonts.gstatic.com is
   refused by the Content-Security-Policy and the fallback takes over with nothing on screen to say so.
   The title is the most visible element the redesign has, so the fallback was the most visible thing
   about it that was wrong.

   SELF-HOSTED RATHER THAN EXEMPTED, which is the choice this file's header already argues for: admitting
   fonts.googleapis.com to `style-src` would let a third party inject CSS into a payments admin console,
   and that is a larger concession than any glyph is worth. Two files under fonts/, served same-origin,
   need no CSP change at all.

   IT IS A VARIABLE FONT, so one file covers the whole 300-900 weight axis and the 300 the redesign asks
   for costs nothing extra. Two unicode subsets are kept -- latin and latin-ext, 34 KB together. Cyrillic
   and Vietnamese are not: this console's text is English and an operator-supplied brand id, and a subset
   nothing renders is bytes on every page load.

   `font-display: swap` DELIBERATELY. The alternative is a title that is invisible until the font lands;
   a moment of Georgia is the same thing this console showed permanently until now.

   The files are pinned by name AND sha256 in AdminUiSourceTests, the same arrangement the vendored
   swagger-ui and msal bundles have, and fonts/OFL.txt is the licence they are redistributed under. */
@font-face {
  font-family: "Playfair Display";
  font-style: normal;
  font-weight: 300 900;
  font-display: swap;
  src: url("fonts/playfair-display-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308,
    U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Playfair Display";
  font-style: normal;
  font-weight: 300 900;
  font-display: swap;
  src: url("fonts/playfair-display-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
    U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
    U+2C60-2C7F, U+A720-A7FF;
}

:root {
  color-scheme: light dark;

  /* ---- neutrals. Every surface and border in the console comes from here and nothing else does.
     Named by ROLE rather than by number, so the dark block can invert the ramp without every rule that
     spends it reading backwards.

     The ramp took the mockup's COOLER hue (a blue-grey rather than a warm grey) but NOT its lightness at
     the muted end: the mockup's tertiary ink is #8b95a3, which is about 3.0:1 on white and fails 1.4.3
     outright. Hue is free; lightness is scored. */
  --bg: #fafafa;
  --bg-subtle: #f2f4f7;
  --surface: #ffffff;
  --surface-hover: #f7f8fa;
  --surface-active: #eceff4;
  --surface-sunken: #fafafa;

  --border: #e3e3e3;
  --border-strong: #c9c9c9;
  /* The boundary of a CONTROL, which WCAG 1.4.11 scores at 3:1. A decorative rule between two rows is
     exempt from that; the edge telling an operator where a field is is not. Its own token, so meeting
     the threshold does not turn every hairline in the console into a wireframe -- an earlier version of
     this console drew inputs with the divider colour and sat at 1.61:1. */
  --border-field: #868b96;

  --text: #000000;
  --text-secondary: #5b5b5b;
  /* The design system separates INK (headings, #000) from BODY copy (#2b2b2b); the console had one
     token for both, so every paragraph rendered at heading weight of colour. */
  --text-body: #2b2b2b;
  /* DISABLED text only, and deliberately NOT --text-muted: muted copy is real content and is scored
     at 4.5:1, while disabled text is exempt. Sharing one token forced one of the two to be wrong. */
  --text-disabled: #9a9a9a;
  --text-muted: #646c78;
  --text-inverse: #ffffff;

  /* ---- accent. ONE hue, deepened to the mockup's indigo. */
  --accent: #ff701f;
  --accent-hover: #f2620f;
  --accent-subtle: #fff1e8;
  --accent-text: #a83c00;
  --accent-ink: #000000;
  --focus: #e05a0c;

  /* ---- verdicts. Two tokens each: a SOLID that carries white text, and an INK for the tonal
     treatment (coloured text on a wash). Every ink is chosen for 4.5:1 against its own wash in both
     themes -- a badge is 12px/500 and is not "large text" under WCAG 1.4.3, so the threshold is the
     full one. Measured by tools/check-admin-ui-contrast.mjs rather than asserted in this comment. */
  --ok: #15803d;
  --ok-wash: #e8f5ee;
  --ok-ink: #1c5c3a;
  --warn: #a35a08;
  --warn-wash: #fdf3e5;
  --warn-ink: #6f3d05;
  --danger: #b8422e;
  --danger-hover: #a03626;
  --danger-wash: #fdf3f1;
  --danger-ink: #b8422e;
  --neutral: #5c636e;
  --neutral-wash: #eceff4;
  --neutral-ink: #414852;

  /* ---- code */
  --code-bg: #f1f3f6;
  --code-ink: #1f2329;
  --pre-bg: #12161c;
  --pre-ink: #e7e9ed;

  /* ---- depth. Softer and more of it than before: with sections boxed, the shadow is what separates a
     card from the page rather than an effect on top of one. Two layers -- a tight contact shadow and a
     wide ambient one -- because a single large blur reads as a glow. */
  --shadow-card: 0 1px 2px rgba(15, 20, 25, .05);
  --shadow-pop: 0 4px 14px rgba(15, 20, 25, .07), 0 1px 2px rgba(15, 20, 25, .05);
  --shadow-overlay: 0 16px 44px rgba(15, 20, 25, .16), 0 2px 8px rgba(15, 20, 25, .09);
  /* Falls LEFT, over the content a pinned column slides across. Short and shallow: it separates two
     surfaces a few pixels apart, not a card from a page. */
  --shadow-pinned: -6px 0 6px -6px rgba(15, 20, 25, .18);
  --scrim: rgba(15, 20, 25, .40);

  /* ---- geometry. Deliberately NOT redefined in the dark block: measurements, not colours.
     Radii went up a step across the board (the mockup's 10px card, 8px control) -- the one place a
     restyle is felt before any colour is. */
  --r-xs: 5px;
  --r-sm: 8px;
  --r-md: 10px;
  --r-lg: 14px;
  --r-pill: 999px;

  --s1: 4px;
  --s2: 8px;
  --s3: 12px;
  --s4: 16px;
  --s5: 24px;
  --s6: 32px;
  --s7: 48px;

  --t-xs: 11px;
  --t-sm: 12px;
  --t-ui: 13px;
  --t-body: 14px;
  --t-lg: 16px;
  --t-title: 23px;
  --t-display: 26px;

  /* Controls went from 30px to 36px, taking row height with them. This is an operator tool used all day
     with a mouse; 30px was tight for a click target and made every toolbar read as a dense strip. */
  --ctl-h: 36px;
  --ctl-h-sm: 30px;
  --row-h: 40px;
  --sidebar-w: 230px;
  --topbar-h: 53px;

  /* ---- density and motion. BOTH ARE TOKENS RATHER THAN RULES, for the same reason every colour here
     is: a screen is dense, or still, by the act of using the token. A screen added next quarter needs
     no density work and cannot opt out of it. --pad-cell is spent by table.grid body cells and nothing
     else -- thead keeps its own tighter padding, because a header row that moved with the body would
     make the two densities differ by more than the thing the operator asked to change. --row-h moves
     with it or compact buys nothing: a 40px floor under 8px of padding is still a 40px row.

     THERE IS NO --motion TOKEN, and the redesign handoff expects one. That sheet has a single 240ms
     screen fade to switch off, so a duration token is the whole mechanism there. This console has no
     screen fade at all and four SHORT animations instead -- the skeleton shimmer, the toast, the drawer
     and the modal -- so the setting is expressed where it already was, as a rule at the foot of this
     file that switches all four off. A duration token would have to be threaded through four keyframe
     users to say the same thing, and the shimmer is a CONTINUOUS animation that wants removing rather
     than shortening. */
  --pad-cell: var(--s3) var(--s4);

  /* ---- the RAIL, WHICH THEMES WITH EVERYTHING ELSE SINCE 2026-09-16.
     This block asserted the opposite -- that the redesign "holds it fixed while everything else themes"
     and that a rail changing colour with the content "would be the loudest thing on the page doing the
     least work". The platform owner asked for it to follow the selection, so that claim is retired: the
     handoff still draws it black in every theme, and this console deliberately does not.

     EACH THEME DECLARES THE WHOLE SET rather than a few text colours over an inherited ground. The old
     shape had dark and contrast re-picking --rail-rest and --rail-label for a black ground they merely
     assumed, which would have been silently wrong the moment the ground moved.

     THEY ARE TOKENS RATHER THAN LITERALS so tools/check-admin-ui-contrast.mjs can score the text on them.
     Fixed across themes is not the same as unscored: white on black is fine, and the MUTED state is the
     one that moves, because 5.3:1 passes the other four themes and fails high contrast. */
  /* --measure (1240px) IS GONE, not merely unused: it capped .page, and a token still sitting here is
     an invitation to put the cap back on the next screen that has one long line. Prose is bounded by
     --prose at the element that carries it; nothing bounds the page. */
  /* --prose IS RETIRED, and is left named here rather than silently deleted because three separate
     rules used to read it. Every run of prose in this console now spans the card or the page it sits
     in; nothing measures a line any more. Re-adding it to fix a long line is the wrong move twice over
     - shorten the sentence instead. */
  /* Verdict pill hairlines. NOT --ok/--warn/--neutral: those are the saturated mark. */
  --ok-border: #bfe0cd;
  --warn-border: #e8d9ae;
  --neutral-border: #d4d4d4;

  /* ---- the rail. IT FOLLOWS THE THEME (2026-09-16), where the handoff holds it black in every
     one and calls it chrome. The platform owner asked for the opposite; the deviation is here
     rather than left to be rediscovered. EACH THEME DECLARES THE WHOLE SET: a partial override
     is what the old shape was, and dark and contrast were re-picking text colours for a black
     ground they merely assumed. --rail-accent is the accent AS PAINTED HERE -- #ff701f is
     2.65:1 on a light rail -- and is what the chip, the selected bar and its icon spend. */
  --rail-bg: #f2f2f2;
  --rail-ink: #000000;
  --rail-rest: #2b2b2b;
  --rail-hover: #e6e6e6;
  --rail-active: #ffffff;
  --rail-label: #5b5b5b;
  --rail-line: #767676;
  --rail-muted: #5b5b5b;
  --rail-accent: #a83c00;
}

/* ============================================================================================================
   THEMES. FIVE OF THEM, AND EVERY ONE IS A TOKEN SWAP -- there is not a single per-screen override in this
   file and there must never be one. A screen is themed by the act of spending the tokens above, which is what
   makes "will this apply to a screen we add later" answerable with yes rather than with a checklist.

   THE ATTRIBUTE IS ALWAYS PRESENT NOW, AND THAT IS THE CHANGE FROM WHAT THIS FILE USED TO SAY. Dark lived in
   a prefers-color-scheme block, so theme had three states and the browser default emitted no attribute at
   all. It has five states now and none of them is expressible as a media query -- contrast and brand are
   CHOICES the operating system knows nothing about -- so theme-boot.js resolves "system" against
   prefers-color-scheme and stamps data-theme on <html> before first paint. The media query is GONE rather
   than kept beside these scopes: two sources for one ramp is two places to edit and one to forget.

   WHAT DID NOT CHANGE: light is still the bare :root above, byte for byte, so the default console is exactly
   what it was. Dark carries the same values the media block carried, re-derived for its ground rather than
   inverted, and is still scored by tools/check-admin-ui-contrast.mjs -- which reads these scopes by name now
   instead of reading a media query.

   THREE INVARIANTS HOLD IN ALL FIVE, and each is a thing a palette edit can quietly break:
     1. --accent is ACTION and --danger is CONSEQUENCE. They are never interchangeable and must stay clearly
        distinguishable from each other -- an operator who cannot tell the primary control from the
        destructive one is one click away from a write they did not mean.
     2. A destructive control is OUTLINED, never filled. That is a rule in the .btn.danger block below and no
        theme may fill it in.
     3. Verdict colours stay mutually distinguishable and never carry meaning by hue alone. The WORD in the
        badge is what is read; the colour agrees with it and never replaces it.
   ============================================================================================================ */

/* ---- dark. Content ground is lifted off true black so the rail and the page stay separable, and the ink and
   border ramps are RE-DERIVED for this ground rather than inverted. The tables in this console are built out
   of hairlines, and an inverted hairline turns to mud -- #e4e7ec flipped is a line nobody can follow across
   twelve columns.

   THE GROUND IS THE REDESIGN HANDOFF'S (2026-09-15): page #141414, surface #1c1c1c, sunken #222, hairline
   #2e2e2e, taken from docs/design/payment-central.css. It replaced a darker blue-grey ramp, and the swap
   was made because it MEASURED clean rather than because the handoff asked for it -- 148 pairs, 0 below
   threshold, and the elevation model unchanged: page darker than surface, exactly as before.

   WHAT DELIBERATELY DID NOT COME WITH IT is the handoff's accent and its verdict colours. Those carry the
   redesign's IDENTITY rather than its legibility, and adopting them is the redesign itself -- thirteen
   screens of markup against a different class system, not a token edit. So this scope is the handoff's
   ground under this console's own accent, on purpose. See docs/open-items.md. */
[data-theme="dark"] {
  /* The rail is black in THIS theme rather than in all of them (see :root), and it sits BELOW the
     page ground rather than above it -- which is what keeps it reading as chrome now that it is no
     longer the only dark surface on the screen. */
  --bg: #141414;
  --bg-subtle: #1a1a1a;
  --surface: #1c1c1c;
  --surface-hover: #222222;
  --surface-active: #2a2a2a;
  --surface-sunken: #222222;

  --border: #2e2e2e;
  --border-strong: #343943;
  --border-field: #737985;

  --text: #f5f7f9;
  --text-secondary: #b4bac3;
  --text-muted: #8b929c;
  --text-inverse: #0a0b0d;

  --accent: #ff8a45;
  --accent-hover: #ff701f;
  --accent-subtle: #1b1932;
  --accent-text: #aca6f8;
  --accent-ink: #0a0b0d;
  --focus: #ff8a45;

  --ok: #3aa76d;
  --ok-wash: #0d2318;
  --ok-ink: #5fd497;
  --warn: #c98a2b;
  --warn-wash: #271c0c;
  --warn-ink: #e8b563;
  --danger: #e05b4f;
  --danger-hover: #ef7a6f;
  --danger-wash: #2a1312;
  --danger-ink: #f79289;
  --neutral: #7b818b;
  --neutral-wash: #1c1f24;
  --neutral-ink: #aab0b8;

  --code-bg: #1a1d22;
  --code-ink: #d6d9de;
  --pre-bg: #0d0f12;
  --pre-ink: #e7e9ed;

  --shadow-card: 0 1px 2px rgba(0, 0, 0, .45);
  --shadow-pop: 0 4px 14px rgba(0, 0, 0, .5), 0 1px 2px rgba(0, 0, 0, .4);
  --shadow-overlay: 0 16px 44px rgba(0, 0, 0, .62), 0 2px 8px rgba(0, 0, 0, .5);
  --shadow-pinned: -6px 0 6px -6px rgba(0, 0, 0, .55);
  --scrim: rgba(0, 0, 0, .62);
  /* Verdict pill hairlines. NOT --ok/--warn/--neutral: those are the saturated mark. */
  --ok-border: #0b3d22;
  --warn-border: #5c4a1f;
  --neutral-border: #4a4a4a;

  /* ---- the rail. IT FOLLOWS THE THEME (2026-09-16), where the handoff holds it black in every
     one and calls it chrome. The platform owner asked for the opposite; the deviation is here
     rather than left to be rediscovered. EACH THEME DECLARES THE WHOLE SET: a partial override
     is what the old shape was, and dark and contrast were re-picking text colours for a black
     ground they merely assumed. --rail-accent is the accent AS PAINTED HERE -- #ff701f is
     2.65:1 on a light rail -- and is what the chip, the selected bar and its icon spend. */
  --rail-bg: #000000;
  --rail-ink: #f2f2f2;
  --rail-rest: #b4b4b4;
  --rail-hover: #1a1a1a;
  --rail-active: #262626;
  --rail-label: #7a7a7a;
  --rail-line: #5c5c5c;
  --rail-muted: #8a8a8a;
  --rail-accent: #ff8a45;
}

/* ---- high contrast. Pure black on white, every hairline at full strength, and every verdict re-picked to
   clear 7:1 against its own ground rather than the 4.5:1 the other themes are scored at.

   THE WASHES GO WHITE AND THE BORDERS CARRY THE STATE. A tonal badge is the ordinary treatment in this
   console -- ok is a green ground, danger a pink one -- and at 7:1 those grounds have nowhere left to go
   without taking the text with them. So a badge here is a white box with a full-strength border and
   full-strength ink, which is the one arrangement where the hue is decoration and the word does all the
   work. That is invariant 3 taken to its end rather than an exception to it.

   THE ACCENT DARKENS AND DOES NOT BRIGHTEN. --accent is spent as a FILL behind --accent-ink, so it is scored
   as a GROUND; a brighter indigo would fail the white sitting on it. */
[data-theme="contrast"] {
  /* Every rail value goes to full strength. --rail-muted is the one that HAD to move: #8a8a8a on
     black is 5.3:1, which passes the other four themes and fails this one. */
  --bg: #ffffff;
  --bg-subtle: #ffffff;
  --surface: #ffffff;
  --surface-hover: #f2f2f2;
  --surface-active: #e6e6e6;
  --surface-sunken: #f2f2f2;

  --border: #000000;
  --border-strong: #000000;
  --border-field: #000000;

  --text: #000000;
  --text-secondary: #000000;
  --text-muted: #2b2b2b;
  --text-inverse: #ffffff;

  --accent: #8a3200;
  --accent-hover: #1d1666;
  --accent-subtle: #ffffff;
  --accent-text: #2c2191;
  --accent-ink: #ffffff;
  --focus: #8a3200;

  --ok: #0b3d22;
  --ok-wash: #ffffff;
  --ok-ink: #0b3d22;
  --warn: #4a3400;
  --warn-wash: #ffffff;
  --warn-ink: #4a3400;
  --danger: #8f1d0c;
  --danger-hover: #6d1609;
  --danger-wash: #ffffff;
  --danger-ink: #8f1d0c;
  --neutral: #000000;
  --neutral-wash: #ffffff;
  --neutral-ink: #000000;

  --code-bg: #f2f2f2;
  --code-ink: #000000;
  --pre-bg: #000000;
  --pre-ink: #ffffff;

  /* Elevation is a border in this theme. A shadow at 7:1 is either invisible or a second black edge
     competing with the real one, and every card here is already fully bounded. */
  --shadow-card: none;
  --shadow-pop: none;
  --shadow-overlay: none;
  --shadow-pinned: none;
  --scrim: rgba(0, 0, 0, .70);
  /* Verdict pill hairlines. NOT --ok/--warn/--neutral: those are the saturated mark. */
  --ok-border: #0b3d22;
  --warn-border: #4a3400;
  --neutral-border: #000000;

  /* ---- the rail. IT FOLLOWS THE THEME (2026-09-16), where the handoff holds it black in every
     one and calls it chrome. The platform owner asked for the opposite; the deviation is here
     rather than left to be rediscovered. EACH THEME DECLARES THE WHOLE SET: a partial override
     is what the old shape was, and dark and contrast were re-picking text colours for a black
     ground they merely assumed. --rail-accent is the accent AS PAINTED HERE -- #ff701f is
     2.65:1 on a light rail -- and is what the chip, the selected bar and its icon spend. */
  --rail-bg: #ffffff;
  --rail-ink: #000000;
  --rail-rest: #000000;
  --rail-hover: #f2f2f2;
  --rail-active: #e6e6e6;
  --rail-label: #2b2b2b;
  --rail-line: #000000;
  --rail-muted: #2b2b2b;
  --rail-accent: #8a3200;
}

/* ---- brand-coloured. INHERITS LIGHT AND RE-POINTS THE ACCENT, NOTHING ELSE -- it declares no ground, no ink
   and no danger, so those fall through to :root above by cascade rather than by being restated.

   THAT RESTRAINT IS THE WHOLE SAFETY PROPERTY. If a brand could set the alarm colour, a brand whose palette
   is red would make every refusal in this console read as ordinary chrome, and a brand whose palette is green
   would make a destructive control read as a success. So the three fixed things -- ink, ground, alarm -- are
   fixed in the strong sense: there is no custom property for a brand to override them with.

   --brand-accent is set by the app from the operating brand and falls back to this console own indigo, so an
   unconfigured brand is the standard accent rather than a blank. --brand-accent-ink is what sits ON the fill
   and is the one a brand gets wrong: a mid-tone brand colour needs black text, not white. */
[data-theme="brand"] {
  --accent: var(--brand-accent, #ff701f);
  --accent-hover: var(--brand-accent-press, #f2620f);
  --accent-text: var(--brand-accent-text, #a83c00);
  --accent-ink: var(--brand-accent-ink, #000000);
  /* A RING OWES 3:1 and the handoff's own orange is 2.65:1 on this ground, so the fallback is the
     darkened step -- the same hue, and the same value the light theme's ring takes. */
  --focus: var(--brand-accent, #e05a0c);
}

/* ---- system. There is deliberately NO prefers-color-scheme rule here. theme-boot.js reads the query and
   stamps data-theme="light" or "dark", so the RESOLVED value is readable from JS -- which is what lets the
   Preferences screen say which one is actually in effect instead of printing "System" and leaving the
   operator to guess. A media block here would make the sheet and the app disagree the moment a preference
   was applied. */

/* ---- density. Comfortable is the default and is what the tables were measured at; compact takes the body
   cell to 8px and the row floor down with it, which is about four more rows on a screen. Nothing is hidden
   and nothing is truncated -- it is padding, not a different table. */
[data-density="comfortable"] { --pad-cell: var(--s3) var(--s4); --row-h: 40px; }
[data-density="compact"]     { --pad-cell: var(--s2) var(--s4); --row-h: 32px; }

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: var(--t-body) / 1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  text-size-adjust: 100%;
}

code, pre, .mono {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono", monospace;
  font-size: .92em;
}
code {
  background: var(--code-bg); color: var(--code-ink);
  padding: .1em .35em; border-radius: var(--r-xs);
}

/* A HEADING'S <code> IS THE PAGE TITLE, SO IT TAKES THE HEADING'S SIZE. Measured in Chromium before
   this rule existed: `h1 > code` rendered SMALLER than body text, because the font-size on `code` wins
   inside the heading -- and almost every title in this console IS an identifier. The monospace face is
   right for an id; only the size was wrong, so the chip fill and padding go with it. tests/AdminUi.e2e
   asserts the RELATIONSHIP (code equals h1, and h1 exceeds body) rather than a pixel value, so moving
   the type scale does not fail it. */
h1 code, h2 code, h3 code {
  font-size: inherit; background: none; padding: 0; border-radius: 0; letter-spacing: -.01em;
}

a { color: var(--accent-text); text-decoration: none; text-underline-offset: 2px; }
a:hover { text-decoration: underline; }

h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
h1 { font-size: var(--t-title); line-height: 1.24; letter-spacing: -.025em; font-weight: 700; }
h2 { font-size: var(--t-body); line-height: 1.35; }
h3 { font-size: var(--t-ui); line-height: 1.4; }
p { margin: 0; }

/* ---- focus ------------------------------------------------------------------------------------------
   ONE RING, DECLARED ONCE, FOR EVERYTHING. :focus-visible rather than :focus is what keeps a mouse
   click on a button from leaving a ring behind; the heuristic is already in the selector.

   NOTHING HERE SUPPRESSES IT. An `outline: none` anywhere in this file fails AdminUiSourceTests, and
   that guard exists because a previous version carried `h1:focus { outline: none }` on the theory that
   focus moved by the application should not draw a ring -- which is the one case that DOES need it,
   since the router moves focus to the view heading after every navigation and a keyboard operator
   would otherwise be relocated with nothing to explain why. */
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: var(--r-xs);
}
/* The topbar sits on the page ground rather than on a dark chrome, so the ring needs no recolour to
   clear WCAG 1.4.11 -- but the rule stays, and stays measured by tests/AdminUi.e2e in both themes,
   because the ratio is a property of two tokens and either can move. */
.topbar :focus-visible { outline-color: var(--focus); }

/* THE VIEW HEADING IS `fit-content`. The router focuses it after every render so a keyboard operator
   lands in the content rather than at the top of the document -- but an <h1> is a block, so the ring
   was previously painted as a full-column rounded rectangle around one word, which reads as an empty
   text input on the first thing the eye meets. Shrinking the box to its text keeps the ring exactly as
   visible to the person it is for, and changes no focus behaviour at all. */
.view-title { width: fit-content; max-width: 100%; }

/* ---- app shell --------------------------------------------------------------------------------------- */

.app {
  display: grid;
  grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
  min-height: 100vh;
}

/* THE RAIL IS A PLAIN SURFACE AGAINST A TINTED PAGE -- see reversal 2 in the file header. It spends no
   colour on navigation and still reads as chrome, because the separation is a surface change and a
   hairline rather than a hue. */
.sidebar {
  position: sticky; top: 0; align-self: start; height: 100vh;
  display: flex; flex-direction: column;
  border-right: 1px solid var(--border);
  background: var(--surface);
  padding: var(--s3) var(--s2);
  overflow-y: auto;
}

.brandmark {
  display: flex; align-items: center; gap: var(--s2);
  padding: var(--s1) var(--s2) var(--s4);
  font-size: var(--t-body); font-weight: 600; letter-spacing: -.01em; color: var(--text);
}
.brandmark .dot {
  width: 27px; height: 27px; flex: none; border-radius: var(--r-sm);
  background: var(--accent); color: var(--accent-ink);
  display: grid; place-items: center; font-size: var(--t-xs); font-weight: 700;
}
/* THE ENVIRONMENT IS A BADGE, NOT A WHISPER. It was muted grey, which is what everything else
   incidental in the rail is -- and the one thing an operator must never misread is which environment
   they are about to change a merchant account in. Warn tonal, so it is legible without being an alarm. */
.brandmark .env {
  margin-left: auto;
  padding: 1px var(--s2); border-radius: var(--r-xs);
  background: var(--warn-wash); color: var(--warn-ink);
  border: 1px solid var(--warn-wash);
  font-size: var(--t-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
}

/* THE COMMAND TRIGGER IS A REAL <button>, not a text input that opens something else. Nothing is typed
   here, and an input that does not accept typing is a lie to anyone reaching it with a screen reader. */
.cmd-trigger {
  display: flex; align-items: center; gap: var(--s2); width: 100%;
  height: var(--ctl-h); padding: 0 var(--s2) 0 var(--s3); margin-bottom: var(--s3);
  border: 1px solid var(--border); border-radius: var(--r-sm);
  background: var(--bg-subtle); color: var(--text-muted);
  font: inherit; font-size: var(--t-ui); text-align: left; cursor: pointer;
  transition: border-color .12s ease, color .12s ease, background-color .12s ease;
}
.cmd-trigger:hover { background: var(--surface-active); border-color: var(--border-strong); color: var(--text-secondary); }
/* ---- THE BASE ICON RULE, AND WHY IT HAS TO EXIST -------------------------------------------------
   Every .ico size in this file used to be scoped to a CONTAINER -- `.cmd-trigger .ico`, `.nav .ico`,
   `.btn .ico`. That is fine until a container is renamed, and then the icon inside it matches nothing:
   an inline <svg> with no width or height falls back to the replaced-element default of 300x150 and
   renders as a large empty box. That is exactly what the rail's search trigger became when the shell
   moved to .pc-* classes, and it is the loudest possible failure for the quietest possible cause.

   So the SIZE is unconditional and the containers only ADJUST it. ui/icons.js already says presentation
   belongs here rather than on the element; this is the half of that promise that was missing. */
.ico {
  width: 16px; height: 16px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
}

.cmd-trigger .ico {
  width: 14px; height: 14px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
}
.cmd-trigger .kbd { margin-left: auto; }

.kbd {
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0 var(--s1); min-width: 18px; height: 18px;
  border: 1px solid var(--border); border-radius: var(--r-xs);
  background: var(--surface); color: var(--text-muted);
  font-family: inherit; font-size: var(--t-xs); font-weight: 500;
}

.nav { display: flex; flex-direction: column; gap: 1px; }
.nav-label {
  padding: var(--s3) var(--s2) var(--s1);
  font-size: var(--t-xs); font-weight: 700; letter-spacing: .11em; text-transform: uppercase;
  color: var(--text-muted);
}
/* The current entry is ACCENT TONAL rather than a grey fill. With a white rail a grey fill is nearly
   invisible, and this is the one piece of state in the chrome that has to be readable at a glance. */
.nav a {
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s2) var(--s2); min-height: var(--ctl-h); border-radius: var(--r-sm);
  color: var(--text-secondary); font-size: var(--t-ui); font-weight: 500;
  transition: background-color .12s ease, color .12s ease;
}
.nav a:hover { background: var(--surface-hover); color: var(--text); text-decoration: none; }
.nav a[aria-current="page"] { background: var(--accent-subtle); color: var(--accent-text); font-weight: 600; }
.nav .ico {
  width: 16px; height: 16px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
  opacity: .75;
}
.nav a[aria-current="page"] .ico { opacity: 1; }

.sidebar-foot {
  margin-top: auto; padding: var(--s3) var(--s2) 0;
  border-top: 1px solid var(--border);
  font-size: var(--t-xs); line-height: 1.5; color: var(--text-muted);
}

/* ---- topbar ------------------------------------------------------------------------------------------ */

.main { min-width: 0; display: flex; flex-direction: column; }

/* OPAQUE, DELIBERATELY. The mockup blurs a translucent bar; tests/AdminUi.e2e scores the focus ring
   against this element's resolved backgroundColor and its parser drops alpha rather than compositing it,
   so a translucent bar would have the gate measuring a colour nobody sees. See the file header. */
.topbar {
  position: sticky; top: 0; z-index: 20;
  display: flex; align-items: center; gap: var(--s3);
  height: var(--topbar-h); padding: 0 var(--s5);
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}
.crumbs { display: flex; align-items: center; gap: var(--s2); min-width: 0; font-size: var(--t-ui); }
.crumbs a { color: var(--text-muted); }
.crumbs a:hover { color: var(--text); }
.crumbs .sep { color: var(--border-strong); }
.crumbs [aria-current="page"] {
  color: var(--text); font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.topbar-end { margin-left: auto; display: flex; align-items: center; gap: var(--s2); min-width: 0; }
/* *** SCOPED TO THE TOPBAR, AND IT WAS NOT. *** This is the signed-in identity, which is one line beside
   a fixed-width chrome and must never push the sign-out control off the end - so it ellipsises, which is
   right for an email address in a corner.

   Unscoped, it also matched `ol.runs .who` on the retokenization screen, which uses the same class name
   for something entirely different: the block holding a run's REFERENCE. So every run name inherited
   `white-space: nowrap` and `text-overflow: ellipsis` from a rule written about the topbar, and two runs
   whose names differ only in their last characters both rendered as
   `nexio-nmi-retokenization-import-test-08-25-202…`. The truncation was never declared anywhere near the
   thing being truncated, which is why it reads as unexplained.

   A generic class name in a global stylesheet is the whole mechanism, and the fix is the scope rather
   than an override on the other end - an `overflow-wrap` on the child would have fought this rule
   instead of removing it, and the next screen to call something `.who` would inherit it again. */
.topbar-end .who {
  font-size: var(--t-sm); color: var(--text-muted); min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The token carries no Config.ReadWrite role, so every admin call this operator makes comes back 403.
   Louder than muted, and the words beside it say so -- colour alone is never the message. */
.topbar-end .who.no-role { color: var(--danger-ink); font-weight: 600; }

/* THE SIGNED-IN CIRCLE. Decoration beside the address rather than instead of it -- shell.js marks it
   aria-hidden and derives the letters there, so nothing here is the accessible name of anything. Fixed
   square with a 50% radius rather than a padded inline box, because two letters and three letters must
   not produce two different sizes. */
.avatar {
  flex: none; width: 28px; height: 28px; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--accent-subtle); color: var(--accent-text);
  font-size: var(--t-xs); font-weight: 700; letter-spacing: .02em;
}

/* ---- two classes the markup used and this stylesheet did not -------------------------------------------
   BOTH WERE ALREADY UNSTYLED BEFORE THE 2026-08-23 RESTYLE -- checked against the previous revision
   rather than assumed, because a restyle is exactly when a dropped rule would be blamed on the restyle.
   They are fixed here because the gap is real either way, and because one of them is a WARNING that was
   rendering in body text with no warning about it.

   .warn-text is a run of inline text inside a cell or a sentence, which is why it is not .note (a block
   with a measure) and not .badge (a pill). tests/AdminUi.e2e asserts on it in three places, so it was
   present in the DOM and merely invisible as a signal -- the failure mode where a test passes on a thing
   the operator cannot see. */
.warn-text { color: var(--warn-ink); font-weight: 600; }

/* The build line under the app: which API, which audience, which vendored bundle versions. It sits
   OUTSIDE #root so it survives the app failing to start, which is exactly when it is needed -- so it is
   quiet enough to ignore and never so quiet it cannot be read. */
.build-note {
  margin: 0; padding: var(--s3) var(--s5) var(--s4);
  font-size: var(--t-xs); line-height: 1.5; color: var(--text-muted);
  overflow-wrap: anywhere;
}

/* ---- the sign-in page -------------------------------------------------------------------------------- */

/* THE WHOLE VIEWPORT WHEN THERE IS NO ACCOUNT, and it deliberately shares nothing with .app above.
   .app is a two-column grid whose first track is var(--sidebar-w); reusing it here would reserve the
   rail's column on a page that has no rail, leaving the card pushed off-centre by exactly the width of
   the nav this page exists NOT to show. A separate root is one rule and it cannot drift into that.

   MIN-HEIGHT RATHER THAN HEIGHT: at a short viewport - a laptop with the console's own devtools open -
   height:100vh would clip the card with nothing to scroll to. */
.signin {
  min-height: 100vh;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: var(--s4);
  padding: var(--s6) var(--s4);
}

.signin-card {
  width: 100%; max-width: 420px;
  padding: var(--s6);
  border: 1px solid var(--border); border-radius: var(--r-lg);
  background: var(--surface); box-shadow: var(--shadow-pop);
}

/* The rail's .brandmark carries the padding that seats it above the command trigger. Here it is the
   first thing in a card that has its own padding, so only the space UNDER it is wanted. */
.signin-mark { padding: 0 0 var(--s5); }

.signin .view-title { margin: 0 0 var(--s3); }
.signin .problem { margin: 0 0 var(--s4); }
.signin .note { margin: 0 0 var(--s5); }

/* FULL WIDTH, because it is the only control on the page. A button sized to its text sits in a corner
   of the card and reads as one option among several - and there are no several. */
.signin .btn { width: 100%; }

/* OUTSIDE THE CARD, and muted: it is the same sentence the rail carries under the nav, and it belongs
   to the console rather than to the act of signing in. Inside the card it competed with the button. */
.signin-foot {
  width: 100%; max-width: 420px; margin: 0;
  font-size: var(--t-xs); line-height: 1.5; color: var(--text-muted); text-align: center;
}

/* ---- page -------------------------------------------------------------------------------------------- */

/* NO MAX-WIDTH ON THE PAGE, AND THE PROSE CAP IS WHAT REPLACED IT.
   This used to be `max-width: var(--measure)` (1240px), which is the right measure for a document and
   the wrong one for this console: the widest thing on most screens is a table of 8 to 12 columns, and
   capping the PAGE cramped it into 1240px while an operator's window sat at 1900 and change -- so the
   table scrolled sideways inside .table-scroll with several hundred pixels of empty page beside it.
   Removing the cap widens exactly the things that wanted the room: tables, stat grids, field grids.
   Do NOT reintroduce a page-level cap to fix a long line -- bound the element that is long.

   WHAT IS BOUNDED CHANGED ON 2026-08-25, and this paragraph used to name the wrong set. It said .lede,
   .note, details.note-disclosure and table captions each carried max-width: var(--prose). Only .lede
   does now. The others sit INSIDE a card -- which is itself a bounded, padded column -- and two of them
   (.note.warn, .note.danger) had become callouts with a ground and a rule, where a max-width no longer
   bounds a LINE but draws a BOX that stops two thirds of the way across its container. That reads as a
   rendering fault, and was reported as one. The distinction to keep: bound a line to aid reading, never
   a box, and check which of the two a rule is actually doing once the element gains a background. */
.page { padding: var(--s5) var(--s5) var(--s7); width: 100%; }
.page-head { margin-bottom: var(--s5); }
.page-head .title-row { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; }
.page-head .actions { margin-left: auto; display: flex; gap: var(--s2); flex-wrap: wrap; }
/* THE LEDE SPANS TOO, and --prose is gone with it. It kept a 92ch measure for one round on the argument
   that it sits on the open page rather than in a card and is read as a paragraph - and it still read as
   a narrow column against a window twice that wide, beside boxes that now fill. Reported twice.

   WHAT MAKES THAT SAFE IS KEEPING LEDES SHORT, which is a writing rule rather than a CSS one: one or
   two sentences saying what the screen is for. A lede that grows to a paragraph will set as one very
   long line, and the fix then is to cut it, not to put a max-width back. */
.page-head .lede {
  margin-top: var(--s1);
  font-size: var(--t-ui); line-height: 1.55; color: var(--text-secondary);
}

/* ---- sections: cards now ------------------------------------------------------------------------------
   SEE REVERSAL 1 IN THE FILE HEADER. A section is a card: surface, hairline, radius, contact shadow. The
   reason is that a screen here is several INDEPENDENT reads stacked vertically, and a rule alone does not
   say where one ends once a table sits between them.

   .section.write IS STILL THE ONE THAT CHANGES WHERE A BRAND'S MONEY SETTLES: a routing preference, a
   merchant account, a credential name, a surcharge rate. Now that everything is boxed it can no longer be
   distinguished BY being boxed, so it takes the accent edge, a heavier shadow and a tinted head -- three
   differences rather than one. AdminUiSourceTests asserts the two are actually drawn differently AND that
   more than one module applies the class; a weight nothing uses is a rule in a stylesheet, not a
   distinction on a screen. */
.section {
  margin-bottom: var(--s5);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-card);
}
.section-head {
  display: flex; align-items: baseline; gap: var(--s3); flex-wrap: wrap;
  padding: var(--s3) var(--s4);
  border-bottom: 1px solid var(--border);
}
.section-head .section-actions { margin-left: auto; display: flex; gap: var(--s2); align-items: center; }
.section-head .count { font-size: var(--t-sm); font-variant-numeric: tabular-nums; color: var(--text-muted); }

/* The row count, when the cap was hit. Louder than the plain count -- it is the difference between "these
   are the failures" and "these are the first hundred failures", and a reader who takes the first reading
   draws a conclusion the screen never supported. */
.section-head .count .capped { color: var(--text); }

/* A button that reads as a link because it acts on THIS page. Not an <a>: it opens a panel and moves
   focus, and an href of `#l-limit` would be a fragment, which in a hash-routed console is a navigation --
   the skip link in app/shell.js paid for that lesson once already. */
.linkish {
  background: none; border: 0; padding: 0; margin-left: var(--s1);
  font: inherit; color: var(--accent-text); text-decoration: underline; cursor: pointer;
}
.linkish:hover { text-decoration: none; }

/* The row the command palette pointed at. A WASH RATHER THAN A BORDER, because a border changes the row's
   height and shifts every row below it -- on a table somebody has just been sent to, the thing that moves
   is the thing they were looking for. The ok wash is already scored for text contrast by
   tools/check-admin-ui-contrast.mjs, so this adds no unscored colour to a config screen. */
tr.row-hit > * { background: var(--ok-wash); }

/* The palette's empty state, which suggests rather than dead-ends. */
.palette-empty .did-you-mean { margin: var(--s3) 0 var(--s1); color: var(--text-secondary); }
.palette-suggestions { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--s3); }
.palette-suggestions .group { color: var(--text-muted); font-size: var(--t-sm); }

/* A section's own content is padded; a table is not, because .table-scroll draws to the card's edge and
   its own header row supplies the inset.

   AND A DIALOG IS NOT CONTENT, WHICH IS WHY ALL THREE OF THESE EXCLUDE ONE.

   A <dialog> rendered inside a .section is a direct child of it, so these selectors match it -- and they
   win. The dialog declares its own `margin: auto` to centre itself (below, with `dialog.modal`), which is
   specificity (0,1,1); each rule here is (0,3,0). The utility replaced `auto` with a literal 16px and the
   Set-rate dialog rendered 235px LEFT of centre, computing to `margin: 310.8px 16px`. Forcing
   margin-left/right back to auto on the live element moved it from 16-736 to 244-964.

   NOT FIXED BY ADDING `margin: auto` TO THE DIALOG. It is already there and already being overridden;
   that edit would do nothing, which is the trap this collision sets for whoever reads only the dialog's
   own rules.

   ALL THREE, NOT JUST THE FIRST. The first two govern the horizontal margins and the bottom; the THIRD
   governs the top margin of whatever follows a .section-head, and a dialog declared after one is hit by
   it too. Fixing the first two alone leaves a dialog correctly centred and vertically displaced -- which
   is the harder version of the same bug to notice, because a centred dialog reads as fixed.

   The narrow `:not(dialog)` rather than moving the gutter onto `.section` as padding: that is the better
   long-term shape and it changes the box of every current .section child, so it is its own change with
   its own blast radius. This one is the smallest edit that removes the collision. */
.section > :not(.section-head):not(.table-scroll):not(dialog) { margin-left: var(--s4); margin-right: var(--s4); }
.section > :not(.section-head):not(.table-scroll):not(dialog):last-child { margin-bottom: var(--s4); }
.section > .section-head + :not(.table-scroll):not(dialog) { margin-top: var(--s3); }

.section.write {
  border-color: var(--border-strong);
  border-left: 3px solid var(--accent);
  box-shadow: var(--shadow-pop);
}
.section.write > .section-head { background: var(--accent-subtle); border-bottom-color: var(--border); }

/* ---- notes: the explanatory prose ----------------------------------------------------------------------
   THIS CONSOLE'S PROSE IS LOAD-BEARING, AND SOME OF IT IS PINNED CHARACTER-FOR-CHARACTER by
   AdminUiSourceTests: the surcharge caveat and its propagation window, the sentence saying a
   deactivated brand still transacts, the two credential remedies. All of it stays on the screen it
   belongs to. What it must not do is compete with the data -- at body size directly under the number it
   annotates, the footnote and the finding read as the same thing.

   So a note is smaller, quieter and bounded to a measure, and a RUN of them is one region rather than
   three loose paragraphs. `<details class="note-disclosure">` carries the longest ones, where the
   screen states the conclusion and the reasoning is one click away. */
/* A NOTE SPANS ITS CARD, and this was max-width: var(--prose) until 2026-08-25.

   The 76ch cap was right when a note was a run of grey text directly on the page: unbounded, it set as
   one very long line with nothing either side of it. It is wrong now for two reasons that arrived
   separately. The sections became CARDS, so a note already sits inside a padded, bounded column rather
   than on open page. And .note.warn/.note.danger became CALLOUTS with a ground and a rule, at which
   point the cap stopped bounding a line and started drawing a box that stops two thirds of the way
   across its container -- which reads as a rendering fault, and was reported as one.

   What keeps the lines readable is that these are SHORT: every note in this console is one to four
   sentences, which is one or two lines at card width. A genuinely long-form run of prose would want
   its measure back, and .lede below still has one. */
.note {
  font-size: var(--t-sm); line-height: 1.55; color: var(--text-muted);
  margin-top: var(--s2);
}
.notes {
  margin-top: var(--s4); padding-top: var(--s3);
  border-top: 1px solid var(--border);
  display: flex; flex-direction: column; gap: var(--s2);
}
.notes .note { margin-top: 0; }
/* A CALLOUT, NOT A RUN OF RED TEXT. These two carry the sentences an operator must not skim past --
   that deactivating a brand record stops no payments, and that the all-brands defaults price every
   brand at once. Drawn as coloured body text they read as an error the page has just thrown, which is
   the one thing they are not: `.problem` is the error box and it carries role="alert". A caution the
   operator is meant to READ and a failure that has already happened must not share a treatment.

   NO ICON, AND THAT IS A DECISION RATHER THAN AN OMISSION. An icon here means either a new node inside
   <Note> -- which every `.note` assertion in tests/AdminUi.e2e would then see, for a decoration -- or a
   data: URI, which img-src governs and which the no-external-assets guard inspects. The rule and the
   ground say "caution" without buying either cost.

   THE TEXT IS UNCHANGED AND MUST STAY SO. DEACTIVATION_IS_NOT_A_STOP is pinned character-for-character
   by AdminUiSourceTests and is extracted from screens/brand-records.js by two helpers in
   console.spec.mjs; this rule changes how it is drawn and nothing about what it says. */
.note.warn, .note.danger {
  padding: var(--s3) var(--s4);
  border-radius: var(--r-xs);
  border-left: 3px solid var(--warn);
}
.note.warn { color: var(--warn-ink); background: var(--warn-wash); }
.note.danger {
  color: var(--danger-ink); background: var(--danger-wash);
  border-left-color: var(--danger);
}
.note strong { color: var(--text-secondary); font-weight: 600; }

details.note-disclosure { margin-top: var(--s3); }
details.note-disclosure > summary {
  cursor: pointer; font-size: var(--t-ui); font-weight: 500; color: var(--text-secondary);
  padding: var(--s1) 0; border-radius: var(--r-xs); list-style: none;
  display: flex; align-items: center; gap: var(--s2);
}
details.note-disclosure > summary::-webkit-details-marker { display: none; }
details.note-disclosure > summary::before {
  content: "\25B8"; color: var(--text-muted); font-size: var(--t-body);
  display: inline-block; transition: transform .12s ease;
}
details.note-disclosure[open] > summary::before { transform: rotate(90deg); }
details.note-disclosure > summary:hover { color: var(--text); }
details.note-disclosure .note { margin-top: var(--s2); }

/* ---- stat tiles -----------------------------------------------------------------------------------------
   auto-fit with a floor rather than a fixed column count: the tiles are three today and the plan adds
   more, and `repeat(3, 1fr)` would put a fourth on its own row at full width. `minmax(0, ...)` so a long
   unbroken value inside one cannot widen its track and push the page sideways. */
/* THE ACTION ON THE ATTENTION BANNER. It sits on its own line under the sentence rather than at the end
   of it: this is the one thing on the dashboard waiting on a decision, and its call to action used to be
   the last four words of a paragraph. */
.problem .banner-actions { margin-top: var(--s3); }

/* WIDER TILES, FEWER PER ROW. At minmax(200px, ...) a 1900px window fitted five and each one was a
   narrow column with a headline number and a paragraph of caveat wrapped to six lines beside it. The
   caveats are a disclosure now - one line each, opening in place - so the tile is a figure again, and
   280px stops the row splitting figures that are read together across two lines. */
.stats {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: var(--s3); margin-bottom: var(--s5);
  /* EACH TILE IS ITS OWN HEIGHT. Grid items stretch to the tallest in the row by default, so the
     approval-rate tile - which carries a warn callout whenever the ledger holds an outcome this console
     does not recognise - dragged four tiles of empty white down with it. That reads as four things
     loading, or as four things missing something, on the row whose whole job is to be scanned. */
  align-items: start;
}
.stat {
  border: 1px solid var(--border); border-radius: var(--r-md);
  background: var(--surface); box-shadow: var(--shadow-card);
  padding: var(--s4);
  display: flex; flex-direction: column; gap: var(--s1);
}
.stat .label {
  font-size: var(--t-sm); font-weight: 500; color: var(--text-muted);
  display: flex; align-items: center; gap: var(--s2);
}
/* tabular-nums so a count ticking over a digit boundary does not reflow the unit beside it. */
.stat .value {
  font-size: var(--t-display); line-height: 1.1; font-weight: 700; letter-spacing: -.03em;
  font-variant-numeric: tabular-nums; color: var(--text);
}
.stat .unit { font-size: var(--t-ui); font-weight: 500; color: var(--text-muted); letter-spacing: 0; }
.stat .breakdown { display: flex; flex-wrap: wrap; gap: var(--s1) var(--s3); margin-top: var(--s1); }
.stat .breakdown span {
  display: inline-flex; align-items: center; gap: var(--s1);
  font-size: var(--t-sm); color: var(--text-secondary); font-variant-numeric: tabular-nums;
}
/* A failed read stands IN PLACE OF the figure: a zero and a failure must never look the same on a
   screen whose entire job is to say what is configured. */
.stat .problem { margin: var(--s1) 0 0; }

/* ---- tables ----------------------------------------------------------------------------------------------
   EVERY TABLE SITS IN .table-scroll, so the TABLE scrolls sideways and the PAGE does not -- otherwise
   the sidebar, the topbar and the footer slide off with it, which reads as "the console is broken on my
   laptop" rather than as a table being wide. AdminUiSourceTests checks every <table> the console renders
   has this wrapper in front of it, and that this block spends overflow-x: auto.

   THE WRAPPER NO LONGER DRAWS THE BORDER. Inside a boxed .section that would be a second frame a
   hairline inside the first. It keeps the radius so the header row clips to the card's corners, and the
   border is retained only for a table that is NOT inside a section. */
.table-scroll {
  overflow-x: auto;
  border-radius: var(--r-md);
  background: var(--surface);
}
.section > .table-scroll { border-radius: 0 0 var(--r-md) var(--r-md); }
.section > .section-head + .table-scroll { border-top: 0; }
.page > .table-scroll,
.stack > .table-scroll { border: 1px solid var(--border); box-shadow: var(--shadow-card); }

table.grid { width: 100%; min-width: 640px; border-collapse: collapse; font-size: var(--t-ui); }

/* THE `wide` FLAG WAS A CLASS WITH NO RULE. DataTable has taken a `wide` prop since it was written and
   three screens have been passing it -- payments twice and the surcharge grid -- and `table.grid.wide`
   matched nothing in this file, so all three got the 640px floor above and none of them said so.
   It means: this grid has more columns than 640px seats legibly, so hold a wider floor and let
   .table-scroll do its job below that. Without a floor the browser honours 640px by wrapping every cell
   to three lines instead of scrolling, which is harder to read than a scrollbar and hides nothing
   usefully. Above the floor the table is fluid -- width: 100% of a page that no longer has a cap. */
table.grid.wide { min-width: 960px; }

/* The caption is the table's accessible name, and several here run to two or three sentences. On a
   surface above the header row it reads as belonging to the table rather than as more page prose. */
table.grid caption {
  caption-side: top; text-align: left;
  padding: var(--s3) var(--s4);
  font-size: var(--t-sm); line-height: 1.5; color: var(--text-muted);
  background: var(--surface-sunken); border-bottom: 1px solid var(--border);
  /* THE BOX SPANS THE TABLE IT NAMES. It used to carry max-width: var(--prose), which bounded the
     BOX and not merely the line -- so a tinted, bordered strip stopped at 76ch above a table running
     the full width of a 1900px window, and read as a layout fault rather than as a measure. A bounded
     LINE aids reading; a bounded BOX just looks broken, and these two were being confused.
     The lines stay short because these captions are short: the longest here is three sentences, which
     is under two lines at this width. Long-form prose keeps its measure -- see .note below. */
  max-width: none;
}

table.grid th, table.grid td {
  text-align: left; padding: var(--pad-cell);
  border-bottom: 1px solid var(--border);
  vertical-align: middle; height: var(--row-h);
}

/* SCOPED TO `thead th`, WHICH IS LOAD-BEARING. The small-uppercase treatment is a COLUMN LABEL. Applied
   to every th it also lands on <th scope="row">, which is a VALUE -- and this console shipped exactly
   that bug: import references rendered in letter-spaced caps, a spelling the operator has to match and
   was never actually shown. Two grids carried hand-written overrides to undo it and the third did not.
   Scoping it here makes a row header unreachable by it, instead of needing an override the next table
   forgets. */
table.grid thead th {
  position: sticky; top: 0; z-index: 1;
  height: auto; padding: var(--s2) var(--s4);
  font-size: var(--t-xs); font-weight: 600; text-transform: uppercase; letter-spacing: .06em;
  color: var(--text-muted); white-space: nowrap;
  background: var(--surface-sunken);
  border-bottom: 1px solid var(--border);
}
/* A sortable header is a button filling its cell, so the whole label is the hit target and the control
   is reachable by keyboard -- a click handler on the <th> would not be. */
table.grid thead th button.sort {
  all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: var(--s1);
  font: inherit; color: inherit; text-transform: inherit; letter-spacing: inherit;
}
table.grid thead th button.sort:hover { color: var(--text); }
table.grid thead th button.sort .arrow { opacity: 0; font-size: var(--t-xs); }
table.grid thead th[aria-sort] button.sort { color: var(--text); }
table.grid thead th[aria-sort] button.sort .arrow { opacity: 1; }

/* A row header is the VALUE the row is about -- a brand id, a state, an import reference -- so it takes
   the body's type with a little more weight, and never the column treatment above. */
table.grid tbody th {
  font-weight: 600; color: var(--text); font-size: var(--t-ui);
  text-transform: none; letter-spacing: 0;
}
table.grid tbody tr:last-child > * { border-bottom: 0; }
@media (hover: hover) {
  table.grid tbody tr:hover > * { background: var(--surface-hover); }
}
/* AN IDENTIFIER IN A CELL STAYS ON ONE LINE. The Account and Credential columns stopped being `nowrap`
   when this table went full-width, and the browser's default break opportunity after a hyphen then split
   `nexio-e2e-brand-usd` as "nexio-e2e-brand-" / "usd" - two lines that read as a shorter id than the one
   stored. The token is the atom here; the human name beside it is what wraps. A pathologically long id
   widens the table instead, which .table-scroll already handles and is the honest outcome. */
table.grid code { white-space: nowrap; }

table.grid td.num, table.grid th.num { text-align: right; font-variant-numeric: tabular-nums; }
table.grid td.nowrap, table.grid th.nowrap { white-space: nowrap; }
table.grid td.shrink { width: 1%; white-space: nowrap; }
table.grid td.actions { text-align: right; }

/* TWO VALUES IN ONE CELL, STACKED. A verdict badge and the Key Vault secret name beside it made this
   column as wide as their SUM, and `table.grid code` is nowrap on purpose -- so the table outgrew the
   viewport and the name was cut mid-value. Stacked, the column is as wide as the longer of the two.
   `min-width: 0` so the code element can be constrained by the cell rather than by its own content. */
.stack-tight { display: flex; flex-direction: column; align-items: flex-start; gap: var(--s1); min-width: 0; }

/* The not-found screen's list of real destinations. A plain column of links: it is a list of places,
   not a nav, so it takes no chrome and nothing about it should suggest the rail. */
/* ---- the skip link ------------------------------------------------------------------------------------
   OFF-SCREEN RATHER THAN display:none, because a hidden element is not focusable and a skip link that
   cannot be focused is not a skip link. It comes back on focus, over everything, with the same focus
   ring the rest of the console uses.

   `left: -9999px` rather than the `.sr-only` clip trick used elsewhere: this one has to become VISIBLE,
   and a clipped element that is un-clipped on focus needs every one of those properties undone. Moving
   it is one property in and one property out. */
.skip-link {
  position: absolute; left: -9999px; top: var(--s2); z-index: 100;
  padding: var(--s2) var(--s3); border-radius: var(--r-sm);
  background: var(--surface); color: var(--accent-text);
  border: 1px solid var(--border-strong); box-shadow: var(--shadow-pop);
  font-size: var(--t-ui); font-weight: 600; text-decoration: none;
}
.skip-link:focus { left: var(--s2); }

/* NO `outline: none` HERE, AND A GUARD SAYS SO. The skip link's target is `tabindex="-1"` and takes
   focus programmatically; the reflex is to suppress the ring so a mouse user who clicked inside the page
   does not see the whole content region outlined. That reflex is what
   AdminUiSourceTests.Focus_moves_to_the_new_heading_after_every_render_and_the_ring_is_visible exists to
   stop, and it fired on exactly this - the console's focus ring is behind `:focus-visible`, which
   ALREADY tells a programmatic focus from a keyboard one and draws nothing for the former. The rule was
   solving a problem the cascade had already solved, at the cost of the one thing the guard protects. */

.destination-list { list-style: none; margin: 0; padding: 0 var(--s4) var(--s4); display: flex;
  flex-direction: column; gap: var(--s2); }

/* ---- the pinned action column ------------------------------------------------------------------------
   A ROW ACTION IS REACHABLE AT ANY VIEWPORT, because a button rendered half-off its container reads as
   broken rather than as scrolled. Measured before this: Brands held 971px of content in 895px of visible
   width and the Deactivate button overflowed by 76px, rendering as a red sliver reading "D". The table
   needed a ~1283px viewport to fit, so every 1280x800 laptop clipped it.

   KEYED ON THE `actions` FLAG THE COLUMN CONFIG ALREADY CARRIES, so this is one rule for all nine screens
   that use DataTable rather than three per-screen fixes. A table with no actions column is untouched.

   THE HEADER STICKS TOO, and it already sticks to the top -- an element can stick on both axes, and it
   has to here or the label scrolls away from the cells beneath it.

   *** EVERY ROW BACKGROUND IS MIRRORED, AND THAT IS THE WHOLE COST OF PINNING. *** A sticky cell paints
   over the content it slides across, so it cannot be transparent -- and the moment it is opaque it stops
   inheriting the row's own state. Hover and .retired are therefore restated here. Miss one and that state
   is visible on every column except this one, which reads as the row being half-highlighted. The values
   are the same tokens the row rules use; nothing new is introduced, so the contrast sweep still covers
   these pairs. */
table.grid td.actions,
table.grid thead th.actions {
  position: sticky; right: 0;
  background: var(--surface);
  /* The edge, so a pinned column reads as pinned rather than as a column that happens to be last. A
     hairline plus a short shadow falling LEFT, over the content sliding under it. */
  border-left: 1px solid var(--border);
  box-shadow: var(--shadow-pinned);
}
/* Above the body cells, and above the top-sticky header's own stacking. */
table.grid thead th.actions { z-index: 2; background: var(--surface-sunken); }
table.grid td.actions { z-index: 1; }

@media (hover: hover) {
  table.grid tbody tr:hover > td.actions { background: var(--surface-hover); }
}
tr.retired > td.actions { background: var(--surface-sunken); }

/* A retired row is dimmed but never hidden: rotating its credential is supported, and it is the row
   that explains why a brand/gateway/currency slot is or is not free. */
tr.retired > * { background: var(--surface-sunken); color: var(--text-muted); }

/* ---- empty and loading ---------------------------------------------------------------------------------- */

.empty {
  display: flex; flex-direction: column; align-items: center; gap: var(--s2);
  padding: var(--s7) var(--s5); text-align: center;
}
.empty .headline { font-size: var(--t-body); font-weight: 600; color: var(--text-secondary); }
.empty .detail { font-size: var(--t-ui); color: var(--text-muted); max-width: 48ch; }
td.empty-cell { padding: 0; height: auto; }

/* SKELETONS, NOT A SPINNER. Every screen here is a list or a table, so a skeleton says what is coming and
   holds the layout still; a spinner says only "wait", and then the page jumps when it goes. The shimmer
   is a continuous animation, which is exactly what prefers-reduced-motion exists to stop -- see the foot
   of this file, where it is removed rather than merely shortened. */
.skeleton {
  background: linear-gradient(90deg, var(--surface-active) 25%, var(--surface-hover) 37%, var(--surface-active) 63%);
  background-size: 400% 100%;
  animation: shimmer 1.4s ease infinite;
  border-radius: var(--r-xs);
  height: 12px;
}
@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
.skeleton-row { display: flex; gap: var(--s4); padding: var(--s3) var(--s4); align-items: center; }
.skeleton-row .skeleton:nth-child(1) { flex: 2; }
.skeleton-row .skeleton:nth-child(2) { flex: 3; }
.skeleton-row .skeleton:nth-child(3) { flex: 2; }
.skeleton-row .skeleton:nth-child(4) { flex: 1; }

/* ---- badges -----------------------------------------------------------------------------------------------
   TONAL, and the ink token is what makes that safe. A saturated pill is white on a strong fill and is
   accessible almost by construction; forty of them down one column of an audit feed is also the loudest
   thing on the screen, so the emphasis is spent by the time something is actually wrong. A wash with the
   verdict as text is quieter and still unambiguous, and every pair is measured at 4.5:1 or better in both
   themes by tools/check-admin-ui-contrast.mjs.

   FULLY ROUNDED AND HAIRLINED NOW. At the previous 4px radius on a boxed card a badge read as another
   small container; a pill reads as a label on one. The border is the wash's own edge -- it adds
   definition against a tinted row without introducing a colour the gate does not already score. */
.badge {
  display: inline-flex; align-items: center; gap: var(--s1);
  padding: 1px var(--s3); border-radius: var(--r-pill);
  border: 1px solid transparent;
  font-size: var(--t-sm); font-weight: 600; line-height: 1.7; white-space: nowrap;
  background: var(--neutral-wash); color: var(--neutral-ink); border-color: var(--neutral-wash);
}
.badge.ok { background: var(--ok-wash); color: var(--ok-ink); border-color: var(--ok-wash); }
.badge.warn { background: var(--warn-wash); color: var(--warn-ink); border-color: var(--warn-wash); }
.badge.danger { background: var(--danger-wash); color: var(--danger-ink); border-color: var(--danger-wash); }
.badge.accent { background: var(--accent-subtle); color: var(--accent-text); border-color: var(--accent-subtle); }
/* The dot carries the verdict for someone scanning a column at speed; the WORD carries it for everyone,
   and there is never a dot without one -- colour is not a message. */
.badge .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; flex: none; }

/* ---- buttons ----------------------------------------------------------------------------------------------- */

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s2);
  height: var(--ctl-h); padding: 0 var(--s4);
  border: 1px solid var(--border-strong); border-radius: var(--r-sm);
  font: inherit; font-size: var(--t-ui); font-weight: 500; white-space: nowrap;
  cursor: pointer;
  background: var(--surface); color: var(--text);
  box-shadow: var(--shadow-card);
  transition: background-color .12s ease, border-color .12s ease, color .12s ease;
}
.btn:hover { background: var(--surface-hover); border-color: var(--border-field); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: var(--text-inverse); font-weight: 600; }
.btn.danger:hover { background: var(--danger-hover); border-color: var(--danger-hover); }
.btn.subtle { background: transparent; border-color: transparent; color: var(--text-secondary); box-shadow: none; }
.btn.subtle:hover { background: var(--surface-active); color: var(--text); border-color: transparent; }
.btn.sm { height: var(--ctl-h-sm); padding: 0 var(--s3); font-size: var(--t-sm); border-radius: var(--r-xs); }
.btn.icon { width: var(--ctl-h); padding: 0; }
.btn.icon.sm { width: var(--ctl-h-sm); }
.btn[disabled] { opacity: .45; cursor: default; }
.btn[disabled]:hover { background: var(--surface); border-color: var(--border-strong); }
.btn.primary[disabled]:hover { background: var(--accent); border-color: var(--accent); }
.btn.danger[disabled]:hover { background: var(--danger); border-color: var(--danger); }
.btn .ico {
  width: 14px; height: 14px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
}
.btn-row { display: flex; gap: var(--s2); flex-wrap: wrap; align-items: center; }

/* ---- forms -------------------------------------------------------------------------------------------------- */

.field { display: flex; flex-direction: column; gap: var(--s1); margin-bottom: var(--s4); min-width: 0; }
.field > label { font-size: var(--t-sm); font-weight: 600; color: var(--text); }
.field .help { font-size: var(--t-sm); color: var(--text-muted); line-height: 1.5; }
.field .error { font-size: var(--t-sm); color: var(--danger-ink); font-weight: 500; }

input[type=text], input[type=url], input[type=search], select, textarea {
  width: 100%; height: var(--ctl-h); padding: 0 var(--s3);
  border: 1px solid var(--border-field); border-radius: var(--r-sm);
  background: var(--surface); color: var(--text);
  font: inherit; font-size: var(--t-ui);
  transition: border-color .12s ease, box-shadow .12s ease;
}
textarea {
  height: auto; min-height: 84px; padding: var(--s2) var(--s3); resize: vertical;
  font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; font-size: var(--t-sm);
}
input:hover, select:hover, textarea:hover { border-color: var(--text-muted); }
/* A mouse click into a text box does not match :focus-visible, so a pointer operator otherwise had no
   indication at all of which field they were typing into. A border move plus a soft ring does it without
   disturbing the outline, which stays for the keyboard. */
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-subtle); }
input::placeholder, textarea::placeholder { color: var(--text-muted); }
input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--accent); }

.checkbox { display: flex; align-items: flex-start; gap: var(--s2); margin-bottom: var(--s4); }
.checkbox input { margin-top: 3px; flex: none; }
.checkbox label { font-size: var(--t-ui); color: var(--text); font-weight: 500; }
.checkbox .help { font-size: var(--t-sm); color: var(--text-muted); display: block; margin-top: 2px; }

/* THE FILE INPUT is the last control a browser draws entirely by itself, and it sits on the one screen
   that performs a permanent, unrevocable change. Only ::file-selector-button is restyled -- the supported
   hook -- so the element keeps its native behaviour and its accessibility intact. */
input[type=file] {
  width: 100%; padding: var(--s4); font: inherit; font-size: var(--t-ui); color: var(--text-secondary);
  border: 1px dashed var(--border-field); border-radius: var(--r-sm); background: var(--bg-subtle);
}
input[type=file]::file-selector-button {
  font: inherit; font-size: var(--t-sm); font-weight: 500;
  height: var(--ctl-h-sm); padding: 0 var(--s3); margin-right: var(--s3);
  border: 1px solid var(--border-strong); border-radius: var(--r-xs);
  background: var(--surface); color: var(--text); cursor: pointer;
}
input[type=file]:hover::file-selector-button { background: var(--surface-hover); }

.field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 var(--s4); }

/* ---- the filter bar, and the disclosure under it -----------------------------------------------------
   THE COMMON FILTERS ON A LINE. `.inline-form` already exists for controls on one row, but its 220px
   minimum is sized for a form field rather than for five of them side by side -- at that width this bar
   wrapped to three rows and was no shorter than the grid it replaced. Narrower tracks, and it still
   wraps rather than overflowing, because a filter bar that scrolls sideways hides a control.

   The buttons ride at the BOTTOM of the row (`align-items: flex-end` on the bar, no label above them),
   so they line up with the inputs rather than floating against their labels. */
.filter-bar {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--s3);
  margin-bottom: var(--s3);
}
.filter-bar .field { margin-bottom: 0; flex: 1 1 160px; min-width: 0; max-width: 260px; }
.filter-bar .btn-row { flex: none; }

/* THE PAGER, under a server-paged table. It carries no colour of its own: the range-and-total readout
   is `.muted` like every other secondary readout in this console, and the two controls are ordinary
   buttons -- so this rule is layout only and adds no palette pair for the contrast check to weigh. */
.pager {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s3);
  margin-top: var(--s3);
}
.pager [data-testid="pager-summary"] { font-variant-numeric: tabular-nums; }

/* The disclosure holding the rest. `summary` is the control, so it takes a control's height and the
   focus ring the rest of the console uses -- it is reached by keyboard like anything else. */
details.more-filters > summary {
  cursor: pointer; list-style: none;
  display: inline-flex; align-items: center; gap: var(--s2);
  min-height: var(--ctl-h-sm); padding: 0 var(--s2);
  border-radius: var(--r-xs);
  font-size: var(--t-ui); font-weight: 500; color: var(--text-secondary);
}
details.more-filters > summary::-webkit-details-marker { display: none; }
details.more-filters > summary::before {
  content: "\25B8"; color: var(--text-muted); display: inline-block; transition: transform .12s ease;
}
details.more-filters[open] > summary::before { transform: rotate(90deg); }
details.more-filters > summary:hover { color: var(--text); background: var(--surface-hover); }
details.more-filters > .field-grid { margin-top: var(--s3); }
/* A LABEL, A BOX AND A BUTTON ON ONE LINE. The field GROWS now; it used to sit at its 220px minimum
   whatever the container, which on a brand's own screen left a 190px box marooned in a card over a
   thousand pixels wide while the textarea directly beneath it spanned the whole card. Two controls in
   one panel disagreeing about how wide a control is reads as unfinished, and was reported as such.

   THE CAP IS DELIBERATE AND IS NOT 100%. A text input as wide as the card is worse than a narrow one:
   the caret ends up a long way from the label, and nothing here holds a value that needs 1200px --
   these are display names, brand ids and Key Vault secret names. 560px fills the space the eye
   expects and still shows the longest value any of them carries. */
.inline-form { display: flex; align-items: flex-end; gap: var(--s2); flex-wrap: wrap; }
.inline-form .field { margin-bottom: 0; flex: 1 1 320px; max-width: 560px; min-width: 0; }
.inline-form input { width: 100%; min-width: 0; }

/* ---- the merchant-account facet bar ----------------------------------------------------------------
   SEVEN SHORT CONTROLS ON ONE ROW, wrapping rather than scrolling. Narrower than .inline-form's fields
   because every one of these holds a closed vocabulary - a gateway wire name, a currency code, a
   three-word verdict - and a 320px box around the word `sandbox` reads as a text input somebody is
   meant to type into. `align-items: flex-end` so the selects line up along their own baseline while
   the Clear button sits level with them rather than with their labels. */
.facets { display: flex; align-items: flex-end; gap: var(--s2) var(--s3); flex-wrap: wrap;
  padding: 0 var(--s4); }
.facets .field { margin-bottom: 0; flex: 0 1 auto; min-width: 8.5rem; }
.facets select { width: 100%; min-width: 0; }

/* ---- the dashboard's two-column row ----------------------------------------------------------------------
   Configuration drift and the recent change feed, side by side: a brand in the left panel is usually
   explained by a row in the right one, and stacked full-width they were a screen apart.

   ONE COLUMN UNDER 1000px RATHER THAN TWO NARROW ONES. The left panel carries a table, and two 400px
   columns of table is worse than one readable column - the breakpoint is above the 900px rail collapse
   deliberately, because the content runs out of room before the chrome does. */
/* TWO PANELS SIDE BY SIDE, and the collapse point is the rule rather than the columns.
   Used by the dashboard (drift beside the change feed) and by retokenization (the form beside the runs
   it produced). Named for the shape and not for either screen: it was `.dash-pair` while only one screen
   had one, and a dashboard-named class on the import screen reads as a copied mistake.
   IT COLLAPSES TO ONE COLUMN UNDER 1000px rather than shrinking both. Two 400px columns of table is
   worse than one readable one, and both users of this carry a table or a form. */
.pair {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: var(--s4); align-items: start;
}
.pair > .section { margin-bottom: 0; }
@media (max-width: 1000px) {
  .pair { grid-template-columns: minmax(0, 1fr); }
  .pair > .section { margin-bottom: var(--s5); }
}

/* ---- the list of earlier retokenization runs -------------------------------------------------------------
   A LIST RATHER THAN A TABLE, and the reason is what each is for. The table this replaced carried eight
   numeric columns -- applied, would apply, unmatched, refused -- which is the right shape for comparing
   runs and the wrong one for the question actually asked here, which is "did the last one work". The
   per-run detail screen still carries every count.
   THE OUTCOME IS THE ONLY NUMBER ON THE ROW, and it is worded so a dry run cannot read as an applied one:
   "would move" and "moved" are different strings, deliberately, because a single "moved" column is exactly
   how a dry run comes to look like a completed import. */
ol.runs { list-style: none; margin: 0; padding: 0; }
/* IT WRAPS, and that is not a nicety. This list sits in a HALF-WIDTH column, and the longest verdict --
   a danger badge followed by "counts do not add up" -- is wider than the space left beside a run's name.
   Without wrapping, flex refuses to break the second child and it runs straight out of the panel: the
   text sat outside the rounded border, which no assertion in the browser suite would have reported,
   because every one of them is a computed ratio, a geometry or a posted body. A screenshot found it. */
ol.runs > li {
  display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
  gap: var(--s1) var(--s3);
  padding: var(--s3) 0; border-bottom: 1px solid var(--border);
}
ol.runs > li:last-child { border-bottom: 0; }
ol.runs .who { flex: 1 1 55%; min-width: 0; }
ol.runs .verdict { flex: 0 1 auto; min-width: 0; }
/* The verdict's own parts wrap too -- the badge and the reconciliation warning beside it. */
ol.runs .verdict .row { flex-wrap: wrap; justify-content: flex-end; }
/* THE WHOLE REFERENCE, WRAPPED RATHER THAN CUT. These names differ in their LAST characters, so any
   end-truncation removes precisely what tells two runs apart. `anywhere` rather than `break-word`
   because a run reference is one unbroken token with no space to break at, and without it the name
   simply overflows its half-width column and pushes the verdict out of the panel. */
ol.runs .name { font-weight: 600; font-size: var(--t-ui); overflow-wrap: anywhere; }
ol.runs .meta { display: block; margin-top: 2px; font-size: var(--t-sm); color: var(--text-muted); }

/* ---- a checkbox that carries a consequence ---------------------------------------------------------------
   ONE CHECKBOX ON THIS DEPLOYMENT GETS THIS TREATMENT: the retokenization dry run, which is the only
   control here whose OFF position performs a permanent, unrevocable change. The wash is the affordance
   -- it says "this is on, and that is the safe state" without adding a second warning nobody reads. It
   is a token pair the contrast script already checks (--text on --ok-wash), so it cannot drift below
   4.5:1 unnoticed. */
.checkbox.guarded {
  padding: var(--s3); border-radius: var(--r-sm);
  background: var(--ok-wash); border: 1px solid var(--border);
}

/* ---- the retokenization stepline -------------------------------------------------------------------------
   A real <ol>, so the count and the position are announced. The current step also carries an .sr-only
   "(current step)" in the markup, because the ring of colour that marks it here says nothing to a screen
   reader -- and this is the sequence whose last step is irreversible. */
ol.stepline {
  list-style: none; margin: 0 0 var(--s5); padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--s1) var(--s5);
}
.stp {
  display: flex; align-items: center; gap: var(--s2);
  font-size: var(--t-ui); color: var(--text-muted);
}
.stp .mark {
  flex: none; width: 22px; height: 22px; border-radius: 50%;
  display: grid; place-items: center;
  border: 1.5px solid var(--border-strong); background: var(--surface);
  font-size: var(--t-xs); font-weight: 600;
}
/* DONE is the only state that gets a fill, and it is the OK tone rather than the accent: a completed
   step is reassurance, and spending the accent on it would compete with the current one. */
.stp.dn { color: var(--text-secondary); }
.stp.dn .mark { background: var(--ok); border-color: var(--ok); color: var(--text-inverse); }
.stp.cu { color: var(--accent-text); font-weight: 600; }
.stp.cu .mark { border-color: var(--accent); border-width: 2px; color: var(--accent-text); }

/* ---- the file drop zone ---------------------------------------------------------------------------------
   THE INPUT IS INSIDE IT AND IS STILL THE CONTROL. A div with a drop handler is reachable by no keyboard,
   so the zone wraps the real <input type=file> rather than replacing it -- the drop is an addition to the
   picker, never a substitute. `.over` is set from a dragover handler in the screen, not from CSS :hover,
   because a drag is not a hover. */
.drop {
  display: flex; flex-direction: column; gap: var(--s2);
  padding: var(--s4);
  border: 1.5px dashed var(--border-field); border-radius: var(--r-md);
  background: var(--bg-subtle);
  transition: border-color .12s ease, background-color .12s ease;
}
.drop.over { border-color: var(--accent); background: var(--accent-subtle); }
.drop .hint { font-size: var(--t-sm); color: var(--text-muted); }
/* The nested input must not redraw its own dashed box inside this one. */
.drop input[type=file] { border: 0; background: transparent; padding: 0; }
.drop .chosen {
  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
  font-size: var(--t-sm); color: var(--text); font-weight: 600;
}

/* ---- the surcharge rate cell ----------------------------------------------------------------------------
   TWO NUMBER BOXES ON ONE LINE, AND THE WIDTH CAP IS THE WHOLE RULE.

   The cell holds a basis-points box and a percent box, live-linked: typing either updates the other. That
   is a real safeguard rather than a convenience -- the API takes basis points, every operator thinks in
   percent, and 250 vs 2.50 is a hundredfold error on what a shopper is charged. A single box with a unit
   toggle elsewhere would be more compact and would show one unit at a time, which is precisely the
   ambiguity these two remove.

   What it did NOT survive was the default `width: 100%` on a text input. Inside a table cell that made
   each box full-width, `.row`'s wrap put them on separate lines, and a row stood about 180px tall -- so
   the 51-state grid ran to some nine thousand pixels and the table the screen exists for could not be
   scanned at all. Capping the width fixes the layout without giving up the pairing; the grid already
   scrolls sideways if the cell cannot fit, which is the correct fallback for a table and not for a page. */
form[data-form="surcharge-rate"] { flex-wrap: nowrap; align-items: center; gap: var(--s2); }
form[data-form="surcharge-rate"] input[type=text] {
  width: 5.5rem; min-width: 0; text-align: right;
  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
/* The unit sits against its own box rather than floating between the two. */
form[data-form="surcharge-rate"] .muted { margin-left: calc(-1 * var(--s1)); }

/* ---- the bulk action bar ---------------------------------------------------------------------------------
   THE SAME TWO BOXES, ONE SCALE UP, AND IT IS DRAWN AS A MONEY-CHANGING PANEL DELIBERATELY.

   This bar appears only while states are ticked and it writes a rate to every one of them, so it is the
   widest-reaching control on the screen. It therefore borrows the accent left border the console already
   uses for a panel that changes money, rather than reading as another row of filters: the difference
   between "narrow this table" and "reprice twelve states" has to be visible before the click, not in the
   confirmation after it.

   The width cap on the two boxes is the per-state cell's rule and the same reason -- an uncapped text
   input is `width: 100%`, which here would push the buttons onto their own line and put the rate a long
   way from the count of states it applies to. */
/* STICKY, BECAUSE THE THING IT ACTS ON IS 51 ROWS LONG. An operator ticking states scrolls the count and
   the Set control off the top of the window, so the only way to see how many are selected is to scroll
   back - on the bar that decides what every brand is charged. `top: 0` puts it directly under the sticky
   table header rather than over it; both are in the same scroll container and the header is z-index 1,
   so this takes 2 to stay above the rows sliding beneath it.

   IT IS OPAQUE FOR THE SAME REASON THE PINNED ACTION COLUMN IS: a sticky element paints over content and
   a transparent one shows the rows through its own text. `--surface-sunken` was already its background,
   so nothing new is introduced and the contrast sweep still covers the pair. */
.selbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s3);
  margin: 0 0 var(--s3); padding: var(--s3);
  /* BELOW THE TOPBAR, NOT AT ZERO. The topbar is itself `sticky; top: 0` at z-index 20, so a bar pinned
     to 0 slides UNDERNEATH it and is hidden by the one piece of chrome that is always there - which is
     worse than not pinning it at all, because the count then looks like it has disappeared. Measured at
     top: 0 the bar sat 1.9px above the viewport, entirely behind the topbar. */
  position: sticky; top: var(--topbar-h); z-index: 2;
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: var(--r-sm);
  background: var(--surface-sunken);
}
.selbar > strong { font-size: var(--t-ui); }
.selbar input[type=text] {
  width: 5.5rem; min-width: 0; text-align: right;
  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.selbar .muted { margin-left: calc(-1 * var(--s1)); }

/* THE CHECKBOX COLUMN. `shrink` already keeps the column narrow; this stops the box inheriting the
   full-width rule that every other input on this screen needs, and centres it under its own header. */
td[data-col="pick"] { text-align: center; }
td[data-col="pick"] input[type=checkbox] { width: auto; margin: 0; cursor: pointer; }

/* ---- the card-type matrix ---------------------------------------------------------------------------------
   306 CELLS, EACH A CHECKBOX AND SOMETIMES A BADGE. The row is a flex line rather than two block
   elements so the badge sits beside the box instead of under it -- a 51x6 grid with a wrapped cell is
   twice as tall as it needs to be and stops being scannable, which is the only thing a matrix is for.
   NO NEW TOKENS. Every colour here comes from the badge and muted-text pairs tools/check-admin-ui-contrast.mjs
   already checks, so a palette nudge cannot drop one of these cells below 4.5:1 unnoticed. */
.cell-toggle { display: flex; align-items: center; justify-content: center; gap: var(--s2); }
.cell-toggle input[type=checkbox] { width: auto; margin: 0; cursor: pointer; }
.cell-toggle .badge { font-size: var(--t-xs); }

/* A READ-ONLY CELL, WHICH IS 255 OF THE 306. Only credit is editable, so the other five columns render a
   disabled box that still shows the rule in force -- the value is the point, and hiding it would hide a
   rule the checkout path is still applying.

   OPACITY AND A CURSOR, AND DELIBERATELY NO COLOUR. Both are outside the palette
   tools/check-admin-ui-contrast.mjs scores, so this cannot be the rule that drops a pair under 4.5:1 --
   which matters here more than usual, because the badge beside the box is the same neutral wash it is
   everywhere else and is scored there. Dimming the CONTAINER would take the badge with it and put a
   scored pair behind an unscored opacity, so the rule names the input alone.

   THE MARKER IS NOT DIMMED for the same reason: `no rule` on a read-only cell is the one combination on
   this screen that means a real fee is silently never charged, and it has to read at full weight whether
   or not anybody may change it. */
/* A READ-ONLY CELL IS TEXT NOW, not a disabled input - see the screen for why. The glyph carries the
   value visually and is aria-hidden; the sentence beside it is the accessible name. Muted rather than
   dimmed with opacity: this is a real value that is simply not editable here, and `--text-muted` on
   `--surface` is a pair the contrast script already scores, where an opacity is not. */
.cell-toggle .glyph {
  font-size: var(--t-ui); line-height: 1; color: var(--text-muted);
  min-width: 1ch; text-align: center;
}

/* The column heading for a read-only funding type: the name, then the badge, on one line. `thead` is
   what survives scrolling a 51-row grid, so this is the label that is still on screen when the note
   above the table is not. */
.col-head { display: inline-flex; align-items: center; gap: var(--s2); }
.col-head .badge { font-size: var(--t-xs); font-weight: 500; }

/* A LABEL FOR A GROUP OF CONTROLS RATHER THAN FOR ONE. The gateway preference is an ordered list with
   three buttons per row, so there is no single control for a <label for> to point at -- and a <label>
   with no control is announced as nothing. The list names itself with aria-labelledby pointing here. */
.group-label { font-size: var(--t-sm); font-weight: 600; color: var(--text); margin-bottom: var(--s1); }

/* ---- the gateway priority list -------------------------------------------------------------------------------
   THE ORDER IS THE DATA, so the rank is rendered rather than implied: a plain list of gateway names does
   not say that the top one is tried first, and this is the panel where that is decided. A real <ol>, so a
   screen reader announces the position and the count -- which is exactly what a move control changes.

   THE RANK IS A FILLED CHIP NOW rather than a muted numeral. The mockup's routing rows make the position
   the most legible thing in the row, which is correct here: the number IS the configuration. */
ol.rank-list { list-style: none; margin: 0 0 var(--s3); padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
ol.rank-list li {
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s2) var(--s2) var(--s2) var(--s3); min-height: 44px;
  border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface);
}
ol.rank-list .rank {
  flex: none; width: 24px; height: 24px; border-radius: var(--r-xs);
  display: grid; place-items: center;
  background: var(--accent); color: var(--accent-ink);
  font-size: var(--t-sm); font-variant-numeric: tabular-nums; font-weight: 600;
}
ol.rank-list .name { font-weight: 600; font-size: var(--t-ui); }
ol.rank-list .controls { margin-left: auto; display: flex; gap: var(--s1); }

/* ---- problem boxes -------------------------------------------------------------------------------------------
   role="alert" goes on the box itself rather than on a wrapper -- see ui/feedback.js. This is written into
   a page that has already drawn, while the operator's attention is on the control they just used, which is
   nowhere near the message. */
.problem {
  border: 1px solid var(--danger-wash); border-left: 3px solid var(--danger);
  background: var(--danger-wash); color: var(--text);
  padding: var(--s3) var(--s4); border-radius: var(--r-sm);
  font-size: var(--t-ui); margin: var(--s3) 0;
  display: flex; flex-direction: column; gap: var(--s1);
}
.problem.warn { border-color: var(--warn-wash); border-left-color: var(--warn); background: var(--warn-wash); }
.problem.ok { border-color: var(--ok-wash); border-left-color: var(--ok); background: var(--ok-wash); }
.problem .detail { color: var(--text-secondary); font-size: var(--t-sm); }

pre.copyable {
  background: var(--pre-bg); color: var(--pre-ink);
  padding: var(--s3) var(--s4); border-radius: var(--r-sm);
  overflow-x: auto; white-space: pre-wrap; word-break: break-all;
  font-size: var(--t-sm); line-height: 1.6; margin: 0;
}
.copy-row { display: flex; align-items: flex-start; gap: var(--s2); }
.copy-row pre.copyable { flex: 1; min-width: 0; }

/* The audit before/after blobs, collapsed behind a native <details>: no JavaScript and no state, and the
   disclosure is the element's own behaviour. Capped and self-scrolling, because a blob is a SELECT * of a
   config row and the feed shows a page of them at once. */
details.blob { font-size: var(--t-sm); }
details.blob > summary { cursor: pointer; color: var(--text-muted); white-space: nowrap; }
details.blob > summary:hover { color: var(--text); }
pre.blob {
  background: var(--pre-bg); color: var(--pre-ink);
  padding: var(--s2) var(--s3); border-radius: var(--r-sm);
  max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word;
  font-size: var(--t-xs); line-height: 1.55; margin: var(--s2) 0 0;
}

/* ---- toasts ---------------------------------------------------------------------------------------------------
   THE PAGE-LEVEL MESSAGE USED TO BE A FULL-BLEED BAR ABOVE THE CONTENT: every success pushed the whole
   page down and landed at the top, while the operator was looking at the button they had just pressed at
   the bottom. A toast appears near where the eye already is and takes no layout at all.

   The region is aria-live="polite" and is ALWAYS mounted, empty, because a live region inserted at the
   same moment as its content is not reliably announced. */
.toaster {
  position: fixed; bottom: var(--s5); right: var(--s5); z-index: 60;
  display: flex; flex-direction: column-reverse; gap: var(--s2);
  max-width: min(420px, calc(100vw - 32px));
}
.toast {
  display: flex; align-items: flex-start; gap: var(--s3);
  padding: var(--s3) var(--s4);
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-left: 3px solid var(--neutral);
  border-radius: var(--r-md); box-shadow: var(--shadow-overlay);
  font-size: var(--t-ui);
  animation: toast-in .14s ease;
}
.toast.ok { border-left-color: var(--ok); }
.toast.warn { border-left-color: var(--warn); }
.toast.danger { border-left-color: var(--danger); }
.toast .body { flex: 1; min-width: 0; }
.toast .title { font-weight: 600; }
.toast .detail { color: var(--text-muted); font-size: var(--t-sm); margin-top: 2px; }
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }

/* ---- overlays: drawer, modal, command palette -------------------------------------------------------------------
   ALL THREE ARE NATIVE <dialog> OPENED WITH showModal(). A modal has to trap focus, make the page behind
   it inert, close on Escape and paint a backdrop, and every one of those is behaviour the element gives
   for free and a hand-rolled div does not. Nothing below positions or centres anything the browser already
   places; these rules are appearance only.

   ::backdrop is painted with a token, and whether a custom property reaches that pseudo-element has changed
   across browser versions -- so it is MEASURED by tests/AdminUi.e2e reading the resolved background in both
   themes, not asserted here. If that goes red the fix is a literal colour per theme, not a guess about
   inheritance. */
dialog { border: 0; padding: 0; background: transparent; color: inherit; max-width: none; max-height: none; }
dialog::backdrop { background: var(--scrim); }

/* THE DRAWER. A detail view that does not throw away the list behind it -- the operator keeps their place,
   their scroll position and their filter, all of which a full page swap discards every time. */
dialog.drawer {
  margin: 0 0 0 auto; height: 100vh; width: min(560px, 100vw);
  background: var(--surface); border-left: 1px solid var(--border);
  box-shadow: var(--shadow-overlay);
  display: flex; flex-direction: column;
  animation: drawer-in .16s ease;
}
@keyframes drawer-in { from { transform: translateX(16px); opacity: .6; } to { transform: none; opacity: 1; } }
.drawer-head {
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s4) var(--s5); border-bottom: 1px solid var(--border); flex: none;
}
.drawer-head .titles { min-width: 0; }
.drawer-head .eyebrow {
  font-size: var(--t-xs); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted);
  font-weight: 700;
}
.drawer-head .close { margin-left: auto; flex: none; }
.drawer-body { padding: var(--s5); overflow-y: auto; flex: 1; }

/* THE CONFIRMATION. The controls in this console that change where a brand's money settles ask for the id
   to be typed, and this is the box that asks. */
dialog.modal, dialog.form-modal {
  margin: auto; width: min(520px, calc(100vw - 32px));
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg);
  box-shadow: var(--shadow-overlay); padding: var(--s5);
  animation: modal-in .14s ease;
}
@keyframes modal-in { from { opacity: 0; transform: scale(.98); } to { opacity: 1; transform: none; } }
dialog.modal h2, dialog.form-modal h2 { margin-bottom: var(--s2); font-size: var(--t-lg); }
dialog.modal .modal-actions, dialog.form-modal .modal-actions { display: flex; gap: var(--s2); justify-content: flex-end; margin-top: var(--s5); flex-wrap: wrap; }

/* THE FORM DIALOG. Wider than the confirmation above, because that one is sized for a sentence and two
   buttons while this one holds a field grid -- the merchant-account form has eight controls, and at
   520px they stack into a single column taller than a laptop viewport.

   max-height AND overflow-y ARE LOAD-BEARING, not tidiness. The base `dialog` rule three blocks up sets
   `max-height: none` to let the drawer be full height, so a dialog that outgrows the window here would
   simply extend past it -- with the submit button off the bottom edge and no way to scroll to it, since
   the page behind a modal <dialog> is inert. That is a form an operator can fill in and cannot send. */
dialog.form-modal {
  width: min(720px, calc(100vw - 32px));
  max-height: calc(100vh - 48px);
  overflow-y: auto;
}
.form-modal .field-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }

/* THE COMMAND PALETTE. Cmd/Ctrl+K: every destination, and every brand the console knows about, reachable
   without touching the mouse -- which is most of what an operator tool used all day is judged on. */
dialog.palette {
  margin: 10vh auto auto; width: min(560px, calc(100vw - 32px));
  background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r-lg);
  box-shadow: var(--shadow-overlay); overflow: hidden;
  animation: modal-in .14s ease;
}
.palette-input {
  width: 100%; height: 48px; padding: 0 var(--s4);
  border: 0; border-bottom: 1px solid var(--border); border-radius: 0;
  background: transparent; font-size: var(--t-lg);
}
.palette-input:focus { border-color: var(--border); box-shadow: none; }
.palette-list { list-style: none; margin: 0; padding: var(--s2); max-height: 320px; overflow-y: auto; }
.palette-list button {
  all: unset; box-sizing: border-box; display: flex; align-items: center; gap: var(--s3);
  width: 100%; padding: var(--s2) var(--s3); border-radius: var(--r-sm);
  font-size: var(--t-ui); cursor: pointer; color: var(--text);
}
.palette-list li[aria-selected="true"] button { background: var(--accent-subtle); color: var(--accent-text); }
.palette-list button .ico {
  width: 15px; height: 15px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round;
}
.palette-list button .group { margin-left: auto; font-size: var(--t-sm); color: var(--text-muted); }
.palette-empty { padding: var(--s5); text-align: center; color: var(--text-muted); font-size: var(--t-ui); }

/* ---- tabs -------------------------------------------------------------------------------------------------------
   A SEGMENTED CONTROL RATHER THAN AN UNDERLINE STRIP, taken from the mockup. On a page whose content is
   now boxed, an underline sitting on the page ground read as a stray rule; a bordered group reads as a
   control. aria-current="page" still marks the open one -- the same attribute the nav uses, so the two
   selected states remain one idea rather than two. */
.tabs {
  display: inline-flex; flex-wrap: wrap; margin-bottom: var(--s4);
  border: 1px solid var(--border); border-radius: var(--r-sm);
  background: var(--surface); box-shadow: var(--shadow-card); overflow: hidden;
}
.tabs a {
  padding: 0 var(--s4); height: var(--ctl-h);
  display: inline-flex; align-items: center;
  font-size: var(--t-ui); font-weight: 500;
  color: var(--text-secondary); border-right: 1px solid var(--border);
}
.tabs a:last-child { border-right: 0; }
.tabs a:hover { color: var(--text); background: var(--surface-hover); text-decoration: none; }
.tabs a[aria-current="page"] { background: var(--accent-subtle); color: var(--accent-text); font-weight: 600; }

/* THE SAME CONTROL DRIVEN BY A BUTTON, for a filter that changes no route.
   A scope filter is not navigation: there is no hash for it, nothing to link to and nothing to go BACK
   to, so it is a <button aria-pressed> and never an <a aria-current>. Sharing the appearance is the
   point -- an operator should not have to learn that two identical-looking strips behave differently --
   but sharing the MARKUP would have meant a link that goes nowhere, which is a lie to anything reading
   the page as a document. */
.tabs button {
  padding: 0 var(--s4); height: var(--ctl-h);
  display: inline-flex; align-items: center; gap: var(--s2);
  border: 0; border-right: 1px solid var(--border);
  background: transparent; color: var(--text-secondary);
  font: inherit; font-size: var(--t-ui); font-weight: 500; cursor: pointer; white-space: nowrap;
}
.tabs button:last-child { border-right: 0; }
.tabs button:hover { color: var(--text); background: var(--surface-hover); }
.tabs button[aria-pressed="true"] { background: var(--accent-subtle); color: var(--accent-text); font-weight: 600; }
/* The count rides inside the button, quieter than the label it qualifies. */
.tabs .count { font-size: var(--t-sm); font-variant-numeric: tabular-nums; color: var(--text-muted); }
.tabs button[aria-pressed="true"] .count { color: var(--accent-text); }

/* ---- definition rows ---------------------------------------------------------------------------------------------- */

dl.rows { display: grid; grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); gap: var(--s2) var(--s4); margin: 0; }
dl.rows dt { font-size: var(--t-ui); color: var(--text-muted); }
dl.rows dd { margin: 0; min-width: 0; font-size: var(--t-ui); overflow-wrap: anywhere; }

/* ---- utilities ---------------------------------------------------------------------------------------------------- */

.muted { color: var(--text-muted); }
.stack { display: flex; flex-direction: column; gap: var(--s3); }
.row { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }

/* THE CONTROLS IN A ROW'S ACTION CELL, AND THE WHOLE POINT IS THAT THEY DO NOT WRAP.
   `.row` wraps, which is right for a run of chips in a body cell and wrong here: an actions column is
   `td.shrink` (width: 1%), so the flex container is squeezed to its minimum and two buttons stack --
   which put brand-records back to ~90px rows on the very change that was meant to take them from ~80px
   to one line. `white-space: nowrap` on the td does not prevent it: the td is not what wraps, the flex
   container inside it is. Right-aligned to sit under `td.actions`'s own alignment. */
.row-actions {
  display: flex; align-items: center; gap: var(--s2);
  flex-wrap: nowrap; justify-content: flex-end;
}
.toolbar { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; margin-bottom: var(--s3); }
.toolbar .search { min-width: 200px; flex: 0 1 280px; }
.toolbar .spacer { flex: 1; }

/* The scope picker on the two 51-row grids, IN THE PAGE HEADING'S OWN ROW. Two things it must do: stay
   ONE ROW at any brand count -- the chip strip it replaces wrapped to two rows at nine brands and was
   measured at ~185px for thirty -- and cost no vertical space of its own, because the criterion it exists
   under is the distance from the heading to the first table row. The list lives in the browser's own
   dropdown, so nine brands and ninety take identical room. */
.scope-control form { display: flex; align-items: flex-end; gap: var(--s2); flex-wrap: wrap; }
.scope-control .field { flex: 0 1 260px; margin: 0; }
.scope-control .problem, .scope-control .note { margin-top: var(--s2); }
.field .hint { margin: var(--s1) 0 0; font-size: var(--t-sm); color: var(--text-muted); }

/* The overrides-only toggle, beside the search box it works with. A label wrapping its own checkbox, so
   the words are part of the hit target -- 51-row grids are read by people clicking quickly, and a 13px
   box is not a target. `nowrap` because the label carries a count and a wrap between the number and its
   noun is the same collapse hazard the button labels are written around. */
.toolbar .toggle {
  display: inline-flex; align-items: center; gap: var(--s1);
  white-space: nowrap; color: var(--text-secondary); cursor: pointer;
}
.toolbar .toggle:has(input:disabled) { color: var(--text-muted); cursor: default; }

/* What the rows add up to, above them. Reads as prose rather than as a control: it is the one line an
   operator can take away from the screen, and `--text` rather than muted because a muted summary is one
   people stop seeing. `text-wrap: pretty` keeps the tail of a long list off a line of its own. */
.grid-summary {
  margin: 0 0 var(--s3); color: var(--text); line-height: 1.5; text-wrap: pretty;
}

/* Clipped rather than display:none -- display:none takes it out of the accessibility tree as well as off
   the screen, which is the one thing a visually-hidden label must not do. No `position: absolute`:
   .table-scroll is not a positioned ancestor, so an absolutely-positioned span inside a grid resolves
   against the initial containing block, escapes the scroller and takes the document's scrollWidth with it
   -- the exact sideways-scroll failure .table-scroll exists to prevent. */
.sr-only {
  display: inline-block; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* ---- responsive --------------------------------------------------------------------------------------------------
   THE SIDEBAR BECOMES A SCROLLING TAB STRIP, one row at every width. The previous console wrapped its nav
   into a ragged three-row block at 390px -- 120px of chrome before any content. scroll-snap so an entry
   never rests half off the edge; the scrollbar is hidden because a bar under six tabs reads as a rendering
   fault, and every entry stays a real link in the tab order whether or not it is scrolled into view. */
@media (max-width: 900px) {
  .app { grid-template-columns: minmax(0, 1fr); }
  .sidebar {
    position: sticky; top: 0; z-index: 30; height: auto;
    flex-direction: row; align-items: center; gap: var(--s3);
    border-right: 0; border-bottom: 1px solid var(--border);
    padding: var(--s2) var(--s3); background: var(--surface);
    overflow-y: visible;
  }
  .brandmark { padding: 0; flex: none; }
  .brandmark .env { display: none; }
  .cmd-trigger { width: auto; margin: 0; flex: none; padding: 0 var(--s2); }
  .cmd-trigger .label { display: none; }
  .nav {
    flex-direction: row; gap: var(--s1); overflow-x: auto; flex: 1; min-width: 0;
    scroll-snap-type: x proximity; scrollbar-width: none;
  }
  .nav::-webkit-scrollbar { display: none; }
  .nav a { white-space: nowrap; scroll-snap-align: start; }
  .nav-label, .sidebar-foot { display: none; }
  .topbar { padding: 0 var(--s4); }
  .page { padding: var(--s4) var(--s4) var(--s6); }
  dialog.drawer { width: 100vw; }
}

@media (max-width: 560px) {
  :root { --t-display: 23px; --t-title: 19px; }
  .brandmark .name { display: none; }
  .who { display: none; }
  dl.rows { grid-template-columns: minmax(0, 1fr); gap: 2px; }
  dl.rows dd { margin-bottom: var(--s2); }
  .toaster { left: var(--s3); right: var(--s3); bottom: var(--s3); max-width: none; }
  .tabs { display: flex; width: 100%; }
  .tabs a { flex: 1; justify-content: center; padding: 0 var(--s2); }
}

/* ---- appearance controls -------------------------------------------------------------------------------------
   The two controls that set the theme, and the only two places in this console where a control is ABOUT the
   look of the console rather than about the platform. Both spend the same tokens as everything else, so they
   are themed by the five scopes at the top of this file exactly as every table is -- there is no theme-picker
   palette, which would be the one component able to look wrong in the theme it was selecting. */

/* The quick switch in the topbar. Four buttons in one bordered group, which is a segmented control by
   construction: the shared border is drawn once by the container and each button contributes a divider on
   its left, so there is no double hairline between two of them and no gap to mis-click into. */
.theme-switch {
  display: inline-flex; align-items: center;
  border: 1px solid var(--border); border-radius: var(--r-xs);
  background: var(--surface); overflow: hidden;
}
.theme-switch-btn {
  appearance: none; border: 0; border-left: 1px solid var(--border);
  background: transparent; color: var(--text-secondary);
  font: 500 var(--t-sm) / 1 inherit; font-family: inherit;
  height: var(--ctl-h-sm); padding: 0 var(--s3); cursor: pointer;
  transition: background-color .12s ease, color .12s ease;
}
.theme-switch-btn:first-child { border-left: 0; }
.theme-switch-btn:hover { background: var(--surface-hover); color: var(--text); }

/* THE CHOSEN ONE IS A FILL, AND THE WORD GOES WITH IT. Marking it with the accent as TEXT is the mistake
   the handoff calls out by name: an accent is a border and a fill colour, and a label in it is the one
   thing it is not light enough to be. So the ground carries the state and --accent-ink is what sits on it,
   which is a pair every theme scope defines together for exactly this. */
.theme-switch-btn[aria-pressed="true"] {
  background: var(--accent); color: var(--accent-ink); font-weight: 600;
}
.theme-switch-btn[aria-pressed="true"]:hover { background: var(--accent-hover); }

/* The radio rows on the Preferences screen. A row is the whole label, so the sentence is part of the
   target rather than something to read and then aim past. */
.choices { display: flex; flex-direction: column; gap: var(--s2); }

.choice {
  display: flex; gap: var(--s3); align-items: flex-start;
  padding: var(--s3); border: 1px solid var(--border); border-radius: var(--r-sm);
  background: var(--surface); cursor: pointer;
  transition: border-color .12s ease, background-color .12s ease;
}
.choice:hover { background: var(--surface-hover); }

/* THE CHOSEN ROW TAKES AN ACCENT BORDER AND ORDINARY INK, never accent-coloured text -- same rule as the
   switch above, and the reason the label goes to 600 instead: weight is the part of "selected" that stays
   legible when the colour cannot be. In high contrast the accent and the border are both black, so the
   weight is the whole signal, which is why it is not decoration there either. */
.choice.is-chosen { border-color: var(--accent); background: var(--accent-subtle); }
.choice.is-chosen .choice-label { font-weight: 600; }

/* THE RADIO ITSELF IS LEFT ALONE. It is an accent-coloured control the browser draws with the operating
   system's own focus and checked treatment, and every attempt to restyle one of these ends up
   reimplementing a control that was already accessible. `accent-color` is the one property that tints it
   without replacing it, and it is baseline in all four browsers this console is read in. */
.choice input[type="radio"] { margin-top: 2px; accent-color: var(--accent); flex: none; }

.choice-body { display: flex; flex-direction: column; gap: var(--s1); }
.choice-head { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.choice-label { font-size: var(--t-ui); color: var(--text); }
.choice-note { font-size: var(--t-sm); line-height: 1.5; color: var(--text-muted); }

/* ---- motion ------------------------------------------------------------------------------------------------------
   Everything above is a 120-160ms hover, focus or overlay entry, and none of it moves content on the page.
   The guard is here anyway, because the next person to add an animation will not read this paragraph first
   -- and the skeleton shimmer in particular is a CONTINUOUS animation, which is precisely what this setting
   exists to stop, so it is removed outright rather than merely shortened. */
/* TWO WAYS TO ASK, ONE SET OF RULES. The stored preference stamps data-reduced-motion="true" and the OS
   setting is honoured WHETHER OR NOT it is ticked -- which is why the media query is kept rather than folded
   into the app's resolution the way prefers-color-scheme was. Theme is a CHOICE the operator makes and the OS
   merely suggests; reduced motion is an accessibility need the OS ASSERTS, and an app that made it
   overridable would let a stored "false" put motion back in front of somebody who asked their system to stop
   it. So this one is a floor, not a default: ticking the box adds the rules, and unticking never removes
   them from an operator whose OS is already asking.

   The selector list is duplicated because CSS has no way to say "either of these" across a media query and an
   attribute. Keep the two arms identical -- they are one rule set expressed twice, not two behaviours. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
  .skeleton { animation: none; background: var(--surface-active); }
}

[data-reduced-motion="true"] *,
[data-reduced-motion="true"] *::before,
[data-reduced-motion="true"] *::after {
  animation-duration: .01ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: .01ms !important;
  scroll-behavior: auto !important;
}
[data-reduced-motion="true"] .skeleton { animation: none; background: var(--surface-active); }
