/* base.css — the typefaces and geometry every page needs.
 *
 * WHY THIS FILE EXISTS
 * ====================
 *
 * Every page used to carry its own <link> to fonts.googleapis.com, and those
 * links had drifted into EIGHT different variants: 31 pages asked for
 * Fraunces 400;500;600, 27 for 500;600, six for no mono at all, one for
 * Inter 700, one for Inter alone.  Whatever a page happened to need the day
 * it was written.  One sheet ends that whole class of "this heading is 500
 * here and 600 there".
 *
 * Self-hosting also bought three things:
 *
 *   1. The CSP lost `https://fonts.googleapis.com` from style-src and
 *      `https://fonts.gstatic.com` from font-src.  Both are gone now.
 *   2. Every page dropped a render-blocking cross-origin DNS + TLS + CSS
 *      round-trip before the font request could even begin.
 *   3. Three families across eight static weights became three variable
 *      files totalling ~116KB, served immutable for a year.
 *
 * VERSIONING — READ BEFORE SWAPPING A FONT
 * ========================================
 *
 * tools/cachebust.ps1 stamps ?v= on .js and .css ONLY (its regex, line 19).
 * A .woff2 gets no query, and daedalus/api/app.py serves font extensions
 * `max-age=31536000, immutable`.  So a replacement font would never reach
 * anyone holding the old one.  The version therefore lives in the FILENAME
 * (`-v1`); bump it to `-v2` and update the src below.  Do not rely on the
 * cachebuster for these.
 *
 * LICENCES
 * ========
 *
 * Switzer — ITF Free Font License v2.0.  Free for commercial use, and
 * self-hosting is explicitly permitted.  THE FILES MUST NOT BE MODIFIED:
 * §02 forbids subsetting, format conversion and metadata edits, so no build
 * step may trim glyphs off these.  See vendor/switzer/NOTICE.md for the two
 * other constraints (never offer it as a user-selectable font; keep it out
 * of generated PDFs).
 *
 * JetBrains Mono — SIL Open Font License 1.1, full text kept beside the file
 * at vendor/jetbrains-mono/OFL.txt as the licence requires.  Latin subset
 * only: that split is Google's own per-subset build, not something we cut.
 */

/* ── Switzer — sans and display ──────────────────────────────────────
   One variable file covers 100..900, so a heading at 600 and body at 400
   cost the same single download.  Replaces BOTH Inter (body) and Fraunces
   (display).  The italic is a genuine upgrade rather than parity: the old
   Google request carried no italic axis at all, so every `font-style:
   italic` in the product was a browser-synthesised oblique. */
@font-face {
  font-family: 'Switzer';
  src: url('/ui/assets/vendor/switzer/switzer-variable-v1.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Switzer';
  src: url('/ui/assets/vendor/switzer/switzer-variable-italic-v1.woff2') format('woff2');
  font-weight: 100 900;
  font-style: italic;
  font-display: swap;
}

/* ── JetBrains Mono — VINs, RO numbers, money ────────────────────────
   Kept deliberately.  Switzer has no monospace companion, and these columns
   need real fixed advance widths plus unambiguous 0/O and 1/l/I — someone
   reads a VIN off this screen and types it somewhere else.  Pair it with
   `font-variant-numeric: tabular-nums` (see .d-mono / .s-mono) so figures
   stay column-aligned. */
@font-face {
  font-family: 'JetBrains Mono';
  src: url('/ui/assets/vendor/jetbrains-mono/jetbrains-mono-variable-latin-v1.woff2') format('woff2');
  font-weight: 100 800;
  font-style: normal;
  font-display: swap;
}

/* ── Geometry ────────────────────────────────────────────────────────
 * Corner radius, as ONE global scale rather than a per-bundle one.
 *
 * Colour is themed and bundle-specific, so --s-* and --d-* rightly
 * differ.  Radius is neither: a card is the same shape in light mode, in
 * dark mode, in the master console and in the customer portal.  Splitting
 * it per bundle is what left 46 files -- the whole master console, the
 * portal, the auth and legal pages -- with no radius token available to
 * them at all, and therefore 610 hardcoded literals.
 *
 * Tightened from 6/10/14/20 to 2/4/6/8 (2026-08-23).  Cards land at 8px,
 * buttons and nav at 4px, small chrome at 2px.  Chosen over a harder
 * 0/2/3/4 because native controls -- selects, date inputs, the colour
 * picker -- keep their own rounded corners and cannot be restyled, so
 * squaring everything else to zero makes those the odd ones out.
 *
 * NOT everything round is a mistake.  Avatars and status dots use 50%,
 * progress tracks and the scrollbar thumb stay fully round, and the
 * assistant launcher is a deliberate orb.  Those are excluded on purpose;
 * do not "finish the job" by squaring them.
 */
:root {
  --radius-sm: 2px;   /* chips, tags, small chrome */
  --radius:    4px;   /* buttons, inputs, nav items */
  --radius-md: 6px;   /* panels, inset surfaces, tiles */
  --radius-lg: 8px;   /* cards, modals, the outermost containers */
}

/* ── Type tokens ─────────────────────────────────────────────────────
 * Defined HERE, not in app.css/shop.css, for the same reason as the
 * geometry above: the legal pages, the portal and the master console load
 * neither bundle, so a token defined only in one of them is invisible to a
 * third of the product.
 *
 * --font-display is the same family as --font-sans.  It stays a separate
 * token because 35+ call sites reference it and hierarchy is one edit away
 * from diverging again if a display face is ever reintroduced.
 */
:root {
  --font-sans:    'Switzer', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-display: 'Switzer', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-mono:    'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
}

/* ── Chrome tokens for INJECTED UI ───────────────────────────────────
 * For components that are injected onto pages of BOTH bundles and
 * therefore cannot name `--s-*` or `--d-*`.  The assistant panel is the
 * case that forced this (2026-08-23).
 *
 * It is styled with five `--d-*` tokens plus 37 hardcoded colours -- 22
 * `rgba(255,255,255,...)` overlays across nine alphas and 15
 * `rgba(201,169,110,...)` accents across eight -- and `shop.js` injects it onto shop
 * pages where NO `--d-*` token is defined.  Every one silently fell back
 * to its dark literal, so the panel rendered dark on a light shop page
 * and light on the console -- the same component, opposite appearance,
 * depending which page you opened it from.  The hardcoded whites never
 * themed at all: on the light rendering every border and inset fill went
 * white-on-white, which is why bordered controls read as bare text.
 *
 * The values below are only a FLOOR.  Each bundle remaps them onto its
 * own palette (see the `--ui-*` block in shop.css / app.css), so injected
 * chrome inherits whichever product is hosting it instead of carrying a
 * third colour scheme.  A page that loads neither bundle -- the legal
 * pages, the portal -- still gets something legible from here.
 *
 * Light in `:root`, dark in a `prefers-color-scheme` media query -- see
 * the note on that block for why it is not an attribute selector.  The
 * two bundles disagree on which way round their own themes go (`app.css`
 * keeps dark in `:root`, `shop.css` keeps light there); because each
 * remaps these onto its own palette, that disagreement does not leak.
 */
:root {
  --ui-surface:     #ffffff;
  --ui-raised:      #f4f2ec;
  --ui-text:        #1a1a1a;
  --ui-text-mute:   rgba(0, 0, 0, 0.55);
  --ui-line:        rgba(0, 0, 0, 0.14);
  --ui-line-soft:   rgba(0, 0, 0, 0.07);
  --ui-accent:      #8a6a34;
  --ui-accent-soft: rgba(138, 106, 52, 0.14);

  /* Ink tokens for text ON or AS the accent. Defined HERE as well as in
     shop.css because four pages that use them -- getting-started,
     portal-join, reset-2fa, reset-password -- link only this bundle. With
     the definition missing they fell back to a literal, which works but
     makes the NAME fiction: defining it later would silently restyle every
     one of those call sites. tests/test_css_custom_properties_resolve.py
     catches exactly that, and caught this.
       --s-accent-ink   text sitting ON the accent fill
       --s-accent-text  the accent used AS text on a light ground */
  --s-accent-ink:   #0E1322;
  --s-accent-text:  #866637;

  /* RGB triplets, so a caller can keep its own alpha and still theme:
     `rgba(var(--ui-ovl), .14)`.  The assistant varies overlay alpha across
     nine values and accent alpha across eight; tokenising each one would
     have flattened deliberate design steps, and hardcoding the colour is
     what broke them in light theme in the first place. */
  --ui-ovl:        0, 0, 0;
  --ui-accent-rgb: 138, 106, 52;

  /* One easing for chrome that moves. Same curve as the hub bundle's own
     easing token, so a control that exists in both renderers decelerates
     identically in each. Declared rather than referenced-with-a-fallback:
     an undefined name plus a fallback looks right and quietly restyles the
     day someone defines that name for another purpose. */
  --ui-ease:       cubic-bezier(0.4, 0, 0.2, 1);

  /* THE RAIL'S OWN GROUND AND TEXT.
     Not --ui-surface / --ui-text, and the reason is specific: --ui-surface
     is the token for INJECTED CHROME, and app.css pins it to #111A3C on
     purpose so the assistant panel does not read brown on the cobalt
     dashboard -- while letting --ui-text follow the theme. One pinned, one
     following: in light mode that pair is #1f1c18 on #111A3C, a contrast
     ratio of 1.0, and the hub's navigation vanished.
     A component that paints a ground AND text on it needs a PAIR that moves
     together. These two are remapped by BOTH bundles (see the --ui-* block
     in shop.css and app.css); the values here are only the floor for pages
     that load neither. */
  --ui-rail-bg:     var(--ui-surface);
  --ui-rail-fg:     var(--ui-text);

  /* The avatar's second stop. Remapped by both bundles, like the pair above
     -- a token base.css defines and no bundle remaps carries base.css's
     palette onto a page painted from a different one. */
  --ui-iris:        #4a5fa8;
}

/* The dark floor is a MEDIA QUERY, not `html[data-theme="dark"]`, and that is
 * deliberate.  An attribute selector scores (0,1,1) and would outrank a
 * bundle's `:root` remap at (0,1,0) -- so base.css would win in dark mode and
 * the assistant would show this generic charcoal instead of the shop's navy.
 * Measured exactly that before switching: --ui-surface came back #1c1b14
 * rather than --s-bg-card's #161B2C.
 *
 * A media query adds no specificity, so `:root` here stays (0,1,0): a bundle
 * remap at the same score wins on source order (bundles load after base), and
 * a page that loads NO bundle still follows the OS. */
@media (prefers-color-scheme: dark) {
  :root {
    /* Cobalt, matching --s-bg-card. Was #1c1b14, a warm brown from the
       palette the product moved off: the assistant is part of the page
       surface now, not a card floating over it, and a brown panel on a
       cobalt page read as a foreign object. */
    --ui-surface:     #161B2C;
    --ui-raised:      #262418;
    --ui-text:        #ede8df;
    --ui-text-mute:   rgba(237, 232, 223, 0.58);
    --ui-line:        rgba(255, 255, 255, 0.14);
    --ui-line-soft:   rgba(255, 255, 255, 0.07);
    --ui-accent:      #c9a96e;
    --ui-accent-soft: rgba(201, 169, 110, 0.18);
    /* On a dark ground the accent is already 8.27:1 as text, so it stays
       itself; only the ink for text sitting ON the fill is dark. */
    --s-accent-ink:   #0E1322;
    --s-accent-text:  #c9a96e;
    --ui-ovl:        255, 255, 255;
    --ui-accent-rgb: 201, 169, 110;
    --ui-iris:        #8C9EFF;
  }
}

/* ── Chrome icons ────────────────────────────────────────────────────
 * Tabler outline glyphs delivered as CSS MASKS, not as the webfont.
 *
 * WHY NOT THE FONT.  vendor/tabler-icons ships all 5,937 glyphs as an
 * 820 KB woff2 plus a 248 KB stylesheet, and it is currently linked from
 * exactly three pages.  Chrome icons appear on ALL of them, so using
 * `ti ti-*` here would have meant loading 1.04 MB on 109 pages to draw
 * eight glyphs.  Strictly worse than what it replaces.
 *
 * WHY NOT AN SVG SPRITE.  Both bundles build their chrome from JS, so a
 * sprite has to be in the DOM before the chrome paints -- a loading order
 * to get wrong -- and `<use href="file.svg#id">` has a history of not
 * inheriting currentColor in Safari.  A mask has neither problem, needs no
 * new file or request (base.css is already on every page), and CSP is
 * satisfied because masks are governed by img-src, which allows `data:`.
 *
 * WHY THIS MATTERS BEYOND TIDINESS.  The chrome mixed media: the bell was
 * the emoji U+1F514 while the mail icon beside it was U+2709, a monochrome
 * text dingbat.  Two different classes of glyph, so they differ in weight,
 * colour and baseline on every platform -- which is precisely why they
 * looked like they belonged to different products.
 *
 * `background-color: currentColor` behind the mask is what makes these
 * inherit the host bundle's text colour, the same property the --ui-*
 * tokens above rely on.
 *
 * NOT everything pictographic is an icon.  U+2713 as a tick and U+2600 on
 * the theme toggle are punctuation and stay as characters; converting them
 * would be churn.  Source: Tabler 3.31.0, MIT, see vendor/tabler-icons/LICENSE.
 */
.ic {
  display: inline-block;
  width: 1em;
  height: 1em;
  flex: 0 0 auto;
  vertical-align: -0.125em;
  background-color: currentColor;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
}
.ic-mail{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10' /%3E %3Cpath d='M3 7l9 6l9 -6' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10' /%3E %3Cpath d='M3 7l9 6l9 -6' /%3E%3C/svg%3E")}
.ic-bell{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6' /%3E %3Cpath d='M9 17v1a3 3 0 0 0 6 0v-1' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6' /%3E %3Cpath d='M9 17v1a3 3 0 0 0 6 0v-1' /%3E%3C/svg%3E")}
.ic-sparkles{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2z' /%3E %3Cpath d='M16 6a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2z' /%3E %3Cpath d='M9 18a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6z' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2z' /%3E %3Cpath d='M16 6a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2z' /%3E %3Cpath d='M9 18a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6z' /%3E%3C/svg%3E")}
.ic-camera{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2' /%3E %3Cpath d='M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2' /%3E %3Cpath d='M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0' /%3E%3C/svg%3E")}
.ic-pencil{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4' /%3E %3Cpath d='M13.5 6.5l4 4' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4' /%3E %3Cpath d='M13.5 6.5l4 4' /%3E%3C/svg%3E")}
.ic-trash{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 7l16 0' /%3E %3Cpath d='M10 11l0 6' /%3E %3Cpath d='M14 11l0 6' /%3E %3Cpath d='M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12' /%3E %3Cpath d='M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 7l16 0' /%3E %3Cpath d='M10 11l0 6' /%3E %3Cpath d='M14 11l0 6' /%3E %3Cpath d='M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12' /%3E %3Cpath d='M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3' /%3E%3C/svg%3E")}
.ic-menu{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l16 0' /%3E %3Cpath d='M4 12l16 0' /%3E %3Cpath d='M4 18l16 0' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l16 0' /%3E %3Cpath d='M4 12l16 0' /%3E %3Cpath d='M4 18l16 0' /%3E%3C/svg%3E")}
.ic-plus{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5l0 14' /%3E %3Cpath d='M5 12l14 0' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5l0 14' /%3E %3Cpath d='M5 12l14 0' /%3E%3C/svg%3E")}
.ic-search{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0' /%3E %3Cpath d='M21 21l-6 -6' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0' /%3E %3Cpath d='M21 21l-6 -6' /%3E%3C/svg%3E")}
.ic-clock{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0' /%3E %3Cpath d='M12 7v5l3 3' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0' /%3E %3Cpath d='M12 7v5l3 3' /%3E%3C/svg%3E")}
.ic-book{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0' /%3E %3Cpath d='M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0' /%3E %3Cpath d='M3 6l0 13' /%3E %3Cpath d='M12 6l0 13' /%3E %3Cpath d='M21 6l0 13' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0' /%3E %3Cpath d='M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0' /%3E %3Cpath d='M3 6l0 13' /%3E %3Cpath d='M12 6l0 13' /%3E %3Cpath d='M21 6l0 13' /%3E%3C/svg%3E")}
.ic-lifebuoy{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0' /%3E %3Cpath d='M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0' /%3E %3Cpath d='M15 15l3.35 3.35' /%3E %3Cpath d='M9 15l-3.35 3.35' /%3E %3Cpath d='M5.65 5.65l3.35 3.35' /%3E %3Cpath d='M18.35 5.65l-3.35 3.35' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0' /%3E %3Cpath d='M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0' /%3E %3Cpath d='M15 15l3.35 3.35' /%3E %3Cpath d='M9 15l-3.35 3.35' /%3E %3Cpath d='M5.65 5.65l3.35 3.35' /%3E %3Cpath d='M18.35 5.65l-3.35 3.35' /%3E%3C/svg%3E")}
.ic-message{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 20l1.3 -3.9c-2.324 -3.437 -1.426 -7.872 2.1 -10.374c3.526 -2.501 8.59 -2.296 11.845 .48c3.255 2.777 3.695 7.266 1.029 10.501c-2.666 3.235 -7.615 4.215 -11.574 2.293l-4.7 1' /%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 20l1.3 -3.9c-2.324 -3.437 -1.426 -7.872 2.1 -10.374c3.526 -2.501 8.59 -2.296 11.845 .48c3.255 2.777 3.695 7.266 1.029 10.501c-2.666 3.235 -7.615 4.215 -11.574 2.293l-4.7 1' /%3E%3C/svg%3E")}

/* ── Film grain, as a reusable surface ───────────────────────────────
   The texture existed only inside `body.d-hub-page::before` in app.css --
   and app.css is loaded by the hub bundle, NOT by the shop pages, which
   load base.css + shop.css. So a shop surface could not reach it at all,
   and copying the data URI across would have left two definitions to drift
   (the defect this repo already fixed once for icons and once for the
   shift clock).

   base.css is loaded by BOTH bundles, so the primitive belongs here.

   `isolation: isolate` + `z-index: -1` is what lets the overlay sit ABOVE
   the host's own background but BELOW its content, without touching a
   single child. Without the isolation the negative z-index escapes to the
   nearest stacking context and paints underneath the card entirely --
   invisible, and confusingly so.

   `border-radius: inherit` keeps the grain inside rounded corners. */
.d-grain { position: relative; isolation: isolate; }
.d-grain::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0.55;
  background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.6  0 0 0 0 0.65  0 0 0 0 0.75  0 0 0 0.045 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>") repeat 0 0;
}

/* ── Omnibox controls: expand arrow + help ────────────────────────────
   Both live in base.css because BOTH bundles need them: operator pages load
   base.css + shop.css, the hub loads base.css + app.css. One copy, for the
   same reason icons, the shift clock and the film grain each ended up with
   one -- two drift.

   ARROW: bottom-right corner of the TEXT BOX. It was on the outer slot
   first, where it sat against the orb and read as part of the button
   cluster instead of as "make this box bigger". */
.s-omni-exp {
  position: absolute;
  right: 4px;
  bottom: 2px;
  z-index: 2;
  background: none;
  border: 0;
  padding: 0;
  line-height: 1;
  font-size: 0.68rem;
  cursor: pointer;
  opacity: 0.38;
  color: currentColor;
  transition: opacity .15s ease, transform .15s ease;
}
.s-omni-exp:hover { opacity: 0.9; }
.s-omni-exp:focus-visible { opacity: 0.9; outline: 1px solid currentColor; }
/* Expanded: the arrow flips to read as "collapse". */
/* Portrait <-> landscape, not grow/shrink: the arrow rotates a quarter
   turn to say "lay it on its side" rather than flipping to mean collapse. */
.s-omni-exp-on { opacity: 0.85; transform: rotate(90deg); }

/* The fat dropdown tab, centred on the box's bottom edge. This is what
   brings the conversation out in the first place -- the corner arrow only
   reshapes one that is already open. Shaped like a tab (square top, rounded
   bottom) because that is the form people already read as "there is more
   below this". */
.s-omni-tab {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: -10px;
  z-index: 3;
  /* Wider and taller than the first pass, which was too small to read as a
     handle at all. It is the control that brings the conversation out, so it
     has to look like something you pull. */
  width: 54px;
  height: 15px;
  padding: 0;
  border: 1px solid var(--s-line, rgba(0, 0, 0, 0.14));
  border-top: 0;
  border-radius: 0 0 8px 8px;
  /* A DIFFERENT shade from the box above it, not the same grey -- the whole
     job of a tab is to look like a separate thing attached to the edge. An
     accent tint at low alpha reads as "part of the assistant" without
     competing with the bubble beside it. */
  background:
    linear-gradient(rgba(var(--ui-accent-rgb, 201, 169, 110), 0.20),
                    rgba(var(--ui-accent-rgb, 201, 169, 110), 0.12)),
    var(--s-bg-card, #fff);
  color: currentColor;
  font-size: 0.66rem;
  line-height: 13px;
  cursor: pointer;
  opacity: 0.9;
  transition: opacity .15s ease, background .15s ease;
}
.s-omni-tab:hover { opacity: 1; }
.s-omni-tab:focus-visible { opacity: 1; outline: 1px solid currentColor; }
.s-omni-tab > span { display: block; transition: transform .18s ease; }
/* Open: the chevron points back up at the box it came from. */
.s-omni-tab-on > span { transform: rotate(180deg); }
/* Open, the panel's own grip sits on the bottom edge of the whole box, so
   this one would be a second handle stranded in the middle of it. */
.s-omni-tab-on { display: none; }

/* HELP: a real control in the row, AFTER the chat bubble -- not a tiny
   glyph pinned to a corner. Ghost-weight so it never competes with the
   bubble beside it, and carrying the lifebuoy from the shared icon set
   rather than a bare "?" so it inherits currentColor and matches the mark
   the assistant's own Help pane already uses. */
.s-omni-help {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  margin-left: 4px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: currentColor;
  font-size: 15px;
  cursor: pointer;
  opacity: 0.42;
  transition: opacity .15s ease, background .15s ease;
}
.s-omni-help:hover {
  opacity: 0.95;
  background: rgba(var(--ovl, 255, 255, 255), 0.10);
}
.s-omni-help:focus-visible { opacity: 0.95; outline: 1px solid currentColor; }

/* Operator-page preview line. The nav strip is a fixed-height row with
   overflow-x:auto, so this cannot live inside it without clipping or
   growing the header on every page -- it is positioned under the bar by
   shop.js instead, tracking it like the conversation does. */
.s-omni-prev {
  position: fixed;
  z-index: 9097;
  display: flex;
  gap: 6px;
  align-items: baseline;
  padding: 7px 11px;
  border-radius: var(--radius-md, 10px);
  border: 1px solid var(--s-line, rgba(0, 0, 0, 0.14));
  background: var(--s-bg-card, #161B2C);
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
  cursor: pointer;
  text-align: left;
  max-width: calc(100vw - 16px);
}
.s-omni-prev[hidden] { display: none; }

/* IN THE HEADER'S FLOW, not floating over the page.
 *
 * Fixed positioning made this a persistent element covering whatever sat
 * under it -- measured on repair-orders, it made the "Scan VIN" button
 * unhittable: the line is a button in its own right at z-index 9097, so the
 * control beneath simply could not be reached while a reply was showing.
 * That is the same defect the floating orb had over modal actions, and it
 * was fixed there by reserving space rather than overlaying.
 *
 * Hosted inside the sticky header, the line takes part in layout: the header
 * grows by its height and the page starts below it, so nothing can be
 * covered and no positioning maths is needed. It also matches the hub, where
 * the preview has always been an in-flow child of the ask card. */
.s-header .s-omni-prev {
  position: static;
  z-index: auto;
  width: auto;
  margin: 0 14px 10px;
  box-shadow: none;
  /* AND ITS OWN SURFACE AND INK, because it is sitting on the bar.
     `.s-omni-prev` above declares a card background, and it never applied:
     shop-assistant.js injects `.sa-prev { background: none; color:
     var(--ui-text) }` at runtime, which is the same specificity and lands
     later, so it wins. The line rendered transparent, in the PAGE's ink, on
     a cobalt bar -- measured 1.13:1, which is why a reply read as a smudge
     under the header rather than as text.

     `--ui-text` could not have helped either: shop.css aliases it to
     var(--s-text) at :root, and a custom property inherits its COMPUTED
     value, so the alias resolves at the root and keeps the page's ink no
     matter what this bar remaps --s-text to. Named directly here for that
     reason.

     Two class names deep, so it beats the injected single-class rule
     without !important. Reads as a panel hanging off the bar -- the same
     thing --s-bg-card means everywhere else up here. */
  background: var(--s-bg-card);
  color: var(--s-text);
  border-color: var(--s-line);
}

/* ── Minimum text-field size on touch ──────────────────────────────
   MINIMUM TOUCH FONT SIZE -- do not lower per-component.

   iOS Safari magnifies the whole page when a text field smaller than 16px
   takes focus, and it does NOT restore the previous zoom on blur. The page
   is left enlarged and off-centre for the rest of the session, on every
   screen, until the user pinches it back by hand. There is no event to
   listen for and no way to undo it from script -- the computed font-size at
   focus time is the only lever there is.

   Every text-entry control in the app was under the line: the omnibox at
   13.76px, the assistant composer at 12.96px, .s-input at 0.88rem, the
   feedback box at 13.6px. So focusing almost anything on a phone left the
   viewport wrong.

   `!important` is deliberate and is the point of the rule: this is a floor
   no component may drop below, including the assistant's ID-scoped styles
   injected from JS, which out-specify any class selector here. Written once
   in base.css because BOTH bundles load it -- the alternative is the same
   rule in shop.css and app.css, drifting apart.

   NOT fixed with `maximum-scale=1` on the viewport meta: that stops the
   zoom by disabling pinch-zoom entirely, for everyone, which takes a
   real accessibility affordance away to work around a platform quirk.

   Scoped to coarse pointers and small screens, so desktop keeps its denser
   type -- a shop tablet needs this as much as a phone does. */
@media (pointer: coarse), (max-width: 767px) {
  input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="file"]),
  textarea,
  select {
    font-size: 16px !important;
  }
}

/* ── Minimum touch target ──────────────────────────────────────────
   MINIMUM TOUCH TARGET -- 44px, the figure Apple and WCAG 2.5.5 both use.

   Measured at 375px, the shared chrome was under it on EVERY page, so this
   was not one page's layout problem: the mail icon 34x34, the account
   trigger 35x44, the notification bell 38x44. Page-level controls were
   worse -- ro-detail carried buttons 13px and 16px wide, and the reports
   CSV buttons were 27px tall.

   Scoped to `pointer: coarse` on purpose. Desktop density is deliberate and
   the owner is happy with it; a mouse can hit a 34px target and a thumb in a
   shop glove cannot. Gating on the INPUT DEVICE rather than a width means a
   tablet gets the roomy version in both orientations, and a narrow desktop
   window does not suddenly grow its chrome.

   min-width/min-height rather than width/height, so anything already larger
   is left exactly as it is and only the undersized grow. */
@media (pointer: coarse) {
  button,
  [role="button"],
  .s-btn,
  a.s-btn,
  input[type="button"],
  input[type="submit"] {
    min-height: 44px;
    /* Width matters as much as height and is the half that gets forgotten:
       a height-only floor left glyph buttons 13px and 16px wide on
       ro-detail -- full height, and still a sliver to hit. */
    min-width: 44px;
  }

  /* Two carve-outs, both for controls that are wide by nature and would be
     harmed by a square floor:
       - a full-width button would keep its width anyway, but an explicit
         100%-width control inside a narrow cell must stay able to shrink;
       - checkbox/radio have their own hit area and native sizing. */
  input[type="checkbox"],
  input[type="radio"] {
    min-width: 0;
    min-height: 0;
  }

  /* Icon-only controls need the width too -- these are square by intent, so
     a height-only floor leaves a 34px-wide sliver that is just as easy to
     miss. */
  #shop-mail-icon,
  #notif-bell-btn,
  .s-user-trigger,
  .s-icon-btn,
  button.s-icon-btn,
  /* The HUB bundle's chrome is a separate set of controls that happen to be
     anchors rather than buttons, so the rule above misses them entirely --
     measured at 30x30 (notifications, messages) and 24x24 (the settings
     gear) on settings.html. Matched on [title]/[aria-label] so the icon
     links are caught and ordinary breadcrumb text links are not. */
  .d-crumbs a[title],
  .d-crumbs a[aria-label],
  #d-notif-icon,
  #d-mail-icon {
    min-width: 44px;
    min-height: 44px;
  }

  /* Settings' own section nav sat at 40px. Wide enough to hit, but these are
     the primary way around that page, so they get the same floor. */
  .s-nav-link {
    min-height: 44px;
  }

  /* Inline text links that act as controls: grown by padding rather than a
     height floor, which would break their line box inside running text. */
  a.s-text-xs,
  #ro-intel-recalls {
    padding-block: 6px;
  }
}

/* ══ Navigation rail ═══════════════════════════════════════════════════
   Lives in base.css, not shop.css, because it has to serve BOTH page
   families: the 44 operator pages (shop.js + shop.css) and the six shell
   pages (shell.js + app.css). base.css is the only stylesheet all of them
   load, so it is also the only namespace both can rely on -- hence --ui-*
   throughout rather than --s-* or --d-*.

   Class names are deliberately NOT .s-sidebar/.s-group/.s-nav-link.
   settings.html already defines all three locally for its own section nav,
   and .s-nav-link is additionally a global top-bar class. Reusing any of
   them would have two stylesheets fighting over one element. */
:root { --ui-header-h: 64px; --ui-rail-w: 216px; }

/* THE TOP BAR RUNS THE FULL WIDTH; THE RAIL HANGS UNDER IT.
   Before, `body.s-has-rail { padding-left }` pushed the HEADER right along
   with everything else, so the bar started at x=216 and the rail owned the
   top-left corner -- a pale empty block beside a dark bar, with the collapse
   chevron floating in it. An L-shaped chrome, and the brand (DAEDALUS SHOP)
   sits in the header, so the one place a brand belongs was the one place
   nothing was.
   Now the header spans the window and paints over the rail's top (it is
   z-index 10 to the rail's 9), which is the arrangement every app with its
   wordmark in the top bar uses.

   --ui-header-h is the header's BOTTOM EDGE, not its height. On the hub a
   master-console banner sits above the bar, so those are 111 and 77 -- and
   padding by the height put the rail's first link inside the header band.
   Measuring the bottom is self-correcting for whatever ends up stacked up
   there. The name is now slightly wrong and the behaviour is right; renaming
   it would touch both renderers and every page that loads them. */
.s-rail {
    position: fixed;
    top: 0; left: 0; bottom: 0;
    width: var(--ui-rail-w);
    /* +10 rather than +8: the extra two points are the optical gap under a
       solid bar, which reads tighter than the same measure against nothing. */
    padding: calc(var(--ui-header-h) + 10px) 0 28px;
    overflow-y: auto;
    overscroll-behavior: contain;
    border-right: 1px solid var(--ui-line-soft);
    background: var(--ui-rail-bg);
    z-index: 9;                 /* under the sticky header, which is 10 */
    scrollbar-width: none;
}
.s-rail::-webkit-scrollbar { display: none; }

.s-rail-group { display: flex; flex-direction: column; margin-bottom: 6px; }
/* The 42px gutter on the first link is gone with the button it was dodging.
   `padding-right` was the wrong tool for it anyway: it moved the TEXT clear
   of the chevron while leaving the link's clickable box underneath, so a
   press a few pixels off target navigated instead of collapsing. */
/* The group headings carry a wash so the rail reads as sections rather than
   one long column of links. Uppercase at 0.62rem and muted was doing all the
   work on its own, which is enough to be a LABEL and not enough to be a
   DIVIDER -- WORK, SCHEDULE, SHOP, INSIGHT and ADMIN sat at the same visual
   weight as the gaps around them.

   rgba(var(--ui-ovl), ...) rather than a fixed colour: --ui-ovl is black in
   light and white in dark, so one rule darkens the band on a pale rail and
   raises it on a dark one. A literal would have been right in exactly one
   theme, which is the trap this token exists to avoid.

   Full-bleed to the rail's edges -- an inset band would read as a chip
   floating in the column instead of a section header spanning it. */
.s-rail-label {
    padding: 10px 18px 6px;
    margin-top: 6px;
    font-size: 0.62rem;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--ui-text-mute);
    background: rgba(var(--ui-ovl), 0.055);
    border-top: 1px solid var(--ui-line-soft);
    border-bottom: 1px solid var(--ui-line-soft);
}
/* No first-of-type exception. I wrote one on the assumption that the top
   band had nothing above it to divide from; it does -- Dashboard is an
   ungrouped row that sits above WORK -- so every band earns its rule, and
   the selector was quietly matching the Dashboard group instead. */
.s-rail-link {
    display: flex;
    align-items: center;
    padding: 7px 16px;
    margin: 1px 8px;
    border-radius: var(--radius);
    font-size: 0.875rem;
    line-height: 1.3;
    /* --ui-text, NOT --ui-text-mute.
       Mute is the token for SECONDARY text -- a caption beside something
       louder. The rail is not secondary: it is the page's primary
       navigation, read on every screen, and at mute's 0.55 / 0.58 alpha the
       destinations were the faintest thing on a page full of headings and
       figures. The group labels above them stay muted, which is what gives
       the rail its hierarchy.

       IT MUST BE PAIRED WITH --ui-rail-bg. Two earlier attempts got this
       wrong in opposite directions. The first invented --ui-rail-text in
       base.css alone -- but each bundle REMAPS the --ui-* set onto its own
       palette, so a token no bundle remaps carries base.css's palette onto a
       page painted from a different one, and the operator rail rendered
       cream on white. The second used --ui-text against --ui-surface, which
       app.css pins dark while letting --ui-text follow the theme: 1.0:1 on
       the hub. Ground and text have to be one pair, remapped together. */
    color: var(--ui-rail-fg);
    text-decoration: none;
    transition: background 0.1s, color 0.1s;
}
.s-rail-link:hover { background: var(--ui-accent-soft); color: var(--ui-text); }
.s-rail-link.active {
    background: var(--ui-accent-soft);
    color: var(--s-accent-text, #866637);
    font-weight: 500;
}

/* ── COLLAPSING THE RAIL ─────────────────────────────────────
   216px is a lot of a laptop to spend on a list you have already learned,
   and on a wide table -- the RO board, Inventory -- it is the difference
   between reading a column and scrolling to it.

   COLLAPSED MEANS GONE, NOT NARROW. An icons-only rail is the usual answer
   and is not available here: these rows are words, with no icon set behind
   them, so a 56px rail would be twenty truncated labels. Hiding it outright
   is honest, and the drawer that already exists below 1024px is exactly the
   way back -- which is why collapsing also reveals that opener at desktop
   width, where it is otherwise hidden.

   The preference is per user and server-side (prefs.nav.rail_collapsed), so
   it follows the operator to the next machine instead of being a per-browser
   surprise. */
/* OUT OF THE FLOW.
   As a flex child it took a full 36px row at the top of the rail and pushed
   every link down by that much -- which is most of why the navigation started
   so far below the header. It is a chrome control, used rarely; it does not
   deserve a row of the thing it is chrome for. Absolutely positioned in the
   rail's top-right corner, it costs nothing and sits exactly where a collapse
   control is looked for. */
.s-rail-collapse {
    position: absolute;
    top: calc(var(--ui-header-h) + 8px);
    right: 8px;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 26px;
    height: 26px;
    flex: none;
    background: transparent;
    border: 1px solid transparent;
    border-radius: var(--radius);
    color: var(--ui-text-mute);
    font: inherit;
    font-size: 0.9rem;
    line-height: 1;
    cursor: pointer;
}
.s-rail-collapse:hover { color: var(--ui-text); border-color: var(--ui-line-soft); }
.s-rail-collapse:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 2px; }
@media (pointer: coarse) { .s-rail-collapse { width: 44px; height: 44px; } }

/* Collapsed: the rail goes, its gutter goes with it, and the drawer opener
   appears at widths where it is normally unnecessary. Without that last
   rule the operator would collapse the rail and have no way back.

   :not(.s-rail-open) IS THE WAY BACK BELOW 1024px, and it was missing.
   `s-rail-hidden` is a desktop preference and it PERSISTS (nav.rail_collapsed
   via Daedalus.prefs), so it was still set on a phone -- where the rail is
   off-canvas anyway and the drawer is the only route into the navigation.
   This rule is (0,3,1) and the drawer's own `display: block` is (0,3,0), so
   it won: the toggle added .s-rail-open, the scrim appeared, aria-expanded
   flipped, and the rail stayed display:none. Everything worked except the
   part you could see, which is why it read as a dead button.

   Collapse the rail once on a desktop and the hamburger was inert on every
   narrow screen from then on. */
/* THE RAIL LEAVES; IT DOES NOT VANISH.
   `display: none` gave no reaction at all: press the button and 216px of
   navigation is simply absent on the next frame, with nothing connecting
   the press to the result. Below 1024px this same control has always slid
   its drawer out over 200ms -- so the product animated on a phone and
   teleported on a desktop, using one button.

   `visibility` rather than `display` so it can be transitioned, and delayed
   to the end of the slide so the rail is not focusable or clickable while
   it is on its way out. */
body.s-rail-hidden .s-rail:not(.s-rail-in-modal):not(.s-rail-open) {
    transform: translateX(-100%);
    visibility: hidden;
}
body.s-rail-hidden.s-has-rail { --ui-rail-shift: 0px; }
body.s-rail-hidden .s-rail-toggle { display: inline-flex; }

/* THE NAV TOGGLE IS ALWAYS THERE, because it now goes both ways.
   It used to appear only where the rail disappeared -- it was the way BACK
   from a collapsed rail, and hiding was a separate button mounted inside the
   rail itself. That second button had no home: first it ate a row of the
   navigation, then it was absolutely positioned on top of the first nav link
   and shared that link's hit area, which is what made "collapse" navigate to
   the dashboard instead.
   One control, in the chrome bar, for both directions. A rail holds
   navigation; the thing that shows and hides the rail is chrome. */
/* BOTH RENDERERS' TOGGLES. shop.js builds `.s-nav-menu`; shell.js builds
   `.s-rail-toggle`. Revealing only the first left the hub -- which is
   shell.js -- with no way to collapse the rail at all once the in-rail
   chevron stopped being mounted. Two renderers is the arrangement here, and
   naming only one of them is how it drifts. */
.s-has-rail .s-nav-menu,
.s-has-rail .s-rail-toggle,
body.s-rail-hidden .s-nav-menu { display: inline-flex; }

/* The rail keeps its full width for links now that nothing is layered over
   the first one, and the collapse control is no longer mounted inside it. */
.s-rail-collapse { display: none; }

/* The content shifts; the rail does not overlap it. Padding the body rather
   than every page's own wrapper is what makes this work without editing 44
   layouts -- the sticky header simply starts after the rail, which is the
   arrangement the reference designs use anyway. */
/* ONE VALUE MOVES ALL THREE THINGS.
   The rail slides, the content follows it, and the top bar is pulled across
   the gutter -- and those have to agree on every intermediate frame or the
   bar visibly jumps. Driving them from three separate transitions cannot
   guarantee that; driving them from ONE animated length can, because there
   is only one number.

   Registering it is what makes it animatable: a plain custom property is a
   string to the animation engine and snaps from end to end. Measured
   frame-by-frame through the transition with this in place, the bar's left
   and right edges hold at 0 and the window width while the rail travels
   0 -> -216 and the content follows 216 -> 20. Without the registration the
   bar jumped to +216 on the first frame and slid back.

   Where @property is unsupported the value still resolves and simply does
   not animate, which is exactly the behaviour this replaces. */
@property --ui-rail-shift {
    syntax: "<length>";
    inherits: true;
    initial-value: 0px;
}
body.s-has-rail {
    --ui-rail-shift: var(--ui-rail-w);
    padding-left: var(--ui-rail-shift);
}

/* Motion is opt-out, and everything below snaps without it -- which is the
   behaviour this section replaced, so the reduced-motion path is the old
   one exactly. */
@media (prefers-reduced-motion: no-preference) {
    body.s-has-rail { transition: --ui-rail-shift 200ms var(--ui-ease); }
    .s-rail { transition: transform 200ms var(--ui-ease),
                          visibility 0s linear 0s; }
    body.s-rail-hidden .s-rail:not(.s-rail-in-modal):not(.s-rail-open) {
        transition: transform 200ms var(--ui-ease),
                    visibility 0s linear 200ms;
    }
    /* The press itself. A control that reports nothing on the way down
       reads as not having been hit -- which is how the last round of this
       bug was described. */
    .s-rail-toggle:active, .s-nav-menu:active { transform: scale(0.92); }
    .s-rail-toggle, .s-nav-menu { transition: transform 90ms ease-out; }
}

/* The header is inside the body, so that padding moved it right too. Pull it
   back out. Scoped to the cases where the padding is actually applied --
   collapsed rail and below 1024px both zero it, and a negative margin left
   behind there would hang the bar off the left edge of the window. */
/* Anything in the top BAND, not only the bar itself. The hub stacks a
   master-console notice above the header; left in the gutter it drew a
   216px step at the very top of the page, directly above a bar that had
   just been straightened out.

   #s-banners is deliberately NOT here. It looks like the same case and is
   not: it sits inside the content column and is meant to align with the
   cards it warns about, not with the window. It was added to this rule on
   the assumption they were equivalent, then measured -- l:310, inside the
   shell -- and taken back out. */
/* Pulled by the SAME value that pads the body, so the two cannot disagree
   on any frame. It is no longer scoped with :not(.s-rail-hidden) -- the
   scope has moved into the value, which is 0px when the rail is away, and a
   rule that stops applying mid-transition cannot animate its way out. The
   original reason for the scope holds either way: with no gutter there must
   be no pull, or the bar hangs off the left edge of the window. */
body.s-has-rail #master-console-slot,
body.s-has-rail .s-header,
body.s-has-rail .d-header {
    margin-left: calc(-1 * var(--ui-rail-shift));
    width: calc(100% + var(--ui-rail-shift));
    /* max-width would otherwise clamp it back to the content column on the
       pages that set one. */
    max-width: none;
}

/* AND PAINTED ABOVE THE RAIL, which is the same requirement and was missed
   for three rounds because getBoundingClientRect() reports the LAYOUT box and
   knows nothing about paint order. The header's box has spanned since the
   first fix; it was simply underneath the rail for its first 216px, so what a
   person saw never changed while every measurement said it had.

   `.d-header` carries z-index 40, but its parent `#hdr` is
   `position: relative; z-index: 1` (app.css, so it sits above the hub's road
   backdrop) and that CREATES A STACKING CONTEXT -- 40 only ranks inside it.
   What competes with the rail is #hdr's 1 against the rail's 9. The banner is
   `position: static` with no z-index at all, so the rail covered it outright.

   An element dragged across the rail that renders beneath it is worse than
   one left in the gutter: it looks broken rather than merely cramped. So the
   two rules live together, and #hdr is here for its STACKING CONTEXT only --
   giving it the geometry as well would shift its already-shifted child twice.

   `position` is set only on the static one. .s-header and .d-header are
   sticky, and relative would drop them out of the top of the viewport. */
body.s-has-rail:not(.s-rail-hidden) #master-console-slot { position: relative; }
body.s-has-rail:not(.s-rail-hidden) #master-console-slot,
body.s-has-rail:not(.s-rail-hidden) #hdr,
body.s-has-rail:not(.s-rail-hidden) .s-header,
body.s-has-rail:not(.s-rail-hidden) .d-header { z-index: 41; }

/* Below this the rail would eat a third of a phone, so it comes off-canvas
   and something else has to open it -- mobile shops must not lose horizontal
   space, but they must not lose navigation either.

   TWO ARRANGEMENTS, and the class says which:

     shop.js's 44 operator pages have a ☰ in their nav strip that opens a
     MODAL drawer (.s-rail-in-modal, below). Their rail simply goes away.

     shell.js has no modal helper, so on the pages it serves the rail ITSELF
     slides in, marked .s-rail-drawer. This is not decoration: the hub was
     navigable on a phone only because it was a grid of launcher tiles, and
     when those tiles became figures (2026-08-30) the page was left with no
     way to reach anything at all below 1024px.

   A page gets exactly one of the two, never both. */

/* The toggle is the drawer's only opener, so it exists only where the drawer
   does. Hidden by default rather than shown-then-hidden: at desktop width the
   rail is already on screen and a button to reveal it would be a control that
   does nothing.

   DECLARED BEFORE THE MEDIA QUERY ON PURPOSE. Both rules set `display` at the
   same specificity, so the later one wins outright -- with this block after
   the query the button was display:none at every width, present in the DOM,
   correctly wired, and 0px tall. */
.s-rail-toggle {
    display: none;
    align-items: center;
    justify-content: center;
    /* The same square as every other control in the bar -- see THE HEADER
       BAR below. It sits beside them, so it cannot be its own size. */
    width: var(--ui-hdr-ctl);
    height: var(--ui-hdr-ctl);
    flex: 0 0 auto;
    flex: none;
    background: transparent;
    /* The same edge as every other control in the bar. The soft LINE token
       is tuned for the page; up here it measured well under the 3:1 a
       control's boundary needs, so this comes off the overlay like theirs. */
    border: 1px solid rgba(var(--ovl), 0.36);
    border-radius: var(--radius);
    /* INHERIT, rather than naming the page's muted ink.
       This is a dark bar in both themes and the page behind it is not, so a
       page token lands the wrong ink on it -- measured 1.48:1 on the hub.
       Its twin on the operator pages was correct for the single reason that
       it names no colour and takes the bar's. Same weight as the icons
       beside it now, which is right for the control that restructures the
       screen. */
    color: inherit;
    font-size: 1rem;
    line-height: 1;
    cursor: pointer;
}
/* Hovers like every other control in the bar: the overlay wash they use,
   and the accent at the edge. It used to reach for the page's text token,
   which on this bar is ink for a surface that is not there. */
.s-rail-toggle:hover {
    background: rgba(var(--ovl), 0.10);
    border-color: var(--ui-accent);
}

/* THE ICON SAYS WHICH WAY IT GOES.
   Three bars mean "menu" -- a thing that opens. They do not say whether the
   rail is currently there, nor which direction pressing will send it, so
   the button looked identical in both states and the only feedback was the
   rail appearing or not appearing.

   Above 1024px the rail is a PANEL that collapses, and the panel-with-an-
   arrow mark is what that means everywhere it is done well: the frame is
   the app, the bar down its left is the rail, and the arrow points the way
   the rail is about to travel. Below 1024px the same button opens a modal
   drawer, and three bars are exactly right for that, so the swap is scoped
   to the width where the rail is a panel at all.

   Same icon system as the rest of the bar: a masked Tabler path, 24px
   viewBox, 2px round-capped stroke. Drawn here rather than added to the
   ic-* set because these two are STATES of one control, not two icons any
   other caller would ask for by name. */
@media (min-width: 1024px) {
    /* Rail showing -- pressing puts it away, so the arrow points at the
       edge it is going to. */
    .s-rail-toggle .ic-menu,
    .s-nav-menu .ic-menu {
        -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' /%3E %3Cpath d='M9 4v16' /%3E %3Cpath d='M15 10l-2 2l2 2' /%3E%3C/svg%3E");
        mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' /%3E %3Cpath d='M9 4v16' /%3E %3Cpath d='M15 10l-2 2l2 2' /%3E%3C/svg%3E");
    }
    /* Rail away -- pressing brings it back, so the arrow turns around. */
    body.s-rail-hidden .s-rail-toggle .ic-menu,
    body.s-rail-hidden .s-nav-menu .ic-menu {
        -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' /%3E %3Cpath d='M9 4v16' /%3E %3Cpath d='M13 10l2 2l-2 2' /%3E%3C/svg%3E");
        mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' /%3E %3Cpath d='M9 4v16' /%3E %3Cpath d='M13 10l2 2l-2 2' /%3E%3C/svg%3E");
    }
}
.s-rail-scrim[hidden] { display: none; }

@media (max-width: 1023px) {
    /* Zero the VARIABLE, not the padding.
       ------------------------------------------------------------------
       This said `padding-left: 0`, which removed the gutter but left
       --ui-rail-shift at 216px. The header's counter-pull reads that
       variable:
           body.s-has-rail .d-header { margin-left: calc(-1 * var(--ui-rail-shift)) }
       so below 1024px the bar was dragged 216px off the left edge of the
       window, taking the brand and the hamburger with it -- there was no
       visible way to open the menu at all.
       The rule that pulls it says "Pulled by the SAME value that pads the
       body, so the two cannot disagree on any frame", and the block above
       says the scope now lives IN the value. Both were true of the
       collapsed-rail case and neither was true here: this one still zeroed
       the padding directly and so opted itself out of the invariant.
       Setting the variable zeroes the gutter (body pads by it) AND the
       counter-pull, which is the single source of truth those comments
       describe. */
    body.s-has-rail { --ui-rail-shift: 0px; }
    .s-rail:not(.s-rail-in-modal) { display: none; }

    .s-rail.s-rail-drawer:not(.s-rail-in-modal) {
        display: block;
        transform: translateX(-100%);
        transition: transform 200ms ease;
        /* Above the sticky header (10) while open, so the drawer covers the
           bar it was opened from rather than sliding under it. */
        z-index: 60;
    }
    .s-rail.s-rail-drawer.s-rail-open:not(.s-rail-in-modal) {
        transform: none;
        box-shadow: 0 0 40px rgba(0, 0, 0, 0.45);
    }
    .s-rail-scrim {
        position: fixed;
        inset: 0;
        z-index: 59;
        background: rgba(0, 0, 0, 0.45);
    }
    .s-rail-toggle { display: inline-flex; }
}
@media (prefers-reduced-motion: reduce) {
    .s-rail.s-rail-drawer:not(.s-rail-in-modal) { transition: none; }
}

/* The same rows, rendered inside the drawer modal. */
.s-rail-in-modal {
    position: static;
    width: auto;
    padding: 0;
    border-right: none;
    background: none;
    max-height: 62vh;
    z-index: auto;
}

/* Touch targets. Keyed on the pointer, not the width -- a small window on a
   desktop is still a mouse. */
@media (pointer: coarse) {
    .s-rail-link { min-height: 44px; }
}


/* ══ THE HEADER BAR ══════════════════════════════════════════
   Both renderers build a bar; neither had a grammar for what goes in it.

   WHAT WAS WRONG, measured at 375px:

     hub          155px tall, 4 rows
     operator     211px tall, 4 rows  (26% of the viewport before content)

   Both headers were `flex-wrap: wrap` containers holding controls of
   whatever size each one had been written at -- three 44px squares beside a
   23px pill beside a 23px chip -- so the controls landed on four different
   baselines at four unrelated x positions. Nothing was "positioned"; they
   fell where the wrap put them. That is what reads as random.

   Worse, every one of those sizes was an INLINE style, so no stylesheet
   could correct it. The fix has to give the controls a shared class, which
   is what .ui-hdr-ctl is.

   THE GRAMMAR

     1. ONE control shape. Every button in the bar is the same square, with
        the same radius and the same gap. A control with a label is that
        square plus its text; below 900px the label is dropped and it
        becomes the square again.
     2. ONE row on the hub. Two DELIBERATE rows on operator pages, where
        the search omnibox genuinely needs the width: identity + actions on
        row 1, search on row 2. Deliberate, not whatever wrap produced.
     3. A FIXED collapse order, so what disappears is a decision:
          never       the drawer toggle, the brand mark, notifications,
                      the user control
          <900px      labels on "+ New" and the shift clock; the acting-shop
                      picker's eyebrow
          <640px      the settings cog -- and ONLY because the rail lists
                      Shop Settings, so nothing becomes unreachable.
        Nothing else is dropped at any width.
   ══════════════════════════════════════════════════════════════ */
:root {
    --ui-hdr-ctl: 40px;   /* the one square */
    --ui-hdr-gap: 6px;
}
@media (min-width: 900px) { :root { --ui-hdr-gap: 8px; } }
/* A finger, not a cursor. Keyed on the pointer rather than the width for
   the same reason as .s-rail-link above. */
@media (pointer: coarse) { :root { --ui-hdr-ctl: 44px; } }

/* One control. Square by default; .ui-hdr-wide adds a label. */
.ui-hdr-ctl {
    flex: 0 0 auto;
    box-sizing: border-box;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    width: var(--ui-hdr-ctl);
    height: var(--ui-hdr-ctl);
    padding: 0;
    margin: 0;                    /* the row's gap owns the spacing, not
                                     each control's own margin-left */
    position: relative;
    border-radius: var(--radius);
    border: 1px solid transparent;
    background: transparent;
    color: inherit;
    font: inherit;
    font-size: 0.8rem;
    line-height: 1;
    text-decoration: none;
    cursor: pointer;
    transition: background 140ms, border-color 140ms, color 140ms;
}
.ui-hdr-ctl:hover { background: rgba(var(--ui-ovl), 0.10); }
.ui-hdr-ctl:focus-visible {
    outline: 2px solid var(--ui-accent);
    outline-offset: 2px;
}
/* Labelled: still the same height, just wider. */
.ui-hdr-wide {
    width: auto;
    min-width: var(--ui-hdr-ctl);
    padding: 0 12px;
}
/* THE OUTLINE HAS TO BE VISIBLE TO BE AN OUTLINE.
   Measured on the hub bar against the surface behind it: the back button
   and "+ New" at 1.07:1 in light and 1.48:1 in dark, the signed-in pill at
   1.67 and 1.17. WCAG asks 3:1 for the boundary that identifies a control,
   and at those numbers these read as bare text with a rectangle nobody can
   see -- the shift-clock chip beside them, which carries a status colour
   rather than this token, measured 6.67 and 11.17.

   The generic line token is tuned for the PAGE, and this class is only ever
   worn inside a header (shell.js twice, shop.js once), which is a dark bar
   in both themes. So it comes off the overlay, which the header now remaps
   to match its own surface -- one value that stays correct in both themes
   instead of a page token borrowed by chrome that is not on the page. */
.ui-hdr-outline { border-color: rgba(var(--ovl), 0.36); }

/* Empty flex children still claim their gaps. The operator cluster carries a
   couple of zero-width spans (a shift-clock counter that is blank off-shift),
   and each one opened a control-sized hole in an otherwise even row. */
.s-nav-right > span:empty,
.d-crumbs > span:empty { display: none; }

/* ── The identity control's face ─────────────────────────────────
   THE PERSON, NOT A PERSON.

   The 44 operator pages already did this: a photo when the account has one,
   the person's INITIAL when it does not. The hub showed an 11px generic
   person glyph instead -- a picture of "a human", which is the one fact
   nobody needed. Same product, same bar, two answers to "who is signed in".

   That question is not decorative here. The pill's own note says why: on a
   shared front-desk terminal this is the one thing in the bar somebody
   actually needs to check, and a glyph cannot answer it.

   The initial is the BASE state and the photo an upgrade layered over it
   (see the preload in shell.js / avatarInto in shop.js) -- so a dead avatar
   URL degrades to the letter instead of a broken-image icon. */
.ui-hdr-avatar {
    width: 28px;
    height: 28px;
    flex: 0 0 auto;
    border-radius: 50%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 0.8rem;
    font-weight: 600;
    line-height: 1;
    letter-spacing: 0;
    text-transform: uppercase;
    overflow: hidden;
    user-select: none;
    background: linear-gradient(135deg, var(--ui-accent) 0%, var(--ui-iris) 100%);
    background-size: cover;
    background-position: center;
    /* Deep navy reads on the bronze->iris ramp in both themes; the ramp is
       mid-tone either way, so this does not flip with the palette. */
    color: #0d1124;
}

/* A bare glyph standing in for a word needs to be read at a glance: "+" set
   at the control's 0.8rem label size rendered as a faint dot between two
   full-weight icons. */
.ui-hdr-ico { font-size: 1.2rem; line-height: 1; font-weight: 400; }

/* The label a narrow bar cannot afford. Hiding it returns the control to
   the square, so the row stays on one rhythm instead of half-collapsing.

   1100, raised from 899. The bar stopped wrapping into a second row, so
   everything a narrower window takes now comes out of the omnibox instead
   -- and measured on a repair order it reaches 183px at 1100, 123 at 1000
   and 62 at 900, which is smaller than its own placeholder. The two words
   up here cost about 100px together and are each sat beside an icon that
   already says the same thing, with a title and an aria-label behind them.
   A word next to a plus sign is the cheapest thing in the row to lose, and
   it has to go before the one control you cannot use at all when it is
   too small.

   ONE LADDER, IN ONE PLACE. This nearly became a second rule in shop.css
   at a different breakpoint, which would have collapsed the label without
   this companion line and left a wide button with nothing in it. */
@media (max-width: 1100px) {
    .ui-hdr-lbl { display: none; }
    .ui-hdr-wide { width: var(--ui-hdr-ctl); min-width: 0; padding: 0; }
}
/* Reachable elsewhere (the rail's Admin group), so this one may go. */
@media (max-width: 639px) {
    .ui-hdr-optional { display: none !important; }
}

/* The wordmark is the last thing in the bar that can afford to go, and at
   phone widths it has to: measured at 375px the lead needs 44 (drawer) + 144
   (wordmark) and the trail needs 244, into 361px of usable row. The MARK
   stays -- brand presence is the glyph, and the page's own H1 says the name
   underneath. Nothing here is a control, so nothing becomes unreachable. */
@media (max-width: 639px) {
    .d-header .d-brand .d-wordmark,
    .d-header .d-brand .d-brand-sub,
    .s-header .s-brand .s-wordmark,
    .s-header .s-brand .s-brand-sub { display: none; }
}

/* ── Zones ───────────────────────────────────────────────────
   lead (toggle + brand) · mid (crumbs / search) · trail (actions) */

/* The hub: one row, always. It has nothing that needs a second. */
.d-header-inner {
    flex-wrap: nowrap;
    column-gap: var(--ui-hdr-gap);
}
/* The nav strip is empty since the rail took navigation -- but an empty
   flex child still claims a line, which is one of the four the hub was
   spending. */
.d-header-inner > .d-nav:empty { display: none; }
/* Actions right, on one line, on the shared gap. */
.d-header-inner > .d-crumbs {
    margin-left: auto;
    display: flex;
    align-items: center;
    flex-wrap: nowrap;
    gap: var(--ui-hdr-gap);
    min-width: 0;
}
/* align-items:baseline on .d-brand (app.css) is right when the bar is a
   wordmark beside crumb text; beside a row of 44px squares it sits the mark
   8px low. Centre it here, where the bar's own rules live. */
.d-header-inner > .d-brand,
.s-header-inner > .s-brand { flex: 0 1 auto; min-width: 0; align-items: center; }

/* Very narrow (iPhone SE and down). Measured at 320px: 292px of usable row
   against a 44px drawer + 51px mark + 244px of controls = 345. Something has
   to go, and it is the only thing in the bar that is not a control -- the
   mark. The drawer toggle and the page's own H1 carry identity from here.
   The gap tightens by 2px rather than the controls shrinking: 44px is the
   touch minimum and is not negotiable on the device that needs it most. */
@media (max-width: 399px) {
    :root { --ui-hdr-gap: 4px; }
    /* The hub only. Its lead carries the drawer toggle AND the mark, which
       is 24px more than 320px has room for; on operator pages the toggle
       lives on row 2, so their mark stays. */
    .d-header .d-brand { display: none; }
    /* Last out. It is master_dev-only, its proper home is the master
       console, and at this width the row is 24px short with it present. */
    .s-scope-picker { display: none; }
}

/* Operator pages: two rows below 900px, and they are chosen rather than
   fallen into. The omnibox is a real input that wants width; giving it the
   whole of row 2 is the ergonomic answer on a phone, and it puts search
   under the thumb rather than wedged between two buttons. */
@media (max-width: 899px) {
    .s-header-inner { flex-wrap: wrap; row-gap: 4px; column-gap: var(--ui-hdr-gap); }
    .s-header-inner > .s-brand     { order: 1; flex: 0 1 auto; min-width: 0; }
    .s-header-inner > .s-nav-right { order: 2; margin-left: auto; flex-wrap: nowrap; gap: var(--ui-hdr-gap); }
    .s-header-inner > .s-nav       { order: 3; flex: 1 0 100%; margin-left: 0; }
    /* The acting-shop picker is master_dev-only (shop.js removes it for
       everyone else) and is slated to move into the master console. Until
       it does, it must not set the width of a bar every operator sees:
       the eyebrow goes and the name truncates. */
    .s-scope-picker > span { display: none; }
    .s-scope-picker select { max-width: 92px; }
}
@media (min-width: 900px) {
    .s-header-inner { flex-wrap: nowrap; column-gap: var(--ui-hdr-gap); }
    .s-header-inner > .s-nav-right { flex-wrap: nowrap; gap: var(--ui-hdr-gap); }
}
