/* ================================================================================================
   THE ONE SET OF DESIGN TOKENS AND SHARED PRIMITIVES. Loaded by BOTH front ends.
   ================================================================================================

   Served at `/shared/tokens.css` and linked by both `index.html` files BEFORE their own `app.css`,
   exactly as `/shared/money.js` is loaded before their own `app.js`, and for the same reason that
   file's header gives:

     "Two copies of a money conversion is the shape that diverges, and it had already diverged."

   Until 4 September 2026 the token block and every primitive class below existed TWICE — once in
   `public/admin/app.css` and once in `public/agent/app.css`, byte-identical for 146 lines, each
   with a comment explaining that it was a deliberate copy. They had already diverged, in the
   direction that always happens: whichever surface was touched last got the improvement, and the
   other one silently did not.

   ⚠️ WHAT THE DIVERGENCE ACTUALLY COST, measured before this file was written:

     - `select.inp` existed only in the agent portal. The admin console has NINETEEN `<select>`
       elements, every one of them rendering at the browser's default height and with the browser's
       default chrome, directly beside a 13.5px `.inp` — on the console's own front screen.
     - `.alert.ok` existed only in the agent portal (added there when a "Client registered."
       confirmation was found rendering in the REFUSAL colours). The admin console still had no
       success variant at all, and its one success message was written `<div class="note ok">`
       against a `.note.ok` rule that DOES NOT EXIST in either file — a dead class, so the
       confirmation rendered as a neutral note.
     - `.fldmsg` / `.inp.bad` — refuse-at-the-field (docs/60 §14.6) — existed only in the agent
       portal, so the console had no way to render a per-field refusal at all.
     - The touch-target block existed only in the agent portal, and Randy uses the ADMIN CONSOLE on
       a phone at a yard.

   docs/60's own surface-target note is the authority here, verbatim: *"This is not a licence for
   two codebases or two design languages. The tokens, the components, the money rules, the error
   wording and the 'not built' state are shared and must stay shared. What differs is LAYOUT
   DENSITY and INPUT ERGONOMICS, and only those."* This file is the "must stay shared" half, made
   structural instead of promised. `src/sharedDesignTokens.test.ts` asserts that neither app
   stylesheet redeclares a token or carries a hex colour of its own, so a copy cannot come back.

   ⚠️ NO SCREEN CARRIES ITS OWN HEX VALUE. Every colour in the platform is declared once, below.
   If a new surface needs a colour that is not here, add it here with a name that says what it is
   FOR, never a literal at the point of use.

   ================================================================================================
   🔴 THE 4 SEPTEMBER 2026 DESIGN PASS — WHAT CHANGED AND WHY. docs/60 §16 is the full rulebook and
   governs every subsequent front-end change; this is the short form, kept here because this is the
   file a later session opens first.

   The brief was SUBTRACTIVE — remove borders, cut padding, cut colours, cut shadows — with a
   second half that is the one usually dropped: WHAT IS REMOVED MUST BE PAID BACK INTO WHAT REMAINS.
   Subtraction on its own produces something dull, not something quiet. So, in five places:

     1. TYPOGRAPHY does the work borders used to. Five sizes, not nine, with a real jump between
        them, and weight carrying as much of the load as size (500 against 600, not 15px against
        16px). A heading is no longer a 10px uppercase grey label.
     2. SPACE IS ASYMMETRIC. Space above a heading is not space below it. That asymmetry is most of
        what reads as designed rather than defaulted, and it costs nothing.
     3. ONE ACCENT, USED WITH CONVICTION: cyan, on the page's single primary action and on the
        active navigation item. Nowhere else. A timid grey-blue everywhere is what looks cheap.
     4. ALIGNMENT: one 4px vertical rhythm, tabular numerals on every table so digits do not move
        between rows, and money right-aligned in its own column.
     5. MICRO-DETAIL where the eye lands: focus ring, hover, PRESSED, active row. Small, and it is
        what people feel rather than notice.

   🔴 AND THE RULE THAT OVERRIDES "HAIRLINES NOT BORDERS": THIS PRODUCT IS DARK-FIRST, AND ON DARK,
   SEPARATION COMES FROM A LIFTED SURFACE, NOT A LINE. Measured before this pass: the card sat
   0.0102 of relative luminance above the page, which is nothing, so a 1px border was doing the
   whole job — every card was a box, and a screen of boxes is what "bulky" means. The lift is now
   large enough to read on its own and `.card` carries NO border at all. A hairline is kept only
   where elevation genuinely cannot work: BETWEEN rows of one table, and under a `.pagehd`.
   ================================================================================================ */

:root {
  /* ---- Colour: ground and structure --------------------------------------------------------
   * An ELEVATION LADDER, not a set of unrelated navies. Each step is a real, visible lift on the
   * one below, because on dark that lift is the separation device (see the header). Deep neutral,
   * never absolute black — these are long sessions and #000 under white text is where eye strain
   * on an operations tool comes from. */
  --bg: #070E17;          /* L* 3.8 — the page itself, behind everything */
  --navy: #0B1826;        /* L* 7.8 — INSET: inputs, the segmented track, the top bar, the side rail */
  --navy2: #1C3855;       /* L* 22.8 — raised ABOVE a card: a hovered row, a note, an address block */
  --navy3: #1E3A55;       /* L* 23.5 — the quietest pill ground */
  --line: #243F5A;        /* L* 25.8 — the default hairline: between table rows, under a page frame */
  --line2: #33587A;       /* L* 36.2 — a hairline that needs to be seen: a modal's edge, an outline button */
  --card: #142B45;        /* 🔴 L* 17.0 — THE LIFTED SURFACE. This is what separates, not a border. */

  /* ---- Colour: text ------------------------------------------------------------------------
   * ⚠️ `--ink3` WAS #6D869E AND FAILED AA. Measured 4 September 2026: 4.31:1 against `--card`,
   * under the 4.5:1 floor for normal-size text — and `--ink3` is what `.muted`, every column
   * header and every field label is painted in, so the failure was on the most-repeated text in
   * both surfaces. Raised until it clears on the lifted card, which is the darkest ground it is
   * ever asked to sit on. */
  --ink: #EAF2F9;         /* body text and headings */
  --ink2: #AFC3D6;        /* secondary text: ledes, note bodies, key labels */
  --ink3: #8FA7BD;        /* tertiary: field labels, column headers, timestamps */

  /* ---- Colour: the five semantic hues (docs/60 §4 — do not add a sixth) --------------------- */
  /* 🔴 THE ACCENT IS THE BAHAMAS FLAG'S AQUAMARINE, TUNED DOWN. Randy, 4 September 2026: *"Adopt the
   * Bahamas flag palette — as a HIERARCHY, not as three co-equal colours. AQUAMARINE IS THE ACCENT,
   * AND THE ONLY ONE… shift it toward the flag's aquamarine rather than replacing it. TUNE IT DOWN
   * from the flag's full chroma — the flag aqua at full saturation vibrates on a charcoal surface
   * over a long session."*
   *
   * So this is a MOVE, not a swap: hue 175° → 188° (the flag's own aquamarine is 189°) and
   * saturation 72% → 58%. It reads as the flag; it does not buzz.
   *
   * ⚠️ AND IT IS NOT `--flag-aqua`, WHICH IS THE MEASUREMENT THAT SETTLES IT. The flag's published
   * aquamarine `#00778B` scores **2.75:1 on a card and 3.70:1 on the page** — it fails AA as text on
   * every ground in this product, which is exactly what Randy predicted when he asked for the check.
   * It lives below as a brand-moment fill and is never a text colour. */
  --cy: #5EC4D4;          /* info / in progress / THE ONE ACCENT: primary action, active nav, focus, links */
  --cy2: #2F9CB0;         /* the same hue, for a border or a focused edge (4.45:1 on a card — a border, not text) */
  --cyd: #0D343D;         /* its ground */
  --gold: #F2B441;        /* warn: provisional, override, needs attention */
  --goldd: #3D2E0E;
  --red: #FF6B6B;         /* bad: rejected, refused, past a hard limit */
  --redd: #3D1A1A;
  --grn: #4ADE80;         /* ok: settled, confirmed, approved */
  --grnd: #123020;

  /* ---- Colour: derived values that were literals at the point of use ----------------------- */
  --cy-hover: #7AD2DF;
  --cy-press: #4CB2C2;    /* the PRESSED state — a fill that darkens, never a button that moves */
  --on-cy: #04222A;       /* text ON a cyan fill — dark enough to clear AA against it */
  --on-cy-deep: #04121D;  /* the same idea on the segmented control's selected tab */
  --red-line: #6B2626;    /* the border of a refusal alert */
  --accent-wash: #123A45; /* the ground under an ACTIVE nav item — the accent, at a whisper */

  /* ---- 🔴 THE FLAG, AND THE THREE RULES THAT KEEP IT A FLAG RATHER THAN A THEME -------------
   * Randy, 4 September 2026: *"THE FULL FLAG PALETTE APPEARS AT BRAND MOMENTS ONLY: the sign-in
   * screen, the logo mark, and document headers — the invoice and the bill of lading."*
   *
   * 1. **`--flag-aqua` IS NEVER TEXT.** Measured: 2.75:1 on a card, 3.70:1 on the page. It is a
   *    FILL and a RULE, and the accent above is what carries the hue into the working interface.
   * 2. **`--flag-gold` IS NEVER THE BRAND COLOUR IN THE WORKING INTERFACE.** Randy: *"GOLD STAYS THE
   *    WARN HUE AND DOES NOT BECOME A BRAND COLOUR ANYWHERE IN THE WORKING INTERFACE… promote gold
   *    to brand and every warning state loses its signal."* This is the same reasoning that retired
   *    the agent portal's gold PRIMARY button — see `.btn.gold` below, which is the record of what
   *    that cost when it was got wrong once already.
   * 3. **`--flag-black` IS CHARCOAL, NOT BLACK.** Randy: *"CHARCOAL REMAINS THE SURFACE. The flag's
   *    black is wrong for a screen."* The flag's own black is #000000; this is L* 4.9. */
  --flag-aqua: #00778B;   /* the flag's published aquamarine — a fill or a rule, NEVER text */
  /* ⚠️ `--flag-gold` (#FFC72C) and `--flag-black` (#0B1119, charcoal — never the flag's
   * #000000) are NOT declared here, deliberately: nothing in the working interface may use them,
   * and a token declared for an unbuilt screen is a placeholder. Their values live in docs/60
   * §16.5.5 for whoever builds the invoice and bill-of-lading headers. */
  --paper: #FFFFFF;       /* the one place a light ground is correct: a QR code's quiet zone */

  /* ---- Elevation, scrim, focus -------------------------------------------------------------
   * 🔴 ONE SHADOW TOKEN, AND ONLY THINGS THAT GENUINELY FLOAT MAY USE IT: a modal, a menu, a
   * popover. A card does not float; it is lifted, and lift is a colour, not a shadow. (The name is
   * historical — `src/sharedDesignTokens.test.ts` asserts this exact token exists. There is one
   * shadow in the system and this is it.) */
  --scrim: rgba(4, 12, 20, .72);
  --shadow-modal: 0 16px 40px rgba(0, 0, 0, .55);
  --focus-ring: 0 0 0 3px rgba(94, 196, 212, .30);          /* the accent, so focus is unmistakably ours */
  --focus-ring-quiet: 0 0 0 3px rgba(94, 196, 212, .13);   /* on an input, which already moves its border */
  --focus-ring-bad: 0 0 0 3px rgba(220, 80, 80, .14);

  /* ---- Radius. 4–6px, two values and a pill. Twelve-pixel corners on a 20px-padded card are a
   * large part of what reads as "big": the corner is the first thing the eye measures a box by. */
  --r-sm: 4px;
  --r: 6px;
  --r-pill: 999px;

  /* ---- Spacing. A STRICT 4px RHYTHM. A new rule takes a step off this ladder, never a fresh
   * number. The old ladder carried 6px and 14px, which are off any 4px grid and were the reason
   * nothing quite lined up vertically between two components. ------------------------------- */
  --s1: 4px;
  --s2: 8px;
  --s3: 12px;
  --s4: 16px;
  --s5: 20px;
  --s6: 24px;
  --s7: 32px;
  --s8: 40px;

  /* ---- THE PINNED FURNITURE AT PHONE WIDTH, DECLARED ONCE — OPS-69B phase C2. ---------------
   * 🔴 THESE TWO NUMBERS ARE THE ONLY PLACE THE BARS' HEIGHTS EXIST, and that is the whole point.
   * Before this, `52px` (the bottom bar) and `44px` (the floating button) were typed in THREE
   * places: the bar's own rule, the button's `bottom:` offset, and `.hasbottomnav .main`'s padding
   * calc — which is the room the page reserves so the bars cannot cover its last row. Three copies
   * of one measurement is three chances for the reservation and the furniture to disagree, and
   * they did: the reservation came to 152px against 96px of furniture, so every screen paid 56px
   * of dead space AND the floating button still lay over content, because it floated rather than
   * occupying any of it.
   *
   * ⚠️ THE RESERVATION IS NOW THE SUM OF THESE, TO THE PIXEL, and `scripts/mobileOverlap.ts`
   * asserts that on every screen. Editing one of these numbers moves the bar and the reservation
   * together; adding a second control to the action bar makes the bar taller than its token, and
   * the assertion goes red rather than an agent finding a covered row.
   *
   * ⚠️ `env(safe-area-inset-bottom)` IS NOT IN EITHER TOKEN, deliberately. It is a runtime value
   * the device supplies, it is added ONCE — by the bottom bar's own padding — and the reservation
   * adds it once. Baking it into a token would double-count it on a notched phone. */
  --bottomnav-h: 52px;  /* the four destinations + More; `min-height` on its buttons IS this */
  /* 61 and not 60: a 44px touch target + 8px of padding top and bottom + the 1px hairline that
   * separates this bar from the navigation under it. `box-sizing: border-box` puts the border
   * INSIDE the box, so a 60px token measured 61px on the page — the button's own 44px min-height
   * pushed the content box past it. One pixel is not worth a comment for its own sake; it is worth
   * one because it is precisely the kind of disagreement the assertion exists to find, and the
   * first thing that assertion found was itself. */
  --actionbar-h: 61px;

  /* ---- The two densities, named. docs/60 §16.4: the enemy is CHROME, not density. Generous
   * space BETWEEN blocks; tight WITHIN a table row. Reported as two numbers, not one. --------- */
  --pad-decide: var(--s6);       /* 24px — a card, a form, anywhere a person decides something */
  --pad-decide-tight: var(--s4); /* 16px — the same card when it holds one control */
  --cell-y: var(--s2);           /* 8px — a table row: DENSE, deliberately */
  --cell-x: var(--s3);           /* 12px */

  /* ---- 🔴 THE COMFORTABLE WIDTH. How wide a block of PROSE OR FORM FIELDS may get before it
   * stops being comfortable to read and starts being a line your eye loses. It is not how wide the
   * page is: the content column takes whatever the window gives it, and a card that holds a TABLE
   * takes the same, because a table is not prose (see the console's own `.shell > .main` rules).
   * 1120px is the figure this console shipped with as a column cap; it is unchanged in value and
   * changed in JOB — it now bounds the block rather than the page. OPS-94 part 2a. ------------- */
  --col-comfortable: 1120px;

  /* ---- Section rhythm. 40px on a phone, 64px on a desktop (widened at the breakpoint). ------ */
  --gap-section: var(--s8);
  --gap-block: var(--s4);        /* between two cards inside one section */

  /* ---- Type scale — FIVE SIZES, SYSTEM-WIDE, WITH A REAL JUMP BETWEEN THEM.
   * Nine sizes were in use on the console's own front screen (9.5 / 10 / 10.5 / 11 / 12 / 12.5 /
   * 13 / 13.5 / 21). Five that differ decisively read as a hierarchy; nine that differ by half a
   * pixel read as an accident. Weight (500 against 600) carries the rest.
   *
   * 🔴 AND THE MOBILE OVERRIDE BELOW IS NOT A PREFERENCE. Any `<input>` under 16px makes iOS zoom
   * the page on focus, which is the single worst thing a phone-first portal can do to an agent
   * standing in a yard. Mobile body is LARGER than desktop body, on purpose. ------------------ */
  --t-micro: 11px;        /* column headers, stat labels, pills — uppercase, tracked */
  --t-small: 11.5px;      /* meta, muted text, hints */
  --t-body: 13px;         /* body copy, inputs, buttons, table cells */
  --t-h2: 12.5px;         /* a section heading — SEMIBOLD, and smaller than body on purpose */
  --t-h1: 17px;           /* a page title */

  /* 🔴 THE DESKTOP COLUMN OF THIS TABLE CHANGED ON 5 September 2026, AND THE MEASUREMENT THAT
   * PROMPTED IT IS WORTH MORE THAN THE VALUES. Randy looked at the rendered agent portal at 1440
   * and said it was bad. The brief that followed named the sizes then in force as header 90px,
   * sidebar 320px, page title 44px, card headings 28px, body 16-17px. MEASURED ON THE COMMIT
   * PRODUCTION WAS ACTUALLY SERVING (GET /v1/version returned the local HEAD, checked), they were
   * header 60px, sidebar 216px, title 26px, headings 18px, body 14px — every one of them almost
   * exactly 1.5x smaller than the brief said.
   *
   * ⚠️ 1.5 IS A WINDOWS DISPLAY SCALE, AND THAT IS ALMOST CERTAINLY WHAT HAPPENED: a screenshot
   * taken at 150% and its DEVICE pixels read as CSS pixels. 60x1.5=90, 216x1.5=324, 18x1.5=27,
   * 840x1.5=1260 against a reported ~1300, 793x1.5=1190 against a reported ~1230. A ratio that
   * consistent across six independent measurements is not six coincidences. The DEFECTS the brief
   * named were all real and all visible in the 1440 screenshot; the NUMBERS were device pixels.
   * Recorded here because the next person to measure this product from a screenshot will make the
   * same mistake, and because a target derived from a stale baseline is a diff nobody can reproduce.
   *
   * WHAT THE SIZES ARE NOW, DESKTOP: title 17 · body 13 · secondary 11.5 · meta 11 · card heading
   * 12.5. That is FOUR distinguishable steps, not five, and 12.5-against-13 is not a step at all —
   * the card heading is separated from body by WEIGHT (600 against 400), not by size. That is a
   * deliberate departure from this section own "a real jump between levels" rule, and it is raised
   * as a finding rather than smuggled in: see docs/60 §16.1.
   *
   * 🔴 MOBILE IS UNTOUCHED. Every value in the override below is what it was, including a page
   * title now LARGER on a phone than on a laptop. That inversion is why --t-h1 is no longer a
   * clamp(): clamp GROWS with the viewport and therefore cannot express "smaller on the wide
   * screen". docs/60 §16.1 "headings use clamp(), never a size swapped at a breakpoint" and its
   * "mobile is larger than desktop" rule are incompatible the moment a heading must shrink as the
   * screen grows, and only one of the two can survive. The mobile rule survives: it exists because
   * iOS zooms a sub-16px input, which is a mechanical fact about a phone. */

  --font-sans: ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  /* ⚠️ ONE LINE THAT FIXES EVERY NATIVE CONTROL. Without it the browser paints its own widgets for
   * the LIGHT scheme regardless of the page: a `<input type="date">`'s calendar button rendered as
   * a near-black glyph on the near-black `.inp` ground and was effectively invisible, and every
   * `<select>`'s drop-down list opened white. Found by looking at the rendered "Find a booking"
   * screen at 390px, not by reading any rule — the stylesheet had nothing wrong in it. */
  color-scheme: dark;

  /* ── THE BILL OF LADING'S OWN PAPER, for previewing a value as the DOCUMENT will set it ────────
   * OPS-39 phase 5(i). These are the ONLY tokens in this file that are not the console's palette,
   * and the exception is deliberate: `vessel_master.full_name` is printed on a bill of lading
   * (DECISIONS.md PART M ⑧), and an operator approving that string must see it as it will appear on
   * the document, not as it appears in a dark admin panel. Showing it in the console's own type and
   * colour would be showing them something other than the thing they are approving.
   *
   * ⚠️ THE VALUES ARE COPIED FROM `STYLES` IN src/services/billOfLadingRender.ts, and that is a
   * known duplication rather than an oversight. The document's stylesheet is a server-side template
   * literal inlined into a standalone HTML file; there is no build step that could share it, and
   * importing the renderer into a console page would pull a legal document's rendering path into an
   * admin screen in order to style a preview. Four colour values in two places is the smaller cost —
   * and they live HERE rather than in `app.css` because `src/sharedDesignTokens.test.ts` forbids a
   * hex value in an app stylesheet outright, with no exception mechanism, which is correct.
   *
   * 🔴 IF THE DOCUMENT'S OWN STYLES CHANGE, CHANGE THESE. They are a preview of it and nothing
   * enforces that they still match. */
  --bl-paper: #fff;
  --bl-ink: #14171a;
  --bl-rule: #d6d6d2;
  --bl-label: #6b7078;
  --bl-missing: #a33;
}

/* 🔴 THE MOBILE TYPE OVERRIDE. Mobile-first would put this in the base and the desktop case in a
 * `min-width` block; it is written this way round only because the console is desktop-first and the
 * portal is phone-first and one of the two has to be the override. What must NOT change is the
 * VALUES: 16px inputs below the breakpoint, always. */
@media (max-width: 767px) {
  :root {
    --t-micro: 12px;
    --t-small: 14px;
    --t-body: 16px;
    --t-h2: 19px;
    /* Restated rather than inherited: the desktop title dropped to 17px and a phone must not
     * follow it down. 22px is exactly what this surface rendered before the desktop pass. */
    --t-h1: 22px;
    --pad-decide: var(--s4);
    --pad-decide-tight: var(--s3);
    --cell-y: var(--s3);
  }
}

@media (min-width: 768px) {
  :root { --gap-section: 64px }
}

* { box-sizing: border-box; margin: 0; padding: 0 }

/* 🔴 THE `hidden` ATTRIBUTE HAS TO BE MADE TO WORK, AND UNTIL 6 September 2026 IT DID NOT.
 *
 * `hidden` gets its `display: none` from the UA stylesheet, and ANY author `display` beats it —
 * author beats UA, whatever the specificity. So `hidden` is a no-op on every component in this file
 * that sets its own display, which is most of them: `.pill` is `inline-flex`, `.btn` is `inline-flex`,
 * `.navitem` is `flex`, `.card` is `block`.
 *
 * ⚠️ FOUND BY OPENING A RENDERED PNG AND READING IT, not by any assertion. The agent portal's
 * notice badge is `<span class="pill grey num" hidden>0</span>` and `setNoticeCount` sets
 * `pill.hidden = true` at zero — and the 1440px capture shows a nav item reading **"Notices  0"**.
 * A badge reading zero is chrome describing an absence, which is docs/60 §12's empty-state rule
 * applied to a badge, and the code that was supposed to prevent it was correct and inert.
 *
 * ⚠️ AND `.infobody` IS THE SAME MECHANISM, ALREADY SHIPPED. `/shared/disclose.js` toggles
 * `body.hidden` on every "i" affordance in both surfaces; that has worked only because `.infobody`
 * happens not to declare a `display`. It was one property away from every disclosure on both
 * surfaces being permanently open.
 *
 * `!important` here rather than a `.hide` class, deliberately: the platform standard is the HTML
 * attribute (`disclose.js`, `setNoticeCount`, and docs/60's own note that visibility is toggled with
 * `el.hidden`, never `style.display`), and a rule that loses to the component it is hiding is worse
 * than no rule. This is the one `!important` in the file. */
[hidden] { display: none !important }

body {
  background: var(--bg);
  color: var(--ink);
  font: var(--t-body)/1.5 var(--font-sans);
  -webkit-font-smoothing: antialiased;
  min-height: 100vh;
}

button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none }
input, select, textarea { font: inherit; color: inherit }
a { color: inherit }

/* ⚠️ THE ACCESSIBILITY FLOOR, AND IT WAS MISSING ENTIRELY. `.inp:focus` moved its own border, so a
 * keyboard user could see where they were in a FORM — but every button, nav item, segmented tab and
 * link in both surfaces relied on the browser's default outline, which against these grounds is
 * somewhere between faint and invisible, and which `.btn`'s own background removes the contrast
 * for. `:focus-visible` rather than `:focus` so a mouse click does not leave a ring behind it. */
:where(button, [role="button"], a, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--cy);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

/* ---- Headings. THE ASYMMETRY IS THE POINT: space ABOVE a heading is not space below it, because
 * a heading belongs to what follows it, not to what it follows. ----------------------------- */
h1 { font-size: var(--t-h1); font-weight: 650; letter-spacing: -.01em; line-height: 1.2; margin-bottom: var(--s1) }
/* ⚠️ AT 12.5px THE NEGATIVE TRACKING HAD TO GO. -.01em is an optical correction for an 18px
 * heading and closes up a 12.5px one until it reads as a smudge — the same declaration is right at
 * one size and wrong at the other, which is the usual reason a shrunk type scale looks worse rather
 * than denser. Slight positive tracking instead, and the weight does the work. */
h2 {
  font-size: var(--t-h2); font-weight: 600; letter-spacing: .01em; line-height: 1.35;
  margin: var(--gap-section) 0 var(--s3);
}
/* ⚠️ `h3` WAS A 13px UPPERCASE TRACKED GREY LABEL, AND IT WAS DOING CARD-TITLE DUTY on eleven
 * screens ("YOUR DETAILS", "RESET A PASSWORD", "PURGE", "HELD NOW"). A title painted in the label
 * colour at the label size is not a level of hierarchy — it is the absence of one, which is exactly
 * what a subtractive pass cannot afford. It is now a real heading: body size, weight 600, ink. The
 * uppercase-tracked-grey treatment still exists, as `.eyebrow`, for the cases that genuinely want a
 * label rather than a heading. */
h3 { font-size: var(--t-body); font-weight: 600; color: var(--ink); margin: var(--s5) 0 var(--s2) }
h2:first-child, h3:first-child { margin-top: 0 }
.eyebrow {
  display: block; font-size: var(--t-micro); font-weight: 500; letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink3); margin-bottom: var(--s2);
}
/* 🔴 max-width: 68ch IS GONE, AND IT WAS THE SECOND CONTENT GRID. Measured on the agent home
 * screen at 1440: the lede rendered 440px wide while the cards beside it ran to 840px — two column
 * widths on one page, which is what makes a screen look unresolved however carefully each block is
 * padded. A measure cap is the right instinct for a page of prose and the wrong one for a page
 * frame, because the lede is now a SINGLE LINE: everything past the first sentence moved behind the
 * .infodot below. A one-line subtitle cannot run to an uncomfortable measure, so there is nothing
 * left for the cap to protect. */
.lede { color: var(--ink2); font-size: var(--t-body); margin-bottom: var(--s5) }
.muted { color: var(--ink3); font-size: var(--t-small) }

/* An identifier is monospaced. A booking reference, a VIN, a version number and an id are read
 * character by character and often read back over a phone — a proportional font makes `1`/`l` and
 * `0`/`O` the reader's problem. docs/60 §1's money rule is the same idea for figures. */
.mono { font-family: var(--font-mono); font-size: .92em; letter-spacing: .01em }

/* 🔴 A LONG IDENTIFIER TRUNCATES; IT NEVER WRAPS AND IT NEVER CLIPS SILENTLY. The full value is on
 * the element's `title`, so it is available on hover AND — because `tabindex="0"` is set with it —
 * on keyboard focus, which a `title`-on-hover alone is not. */
.trunc {
  display: inline-block; max-width: 100%; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
}

/* ---- Surfaces. A CARD HAS NO BORDER. It is a lift. -------------------------------------------
 * The two densities are named tokens, so "how much padding does a card get" has one answer per
 * KIND of card rather than one answer per screen. */
.card { background: var(--card); border-radius: var(--r); padding: var(--pad-decide) }
.card.tight { padding: var(--pad-decide-tight) }
/* 🔴 ONE NAMED STEP, REPLACING FIFTY-SIX INLINE NUMBERS. `margin-bottom` was written inline at 2,
 * 6, 8, 10, 12, 14, 16 and 18 pixels across the four front-end files — eight answers to one
 * question, five of them off any 4px grid. `.spaced` is the answer now, and it is a token. */
.spaced { margin-bottom: var(--gap-block) }
.spaced-t { margin-top: var(--gap-block) }
/* An explanatory line under a control. Fourteen of these each stated their own `margin-top:6px`. */
.hint { margin-top: var(--s1) }
/* ⚠️ VISUALLY HIDDEN, NOT HIDDEN. `display:none` and `visibility:hidden` remove an element from the
 * accessibility tree as well as from the page, so neither can carry a word that only a screen
 * reader should hear. This is the standard clip technique, and it is here in the shared sheet
 * rather than in one portal because the first use — naming the three states of the booking
 * wizard's step breadcrumb — is exactly the kind of thing the admin console will want next, and
 * two copies of a utility diverge (rule 22's reasoning, applied to a rule rather than a document).
 * `clip-path: inset(50%)` matches what `table.cards thead` above already uses. */
.vh {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; border: 0; padding: 0; margin: -1px;
}
/* An acknowledgement checkbox and the sentence it governs. Seven copies of one five-declaration
 * inline style — including its own font size and its own colour, both of which the system owns. */
.ackrow {
  display: flex; gap: var(--s2); align-items: flex-start;
  font-size: var(--t-small); color: var(--ink2); line-height: 1.45;
}
.ackrow input { margin-top: 2px; flex: 0 0 auto }
/* A heading on the left, a figure on the right — four copies of one inline declaration. */
.rowsplit { justify-content: space-between; align-items: flex-start }
/* The bulleted list under a note. Five copies, two of them differing by 2px for no stated reason. */
.bullets { margin: var(--s2) 0 0 var(--s4); padding: 0 }
.bullets li { font-size: var(--t-small); line-height: 1.5; color: var(--ink2); margin-top: var(--s1) }
/* A `<summary>` is a button that browsers do not style like one. It carried its own cursor and its
 * own 13px — the last stray type size in either front end. */
.summ { cursor: pointer; font-size: var(--t-small); color: var(--ink2); padding: var(--s1) 0 }
.summ:hover { color: var(--ink) }
/* One stat, right-aligned in a page header beside the booking reference it belongs to. */
.statright { text-align: right; min-width: 160px }
/* And the same step between two cards that ARE adjacent siblings — thirty of them are. Margins
 * collapse, so a `.spaced` card followed by a plain one still gets exactly one gap, not two. */
.card + .card { margin-top: var(--gap-block) }
/* A heading whose subtitle belongs to it: the two are one unit, so the gap between them is optical
 * rather than rhythmic. The one case in the platform is the booking reference over its customer. */
h2.tight { margin-bottom: var(--s1) }
.row { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: center }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s4) }
.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s4) }
.grid4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s4) }
@media (max-width: 767px) { .grid2, .grid3, .grid4 { grid-template-columns: 1fr } }

/* ⚠️ A CARD IS THE SCROLL CONTAINER FOR ITS OWN WIDE CONTENT. Both surfaces learned this by
 * measuring a rendered page rather than by reading a stylesheet (docs/26 items 249 and the agent
 * portal's own six-column drafts table). A table that cannot fit must scroll inside the card it
 * sits in; the page body must never scroll sideways, or the nav and the header slide off with it.
 * At every width, because "wider than its container" is not a property of the viewport.
 * ⚠️ Below the breakpoint a table stops being wide at all — it becomes cards (see `table.cards`),
 * so this is the LAST RESORT it was always meant to be rather than the mobile plan. */
.card { overflow-x: auto }

/* ---- Buttons. ONE PRIMARY PER SCREEN, filled, in the one accent. Secondary is OUTLINED.
 * Tertiary is text. A row action inside a table is never the page's primary. -------------------
 *
 * ⚠️ THE HOVER NO LONGER MOVES THE BUTTON. `transform: translateY(-1px)` meant the thing under the
 * cursor moved out from under the cursor — the one interaction rule that is never a matter of
 * taste. The hover is a lighter fill and the PRESS is a darker one, which is the feedback the lift
 * was standing in for.
 *
 * ⚠️ HEIGHT COMES FROM THE TYPE SCALE, NOT FROM A TOUCH MINIMUM — 34px on a desktop, where the
 * pointer is a mouse. The 44px minimum is a PHONE requirement and is applied at the breakpoint,
 * where it belongs. A 41px button on a laptop is just a big button. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s2);
  padding: var(--s2) var(--s4); border-radius: var(--r-sm);
  background: var(--cy); color: var(--on-cy);
  font-weight: 600; font-size: var(--t-body); line-height: 1.2;
  transition: background .12s linear, border-color .12s linear, color .12s linear;
}
.btn:hover { background: var(--cy-hover) }
.btn:active { background: var(--cy-press) }
.btn.ghost { background: transparent; box-shadow: inset 0 0 0 1px var(--line2); color: var(--ink) }
.btn.ghost:hover { background: var(--navy2); box-shadow: inset 0 0 0 1px var(--ink3) }
.btn.ghost:active { background: var(--navy) }
/* A row's affirmative action: the accent, as an outline rather than a fill, so a table of ten rows
 * does not put ten filled accent blocks on one screen and leave the page's real primary competing
 * with them. One accent, used with conviction, still means used ONCE at full strength. */
.btn.ghost.accent { box-shadow: inset 0 0 0 1px var(--cy2); color: var(--cy) }
.btn.ghost.accent:hover { background: var(--cyd); box-shadow: inset 0 0 0 1px var(--cy) }
/* Destructive: OUTLINED in the danger hue until confirmed. It is filled only inside the
 * confirmation that follows it, never on the screen that offers it. */
.btn.danger { background: transparent; box-shadow: inset 0 0 0 1px var(--red); color: var(--red) }
.btn.danger:hover { background: var(--redd); box-shadow: inset 0 0 0 1px var(--red) }
/* …and FILLED only inside the confirmation itself. One button in the platform qualifies: the submit
 * inside `#mdl-cancel`. It was an inline `background:var(--red);color:#3D0A0A` — a hex colour in
 * markup, which is the one place `src/sharedDesignTokens.test.ts` cannot see one. */
.btn.danger.filled { background: var(--red); color: var(--redd); box-shadow: none }
.btn.danger.filled:hover { background: var(--red-line); color: var(--ink) }
/* Tertiary. No box at all — the accent is spent elsewhere. */
.btn.link { background: transparent; color: var(--ink2); padding-left: var(--s2); padding-right: var(--s2) }
.btn.link:hover { background: transparent; color: var(--ink); text-decoration: underline }
.btn.sm { padding: var(--s1) var(--s3); font-size: var(--t-small) }
/* 🔴 A DISABLED BUTTON IS NOT A DIMMED ONE. `opacity: .4` over the accent fill produced a slate-grey
 * block that reads as a COLOUR CHOICE rather than as an unavailable control — Randy, looking at the
 * portal People screen: *"Add this person is muted slate ... if disabled, it must LOOK disabled."*
 * The state now has its own ground and its own ink, so it is unmistakably off rather than faint,
 * and it no longer inherits the accent at all. Subtractive: it removes a colour from a state that
 * cannot be acted on. */
.btn:disabled {
  background: var(--navy3); color: var(--ink3); box-shadow: none;
  opacity: 1; cursor: not-allowed;
}
.btn:disabled:hover { background: var(--navy3); color: var(--ink3) }

/* ⚠️ `.btn.gold` IS RETIRED AS A PRIMARY AND KEPT ONLY AS AN ALIAS, 4 September 2026.
 * Gold is the WARN hue in this design system — docs/60 §4 defines it as "provisional, override,
 * needs attention" and every `.pill.warn` and `.note.warn` on both surfaces uses it that way. The
 * agent portal was also using it for its PRIMARY action ("+ Register a client", "+ New draft
 * booking", "Create draft", "Register client"), so on the one surface Sankofa actually sees, the
 * button you are meant to press was the same colour as the badge that means something is wrong.
 * Meanwhile the admin console's primary is cyan. Two primaries, one of them colliding with a
 * semantic hue.
 *
 * Resolved toward the console: cyan is the primary on both surfaces. The class is kept rather than
 * removed from the markup so this is a one-line presentation change and not an edit to five
 * templates — and so that a screen that genuinely wants a gold ACTION some day has somewhere to
 * put it, deliberately, rather than by reaching for the warn colour again. */
.btn.gold { background: var(--cy); color: var(--on-cy) }
.btn.gold:hover { background: var(--cy-hover) }

/* A full-width action. The phone case wants this constantly, and it was reached for with an inline
 * `style="width:100%;justify-content:center"` five times in the agent portal's markup. */
.btn.block { width: 100%; justify-content: center }

.pill {
  display: inline-flex; align-items: center; gap: var(--s1);
  font-size: var(--t-micro); font-weight: 600;
  padding: 2px var(--s2); border-radius: var(--r-pill); letter-spacing: .02em;
  white-space: nowrap;
}
.pill.ok { background: var(--grnd); color: var(--grn) }
.pill.warn { background: var(--goldd); color: var(--gold) }
.pill.bad { background: var(--redd); color: var(--red) }
.pill.info { background: var(--cyd); color: var(--cy) }
.pill.grey { background: var(--navy3); color: var(--ink3) }
/* A pill holding a NUMBER. Not a sixth colour and not a new component — a typographic modifier on
 * the five that already exist (§4: a sixth hue means the thing needs its own word, not its own
 * colour), so a LOUD count is `.pill.warn.num` and a quiet one is `.pill.grey.num`.
 *
 * ⚠️ TABULAR AND MINIMUM-WIDTH, AND BOTH ARE THE SAME DEFECT docs/60 §16.8 NAMES FOR TABLE
 * COLUMNS: proportional digits make a badge change width as its count changes, so every nav item
 * in the rail shifts when one number goes from 9 to 10. `1ch` of extra minimum keeps a single
 * digit the same size as a double, which is what stops the rail moving while somebody reads it. */
.pill.num { min-width: 2.1ch; justify-content: center; font-variant-numeric: tabular-nums }

/* ---- Forms ---------------------------------------------------------------------------------- */
label.f {
  display: block; font-size: var(--t-micro); font-weight: 500; letter-spacing: .08em;
  text-transform: uppercase; color: var(--ink3); margin-bottom: var(--s1);
}
/* An input is INSET — a step BELOW the card it sits on — which is what tells a reader it takes
 * something, without a border having to say so. The 1px edge survives only because a text field
 * with no edge at all has no target to aim at. */
.inp {
  width: 100%; padding: var(--s2) var(--s3); border-radius: var(--r-sm);
  background: var(--navy); border: 1px solid var(--line); color: var(--ink);
  font-size: var(--t-body); line-height: 1.4; outline: none;
  transition: border-color .12s linear, box-shadow .12s linear;
}
.inp:hover { border-color: var(--line2) }
.inp:focus { border-color: var(--cy2); box-shadow: var(--focus-ring-quiet) }
.inp::placeholder { color: var(--ink3); opacity: .75 }
.fld { margin-bottom: var(--s4) }

/* A textarea takes `.inp` like every other field, and needs two things `.inp` cannot give it: a
 * floor, so that a `rows` attribute is the only thing standing between a paste box and one line of
 * text, and a resize axis. HORIZONTAL RESIZE IS OFF DELIBERATELY: a field dragged wider than the
 * card it sits on breaks the layout around it, and nobody drags a paste box sideways on purpose. */
textarea.inp { min-height: 8em; resize: vertical; line-height: 1.5 }

/* A generated select must match the generated inputs beside it — `.inp` is written for `<input>`,
 * and a bare `<select>` renders at the browser's default height next to a 13.5px input. This lived
 * only in the agent portal, which has ONE select; the admin console has nineteen.
 *
 * ⚠️ AND IT MUST DRAW ITS OWN CHEVRON BACK. The portal's version was `appearance:none` plus
 * `background-image:none`, which strips the browser's drop-down arrow and puts nothing in its
 * place. With one select that is invisible; with nineteen it is a screen full of controls that
 * look exactly like text inputs and give no sign they can be opened. Caught by looking at the
 * rendered "Find a booking" screen immediately after moving the rule here — the height matched
 * and the affordance had gone. The chevron is an inline SVG so it costs no request and inherits
 * nothing that can fail to load. */
select.inp {
  appearance: none; -webkit-appearance: none; cursor: pointer;
  padding-right: var(--s7);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5' fill='none' stroke='%238FA7BD' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--s3) center;
}

/* A per-field refusal or note, rendered directly beneath the input that caused it. docs/60 §14.6's
 * "refuse at the field, not at the end": the answer belongs next to the question, not in a summary
 * at the bottom that makes someone scroll to find out which field is wrong. */
.fldmsg { font-size: var(--t-small); margin-top: var(--s1); line-height: 1.4 }
.fldmsg:empty { display: none }
.fldmsg.bad { color: var(--red) }
.fldmsg.ok { color: var(--grn) }
/* ⚠️ A THIRD TONE, BECAUSE THERE IS NOW A THIRD ANSWER — 4 September 2026. The height limit advises
 * and no longer refuses (Randy, register item 464), so a field can say "this proceeds, and here is
 * what you are taking on" — which is neither `bad` (it does not refuse) nor `ok` (it is not fine).
 * `--gold` is the existing token for exactly this meaning: "warn: provisional, override, needs
 * attention". Rendering an advisory as `bad` would have put a refusal's colour on a booking that
 * goes ahead, which is the same defect as refusing it, in paint. */
.fldmsg.warn { color: var(--gold) }
.inp.bad { border-color: var(--red) }
.inp.bad:focus { box-shadow: var(--focus-ring-bad) }

/* The segmented control. On the agent portal this IS the navigation, so its selected tab is one of
 * the two places the accent is spent at full strength (the other is the page's primary button). */
.seg {
  display: inline-flex; gap: 0; background: var(--navy); border-radius: var(--r);
  padding: 3px; overflow: hidden; margin: 0 0 var(--s5); max-width: 100%;
}
.seg button {
  flex: 1 1 auto; padding: var(--s2) var(--s4); background: transparent; border: 0;
  color: var(--ink2); font: inherit; font-size: var(--t-small); font-weight: 500;
  border-radius: var(--r-sm); cursor: pointer; white-space: nowrap;
  transition: background .12s linear, color .12s linear;
}
.seg button:hover { color: var(--ink) }
/* ⚠️ `.seg` sets `overflow:hidden`, which CLIPS the shared `:focus-visible` outline on a child —
 * so the agent portal's entire navigation had a keyboard focus indicator that could not be seen.
 * An inset ring cannot be clipped by the parent it is inside. This is the one use of
 * `--focus-ring`, and the reason that token exists separately from `--focus-ring-quiet`. */
.seg button:focus-visible { outline: none; box-shadow: inset var(--focus-ring) }
.seg button.on { background: var(--cy); color: var(--on-cy-deep); font-weight: 600 }

/* ---- Tables ----------------------------------------------------------------------------------
 * ONE HAIRLINE BETWEEN ROWS. No cell borders, no box around the table, no zebra. Tabular numerals
 * on the whole table, not only on money: a date column whose digits shift between rows is the same
 * defect, and it is the one nobody names. */
table {
  width: 100%; border-collapse: collapse; font-size: var(--t-body);
  font-variant-numeric: tabular-nums;
}
th {
  text-align: left; font-size: var(--t-micro); letter-spacing: .08em; text-transform: uppercase;
  color: var(--ink3); padding: var(--cell-y) var(--cell-x); font-weight: 500;
  border-bottom: 1px solid var(--line);
  /* 🔴 STICKY, so a long queue never leaves a reader guessing which column they are in. It sticks
   * to the CARD, which is the scroll container, so it needs the card's own ground under it. */
  position: sticky; top: 0; z-index: 1; background: var(--card);
}
td { padding: var(--cell-y) var(--cell-x); border-bottom: 1px solid var(--line); vertical-align: middle }
/* 🔴 A LONG UNBROKEN TOKEN MUST NOT SET A COLUMN'S MINIMUM WIDTH. `accounts@arawakcay.example` has
 * no space in it, so it contributed 26 unbreakable characters to the table's min-content width and
 * pushed the agent portal's client table 57px past its card at 760px — measured, not guessed. The
 * cells that must NOT break (a reference, a date, money) are exempt by their own classes, which is
 * why this is written as an exclusion rather than applied to every cell. */
td:not(.nowrap):not(.n) { overflow-wrap: anywhere }
tr:last-child td { border-bottom: 0 }
/* ⚠️ FIRST AND LAST CELL SIT ON THE CARD'S OWN EDGE. Without this a table inside a 24px-padded card
 * is indented twice and the column of references no longer lines up with the heading above it —
 * alignment discipline is invisible when it is right and obviously amateur when it is wrong. */
td:first-child, th:first-child { padding-left: 0 }
td:last-child, th:last-child { padding-right: 0 }
tr.clik { cursor: pointer; transition: background .12s linear }
tr.clik:hover td { background: var(--navy2) }
tr.clik:active td { background: var(--navy) }
div.clik { cursor: pointer; transition: background .12s linear }
div.clik:hover { background: var(--navy2) }

/* Money is right-aligned in tabular numerals so a column of amounts can be added down the page by
 * eye and digits do not shift position between rows (docs/60 §§10–11). */
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap }

/* A row's action cell. It was an inline `class="row" style="flex-wrap:nowrap;gap:6px"` in three
 * places, and the `nowrap` is what made one table 8px wider than its card at 760px — an inline
 * style cannot be relaxed by a media query, which is the whole argument for it being a class. */
/* ⚠️ `align-items: center` ADDED OPS-36. `display: flex` takes this cell out of table layout, so
 * the `vertical-align: middle` on `td` above does not reach it — in a row made tall by a wrapping
 * cell beside it, the buttons sat at the top and did not line up with the row they belonged to.
 * Invisible until a row was tall enough to show it, which is why it survived. */
td.actions { display: flex; align-items: center; gap: var(--s2); flex-wrap: nowrap; justify-content: flex-end }
/* ---- A TABLE WHOSE ACTIONS MUST NEVER SCROLL OUT OF REACH -------------------------------------
 * 🔴 WHY THIS EXISTS: THE OVERFLOW GUARD PASSED AND THE OPERATOR WAS LOST. Register item 912(b).
 * The voyages table has nine columns; at 1440px its Master / Arrival / Publish buttons sat at
 * x ≈ 1533 with the card's scroller 1080px wide. The card is `overflow-x: auto`, so the table was
 * rule-compliant and the page never scrolled sideways — and the three actions a person needs to
 * run a voyage were simply not on the screen, with nothing to suggest they existed. An operator on
 * a laptop sees a complete-looking table and cannot find the controls.
 *
 * ⚠️ A SCROLL HINT WOULD NOT HAVE FIXED IT, and that was considered first. "There is more to the
 * right" tells someone their screen is too small; it does not put the button within reach, and the
 * operator still has to scroll away from the voyage code to find the row's own actions and then
 * scroll back to check they acted on the right one.
 *
 * 🔒 STICKY KEEPS EVERY COLUMN. Fewer columns and a row menu were the other two options; both
 * remove something. This removes nothing: the actions pin to the right edge of the card while the
 * capacity columns scroll under them, so the voyage code is readable on the left and the buttons
 * are always in the same place on the right.
 *
 * ⚠️ IT NEEDS THE CARD'S OWN GROUND UNDER IT, exactly as the sticky `th` above does — a transparent
 * sticky cell shows the scrolling row through itself. The hairline on its leading edge is what
 * makes it read as pinned rather than as a rendering fault.
 *
 * ⚠️ WIDE SCREENS ONLY. Below the breakpoint a pinned column would take a third of a phone and the
 * table is short enough to scroll honestly; `td:last-child{padding-right:0}` also puts the pinned
 * cell flush with the card edge, which is right on a laptop and cramped on a phone. */
@media (min-width: 768px) {
  table.stickyactions td.actions,
  table.stickyactions th:last-child {
    position: sticky; right: 0; z-index: 2;
    background: var(--card);
    border-left: 1px solid var(--line);
    padding-left: var(--cell-x);
    /* 🔴 THE SHADOW IS NOT DECORATION — IT PAINTS THE CARD'S OWN RIGHT PADDING, AND WITHOUT IT THE
     * FIRST VERSION OF THIS LEAKED. `right: 0` pins the cell to the scroll port's CONTENT edge, so
     * the card's `--pad-decide` of right padding sits outside the pinned cell and the columns
     * scrolling underneath showed through it as a strip of half-numbers — "25.0", "/ 40", and a
     * header reading "TED" where ALLOTTED ran under the buttons. An offset solid shadow with zero
     * blur paints a rectangle of the card's own colour over exactly that strip.
     *
     * ⚠️ FOUND BY OPENING THE PNG, NOT BY A THRESHOLD, which is the second time on this one table
     * (rule 42: a script that hands a human an artefact should be looked at, once, by a human). */
    box-shadow: var(--pad-decide) 0 0 var(--card);
  }
  /* The header cell is sticky in BOTH directions — top from the rule above, right from this one —
   * so the actions column keeps its heading when the table is scrolled either way. */
  table.stickyactions th:last-child { z-index: 3 }
  /* A clickable row's hover must still reach the pinned cell, or the highlight stops halfway
   * across and reads as a rendering fault rather than as a row. */
  table.stickyactions tr.clik:hover td.actions { background: var(--navy2) }
  table.stickyactions tr.clik:active td.actions { background: var(--navy) }
}

/* The "this row opens" chevron. It earns its place on a wide table, where the row's clickability is
 * otherwise unsignalled — and it earns nothing in card view, where the whole card is the target and
 * a bare `›` on a line of its own just reads as a stray character. Hidden there, not restyled. */
td.chev { text-align: right; color: var(--ink3) }
@media (max-width: 767px) {
  table.cards td.actions { flex-wrap: wrap; justify-content: flex-start; padding-top: var(--s2) }
  table.cards td.chev { display: none }
}

/* A reference code and a date are single tokens a person reads back over the phone; breaking
 * "HK-BVF-mtj747sd" across three lines makes it harder to read, not narrower — the card already
 * scrolls, so there is nothing to gain by wrapping them. */
td.nowrap, th.nowrap { white-space: nowrap }

/* 🔴 CARD VIEW IS THE DEFAULT FOR A TABLE BELOW THE BREAKPOINT, and horizontal scroll is the last
 * resort it was always supposed to be. `/shared/tables.js` copies each column's heading onto the
 * cells under it as `data-label` — one decorator for every table on both surfaces, rather than
 * fifteen render functions each remembering to emit a label — and this block turns the labelled
 * table into one card per row. The FIRST cell is the card's header (it is the identifier on every
 * table in this product) and carries no label; a cell that holds only actions has no label either,
 * so it spans the row. */
@media (max-width: 767px) {
  table.cards, table.cards thead, table.cards tbody, table.cards tr, table.cards td { display: block }
  table.cards thead, table.cards tr.hd { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%) }
  table.cards tr {
    background: var(--navy2); border-radius: var(--r);
    padding: var(--s3); margin-bottom: var(--s2);
  }
  table.cards tr:last-child { margin-bottom: 0 }
  table.cards td {
    display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
    gap: var(--s1) var(--s4); padding: var(--s1) 0; border-bottom: 0; text-align: left;
  }
  table.cards td::before {
    content: attr(data-label); flex: 0 0 auto;
    font-size: var(--t-micro); letter-spacing: .08em; text-transform: uppercase;
    color: var(--ink3); font-weight: 500;
  }
  table.cards td[data-label=""]::before, table.cards td:first-child::before { content: none }
  table.cards td:first-child {
    font-size: var(--t-body); font-weight: 600; padding-bottom: var(--s2); display: block;
  }
  table.cards td.n { text-align: left }
}

/* ---- Notes, alerts, key/value, stats ----------------------------------------------------------
 * ⚠️ `.note` AND `.alert` ARE DELIBERATELY UNTOUCHED BY THE 4 September DESIGN PASS BEYOND WHAT
 * THE TOKENS THEMSELVES CHANGE. They are the notification and banner surfaces, and a separate run
 * rebuilds them; restyling them here and then again there is how two designs for one thing get
 * written. */
.note {
  border-left: 3px solid var(--cy2); background: var(--navy2); padding: var(--s3) var(--s4);
  border-radius: 0 var(--r) var(--r) 0; font-size: var(--t-small); color: var(--ink2); margin-top: var(--s4);
}
.note.warn { border-left-color: var(--gold) }
.note.bad { border-left-color: var(--red) }
/* ⚠️ `.note.ok` WAS WRITTEN BEFORE IT EXISTED. `public/admin/app.js` renders the profile screen's
 * confirmation as `<div class="note ok">Saved.</div>` against no such rule in either stylesheet, so
 * the one success message in the console rendered as a neutral cyan note. A class name that does
 * nothing is invisible in review and invisible on screen. */
.note.ok { border-left-color: var(--grn) }
.note b { color: var(--ink) }

.alert {
  border-radius: var(--r); padding: var(--s3) var(--s4); font-size: var(--t-small); margin-bottom: var(--s4);
  background: var(--redd); border: 1px solid var(--red-line); color: var(--ink);
}
.alert b { color: var(--red) }
.alert.info { background: var(--cyd); border-color: var(--cy2) }
.alert.info b { color: var(--cy) }
/* ⚠️ A SUCCESS ALERT. `.alert` is red by default (it is overwhelmingly used for refusals) and for a
 * while the only variant was `.alert.info`, so a "Client registered." confirmation rendered in the
 * REFUSAL colours — caught by looking at the screen, not by any assertion. */
.alert.ok { background: var(--grnd); border-color: var(--grn) }
.alert.ok b { color: var(--grn) }
/* ⚠️ A CAUTION THAT IS NOT A REFUSAL, AND THE GAP IT FILLS IS EXACTLY THE ONE `.alert.ok` FILLED.
 * The vessel capacity advisory (migration 237) succeeds and warns in the same breath — the voyage
 * WAS created, and somebody should check the ship holds it. With only `info` and the red default,
 * that sentence had to render either as a confirmation nobody reads twice or as a refusal that did
 * not happen. Same gold as `.pill.warn` and `.note.warn`, which is this system's one "needs
 * attention" colour. */
.alert.warn { background: var(--goldd); border-color: var(--gold) }
.alert.warn b { color: var(--gold) }

.kv {
  display: flex; justify-content: space-between; gap: var(--s4); padding: var(--s2) 0;
  border-bottom: 1px solid var(--line); font-size: var(--t-body);
}
.kv:last-child { border-bottom: 0 }
.kv .k { color: var(--ink3); font-size: var(--t-small) }

/* A stat is a figure, not a box. The border and the second ground are gone; what separates one
 * stat from the next is the space between them and the weight jump between label and value. */
.stat { padding: 0 }
.stat .lbl { font-size: var(--t-micro); letter-spacing: .08em; text-transform: uppercase; color: var(--ink3); font-weight: 500 }
.statrow { margin-top: var(--s3) }
.stat .val {
  font-size: var(--t-h2); font-weight: 650; letter-spacing: -.02em;
  margin: var(--s1) 0 0; font-variant-numeric: tabular-nums;
}

/* ---- Screen switching ------------------------------------------------------------------------ */
.screen { display: none }
.screen.on { display: block; animation: fade .12s linear }
@keyframes fade { from { opacity: 0 } to { opacity: 1 } }

/* ⚠️ RESPECT A REDUCED-MOTION PREFERENCE. The screen-switch fade is decoration and carries no
 * meaning, so it is safe to drop. The spinner keeps turning — a stopped spinner would read as a
 * hung request. */
@media (prefers-reduced-motion: reduce) {
  .screen.on { animation: none }
  * { transition-duration: .01ms !important }
}

/* ---- Modal ------------------------------------------------------------------------------------
 * The one thing in the system that genuinely floats, so the one thing that may carry the shadow. */
.mdlbg {
  position: fixed; inset: 0; background: var(--scrim); backdrop-filter: blur(3px); z-index: 80;
  display: none; align-items: flex-start; justify-content: center;
  padding: var(--s8) var(--s5) max(var(--s8), env(safe-area-inset-bottom)); overflow: auto;
}
.mdlbg.on { display: flex }
/* 🔴 A DIALOG THAT IS SUMMONED BY ANOTHER DIALOG SITS ABOVE IT. OPS-101 part 2, `docs/26` 1658.
 *
 * ⚠️ FOUND BY DRIVING IT, NOT BY READING IT. `npm run walk:staff-day` confirmed an identity check,
 * the server demanded a fresh authenticator code, and `document.elementFromPoint` at the centre of
 * the step-up dialog's own "Continue" button returned **`div#mdl-kyc-decision`**. Both overlays
 * carried `z-index: 80`, so with two open the LATER ONE IN THE DOCUMENT painted on top — and
 * `#mdl-stepup` is declared at line 4473 while the dialog that summons it is at 4573. The staff
 * member is left looking at a Confirm button reading "Recording…" for ever, which is the exact
 * symptom `scripts/stuckButtonWalk.ts` was written about and a SECOND, different cause of it.
 *
 * 🔒 THE INVOICE AND COMMISSION-RATE DIALOGS WERE CORRECT ONLY BY ACCIDENT OF SOURCE ORDER — both
 * are declared BEFORE `#mdl-stepup`, so the step-up happened to win. That is not a property anybody
 * chose and it is not one anybody would maintain: the next dialog written below line 4473 that calls
 * `apiWithStepUp` breaks in exactly this way, silently, and only in the browser. This class makes
 * the ordering a decision instead of a coincidence.
 *
 * ⚠️ IT IS A CLASS AND NOT AN ID RULE, so the stylesheet never names one dialog, and
 * `src/stepUpDialogIsOnTop.test.ts` is what refuses a second overlay that claims it or a step-up
 * overlay that loses it. */
.mdlbg.over { z-index: 90 }
.mdl {
  width: 100%; max-width: 640px; background: var(--card); border: 1px solid var(--line2);
  border-radius: var(--r); box-shadow: var(--shadow-modal);
}
.mdlhd {
  display: flex; align-items: flex-start; gap: var(--s3);
  padding: var(--s4) var(--pad-decide) var(--s3); border-bottom: 1px solid var(--line);
}
/* A modal's title is the largest thing in the modal, because it is the question being asked. It was
 * an `<h3>` carrying an inline `margin:0` in twenty-six places, rendering at the label size. */
.mdlhd h3 { margin: 0; font-size: var(--t-h2) }
.mdlhd .lede, .mdlhd .muted { margin: var(--s1) 0 0 }
.mdlbd { padding: var(--pad-decide) }
/* ⚠️ `.mdlft` WAS EMITTED EIGHT TIMES IN THE ADMIN CONSOLE AND STYLED NOWHERE. Every one of those
 * eight modal footers — create an agent, create a staff account, issue a credential, publish a rate
 * — laid its Cancel and its confirm button out with no rule of its own at all. The confirm action
 * sits LAST (right) so the eye ends on it, and Cancel is a ghost beside it, never a second fill. */
.mdlft {
  display: flex; justify-content: flex-end; align-items: center; gap: var(--s2);
  padding: var(--s3) var(--pad-decide) var(--pad-decide); flex-wrap: wrap;
}
.mdlft > .btn { flex: 0 0 auto }
@media (max-width: 767px) {
  .mdlft { flex-direction: column-reverse; align-items: stretch }
  .mdlft > .btn { width: 100% }
}
.mdlx {
  margin-left: auto; background: transparent; border: 0; color: var(--ink3); font-size: var(--t-h2);
  line-height: 1; cursor: pointer; padding: var(--s1) var(--s2); border-radius: var(--r-sm);
}
.mdlx:hover { color: var(--ink); background: var(--navy2) }

/* ---- The pre-login auth chain: one centred card, no shell. Identical on both surfaces. -------- */
.authpage { min-height: 100vh; display: grid; place-items: center; padding: var(--s6) var(--s5) }
.authcard { width: 100%; max-width: 400px }
.brand { display: flex; align-items: center; gap: var(--s3); margin-bottom: var(--s5) }
/* 🔴 THE MARK IS A LETTERFORM IN THE FLAG'S AQUAMARINE. Two attempts at a literal flag were tried
 * and BOTH were rejected by the owner looking at them, 4 September 2026, and both rejections were
 * right for reasons worth keeping:
 *
 *   1. The flag drawn edge-to-edge on the dark page: *"the black on the flag gets lost in the dark
 *      background."* Measured, `--flag-black` is L* 4.9 against a page at L* 3.8 — one point apart.
 *      The hoist triangle merged with the page and the mark read as a rectangle with a bite out of
 *      it. A flag needs a field to sit on; drawn straight onto a dark ground it cannot work.
 *   2. So the flag was given a paper field, which fixed the black and produced the second verdict:
 *      *"i do not like the white border on flag either. it look tacky."* A light rectangle on a
 *      dark operations console reads as a sticker, not as a brand.
 *
 * ⚠️ THE LESSON IS NOT "PICK A THIRD FLAG TREATMENT." It is that a 24px mark in a working top bar
 * has no room for a three-colour device with a fourth colour holding it together, and that the flag
 * reaches this product far better through the ACCENT — which IS the flag's aquamarine, tuned (see
 * `--cy`) — than through a picture of itself. The full three-colour flag is reserved for the
 * document headers (docs/60 §16.5.5), which have the size and the light ground it needs.
 *
 * The fill is `--flag-aqua`, which §16.5.2 permits as a FILL and forbids as text; near-white on it
 * measures 4.63:1. Deliberately NOT `--cy`: a logo painted the exact colour of the primary button
 * spends the accent twice and weakens "one accent, used with conviction". */
.brandmark {
  width: 30px; height: 30px; flex: 0 0 auto; border-radius: var(--r);
  background: var(--flag-aqua);
  display: grid; place-items: center;
  font-weight: 700; font-size: var(--t-body); color: var(--ink); letter-spacing: 0;
}
.brandmark.sm { width: 24px; height: 24px; border-radius: var(--r-sm); font-size: var(--t-small) }
/* The signed-in header wears a smaller mark than the login card does. It was four inline `style`
 * declarations repeated across both surfaces — including the one 13px font size left in either
 * front end, which is not on the five-size scale. */
/* ⚠️ THE LINE HEIGHTS ARE EXPLICIT BECAUSE THEY ARE WHAT THE HEADER HEIGHT IS MADE OF. With the
 * body default (1.5) the two-line brand block measured 35px and the top bar came out at 51px
 * against a 48px target -- three pixels that no padding change could reach, because the padding was
 * already at 8px and the next step down is 4px. Measured, not guessed: 48px exactly. */
.brandname { font-weight: 650; letter-spacing: .12em; font-size: var(--t-small); line-height: 1.2 }
.brandsub { font-size: var(--t-micro); color: var(--ink3); letter-spacing: .08em; margin-top: 1px; text-transform: uppercase; line-height: 1.2 }
.brandrow { gap: var(--s2); flex-wrap: nowrap }
.topbar .brandsub { margin-top: 0 }

/* 48px, down from 60. The header is chrome on every screen in both portals, so its height is paid
 * once per screen for ever — the cheapest vertical pixel in the product to remove. Nothing inside
 * it changed; the padding did (12px to 8px). */
.topbar {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4);
  padding: var(--s2) var(--s5); border-bottom: 1px solid var(--line); background: var(--navy);
}
.topbar .who { font-size: var(--t-small); color: var(--ink3) }
.topbar .who b { color: var(--ink2); font-weight: 500 }
.main {
  padding: var(--s5) var(--s5) max(var(--s8), env(safe-area-inset-bottom));
  max-width: 1080px; margin: 0 auto;
}

/* ================================================================================================
   THE NAVIGATION SHELL — ONE RULE SET, BOTH SURFACES. Moved here 4 September 2026 (Run F).
   ================================================================================================
   ⚠️ WHY THIS MOVED, AND IT IS THE SAME REASON `.pagehd` MOVED THE DAY BEFORE. These rules lived
   in `public/admin/app.css` alone, under a header calling the grouped side rail "composition that
   has no counterpart in the agent portal". That stopped being true the moment the portal's
   segmented three-tab strip was replaced by a rail of seven destinations. The choice was a second
   copy of `.navitem` in the portal's stylesheet or one set here, and this file's own header is the
   record of what the first option already cost once on this exact pair of files.

   What is NOT here, deliberately: each surface's NARROW-WIDTH treatment. The console turns its
   rail into a horizontally scrolling strip; the portal hides the rail and uses a bottom bar
   (below). Those are genuinely different answers to the same question, which is precisely the
   "layout density and input ergonomics" difference docs/60 permits — so they stay in the two app
   stylesheets, and the shared part is the part that is actually shared.

   WHY A RAIL AND NOT MORE TABS (carried over verbatim from the console's own reasoning, because it
   applies twice as hard at seven destinations): the flat tab strip put peers side by side with no
   grouping, which is how a working search screen went unused for days (docs/26). Grouping makes an
   area distinguishable by WHERE it sits as well as by its label, and leaves room for a group to
   say honestly that it holds nothing yet.

   ⚠️ THE RAIL HAS NO BORDER. It is a lifted surface and the lift is what separates it (this file's
   header explains why that is the dark-mode rule rather than a preference). */
/* 196px, down from 216. The longest rail label in either portal ("Sailed & unpaid", console) sets
 * the floor; 196 clears it and returns 20px per screen to the content column at every width above
 * the breakpoint. */
.shell { display: grid; grid-template-columns: 196px minmax(0, 1fr); align-items: start }
.side {
  position: sticky; top: 0; align-self: stretch; background: var(--navy);
  padding: var(--s4) 0 var(--s6); min-height: calc(100vh - 52px);
}
.navgroup { margin-bottom: var(--s5) }
/* The group label is an eyebrow: it names a region, it is not a heading and must not compete with
 * the item under it. Space above it is three times the space below — the asymmetry is what makes
 * five groups read as five groups rather than as one list of nineteen things. */
.glabel {
  font-size: var(--t-micro); letter-spacing: .1em; text-transform: uppercase; color: var(--ink3);
  font-weight: 500; padding: 0 var(--s5) var(--s2); display: block;
}
/* ================================================================================================
   THE GROUP LABEL AS A SWITCH — OPS-53 phase E. COLLAPSING ONLY.
   ================================================================================================
   Randy, OPS-53: *"Make the eight existing categories collapsible, keeping every item, every label
   and every position exactly as they are today… Collapsing ONLY. Not the re-organisation."* So
   nothing here moves an item or renames a group; `docs/90`'s eleven-section tree is approved in
   principle and waits for its own run.

   ⚠️ THE SAME SELECTOR SERVES BOTH SHAPES, WHICH IS WHY THE BUTTON RULES ARE ADDITIVE RATHER THAN A
   REWRITE. The agent portal's rail uses `<span class="glabel">` and is untouched by this commit;
   the console's is now a `<button class="glabel">`. Everything above still applies to both, and
   only the four rules below distinguish them — so a portal that later wants collapsing gets it by
   changing its markup, not by finding a second stylesheet.

   ⚠️ IT IS A `<button>` AND NOT A `<summary>`/`<details>` PAIR. `<details>` brings its own marker,
   its own open/close semantics and a default disclosure triangle that no token in this file
   controls, and it would have to be restyled back to neutral before anything else could be said.
   A button with `aria-expanded` says exactly what is happening, is already keyboard-operable, and
   is the same element every other control in this rail already is. */
button.glabel {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s2);
  width: 100%; text-align: left; background: transparent; border: 0;
  cursor: pointer; transition: color .12s linear;
}
button.glabel:hover { color: var(--ink2) }
/* The chevron is drawn from a border, not an icon font or an SVG asset: two borders rotated 45°,
 * which costs no request and inherits `currentColor` so it follows the label's own hover state. */
.gchev {
  width: 6px; height: 6px; margin-right: var(--s3); flex: 0 0 auto;
  border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .12s linear;
}
button.glabel[aria-expanded="false"] .gchev { transform: rotate(-45deg) translate(-1px, 1px) }
/* ⚠️ A COLLAPSED GROUP KEEPS ITS LABEL AND LOSES ONLY ITS ITEMS. The alternative — hiding the whole
 * group — would make a collapsed section unreachable, which is a rail that forgets where things
 * are rather than one that tidies them. */
.navitems { display: block }
.navitem {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s2); width: 100%;
  padding: var(--s2) var(--s3) var(--s2) var(--s5); font-size: var(--t-body); line-height: 1.3;
  color: var(--ink2); text-align: left; border-left: 2px solid transparent;
  transition: background .12s linear, color .12s linear;
}
.navitem:hover { background: var(--navy2); color: var(--ink) }
.navitem:active { background: var(--bg) }
/* 🔴 ONE OF EXACTLY TWO PLACES THE ACCENT IS SPENT AT FULL STRENGTH (the other is the page's single
 * primary button). A 2px accent rule, an accent-tinted ground, ink-white text and a weight step —
 * four signals rather than one faint background, because "which screen am I on" is a question the
 * eye should answer without being asked. */
.navitem.on { background: var(--accent-wash); color: var(--ink); border-left-color: var(--cy); font-weight: 600 }
/* Not built yet: visibly present, honestly labelled, and inert — never a link to a 403 or a blank
 * screen (docs/60 §8). No hover state, no pointer cursor, no click handler bound at all. */
.navitem.off { color: var(--ink3); cursor: default }
.navitem.off:hover { background: transparent; color: var(--ink3) }
.navitem .pill { flex: 0 0 auto }
/* ⚠️ min-width:0 ON BOTH SHELL CHILDREN, AND minmax(0,1fr) ON THE WIDE TRACK. A grid track written
 * `1fr` has an `auto` minimum, which is the item's MIN-CONTENT width — and a narrow-mode `.side` is
 * a flex row of nowrap items, so its min-content is every group laid end to end (measured: 562px
 * inside a 420px viewport). The track grew to fit it, the content column grew with it, and the
 * whole page scrolled sideways — the nav's own `overflow-x:auto` never got the chance to scroll,
 * because nothing ever constrained it. Found by measuring which elements were wider than the
 * viewport, not by reading the rule. */
.side, .shell > .main { min-width: 0 }
/* ⚠️ `.main`'s own `margin: 0 auto` MUST BE NEUTRALISED INSIDE A SHELL, AND THIS IS A MEASURED
 * FINDING RATHER THAN A TIDY-UP. An auto margin on a grid item disables the default stretch, so
 * the content column sized itself to its content and floated in the middle of its track instead
 * of filling it — caught by measuring the rendered console (666px inside a 1189px column), not
 * by reading the stylesheet. Left-aligned deliberately: beside a fixed left rail, content that
 * drifts toward the centre of a wide screen moves away from the navigation it is being read
 * against. Each surface keeps its own column WIDTH (the console is table-heavy and wider; the
 * portal is prose-heavy and narrower) — this is only the part that is the same on both. */
.shell > .main { margin: 0; width: 100% }

/* ---- Collapsing the rail ---------------------------------------------------------------------
 * 🔴 COLLAPSED MEANS HIDDEN, NOT NARROWED, AND THAT IS FORCED BY A DESIGN RULE RATHER THAN A
 * PREFERENCE. The usual icon rail is unavailable here: "No decorative icons. An icon set is
 * complete or absent" — and this product has no icon set. A 56px rail with the labels removed and
 * nothing put back is seven unlabelled buttons, which is worse than no rail at all. So the rail
 * goes away entirely and the content column takes the full width; the control that brings it back
 * lives in the top bar, where it is visible in BOTH states and says which state it will produce.
 *
 * The toggle is a worded button, never a glyph, for the same reason. */
.shell.railhidden { grid-template-columns: minmax(0, 1fr) }
.shell.railhidden > .side { display: none }

/* ================================================================================================
   THE MOBILE BOTTOM BAR — the portal's narrow-width navigation (Run F).
   ================================================================================================
   Randy: "a tasteful hamburger menu might be appreciated", and then the correction that decided the
   shape: an agent standing in a yard holds the phone one-handed and the thumb is at the bottom. So
   this is a HYBRID and not a hamburger — the destinations used every day are one thumb-reach away,
   and the rest are behind "More". A hamburger hiding all seven would make the daily four cost two
   taps each.

   ⚠️ NO `overflow: hidden` ANYWHERE IN THIS BLOCK, AND THAT OMISSION IS THE POINT. `.seg` — what
   this replaces — sets `overflow:hidden`, which CLIPPED the shared `:focus-visible` outline on its
   children, so the portal's ENTIRE navigation had a keyboard focus indicator that could not be
   seen. That was repaired for `.seg` with an inset ring; here it is avoided by construction, and
   the items carry BOTH the shared outline and an inset ring so neither mechanism is load-bearing
   on its own.

   ⚠️ `env(safe-area-inset-bottom)` — index.html sets `viewport-fit=cover`, so this resolves to a
   real number on a notched phone. Without it the last row of a bar pinned to the bottom sits under
   the home indicator. */
.bottomnav { display: none }
.moresheet { display: none }
/* The primary action. Phone only — on a desktop the rail already carries it at one tap, and two
 * primary actions on one surface is one too many. See agent/index.html for the argument about why
 * this is not a fifth cell in the bar (docs/26 item 1159).
 *
 * ════════════════════════════════════════════════════════════════════════════════════════════════
 * 🔴 IT WAS A FLOATING BUTTON (`.fab`) AND IT IS NOW A FULL-WIDTH BAR — Randy, OPS-69B, deciding it:
 * *"The floating button becomes a FULL-WIDTH ACTION BAR pinned to the bottom, occupying the 152px
 * that .main already reserves. It must be incapable of overlapping content by construction, not by
 * tuning."*
 * ════════════════════════════════════════════════════════════════════════════════════════════════
 * ⚠️ THE OLD ONE WAS NOT OVERLAPPING BECAUSE ITS NUMBERS WERE WRONG. `.hasbottomnav .main` reserved
 * 152px, which was MORE than the 96px of furniture, and the button still lay over the last row of
 * four screens — measured, `scripts/mobileOverlap.ts`, 16 September 2026: over "9 Sep 2026" on
 * Bookings, "$1,400.00" on Rates, "oldest sitting 8 days 19 hours" on People and "Bookings you
 * create appear here" on Earnings. A floating element is not covered by a reservation at the FOOT
 * of a page: it hangs at a fixed offset from the bottom of the VIEWPORT, so on any page long enough
 * to scroll it sits over whatever row happens to be at that offset, at every scroll position except
 * the last. More reserved room would not have fixed that, and more tuning would not either — which
 * is what "by construction" means here. A bar that spans the full width and docks above the
 * navigation cannot be over anything, because there is nothing beside it to be over. */
.actionbar { display: none }

@media (max-width: 767px) {
  .bottomnav {
    display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
    background: var(--navy); border-top: 1px solid var(--line);
    padding-bottom: env(safe-area-inset-bottom);
  }
  .bottomnav button {
    /* `var(--bottomnav-h)`, not 52px. The bar's height IS its buttons' height, so this is the one
     * place that number is decided and the reservation reads the same token. */
    flex: 1 1 0; min-width: 0; min-height: var(--bottomnav-h); padding: var(--s2) var(--s1);
    background: transparent; border: 0; color: var(--ink3); font: inherit;
    font-size: var(--t-micro); font-weight: 500; line-height: 1.2; cursor: pointer;
    /* `gap` because More can carry a count pill beside its label (see index.html's note on why
     * Notices is behind More rather than on the bar). Without it the badge touches the word. */
    display: flex; align-items: center; justify-content: center; gap: var(--s1); text-align: center;
    border-top: 2px solid transparent; margin-top: -1px;
  }
  .bottomnav button.on { color: var(--ink); border-top-color: var(--cy); background: var(--accent-wash) }
  .bottomnav button:focus-visible { box-shadow: inset var(--focus-ring) }

  /* The sheet "More" opens. Anchored to the bottom, above the bar, so the thumb that opened it is
     already where the choices appear. A full-height menu would put the first choice furthest from
     the hand that asked for it.

     ⚠️ ITS BOTTOM PADDING CLEARS THE HOME INDICATOR AND *NOT* THE BAR — corrected after looking at
     the rendered sheet on a 390px phone. The first version reserved the bar's own 52px as well,
     on the assumption the sheet sat beside it; the sheet sits OVER it (z-index 41 against 40), so
     that 52px was simply dead space under the last choice. The scrim covers the bar, so a tap
     where the bar was still closes the sheet — three ways out, unchanged. */
  .moresheet { display: block; position: fixed; inset: 0; z-index: 41; visibility: hidden }
  .moresheet.on { visibility: visible }
  .moresheet .scrim { position: absolute; inset: 0; background: var(--scrim) }
  .moresheet .sheet {
    position: absolute; left: 0; right: 0; bottom: 0; background: var(--card);
    border-radius: var(--r) var(--r) 0 0; box-shadow: var(--shadow-modal);
    padding: var(--s4) 0 calc(var(--s4) + env(safe-area-inset-bottom));
  }
  .moresheet .navitem { min-height: 48px }

  /* The primary action, DOCKED directly above the navigation and spanning the full width. z-index
     39 puts it UNDER the More sheet and its scrim (41) and under the bar (40), so opening More
     covers it rather than leaving a gold button stranded over a dimmed screen — unchanged from the
     floating version, and the only thing about it that is.

     ⚠️ `bottom` IS THE NAVIGATION'S HEIGHT PLUS THE SAFE AREA, WITH NO GAP TERM. The two bars are a
     stack, not two floating things at chosen offsets: this one's bottom edge is exactly the other
     one's top edge, so the pair is one block of furniture whose total height is the sum of two
     tokens and nothing else. A gap here would be a third number that the reservation below would
     have to know about, which is the shape this change exists to remove.

     ⚠️ `min-height`, NOT `height`. A fixed height would clip the label at a large text-zoom setting;
     a minimum grows instead. What stops it growing SILENTLY past its token is not this rule — it is
     `scripts/mobileOverlap.ts`, which compares the reservation against the bars' measured heights on
     every screen and fails if they disagree. A promise enforced by a test, not by a comment. */
  .actionbar {
    display: flex; align-items: center; position: fixed; z-index: 39; left: 0; right: 0;
    bottom: calc(var(--bottomnav-h) + env(safe-area-inset-bottom));
    min-height: var(--actionbar-h); box-sizing: border-box;
    padding: var(--s2) var(--s4);
    background: var(--navy); border-top: 1px solid var(--line);
  }
  /* 🔒 THE LABEL STAYS — Randy, OPS-69B: *"Keep the label. '+ New booking' is what she is looking
     for. It does not become an icon."* `nowrap` is what keeps it one line, which is what keeps the
     bar at its token height. */
  .actionbar .btn { width: 100%; justify-content: center; white-space: nowrap }

  /* 🔴 THE RESERVATION IS THE FURNITURE, TO THE PIXEL. Two tokens and one runtime inset, the same
     three terms the two bars above are positioned from — so the room the page keeps free and the
     room the bars occupy are not two numbers that happen to agree, they are one expression written
     twice. `env(safe-area-inset-bottom)` appears exactly once here and exactly once in the bottom
     bar's own padding; adding it to the action bar's offset too would count a notched phone's home
     indicator twice.

     ⚠️ WHAT THIS REPLACED, AND WHY A LARGER NUMBER WAS NOT A SAFER ONE. It was
     `var(--s8) + 52px + 44px + var(--s4)` = 152px against 96px of furniture. The 56px of slack did
     not buy safety — the floating button still covered four screens — it bought dead space at the
     foot of every screen on a device that has the least room to spare. Over-reserving is how a
     reservation stops being checkable: nobody compares 152 against 96 and concludes anything. */
  .hasbottomnav .main {
    padding-bottom: calc(var(--actionbar-h) + var(--bottomnav-h) + env(safe-area-inset-bottom));
  }
}

/* ---- One page frame, every screen (docs/60 §9) ------------------------------------------------
 * What this is, one line on what it is for, actions in the same place. A person who learns where
 * the button is on one screen has learned it for all of them. Deliberately ONE rule for both
 * surfaces: a second copy of one frame is how two frames start to differ, which is what this whole
 * file exists to stop.
 *
 * The hairline under it is one of only two that survive the pass: a page frame has nothing below it
 * to lift against, so elevation genuinely cannot do this job. */
.pagehd {
  display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s5);
  margin-bottom: var(--s6); padding-bottom: var(--s4); border-bottom: 1px solid var(--line);
}
.pagehd h1 { margin-bottom: var(--s2) }
.pagehd .lede { margin-bottom: 0; font-size: var(--t-small) }
.pagehd .row { flex: 0 0 auto }

/* ================================================================================================
   THE "i" — WHERE PERMANENT EXPLANATION LIVES NOW.
   ================================================================================================
   🔴 THE ROOT CAUSE OF "THE CARDS ARE HUGE" WAS NEVER PADDING. Measured on the agent home screen at
   1440 before this pass: 34% of every visible character in the content column was permanent policy
   prose, and the three worklist cards spent about 480 vertical pixels between them to communicate
   "0, 0, 0". No padding token touches that. The words are correct and they were expensive to write,
   so they are not deleted — they are RELOCATED. An agent reads them once.

   The affordance is a 16px circled "i" sitting on the heading it explains. It is a real <button>
   with an aria-label and aria-expanded, its body is a sibling carrying `hidden`, and ONE delegated
   handler in /shared/disclose.js wires every one of them (docs/60 §6: one list, one function, one
   delegated handler).

   ⚠️ THIS IS THE FIFTH ICON IN A PLATFORM WHOSE ENTIRE INVENTORY WAS FOUR MARKS, and docs/60 §16.7
   test is "if you cannot say why an icon earns its place, remove it". It earns it because no word
   fits: a heading followed by the word "About" reads as a second heading, and a <details> summary
   cannot sit ON a heading row without taking a line of its own — which is the height this exists to
   reclaim. It is paired with a real label for anyone who cannot see it.

   ⚠️ AND IT IS NOT HOVER-ONLY (docs/60 §16.9). It toggles on click, stays open until clicked again,
   and is keyboard-reachable in the ordinary tab order. */
.infodot {
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
  letter-spacing: 0;
  width: 16px; height: 16px; margin-left: var(--s2); vertical-align: -2px;
  border-radius: var(--r-pill); background: var(--navy3); color: var(--ink3);
  font-size: 10px; font-weight: 700; font-style: italic; line-height: 1; cursor: pointer;
  transition: background .12s linear, color .12s linear;
}
.infodot:hover, .infodot[aria-expanded="true"] { background: var(--accent-wash); color: var(--cy) }
/* 44px of HIT AREA without 44px of visual size (docs/60 §16.9) — the mark stays 16px and the
   touchable box grows around it. */
@media (max-width: 767px) {
  .infodot { position: relative }
  .infodot::after { content: ""; position: absolute; inset: -14px }
}
/* The disclosed text. Deliberately NOT a card: it is an annotation on the thing above it, and a
   second lifted surface would make the explanation look more important than the data. */
.infobody {
  font-size: var(--t-small); color: var(--ink3); line-height: 1.5;
  margin: var(--s2) 0 var(--s3); padding-left: var(--s3);
  border-left: 2px solid var(--line);
}
.infobody:last-child { margin-bottom: 0 }

/* ================================================================================================
   A NOTICE ROW — shared by both surfaces (migration 190).
   ================================================================================================
   ⚠️ HERE AND NOT IN EITHER APP STYLESHEET, because both surfaces render the identical row from the
   identical payload shape. This file's own header records what the first copy of `.navitem` cost on
   exactly this pair of files.

   ⚠️ NOT A TABLE, AND NOT A CARD PER ROW. A table's value is a column you scan down, and these rows
   share only a date — the headline is a sentence of a different length every time, which in a cell
   would either wrap or set the column width. A card each would put ten lifted surfaces on one
   screen, which is what "bulky" means (§16.4). So: one card, hairlines between rows, which is
   §16.8's table rule applied to something that is not a table. */
.noticerow {
  display: flex; align-items: flex-start; gap: var(--s4);
  padding: var(--cell-y) 0; border-bottom: 1px solid var(--line);
}
.noticerow:last-child { border-bottom: 0 }
.noticerow:first-child { padding-top: 0 }
/* min-width:0 so a long headline wraps inside the row instead of widening it — the same reason
 * every shell child carries it (see this file's grid note). */
.noticebody { flex: 1 1 auto; min-width: 0 }
/* The pills and the age, on one line above the headline. `flex-wrap` because at 390px three chips
 * and a date do not fit a line, and the alternative is a row that scrolls sideways. */
.noticehead {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--s2); margin-bottom: var(--s1);
  font-size: var(--t-small);
}
/* The headline is the sentence, and it carries the weight step rather than a size step — §16.1's
 * own rule that weight is half the hierarchy. */
.noticetext { font-size: var(--t-body); font-weight: 500; color: var(--ink); line-height: 1.4 }
/* 🔴 UNSEEN, AND IT IS A 2px RULE RATHER THAN A GROUND. The accent-tinted ground belongs to the
 * active nav item (§16.5.1 spends `--cy` at full strength in exactly two places), and a washed row
 * here would compete with it across the screen. A rule on the leading edge is the smallest mark
 * that survives being one of ten. */
.noticerow.isnew { border-left: 2px solid var(--cy); padding-left: var(--s3) }
.noticerow .actions { flex: 0 0 auto }
@media (max-width: 767px) {
  /* One action per row and a thumb at the bottom of the phone: stack, and let the button take the
   * width rather than sitting in a 44px column beside a wrapped sentence. */
  .noticerow { flex-direction: column; gap: var(--s2) }
  .noticerow .actions { width: 100% }
  .noticerow .actions .btn { width: 100%; justify-content: center }
}

/* ---- Empty state (docs/60 §12: say what is absent, never what is broken) ----------------------
 * The dashed box is gone. An empty state is a sentence, not a container: a dashed rectangle around
 * "there is nothing here" is chrome describing an absence. */
.emptystate { padding: var(--s7) 0; color: var(--ink2); max-width: 52ch }
.emptystate h3 { margin: 0 0 var(--s2); color: var(--ink); font-size: var(--t-h2); font-weight: 600 }
.emptystate p { font-size: var(--t-small) }

/* ---- Gate-in evidence: one vehicle's photographs, on the console and in the portal -------------
 * docs/26 item 1623. The gate tablet has carried `.shots` / `.shot` in its own sheet since it was
 * built, and this is DELIBERATELY NOT a move of those rules: the tablet is a proven surface used in
 * sunlight at a terminal, and a second surface's needs are not a reason to edit it (rule 31). What
 * these rules ARE is one definition for the TWO new surfaces, so the console and the portal cannot
 * fork the way `public/shared/tokens.css`'s own header records two copies of a token block forking.
 *
 * ⚠️ `.evshot` IS A BUTTON, not a tile with a click handler. A photograph on a claim is something a
 * person opens, so it has to be reachable from a keyboard and announce itself to a screen reader —
 * and a `<div role="button">` is how that gets half-done. */
.evgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--s2) }
/* 🔴 `width: 100%` IS LOAD-BEARING AND ITS ABSENCE WAS A 3-PIXEL PHOTOGRAPH. A `<button>` is
 * shrink-to-fit whatever `display` it is given, so without this the tile sized itself to the
 * INTRINSIC width of the image inside it — measured in a real Chromium at both 390px and 1280px:
 * the grid column was 159px and 143px respectively and the tile inside it was **3px × 2px**, with
 * the image decoded, the border correct and every assertion in the walk passing. That is the
 * unscannable-QR failure (rule 42) and the invisible `.warn` (src/stateClassesAreStyled.test.ts)
 * a third time: correct markup, green tests, nothing a person could see. */
.evshot {
  position: relative; width: 100%; aspect-ratio: 4 / 3; padding: 0; border: 1px solid var(--line);
  border-radius: var(--r-sm); overflow: hidden; background: var(--navy); cursor: zoom-in; display: block;
}
.evshot img { width: 100%; height: 100%; object-fit: cover; display: block }
.evshot:focus-visible { outline: none; box-shadow: var(--focus-ring); border-color: var(--cy2) }
/* 🔴 A DAMAGE PHOTOGRAPH IS NOT A CONDITION PHOTOGRAPH, AND THE SCREEN MUST SAY SO WITHOUT BEING
 * READ. docs/86 §2.4: the damage report is the bill of lading's "unless otherwise stated", so the
 * two kinds carry opposite meanings for a claim — one is evidence of apparent good order and the
 * other is evidence against it. The caption beneath each tile states it in words as well; the
 * border is what makes a grid of twelve scannable. */
.evshot.evdamage { border-color: var(--gold); border-width: 2px }
.evcap { font-size: var(--t-micro); color: var(--ink3); margin-top: var(--s1); line-height: 1.35 }
/* A tile whose image did not arrive says so in words — never the browser's torn-page glyph, which
 * reads identically to "the photograph is bad" (the gate's own item 1112). */
.evshot.evbad {
  cursor: default; display: flex; align-items: center; justify-content: center; text-align: center;
  padding: var(--s2); font-size: var(--t-micro); color: var(--ink3); background: var(--navy2);
  border-color: var(--line2);
}

/* 🔴 NO SPINNER UNDER 300ms — a flash of loading is worse than a brief pause, and every one of the
 * ~20 loading blocks in the two surfaces showed one the instant a request left. The whole block
 * fades in after 300ms and reserves its own height, so the layout does not jump when it arrives or
 * when it is replaced by the result. */
.loading {
  min-height: 72px; padding: var(--s5) 0; color: var(--ink3); font-size: var(--t-small);
  opacity: 0; animation: appear 1ms linear 300ms forwards;
}
@keyframes appear { to { opacity: 1 } }
.spin {
  display: inline-block; width: var(--s3); height: var(--s3); border-radius: 50%;
  border: 2px solid var(--line2); border-top-color: var(--cy); animation: spin .7s linear infinite;
  vertical-align: middle; margin-right: var(--s2);
}
@keyframes spin { to { transform: rotate(360deg) } }

.secretbox {
  background: var(--navy); border: 1px solid var(--line); border-radius: var(--r); padding: var(--s3);
  font-family: var(--font-mono); font-size: var(--t-body); letter-spacing: .06em;
  word-break: break-all; color: var(--cy); margin-bottom: var(--s2);
}
.urlbox {
  background: var(--navy); border: 1px solid var(--line); border-radius: var(--r); padding: var(--s2) var(--s3);
  font-family: var(--font-mono); font-size: var(--t-small); color: var(--ink3);
  word-break: break-all; margin-bottom: var(--s2);
}
/* The QR code's quiet zone is the one light ground in the platform, and it is a requirement of the
 * format rather than a style choice — a scanner needs the border (CLAUDE.md rule 42). It was an
 * inline `background:#fff` in `public/admin/app.js`, which is the one thing this file exists to
 * stop; it is a named token now. */
.qrpaper {
  display: flex; justify-content: center; padding: var(--s4);
  background: var(--paper); border-radius: var(--r); margin-top: var(--s3);
}
/* 🔴 THE QR IS CAPPED TO ITS CONTAINER, AND THAT IS NOT A COSMETIC RULE — IT WAS MEASURED.
 *
 * A QR's pixel size is a function of HOW MUCH DATA IT ENCODES: `db/totpQr.mjs` renders at a fixed
 * 5px cell, and the encoder picks the smallest version that fits, so every version step adds 4
 * modules — 20px — to the side. The data is an `otpauth://` URI containing the account's EMAIL
 * ADDRESS, so the QR grows with the length of a staff member's address and nothing in the product
 * bounds that.
 *
 * MEASURED at 390px, on the real markup with the real stylesheet:
 *   ops@harkham.net                                   (15 ch)  ->  285px QR in a 318px box   ✅
 *   operations.lead.nassau@harkhamtrading.example.com (49 ch)  ->  325px QR in a 318px box   ❌ +7px
 * The next version step lands at 345px and would breach the CARD, not just its padding.
 *
 * ⚠️ THIS DOES NOT CONTRADICT `db/totpQr.mjs`'s OWN "FIXED SIZE, NOT SCALABLE" RULE — it is the
 * other half of it. That rule exists because a first version passed `scalable: true`, which omits
 * the width/height attributes entirely and left the browser stretching the code to fill the window,
 * UNBOUNDED and getting worse with zoom. This is a CEILING on a code that still declares its own
 * fixed size: it never grows, and it shrinks only when it would otherwise overflow the paper it
 * sits on. Scaling a vector QR down is safe — it stays square and the modules stay aligned.
 *
 * ⚠️ AND `height: auto` IS LOAD-BEARING, NOT DECORATION. Without it, `max-width` alone squashes the
 * code horizontally while the height attribute holds — a non-square QR does not scan at all, which
 * would be strictly worse than the overflow it was fixing. `createSvgTag({ scalable: false })`
 * emits width, height AND a viewBox, so the aspect ratio is there to be preserved. */
.qrpaper svg { max-width: 100%; height: auto }

.stepnote { text-align: center; font-size: var(--t-small); color: var(--ink3); margin-top: var(--s4) }
.stepnote a { color: var(--cy); cursor: pointer; text-decoration: underline }

/* The agent booking wizard's step breadcrumb — a reachable step is a link back to it, so the flow
 * is not a one-way corridor (run C part 3.2). Existing tokens only: no new size, no new radius,
 * and the same `--cy` link colour and underline the `.stepnote` links above already use, so a
 * clickable step looks like every other clickable word in both surfaces.
 *
 * ⚠️ `flex-wrap` AND A GAP, NOT A NOWRAP ROW. Four steps plus separators do not fit on one line at
 * 390px, and a breadcrumb that overflows horizontally would put a scrollbar on the modal header —
 * the standard for this run is zero horizontal page scroll. */
.stepcrumbs { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s2) }
.stepcrumbs a { color: var(--cy); cursor: pointer; text-decoration: underline }

/* ================================================================================================
   THE NARROW CASE, FOR BOTH SURFACES.
   ================================================================================================
   docs/60's surface-target table makes the agent portal phone-first and the admin console
   desktop-first, and that is right about DENSITY. It is not a reason for the console to be
   unusable on a phone: Randy uses it on a phone at a yard, and until now this block existed only
   in the agent portal, so the console kept 26px page padding and 7px buttons at 390px.

   🔴 44px OF HIT AREA, NOT 44px OF VISUAL SIZE. The button stays the size the type scale says; what
   grows is what a thumb can land on. `min-height` plus generous padding is how density and touch
   coexist — measured before this pass, TWENTY-TWO controls on the console's front screen at 390px
   were under 44px, including every navigation item at 31px. */
@media (max-width: 767px) {
  .main { padding: var(--s4) var(--s4) max(var(--s8), env(safe-area-inset-bottom)) }
  /* ════════════════════════════════════════════════════════════════════════════════════════════
     🔴 ONE ROW ON A PHONE — docs/26 item 1171, and the 19% it replaces was pure chrome.
     ════════════════════════════════════════════════════════════════════════════════════════════
     `flex-wrap: wrap` put the brand on one line and the account-plus-sign-out on a second, and with
     the 44px touch floor beneath every `.btn.sm` that is **162px on the agent portal and 142px on
     the console, at 390x844, on EVERY screen** — 19% and 17% of the viewport spent before a single
     word of content. It is the cheapest vertical pixel in the product to remove, which is exactly
     what the note above `.topbar` says about the 60px-to-48px cut that came before it.

     🔒 NOTHING IS REMOVED FROM THE HEADER. The brand, the account and Sign out are all still there
     and Sign out keeps its full 44px hit area. Three things change and each is chrome rather than
     function: the row stops wrapping, the brand SUB-label ("Agent portal" / "Console") goes — the
     mark and the name already say which app this is — and a long account name truncates instead of
     forcing a second line. Measured after: 60px, 7.1%.

     ⚠️ `min-width: 0` ON THE IDENTITY, WHICH IS THE PART THAT ACTUALLY MAKES IT WORK. A flex item's
     default `min-width: auto` refuses to shrink below its content, so `text-overflow: ellipsis` on
     `.who` never fires and the row overflows sideways instead — the same trap `tokens.css:893`
     already records for `.side, .shell > .main`. */
  .topbar { flex-wrap: nowrap; gap: var(--s2) var(--s3); padding: var(--s2) var(--s4) }
  .topbar .brandsub { display: none }
  /* ════════════════════════════════════════════════════════════════════════════════════════════
     🔴 `nowrap` ON THE BAR WAS NEVER THE RULE THAT COULD STOP THE SECOND ROW — OPS-69B phase B2.
     ════════════════════════════════════════════════════════════════════════════════════════════
     The block above claims "Measured after: 60px, 7.1%." It was **91px**, measured at 390x844 on
     16 September 2026 (`scripts/mobileOverlap.ts`), and had been since the block was written. The
     bar's own `flex-wrap: nowrap` was doing its job perfectly: `.topbar` has exactly two children,
     both flex items, both on ONE flex line — their different top edges are `align-items: center`
     centring a 24px brand block inside a 74px sibling, not two lines.

     🔴 THE SECOND ROW WAS INSIDE THE LAST CHILD. It is a `.row`, and `.row` (tokens.css:431) sets
     `flex-wrap: wrap`. Nothing overrode it here, so at 390px its three children landed on THREE top
     edges: `.who` took the whole 255px line and `Sign out` wrapped beneath it.

     ⚠️ AND `min-width: 0` ON `.who` COULD NOT PREVENT THAT, which is the part worth carrying away.
     `min-width: 0` lets a flex item SHRINK; it does not affect LINE BREAKING. A wrapping flex
     container assigns items to lines by their hypothetical main size — content width, before any
     shrinking — and only then distributes shrinkage WITHIN each line. `.who`'s text is far wider
     than 255px, so it filled line one by itself and pushed its sibling off; the ellipsis then
     applied to an item that was already alone on its line. The block above reasoned carefully about
     `min-width: 0` and was reasoning about the wrong container. One `nowrap` on the inner row is the
     whole fix: 91px → 60px, on every screen of the portal, which is what the note above thought it
     had already bought. */
  .topbar > .row { flex-wrap: nowrap }
  .topbar > .row:last-child { min-width: 0; flex: 0 1 auto }
  .topbar .who {
    min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    font-size: var(--t-micro);
  }
  /* ⚠️ AND THE CONTROLS MUST NOT WRAP INTERNALLY, WHICH IS WHERE THE FIRST VERSION OF THIS BLOCK
     WENT WRONG AND THE HARNESS SAID SO. Stopping the ROW from wrapping squeezes every item in it,
     and a squeezed button wraps its own LABEL onto a second line — so the console's header went
     142px to 181px while the agent portal's went 162px to 91px. One surface improved and the other
     regressed, measured, not guessed. A control in a header is one line or it is not in the header. */
  .topbar .btn, .topbar .pill { white-space: nowrap; flex: 0 0 auto }
  /* 🔴 THE BRAND DOES NOT SHRINK; THE ACCOUNT NAME DOES. `flex: 0 1 auto` let the flex algorithm
     divide the squeeze between the two children in proportion to their content, so at 390px
     "HARKHAM TRADING" was cut to 59px — about "HARKHAM T…" — while a fixture account name kept
     166px. That is the wrong way round, and the block above already says which way round it should
     be: *"a long account name truncates instead of forcing a second line."* The two fixed strings
     in this bar (the product's name, and the word on the sign-out button) are whole or they are
     wrong; the one variable string is the one that can give way, and it already carries the
     ellipsis to do it with. */
  .topbar .brandrow { flex: 0 0 auto; min-width: 0 }
  /* ════════════════════════════════════════════════════════════════════════════════════════════
     🔴 THE BRAND'S OWN WRAPPER NEEDS `min-width: 0` TOO — and this is Randy's third sighting,
     the one the old instrument answered `overlap=false` about.
     ════════════════════════════════════════════════════════════════════════════════════════════
     `scripts/mobileOverlap.ts`'s header records the sighting in his words — *"Sign out overlaps the
     Harkham logo"* — and the check written for it compared `.topbar .brandrow` against the Sign out
     button: two boxes in the same flex line, which never intersect. It reported false. Found
     instead by LOOKING at a 390px capture (rule 42's lesson, again), and then confirmed in the
     numbers that had been printed all along: `.brandrow` measures **91px** and the `<div>` inside it
     that holds the brand name measures **165px**. A child 74px wider than its parent is drawing
     over whatever sits to its right, which is the account name.
     ⚠️ `.brandname` ALREADY CARRIES `text-overflow: ellipsis`, AND IT COULD NEVER FIRE. The
     ellipsis belongs to `.brandname`; the element that has to SHRINK is its unclassed wrapper — the
     actual flex item — whose default `min-width: auto` refuses to go below its content. So the
     wrapper stayed at 165px, `.brandname` was never narrower than its own text, and the one
     declaration written to prevent this was inert. The stylesheet records this exact trap twice
     already (`.who`, and `.side, .shell > .main` at tokens.css:893); it is recorded a third time
     because the fix was applied to the element with the ellipsis rather than to the element that
     had to give way, which is the mistake that will be made again. */
  .topbar .brandrow > div { min-width: 0 }
  .topbar .brandname { white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
  .pagehd { flex-direction: column; align-items: stretch; gap: var(--s3) }
  .pagehd .row { flex: 1 1 auto }
  .pagehd .row .btn { width: 100%; justify-content: center }
  .btn { padding: var(--s3) var(--s5); min-height: 44px }
  /* An input is a touch target too. 16px type in 8px of padding is 40px — close enough to look
   * right and four pixels short of the floor, which is exactly how this gets missed. */
  .inp { min-height: 44px }
  .btn.sm { padding: var(--s2) var(--s3); min-height: 44px; font-size: var(--t-small) }
  .seg { display: flex; overflow-x: auto; width: 100% }
  .seg button { min-height: 44px }
  .mdlbg { padding: var(--s4) var(--s3) max(var(--s5), env(safe-area-inset-bottom)) }
  .mdlhd { padding: var(--s4) var(--s4) var(--s3) }
  .mdlbd { padding: var(--s4) }
  /* Nothing here is hover-only: every affordance that a pointer reveals is already visible. */
  .navitem, .mdlx { min-height: 44px }
  /* A breadcrumb step is a control, so it is a touch target. `inline-flex` + `min-height` gives it
   * the floor without changing the type size, exactly as the buttons above do — a line of 12px
   * words at 390px is otherwise about 16px tall and four of them sit a thumb-width apart. */
  .stepcrumbs a, .stepcrumbs span { display: inline-flex; align-items: center; min-height: 44px; padding: 0 var(--s1) }
}
