/* =========================================================================
   Northstar UI kit
   -------------------------------------------------------------------------
   Born in the billing hub (docs/DESIGN.md §12), adopted afterwards by NSBIZ,
   RMS, spm and the client portals. Other apps COPY a tagged version of this
   file plus the fonts and the Twig macros — no CDN, no symlink across repos.

   WHY ONE FILE. The probe that preceded this build found ~1,316 lines of CSS
   inline across 39 of NSBIZ's 139 templates, with the same badge vocabulary
   independently redefined in at least four places at different radii. The
   point of a kit is that there is one answer to "what does a status badge look
   like", and the way to keep it is one file nobody is tempted to work around.

   ORDER: tokens, density, reset, layout, components, utilities, responsive.
   The responsive block is LAST and deliberately in one place: a layout that
   stacks correctly is a property of the page, and chasing it through nine
   component-local media queries is how one of them gets missed.

   TWO DENSITIES FROM THE SAME PARTS. Everything sizeable reads a token.
   `.density-client` on <body> re-points those tokens and nothing else changes
   — no component has a client-specific rule. Under 900px BOTH densities grow
   every control to 44px, because a thumb is the same size on an admin screen.

   NO INLINE STYLES ANYWHERE IN THIS APP. That is not tidiness: the vhost sends
   `style-src 'self'` with no 'unsafe-inline', so an inline style attribute is
   silently dropped by the browser. Anything that looks like it needs one wants
   a utility class or a custom property set in a stylesheet.
   ========================================================================= */

/* ---------------------------------------------------------------- tokens */
:root {
  /* Palette. These eleven pairs are the whole of the STATUS palette; a twelfth
     status colour is a design decision, not a CSS edit.

     BILLING RUN 3 added one group below that is deliberately not part of it:
     the three ACH check colours, which name a place on a check rather than a
     state. That distinction is the reason they are a separate block with its
     own note instead of pairs twelve to fourteen. */
  --brand:        #0033A0;
  --brand50:      #EBF0FA;
  --brandhover:   #00287F;
  --brandlink:    #00256F;

  --ink:          #0A1733;
  --ink2:         #2B3A55;
  --muted:        #5F6B7F;   /* passes 4.5:1 on white and on --bg */

  --line:         #E3E7EF;
  --line2:        #EEF1F6;
  --bg:           #F7F8FA;
  --surface:      #FFFFFF;

  --okbg:         #E6F4EB;
  --ok:           #1C5A32;
  --badbg:        #FDE7E7;
  --bad:          #A02323;
  --warnbg:       #FDF1D3;
  --warn:         #6E4A00;
  --nbg:          #EEF0F5;
  --n:            #3F4A5C;

  /* Derived, from the Patterns mockup: the highlighted open accordion. */
  --openbg:       #F3F6FD;
  --openline:     #B7C7E8;

  /* ----------------------------------------------- the check's three colours
     BILLING RUN 3, item 7. The ONE place the kit carries a colour whose job is
     to point at a place on a piece of paper rather than to carry a status, so
     they are their own trio rather than a reuse of --ok / --brand / --warn.
     Reusing --ok for the routing field would have said "this field is correct"
     in a kit where green means exactly that, on a field the shopper has not
     filled in yet.

     THREE VALUES EACH, AND ALL FOUR CONTRASTS ARE ASSERTED, not eyeballed —
     bin/test-run3-l1.php §21 computes the WCAG ratios from these very
     declarations and fails if one slips:

       the text colour on --surface        >= 4.5:1
       the text colour on its own tint     >= 4.5:1
       --ink on the tint                   >= 4.5:1   (the digits the shopper
                                                       types sit on the tint)
       the line colour on white and tint   >= 3.0:1   (a UI component boundary)

     The orange line started at #C9792E and came back 2.97:1 against its own
     tint, which is the kind of miss an eye does not make reliably. */
  --achrouting:     #1B5E35;   /* 7.78:1 on white, 6.93:1 on its tint */
  --achroutingtint: #E8F5EC;
  --achroutingline: #4F9A6B;   /* 3.41:1 on white, 3.03:1 on the tint */
  --achaccount:     #13407F;   /* 10.16:1 on white, 8.69:1 on its tint */
  --achaccounttint: #E6EEFA;
  --achaccountline: #5A83C8;   /* 3.81:1 on white, 3.26:1 on the tint */
  --achcheck:       #8A4208;   /* 7.33:1 on white, 6.48:1 on its tint */
  --achchecktint:   #FCEFE0;
  --achcheckline:   #BC6D21;   /* 3.93:1 on white, 3.47:1 on the tint */

  /* Form chrome. The input border is darker than --line on purpose: a control
     you can type into has to read as more than a panel edge. */
  --inputline:    #CFD6E2;
  --focusring:    #9DB4E3;
  --dangerline:   #F1C3C3;

  /* The search box lives INSIDE the blue header bar, so it needs its own
     three values; nothing else on the page is on a blue ground. */
  --headerfield:  #1A47AC;
  --headerfieldline: #4A70C6;
  --headerplaceholder: #C9D6F2;
  --headerkbd:    #DCE5F7;

  --hover:        #FAFBFC;
  --sidehover:    #EDEFF3;

  --radius:       6px;
  --radiuspanel:  8px;
  --radiuspill:   999px;
  --shadowbtn:    0 1px 1px rgba(10, 23, 51, .04);
  --shadowmodal:  0 12px 40px rgba(10, 23, 51, .22);

  /* ----------------------------------------------------- admin density */
  --fontsize:     14px;
  --fontsizesm:   13px;
  --fontsizexs:   12px;
  --fontsizelabel:12px;
  --fontsizeh1:   20px;
  --fontsizeh2:   14px;
  --fontsizekpi:  22px;

  --controlheight:32px;
  --rowpad:       6px 10px;
  --panelpad:     10px 12px;
  --panelheadpad: 8px 12px;
  --gap:          12px;
  --gapsm:        8px;
  --gapxs:        6px;
  --gutter:       20px;
  --guttertop:    16px;
  --navheight:    32px;
  --sidewidth:    224px;
}

/* -------------------------------------------------------- client density */
/*
  Mobile-first and roomier. §12: body 15px, rows ~44px, controls 44px, panel
  padding 16px. ONLY TOKENS CHANGE — no component has a client-specific rule,
  which is the kit's central claim.
  
  THE SELECTOR IS `.density-client`, NOT `body.density-client`, and that
  difference is a real fix. Custom properties inherit, so a nested container
  carrying the class re-scales everything inside it — but only if the selector
  matches the element. Written as `body.density-client` it matched the sign-in
  page and silently did nothing on the style guide's own side-by-side demo,
  which rendered 32px "client density" controls next to 32px admin ones. The
  page that exists to demonstrate the claim was quietly refuting it.
  
  So the API is: put the class on `<body>` for a client surface, or on any
  container to re-scale a region.
*/
.density-client {
  --fontsize:     15px;
  --fontsizesm:   14px;
  --fontsizexs:   13px;
  --fontsizelabel:13px;
  --fontsizeh1:   22px;
  --fontsizeh2:   16px;
  --fontsizekpi:  24px;

  --controlheight:44px;
  --rowpad:       12px 14px;
  --panelpad:     16px;
  --panelheadpad: 14px 16px;
  --gap:          16px;
  --gapsm:        12px;
  --gapxs:        8px;
  --gutter:       16px;
  --guttertop:    16px;
  --navheight:    44px;
}

/* ----------------------------------------------------------------- fonts */
/* Inter 4.1, self-hosted, variable. See the SOURCE.md beside the file for the
   download URL and its SHA-256, verified against two independent sources.
   `font-display: swap` so a slow font never blanks the page. */
@font-face {
  font-family: 'InterVariable';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('/assets/vendor/inter/4.1/InterVariable.woff2') format('woff2');
}

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

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--surface);
  color: var(--ink);
  font-family: 'InterVariable', Inter, system-ui, -apple-system, 'Segoe UI', sans-serif;
  font-size: var(--fontsize);
  line-height: 1.4;
  /* cv11 is the single-storey 'a'; ss01 the alternate digits. Both are Inter
     features the mockups asked for. */
  font-feature-settings: "cv11", "ss01";
}

h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
h1 { font-size: var(--fontsizeh1); }
h2 { font-size: var(--fontsizeh2); }
h3 { font-size: var(--fontsizesm); }

p { margin: 0 0 var(--gapsm); }

a { color: var(--brand); text-decoration: none; }
a:hover { color: var(--brandlink); text-decoration: underline; }

/* A visible focus ring on every interactive element, always. Removing it is
   the most common accessibility regression in a dense admin UI and the one
   nobody notices until they are navigating by keyboard. */
:focus-visible {
  outline: 2px solid var(--focusring);
  outline-offset: 1px;
}

/* Screen-reader-only. Used for table action-column headers, which must exist
   for the column to be announced and must not take horizontal space. */
.nsvisuallyhidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* x-cloak hides Alpine-managed markup until Alpine has initialised, so a
   closed accordion does not flash open on load. If Alpine fails to load this
   would hide content permanently — bin/check-no-cdn.php exists partly to
   catch the vendored path 404ing, which is how that happens. */
[x-cloak] { display: none !important; }

/* ---------------------------------------------------------------- layout */
.nsshell {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  background: var(--surface);
}

/* Full-width header bar in --brand. */
.nsheader {
  background: var(--brand);
  color: #FFFFFF;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px 20px;
  padding: 10px var(--gutter);
}
.nsheaderbrand {
  flex: 0 0 204px;
  display: flex;
  align-items: center;
  gap: 9px;
  color: #FFFFFF;
}
.nsheaderbrand:hover { color: #FFFFFF; text-decoration: none; }
.nsheaderbrand svg { flex: 0 0 auto; }
.nsheaderbrandname { font-weight: 700; font-size: 15px; letter-spacing: -.01em; }

.nsmodepill {
  background: var(--warnbg);
  color: var(--warn);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .05em;
  padding: 2px 8px;
  border-radius: var(--radiuspill);
  white-space: nowrap;
}

.nsheadersearch {
  position: relative;
  flex: 1 1 320px;
  max-width: 560px;
  min-width: 0;
}
.nsheadersearch .nssearchicon {
  position: absolute;
  left: 10px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--headerplaceholder);
  pointer-events: none;
}
.nsheadersearch input {
  font: inherit;
  font-size: var(--fontsizesm);
  width: 100%;
  height: var(--controlheight);
  padding: 0 44px 0 32px;
  border: 1px solid var(--headerfieldline);
  border-radius: var(--radius);
  background: var(--headerfield);
  color: #FFFFFF;
}
.nsheadersearch input::placeholder { color: var(--headerplaceholder); }
.nsheaderkbd {
  position: absolute;
  right: 8px;
  top: 50%;
  transform: translateY(-50%);
  font: inherit;
  font-size: 11px;
  background: var(--brand);
  border: 1px solid var(--headerfieldline);
  border-radius: 4px;
  padding: 0 5px;
  color: var(--headerkbd);
}

.nsheaderend {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 14px;
}
.nsheaderlink { font-size: var(--fontsizesm); color: #FFFFFF; }
.nsheaderlink:hover { color: #FFFFFF; }

.nsavatar {
  font: inherit;
  font-size: var(--fontsizexs);
  font-weight: 700;
  width: var(--controlheight);
  height: var(--controlheight);
  border-radius: var(--radiuspill);
  border: 0;
  background: var(--headerfield);
  color: #FFFFFF;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.nsbody { display: flex; flex-wrap: wrap; flex: 1; }

/* White sidebar with a 1px separating line. */
.nsside {
  flex: 0 0 var(--sidewidth);
  max-width: var(--sidewidth);
  /* min-width: 0 IS LOAD-BEARING, and leaving it out broke every screen.
     A flex item's automatic minimum size (`min-width: auto`) resolves to its
     MIN-CONTENT width, so this aside refused to shrink below the width of its
     own nav. Under 900px the nav lays out horizontally — about 1,316px of
     items — so the aside stayed 1,316px wide inside a 390px viewport and the
     whole page scrolled sideways. The `overflow-x: auto` on the nav cannot
     help while its parent is being sized by it. */
  min-width: 0;
  background: var(--surface);
  border-right: 1px solid var(--line);
  display: flex;
  flex-direction: column;
}
.nssidenav {
  display: flex;
  flex-direction: column;
  gap: 1px;
  padding: var(--gapsm);
}
.nssidenav a {
  display: flex;
  align-items: center;
  gap: 10px;
  height: var(--navheight);
  padding: 0 10px;
  border-radius: var(--radius);
  color: var(--ink2);
  font-size: 13.5px;
  font-weight: 500;
  white-space: nowrap;
}
.nssidenav a:hover { background: var(--sidehover); color: var(--ink); text-decoration: none; }
.nssidenav a.nson { background: var(--brand50); color: var(--brand); }
.nssidenav a svg { flex: 0 0 auto; color: currentColor; opacity: .85; }

.nsnavgroup {
  font-size: 11px;
  font-weight: 600;
  color: var(--muted);
  letter-spacing: .06em;
  text-transform: uppercase;
  padding: 14px 10px 6px;
}

.nsmainwrap { flex: 999 1 560px; min-width: 0; display: flex; flex-direction: column; }
.nsmain {
  padding: var(--guttertop) var(--gutter) 24px;
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  max-width: 1600px;
  width: 100%;
}
/*
  WITHOUT THIS, EVERY WIDE TABLE DRAGS THE WHOLE PAGE SIDEWAYS, and the
  `overflow-x: auto` on .nstablebox never gets a chance to do anything.

  .nsmain is a COLUMN flex container, so the width of a child is its CROSS
  size. A single-line flex container's line takes the largest hypothetical
  cross size of its items — so a child containing a 1,000px min-content table
  widens the line to 1,000px, and then every stretched sibling is 1,000px too.
  The table box is handed 1,000px of room and has nothing to scroll.

  Measured: /admin/invoices at 390px scrolled 610px sideways, with no single
  element reporting an unclipped overflow — because nothing WAS overflowing.
  The container had simply grown.

  `max-width: 100%` caps each item's hypothetical cross size at the container's
  inner width, so the line cannot grow; `min-width: 0` then lets the item
  actually shrink to it. Both are needed and neither is enough alone.
*/
.nsmain > * {
  max-width: 100%;
  min-width: 0;
}

/* ------------------------------------------------------------ page header */
.nsbreadcrumb { font-size: 12.5px; }
.nsbreadcrumb .nssep { color: var(--muted); }

.nspagehead {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 10px;
}
.nspageheadmain {
  flex: 1 1 360px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.nspagetitlerow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gapsm);
}
.nspagemeta { color: var(--muted); font-size: var(--fontsizesm); }
.nspageactions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gapxs);
  align-items: center;
}
.nspageactions.nspushright { margin-left: auto; }

/* --------------------------------------------------------------- buttons */
.nsbtn {
  font: inherit;
  font-size: var(--fontsizesm);
  font-weight: 600;
  height: var(--controlheight);
  padding: 0 12px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--gapxs);
  cursor: pointer;
  box-shadow: var(--shadowbtn);
  text-decoration: none;
  white-space: nowrap;
}
.nsbtn:hover { background: var(--bg); text-decoration: none; color: var(--ink); }
.nsbtn svg { flex: 0 0 auto; }

.nsbtn.nsprimary { background: var(--brand); border-color: var(--brand); color: #FFFFFF; }
.nsbtn.nsprimary:hover { background: var(--brandhover); color: #FFFFFF; }

.nsbtn.nsghost { border-color: transparent; background: transparent; color: var(--brand); box-shadow: none; }
.nsbtn.nsghost:hover { background: var(--brand50); }

.nsbtn.nsdanger { color: var(--bad); border-color: var(--dangerline); }
.nsbtn.nsdanger:hover { background: var(--badbg); color: var(--bad); }

.nsbtn.nsicon { width: var(--controlheight); padding: 0; color: var(--muted); }
.nsbtn.nsiconbare { border-color: transparent; box-shadow: none; }

/* A disabled control must LOOK unavailable and must not respond to hover.
   The Run-1 screens lean on this heavily — most actions are present and
   disabled with a "Available in Run 3" title — so it has to read as
   deliberate rather than broken. */
.nsbtn[disabled], .nsbtn[aria-disabled="true"] {
  opacity: .5;
  cursor: not-allowed;
  box-shadow: none;
}
.nsbtn[disabled]:hover, .nsbtn[aria-disabled="true"]:hover { background: var(--surface); }
.nsbtn.nsprimary[disabled]:hover { background: var(--brand); }

/* Text-link action, for minor actions inside a panel header or a row. */
.nslink {
  font: inherit;
  font-size: var(--fontsizesm);
  font-weight: 500;
  color: var(--brand);
  background: transparent;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-align: left;
}
.nslink:hover { text-decoration: underline; }
.nslink[disabled] { color: var(--muted); cursor: not-allowed; text-decoration: none; }

/* ------------------------------------------------------- segmented control */
.nsseg {
  display: inline-flex;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  height: var(--controlheight);
}
/*
  BUTTONS **AND** LINKS, as of BILLING RUN 3.

  A segmented control whose segments change a CLIENT-side filter is a row of
  buttons; one that changes a SERVER-side filter has to be a row of links, or
  middle-click, Ctrl-click and "copy link" stop working and the filtered list
  has no URL. Run 3 moved the list screens' filtering into SQL, so both shapes
  now exist and both have to look identical — hence one rule for the two
  selectors rather than a second class.
*/
.nsseg button, .nsseg a {
  font: inherit;
  font-size: var(--fontsizesm);
  font-weight: 500;
  border: 0;
  border-right: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink2);
  padding: 0 11px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  white-space: nowrap;
}
.nsseg button:last-child, .nsseg a:last-child { border-right: 0; }
.nsseg button.nson, .nsseg a.nson { background: var(--brand50); color: var(--brand); font-weight: 600; }
.nsseg a:hover { background: var(--bg); color: var(--ink); text-decoration: none; }
.nsseg a.nson:hover { background: var(--brand50); color: var(--brand); }

/* ------------------------------------------------------------------ tabs */
/*
  TABS THAT LOOK LIKE TABS (BILLING FIX-2026-11-09-B, §A2).

  🔴 WHAT THEY LOOKED LIKE BEFORE, AND WHY THAT WAS THE PROBLEM. The selected
  tab got `background: var(--nbg)` and 600 weight — a faint grey pill among
  grey text. On the invoice list, with seven tabs and a search box on the same
  line, Troy could not tell which one was active without reading the URL. A
  tab bar's entire job is to say where you are.

  SO THE ACTIVE TAB NOW HAS A 2px `--brand` UNDERLINE. That is the one
  affordance everybody already reads as "tabs", it survives being next to a
  segmented control (which keeps the pill, so the two stop looking alike), and
  it needs no colour on the text to carry the meaning — the darker ink is
  reinforcement, not the signal. `border-bottom` on every tab, transparent
  until active, so nothing moves by 2px when the selection changes.

  COUNTS GO IN A PILL. A bare number after a word reads as part of the label
  ("Open 3"); a pill makes it a quantity. Grey normally, `--brand50` with brand
  ink when the tab is active, which is what keeps the active tab legible as one
  object rather than a word plus an unrelated badge.

  BUTTONS **AND** LINKS, like `.nsseg` — see its note. A tab that changes a
  client-side filter is a button; one that changes a SERVER-side filter has to
  be a link, or the filtered list has no URL and middle-click is broken. Run 3
  moved the list screens' status tabs into SQL, so both shapes exist and must
  look identical — which is now enforced by both selectors carrying every rule
  below, including the underline.
*/
.nstabs {
  display: flex;
  gap: 2px;
  flex-wrap: wrap;
  /* The rail the underlines sit on, so a one-tab bar still reads as a tab bar
     and the active underline does not float in space. */
  border-bottom: 1px solid var(--line);
}
.nstabs button, .nstabs a {
  font: inherit;
  font-size: var(--fontsizesm);
  font-weight: 500;
  color: var(--muted);
  background: transparent;
  border: 0;
  /* Transparent until active: the box never changes size, so selecting a tab
     does not reflow the row it is in. */
  border-bottom: 2px solid transparent;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  /* Square at the bottom — a radius would cut the corners off the underline. */
  border-radius: var(--radius) var(--radius) 0 0;
  padding: 5px 9px;
  min-height: 26px;
  /* The 1px rail above is overlapped by the 2px underline, so the active tab's
     line sits ON the rail rather than under it. */
  margin-bottom: -1px;
  cursor: pointer;
}
.nstabs button:hover, .nstabs a:hover {
  color: var(--ink);
  text-decoration: none;
  /* A hover tint, so an inactive tab announces that it is a control. */
  background: var(--bg);
}
.nstabs button.nson, .nstabs a.nson {
  background: transparent;
  color: var(--ink);
  font-weight: 600;
  border-bottom-color: var(--brand);
}
.nstabs button.nson:hover, .nstabs a.nson:hover { background: transparent; }
/*
  THE COUNT PILL. `min-width` plus centring so "1" and "12" are the same shape
  and a column of tabs does not jitter as counts change; tabular numerals for
  the same reason.
*/
.nstabcount {
  font-size: var(--fontsizexs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
  background: var(--nbg);
  border-radius: 999px;
  padding: 1px 6px;
  min-width: 18px;
  text-align: center;
  line-height: 1.5;
}
.nstabs button.nson .nstabcount, .nstabs a.nson .nstabcount {
  background: var(--brand50);
  color: var(--brand);
}

/* ----------------------------------------------------------------- panel */
.nspanel {
  border: 1px solid var(--line);
  border-radius: var(--radiuspanel);
  background: var(--surface);
  min-width: 0;
}
/* Panels are SEPARATE and outlined, not joined into one card. §12. */
.nspanelhead {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: var(--panelheadpad);
  border-bottom: 1px solid var(--line);
  min-height: 24px;
}
.nspanelhead.nswrap { flex-wrap: wrap; }
.nspanelbody { padding: var(--panelpad); }
.nspanelfoot {
  padding: var(--panelheadpad);
  border-top: 1px solid var(--line);
  display: flex;
  gap: var(--gapxs);
  align-items: center;
}
.nspushright { margin-left: auto; }
/* An overflow-hidden panel clips a table's own corners to the panel radius.
   Used where a table is the panel's only child. */
.nspanel.nsclip { overflow: hidden; }

/* ------------------------------------------------------------ stat cards */
/* Individually outlined, in a grid with an 8px gap. §12. */
.nsstats {
  display: grid;
  grid-template-columns: repeat(var(--statcolumns, 5), minmax(0, 1fr));
  gap: var(--gapsm);
}
.nsstats.nsstats4 { --statcolumns: 4; }
.nsstats.nsstats3 { --statcolumns: 3; }
.nsstats.nsstats2 { --statcolumns: 2; }

.nsstat {
  padding: var(--panelpad);
  border: 1px solid var(--line);
  border-radius: var(--radiuspanel);
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.nsstatlabel { font-size: 12.5px; color: var(--muted); font-weight: 500; }
.nsstatvaluerow {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--gapsm);
  /* The sparkline wraps under the figure rather than pushing it out of the
     card. At 390px two cards share the row, so a card is about 187px — and
     "$4,365.00" at 22px plus a 72px sparkline does not fit. It overflowed the
     page by exactly 10px before this. Wrapping keeps the trend visible;
     hiding it would have been the other reasonable call. */
  flex-wrap: wrap;
}
.nsstatvalue {
  font-size: var(--fontsizekpi);
  font-weight: 600;
  letter-spacing: -.01em;
  font-variant-numeric: tabular-nums;
}
.nsstatvalue.nsbadvalue { color: var(--bad); }
.nsstatsub { font-size: var(--fontsizexs); color: var(--muted); }
.nsstatdelta { font-weight: 600; }
.nsstatdelta.nsup { color: var(--ok); }
.nsstatdelta.nsdown { color: var(--bad); }
/* Shrinkable and capped, so it can never be the thing that widens a card.
   The SVG has a viewBox, so it scales rather than clipping. */
.nssparkline { flex: 0 1 auto; max-width: 100%; color: var(--brand); }

/* ------------------------------------------------------------ data table */
/*
  THE TABLE'S SCROLL BOX.

  🔴 `contain: paint` STAYS, AND BILLING FIX-2026-11-09-B TRIED TO REMOVE IT
  AND MEASURED THAT IT COULD NOT.

  That build needed the row menu to escape this box (§A5), and the containment
  is what clips it: `contain: paint` makes an element a containing block for
  fixed-position descendants and then clips them, while `overflow` alone does
  NOT — a fixed element's containing block is the viewport, and clipping
  follows the containing-block chain. So the obvious move was to drop the one
  line and let `.nsmain > { max-width: 100%; min-width: 0 }` — which arrived
  after Run 1's note below and fixes the widening at its real cause — carry it
  alone.

  IT DOES NOT. Measured on this box, at 1024px, with the current stylesheet:

      .nstablebox          overflow-x: auto, 758px wide, scrollWidth 980
      the <table> inside   980px wide, right edge at x = 1225
      window.scrollTo(9999, 0)   moves the document 191px

  — a working scroll container, with the page panning anyway, which is Run 1's
  measurement reproduced three builds later. Then, one property at a time:

      .nstablebox { overflow-x: clip }        191px   no change
      html / body / .nsshell / .nsbody /
        .nsmain / .nspanel { overflow-x: clip }
                                              191px   no change, all six
      .nstablebox { contain: paint }            0px   fixed
      .nstablebox { contain: layout }           0px   fixed
      .nspanel    { contain: paint }            0px   fixed
      table.nstable { table-layout: fixed }     0px   fixed
      table.nstable { display: none }           0px   (proves the source)

  So the table's layout overflow inflates the VIEWPORT's scrollable area past
  every ancestor that clips it, and only containment — or taking the table's
  intrinsic width away — stops it. `table-layout: fixed` would mean equal
  columns on every table in the hub, which is a visual change to pay for a
  scroll bug; `contain: layout` clips fixed descendants exactly as `paint`
  does. There is no option here that both fixes the pan and lets a fixed
  descendant out.

  🔴 WHICH IS WHY THE ROW MENU IS A `[popover]` AND NOT A FIXED `<div>`. The
  TOP LAYER is not affected by ancestor containment at all — verified the same
  way, with `contain: paint` on this box and a `popover` inside it: its rect
  sat 200px below the box's bottom edge and `elementFromPoint()` returned it,
  so it was painted and hit-testable outside the contained box, while the page
  pan stayed at 0. See `.nsmenupanel` below.

  ---- Run 1's measurements, kept, because they are why this box exists ----
  
  `overflow-x: auto` makes this a scroll container and the table is visually
  clipped by it — but on Chromium the ROOT's scrollable overflow area was still
  being inflated by the table's layout overflow, so the whole PAGE panned
  sideways. Measured on /admin/invoices: at 1024px the box was 758px wide with
  a 981px table inside it and `scrollLeft` reaching 223 (a working scroll
  container), while `window.scrollTo(9999, 0)` still moved the document 192px.
  
  What was ruled out, in order, each measured rather than assumed:
  `overflow-x: hidden` on this box — no change. `overflow-x: clip` — no change.
  `width: 100%` and `max-width: 100%` on this box — no change.
  `width: max-content` on the table — no change. `display: flow-root` on the
  panel — no change. Hiding the 13 absolutely-positioned row menus — no change.
  Hiding the table — fixed, which is what proved the table was the source.
  `contain: paint` — fixed.
  
  So this is not a substitute for the overflow property; both are needed. The
  containment is also harmless to everything else here: this box has no
  fixed-position descendants, and the one absolutely-positioned thing inside it
  (the row menu) was already clipped by the scroll container.
*/
.nstablebox {
  overflow-x: auto;
  /* Both are needed. See the note above for the measurement, and for why the
     row menu had to leave the normal flow rather than this line leave. */
  contain: paint;
}
.nstable { width: 100%; border-collapse: collapse; font-size: var(--fontsizesm); }
.nstable th {
  background: var(--bg);
  text-align: left;
  font-size: var(--fontsizexs);
  font-weight: 600;
  color: var(--muted);
  padding: var(--rowpad);
  border-bottom: 1px solid var(--line);
  white-space: nowrap;
}
.nstable td {
  padding: var(--rowpad);
  border-bottom: 1px solid var(--line2);
  white-space: nowrap;
}
.nstable tbody tr:hover { background: var(--hover); }
.nstable tbody tr:last-child td { border-bottom: 0; }
.nstable tbody tr.nsselected { background: var(--openbg); }
.nstable .nsnumeric { text-align: right; font-variant-numeric: tabular-nums; }
.nstable .nscheckcol { width: 24px; }
.nstable .nsactioncol { width: 36px; text-align: right; }
.nstable td.nstruncate { max-width: 220px; }

.nscheckbox {
  width: 15px;
  height: 15px;
  margin: 0;
  accent-color: var(--brand);
  vertical-align: middle;
}

.nsbulkbar {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--gapxs);
}
.nsbulkcount { font-weight: 600; }
.nstablemeta { margin-left: auto; color: var(--muted); }

/* ---------------------------------------------------------- status badge */
.nsbadge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: var(--fontsizexs);
  font-weight: 600;
  padding: 1px 7px;
  border-radius: 5px;
  line-height: 18px;
  white-space: nowrap;
}
/* The dot is a ::before so the badge stays one element in the markup and the
   dot can never be forgotten at a call site. */
.nsbadge::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: var(--radiuspill);
  background: currentColor;
  opacity: .8;
  flex: 0 0 auto;
}
.nsbadge.nstoneok   { background: var(--okbg);   color: var(--ok); }
.nsbadge.nstoneopen { background: var(--brand50);color: var(--brand); }
.nsbadge.nstonebad  { background: var(--badbg);  color: var(--bad); }
.nsbadge.nstonewarn { background: var(--warnbg); color: var(--warn); }
.nsbadge.nstonen    { background: var(--nbg);    color: var(--n); }
/* A non-status note wearing badge chrome — "Sample data". Lighter weight so it
   does not read as a state the record is in. */
.nsbadge.nsnote { font-weight: 500; }
.nsbadge.nsnodot::before { display: none; }

/* --------------------------------------------------------------- app tag */
.nstag {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .04em;
  color: var(--n);
  background: var(--nbg);
  border-radius: 4px;
  padding: 1px 5px;
  white-space: nowrap;
}

/* ------------------------------------------------------------ detail list */
.nsdetaillist {
  display: grid;
  grid-template-columns: var(--detaillabelwidth, 120px) minmax(0, 1fr);
  margin: 0;
  font-size: var(--fontsizesm);
}
.nsdetaillist dt, .nsdetaillist dd {
  margin: 0;
  padding: var(--rowpad);
  border-top: 1px solid var(--line2);
  min-width: 0;
}
.nsdetaillist dt { color: var(--muted); }
.nsdetaillist dt:first-of-type, .nsdetaillist dt:first-of-type + dd { border-top: 0; }

/* -------------------------------------------------------------- row list */
/* The one-line row used for "Needs attention" and recent payments. */
.nsrow {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 12px;
  border-bottom: 1px solid var(--line2);
  font-size: var(--fontsizesm);
  min-width: 0;
}
.nsrow:last-child { border-bottom: 0; }
/*
  `flex: 1 1 0`, NOT `1 1 auto`. With an `auto` basis the item's base size is
  its content width — and the content here is `white-space: nowrap` text, so in
  a wrapping flex row the browser puts it on a line of its own BEFORE it
  considers shrinking it. The dashboard's webhook-health line came out as three
  stacked rows: a lone dot, the sentence, and the link. A zero basis lets it
  take the space that is left and ellipsise, which is what `min-width: 0` and
  `.nsellipsis` are for.
*/
.nsrowgrow { flex: 1 1 0; min-width: 0; }
.nsrowstack { display: flex; flex-direction: column; min-width: 0; }
.nsrowend { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.nsrowtime { flex: 0 0 110px; font-size: var(--fontsizexs); }

.nsdot { width: 8px; height: 8px; border-radius: var(--radiuspill); flex: 0 0 auto; }
.nsdot.nstoneok   { background: #1C8740; }
.nsdot.nstonebad  { background: #C02B2B; }
.nsdot.nstonewarn { background: #C08A00; }
.nsdot.nstonen    { background: var(--n); }
.nsdot.nstoneopen { background: var(--brand); }

/* --------------------------------------------------------- two-line list */
.nstwoline {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 7px 12px;
  border-top: 1px solid var(--line2);
}
.nstwoline:first-child { border-top: 0; }
.nstwoline:hover { background: var(--hover); }
.nstwolinebody { flex: 1 1 auto; min-width: 0; }
.nstwolinesub { color: var(--muted); font-size: var(--fontsizexs); }
.nstwolineage { color: var(--muted); font-size: var(--fontsizexs); white-space: nowrap; }

.nsavatarsm {
  width: 26px;
  height: 26px;
  border-radius: var(--radiuspill);
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
}
.nsavatarsm.nstoneok   { background: var(--okbg);   color: var(--ok); }
.nsavatarsm.nstonebad  { background: var(--badbg);  color: var(--bad); }
.nsavatarsm.nstonewarn { background: var(--warnbg); color: var(--warn); }
.nsavatarsm.nstoneopen { background: var(--brand50);color: var(--brand); }
.nsavatarsm.nstonen    { background: var(--nbg);    color: var(--n); }

/* ---------------------------------------------------------------- fields */
.nsfield { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.nsfield label, .nslabel {
  font-size: var(--fontsizelabel);
  font-weight: 600;
  color: var(--ink2);
}
.nsfieldhint { font-size: var(--fontsizexs); color: var(--muted); }

.nsinput {
  font: inherit;
  font-size: var(--fontsizesm);
  height: var(--controlheight);
  padding: 0 8px;
  border: 1px solid var(--inputline);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--ink);
  width: 100%;
  min-width: 0;
}
.nsinput:focus {
  outline: 2px solid var(--focusring);
  outline-offset: 0;
  border-color: var(--brand);
}
.nsinput[disabled] { background: var(--bg); color: var(--muted); cursor: not-allowed; }
.nsinput.nsnumeric { text-align: right; font-variant-numeric: tabular-nums; }
textarea.nsinput { height: auto; padding: 6px 8px; resize: vertical; line-height: 1.45; }
select.nsinput { padding-right: 4px; }

.nsfieldrow { display: flex; align-items: center; justify-content: space-between; gap: var(--gap); }

.nscheckrow {
  display: flex;
  gap: var(--gapsm);
  align-items: flex-start;
  padding: var(--panelheadpad);
  border-top: 1px solid var(--line2);
  cursor: pointer;
  font-size: var(--fontsizesm);
}
.nscheckrow:first-of-type { border-top: 0; }
.nscheckrow input { width: 15px; height: 15px; margin: 2px 0 0; accent-color: var(--brand); flex: 0 0 auto; }

/* ---------------------------------------------------------------- switch */
.nsswitch {
  width: 32px;
  height: 18px;
  border-radius: var(--radiuspill);
  background: #B9C1CF;
  border: 0;
  position: relative;
  cursor: pointer;
  padding: 0;
  flex: 0 0 auto;
}
.nsswitch::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 14px;
  height: 14px;
  border-radius: var(--radiuspill);
  background: #FFFFFF;
  transition: left .12s;
}
.nsswitch.nson { background: var(--brand); }
.nsswitch.nson::after { left: 16px; }
.nsswitch[disabled] { opacity: .55; cursor: not-allowed; }

/* A switch that POSTS: the hidden input plus the button, in one scope.
   `display: contents` so the wrapper changes no layout — the button keeps
   sitting exactly where a bare `.nsswitch` did, which is what lets
   `ui.switchfield()` replace `ui.switch()` inside `.nssettingrow` without a
   single measurement moving. See the macro for why the wrapper exists. */
.nsswitchfield { display: contents; }

/* A settings row that is an editable FIELD rather than a label and a value.
   The input is right-aligned to the column the read-only `.nssettingvalue`
   occupied, so an accordion section reads the same whether it is being edited
   or looked at. */
.nssettingrow .nssettinginput { width: 180px; max-width: 45%; text-align: right; }
.nssettingrow .nssettinginputwide { width: 260px; max-width: 60%; }
.nssettingsave {
  display: flex;
  align-items: center;
  gap: var(--gap);
  padding: var(--panelpad);
  border-top: 1px solid var(--line2);
}
.nssettingsave .nssettinghint { color: var(--muted); font-size: var(--fontsizesm); }

@media (max-width: 900px) {
  /* Below 900px everything stacks and every control grows to 44px (§12), so a
     fixed-width right-aligned input would be a 180px box beside a wrapped
     label. Full width, left aligned, like every other stacked field. */
  .nssettingrow .nssettinginput,
  .nssettingrow .nssettinginputwide { width: 100%; max-width: none; text-align: left; }
}

/* ------------------------------------------------------ settings accordion */
/* Facebook style: the OPEN section is highlighted with a blue tint and an
   inset outline, a solid brand icon and a brand-coloured title. The outline is
   `box-shadow: inset` rather than a border so opening a section does not shift
   the rows below it by 2px. */
.nsaccordion { }
.nsaccitem { border-bottom: 1px solid var(--line); }
.nsaccitem:last-child { border-bottom: 0; }
.nsaccitem.nsopen { background: var(--openbg); box-shadow: inset 0 0 0 1px var(--openline); }

.nsacchead {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: var(--panelheadpad);
  background: transparent;
  border: 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
  color: var(--ink);
}
.nsacchead:hover { background: var(--hover); }
.nsaccitem.nsopen .nsacchead:hover { background: transparent; }

.nsaccicon {
  width: 28px;
  height: 28px;
  border-radius: var(--radius);
  background: var(--nbg);
  color: var(--n);
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
}
.nsaccitem.nsopen .nsaccicon { background: var(--brand); color: #FFFFFF; }

.nsacctext { flex: 1 1 auto; display: flex; flex-direction: column; min-width: 0; }
.nsacctitle { font-weight: 600; font-size: 13.5px; }
.nsaccitem.nsopen .nsacctitle { color: var(--brand); }
.nsaccsub { font-size: var(--fontsizexs); color: var(--muted); }
.nsaccchevron { color: var(--muted); flex: 0 0 auto; transition: transform .12s; }
.nsaccitem.nsopen .nsaccchevron { transform: rotate(180deg); }

.nsaccbody { padding: 0 12px 10px 50px; }
.nssettingrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap);
  padding: 6px 0;
  border-top: 1px solid #E1E7F3;
}
.nssettingrow:first-child { border-top: 0; }
.nssettingvalue { font-variant-numeric: tabular-nums; }

/* ----------------------------------------------------------- empty state */
.nsempty {
  padding: 32px 16px;
  text-align: center;
  color: var(--muted);
  font-size: var(--fontsizesm);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--gapsm);
}
.nsemptyicon { color: var(--line); }
.nsemptytitle { font-weight: 600; color: var(--ink); font-size: var(--fontsize); }
.nsemptybody { max-width: 46ch; }

/* ------------------------------------------------------------ flash/toast */
.nsflashes {
  display: flex;
  flex-direction: column;
  gap: var(--gapsm);
}
.nsflash {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: var(--panelpad);
  border: 1px solid var(--line);
  border-radius: var(--radiuspanel);
  font-size: var(--fontsizesm);
}
.nsflash.nstoneok   { background: var(--okbg);   border-color: #BFE0CB; color: var(--ok); }
.nsflash.nstonebad  { background: var(--badbg);  border-color: var(--dangerline); color: var(--bad); }
.nsflash.nstonewarn { background: var(--warnbg); border-color: #EBD69B; color: var(--warn); }
.nsflash.nstoneopen { background: var(--brand50);border-color: var(--openline); color: var(--brand); }
.nsflashbody { flex: 1 1 auto; min-width: 0; }
.nsflashclose {
  background: transparent;
  border: 0;
  color: inherit;
  cursor: pointer;
  padding: 0;
  opacity: .7;
  display: flex;
}
.nsflashclose:hover { opacity: 1; }

/* ----------------------------------------------------------------- modal */
/* <dialog>, so focus trapping, Escape and the top layer come from the
   platform rather than from JavaScript we would have to maintain. */
/*
  `display: flex` IS ON `[open]` AND MUST STAY THERE.

  The UA stylesheet closes a dialog with `dialog:not([open]) { display: none }`.
  An unqualified `.nsmodal { display: flex }` overrides that, so a CLOSED
  dialog renders — as an inert flex box sitting in the page, with its heading,
  its body and its buttons all visible and none of them doing anything. The
  style guide showed the "Void this invoice?" modal permanently, and Escape
  appeared not to work because the dialog really had closed and was still on
  screen.

  The flex column itself is load-bearing for a different reason: a long body
  has to scroll while the footer stays put. Without `min-height: 0` on the
  body, overflowing content pushes the footer — with the save button on it —
  off the bottom of the viewport.
*/
.nsmodal[open] {
  display: flex;
  flex-direction: column;
}
.nsmodal {
  border: 1px solid var(--line);
  border-radius: var(--radiuspanel);
  padding: 0;
  background: var(--surface);
  color: var(--ink);
  box-shadow: var(--shadowmodal);
  width: min(560px, calc(100vw - 32px));
  max-height: min(80vh, 720px);
  overflow: hidden;
}
.nsmodal::backdrop { background: rgba(10, 23, 51, .45); }
.nsmodal form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.nsmodalhead {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: var(--panelheadpad);
  border-bottom: 1px solid var(--line);
}
.nsmodalbody { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--panelpad); }
.nsmodalfoot {
  flex: 0 0 auto;
  display: flex;
  gap: var(--gapxs);
  justify-content: flex-end;
  padding: var(--panelheadpad);
  border-top: 1px solid var(--line);
}

/* ------------------------------------------------------------ pagination */
.nspagination {
  display: flex;
  align-items: center;
  gap: var(--gapxs);
  flex-wrap: wrap;
  font-size: var(--fontsizesm);
}
.nspagination .nspageinfo { color: var(--muted); margin-right: auto; }
.nspagination a, .nspagination span.nspagenumber {
  min-width: var(--controlheight);
  height: var(--controlheight);
  padding: 0 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.nspagination a:hover { background: var(--bg); text-decoration: none; }
.nspagination .nson { background: var(--brand50); border-color: var(--openline); color: var(--brand); font-weight: 600; }
.nspagination .nsdisabled { color: var(--muted); opacity: .6; }

/* ----------------------------------------------------------- dropdown menu */
/*
  🔴 A ROW MENU IS NEVER CLIPPED, AND IT IS A `[popover]` (BILLING
  FIX-2026-11-09-B, §A5).

  WHAT IT USED TO DO. The panel was `position: absolute` inside a
  `position: relative` `.nsmenu`, which put it inside `.nstablebox` — a scroll
  container with `contain: paint`. So the menu was clipped at the box's bottom
  edge, and `nsmenu()` compensated by flipping it upward when it measured too
  little room below. That works on a long table and fails completely on a SHORT
  one: a one-row table's box is about 80px tall, there is no room below AND
  none above, the flip correctly declines to make things worse, and the menu
  renders half-visible with its last items unreachable. Troy found it on a
  two-row Payments table.

  AND IT IS WHY THE DESTRUCTIVE ACTIONS WERE TAKEN OUT OF THE ROW MENU IN RUN
  3 — not because of the clipping, but because the obvious fix for it was to
  put the confirm modal inside the panel, and a `<dialog>` inside an `x-show`n
  element is `display: none`d by the menu's own click-outside handler the
  moment somebody reaches for it. A top-layer element is not clipped by an
  ancestor's overflow; it IS removed by an ancestor's `display: none`.

  🔴 WHY A POPOVER AND NOT `position: fixed`. Because `contain: paint` cannot
  come off `.nstablebox` — that was tried, and the note on that rule carries
  the eight measurements that say the page pans 191px without it and that no
  overflow property anywhere in the ancestor chain substitutes. Containment
  makes the element a containing block for fixed descendants, so a fixed panel
  inside it is clipped exactly as an absolute one was. The TOP LAYER is the
  only thing that gets out: it is not subject to ancestor containment, and the
  platform gives a non-modal top layer with light dismiss and Escape for free.

  `popover="auto"` THEREFORE PROVIDES: the top layer (so no clipping, ever, by
  any ancestor), light dismiss on an outside click, Escape, and correct
  stacking with the `<dialog>` modals a menu item opens. None of that is ours
  to maintain. What stays ours is the POSITION, written by
  `nsmenu().position()` from the trigger's own rect — CSS anchor positioning
  would do it declaratively and is not in every browser yet.

  THE DIALOGS STAY OUTSIDE THE PANEL REGARDLESS: a menu item is a plain button
  that calls `showModal()` on a `<dialog>` rendered as a sibling of the menu.
  See `templates/ui/confirm.twig`'s `menuitem` mode. A modal nested inside a
  popover would be removed from the page when the popover light-dismissed,
  which is the Run 3 failure with a new mechanism.

  REQUIRES `[popover]` — Chrome 114, Safari 17, Firefox 125. The kit already
  requires `<dialog>.showModal()`, which is the same generation of platform
  feature and is load-bearing for every money confirm; docs/UI-KIT.md names
  both.
*/
.nsmenu { position: relative; display: inline-flex; }
/*
  THE UA SHEET'S `[popover]` RULES ARE OVERRIDDEN DELIBERATELY AND IN FULL.
  It gives a popover `position: fixed; inset: 0; margin: auto; border: solid;
  padding: 0.25em; width/height: fit-content`, which centres it in the
  viewport — correct for a dialog-ish popover and wrong for a menu anchored to
  a trigger. `inset: auto` plus `margin: 0` hands positioning back to the
  `top`/`left` that `position()` writes.
*/
.nsmenupanel {
  position: fixed;
  inset: auto;
  margin: 0;
  /*
    🔴 `display: none` HERE AND `flex` ONLY WHEN OPEN, AND THE ORDER MATTERS.
    The UA hides a closed popover with `[popover]:not(:popover-open) { display:
    none }`, which an AUTHOR `display: flex` on this class would beat — leaving
    every menu on the page permanently open. So the default is stated here and
    the open state is the exception.
  */
  display: none;
  z-index: 60;
  min-width: 180px;
  max-width: min(280px, calc(100vw - 24px));
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radiuspanel);
  box-shadow: 0 8px 24px rgba(10, 23, 51, .12);
  padding: 4px;
  flex-direction: column;
}
.nsmenupanel:popover-open { display: flex; }
/* No dimming behind a menu. A backdrop is right for a modal and wrong here. */
.nsmenupanel::backdrop { background: transparent; }
.nsmenupanel button, .nsmenupanel a {
  font: inherit;
  font-size: var(--fontsizesm);
  text-align: left;
  background: transparent;
  border: 0;
  border-radius: var(--radius);
  padding: 6px 8px;
  color: var(--ink);
  cursor: pointer;
  white-space: nowrap;
}
.nsmenupanel button:hover, .nsmenupanel a:hover { background: var(--bg); text-decoration: none; }
.nsmenupanel button[disabled] { color: var(--muted); cursor: not-allowed; }
.nsmenupanel button[disabled]:hover { background: transparent; }
/*
  A DESTRUCTIVE MENU ITEM (BILLING FIX-2026-11-09-B, §A5). The row menu holds
  void and write-off again, and "Void" in the same ink as "Open invoice" is the
  kind of list where the wrong item gets clicked. `--bad` on the text only: a
  red FILL on a menu item reads as a selected state.
*/
.nsmenupanel button.nsdanger { color: var(--bad); }
.nsmenupanel button.nsdanger:hover { background: var(--badbg); }
.nsmenusep { height: 1px; background: var(--line); margin: 4px 0; }

/* ------------------------------------------------------- lifecycle steps */
.nsstep {
  display: flex;
  gap: var(--gapsm);
  align-items: baseline;
  padding: 6px 12px;
  border-top: 1px solid var(--line2);
  font-size: var(--fontsizesm);
}
.nsstep:first-child { border-top: 0; }
.nsstepnumber {
  flex: 0 0 18px;
  height: 18px;
  border-radius: var(--radiuspill);
  background: var(--nbg);
  color: var(--n);
  font-size: 11px;
  font-weight: 700;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.nsstep.nson .nsstepnumber { background: var(--brand); color: #FFFFFF; }

/* ------------------------------------------------------- invoice editor */
.nslines {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 64px 110px 110px 28px;
  gap: var(--gapxs) var(--gapsm);
  align-items: center;
}
.nslineheader { padding-bottom: 4px; }
.nslinerow { padding: 3px 0; }
.nslineamount { text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }
.nslineremove {
  width: 28px;
  height: 28px;
  border: 0;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius);
}
.nslineremove:hover { background: var(--badbg); color: var(--bad); }

/*
  THE ISSUED INVOICE AS A DOCUMENT (BILLING FIX-2026-11-09-B, §C).

  `nsdocgrid` is the From / Bill to pair at the top. Two columns that collapse
  to one under the narrow breakpoint, which is the whole of the responsive
  behaviour an address block needs.

  `nsdocaddress` stacks its lines. `display: flex; flex-direction: column` and
  not `<br>`s, because the number of lines is data — an address may have one
  line or four — and a block that is laid out by its content cannot be left
  with a trailing break.
*/
.nsdocgrid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--gap);
}
.nsdocaddress {
  display: flex;
  flex-direction: column;
  gap: 1px;
  font-size: var(--fontsizesm);
  margin-top: 3px;
}
/*
  AN ADJUSTMENT ROW IS NOT A LINE ITEM AND MUST NOT LOOK LIKE ONE. A faint
  tint and a left marker, so a reader scanning the lines sees where the
  original bill stopped — which is the entire reason an adjustment is appended
  rather than edited into a line.
*/
.nstable tbody tr.nsadjustmentrow { background: var(--bg); }
.nstable tbody tr.nsadjustmentrow td:first-child { box-shadow: inset 2px 0 0 var(--line); }

.nstotals {
  width: 100%;
  max-width: 280px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: var(--fontsizesm);
  font-variant-numeric: tabular-nums;
  margin-left: auto;
}
.nstotalrow { display: flex; justify-content: space-between; gap: var(--gap); }
.nstotalrow.nsgrand {
  padding-top: var(--gapxs);
  border-top: 1px solid var(--line);
  font-weight: 700;
  font-size: 15px;
}

/* --------------------------------------------------------------- grids */
/* Named by what they hold, not by their ratio, so a column change is one edit
   here instead of a search for `1fr 380px`. */
.nsgridmain {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
  gap: var(--gap);
  align-items: start;
}
.nsgridcustomer {
  display: grid;
  grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
  gap: var(--gap);
  align-items: start;
}
.nsgridinvoice {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
  gap: var(--gap);
  align-items: start;
}
.nsgridhalf {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  gap: var(--gap);
  align-items: start;
}
.nsstack { display: flex; flex-direction: column; gap: var(--gap); min-width: 0; }
.nsstacksm { display: flex; flex-direction: column; gap: var(--gapsm); min-width: 0; }
.nsinline { display: flex; align-items: center; gap: var(--gapsm); flex-wrap: wrap; }
.nsfieldgrid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: var(--gapsm);
}

/* 🔴 THE BRANDING PREVIEW (BILLING RUN 5 §A7).
 *
 * Apps → {app} → Branding shows what a customer sees, so a colour and a logo
 * can be judged without minting a pay link and opening it.
 *
 * 🔴 IT REUSES `.nspaybrandchip`, `.nspaybrandlogo`, `.nspaybrandname` AND
 * `.nspayprocessor` — the pay page's own classes — rather than having preview
 * copies of them. A preview with its own styling is a preview that can be
 * wrong, and the one thing it must not be is reassuring about something that
 * will render differently.
 *
 * WHAT IT CANNOT SHOW IS THE COLOUR FROM `/brand/{code}.css`, because this is
 * an ADMIN page and that stylesheet is only loaded by the pay pages — loading
 * it here would re-point `--brand` for the whole admin screen, turning every
 * primary button and the header bar into the app's colour. So the bar's colour
 * is set from the token on this element's own scope, which is what
 * `--previewbrand` is for: one declaration, bounded to the preview. */
.nsbrandpreview {
  border-top: 1px solid var(--line2);
}
.nsbrandpreviewbar {
  background: var(--previewbrand, var(--brand));
  color: #FFFFFF;
  padding: 14px var(--panelpad, 16px);
  display: flex;
  align-items: center;
  /* 12px, matching `.nspaybrand` — the preview's whole job is to be wrong
     about nothing the customer will see. */
  gap: 12px;
  min-width: 0;
}
.nsbrandpreviewbar .nspaybrandname { font-weight: 600; font-size: var(--fontsizeh2); }
.nsbrandpreviewbody {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: var(--gap) var(--panelpad, 16px);
  background: var(--surface);
}

/* ------------------------------------------------------------- utilities */
.nsnumeric { font-variant-numeric: tabular-nums; }
.nsmuted { color: var(--muted); }
.nsstrong { font-weight: 600; }
.nsmono {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--fontsizexs);
}
.nsellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.nsright { text-align: right; }
.nsnowrap { white-space: nowrap; }
.nsbadtext { color: var(--bad); }
.nsoktext { color: var(--ok); }

/* ---------------------------------------------------- style-guide chrome */
/* Only /admin/kit uses these. They live here rather than in a second
   stylesheet so the kit page cannot drift from the kit. */
.nskitsection { display: flex; flex-direction: column; gap: var(--gapsm); }
.nskitlabel {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0;
}
.nskitswatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: var(--gapsm); }
.nkswatch { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.nkswatchchip { height: 44px; }
.nkswatchmeta { padding: 6px 8px; font-size: var(--fontsizexs); display: flex; flex-direction: column; }
.nkswatchname { font-weight: 600; }
.nsiconwall { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: var(--gapsm); }
.nsiconcell {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--gapsm);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  font-size: 11px;
  color: var(--muted);
  text-align: center;
}
.nsdensitydemo { border: 1px dashed var(--openline); border-radius: var(--radiuspanel); padding: var(--gap); }

/* --------------------------------------------------------- login screen */
/* Uses client density: one centred card, a logo, roomy controls. It is the
   only page an unauthenticated person can reach and it is often reached on a
   phone. */
.nsauthshell {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px 16px;
  background: var(--bg);
}
.nsauthcard {
  width: 100%;
  max-width: 400px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 24px;
  display: flex;
  flex-direction: column;
  gap: var(--gap);
}
.nsauthbrand {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--brand);
}
.nsauthbrandname { font-weight: 700; font-size: 17px; letter-spacing: -.01em; }
.nsauthtitle { font-size: 17px; }
.nsauthhint { color: var(--muted); font-size: var(--fontsizesm); }
.nsauthcard .nsbtn { width: 100%; }
.nsauthfoot { font-size: var(--fontsizexs); color: var(--muted); text-align: center; }
/* A one-time secret shown once, at enrolment. Breaks anywhere so a long
   base32 string cannot widen the card. */
.nsauthsecret {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--fontsizesm);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--gapsm);
  overflow-wrap: anywhere;
}

/* ======================================================================
   RESPONSIVE — one block, at the end.
   Below 900px: layouts stack, the sidebar becomes a horizontal scrolling
   strip, controls grow to 44px in BOTH densities, stat cards go two per row,
   and tables keep their own overflow box.
   ====================================================================== */
@media (max-width: 900px) {
  :root {
    --controlheight: 44px;
    --navheight: 44px;
    --gutter: 16px;
  }

  /* The sidebar becomes a full-width strip that scrolls sideways. The group
     labels go: they are vertical-list furniture and read as dead items in a
     horizontal strip. */
  .nsside {
    flex: 1 1 100%;
    max-width: none;
    /* Restated with the base rule's reason: this is the viewport where the nav
       becomes a 1,316px horizontal strip, so this is where an `auto` minimum
       would drag the page sideways. */
    min-width: 0;
    border-right: 0;
    border-bottom: 1px solid var(--line);
  }
  .nssidenav {
    flex-direction: row;
    overflow-x: auto;
    /* The scrolling box must not be sized by its contents either — same rule,
       one level down. Without it the nav grows to fit all eleven items and
       `overflow-x` has nothing to clip. */
    min-width: 0;
    max-width: 100%;
    padding-bottom: var(--gapsm);
    /* Momentum scrolling on iOS, and a scrollbar that does not overlay the
       last item on desktop-narrow. */
    -webkit-overflow-scrolling: touch;
  }
  /* Each item keeps its own size inside the strip; without this they would
     squash towards their min-content width instead of scrolling. */
  .nssidenav a {
    flex: 0 0 auto;
  }
  .nsnavgroup { display: none; }

  .nsstats { --statcolumns: 2; }
  .nsstats.nsstats4, .nsstats.nsstats3, .nsstats.nsstats2 { --statcolumns: 2; }

  .nsgridmain, .nsgridcustomer, .nsgridinvoice, .nsgridhalf {
    grid-template-columns: minmax(0, 1fr);
  }

  /* 44px controls. `.nsbtn.nsicon` needs its width too or an icon button
     becomes a 44-tall, 32-wide sliver. */
  .nsbtn, .nsinput, .nsseg { height: var(--controlheight); }
  .nsbtn.nsicon { width: var(--controlheight); }
  textarea.nsinput { height: auto; }

  /* A 44px tap target for the row menu and the line-remove button. */
  .nslineremove { width: 40px; height: 40px; }
  .nstabs button, .nstabs a { min-height: 36px; padding: 6px 10px; }
  /* One column for the From / Bill to pair: two 150px address blocks side by
     side at 390px wrap every line. */
  .nsdocgrid { grid-template-columns: minmax(0, 1fr); }

  .nsswitch { width: 44px; height: 26px; }
  .nsswitch::after { width: 22px; height: 22px; }
  .nsswitch.nson::after { left: 20px; }

  .nsacchead { min-height: 52px; }
  .nsaccbody { padding-left: 12px; }

  /* The amount column is derivable from qty x unit price, so it is the one to
     drop when the editor has to fit a phone. */
  .nslines { grid-template-columns: minmax(0, 1fr) 56px 90px 40px; }
  .nslines .nslineamount { display: none; }

  .nsrowtime { flex: 0 0 84px; }
  .nsheaderbrand { flex: 0 0 auto; }
  .nsheadersearch { order: 10; flex: 1 1 100%; max-width: none; }
  .nsdetaillist { --detaillabelwidth: 104px; }
}

/* Very narrow: the detail list stops being two columns, because a 104px label
   column leaves nothing for a long email address. */
@media (max-width: 420px) {
  .nsdetaillist { grid-template-columns: minmax(0, 1fr); }
  .nsdetaillist dt { padding-bottom: 0; border-top: 1px solid var(--line2); }
  .nsdetaillist dt + dd { border-top: 0; padding-top: 2px; }
  .nspageactions { width: 100%; }
  .nspageactions .nsbtn { flex: 1 1 auto; }
}

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; }
}

/* A narrower label column for the detail list, used by the invoice editor's
   sidebar panels. A modifier class rather than an inline width, because
   `style-src 'self'` drops an inline style attribute without saying so. */
.nsdetaillist.nslabelnarrow { --detaillabelwidth: 110px; }

/* ---------------------------------------------- style-guide swatch chips */
/* One class per token, because the swatch's colour must come from the token
   itself: a hard-coded hex here would keep looking authoritative after the
   token changed, and a removed token would still show its old colour instead
   of showing nothing. `style-src 'self'` rules out the inline alternative. */
.nkchip-brand    { background: var(--brand); }
.nkchip-brand50  { background: var(--brand50); }
.nkchip-ink      { background: var(--ink); }
.nkchip-ink2     { background: var(--ink2); }
.nkchip-muted    { background: var(--muted); }
.nkchip-line     { background: var(--line); }
.nkchip-line2    { background: var(--line2); }
.nkchip-bg       { background: var(--bg); }
.nkchip-okbg     { background: var(--okbg); }
.nkchip-ok       { background: var(--ok); }
.nkchip-badbg    { background: var(--badbg); }
.nkchip-bad      { background: var(--bad); }
.nkchip-warnbg   { background: var(--warnbg); }
.nkchip-warn     { background: var(--warn); }
.nkchip-nbg      { background: var(--nbg); }
.nkchip-n        { background: var(--n); }
.nkchip-openbg   { background: var(--openbg); }
.nkchip-openline { background: var(--openline); }

/* The style guide's ordered list, which is the only <ol> in the app. */
.nspanelbody ol { margin: 0 0 var(--gapsm); padding-left: 1.4em; }
.nspanelbody ol li { margin-bottom: 4px; }

/* ------------------------------------------------- utility: hidden (RUN 2)
   `nshidden` exists because `style-src` has no 'unsafe-inline' and is not
   going to get one, so `element.style.display = 'none'` from a script is not
   available either — the browser blocks the property write on a page with a
   strict style-src. Toggling a class is the only mechanism left, which is the
   intended consequence of that directive rather than a workaround for it.
   `!important` so it wins against a component's own display rule; this class
   has exactly one job and overriding it is never what somebody meant. */
.nshidden { display: none !important; }

/* ----------------------------------------- step progress with outcome (RUN 2)
   Built on the existing `.nsstep` row, which already supplies the padding and
   the hairline. Three states: pending (the default, muted mark), done (a green
   tick) and failed (a red mark). It is a LIST OF STEPS WITH OUTCOMES, which is
   a different component from `.nsstepnumber`'s numbered lifecycle — that one
   shows where a record is in a sequence, this one shows which parts of an
   operation have actually happened. Both are in the kit because the test
   charge needed the second and the invoice detail already used the first. */
.nssteplist { display: flex; flex-direction: column; }
.nsstep .nsstepmark {
  flex: 0 0 18px;
  height: 18px;
  border-radius: var(--radiuspill);
  background: var(--nbg);
  color: var(--nbg);        /* the tick is invisible until the step is done */
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.nsstep.nsdone .nsstepmark   { background: var(--okbg);  color: var(--ok); }
.nsstep.nsfailed .nsstepmark { background: var(--badbg); color: var(--bad); }
.nsstep .nssteptext { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.nsstep .nssteplabel { color: var(--ink2); }
.nsstep.nsdone .nssteplabel { color: var(--ink); font-weight: 500; }
.nsstep.nsfailed .nssteplabel { color: var(--bad); font-weight: 500; }
.nsstep .nsstepdetail { color: var(--muted); font-size: 12px; }
.nsstep .nssteptiming {
  color: var(--muted);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------- computed checklist (RUN 2)
   The "Troy's setup" list on Payment settings. Every row is DERIVED — there is
   no stored tick anywhere — so the only states are done and not-done, and
   there is deliberately no affordance for marking one by hand. */
.nschecklist { display: flex; flex-direction: column; }
.nscheckitem {
  display: flex;
  gap: var(--gapsm);
  align-items: baseline;
  padding: 6px 12px;
  border-top: 1px solid var(--line2);
  font-size: var(--fontsizesm);
}
.nscheckitem:first-child { border-top: 0; }
.nscheckmark {
  flex: 0 0 18px;
  height: 18px;
  border-radius: var(--radiuspill);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--warnbg);
  color: var(--warn);
}
.nscheckitem.nsdone .nscheckmark { background: var(--okbg); color: var(--ok); }
.nscheckbody { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.nschecklabel { color: var(--ink2); }
.nscheckitem.nsdone .nschecklabel { color: var(--ink); }
.nscheckdetail { color: var(--muted); font-size: 12px; }

/* =========================================================================
   BILLING RUN 3 additions
   -------------------------------------------------------------------------
   Three groups, and all three belong in the KIT rather than on a page: the
   money confirm modal's trigger wrapper, the client-facing surface frame (the
   kit's third layout, after admin and auth — docs/DESIGN.md §12 always meant
   it to serve the client portals too), and the Adyen Drop-in theme.

   NO PAGE HAS ITS OWN STYLESHEET AND NO TEMPLATE HAS AN INLINE `style=`. The
   vhost sends `style-src 'self'` with no 'unsafe-inline', so an inline style
   is silently dropped by the browser — which is what makes "everything is a
   class" enforced rather than aspirational.
   ========================================================================= */

/* ------------------------------------------------- money confirm trigger */
/* `ui/confirm.twig` wraps its trigger and its <dialog> in one element so the
   Alpine scope that opens the dialog is the one that contains it. The wrapper
   therefore has to lay out like the button it contains and nothing else — an
   inline-flex span with no box of its own. Without this the action row's gap
   applies to the span and the button inside it floats. */
/*
  `menuitem` MODE RENDERS NO TRIGGER, so this wrapper has nothing in it but a
  closed `<dialog>` — which is `display: none`. `display: contents` takes the
  wrapper itself out of the layout entirely, so a row of these between two
  panels cannot contribute an inline box or a stray line of whitespace. The
  `x-data` scope is unaffected: `display: contents` is a layout property and
  the element is still in the DOM.
*/
.nsconfirm.nsconfirmhosted { display: contents; }
.nsconfirm { display: inline-flex; }
.nsconfirm > .nsbtn, .nsconfirm > .nslink { flex: 0 0 auto; }

/* The money effect inside a confirm modal: what this action does, in order.
   It is a list rather than a paragraph because the effects ARE a list — the
   money moves, the invoice changes state, an event goes to the app — and a
   reader scanning three bullets will notice the one they did not expect. */
.nseffect { margin: 0 0 var(--gap); padding: 0; list-style: none; }
.nseffect li {
  display: flex;
  gap: var(--gapxs);
  align-items: baseline;
  padding: 4px 0;
  color: var(--ink2);
  font-size: var(--fontsizesm);
}
.nseffect li::before {
  content: "";
  flex: 0 0 auto;
  width: 5px; height: 5px;
  margin-top: 6px;
  border-radius: 50%;
  background: var(--brand);
}
.nseffect .nsamount { font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; }
.nseffectwarn { color: var(--warn); }

/* The one-line filter bar above a list: a search box, app chips, a count. */
.nsfilterbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gapxs);
}
.nsfilterbar form { display: flex; gap: var(--gapxs); align-items: center; }
.nsfilterbar .nsinput { width: 220px; max-width: 100%; }

/* =========================================================================
   The CLIENT SURFACE frame — /pay/{token} and /card/{token}
   -------------------------------------------------------------------------
   The kit's third layout. Admin is a header bar plus a sidebar; auth is a
   centred card; this is a centred COLUMN that has to be excellent at 390px
   and look deliberate at 1440px rather than a 390px page stretched out.

   SO THE COLUMN HAS A MAX WIDTH AND THE PAGE HAS A TINTED GROUND. At 1440px a
   full-width invoice would put the line descriptions a foot away from their
   amounts; at 390px the column is the viewport minus a 16px gutter. One rule,
   both ends.

   `.density-client` ON <body> IS WHAT MAKES THE CONTROLS 44px, and no rule
   below is client-specific — it re-points the kit's tokens and every existing
   component follows. That is the kit's central claim and this page is where it
   is finally tested against something a customer sees.
   ========================================================================= */
.nspayground {
  min-height: 100vh;
  background: var(--bg);
  display: flex;
  flex-direction: column;
}
.nspayshell {
  width: 100%;
  max-width: 640px;
  margin: 0 auto;
  padding: 24px var(--gutter) 32px;
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  flex: 1 1 auto;
}
/* 🔴 THE HEADER BAR, IN THE APP'S COLOUR (BILLING RUN 5 §A7).
 *
 * The brand row used to sit inside the shell as dark text on the page
 * background with a small coloured square beside it. Troy's decision: these
 * pages are branded as the APP's site, so the bar is the app's colour across
 * the full width — the same shape `.nsheader` gives the admin app, which is
 * what makes the two read as one system.
 *
 * `background: var(--brand)` AND NOTHING PER-APP. The colour arrives by
 * `/brand/{appcode}.css` re-pointing that one token, so there is no per-app
 * rule anywhere in this file. That is the kit's central claim, and this is the
 * first place it is tested against a value somebody typed into a form.
 *
 * THE TEXT ON IT IS ALWAYS WHITE, which is why `Branding\Brandcolour` refuses
 * a brand colour whose contrast with white falls below 4.5:1. A page whose
 * text colour depended on a form field is a page that is unreadable for
 * exactly one app and nobody notices which.
 */
.nspayheader {
  background: var(--brand);
  color: #FFFFFF;
  padding: 14px var(--gutter);
}
.nspayheaderinner {
  width: 100%;
  max-width: 640px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}
.nspaybrand {
  display: flex;
  align-items: center;
  /* 12px to the name, which is the one gap on this bar. The chip already
     carries 4px of white round the artwork, so a tighter value reads as the
     logo touching the text. */
  gap: 12px;
  color: inherit;
  min-width: 0;
}
.nspaybrand .nspaybrandname {
  font-weight: 600;
  font-size: var(--fontsizeh2);
  /* A long business name wraps rather than pushing the bar wider at 390px. */
  overflow-wrap: anywhere;
}

/* 🔴 THE LOGO SITS IN A WHITE CHIP (BILLING FIX-2026-11-10-C).
 *
 * The bar is the APP's colour and the logo is the APP's artwork, and nothing
 * makes the two agree: NSBIZ's mark is a blue tile, and a blue tile on a blue
 * bar is a blue bar. A dark wordmark on a dark brand colour is the same
 * failure with less to look at. Tinting the logo would be changing somebody's
 * brand, and asking every app for a white-on-transparent variant is asking for
 * a file most of them do not have.
 *
 * So the logo is given a white surface of its own — 4px of padding and a 6px
 * radius, the kit's small radius — and then ANY logo reads on ANY brand
 * colour, including a transparent PNG drawn in the brand colour itself.
 *
 * THE CHIP EXISTS ONLY WHEN THERE IS A LOGO. An app with none gets its name
 * and nothing else: the empty chip would be a white box on the bar, and the
 * star mark that used to stand in for a logo was a mark the app never chose.
 */
.nspaybrandchip {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #FFFFFF;
  padding: 4px;
  border-radius: 6px;
}

/* 🔴 THE LOGO IS CONSTRAINED BY HEIGHT, AND `object-fit` HOLDS THE RATIO.
 *
 * The stored file can be any aspect ratio — a square mark or a wide
 * wordmark — and `Brandingservice` scales it to fit 600px on the long edge
 * without cropping. Fixing the height and leaving the width automatic is what
 * makes a bar of consistent depth whatever the logo is.
 *
 * `max-width` IS WHY `object-fit: contain` IS HERE. A 600×20 banner is 840px
 * wide at 28px tall; the clamp cuts the BOX to 160px, and without `contain`
 * the browser squashes the picture into it. With it, the artwork letterboxes
 * inside the chip at its own ratio, which is the one thing a logo may not lose.
 *
 * The `width`/`height` attributes on the element are the INTRINSIC size, which
 * is what stops the bar reflowing as the image loads.
 */
.nspaybrandlogo {
  display: block;
  height: 28px;
  width: auto;
  max-width: 160px;
  object-fit: contain;
}
.nspaytitlerow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gapxs);
}
.nspaytitlerow h1 { font-size: var(--fontsizeh1); }
.nspaysub { color: var(--muted); font-size: var(--fontsizesm); }

/* The amount due, stated once and large. It is the number the whole page is
   about, so it is the only thing on it at this size. */
.nspayamount {
  font-size: 30px;
  font-weight: 700;
  letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}
.nspayamountrow {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gapxs);
}

/* The line items, as a two-column list rather than a table: at 390px a table
   with a quantity column and a unit-price column either scrolls sideways or
   squeezes the description to three words. Description and amount are what
   somebody checks; the quantity rides under the description. */
.nspaylines { display: flex; flex-direction: column; }
.nspayline {
  display: flex;
  gap: var(--gap);
  align-items: baseline;
  justify-content: space-between;
  padding: 10px 0;
  border-bottom: 1px solid var(--line2);
}
.nspayline:last-child { border-bottom: 0; }
.nspaylinebody { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.nspaylinesub { color: var(--muted); font-size: var(--fontsizexs); }
.nspaylineamount { font-variant-numeric: tabular-nums; white-space: nowrap; }

.nspayfoot {
  border-top: 1px solid var(--line);
  padding: var(--gap) var(--gutter);
  text-align: center;
  color: var(--muted);
  font-size: var(--fontsizexs);
  background: var(--surface);
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.nspayfoot a { color: var(--brandlink); }

/* 🔴 "Secure payment processing by Northstar Data Services" — ONE LINE, AND IT
 * IS DELIBERATELY QUIET BUT PRESENT (BILLING RUN 5 §A7).
 *
 * A customer entering a card is entitled to know who is taking it. Removing it
 * would make the page a plain impersonation of the app; making it prominent
 * would answer a question nobody asked on the screen where they are being
 * asked for money. Smaller than the support line above it, and last. */
.nspayprocessor { font-size: 11px; color: var(--muted); }

/* "Return to {app}" — the way back, centred above the footer. It is only
 * rendered when the app has an allowlisted return host, so the space it takes
 * is zero for an app that has not set one. */
.nspayreturn {
  display: flex;
  justify-content: center;
  padding: 0 var(--gutter) var(--gap);
}

/* The receipt. A tick, a sentence, and the reference — nothing to click,
   because there is nothing left to do. */
.nspayreceipt {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--gapsm);
  text-align: center;
  padding: var(--gap) 0;
}
.nspayreceiptmark {
  width: 48px; height: 48px;
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: var(--okbg);
  color: var(--ok);
}
.nspayreceiptmark.nspending { background: var(--warnbg); color: var(--warn); }
.nspayreceiptmark.nsfailedmark { background: var(--badbg); color: var(--bad); }
.nspayreceipttitle { font-size: var(--fontsizeh1); font-weight: 600; }
.nspayreceiptbody { color: var(--ink2); max-width: 42ch; }

/* A "test mode" ribbon on a customer-facing page. It has to be impossible to
   miss: a sandbox invoice that looked real is a customer ringing up about a
   payment that never happened. */
.nspaytestbanner {
  background: var(--warnbg);
  color: var(--warn);
  border: 1px solid var(--warn);
  border-radius: var(--radius);
  padding: 10px 12px;
  font-size: var(--fontsizesm);
  font-weight: 600;
  display: flex;
  gap: var(--gapxs);
  align-items: center;
}

/* =========================================================================
   ADYEN DROP-IN, themed to the kit (BILLING RUN 3, §6 of the prompt)
   -------------------------------------------------------------------------
   Scoped to `.nsdropin`, which is the container every page mounts into, so
   none of this leaks onto a kit control that happens to share a token.

   🔴 WHAT WAS ACTUALLY WRONG, MEASURED RATHER THAN GUESSED:
   `adyen.css` sets NO font-family on `.adyen-checkout__button`, and the UA
   stylesheet gives a <button> `font: 400 13.33px Arial` — buttons do not
   inherit `font-family`. So the Pay button rendered in Arial next to a page of
   Inter. The computed style said `fontFamily: "Arial"`; the inputs next to it
   said `InterVariable`, because `.adyen-checkout__input` carries
   `font-family: inherit` and the button does not.

   The second half — "Field valid" printed on screen and an empty brand-icon
   box — was NOT a font or a precedence problem and is NOT fixed here. Both
   were broken <img> elements rendering their `alt` text, because Adyen's
   image host 302-redirects to a DIFFERENT host that the CSP did not allow.
   The fix is one host in `config/csp.conf`. The `font-size: 0` below is a
   second line of defence for the next time an image fails for some other
   reason, and it is the right kind: it stops the text PAINTING while leaving
   the `alt` attribute in the accessibility tree, which is where "Field valid"
   was always supposed to be read.
   ========================================================================= */
.nsdropin {
  font-family: 'InterVariable', Inter, system-ui, -apple-system, 'Segoe UI', sans-serif;
  font-size: var(--fontsize);
  color: var(--ink);
}

/* Inter on everything Drop-in renders, including the elements that do not
   inherit it. */
.nsdropin .adyen-checkout__button,
.nsdropin .adyen-checkout__input,
.nsdropin .adyen-checkout__label__text,
.nsdropin .adyen-checkout__error-text,
.nsdropin .adyen-checkout__checkbox__label,
.nsdropin select,
.nsdropin input,
.nsdropin button {
  font-family: inherit;
}

/* Fields: the kit's own height, radius and border. `--controlheight` is 32px
   in admin density and 44px in client density, and under 900px both grow to
   44 — so the card fields match the inputs beside them on every surface
   without a single media query here. */
.nsdropin .adyen-checkout__input {
  height: var(--controlheight);
  border-radius: var(--radius);
  border-color: var(--inputline);
  font-size: var(--fontsizesm);
  color: var(--ink);
  caret-color: var(--brand);
}
.nsdropin .adyen-checkout__input:hover { border-color: var(--muted); }
.nsdropin .adyen-checkout__input--focus,
.nsdropin .adyen-checkout__input--focus:hover,
.nsdropin .adyen-checkout__input:focus,
.nsdropin .adyen-checkout__input:active {
  border-color: var(--brand);
  box-shadow: 0 0 0 2px var(--focusring);
}
.nsdropin .adyen-checkout__label__text { color: var(--muted); font-size: var(--fontsizelabel); }
.nsdropin .adyen-checkout__label--focused .adyen-checkout__label__text { color: var(--brand); }

/* Errors, from the kit's tokens rather than Adyen's #c12424. */
.nsdropin .adyen-checkout__error-text { color: var(--bad); font-size: var(--fontsizexs); }
.nsdropin .adyen-checkout__input--error,
.nsdropin .adyen-checkout__input--error:hover,
.nsdropin .adyen-checkout__input--invalid,
.nsdropin .adyen-checkout__input--invalid:hover { border-color: var(--bad); }
.nsdropin .adyen-checkout-input__inline-validation--valid { color: var(--ok); }
.nsdropin .adyen-checkout-input__inline-validation--invalid { color: var(--bad); }

/* The Pay button: --brand, the kit's radius, the kit's control height, the
   kit's weight. Adyen's own is #00112c at 48px with weight 500. */
.nsdropin .adyen-checkout__button {
  background: var(--brand);
  border-radius: var(--radius);
  height: var(--controlheight);
  padding: 0 14px;
  font-size: var(--fontsizesm);
  font-weight: 600;
  box-shadow: var(--shadowbtn);
}
.nsdropin .adyen-checkout__button:hover { background: var(--brandhover); box-shadow: var(--shadowbtn); }
.nsdropin .adyen-checkout__button:active { background: var(--brandlink); }
.nsdropin .adyen-checkout__button:focus,
.nsdropin .adyen-checkout__button:hover:focus {
  box-shadow: 0 0 0 2px var(--focusring);
  outline: 0;
}
.nsdropin .adyen-checkout__button.adyen-checkout__button--loading { background: var(--muted); }
.nsdropin .adyen-checkout__button.adyen-checkout__button--pay { margin-top: var(--gap); }

/* 🔴 ALT TEXT MUST NEVER PAINT. See the block comment above: a broken Adyen
   icon printed "Field valid" beside the expiry and CVC fields, which is
   screen-reader text arriving on screen. `font-size: 0` keeps the `alt`
   attribute where assistive technology reads it and stops the browser drawing
   it. The images themselves are fixed in config/csp.conf. */
.nsdropin img { font-size: 0; }
.nsdropin .adyen-checkout-input__inline-validation { overflow: hidden; }

/* The stored-method list and the "save this card" row, so a saved card inside
   Drop-in reads like a saved card everywhere else. */
.nsdropin .adyen-checkout__payment-method {
  border-color: var(--line);
  border-radius: var(--radius);
}
.nsdropin .adyen-checkout__payment-method--selected {
  background: var(--openbg);
  border-color: var(--openline);
}
.nsdropin .adyen-checkout__payment-method__name { color: var(--ink); font-weight: 500; }
.nsdropin .adyen-checkout__checkbox__label { color: var(--ink2); font-size: var(--fontsizesm); }
.nsdropin .adyen-checkout__spinner { border-color: var(--brand); border-top-color: transparent; }

/* =========================================================================
   ADYEN DROP-IN — THE ACH PANEL (BILLING RUN 3, item 7)
   -------------------------------------------------------------------------
   Ported from OppsBridge's handoff for Adyen Web 5.50.0
   (/var/www/cc_images/new/adyen-dropin-styling.md) and re-verified against the
   5.66.0 bundle vendored in public/assets/vendor/adyen-web/5.66.0/.

   WHAT IT DOES: routing number first and tinted green, account number second
   and tinted blue — the order and the colours of the check strip drawn
   underneath them, so the field and the picture read as one thing.

   🔴 EVERY SELECTOR BELOW IS AN ADYEN INTERNAL, NOT A PUBLIC API. Each one was
   grepped out of the vendored bundle rather than taken from the handoff, and
   `bin/test-run3-l1.php` §21 parses them back OUT of this block and asserts
   each still appears in `adyen.js` or `adyen.css`. So the next SDK upgrade
   fails a test instead of quietly un-styling a payment form. What changed
   between 5.50.0 and 5.66.0:

     * `.adyen-checkout__field-wrapper` NOW CARRIES `display: flex` ITSELF
       (`{display:flex;width:100%}`), so the handoff's own `display: flex`
       declaration is redundant at this version. It is still written below,
       because a future version dropping it would otherwise take the whole
       reorder with it and nothing would say so.
     * `flex-wrap` IS STILL MISSING, so the handoff's warning still applies —
       see the 🔴 on the wrap rule.
     * the two fields NOW ALSO CARRY `.adyen-checkout__field--50` (`{width:50%}`),
       which did not exist at 5.50.0. Harmless here: flex-basis decides a flex
       item's main size, not `width`.
     * the DOM order is unchanged — Adyen still renders ACCOUNT NUMBER FIRST,
       which is what there is to swap.

   🔴 NO `!important` ANYWHERE, AND THAT IS A DEPARTURE FROM THE HANDOFF.
   OppsBridge needed it because they loaded `adyen.css` from Adyen's host and
   could not order the two sheets. We vendor it, so the only problem is
   specificity: `northstar-ui.css` is linked BEFORE `adyen.css` on every
   Drop-in page, which means a tie goes to Adyen. Adyen's margin rules are
   `.adyen-checkout__field-wrapper > .adyen-checkout__field:first-child`, which
   is (0,3,0). The reorder rules below are four classes deep, (0,4,0), so they
   win on specificity alone — no `!important`, and the next person overriding
   one of them is not starting from an arms race.
   ========================================================================= */

/* 🔴 `flex-wrap: wrap` IS LOAD-BEARING AND IT IS THE FIRST THING HERE.
   The phone rule below sets `flex: 0 0 100%` — grow 0, SHRINK 0, basis 100%.
   Two unshrinkable full-width items in a non-wrapping row cannot stack: they
   overflow the panel sideways instead. OppsBridge shipped that in `xcust` and
   `xpay` has the missing declaration; their handoff says to add it from the
   start and this is it. `.adyen-checkout__ach-sf__form` is matched together
   with `.adyen-checkout__field-wrapper` because Adyen puts both classes on the
   one element, and the compound keeps this off every OTHER field wrapper in
   the form. */
.nsdropin .adyen-checkout__ach-sf__form.adyen-checkout__field-wrapper {
  display: flex;
  flex-wrap: wrap;
}

/* The swap. Routing is SECOND in Adyen's DOM and first on screen; no node is
   moved, so Adyen's own event wiring and its iframes are untouched. */
.nsdropin .adyen-checkout__ach-sf__form > .adyen-checkout__field.adyen-checkout__field--bankLocationId {
  order: 1;
  flex: 0 0 calc(50% - 4px);
  max-width: calc(50% - 4px);
  box-sizing: border-box;
  margin-left: 0;
  margin-right: 8px;
}
.nsdropin .adyen-checkout__ach-sf__form > .adyen-checkout__field.adyen-checkout__field--bankAccountNumber {
  order: 2;
  flex: 0 0 calc(50% - 4px);
  max-width: calc(50% - 4px);
  box-sizing: border-box;
  margin-left: 0;
  margin-right: 0;
}

/* The tints. The DIGITS are inside Adyen's secure-field iframe and cannot be
   reached from here — their colour comes from the `styles` option in
   dropin.js — but the iframe is transparent, so this background is what they
   sit on. That is why --ink's contrast against both tints is asserted. */
.nsdropin .adyen-checkout__field--bankLocationId .adyen-checkout__input {
  background: var(--achroutingtint);
  border-color: var(--achroutingline);
}
.nsdropin .adyen-checkout__field--bankLocationId .adyen-checkout__label__text {
  color: var(--achrouting);
  font-weight: 600;
}
.nsdropin .adyen-checkout__field--bankAccountNumber .adyen-checkout__input {
  background: var(--achaccounttint);
  border-color: var(--achaccountline);
}
.nsdropin .adyen-checkout__field--bankAccountNumber .adyen-checkout__label__text {
  color: var(--achaccount);
  font-weight: 600;
}
/* Focus and error still win over the tint: a brand ring and a --bad border are
   the kit's answers everywhere else and this panel is not an exception. */
.nsdropin .adyen-checkout__field--bankLocationId .adyen-checkout__input--focus,
.nsdropin .adyen-checkout__field--bankAccountNumber .adyen-checkout__input--focus {
  border-color: var(--brand);
}
.nsdropin .adyen-checkout__field--bankLocationId .adyen-checkout__input--error,
.nsdropin .adyen-checkout__field--bankAccountNumber .adyen-checkout__input--error {
  border-color: var(--bad);
}

/* One per row on a phone. 480px rather than the kit's 900px: this is about two
   half-width fields having room for nine and seventeen digits, not about the
   page's layout changing shape. */
@media (max-width: 480px) {
  .nsdropin .adyen-checkout__ach-sf__form > .adyen-checkout__field.adyen-checkout__field--bankLocationId,
  .nsdropin .adyen-checkout__ach-sf__form > .adyen-checkout__field.adyen-checkout__field--bankAccountNumber {
    flex: 0 0 100%;
    max-width: 100%;
    margin-left: 0;
    margin-right: 0;
  }
}

/* ---------------------------------------------------------- the check strip */
/*
  Inserted into Adyen's ACH fieldset by `addachcheckstrip()` in
  public/assets/billing/dropin.js, cloned from a <template> in
  templates/ui/achpanel.twig. Markup, not a JavaScript string, so
  `bin/check-templates.php` can see it and so the no-inline-style rule is
  enforced on it like everything else.
*/
.nsachstrip {
  margin-top: var(--gapsm);
  display: flex;
  flex-direction: column;
  gap: var(--gapxs);
  align-items: flex-start;
}
.nsachstripsvg {
  width: 100%;
  max-width: 360px;
  height: auto;
  display: block;
}
/* The caption is a kit link and NOT red text. OppsBridge's is
   `<span style="font-size:12px;color:red">`, which on this host would be
   dropped by `style-src 'self'` and, if it were not, would read as an error
   message on a form the shopper has not got wrong yet. */
.nsachstripcaption { font-size: var(--fontsizesm); }

/* The strip's own type. `.nsmicr` is the MICR stack, declared once so the
   strip, the diagram and the legend cannot drift apart.

   🔴 THE FONT ITSELF IS NOT SHIPPED. `MICR Encoding` is Digital Graphic Labs'
   1998 face, "All Rights Reserved", with no licence string and no licence URL
   in its name table — see public/assets/vendor/micr/1.02/SOURCE.md. So there
   is deliberately NO `@font-face` here: the first two families match a copy
   the viewer's own machine happens to have installed, and everyone else gets
   `'Courier New', monospace`, which is why the fallback is in the stack and
   why the drawings below spell the transit and on-us symbols as SVG shapes
   rather than as the font's `a` and `c` glyphs. */
.nsmicr {
  font-family: 'MICR Encoding', MICR, 'Courier New', monospace;
  letter-spacing: .02em;
}
.nsachboxrouting { fill: var(--achroutingtint); stroke: var(--achroutingline); }
.nsachboxaccount { fill: var(--achaccounttint); stroke: var(--achaccountline); }
.nsachboxcheck   { fill: var(--achchecktint);   stroke: var(--achcheckline); }
.nsachinkrouting { fill: var(--achrouting); }
.nsachinkaccount { fill: var(--achaccount); }
.nsachinkcheck   { fill: var(--achcheck); }
.nsachlinerouting { stroke: var(--achroutingline); fill: none; stroke-width: 2; }
.nsachlineaccount { stroke: var(--achaccountline); fill: none; stroke-width: 2; }
.nsachlinecheck   { stroke: var(--achcheckline);   fill: none; stroke-width: 2; }
.nsachpaper   { fill: var(--surface); stroke: var(--line); }
.nsachband    { fill: var(--bg); }
.nsachrule    { stroke: var(--line); fill: none; }
.nsachlabel   { fill: var(--muted); font-size: 9px; }
.nsachlabelstrong { fill: var(--ink2); font-size: 11px; }
.nsachheading { font-size: 9px; font-weight: 700; }
.nsachfigures { font-size: 14px; fill: var(--ink); }
.nsachfiguressm { font-size: 12px; fill: var(--ink); }
.nsachbankname { fill: var(--ink); font-size: 15px; font-weight: 700; }
.nsachfoot    { fill: var(--muted); font-size: 8px; }
/* The SVG text elements inherit the page's font, which is Inter — the kit's
   own. Only the MICR line opts out, through .nsmicr. */
.nsachstripsvg text, .nsachdiagramsvg text { font-family: inherit; }

/* ------------------------------------------------------- the full diagram */
/*
  A kit <dialog>, server-rendered on every Drop-in page and opened by the strip
  caption. Not built in JS on first click, as OppsBridge's overlay is: a
  <dialog> gives focus trapping, Escape and the backdrop from the platform, and
  a server-rendered one is subject to check-templates.php.
*/
.nsachdialog { width: min(560px, calc(100vw - 32px)); }
.nsachdiagramsvg {
  width: 100%;
  height: auto;
  display: block;
}
.nsachlegend {
  margin: var(--gapsm) 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--gapxs);
  font-size: var(--fontsizesm);
}
.nsachlegenditem {
  border-left: 3px solid var(--line);
  padding-left: var(--gapsm);
}
.nsachlegenditem.nsachrouting { border-left-color: var(--achroutingline); }
.nsachlegenditem.nsachaccount { border-left-color: var(--achaccountline); }
.nsachlegenditem.nsachcheck   { border-left-color: var(--achcheckline); }
.nsachlegendterm { font-weight: 600; }
.nsachlegenditem.nsachrouting .nsachlegendterm { color: var(--achrouting); }
.nsachlegenditem.nsachaccount .nsachlegendterm { color: var(--achaccount); }
.nsachlegenditem.nsachcheck   .nsachlegendterm { color: var(--achcheck); }

/* -------------------------------------------- the checking/savings selector */
/*
  🔴 ADYEN WEB 5.66.0 DOES NOT COLLECT IT. Verified in the bundle: the ACH
  form renders an account-holder name, the two secure fields and an optional
  billing address, and nothing else. Adyen's ACH stores a bank account without
  it, but the hub's `paymentmethods.bankaccounttype` column has no other
  source, and "Bank account ••••1234" with no account type is a row an operator
  cannot reconcile against a bank statement. So the hub supplies the control,
  using the kit's own select, cloned into the panel beside the strip.
*/
.nsachtype { margin-top: var(--gap); }
.nsachtypelabel {
  display: block;
  color: var(--muted);
  font-size: var(--fontsizelabel);
  margin-bottom: 4px;
}

/* =========================================================================
   THE REVEAL (BILLING RUN 5) — the webhook log's raw body and the outbox's
   event payload.

   A `<pre>` in a monospace face, scrolling in BOTH directions and capped in
   height. Adyen's notification JSON is ~1.5 KB on one line before it is
   pretty-printed and a few dozen lines after; without the cap, opening one
   reveal pushes every row below it off the screen, and without the horizontal
   scroll a long `merchantReference` wraps mid-token where somebody is trying
   to read it.
   ========================================================================= */
.nsreveal {
  margin: 0;
  padding: var(--gapsm);
  background: var(--bg);
  border-top: 1px solid var(--line2);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--fontsizexs);
  line-height: 1.5;
  color: var(--ink);
  max-height: 320px;
  overflow: auto;
  /* `pre` so the pretty-printed indentation survives, and `tab-size` because
     json_encode(JSON_PRETTY_PRINT) indents with four spaces and a reader
     comparing this against the Customer Area wants the same shape. */
  white-space: pre;
  tab-size: 4;
}
.nsrevealerror { color: var(--bad); font-size: var(--fontsizexs); padding: var(--gapsm); }

/* A full-width row under a table row, for a reveal. `colspan` plus a muted
   background, so the opened body reads as belonging to the row above it rather
   than as another record. The padding matches `.nstable td` so the control
   lines up with the column it came from. */
.nstablereveal {
  background: var(--bg);
  border-top: 0;
  padding: var(--rowpad);
  /* `normal` so the <pre>'s own `white-space: pre` governs the body and the
     control above it can wrap: `.nstable td` sets `nowrap` for every other
     cell, which would make a long error message widen the table. */
  white-space: normal;
}
