/* =========================================================================
   nsbiz-components.css — NSBIZ's OWN components, built on kit tokens.

   PERMANENT, and the third of three NSBIZ stylesheets. They have three
   different jobs and keeping them apart is what makes "where does this rule
   go?" answerable:

     nsbiz-legacy.css      the COMPATIBILITY LAYER. Page-body rules rescued
                           out of the three old layouts. It only ever SHRINKS,
                           nothing is added, and when no page is left inside
                           it the file is deleted. REWORK-1.
     nsbiz-shell.css       PERMANENT. Places NSBIZ deliberately DIFFERS from
                           the kit — the mobile nav drawer, the surface tag,
                           the impersonation banner. A fix that belongs
                           upstream does not go here; it goes to the hub.
     nsbiz-components.css  PERMANENT, this file. Components NSBIZ needs that
                           the kit does not HAVE, because the hub's screens
                           do not ask the question. Built from kit tokens and
                           kit components; no colour, size or radius is
                           written as a literal.

   🔴 EVERY VALUE IN HERE IS A var(--token). Not one hex, not one px radius,
   not one font-size literal. That is the test of whether a component really
   is built on the kit or merely sits next to it — and it is what makes the
   whole file follow the day the kit's tokens change.

   Rendered by templates/_partials/nsb-macros.twig, which carries the
   rationale for each component. Documented in docs/UI-KIT-NSBIZ.md.
   ========================================================================= */

/* ----------------------------------------------------------- page header */
/*
  A title, one plain line saying what the page is for, and the page's primary
  action on the right. `flex-wrap` plus `min-width: 0` on the text column
  because a long description must wrap rather than push the action off the
  page — the kit's own non-optional layout rule, applied here.
*/
.nsbpagehead {
  display: flex;
  align-items: flex-start;
  gap: var(--gap);
  flex-wrap: wrap;
  margin: 0 0 var(--gutter);
  padding-bottom: var(--gapsm);
  border-bottom: 1px solid var(--line);
}
.nsbpageheadmain { flex: 1 1 24ch; min-width: 0; }
.nsbpagetitle {
  margin: 0;
  font-size: var(--fontsizeh1);
  font-weight: 600;
  color: var(--ink);
}
/*
  The description is `--ink2`, not `--muted`. Muted is for labels and meta —
  things you read only when you are looking for them. This line is the one
  sentence a first-time visitor actually reads, so it gets the darker of the
  two secondary inks.
*/
.nsbpagedesc {
  margin: 4px 0 0;
  font-size: var(--fontsizesm);
  color: var(--ink2);
  max-width: 72ch;
}
/*
  🔴 `flex-wrap: wrap` AND `min-width: 0` WERE MISSING, AND THAT WAS A LATENT
  OVERFLOW FROM THE DAY THIS WAS WRITTEN (found in NSBIZ-REWORK-3B).

  It is a flex ROW with no wrap, so everything in it sat on one line and
  anything wider than the container pushed the page sideways. REWORK-2's own
  screens never hit it because each passed a single short item — one count
  badge, or one "changes apply within 30 minutes" note. REWORK-3B passes two
  badges on some pages and a sentence with `<code>` in it on others, and
  `/settings/ai-models` panned 17px at 390 — caught by
  `components.spec.ts`'s pan measurement, which is exactly the thing that
  measurement exists for.

  Both properties are needed and they fix different halves: `flex-wrap` lets
  the ITEMS move to a second line, and `min-width: 0` lets a single long item
  shrink below its content width instead of forcing the row open. Prose wraps
  correctly in a wrapping flex container because each text run becomes an
  anonymous flex item, which is blockified and therefore breaks on words.
*/
.nsbpagemeta {
  margin-top: var(--gapxs);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gapxs) var(--gapsm);
  min-width: 0;
  font-size: var(--fontsizexs);
  color: var(--muted);
}
.nsbpageaction { flex: 0 0 auto; display: flex; gap: var(--gapsm); align-items: center; }

/* -------------------------------------------------------- section header */
.nsbsectionhead { margin: 0 0 var(--gapsm); scroll-margin-top: var(--gutter); }
.nsbsectiontitle {
  margin: 0;
  font-size: var(--fontsizeh2);
  font-weight: 600;
  color: var(--ink);
}
.nsbsectionhelp {
  margin: 2px 0 0;
  font-size: var(--fontsizesm);
  color: var(--muted);
  max-width: 76ch;
}

/* ------------------------------------------------------------- jump list */
/*
  Deliberately a row of chips rather than a stacked list: it sits under the
  page header, above the first section, and a vertical list there pushes the
  content it is advertising below the fold on a phone — which is the opposite
  of what a table of contents is for.
*/
.nsbjumplist {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--gapsm);
  margin: 0 0 var(--gutter);
  padding: var(--panelpad);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: var(--radiuspanel);
}
.nsbjumplabel {
  font-size: var(--fontsizelabel);
  font-weight: 600;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: .04em;
}
.nsbjumplist ul {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: var(--gapxs) var(--gapsm);
}
.nsbjumplist li { margin: 0; }
.nsbjumplist a {
  display: inline-block;
  padding: 2px var(--gapxs);
  border-radius: var(--radius);
  font-size: var(--fontsizesm);
  color: var(--brandlink);
  text-decoration: none;
}
.nsbjumplist a:hover { background: var(--brand50); text-decoration: underline; }

/* ----------------------------------------- advanced, collapsed but legible */
.nsbadvanced {
  border: 1px solid var(--line);
  border-radius: var(--radiuspanel);
  background: var(--surface);
  margin: var(--gap) 0;
}
.nsbadvancedsummary {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--gapsm);
  padding: var(--panelheadpad);
  cursor: pointer;
  list-style: none;
  min-height: var(--controlheight);
}
/* Safari and the old spec draw their own triangle; ours is the chevron. */
.nsbadvancedsummary::-webkit-details-marker { display: none; }
.nsbadvancedsummary::marker { content: ''; }
.nsbadvancedsummary:hover { background: var(--hover); }
.nsbadvancedsummary:focus-visible {
  outline: 2px solid var(--focusring);
  outline-offset: -2px;
}
.nsbadvancedchevron {
  flex: 0 0 auto;
  color: var(--muted);
  transition: transform .12s ease;
  /* The glyph's own box is taller than the cap height of the title beside it,
     so baseline alignment would hang it low. */
  align-self: center;
  display: inline-flex;
}
.nsbadvanced[open] > .nsbadvancedsummary .nsbadvancedchevron { transform: rotate(90deg); }
.nsbadvancedtitle { font-weight: 600; font-size: var(--fontsizesm); color: var(--ink); }
/*
  THE SUMMARY OF CURRENT VALUES. It is visible closed AND open: hiding it on
  open would move the reader's reference point away exactly as they start
  changing the thing it describes.
*/
.nsbadvancedvalues {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gapxs) var(--gap);
  font-size: var(--fontsizexs);
  color: var(--ink2);
  margin-left: auto;
  /* 🔴 `min-width: 0` — NSBIZ-QUICKPOST-B. Without it a single long value
     forces the summary row open and the page pans. Every caller before
     this build passed SHORT values — a colour, a font name, a count — so
     the fault was latent from the day the component was written, exactly
     as `.nsbpagemeta`'s was until REWORK-3B passed it two badges. The
     quick-post channel line is "Blog · Facebook Page · Google Business
     Profile", and `/post/quick` panned 4px at 390. */
  min-width: 0;
}
/* 🔴 A PAIR WRAPS BETWEEN WORDS AND ITS LABEL DOES NOT.
   This was `white-space: nowrap` on the whole pair, which keeps "Primary
   #0033A0" together — the intent — and also makes a long value
   unshrinkable, which is the pan above. The label is the part that must
   not be orphaned from its value, so the label is what carries `nowrap`;
   a short pair still sits on one line because it fits. */
.nsbadvancedpair { white-space: normal; min-width: 0; }
.nsbadvancedlabel { color: var(--muted); margin-right: 4px; white-space: nowrap; }
.nsbadvancedbody { padding: var(--panelpad); border-top: 1px solid var(--line2); }

/* ----------------------------------------- the settings area + its rail */
/*
  Two columns at desktop width, one below 900px. `min-width: 0` on the content
  column is the kit's non-optional rule again: without it a wide table inside
  the column sizes the column, and the whole page pans sideways.
*/
.nsbsettingsarea {
  display: flex;
  align-items: flex-start;
  gap: var(--gutter);
}
.nsbsettingsmain { flex: 1 1 auto; min-width: 0; max-width: 100%; }

.nsbsectionlist { flex: 0 0 var(--sidewidth); }
.nsbsectionlistmobile { display: none; }
.nsbsectionlistdesktop {
  display: flex;
  flex-direction: column;
  gap: 1px;
  position: sticky;
  top: var(--guttertop);
}
.nsbsectionlist .nsbsectiongroup {
  font-size: var(--fontsizelabel);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--muted);
  padding: var(--gapsm) var(--gapxs) var(--gapxs);
}
.nsbsectionlist .nsbsectiongroup:first-child { padding-top: 0; }
.nsbsectionlist a {
  display: block;
  padding: var(--rowpad);
  border-radius: var(--radius);
  font-size: var(--fontsizesm);
  color: var(--ink2);
  text-decoration: none;
  min-height: var(--controlheight);
  line-height: 1.5;
}
.nsbsectionlist a:hover { background: var(--sidehover); text-decoration: none; }
.nsbsectionlist a.nsbon {
  background: var(--brand50);
  color: var(--brand);
  font-weight: 600;
}

@media (max-width: 900px) {
  .nsbsettingsarea { flex-direction: column; gap: var(--gapsm); }
  .nsbsectionlist { flex: 1 1 auto; width: 100%; align-self: stretch; }
  .nsbsectionlistdesktop { display: none; }
  .nsbsectionlistmobile {
    display: block;
    border: 1px solid var(--line);
    border-radius: var(--radiuspanel);
    background: var(--surface);
  }
  .nsbsectionlistmobile > summary {
    display: flex;
    align-items: center;
    gap: var(--gapsm);
    padding: var(--panelheadpad);
    min-height: 44px;
    cursor: pointer;
    list-style: none;
    font-size: var(--fontsizesm);
  }
  .nsbsectionlistmobile > summary::-webkit-details-marker { display: none; }
  .nsbsectionlistmobile > summary::marker { content: ''; }
  .nsbsectionlistcurrent { font-weight: 600; color: var(--ink); }
  .nsbsectionlistmore { margin-left: auto; color: var(--brandlink); }
  .nsbsectionlistitems {
    padding: var(--gapxs);
    border-top: 1px solid var(--line2);
    display: flex;
    flex-direction: column;
    gap: 1px;
  }
  /* 44px targets below 900px, which is the kit's rule for both densities. */
  .nsbsectionlist a { min-height: 44px; display: flex; align-items: center; }
  .nsbpageaction { width: 100%; }
}

/* ------------------------------------------------- review-links rows only */
/*
  Three rules for one page, and they are here rather than in a `<style>` block
  on that page because `nsbiz-components.css` is the only place an NSBIZ rule
  is allowed to live now. They are layout-only — no colour, no size literal —
  and each exists because the kit has a row primitive but no opinion about
  which column grows.
*/
.nsbrlfields { align-items: flex-end; gap: var(--gapsm); flex-wrap: wrap; }
.nsbrllabel  { flex: 0 0 9rem; }
.nsbrlactions { gap: var(--gapxs); flex-wrap: wrap; margin-top: var(--gapsm); }
.nsbrladd { gap: var(--gapxs); flex-wrap: wrap; margin: var(--gap) 0; }
.nsbrlnote {
  margin: var(--gapsm) 0 0;
  font-size: var(--fontsizexs);
  color: var(--muted);
}

/* --------------------------------- the service-area "primary" marker */
/*
  Was `style="color:#0a7a3b"` inline, a green that exists nowhere else in the
  product. `--ok` is the token that means "this is the good one".
*/
.nsbprimary { color: var(--ok); display: inline-flex; align-items: center; gap: 4px; }

/* ------------------------------------------------ blog-appearance form */
/*
  Layout only, and only what the kit has no opinion about: which column of a
  label/URL pair grows, and how wide a native colour swatch is. The kit styles
  inputs, textareas and selects all through `.nsinput` (with element-qualified
  overrides), so nothing here restyles a control — `.nsbcolor` exists because
  `<input type="color">` is a swatch rather than a text field and `.nsinput`'s
  full width would draw a 460px block of colour.
*/
.nsbchromeform { max-width: 72ch; }
.nsbchromerow { gap: var(--gapxs); flex-wrap: wrap; align-items: center; margin-bottom: var(--gapxs); }
.nsbchromelabel { flex: 0 0 9rem; }
.nsbchromesave { margin-top: var(--gutter); }
.nsbcolor {
  width: 56px;
  height: var(--controlheight);
  padding: 0;
  border: 1px solid var(--inputline);
  border-radius: var(--radius);
  background: var(--surface);
}

/* ----------------------------------------------------- the jobs filter row */
/*
  The filter strip and the sort links share one line. `align-items: center`
  rather than baseline because `.nstabs` has its own 1px rail along the bottom
  and a baseline-aligned sibling sits on top of it.
*/
.nsbjobsfilter { align-items: center; flex-wrap: wrap; gap: var(--gap); margin: var(--gap) 0; }
.nsbjobssort { font-size: var(--fontsizexs); color: var(--muted); align-self: center; }
.nsbjobssort a { color: var(--muted); }
.nsbjobssort a.nsbon { font-weight: 700; color: var(--brand); }

/* ------------------------------------------- ONE token the kit does not have */
/*
  🔴 `--nsbai` IS THE ONE COLOUR NSBIZ DECLARES FOR ITSELF, and it is declared
  here exactly once rather than written as a literal in eight places.

  It means "this is the AI doing something": Generate with AI, the AI-drafted
  instruction panel, the sparkle controls. The kit has no such token because
  the hub has no such concept — its eleven pairs are STATUSES (paid, failed,
  expiring, draft) and an AI action is not a status, so reusing `--brand` would
  make "generate this for me" look like every other primary action and reusing
  `--ok` would say the result was already correct.

  It is also the one distinguishable colour left for a provider dot on the AI
  routing legend, where the others are already `--ok` and `--ink`.

  If a second Northstar app ever grows an AI surface, this belongs in the hub
  and this block goes away. Until then it is honestly local: one token, one
  meaning, one place. The tint is `color-mix()`d from it rather than a second
  hand-picked value, so the pair cannot drift.
*/
:root {
  --nsbai: #6B3FA0;
  --nsbaitint: color-mix(in srgb, var(--nsbai) 6%, var(--surface));
}

/* ----------------------------------- the admin issue tenant picker */
/*
  The field and the dropdown it anchors. NSBIZ-REWORK-3B moved these two out
  of the template's inline styles, where `position: relative` sat on the
  `<label>` and the panel's eight declarations sat on the `<ul>` — one
  decision written in two places, with nothing saying they were a pair.

  The kit has no combobox, which is the honest reason this is here: a
  text input that filters a list and writes a hidden field is a component, and
  `ui.field` is a label plus a control.
*/
.nsbtenantpickfield { position: relative; min-width: 240px; }
.nsbtenantpicklist {
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  z-index: 50;
  margin: 0;
  padding: var(--gapxs) 0;
  list-style: none;
  max-height: 240px;
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--inputline);
  border-radius: var(--radius);
  box-shadow: var(--shadowmodal);
}

/* ------------------------------------- the admin issue tenant-picker rows */
.nsbtenantpickrow {
  padding: var(--rowpad);
  cursor: pointer;
  font-size: var(--fontsizesm);
}
.nsbtenantpickrow:hover,
.nsbtenantpickrow:focus-visible { background: var(--brand50); }
.nsbtenantpickclear {
  font-size: var(--fontsizexs);
  color: var(--muted);
  border-top: 1px solid var(--line2);
}

/* ------------------------------------------------------------- one utility */
/* A badge in a flex row next to wrapping text must not be the thing that
   wraps. The kit has no nowrap utility; this is the only one NSBIZ needs. */
.nsbnowrap { flex: 0 0 auto; white-space: nowrap; }

/* A detail list flush inside a panel body that already has the padding. */
.nsbflush { margin: 0; }

/* ----------------------------------------------- four one-line utilities */
/*
  Each replaces an inline `style=` that appeared dozens of times and whose
  whole content was one kit token. `.nsmuted` is the kit's own and is used
  from the markup directly; these four are the ones it does not have.

  🔴 THIS LIST DOES NOT GROW. A utility class per possible declaration is a
  second stylesheet language, which is exactly what `nsbiz-legacy.css`'s 338
  hand-drawn classes already taught this repo. These four exist because they
  were each written out more than twenty times by hand; the next inline style
  somebody wants to remove belongs in a component, not here.
*/
.nsbbad { color: var(--bad); }
.nsbflushtop { margin-top: 0; }
.nsbinlineform { display: inline; }

/* ------------------------------------------------- the reorder arrow pair */
/*
  🔴 THE KIT HAS NO `chevronup`, SO MOVE-UP IS `chevrondown` ROTATED.

  Both reorder buttons render the same glyph and one is turned over, which is
  how a chevron pair is normally drawn anyway — the two arrows are then exactly
  mirror images rather than two paths that have to be kept in step. `transform`
  on an inline SVG, not a second glyph upstream: adding `chevronup` to the hub
  would be adding a path that is the existing one reflected, which is a
  stylesheet's job.

  The direction a screen reader announces is on the BUTTON's `aria-label`, so
  the rotation carries no meaning that is only visible.
*/
.list-row-move[data-test="list-row-move-up"] svg { transform: rotate(180deg); }

/* ---------------------------------- rows that used to pan a phone sideways */
/*
  Each of these replaces an inline `min-width` in a non-wrapping flex row.
  `components.spec.ts` MEASURES the document's horizontal pan at 390 on 29
  screens, and these three are what it found: a 14rem label beside a state
  beside three buttons comes to well over 390px, and a flex row with no
  `wrap` simply gets wider than its container rather than reflowing.

  The pattern in all three: a sensible minimum at desktop width, expressed as
  a `flex-basis` that is allowed to shrink, instead of a `min-width` that is
  not.
*/
.nsbchannelrow {
  display: flex;
  align-items: center;
  gap: var(--gapsm);
  flex-wrap: wrap;
  padding: var(--gapxs) 0;
  border-bottom: 1px solid var(--line2);
}
.nsbchannelname { flex: 1 1 10rem; min-width: 0; }
.nsbgbpurl { flex: 1 1 12rem; min-width: 0; }

.nsbsifilter { gap: var(--gapxs); flex-wrap: wrap; margin-left: auto; align-items: center; }
.nsbsisearch { flex: 1 1 12rem; min-width: 0; }

/* ====================================================== ONE MODAL LOOK ==== */
/*
  🔴 SIX HAND-ROLLED MODAL FAMILIES, ONE SET OF RULES, AND THE CLASS NAMES
  ARE ALL KEPT.

  NSBIZ had `.um-modal`, `.rml-modal`, `.pl-modal`, `.ttd-modal`, `.aw-modal`
  and `.compare-modal`, each declared in its own page's `<style>` block, each
  with its own backdrop opacity (.55, .45, .55, .45, .45), its own radius, its
  own shadow and its own z-index (50, 60, 1000, 1500). Six answers to one
  question, and no two dialogs on this product dimmed the page by the same
  amount.

  ── WHY NOT `ui/confirm.twig` AND A `<dialog>` ───────────────────────────

  `confirm.twig` was deliberately NOT copied into NSBIZ — the vendored
  `SOURCE.md` records why: it calls `csrffield()` and a `money` filter that do
  not exist here, and it is built around a destructive-money confirmation,
  which NSBIZ has not had since billing became the hub's. The kit's
  `nsmodal()` wraps a real `<dialog>`, and moving to one is the right end
  state: focus trapping and Escape then come from the platform instead of from
  four different widgets' JavaScript.

  **That rebuild is per-page work and belongs with each page group in Phase
  3** — it changes the open/close contract of the users widget, the AI routing
  widget, the posts bulk-delete and two confirm dialogs, each with its own
  Alpine state and its own specs. What this run does is end the SIX
  VOCABULARIES, which is Phase 2's job, without touching a single line of that
  JavaScript: the class names stay exactly as they are, so every `x-show`,
  every `querySelector` and every `data-test` keeps working, and the look
  arrives from one place.

  The backdrop is `--ink` at 45% rather than a literal `rgba(10,23,51,…)`:
  `color-mix()` reads the token, so the dim follows the ink.
*/
.um-modal,
.pl-modal,
.rml-modal-backdrop,
.ttd-modal-backdrop,
.aw-modal-backdrop,
.compare-modal-backdrop {
  position: fixed;
  inset: 0;
  background: color-mix(in srgb, var(--ink) 45%, transparent);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--gutter);
  /* One z-index for every modal on the platform. The four it replaces were
     50, 60, 1000 and 1500 — which is four guesses, and the reason a dialog
     on one screen could sit under a sticky element on another. */
  z-index: 100;
}
.um-modal__panel,
.pl-modal__panel,
.rml-modal,
.ttd-modal,
.aw-modal {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radiuspanel);
  box-shadow: var(--shadowmodal);
  max-height: 90vh;
  overflow: auto;
}
/* The small confirm dialogs: a padded card. */
.um-modal__panel,
.pl-modal__panel,
.ttd-modal,
.aw-modal {
  width: min(30rem, 100%);
  padding: var(--panelpad);
}
/* The two large ones scroll their own body, so the panel must not. */
.rml-modal {
  width: min(900px, 100%);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.rml-modal form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.rml-modal-header,
.compare-modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--gapsm);
  padding: var(--panelheadpad);
  border-bottom: 1px solid var(--line);
  flex: 0 0 auto;
}
.rml-modal-body { padding: var(--panelpad); overflow-y: auto; flex: 1 1 auto; min-height: 0; }
.rml-modal-footer {
  padding: var(--panelheadpad);
  border-top: 1px solid var(--line);
  display: flex;
  justify-content: flex-end;
  gap: var(--gapxs);
  flex: 0 0 auto;
  flex-wrap: wrap;
}

/* Titles and action rows, shared across all six. */
.um-modal__title,
.pl-modal__title,
.rml-modal-header h2,
.ttd-modal h3,
.aw-modal h3 {
  margin: 0 0 var(--gapsm);
  font-size: var(--fontsizeh2);
  font-weight: 600;
  color: var(--ink);
}
.um-modal__actions,
.pl-modal__actions,
.ttd-modal-actions,
.aw-modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--gapxs);
  margin-top: var(--gapsm);
  flex-wrap: wrap;
}

/* ---------------------------------------------------------- a back-crumb */
/*
  A "back to the list" link under a tab strip. The two that existed used
  `margin: -.5rem 0 1rem` to sit tight under the OLD strip, and `.nstabs`
  carries its own 1px bottom rail — so the negative margin put the crumb on
  top of it. The chevron is `chevrondown` rotated a quarter turn, for the
  reason given at the reorder pair: the kit has no left or up chevron, and
  adding a reflected path upstream is a stylesheet's job.
*/
.nsbcrumb { margin: var(--gapsm) 0 var(--gap); font-size: var(--fontsizesm); }
.nsbcrumb a { color: var(--muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px; }
.nsbcrumb a:hover { color: var(--brand); text-decoration: underline; }
.nsbcrumb a svg { transform: rotate(90deg); }

/* -------------------------------------------- the team table's byline cell */
.nsbbylineform { gap: var(--gapxs); align-items: center; flex-wrap: nowrap; }
/*
  The public byline input. Narrow on purpose: this is a six-column table and
  the byline is the one free-text cell in it, so every rem it takes comes off
  the Actions column at the far right — the column with Remove in it, which
  both the before AND after screenshots of REWORK-3C showed clipped off the
  edge of a 1440 screen before this was tightened.
*/
.nsbbyline { width: 6.5rem; min-width: 0; }

/* =============================================== NSBIZ-REWORK-3A additions */

/* ------------------------------------------- the auth card, in the shell */
/*
  The kit HAS the auth card — `.nsauthcard` and its brand/title/hint/foot
  parts, plus `.nsauthcard .nsbtn { width: 100% }`. What it does not have is a
  way to centre one INSIDE a page: `.nsauthshell` is `min-height: 100vh` and
  `display: flex`, written for a document that IS the card. NSBIZ puts the card
  in `_shell.twig`'s content column, under the brand bar, so a 100vh child of
  `<main>` would make every sign-in screen taller than the viewport by the
  height of the header.

  So this is the one piece: centre the card, and give it room above so it does
  not sit against the brand bar. `.nsauthcard` itself is the kit's and is not
  touched — which is what makes the sign-in screens look like the hub's.
*/
.nsbauthwrap {
  display: flex;
  justify-content: center;
  padding: calc(var(--gap) * 2) 0;
}
/* The hint paragraph and the "use the other method" switch under a form. */
.nsbauthalt { text-align: center; font-size: var(--fontsizesm); margin: 0; }

/* -------------------------------------------------------------- a meter */
/*
  🔴 THE ONE INLINE `style=` THE RATCHET ALLOWS, AND WHY.

  A pillar score bar is a width the SERVER computes per row, so it cannot be a
  class: a stylesheet would need one rule per percentage. The kit has no meter
  — the hub shows trends as `ui.sparkline`, which is a different claim (a
  series over time, not a fraction of a whole).

  The width arrives as a CUSTOM PROPERTY in one attribute,
  `style="--nsbmeterpct: 72%"`, rather than as `style="width:72%"`, for two
  reasons:

    1. `bin/check-templates.php`'s ratchet can then tell a computed value from
       a hand-written one — it allows a `style=` whose content starts with `--`
       and refuses every other one, so the exception cannot quietly widen into
       "and some colours".
    2. The FALLBACK lives in the stylesheet (`var(--nsbmeterpct, 0%)`), so a
       row whose score is null renders an EMPTY bar rather than a full one.
       `width:` with an unset value would have given the opposite, and a
       pillar nobody has measured would read as 100.

  🔴 AND IT DOES NOT SURVIVE A CSP. The probe's Phase 5 item is
  `style-src 'self'`, and that blocks the whole inline `style` attribute,
  custom property or not — `style-src-attr 'unsafe-inline'` would be needed to
  keep it. So this is a KNOWN EXCEPTION, listed as one in the REWORK-3A
  report, not a solved problem. The fix when the CSP lands is a server-set
  class per bucket (0, 10, 20 … 100), which is eleven rules and loses one
  significant figure nobody reads off a 10px bar.
*/
.nsbmeter {
  background: var(--line2);
  height: var(--gapsm);
  border-radius: var(--radiuspill);
  overflow: hidden;
}
.nsbmeter > span {
  display: block;
  height: 100%;
  background: var(--brand);
  width: var(--nsbmeterpct, 0%);
}

/* ------------------------------------------- a row whose actions may wrap */
/*
  The kit's `.nsrow` is `display: flex` with NO wrap, which is right for its
  own rows (a dot, a sentence, a link). A Things-to-do row carries a severity
  badge, a title, a description AND four controls, and at 390px those cannot
  share one line — unwrapped they push the page sideways, which
  `components.spec.ts` measures directly.

  So this is a MODIFIER used alongside `.nsrow`, not a replacement: the
  padding, the 1px rule and the `:last-child` exception stay the kit's.
*/
.nsbactionrow { flex-wrap: wrap; align-items: flex-start; }
.nsbactionrow > .nsinline { margin-left: auto; }
@media (max-width: 700px) {
  /* Below this the actions get the full width rather than a cramped column,
     and `margin-left: auto` would hold them against the right edge. */
  .nsbactionrow > .nsinline { margin-left: 0; flex: 1 1 100%; }
}

/* =============================================== NSBIZ-REWORK-3B additions */
/*
  Four components, for the admin screens and the onboarding wizard. Every
  value is a `var(--token)`, which is the test this file's header states.
*/

/* -------------------------------------------- a kit BLOCK that is a LINK */
/*
  🔴 FIVE SEPARATE COUNT-TILE IMPLEMENTATIONS BECAME `ui.statcard`, AND ONE
  THING STOPPED IT BEING ALL OF THEM: the admin dashboard's tiles and the
  admin settings rows NAVIGATE. The kit renders `.nsstat` as a `<div>` and
  `.nstwoline` as a `<div>`, and it is right to — a KPI on a billing dashboard
  is a number, not a destination.

  So this is the three things a whole-block link needs and a `<div>` does not:
  no underline, the body colour rather than brand blue, and a focus ring,
  since neither block was ever a focusable element. The HOVER is `.nstwoline`'s
  own; `.nsstat` has none, so it gets the same `--hover` tint and a `--brand`
  border, which is what the sidebar items and the two-line list already use.

  It is NOT a second tile or a second row component. Everything about the look
  comes from the kit class beside it — take `.nsbblocklink` off and you have
  the kit's block, unstyled as a link. That is the property that keeps the two
  from drifting, and it is why this is a modifier rather than a component.
*/
a.nsbblocklink { color: var(--ink); text-decoration: none; }
a.nsbblocklink:hover { background: var(--hover); text-decoration: none; }
a.nsstat.nsbblocklink:hover { border-color: var(--brand); }
a.nsbblocklink:focus-visible { outline: var(--focusring); outline-offset: 2px; }

/* ------------------------------------------- MULTI-SELECT filter chips */
/*
  The admin tenant grid filters on five dimensions at once — status, health,
  business type, GSC, score band — and more than one value per dimension.

  🔴 THAT IS THE ONE FILTER SHAPE THE KIT DOES NOT HAVE, and the kit's own
  four-way distinction (docs/UI-KIT-NSBIZ.md) is why: `ui.tabs` is one panel
  at a time, `nsb.filtertabs` is one URL at a time, `.nsseg` is one segment at
  a time. All three are single-select. A chip group is a checkbox set wearing
  pills, which is why the markup carries `role="group"` and `aria-pressed`
  rather than `aria-current` — the fourth row of that table in the docs, the
  one it says is "written at the call site".

  🔴 THE CLASS NAMES ARE A CONTRACT WITH `public/js/admin-datatable.js`, which
  finds chips by `.nsbchip[data-group]` and toggles `.nsbchipon` on them. They
  were `.chip` / `.chip-on` — renamed here because an unnamespaced `.chip` in
  a PERMANENT stylesheet is a collision waiting for the next screen that wants
  a chip, and the composer already has four different ones. The JS header
  records the same contract; change both or neither.
*/
.nsbchipgroup {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gapxs);
  margin-bottom: var(--gapxs);
}
.nsbchiplabel {
  font-size: var(--fontsizelabel);
  color: var(--muted);
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  min-width: 90px;
}
.nsbchip {
  font: inherit;
  font-size: var(--fontsizesm);
  line-height: 1.2;
  padding: var(--gapxs) var(--gapsm);
  border: 1px solid var(--inputline);
  border-radius: var(--radiuspill);
  background: var(--surface);
  color: var(--ink);
  cursor: pointer;
  user-select: none;
}
.nsbchip:hover { background: var(--brand50); border-color: var(--brand); }
.nsbchip.nsbchipon {
  background: var(--brand);
  border-color: var(--brand);
  color: var(--surface);
}
.nsbchip.nsbchipon:hover { background: var(--brandhover); }
.nsbchip:focus-visible { outline: var(--focusring); outline-offset: 1px; }
@media (max-width: 900px) {
  /* §12: every control is 44px below 900px, and a chip is a control. The
     label stops reserving a column and becomes its own line, because at 390
     a 90px label plus four chips is four chips one per row. */
  .nsbchip { min-height: 44px; padding-inline: var(--gap); }
  .nsbchiplabel { min-width: 100%; }
}

/* ------------------------------------- a table row that needs attention */
/*
  The connections grid is sorted worst-first and every row carries a status
  badge, so the state is already stated twice. This is the third statement and
  it is the one that works at a glance down a 60-row table: a 3px red gutter
  on the first cell.

  The kit has the same idea for an adjustment row (`inset 2px 0 0 var(--line)`
  on `.nslinerow`), so this keeps its own width and colour and re-anchors onto
  `.nstable`. It is a MODIFIER on the row, so the table's own borders,
  padding and zebra stay the kit's.
*/
.nstable tr.nsbrowattention td:first-child { box-shadow: inset 3px 0 0 var(--bad); }

/* --------------------------------------- the onboarding step indicator */
/*
  🔴 THE KIT HAS `.nssteplist` / `.nsstep` / `.nsstepnumber` AND THIS IS NOT
  THAT. The kit's is a `flex-direction: column` list with `border-top`
  separators: the invoice LIFECYCLE, "these things happened to this record,
  in order, and here is when". A wizard indicator makes the opposite claim —
  "you are here, these are done, these are still to come" — and nothing in it
  has happened to a record.

  Wearing the kit's class would make two different claims look identical,
  which is the exact failure `nsb.statusbadge` exists to end one level down.
  So it is its own component, and the reason it is HERE rather than in the hub
  is the one this file's header gives: the hub is a billing console with no
  onboarding, because its two operators were onboarded once, in person. If a
  second Northstar app grows a wizard, this goes upstream and this block is
  deleted.

  ── WHY IT IS A LIST OF LINKS AND NOT A PROGRESS BAR ────────────────────

  A bar says how far along you are. A new tenant on a phone needs three other
  things first — what this step is, what is still coming, and how to get back
  to one they have already done without losing what they typed. So each
  completed step is an `<a>` to its own URL (every step is a GET, and the
  wizard re-renders from stored settings, so going back is lossless), the
  current one is `aria-current="step"`, and the ones ahead are plain text:
  they are reachable, but offering them would skip the step the reader is on.

  ── BELOW 640px IT BECOMES ONE LINE ─────────────────────────────────────

  Eight pills at 390 is three rows of chrome above a form, which pushes the
  first field off the screen on the one device most of these tenants will use.
  So the strip collapses to the current step alone, with the count beside it,
  and the back link is the form's own — the same reasoning the settings rail's
  `<details>` carries, reaching the same place by a simpler route because
  there is nothing here to choose.
*/
.nsbwizard {
  display: flex;
  flex-direction: column;
  gap: var(--gapsm);
  margin-bottom: var(--gap);
}
.nsbwizardcount {
  font-size: var(--fontsizesm);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.nsbwizardsteps {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gapxs);
  list-style: none;
  margin: 0;
  padding: 0;
}
.nsbwizardsteps > li { display: flex; }
.nsbwizardstep {
  display: inline-flex;
  align-items: center;
  gap: var(--gapxs);
  font-size: var(--fontsizesm);
  line-height: 1.2;
  padding: var(--gapxs) var(--gapsm);
  border: 1px solid var(--line);
  border-radius: var(--radiuspill);
  background: var(--surface);
  color: var(--muted);
  text-decoration: none;
}
.nsbwizardstep .nsbwizardnum {
  flex: 0 0 18px;
  height: 18px;
  border-radius: var(--radiuspill);
  background: var(--nbg);
  color: var(--n);
  font-size: var(--fontsizexs);
  font-weight: 600;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
a.nsbwizardstep { color: var(--ink); }
a.nsbwizardstep:hover { border-color: var(--brand); background: var(--hover); text-decoration: none; }
a.nsbwizardstep:focus-visible { outline: var(--focusring); outline-offset: 1px; }
.nsbwizardstep.nsbdone .nsbwizardnum { background: var(--okbg); color: var(--ok); }
.nsbwizardstep.nsbon {
  border-color: var(--brand);
  background: var(--brand50);
  color: var(--ink);
  font-weight: 600;
}
.nsbwizardstep.nsbon .nsbwizardnum { background: var(--brand); color: var(--surface); }

/*
  The time estimate, which rides in the page header's `meta` slot rather than
  in a component of its own.

  🔴 THAT SLOT IS THE RIGHT ONE AND IT TOOK A SECOND DRAFT TO SEE IT.
  `nsb.pageheader`'s contract is title / one line of purpose / primary action,
  with `meta` for "things about the page's STATE rather than its purpose". A
  first draft of this group added a `wizardpurpose` macro that rendered the
  purpose line AGAIN under the header that already carried it, plus the time —
  a second component duplicating the first. The purpose belongs in
  `description`, which is already required and already non-empty, and
  "about 2 minutes" is a fact about this step rather than its purpose, so it
  belongs in `meta`. One component, both halves of the prompt's ask.
*/
/*
  NOT `white-space: nowrap`, which the first draft had: this is a SENTENCE
  ("About two minutes, and you can change any of it later in Settings"), and an
  unbreakable sentence in the header's meta row is 390px of horizontal pan on
  the one screen this group is explicitly mobile-first about. `nowrap` is right
  for a bare duration and wrong the moment it carries a clause.
*/
.nsbwizardtime { color: var(--muted); font-size: var(--fontsizesm); }

/*
  Below 640 the strip goes and `.nsbwizardcount` carries the state on its own —
  it says "Step 3 of 8 · Business description", which is the same three facts
  the strip shows minus the names of the steps ahead. Getting back is the
  form's own Back control, which is where a thumb already is.
*/
@media (max-width: 640px) {
  .nsbwizardsteps { display: none; }
}

/* ------------------------------- two value tones the kit's statcard lacks */
/*
  `ui.statcard` has one tonal value class — `.nsbadvalue`, which is red. The
  admin dashboard needs two more, and the reason it needs them is that its
  tiles are a TRIAGE ROW rather than a KPI row: "at risk" and "attention
  needed" are two different severities an operator ranks by eye, and "dormant"
  is a count that is not a problem at all.

  Painting all three red would make the row say three times that something is
  wrong, which is how a red number stops meaning anything — the same argument
  `nsb.severitybadge` makes for collapsing `critical` and `high` onto one red
  rather than inventing a second.

  If a second Northstar app grows a triage row, these two belong in the hub
  beside `.nsbadvalue` and this block goes away.
*/
.nsstatvalue.nsbwarnvalue  { color: var(--warn); }
.nsstatvalue.nsbmutedvalue { color: var(--muted); }

/* ------------------------------------------------- a raw provider payload */
/*
  `pre.evidence` was a compatibility-layer rule — one of the ten lifted out of
  `templates/admin/_layout.twig` — so the admin issue page's two JSON blocks
  would have lost their scroll box and their tint on leaving the layer. This
  is the same rule on kit tokens.

  🔴 THE `max-height` IS THE WHOLE COMPONENT. A check's `evidence` column can
  hold a few hundred lines of a provider's response, and without a cap the
  page becomes that payload — which is why the original had one too. The kit
  has no equivalent because the hub never shows a provider's raw body to an
  operator; NSBIZ does, on the one screen where "why did this check fire" is
  the question.
*/
.nsbevidence {
  background: var(--bg);
  padding: var(--gapsm);
  border-radius: var(--radius);
  overflow: auto;
  max-height: 260px;
  font-size: var(--fontsizexs);
  margin: 0;
}

/* ========================= the admin tenant grid (NSBIZ-REWORK-3B) ======= */
/*
  `public/js/admin-datatable.js` builds this grid, its pager, its contact
  popover and its two saved-view modals in JavaScript, which is why these
  rules are here rather than in a template: there is no template to put them
  in, and the strings they style are concatenated in a `.js` file where
  `check-templates.php`'s ratchet cannot see them. That blind spot is how
  eight hex values and fifteen inline styles survived two restyles on the
  product's busiest admin screen.
*/

/* The select-all column. A width, which is layout the kit cannot know. */
.nstable th.nsbcheckcol { width: 1.5rem; }

/* A sortable header. The cursor was `th.style.cursor = 'pointer'`, written
   per element on every render — a design decision with one answer, which is
   the kind the kit says belongs in CSS. */
.nstable th[data-sort] { cursor: pointer; user-select: none; }
.nstable th[data-sort]:hover { color: var(--brand); }

/* ---------------------------------------- the contact-info popover */
/*
  Hovering the ⓘ beside a tenant's name shows their contact block. The LOOK is
  here; the coordinates stay in JavaScript, because they are computed from
  `getBoundingClientRect()` and are the geometry case the kit's note allows.

  Its three hardcoded hex values are the reason this block exists — see the
  long note at `ensureContactPopover()`.
*/
.nsbcontacticon {
  display: inline-block;
  margin-left: var(--gapxs);
  color: var(--brand);
  cursor: help;
  line-height: 1;
  user-select: none;
}
.nsbcontacticon:focus-visible { outline: var(--focusring); outline-offset: 1px; }
.nsbcontactpopover {
  position: absolute;
  z-index: 1500;
  max-width: 320px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadowmodal);
  padding: var(--panelpad);
  font-size: var(--fontsizesm);
  line-height: 1.4;
  color: var(--ink);
}

/* ------------------------------------- the saved-view modal host */
/*
  🔴 NOT A `<dialog>`, AND THAT IS A DEFERRAL RATHER THAN A DECISION.
  `admin-datatable.js` writes `innerHTML` into `#adt-modal` and wires its own
  Escape and click-outside handlers; moving it to `<dialog>` means changing
  that contract and the five handlers around it, which is the same
  "open/close contract of a widget with its own state" REWORK-2 deliberately
  left alone. What this pass fixes is that the box was drawn with six inline
  declarations and four hex values — it wears `.nsmodal`'s own look now, from
  the same tokens, so the two kinds of modal on this product no longer
  disagree about what a modal looks like.
*/
.nsbmodalbackdrop {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  /* The kit's own backdrop tint, from its `.nsmodal::backdrop`. */
  background: rgb(10 23 51 / 45%);
}
.nsbmodalpanel {
  background: var(--surface);
  border-radius: var(--radiuspanel);
  box-shadow: var(--shadowmodal);
  width: 100%;
  max-width: 520px;
  min-width: 0;
  max-height: 90vh;
  overflow: auto;
  padding: var(--panelpad);
  /* 16px of gutter at 390, which is the page standard's floor. */
  margin: var(--gap);
}
.nsbmodaltitle {
  margin: 0 0 var(--gapsm);
  font-size: var(--fontsizeh2);
}

/* ----------------------------- a section heading INSIDE a table */
/*
  The AI-costs table mixes two kinds of row: models that spent money, and
  models that only failed. The second group needs a heading, and the heading
  has to be a `<tr>` or it cannot sit between two `<tbody>` rows.

  The kit has no equivalent because no hub table mixes two row kinds — its
  lists are homogeneous and a second kind would be a second table. Here the
  columns are identical and splitting them would mean reading two
  thirteen-column headers.

  The tint is the only thing distinguishing it from a data row, so it is
  load-bearing rather than decoration — which is why it is a rule and not an
  `<em>`.
*/
.nstable tr.nsbtablesubhead td {
  background: var(--bg);
  font-weight: 600;
  color: var(--ink2);
}

/* ------------------------------------------- two table column widths */
/*
  A column width is layout the kit cannot know, and these two were
  `<th style="width:28px">` and `<th style="width:90px">` — which the ratchet
  refuses, correctly, because a width in an attribute is invisible to every
  tool that reads stylesheets.

  `.nsbmovecol` holds the reorder pair; `.nsbnarrowcol` holds a lone
  checkbox. Both exist so the cell does not take a share of the table's free
  space that its content cannot use.
*/
.nstable th.nsbmovecol { width: 4.5rem; }
.nstable th.nsbnarrowcol { width: 6rem; }

/* ===================================================================== */
/* NSBIZ-REWORK-3C — the settings page bodies and the shared managers     */
/* ===================================================================== */
/*
  The three shared manager bodies (keyword curator, signature manager,
  writing-instructions manager) are included by SIX host templates across two
  surfaces, and between them they carried 168 inline `style=` attributes. Each
  one was a styling decision taken in a partial, rendered on two surfaces, and
  checked by nothing — the ratchet reads whole templates and these partials
  were not on it, because the pages that include them had not been converted.

  🔴 AND THE COST WAS NOT ONLY TIDINESS. Every one of the three drew its
  columns with `class="panel"`, which lives in `nsbiz-legacy.css` and applies
  only inside `.nslegacy`. Both host surfaces left that layer in REWORK-2, so
  the panels had been rendering as bare unbordered `<div>`s on every one of
  those six screens for a phase and a half. A shared partial inherits the
  layer decision of whichever page includes it, which is an argument for the
  partial naming kit classes rather than host-provided ones.
*/

/* --------------------------------------------- the AI action vocabulary */
/*
  `--nsbai` has existed since REWORK-2 and was spelled out in SIX inline
  styles in the writing-instructions manager and nowhere else — a token whose
  only callers were the thing the token exists to stop. These three give it
  the two shapes it is actually used in.

  `.nsbai` is an OUTLINE button: "ask the AI" is an offer, not the page's
  primary action, and a solid purple button beside a solid blue one makes a
  reader choose between two primaries. `.nsbaifill` is the solid form, for the
  one control inside an AI panel that accepts its output — there the AI result
  IS the subject, so its accept button is the primary of that panel.
*/
.nsbtn.nsbai {
  border-color: var(--nsbai);
  color: var(--nsbai);
  background: var(--surface);
}
.nsbtn.nsbai:hover { background: var(--nsbaitint); }
.nsbtn.nsbaifill {
  border-color: var(--nsbai);
  background: var(--nsbai);
  color: var(--surface);
}
.nsbtn.nsbaifill:hover { background: var(--nsbai); filter: brightness(1.1); }
/* The panel an AI draft lands in, and its heading. */
.nsbaipanel {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--nsbaitint);
  padding: var(--panelheadpad);
}
.nsbaititle { color: var(--nsbai); font-weight: 600; }

/* ------------------------------------------------- two more text tones */
/*
  `.nsbbad` already exists above. `.nsbok` is its pair, and both are used on
  `.nsbtn.nsicon` row actions where the kit's own `.nsdanger` would repaint
  the whole button rather than the glyph.
*/
.nsbok { color: var(--ok); }

/* ----------------------------------------------- two width utilities */
/*
  `.nsinput` is `width: 100%`, which is right in a `.nsfield` column and wrong
  for the two controls that must not grow: a `<select>` whose options are one
  word, and a 0–100 priority box. These replace `style="width:5rem"` and
  friends, which appeared eleven times across the three partials at four
  different values for the same two jobs.
*/
.nsinput.nsbwauto { width: auto; min-width: 0; }
.nsfield.nsbwnum { flex: 0 0 5.5rem; }
.nsfield.nsbwnum .nsinput { width: 100%; }

/* ------------------------------------------------- the keyword curator */
/*
  A grouped list rather than a table: the rows are grouped by keyword type and
  Alpine's `x-for` template takes exactly one root element, so a type heading
  and its rows cannot both come out of one `<template>` inside a `<tbody>`.
  `.nsrow` gives the kit's row metrics to an `<li>`, which is what the four
  specs that address these rows by `li:has-text(...)` require it to stay.
*/
.nsbkwgroup {
  margin: 0;
  padding: var(--panelheadpad);
  font-size: var(--fontsizexs);
  font-weight: 600;
  color: var(--muted);
  text-transform: capitalize;
  background: var(--bg);
  border-bottom: 1px solid var(--line2);
}
.nsbkwlist { list-style: none; margin: 0; padding: 0; }
/* The priority number. Right-aligned and tabular so a column of them reads
   as a column, which is the one thing a number in a flex row loses. */
.nsbkwprio {
  flex: 0 0 2.5rem;
  text-align: right;
  color: var(--muted);
  font-size: var(--fontsizexs);
  font-variant-numeric: tabular-nums;
}
.nsrow.nsbkwarchived { color: var(--muted); }
.nsrow.nsbkwarchived .nsrowgrow { text-decoration: line-through; }
/* A suggestion carries the model's reason under the keyword, so its row is
   top-aligned — a two-line body centred against a one-line button column
   puts the button beside the reason instead of beside the keyword. */
.nsrow.nsbkwsuggestion { align-items: flex-start; }
.nsbkwreason { font-style: italic; }
/*
  The add form sits in the panel foot. It wraps, because at 390 four controls
  on one line is four controls off the right edge.

  🔴 AND THE KEYWORD FIELD NEEDS A FLEX BASIS, NOT JUST `flex-grow`. With
  `.nsrowgrow`'s `flex: 1 1 0` it has a zero basis, so at 390 it does not
  wrap — it SHRINKS, to about 12px, with its label sitting on top of the one
  beside it. The field is still there and still focusable, so the page has no
  horizontal pan and `components.spec.ts` was green throughout; only the
  `after` screenshot showed it. A 12rem basis makes the field claim a line of
  its own instead.

  Worth stating as a rule: **zero pan is necessary and not sufficient.** A
  control that collapses rather than overflows passes every measurement this
  rework takes.
*/
.nsbkwadd { display: flex; align-items: flex-end; gap: var(--gapsm); flex-wrap: wrap; }
.nsbkwadd > .nsfield.nsrowgrow { flex: 1 1 12rem; }

/* ------------------------------------------------ a GENERIC modal pair */
/*
  The REWORK-2 unification named the six modals it found and deliberately kept
  their class names so no JavaScript or spec changed. A SHARED partial has no
  page to take a name from, and borrowing one — `.ttd-modal` on the keyword
  curator — would tie this dialog to the Things-to-do screen for ever.

  So: one generic name, joined to the SAME rule blocks above rather than given
  rules of its own. Nothing here is a new look; `.nsbmodalback` and
  `.nsbmodalcard` are additional selectors on the existing group, declared at
  the group so the two cannot drift. A seventh vocabulary would be the thing
  REWORK-2 removed.
*/
.nsbmodalback {
  position: fixed;
  inset: 0;
  background: color-mix(in srgb, var(--ink) 45%, transparent);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--gutter);
  z-index: 100;
}
.nsbmodalcard {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radiuspanel);
  box-shadow: var(--shadowmodal);
  max-height: 90vh;
  overflow: auto;
  width: min(30rem, 100%);
  padding: var(--panelpad);
}
.nsbmodalcardwide { width: min(900px, 100%); }
.nsbmodalactions {
  display: flex;
  justify-content: flex-end;
  gap: var(--gapxs);
  margin-top: var(--gapsm);
  flex-wrap: wrap;
}

/* ------------------------------------- the signature / instruction rows */
/*
  Both managers render a list of cards, each with a heading row (name, badges,
  and the row's actions) and a body of pre-wrapped text. The heading row is a
  `space-between` flex that WRAPS and aligns on the baseline: the left side is
  a name plus up to two badges, the right is up to three buttons, and at 390
  they must stack rather than push the card sideways.
*/
.nsbsigheadrow {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--gapsm);
  flex-wrap: wrap;
}
/*
  The content itself is a `<pre>` because it is the user's own text with their
  own line breaks — `white-space: pre-wrap` is the whole reason for the
  element. `font-family: inherit` undoes the monospace that `<pre>` carries by
  default: this is prose a person wrote, not code.
*/
.nsbsigcontent {
  margin: 0;
  white-space: pre-wrap;
  font-family: inherit;
  font-size: var(--fontsizesm);
  color: var(--ink2);
}

/* ------------------------------------------- three small form utilities */
/*
  `.nsbformwidth` caps a settings form at a readable measure. A form field
  stretched to 1200px is a line nobody tracks back from, and both managers
  had `max-width:42rem` inline for exactly this.
*/
.nsbformwidth { max-width: 42rem; }
.nsbformwidthsm { max-width: 20rem; }
/* A block of stacked controls that needs the panel's own bottom gap. */
.nsbstackblock { margin-bottom: var(--gap); }
.nsbitalic { font-style: italic; }
.nsbwarntext { color: var(--warn); }
/* Two fields that share a line and both grow. `.nsinline` wraps already, so
   below ~24rem they become two rows rather than two slivers. */
.nsbfieldpair > .nsfield { flex: 1 1 12rem; }

/* ------------------------------------------ the signature default banner */
/*
  "Default for new posts: X". A tinted strip rather than a panel, because it
  is one fact about the list below it rather than a section of its own — and
  not `ui.flash()`, which is for something that just happened.
*/
.nsbdefaultbar {
  margin-bottom: var(--gap);
  padding: var(--panelheadpad);
  background: var(--brand50);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.nsbdefaultbarlabel { color: var(--brand); font-size: var(--fontsizesm); }

/* -------------------------------------------- the taller modal variants */
/*
  The AI-generate dialog is a head / scrolling body / foot, which the small
  `.nsbmodalcard` is not — that one is a padded card and scrolls as a whole.
  `.nsbmodalsplit` turns the card into the three-part column, and the padding
  moves onto the three parts. Same arrangement `.rml-modal` has above; this is
  the generic name for it.
*/
.nsbmodalcardmid { width: min(560px, 100%); }
.nsbmodalsplit {
  padding: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  max-height: 85vh;
}
.nsbmodalhead {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--gapsm);
  padding: var(--panelheadpad);
  border-bottom: 1px solid var(--line);
  flex: 0 0 auto;
}
.nsbmodalscroll { padding: var(--panelpad); overflow-y: auto; flex: 1 1 auto; min-height: 0; }
.nsbmodalfoot {
  padding: var(--panelheadpad);
  border-top: 1px solid var(--line);
  display: flex;
  justify-content: flex-end;
  gap: var(--gapxs);
  flex: 0 0 auto;
  flex-wrap: wrap;
}

/* ------------------------------------------- the link-destination extras */
/*
  A label's parenthetical qualifier — "(for the AI)", "(optional)" — which was
  `style="font-weight:400;color:var(--muted)"` on a `<span>` in nine places.
  It un-bolds inside `.nsfield label`, which is 600 by the kit's rule.
*/
.nsbqualifier { font-weight: 400; color: var(--muted); }
/*
  A URL printed in full. `word-break: break-all` because a destination URL has
  no spaces and a long one otherwise sets the card's minimum width — which at
  390 is the whole page panning sideways.
*/
.nsbbreakurl { color: var(--brand); word-break: break-all; font-size: var(--fontsizesm); }
/* A paused row: the card itself recedes. One rule, where the old markup
   computed a background string and a text colour in two separate binds. */
.nspanel.nsbpaused { background: var(--bg); }

/* -------------------------------------- the instruction picker's chrome */
/*
  The picker is the one dialog with four parts rather than three: head, a tab
  strip, a search box, and the scrolling list. The strip and the search box
  are both `flex: 0 0 auto` so only the list scrolls — a search field that
  scrolls away is a search field you cannot correct without scrolling back.
*/
.nsbmodalwide { width: min(640px, 100%); }
.nsbmodalheadtitle { font-size: var(--fontsizeh2); color: var(--brand); }
.nsbmodaltabs { flex: 0 0 auto; padding: 0 var(--panelpad); }
.nsbmodalsearch { flex: 0 0 auto; padding: var(--gapsm) var(--panelpad); }
/*
  🔴 ONE Z-INDEX ABOVE THE SHARED 100, AND ONLY HERE. The preview opens FROM
  the picker and must sit over it; everything else on the platform shares 100
  precisely so no two dialogs have to be ranked. A sub-dialog is the one case
  where they do, and it is written down rather than guessed — the four values
  REWORK-2 replaced were 50, 60, 1000 and 1500.
*/
.nsbmodalback.nsbmodalover { z-index: 101; }
/* A row in the picker's list: a two-line body and a fixed action pair. */
.nsbpickrow {
  display: flex;
  gap: var(--gapsm);
  align-items: flex-start;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--panelheadpad);
}
.nsbpickactions { flex: 0 0 auto; }

/* ===================== the settings page bodies (REWORK-3C) ============ */
/*
  Eight rules that between them replaced 52 inline styles on the business
  profile alone, and another 60 across integrations, the team page and the
  four display partials. Each one was the SAME decision written out again:
  "muted, .9rem, a little space below" appeared fourteen times at four
  different margins.
*/

/* A help paragraph under a heading or above a field group. */
.nsbhelp {
  color: var(--muted);
  font-size: var(--fontsizesm);
  margin: 0 0 var(--gapsm);
}
/* …the same, under the thing it explains rather than above it. */
.nsbhelpafter { margin: var(--gapxs) 0 0; }
.nsbhelptight { margin: 0 0 var(--gapxs); }

/*
  A fieldset used purely for GROUPING AND ITS LEGEND, which is what a radio
  set or a checkbox set is. The browser's default fieldset border and padding
  are a box nobody asked for; the legend is the heading that makes the group
  announce itself to a screen reader, and that is the whole reason the element
  is here rather than a `<div>`.
*/
.nsbfieldset { border: 0; padding: 0; margin: 0 0 var(--gap); }
.nsbfieldsetafter { margin: var(--gap) 0 0; }
.nsblegend { font-weight: 600; padding: 0; margin: 0 0 var(--gapxs); color: var(--ink2); }
/* One option in such a group. `font-weight: 400` because a `<label>` inside
   `.nsfield` is 600 by the kit's rule, and an option is not a field name. */
.nsbchoice { display: block; font-weight: 400; margin: var(--gapxs) 0; }
.nsbchoicegap { margin-top: var(--gapsm); }
/* The detail line under a checked option, indented past its control. */
.nsbchoiceindent { padding-left: 1.6rem; }

/* A row of fields that are different widths and align on their baselines. */
.nsbfieldrowend {
  display: flex;
  gap: var(--gapsm);
  flex-wrap: wrap;
  align-items: flex-end;
  margin-top: var(--gapxs);
}
.nsbgapbelow { margin-bottom: var(--gapxs); }

/*
  🔴 FIELD WIDTHS ARE NAMED FOR WHAT THEY HOLD, not for their size — the
  arrangement `.nsgridmain` / `.nsgridcustomer` already uses in the kit, and
  for the same reason: a ZIP box and a day-count box are both "about 5rem"
  until one of them needs to change, at which point a `.nsbw5` would change
  both. `max-width` as well as `width` so a flex parent cannot stretch them.
*/
.nsinput.nsbwcity { width: 14rem; max-width: 14rem; }
.nsinput.nsbwzip { width: 9rem; max-width: 9rem; }
.nsinput.nsbwstate { width: auto; max-width: 12rem; }
.nsinput.nsbwstatecode { width: 5rem; max-width: 5rem; text-transform: uppercase; }
.nsinput.nsbwdate { width: 12rem; max-width: 12rem; }
.nsinput.nsbwdays { width: 4.5rem; max-width: 4.5rem; }
.nsinput.nsbwrowname { max-width: 21rem; }

/* ------------------------------------------------- the integrations page */
/*
  The deep-link highlight, out of the page's `<style>` block. Fix / Reconnect
  links from the home card, the composer rows and the alert emails all land on
  `#integration-<type>`, and a page of six tiles with nothing marked is a page
  the tenant has to search. `scroll-margin-top` keeps the tile clear of the
  sticky header — without it the thing the link points at arrives underneath
  the top bar, which looks exactly like the link not working.
*/
.nsbadvanced:target { box-shadow: 0 0 0 3px var(--line); border-color: var(--warn); }
.nsbadvanced { scroll-margin-top: 5rem; }

/*
  A notice inside a panel: a tinted block with a 3px rail in the tone's
  colour. Three tones, one shape. It is NOT `ui.flash()` — a flash reports
  something that just happened and is dismissible; these state a condition
  that is true until it is fixed.
*/
.nsbnotice {
  border-left: 3px solid var(--line);
  padding: var(--panelheadpad);
  font-size: var(--fontsizesm);
  border-radius: 0 var(--radius) var(--radius) 0;
}
.nsbnoticebad { background: var(--badbg); border-left-color: var(--bad); }
.nsbnoticewarn { background: var(--warnbg); border-left-color: var(--warn); }
.nsbnoticeaside { display: block; color: var(--muted); margin-top: var(--gapxs); }

/* ========================================== THE ON/OFF/MANUAL PICKER ==== */
/*
  🔴 THE CURRENT CHOICE IS **SELECTED**, NOT **DISABLED** (NSBIZ-FIX-2026-11-13).

  Both copies of this control drew the current mode by marking the button
  `disabled`, and that is a category error with a visible cost:

    - the tenant page used `.nsbtn.nsprimary` + `disabled`, and the kit's
      `.nsbtn[disabled]` rule sets `opacity: .5` — so the mode a channel is
      ACTUALLY IN was the faintest of the three, and read as "this option is
      unavailable to you";
    - the admin page used `.nsseg` + `.nson`, which tints with `--brand50`
      and is legible but says "hovered" rather than "chosen".

  `disabled` means "you may not pick this". The current mode is the one the
  tenant already picked. Drawing them the same way is why both pages needed
  a "current: on" label beside the control to say what the control was
  already trying to say — and a redundant label is the signal that the
  drawing has failed, not an acceptable repair for it.

  So: solid brand, white text, FULL opacity, and `cursor: default` rather
  than `not-allowed`, because the choice is not unavailable. Still inert —
  `disabled` stays on the element, so it cannot be clicked, cannot be
  focused and is not submitted; only the appearance changes. `aria-pressed`
  carries the state for anyone not looking at it, which is why the label
  could go.

  This is NSBIZ's own component, so it is styled here rather than by
  overriding the kit's `.nsbtn[disabled]` — which is a correct rule that
  other screens depend on (the Run-1 "Available in Run 3" buttons lean on
  it heavily). Scoped to `.nsbmodepicker` for exactly that reason.
*/
.nsbmodepicker { margin-left: auto; display: flex; gap: var(--gapxs); flex-wrap: wrap; }
.nsbmodepicker .nsbtn.nsbmodecurrent,
.nsbmodepicker .nsbtn.nsbmodecurrent[disabled],
.nsbmodepicker .nsbtn.nsbmodecurrent[aria-disabled="true"] {
  background: var(--brand);
  border-color: var(--brand);
  color: #FFFFFF;
  opacity: 1;
  font-weight: 600;
  cursor: default;
  box-shadow: none;
}
/* Hover must not undo it: the kit's disabled-hover rule resets the
   background to `--surface`, which would make the selected segment flash
   white under the pointer. */
.nsbmodepicker .nsbtn.nsbmodecurrent[disabled]:hover,
.nsbmodepicker .nsbtn.nsbmodecurrent[aria-disabled="true"]:hover {
  background: var(--brand);
  color: #FFFFFF;
}
/* The two a tenant CAN pick stay ordinary buttons. Stated so the
   contrast between chosen and choosable is deliberate rather than
   whatever the cascade leaves. */
.nsbmodepicker .nsbtn:not(.nsbmodecurrent) { font-weight: 500; }
/* A second form inside one panel body, divided from the one above it. */
.nsbsubform { margin-top: var(--gap); padding-top: var(--gapsm); border-top: 1px solid var(--line2); }

/* ---------------------------------------------------- the team-members page */
/*
  🔴 `.um-wrap`'s 880px CAP IS GONE, AND THE CAP WAS THE BUG.
  Its comment said it stopped the Name column taking the slack and pushing
  Actions — the column with Remove in it — off the right edge. It did the
  opposite: six columns do not fit in 880px, so `.nstablebox` scrolled and
  Actions went off the edge ANYWAY, on a 1440 screen, with no scrollbar in
  view to say so. The before/after screenshots show it clipped in BOTH sets,
  which is how a defect survives three passes: it looks like a styling choice.

  Two things fix it together and neither alone: the cap goes, so the table
  gets the settings area's full width; and the row actions are `.nslink`
  rather than `.nsbtn`, which is the kit's own control for an action inside a
  dense row — `admin-datatable.js` uses it for exactly this in the saved-views
  list. `.nstruncate` on the name cell already stops the Name column taking
  the slack, which is what the cap was really for.
*/
.nsbteamwrap { max-width: 100%; }
/* A table cell holding a name over a quieter second line. The kit's
   `.nstwolinebody` is for a flex row and this is a `<td>`. */
.nsbcellname { font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; }
.nsbcellsub {
  display: block;
  font-weight: 400;
  color: var(--muted);
  font-size: var(--fontsizexs);
  margin-top: 1px;
  overflow: hidden;
  text-overflow: ellipsis;
}
/*
  The actions cell. 🔴 NO `white-space: nowrap` — that was the first attempt
  and it made things WORSE: it forces three controls onto one line, so the
  column's minimum width becomes the sum of all three and the table pushes
  past the panel again. A flex row that WRAPS is what lets "Resend invite /
  Revoke invite" become two lines in a narrow column instead of one wide one.

  `justify-content: flex-end` rather than `text-align: right`, because the
  children are flex items once the cell is a flex container and `text-align`
  stops reaching them.
*/
.nsbrowactionscell {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--gapxs) var(--gapsm);
}
/* A labelled field inside a modal, and a radio set inside one. */
.nsbmodalfield { margin: var(--gapsm) 0; }
.nsbmodalerror:empty { display: none; }
.nsbradiogroup { margin: var(--gapxs) 0 var(--gap); }
.nsbradio { display: flex; align-items: flex-start; gap: var(--gapxs); font-weight: 400; font-size: var(--fontsizesm); }
/* `<em>` inside a radio label is the option's explanation, not emphasis. */
.nsbradio em { display: block; color: var(--muted); font-style: normal; font-size: var(--fontsizexs); margin-top: 1px; }

/* ------------------------------------------------------- the billing page */
/* The summary row sits between the page header and the first panel. */
.nsbbillingsummary { margin-bottom: var(--gap); }
/* The "Add a card" foot: a button and the sentence that explains where the
   number is actually typed. They belong on one line and must wrap together. */
.nsbcardfoot { display: flex; align-items: center; gap: var(--gapsm); flex-wrap: wrap; }
/* A label / state pair on one line, dotted-ruled. The digest category list
   is the only caller; it is a list of on/off facts, not a table. */
.nsbtogglerow {
  display: flex;
  justify-content: space-between;
  gap: var(--gapsm);
  padding: 2px 0;
  border-bottom: 1px dotted var(--line);
}
.nsbtogglerow:last-child { border-bottom: 0; }

/* ------------------------- the AI compare modal and the blog channel row */
/* A notice that is a block of its own rather than a line inside one. */
.nsbnoticeblock { margin: var(--gapsm) 0; }
.nsbnoticeinfo { background: var(--line2); border-left-color: var(--brand); }
.nsbnoticetitle { font-weight: 600; margin-bottom: var(--gapxs); }
.nsblinktext { color: var(--brandlink); }
.nsbsmalltext { font-size: var(--fontsizesm); }
.nsbunderline { text-decoration: underline; }
/* A `<summary>` that is a plain disclosure label, not a button. The kit's
   `.nsbadvancedsummary` is the full component with a chevron and a value
   strip; this is the one-word "show me the evidence" case. */
.nsbplainsummary { cursor: pointer; color: var(--muted); font-size: var(--fontsizesm); }
/* A short literal — a header name, a selector — inline in prose. */
.nsbcodechip {
  display: inline-block;
  margin-right: var(--gapxs);
  padding: 1px 5px;
  border-radius: var(--radius);
  background: var(--line2);
  color: var(--ink2);
}
/* An outline button in the warn tone. The kit has `.nsdanger` and nothing
   between it and a plain button, and "I disagree with this detection" is a
   caution rather than a destruction. */
.nsbtn.nsbwarnbtn { border-color: var(--warn); color: var(--warn); }
.nsbtn.nsbwarnbtn:hover { background: var(--warnbg); }

/* ------------------------------------- the Pintura "More tools" toggle */
/*
  A floating toggle injected into Pintura's own modal root by
  `public/js/pintura-bootstrap.js`. It was thirteen declarations in a
  `style.cssText` with `#0033A0` written out by hand — the brand blue spelled
  out in the one file nothing had ever read, which is what the REWORK-3C JS
  scan exists to refuse.

  🔴 POSITIONED JUST UNDER THE × CLOSE BUTTON, top-left of the modal, so it
  does not collide with Pintura's centre-top per-tool toolbar (Rotate left /
  Flip horizontal for crop) or the undo/redo arrows beside ×. The vertical
  space between × and the first tool tab is ~150px, which is room for a 28px
  button without crowding. Those three numbers are the whole component and
  they are measurements of SOMEBODY ELSE'S UI, so they stay literal — a token
  would imply they follow our scale when they follow Pintura's.

  The surface is `--surface` at 95% rather than a literal `rgba(255,255,255,…)`
  so it survives a dark theme, and the text is `--brand`.
*/
.nsbpinturatoggle {
  position: absolute;
  top: 46px;
  left: 14px;
  z-index: 10;
  padding: .4rem .85rem;
  border: 0;
  border-radius: var(--radiuspill);
  background: color-mix(in srgb, var(--surface) 95%, transparent);
  color: var(--brand);
  font: inherit;
  font-size: var(--fontsizesm);
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  box-shadow: var(--shadowmodal);
}

/* ------------------------- a six-column table, on a phone (REWORK-3C) */
/*
  🔴 A TABLE THAT CANNOT FIT SHOULD STOP BEING A TABLE, not scroll sideways
  inside a panel with nothing saying it does. The team-members table is six
  columns; below 640px it showed two of them and hid Role, Status, Last login
  and Actions behind a horizontal scroll nobody discovers — so a tenant on a
  phone could not see a teammate's role and could not remove anybody. Both the
  before AND after screenshots of REWORK-3C show it, which is exactly how it
  survived three passes: it looks like a table, and tables scroll.

  Each row becomes a card and each cell prints its column name from
  `data-label`, which is on the cell in the markup rather than derived — a
  `::before` cannot read a `<th>`, and a second list of column names in the
  stylesheet is a second list free to disagree with the first.

  `.nsbstacktable` scopes it. The kit has no equivalent because the hub's
  tables are operator screens on a desktop; this is a tenant's phone.
*/
@media (max-width: 640px) {
  .nstable.nsbstacktable,
  .nstable.nsbstacktable tbody,
  .nstable.nsbstacktable tr,
  .nstable.nsbstacktable td {
    display: block;
    width: auto;
  }
  /* The header row goes: every cell carries its own label now, and a sighted
     reader of a card does not need a column strip above it. It stays in the
     DOM for assistive technology rather than being removed. */
  .nstable.nsbstacktable thead { display: none; }
  .nstable.nsbstacktable tr {
    padding: var(--panelheadpad);
    border-bottom: 1px solid var(--line2);
  }
  .nstable.nsbstacktable tr:last-child { border-bottom: 0; }
  .nstable.nsbstacktable td {
    border: 0;
    padding: 3px 0;
    display: flex;
    align-items: baseline;
    gap: var(--gapsm);
  }
  .nstable.nsbstacktable td::before {
    content: attr(data-label);
    flex: 0 0 7.5rem;
    color: var(--muted);
    font-size: var(--fontsizexs);
    font-weight: 600;
  }
  /* A cell with no label prints none rather than an empty gutter. */
  .nstable.nsbstacktable td:not([data-label])::before { content: none; }
  /* The name cell is the card's heading, so it gets the row to itself. */
  .nstable.nsbstacktable td[data-label="Name"] { display: block; }
  .nstable.nsbstacktable td[data-label="Name"]::before { content: none; }
  /* `.nstruncate`'s desktop cap is wrong on a card, where there is no column
     for the text to overflow out of. */
  .nstable.nsbstacktable td.nstruncate { max-width: none; }
  /* Actions align left with the labels rather than off to the right edge. */
  .nstable.nsbstacktable td.nsbrowactionscell { justify-content: flex-start; }
}

/* ------------------------------------------ record detail (REWORK-3D) */
/*
  THE RECORD-DETAIL PATTERN: a page header, an overview, URL tabs, and a
  danger zone last. `docs/UI-KIT-NSBIZ.md` holds the pattern; these are the
  only rules it needs that the kit does not already have.

  🔴 EVERY VALUE HERE IS A `var(--token)`, like the rest of this file. The two
  `44px` minimum heights below are the exception the file already makes for
  the settings rail, for the same reason and in the same words: 44px is the
  kit's touch-target rule for both densities, it is a number the kit states
  rather than a colour or a size of our own, and `--controlheight` is the
  ADMIN control height (32px), which is the thing being overridden.
*/

/* The tab strip. `.nstabs` does the drawing; this only places it and swaps
   it for the disclosure below 900px. */
.nsbrecordtabs { margin: var(--gap) 0; }
.nsbrecordtabsmobile { display: none; }

@media (max-width: 900px) {
  .nsbrecordtabsdesktop { display: none; }
  .nsbrecordtabsmobile {
    display: block;
    border: 1px solid var(--line);
    border-radius: var(--radiuspanel);
    background: var(--surface);
  }
  .nsbrecordtabsmobile > summary {
    display: flex;
    align-items: center;
    gap: var(--gapsm);
    padding: var(--panelheadpad);
    min-height: 44px;
    cursor: pointer;
    list-style: none;
    font-size: var(--fontsizesm);
  }
  .nsbrecordtabsmobile > summary::-webkit-details-marker { display: none; }
  .nsbrecordtabsmobile > summary::marker { content: ''; }
  .nsbrecordtabscurrent { font-weight: 600; color: var(--ink); }
  .nsbrecordtabsmore { margin-left: auto; color: var(--brandlink); }
  .nsbrecordtabsitems {
    padding: var(--gapxs);
    border-top: 1px solid var(--line2);
    display: flex;
    flex-direction: column;
    gap: 1px;
  }
  .nsbrecordtabsitems a {
    display: flex;
    align-items: center;
    gap: var(--gapxs);
    min-height: 44px;
    padding: var(--rowpad);
    border-radius: var(--radius);
    font-size: var(--fontsizesm);
    color: var(--ink2);
    text-decoration: none;
  }
  .nsbrecordtabsitems a:hover { background: var(--sidehover); text-decoration: none; }
  .nsbrecordtabsitems a.nson {
    background: var(--brand50);
    color: var(--brand);
    font-weight: 600;
  }
}

/*
  THE DANGER ZONE. A wrapper rather than a panel modifier, because
  `ui/panel.twig` is a byte-identical vendored file and takes no extra class —
  the same constraint `ui.button` carries, answered the same way: the kit's
  markup, with the difference declared outside it.
*/
.nsbdanger .nspanel { border-color: var(--dangerline); }
.nsbdanger .nspanelhead h2 { color: var(--bad); }
.nsbdangersplit {
  margin: var(--gap) 0;
  border: 0;
  border-top: 1px solid var(--dangerline);
}

/*
  The header's meta line: badges and facts on one row that wraps, rather than
  a run of middots. `.nsbpagemeta` is the pageheader's own slot; this is what
  goes INSIDE it when the content is more than one phrase.
*/
.nsbmetarow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gapxs) var(--gapsm);
}
.nsbmetarow > * { font-size: var(--fontsizesm); }

/*
  The delete gate's slug field, inside the confirm dialog. The red border is
  the one piece of the old danger zone worth keeping: it is the only input on
  the product whose contents arm an irreversible action.
*/
.nsinput.nsbdangerfield { border-color: var(--bad); }

/* The header's action cluster: two buttons and a More menu, wrapping on a
   phone rather than pushing the title out of the viewport. */
.nsbheadactions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gapxs);
}
@media (max-width: 900px) {
  .nsbheadactions { width: 100%; }
}

/* ------------------------------------------------- capture-queue thumbnail */
/*
  A photo tile inside a kit `.nsrow`. NSBIZ-QUICKPOST-A.

  The kit has no photo surfaces at all — the hub's screens are invoices,
  tenants and commission runs, none of which carry an image — so there is no
  upstream class to reuse and none to fix. That is exactly the question this
  file exists to answer.

  `object-fit: cover` and the fixed box are the load-bearing half: a capture
  queue is phone photos, which arrive portrait and landscape in the same list,
  and a thumbnail that honours its aspect ratio makes every row a different
  height. `flex: 0 0 auto` keeps it from shrinking when a long note pushes the
  row's text column.

  The size is `--avatar`-scale rather than a literal, so it tracks the kit's
  own row rhythm; the radius and the line are tokens for the same reason.
*/
.nsbthumb {
  width: calc(var(--fontsizesm) * 4);
  height: calc(var(--fontsizesm) * 4);
  flex: 0 0 auto;
  object-fit: cover;
  border-radius: var(--radius);
  border: 1px solid var(--line2);
  background: var(--hover);
}

/*
  A row list with no list chrome. `.nsrow` already draws the separators and
  the padding, so the `<ul>` around a set of them only needs its own defaults
  taken off — and a `<ul>` is the right element because the queue IS a list
  and a screen reader should say how long it is.
*/
.nsbrowlist {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* ═══════════════════════════════════════════════════════════════════════
   THE QUICK PATH — NSBIZ-QUICKPOST-B (2026-11-14)

   Six screens built for one hand on a phone in a van: quick post, review
   & post, the two nudges, capture, and the photo queue.

   🔴 EVERY VALUE HERE IS A `var(--token)`, which is this file's rule and
   the only real test of whether a component sits ON the kit or merely
   beside it. The two exceptions are declared where they are used and both
   are SHAPE rather than style: `aspect-ratio: 1` (a square is a square at
   every density) and the 1fr grid fractions.

   Why these are here rather than in the hub: the hub is a billing console
   two operators use at a desk. There is no camera in it, no photo queue
   and no roundup. If a second Northstar app grows a capture flow, this
   whole block goes upstream and is deleted from here.
   ═══════════════════════════════════════════════════════════════════════ */

/* A square photo grid: three across on a phone, which is the width that
   makes a thumbnail recognisable at 390 without scrolling to see four. */
.nsbqpgrid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--gapsm);
}
.nsbqptile {
  position: relative;
  aspect-ratio: 1;
  border-radius: var(--radius);
  background: var(--brand50);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
}
.nsbqptile img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* The add tile is a dashed outline rather than a filled button, so the
   photos read as content and the control reads as an invitation. */
.nsbqpadd {
  aspect-ratio: 1;
  border: 1px dashed var(--inputline);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--brand);
  font: inherit;
  font-size: var(--fontsizesm);
  font-weight: 600;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--gapxs);
}
.nsbqpadd:hover { background: var(--brand50); }
.nsbqpadd:focus-visible { outline: var(--focusring); outline-offset: 2px; }

/* A tile's own state. FIX-2026-11-13's rule is that a failed upload keeps
   its tile, its own message and its own Retry — so the message has to
   live ON the tile, legibly, over a photo. */
.nsbqptilefoot {
  position: absolute;
  inset: auto 0 0 0;
  padding: var(--gapxs);
  background: var(--surface);
  font-size: var(--fontsizexs);
  color: var(--ink2);
  text-align: center;
}
.nsbqptilefoot.nsbqptilebad { background: var(--badbg); color: var(--bad); }
.nsbqptileremove {
  position: absolute;
  inset: var(--gapxs) var(--gapxs) auto auto;
  width: var(--controlheight);
  height: var(--controlheight);
  min-width: var(--controlheight);
  border: 0;
  border-radius: var(--radiuspill);
  background: var(--surface);
  color: var(--ink2);
  font: inherit;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.nsbqptileremove:hover { color: var(--bad); }
.nsbqptileremove:focus-visible { outline: var(--focusring); outline-offset: 1px; }

/* The dictation hint. 🔴 Nothing on the textarea itself may suppress a
   keyboard mic — no `autocomplete="off"`, no `inputmode`, no
   `autocorrect="off"`. Talking is the input method most of these tenants
   will use, and the hint is there because the keyboard's mic key is the
   one affordance a web page cannot draw. */
.nsbqpmic {
  display: flex;
  align-items: center;
  gap: var(--gapsm);
  margin: var(--gapxs) 0 0;
  font-size: var(--fontsizesm);
  color: var(--muted);
}
.nsbqpmic svg { flex: 0 0 auto; color: var(--brand); }

/* The action bar. NOT fixed: a fixed bar at 390 covers the textarea the
   keyboard has already shrunk, and the shell's own fixed mobile bar was
   removed in REWORK-1 for the same reason. It is the last thing on the
   page, which on a two-control screen is on-screen anyway. */
.nsbqpbar {
  margin: var(--gutter) 0 0;
  padding: var(--gap) 0 0;
  border-top: 1px solid var(--line2);
  display: flex;
  flex-direction: column;
  gap: var(--gapxs);
}
/* One primary action, deliberately bigger than the kit's control height:
   this is the button a cold thumb presses in a van. */
.nsbtn.nsbqpbig {
  min-height: calc(var(--controlheight) + var(--gap));
  border-radius: var(--radiuspill);
  font-size: var(--fontsizeh2);
  font-weight: 600;
  width: 100%;
}
.nsbqpbar .nsbqpsecondary {
  align-self: center;
  font-size: var(--fontsizesm);
  padding: var(--gapsm);
}

/* The big camera button on the capture screen: the whole point of that
   screen, so it gets the height of two controls and nothing competes. */
.nsbtn.nsbqpcapture {
  min-height: calc(var(--controlheight) * 2);
  width: 100%;
  border-radius: var(--radiuspanel);
  font-size: var(--fontsizeh1);
  font-weight: 600;
  gap: var(--gapsm);
}

/* "Writing your post…" — a state, with a recoverable error under it. */
.nsbqpworking {
  display: flex;
  align-items: center;
  gap: var(--gapsm);
  padding: var(--panelpad);
  background: var(--brand50);
  border-radius: var(--radius);
  color: var(--ink2);
  font-weight: 600;
}
.nsbqpspinner {
  width: var(--fontsizeh1);
  height: var(--fontsizeh1);
  flex: 0 0 auto;
  border: 2px solid var(--line);
  border-top-color: var(--brand);
  border-radius: var(--radiuspill);
  animation: nsbqpspin 0.8s linear infinite;
}
@keyframes nsbqpspin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .nsbqpspinner { animation: none; }
}

/* A numbered channel card's head: "1. Blog post", a green check when the
   channel is ready, and a quiet Edit. */
.nsbqpcardhead {
  display: flex;
  align-items: center;
  gap: var(--gapsm);
  flex-wrap: wrap;
}
.nsbqpcardhead h2 { margin: 0; flex: 1 1 10ch; min-width: 0; }
.nsbqpready { color: var(--ok); display: inline-flex; align-items: center; }
.nsbqpcardbody { display: flex; flex-direction: column; gap: var(--gapsm); }
.nsbqpblogtitle { margin: 0; font-size: var(--fontsizeh2); color: var(--ink); }
.nsbqpexcerpt { margin: 0; color: var(--ink2); }

/* A placed link, as a chip with its own remove form. One chip per
   destination; §3 asks for one tap per link. */
.nsbqpchips { display: flex; flex-wrap: wrap; gap: var(--gapxs); }
.nsbqpchip {
  display: inline-flex;
  align-items: center;
  gap: var(--gapxs);
  padding: var(--gapxs) var(--gapsm);
  border: 1px solid var(--line);
  border-radius: var(--radiuspill);
  background: var(--bg);
  font-size: var(--fontsizexs);
  color: var(--ink2);
  max-width: 100%;
}
.nsbqpchip form { display: inline-flex; margin: 0; }
.nsbqpchiptext { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nsbqpchipdrop {
  border: 0;
  background: transparent;
  color: var(--muted);
  font: inherit;
  line-height: 1;
  cursor: pointer;
  min-width: var(--controlheight);
  min-height: var(--controlheight);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.nsbqpchipdrop:hover { color: var(--bad); }
.nsbqpchipdrop:focus-visible { outline: var(--focusring); outline-offset: 1px; }

/* A nudge: §14's repeat prompt and §2's one question. It is NOT
   `ui.flash` — a flash is dismissible and one-shot, and both of these are
   a decision the tenant has to make before the post goes anywhere. */
.nsbqpnudge {
  border: 1px solid var(--warn);
  border-left-width: 3px;
  border-radius: var(--radius);
  background: var(--warnbg);
  padding: var(--panelpad);
  display: flex;
  flex-direction: column;
  gap: var(--gapsm);
}
.nsbqpnudge.nsbqpnudgeinfo { border-color: var(--brand); background: var(--brand50); }
.nsbqpnudgetitle { margin: 0; font-size: var(--fontsizeh2); color: var(--ink); }
.nsbqpnudgebody { margin: 0; color: var(--ink2); }
.nsbqpnudgeactions { display: flex; flex-direction: column; gap: var(--gapxs); }

/* Three thumbnails of what a roundup already holds. */
.nsbqpthumbrow { display: flex; gap: var(--gapxs); flex-wrap: wrap; align-items: center; }
.nsbqpminithumb {
  width: calc(var(--controlheight) * 1.5);
  height: calc(var(--controlheight) * 1.5);
  object-fit: cover;
  border-radius: var(--radius);
  display: block;
}
.nsbqpmore {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: calc(var(--controlheight) * 1.5);
  height: calc(var(--controlheight) * 1.5);
  border: 1px dashed var(--line);
  border-radius: var(--radius);
  color: var(--muted);
  font-size: var(--fontsizexs);
  text-align: center;
}

/* "Checked for you:" — one line, at the foot, in the tenant's words. */
.nsbqpchecked {
  margin: var(--gap) 0 0;
  font-size: var(--fontsizexs);
  color: var(--muted);
}

/* One line per warning, on the card it concerns — never a wall. */
.nsbqpwarn {
  margin: 0;
  padding: var(--gapxs) 0 0;
  font-size: var(--fontsizesm);
  color: var(--warn);
  display: flex;
  gap: var(--gapxs);
  align-items: flex-start;
}
.nsbqpwarn svg { flex: 0 0 auto; }
.nsbqpoverride {
  background: transparent;
  border: 0;
  padding: var(--gapxs) 0;
  font: inherit;
  font-size: var(--fontsizexs);
  color: var(--brandlink);
  text-decoration: underline;
  cursor: pointer;
}

/* The queue's selectable grid. A whole tile is the target, because a
   checkbox at 390 is a 16px target inside a 110px photo. */
.nsbqpselect { border: 0; padding: 0; margin: 0; min-width: 0; }
.nsbqpqueuetile {
  position: relative;
  display: block;
  cursor: pointer;
  border-radius: var(--radius);
  overflow: hidden;
  border: 2px solid var(--line);
  background: var(--brand50);
}
.nsbqpqueuetile img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.nsbqpqueuetile input { position: absolute; opacity: 0; pointer-events: none; }
.nsbqpqueuetile:has(input:checked) { border-color: var(--brand); }
.nsbqpqueuetile:has(input:focus-visible) { outline: var(--focusring); outline-offset: 2px; }
.nsbqptilemeta {
  /* 🔴 `display: block` — IT IS A `<span>`, AND AN INLINE BOX IGNORES
     `overflow` AND `text-overflow`. So the note was being clipped by the
     tile's own `overflow: hidden` with no ellipsis, and every tile read
     "Sprinkler blowou". The two properties below were written and had
     never had an effect; a screenshot is what found that. */
  display: block;
  padding: var(--gapxs);
  background: var(--surface);
  font-size: var(--fontsizexs);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.nsbqpticked {
  position: absolute;
  inset: var(--gapxs) auto auto var(--gapxs);
  width: var(--fontsizeh1);
  height: var(--fontsizeh1);
  border-radius: var(--radiuspill);
  background: var(--surface);
  color: var(--brand);
  display: none;
  align-items: center;
  justify-content: center;
}
.nsbqpqueuetile:has(input:checked) .nsbqpticked { display: flex; }

/* The queue's footer: "N selected — Write a post from these". */
.nsbqpqueuefoot {
  margin: var(--gutter) 0 0;
  padding: var(--gap) 0 0;
  border-top: 1px solid var(--line2);
  display: flex;
  align-items: center;
  gap: var(--gap);
  flex-wrap: wrap;
}
.nsbqpqueuefoot .nsbqpcount { color: var(--ink2); font-size: var(--fontsizesm); }

/* The capture history row: thumbnail, note, time, "In queue". */
.nsbqphistory { list-style: none; margin: var(--gap) 0 0; padding: 0; }
.nsbqphistory li {
  display: flex;
  align-items: center;
  gap: var(--gapsm);
  padding: var(--gapsm) 0;
  border-top: 1px solid var(--line2);
}
.nsbqphistory li:first-child { border-top: 0; }
.nsbqphistorynote { flex: 1 1 auto; min-width: 0; }
.nsbqphistorynote span { display: block; }
.nsbqphistorytime { color: var(--muted); font-size: var(--fontsizexs); }

/* The Field role's footer line: what happens to these photos, and what
   this account cannot do. Said plainly, once, at the bottom. */
.nsbqpfieldnote {
  margin: var(--gutter) 0 0;
  padding: var(--gap) 0 0;
  border-top: 1px solid var(--line2);
  color: var(--muted);
  font-size: var(--fontsizesm);
}

/* The two photo controls, side by side and both reachable with a thumb. */
.nsinline.nsbqpactions { gap: var(--gapsm); margin-top: var(--gap); flex-wrap: wrap; }
.nsbqpactions > .nsbtn { flex: 1 1 10rem; justify-content: center; cursor: pointer; }

/* The schedule form sits after the publish form (HTML forbids nesting)
   and reads as part of the disclosure above it. */
.nsstack.nsbqpsubform { margin-top: var(--gap); max-width: 24rem; }

/* The dashboard's posting actions. Quick post is the primary action on the
   page a tenant lands on; Capture sits beside it at the same height. */
.nsbqpdashactions {
  display: flex;
  gap: var(--gapsm);
  flex-wrap: wrap;
  margin: var(--gap) 0 var(--gutter);
}
.nsbqpdashactions > .nsbtn { flex: 1 1 12rem; justify-content: center; }
.nsbqpdashactions > .nsbtn.nsbqpbig { flex: 2 1 16rem; }
