/* =========================================================================
   Inventory Module — Design System
   Style: Data-Dense Dashboard
   Fonts: Fira Code (headings), Fira Sans (body)
   ========================================================================= */

:root {
  /* Brand */
  --color-primary:        #4C74A6;   /* cobalt-600 */
  --color-primary-600:    #3E6394;
  --color-primary-700:    #35608F;
  --color-primary-50:     #E7EEF6;
  --color-secondary:      #8FA6C0;
  --color-cta:            #5E86B8;   /* sky-500 */
  --color-cta-600:        #4C74A6;
  --color-cta-50:         #EAF0F7;

  /* Surfaces */
  --color-bg:             #E7EAEE;   /* app background — see --bg-page-1 */
  --color-body:           #cfedff;
  --color-surface:        #FFFFFF;
  --color-surface-alt:    #F5F8FA;
  --color-border:         #E4E9EE;
  --color-border-strong:  #D3DBE2;
  --color-table-head:     #E7EEF3;   /* every table header, every page — themed below */

  /* Controls. Anything the cursor can enter or press sits on this ground, never
     on the panel's own. A white field on a white card is a field nobody can
     see: the hairline border alone does not survive a projector, a laptop at an
     angle, or a warehouse screen. See memory.md §13. */
  --color-control:        #EBEFF4;
  --color-control-border: #A9B5C2;

  /* Text */
  --color-text:           #2A323B;   /* deep cobalt ink */
  --color-text-muted:     #5C6673;   /* slate-600 */
  --color-text-subtle:    #8794A1;

  /* Warmth — the platform's one status ramp.
     A record's state is read as a temperature, and it runs the same way wherever
     it is read: grey while nothing has happened yet, blue once it is alive but
     cool, amber while it is warming and waiting on somebody, green when it has
     landed. Ice sits outside the run — it is a state, not a step: something that
     was warm and went out.

     It began on the CRM board as five hardcoded hexes written twice (the Warmth
     columns and the Warmth column of the clients table). They are tokens here so
     anything else that reports a state — a quote's Draft, a campaign, a
     request — reads the same ramp rather than inventing a neighbouring one.

     USE THEM AS A HUE, NOT AS INK: mix against `--color-surface`
     (`color-mix(in oklab, var(--wm) 13%, var(--color-surface))` for a ground,
     ~38% for its edge) so the chip follows every theme instead of needing a
     second palette, and leave the word itself in the page's own text colour. */
  --wm-new:       #94A3B8;   /* nothing has happened yet */
  --wm-contacted: #3B82F6;   /* alive, but cool */
  --wm-engaged:   #F59E0B;   /* warming, and waiting on somebody */
  --wm-active:    #3F9E82;   /* landed — the same green as --color-success */
  --wm-dormant:   #7DD3FC;   /* was warm and went out */

  /* Semantic */
  --color-success:        #3F9E82;
  --color-success-50:     #E3F3EE;
  --color-warn:           #CF9A3A;
  --color-warn-50:        #F7EFDB;
  --color-danger:         #D06A5A;
  --color-danger-50:      #FAE9E5;
  --color-info:           #4C74A6;
  --color-info-50:        #E7EEF6;
  /* The VIP Offers mark. The deeper of the two golds the VIP mast carries:
     the brighter one is drawn on that dark header and cannot be read as
     small text on a white row. Everywhere a record says it came out of a
     VIP Offer wears this, and nothing else does. */
  --color-vip:            #A98338;
  /* Grey is a state of its own — inert — so it is a token family like the rest
     rather than four loose hexes in the badge rules. */
  --color-neutral:        #7E8CA2;
  --color-neutral-50:     #F1F5F9;
  /* A visit — somebody is coming in. Not a condition like the five above,
     which is why it is violet and not on the green-to-red run. */
  --color-visit:          #7C3AED;
  --color-visit-50:       #EDE9FE;

  /* The ink each state is written in. The base token is the mark's colour — a
     dot, a rule, a bar — and it is too light to read as text on its own 50 fill,
     so each state carries a darker sibling for type. Every theme defines all
     five: a theme that inherits these is a light ink on a light fill or the
     reverse. See memory.md §19. */
  --color-success-ink:    #2B6E5A;
  --color-warn-ink:       #8A6414;
  --color-danger-ink:     #9C4436;
  --color-info-ink:       #35608F;
  --color-neutral-ink:    #475569;
  --color-visit-ink:      #6D28D9;

  /* Elevation */
  --shadow-sm:  0 1px 2px 0 rgb(15 23 42 / 0.04);
  --shadow:     0 1px 3px 0 rgb(15 23 42 / 0.06), 0 1px 2px -1px rgb(15 23 42 / 0.04);
  --shadow-md:  0 4px 6px -1px rgb(15 23 42 / 0.07), 0 2px 4px -2px rgb(15 23 42 / 0.05);
  --shadow-lg:  0 10px 15px -3px rgb(15 23 42 / 0.08), 0 4px 6px -4px rgb(15 23 42 / 0.04);

  /* Layout */
  --sidebar-w:        174px;
  --sidebar-w-collapsed: 64px;
  --header-h:         56px;
  /* The band of panel colour above every table's first header row (memory.md §18),
     and with it the lead-in under a tab strip. One number for the whole platform:
     39 pages open a table under a tab strip and all of them read this, and
     `.tab-shell`'s own top padding is the same 10 so the CRM screens sit level with
     them. Set it here, nowhere else. */
  --table-head-band:  10px;
  /* The same standoff on the side edges (memory.md §20). The table was the one
     element running to the panel's edge, which is why its grey header read as the
     lid of the box rather than a band inside it.

     10, not the 16 every other thing in a card pads. A table is the widest thing the
     platform draws and the only one charged for its gutters twice — once a side, out
     of columns that are already bounded — so it takes the smallest standoff that
     still visibly clears the edge. The cost is that the band now starts 6px outside
     the toolbar text above it instead of under it; the alternative was spending 32px
     of a wide table's width on air. */
  --table-inset:      10px;
  /* THE PAGE STACK GAP (memory.md §22). One page is a stack of blocks under the
     header — the search toolbar, the KPI summary, a warning banner, then the tab
     strip and its table — and every joint between them is this, on every page.

     Measured off quotes.html, where it was already right. Each block in the stack
     owns its bottom margin and nothing owns a top margin, so the gap is one number
     and not a sum of two — which is how it drifted to 24 and 30 in the first place,
     a bottom margin here and a top margin there, neither of them wrong on its own. */
  --page-gap:         14px;
  --radius-sm:        5px;
  --radius:           5px;
  --radius-md:        5px;
  --radius-lg:        5px;
  --border-input-1: #D3DBE2;
  --color-value-tnum: #35608F;
  --bg-input-1: #FFFFFF;
  --bg-page-2: #f7f7f7;

  /* Shell chrome (sidebar / header / scrollbar / search) — themeable; see THEME LAYER below.
     Default values equal the original hardcoded chrome colors, so the Default theme is unchanged. */
  --color-sidebar-bg:          #112f55;
  --color-sidebar-border:      #17304F;
  --color-sidebar-divider:     rgba(255,255,255,0.08);
  --color-sidebar-heading:     #F3F7FC;
  --color-sidebar-section:     #6E82A0;
  --color-sidebar-text:        #AEBED6;
  --color-sidebar-hover-bg:    rgba(255,255,255,0.06);
  --color-sidebar-hover-text:  #FFFFFF;
  --color-sidebar-active-bg:   rgba(255,255,255,0.12);
  --color-sidebar-active-text: #FFFFFF;
  --color-sidebar-accent:      #6BA8DE;
  --color-header-bg:           rgba(255,255,255,0.85);
  --color-search-bg:           #fcfeec;
  --color-scrollbar:           #CBD5E1;
  --color-scrollbar-hover:     #94A3B8;
  --brand-gradient:            linear-gradient(135deg,#4C74A6,#6BA8DE);
  /* The ground the cards sit on. Cool, not neutral: #ededed carried no hue at
     all, and beside this palette's cool borders, cobalt blue and blue-grey table
     heads the eye read that neutrality as warmth — a brown nobody could point at.
     Same lightness, warmth removed. --color-bg tracks it so the scrollbar gutter
     does not keep the old colour in a 2px outline. */
  --bg-page-1: #E7EAEE;
  --bg-sidebar-1: #112f55;
}

/* =========================================================================
   THEME LAYER
   Switch the portal palette by setting data-theme on <html> (done in js/app.js
   from localStorage['vava-theme']). No attribute / "default" = the current look.
   Each block redefines the SAME token set as :root above — copy one to add a
   theme. Palette source of record: css/vava-palettes.css.
   Scope: core "palette swap" (surfaces, text, borders, accent + shell). A few
   hardcoded component tints (some table rows, badge text/borders) are not yet
   tokenized, so the dark themes (Evening, Elegant) theme the shell + cards but
   may leave a few light accents — a known follow-up.
   ========================================================================= */

/* ---- Evening (dark, blue-gray + electric blue) ---- */
[data-theme="evening"] {
  --color-primary: #5B8CFF; --color-primary-600: #5B8CFF; --color-primary-700: #8AB0FF; --color-primary-50: #1D2637;
  --color-secondary: #7E8CA2;
  --color-cta: #5B8CFF; --color-cta-600: #5B8CFF; --color-cta-50: #1D2637;
  --color-bg: #0B0E14; --color-body: #0B0E14; --color-surface: #131826; --color-surface-alt: #0E121C;
  --color-border: #232C3E; --color-border-strong: #2A344A; --color-table-head: #1B2333;
  --color-text: #C2CCDB; --color-text-muted: #7E8CA2; --color-text-subtle: #55637A;
  --color-success: #34D399; --color-success-50: #152A22;
  --color-warn: #FBBF24; --color-warn-50: #2A2413;
  --color-danger: #F87171; --color-danger-50: #2C1A1C;
  --color-info: #5B8CFF; --color-info-50: #152036;
  /* The five states, this theme's half. On a dark fill the base token IS the
     legible ink, so each -ink points back at it rather than darkening further. */
  --color-neutral: #7E8CA2; --color-neutral-50: #1A2130;
  --color-visit: #A78BFA; --color-visit-50: #221A38;
  --color-success-ink: #34D399; --color-warn-ink: #FBBF24; --color-danger-ink: #F87171;
  --color-info-ink: #8AB0FF;   --color-neutral-ink: #C2CCDB;
  --color-visit-ink: #A78BFA;
  --wiz-idbar-bg: rgba(91,140,255,0.14);   /* Product id-bar: soft blue on dark; light themes fall back to #E7F0FF */
  --wiz-idbar-border: rgba(91,140,255,0.34);
  --border-input-1: #2A344A; --color-value-tnum: #8AB0FF; --bg-input-1: #1A2130;
  --color-control: #1A2130; --color-control-border: #2A344A;   /* the light ground would blaze here */
  --color-sidebar-bg: #101d37; --color-sidebar-border: #232C3E; --color-sidebar-divider: rgba(255,255,255,0.06);
  --color-sidebar-heading: #F1F5F9; --color-sidebar-section: #55637A; --color-sidebar-text: #C2CCDB;
  --color-sidebar-hover-bg: rgba(255,255,255,0.05); --color-sidebar-hover-text: #F1F5F9;
  --color-sidebar-active-bg: #1D2637; --color-sidebar-active-text: #F1F5F9; --color-sidebar-accent: #5B8CFF;
  --color-header-bg: rgba(19,24,38,0.85);
  --color-search-bg: #1A2130;
  --color-scrollbar: #2A344A; --color-scrollbar-hover: #3A465E;
  --brand-gradient: linear-gradient(135deg,#3E6394,#5B8CFF);
  --bg-page-1: #0B0E14;   /* page surface — themed (was leaking :root's light value) */
  --bg-sidebar-1: #101d37;
}

/* ---- Elegant (dark, true neutral — the owner's #171717) ----
   Evening is a BLUE black: its page, cards, borders and rail all carry hue, so
   the whole theme reads navy. Elegant is the achromatic one. Every token that
   covers AREA — page, card, control, border, table head, selection ground, the
   rail — is R=G=B by construction, and hue survives only where it carries
   meaning: the accent, the five states, a visit. That is the difference between
   the two dark themes, and it is why they do not read as the same theme twice.

   #171717 is the owner's colour and it is the GROUND: page, body, page surface
   and the sidebar all sit on it, so the rail and the page are one continuous
   field and only the cards lift off it.

   ⚠ THE ACCENT IS BLUE, AND IT IS DELIBERATELY THE DARKER BLUE OF THE TWO
   THEMES. A grey accent is what a neutral theme wants, and it cannot work here:
   ten rules paint a hardcoded `color:#fff` on a --color-primary fill
   (.btn-primary, .btn-cta, .add-row-btn, .row-add, .facet-count, .step-num,
   .rb-tab__n …), so a light accent is white type on a near-white button. One
   token cannot be both a fill that carries white ink and an ink that reads on a
   dark card — the two demands pull opposite ways — so this block spends the base
   token on the FILL (#fff clears 4.5:1 on it, which evening's own accent never
   has) and puts the ink duty on -600 and -700, which is where links already
   live. The greys carry the theme; the blue only marks what can be pressed. */
[data-theme="elegant"] {
  color-scheme: dark;   /* native selects, date pickers and inputs render dark */
  --color-primary: #2F6FEB; --color-primary-600: #4C86F5; --color-primary-700: #8FB4FF; --color-primary-50: #383838;
  --color-secondary: #9A9A9A;
  --color-cta: #2F6FEB; --color-cta-600: #4C86F5; --color-cta-50: #383838;
  --color-bg: #171717; --color-body: #171717; --color-surface: #212121; --color-surface-alt: #1B1B1B;
  --color-border: #2F2F2F; --color-border-strong: #4A4A4A; --color-table-head: #272727;
  --color-text: #E6E6E6; --color-text-muted: #A8A8A8; --color-text-subtle: #8C8C8C;
  --color-success: #34D399; --color-success-50: #1D3229;
  --color-warn: #FBBF24; --color-warn-50: #322C1B;
  --color-danger: #F87171; --color-danger-50: #342220;
  /* Info is the one state that does NOT track the accent here, though it does in
     both other themes. This theme's accent is pitched dark enough to carry white
     button ink, and `.badge-info` / `.alert-info` write their text in
     --color-info on --color-info-50 — the dark blue reads 3.19:1 there. The
     lighter blue is the one that can be read on its own chip. */
  --color-info: #5B8CFF; --color-info-50: #232935;
  /* The five states, this theme's half. On a dark fill the base token IS the
     legible ink, so each -ink points back at it rather than darkening further. */
  --color-neutral: #8A8A8A; --color-neutral-50: #2A2A2A;
  --color-visit: #A78BFA; --color-visit-50: #29223B;
  --color-success-ink: #34D399; --color-warn-ink: #FBBF24; --color-danger-ink: #F87171;
  --color-info-ink: #8FB4FF;   --color-neutral-ink: #C6C6C6;
  --color-visit-ink: #A78BFA;
  --wiz-idbar-bg: rgba(255,255,255,0.10);   /* Product id-bar: a white veil, not a tint — hue is reserved for marks */
  --wiz-idbar-border: rgba(255,255,255,0.26);
  --border-input-1: #4A4A4A; --color-value-tnum: #FAFAFA; --bg-input-1: #2E2E2E;
  --color-control: #2E2E2E; --color-control-border: #4A4A4A;   /* the light ground would blaze here */
  --color-sidebar-bg: #171717; --color-sidebar-border: #262626; --color-sidebar-divider: rgba(255,255,255,0.06);
  --color-sidebar-heading: #F2F2F2; --color-sidebar-section: #8A8A8A; --color-sidebar-text: #C7C7C7;
  --color-sidebar-hover-bg: rgba(255,255,255,0.05); --color-sidebar-hover-text: #FFFFFF;
  /* The rail shares the page ground, so the selected item cannot be told by hue
     the way evening tells it — lightness is the whole signal. This is the
     block's own --color-primary-50, a step ABOVE --color-control so a lit nav
     item never reads as a field. */
  --color-sidebar-active-bg: #383838; --color-sidebar-active-text: #FFFFFF; --color-sidebar-accent: #4C86F5;
  --color-header-bg: rgba(33,33,33,0.85);
  --color-search-bg: #2E2E2E;
  --color-scrollbar: #4A4A4A; --color-scrollbar-hover: #5C5C5C;
  --brand-gradient: linear-gradient(135deg,#3F3F3F,#9C9C9C);
  --bg-page-1: #171717;
  --bg-sidebar-1: #171717;
}

/* -------------------------------------------------------------------------
   Base
   ------------------------------------------------------------------------- */
*,
*::before,
*::after { box-sizing: border-box; }

html, body { height: 100%; }

body {
  margin: 0;
  /* The APP SHELL owns the scroll: the sidebar is fixed and `.main` scrolls inside it,
     so the document itself must not. A body overflow propagates to the VIEWPORT, which
     is what makes this line load-bearing — and what makes it wrong for any page with no
     shell. The public showroom pages are ordinary documents; they opt back out with
     `.lp-doc` below. */
  overflow: hidden;
  font-family: 'Hanken Grotesk', system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  color: var(--color-text);
  background: var(--color-body);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* ---- Public documents (showroom skins, buyer pages) ---------------------
   No app shell, so nothing else can scroll: the document must. Without this the
   page clips at viewport height and simply will not scroll — silently, because
   `overflow: hidden` on body propagates to the viewport and reports no error.
   `visible` rather than `auto` restores the true default instead of nesting a
   second scroll container. The modal lock still wins: openModal() sets
   `body.style.overflow = 'hidden'` inline, and inline beats a class. */
body.lp-doc { overflow: visible; }

h1, h2, h3, h4, h5, h6, .font-mono {
  font-family: 'Schibsted Grotesk', system-ui, sans-serif;
  letter-spacing: -0.02em;
}

h1 { font-size: 22px; font-weight: 600; }
h2 { font-size: 17px; font-weight: 600; }
h3 { font-size: 15px; font-weight: 600; }

a { color: var(--color-primary-600); text-decoration: none; }
a:hover { color: var(--color-primary-700); text-decoration: none; }

button, [role="button"] { cursor: pointer; }

::-webkit-scrollbar         { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb   { background: var(--color-scrollbar); border-radius: 5px; border: 2px solid var(--color-bg); }
::-webkit-scrollbar-thumb:hover { background: var(--color-scrollbar-hover); }
::-webkit-scrollbar-track   { background: transparent; }

:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
  border-radius: 4px;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* -------------------------------------------------------------------------
   App shell
   ------------------------------------------------------------------------- */
.app {
  position: fixed;
  inset: 0;
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  /* The one row is the WINDOW, not the taller of the two things in it. Left
     implicit the row is `auto`, which sizes to content — and the sidebar's own
     content is a fixed ~780px of nav links, so on any window shorter than that
     the row stayed 780 and both columns with it. `.app` is fixed and clipped,
     so the surplus was not scrolled, it was cut: the bottom of `.page` — the
     pagination on every list, the footer on every page — sat below the window
     with nothing that could scroll to it. The same page filtered down to a few
     rows showed its pager fine, which is what made it read as a filter bug.
     `minmax(0, 1fr)` pins the row to the shell and lets both items shrink into
     it; each then scrolls its own overflow (`.sidebar-nav`, `.page`). */
  grid-template-rows: minmax(0, 1fr);
  overflow: hidden;
}

.app.collapsed { grid-template-columns: var(--sidebar-w-collapsed) 1fr; }

/* Collapsed sidebar — big icons only, labels hidden */
/* The mark draws at the size it has inside the full logo: 36px × 29.4152/30 of its viewBox. */
.app.collapsed .sidebar-brand { overflow: hidden; background: url('../img/Vava-Logomark.svg') center / auto 35.3px no-repeat; }
.app.collapsed .sidebar-brand img { display: none; }
.app.collapsed .sidebar-section { font-size: 0; padding: 10px 0 0; margin-bottom: 0; }
.app.collapsed .nav-link { justify-content: center; gap: 0; font-size: 0; padding: 9px 0; }
.app.collapsed .nav-link svg { width: 20px; height: 20px; }
.app.collapsed .nav-badge--alert,
.app.collapsed .nav-badge--new { right: -15px; }
.app.collapsed .nav-badge--alert,
.app.collapsed .nav-badge--new { width: 0; margin-left: 0; }   /* no label to follow; the edge tab stays */
.app.collapsed .sidebar-support .nav-badge--new,
.app.collapsed .nav-link[data-nav="concierge"] .nav-badge--alert { margin: 0; }   /* the right-end dot's margins would push the icon off centre */
.app.collapsed .sidebar-footer { justify-content: center; padding: 12px 0; }
.app.collapsed .sidebar-footer > div,
.app.collapsed .sidebar-footer > .icon-btn { display: none; }

/* Sidebar */
/* Stacked above the body column so a nav row's queue marker can hang past the
   sidebar's own right edge and land on the grey; `.page` comes later in the DOM
   and would otherwise paint over the part that crosses. See `.nav-badge--alert`. */
.sidebar {
  height: 100%;
  background: var(--color-sidebar-bg);
  color: var(--color-sidebar-text);
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--color-sidebar-border);
  position: relative;
  z-index: 1;
}

.sidebar-brand {
  display: flex;
  align-items: center;
  /* The wordmark is wider than the padding box can hold, so left-aligning it
     spent 16px on one side and 12px on the other. Centred, the rail reads even
     whatever the lockup's exact width. */
  justify-content: center;
  gap: 10px;
  padding: 14px 16px;
  border-bottom: 1px solid var(--color-sidebar-divider);
  color: var(--color-sidebar-heading);
  font-family: 'Hanken Grotesk', sans-serif;
  font-weight: 600;
  font-size: 15px;
  letter-spacing: -0.02em;
  height: var(--header-h);
}
.sidebar-brand img { height: 36px; width: auto; }

.sidebar-brand .brand-mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border-radius: 5px;
  background: var(--brand-gradient);
  color: #fff;
  flex-shrink: 0;
}

.sidebar-section {
  padding: 14px 12px 1px;
  margin-bottom: -4px;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--color-sidebar-section);
  font-family: 'Hanken Grotesk', sans-serif;
}

/* ---- Collapsible sections --------------------------------------------------
   A section heading folds its own menus away, so a nav with more items than the
   window has room for is still a nav you can see all of.

   THE LABEL IS THE BUTTON. No arrow, no chevron, no disclosure icon — the row
   already reads as a heading and the whole of it is the hit area, so an icon
   would only be a second thing to aim at. The rule above supplies the 10px
   uppercase type; this is what a <button> needs to stop looking like one. */
.sidebar-nav .sidebar-section {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  background: none;
  border: 0;
  text-align: left;
  cursor: pointer;
  transition: color 120ms;
}

/* No underline on a clickable label — the colour lift is the hover. */
.sidebar-nav .sidebar-section:hover,
.sidebar-nav .sidebar-section:focus-visible { color: var(--color-sidebar-heading); }

/* What a shut section is holding, so closing one is not the same as losing it.
   A plain count of the menus inside, unless one of them is waiting on you — then
   the alert bubble comes up to the heading, in the sidebar's own alert style
   rather than a second one invented here. */
.sidebar-section__count {
  font-size: 10px;
  letter-spacing: 0;
  opacity: 0.75;
}
.sidebar-section .nav-badge--alert { position: static; top: auto; right: auto; margin-left: 0; }
.sidebar-section .nav-badge--alert::after { display: none; }

/* The rail has no room for a heading to be read, let alone pressed: its labels
   are already `font-size: 0`. A section shut on the wide sidebar would take its
   icons with it and leave nothing to press to get them back, so the rail shows
   every group whatever the sections were left set to. */
.app.collapsed .nav-group[hidden] { display: block; }
.app.collapsed .sidebar-section__count,
.app.collapsed .sidebar-section .nav-badge--alert { display: none; }
/* In the rail a heading is a bare 10px gap: no click flips a saved section
   unseen, and no focus ring draws round an empty strip. */
.app.collapsed .sidebar-nav .sidebar-section { pointer-events: none; outline: none; }
/* A rail scrollbar takes 9px of a 64px rail and shoves every icon off the logo's
   centre line. The rail still scrolls by wheel and touch, it just draws no bar. */
.app.collapsed .sidebar-nav { scrollbar-width: none; }

/* The 20px right padding pulled back by an equal negative margin is what lets a
   queue marker cross the sidebar's edge. This is a scroll container, and a scroll
   container clips to its PADDING box on both axes — there is no scrolling one way
   while painting past the other. Widening the padding moves that clip 12px right,
   into the grey, while the negative margin keeps the laid-out width and every
   row's measurements exactly as they were. Change the two together or not at all. */
.sidebar-nav { padding: 4px 20px 16px 8px; margin-right: -12px; flex: 1; overflow-y: auto; }

.nav-link {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 10px;
  border-radius: 5px;
  color: var(--color-sidebar-text);
  font-size: 13px;
  margin-top: 2px;
  margin-bottom: 1px;
  transition: background 150ms ease, color 150ms ease;
  text-decoration: none;
  position: relative;
}

.nav-link:hover { background: var(--color-sidebar-hover-bg); color: var(--color-sidebar-hover-text); text-decoration: none; }
.nav-link.active {
  background: var(--color-sidebar-active-bg);
  color: var(--color-sidebar-active-text);
}
.nav-link.active::before {
  content: '';
  position: absolute;
  left: -3px;
  top: 4px;
  bottom: 4px;
  width: 3px;
  border-radius: 0 3px 3px 0;
  background: var(--color-sidebar-accent);
}

.nav-link svg { width: 16px; height: 16px; flex-shrink: 0; }

.nav-badge {
  margin-left: auto;
  font-size: 10px;
  background: var(--color-danger);
  color: #fff;
  padding: 1px 6px;
  border-radius: 5px;
  font-family: 'Hanken Grotesk', sans-serif;
}

/* Marker stripe for a queue that is waiting on someone. It answers the active
   row's left stripe, but louder: 5px, full row height, and pushed the whole 8px
   of the nav's own padding so it sits flush against the sidebar's right edge
   rather than floating inside it. It carries no number — a count bubble had to
   float over the label to avoid claiming width, and any label long enough ran
   under it. The row says something is waiting; the page it opens says how much.
   ⚠ Crossing that edge is not free: `.sidebar-nav` is a scroll container and
   clips to its padding box, so this only paints because that rule widens its
   right padding by 12px. Move the marker further right than -20px and the tail
   is cut. `.sidebar` carries the z-index that keeps the crossing half above the
   page. Three rules hold this up — read all three before retuning any of them.
   Colours stay the same in every theme so they never blend into a light sidebar.
   `--new` is that same stripe in light blue, for messages that have arrived in
   Live Support. A queue somebody still has to work and a reply already waiting
   to be read are not the same news, so they are not the same colour; the blue
   is hardcoded for the yellow's reason.
   Each is drawn as two marks from one span: the span itself is a 7px dot right
   after the label, and its ::after is a short tab (8px, half the row) on the
   edge. The ::after resolves against `.nav-link`, not the static span. */
.nav-badge--alert,
.nav-badge--new {
  width: 7px;
  height: 7px;
  min-width: 0;
  margin-left: -4px;
  padding: 0;
  border-radius: 50%;
  font-size: 0;
  flex-shrink: 0;
  pointer-events: none;
}
.nav-badge--alert::after,
.nav-badge--new::after {
  content: '';
  position: absolute;
  top: 25%;
  bottom: 25%;
  right: -15px;
  width: 8px;
  border-radius: 3px;
  background: inherit;
}
.nav-badge--alert { background: #F1C452; }
/* THE OLD #BFDBFE WAS NOT TOO DARK, IT WAS TOO PALE. Measured against the navy
   rail it carried 9.46:1 — MORE contrast than the amber's 8.17:1 — and still
   read as a washed grey sliver beside it, because at 87% lightness there is
   almost no colour left to see. This is the same blue with the lightness pulled
   back to where the amber sits: 8.07:1, within a hair of the alert stripe's own
   weight, so the two now read as equally present and differ only in hue. Do not
   "fix" this by darkening further — past sky-400 it loses the contrast that
   makes it work on the two near-black sidebars. */
.nav-badge--new   { background: #7DD3FC; }

.sidebar-footer {
  padding: 12px;
  border-top: 1px solid var(--color-sidebar-divider);
  display: flex;
  align-items: center;
  gap: 10px;
}

/* Support link pinned at the bottom of the sidebar (sits below the flex:1 nav) */
.sidebar-support { padding: 6px 8px 10px; border-top: 1px solid var(--color-sidebar-divider); }
.sidebar-support .nav-link { margin: 4px 0 0; }
/* Live Support's and VIP Concierge's dots sit at the row's right end, beside the edge tab, not after the label. */
.sidebar-support .nav-badge--new,
.nav-link[data-nav="concierge"] .nav-badge--alert { margin-left: auto; margin-right: -4px; }

.avatar {
  width: 32px; height: 32px;
  border-radius: 999px;
  background: var(--brand-gradient);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-weight: 600;
  font-size: 12px;
  font-family: 'Hanken Grotesk', sans-serif;
  flex-shrink: 0;
}

/* Main column. Element-qualified: `.main` is also the name of the primary line
   inside `.cell-stack` (span/a/td), and an unqualified rule turned every one of
   those into a column flex container — a badge or icon next to the text became
   a second, full-width row. */
main.main { display: flex; flex-direction: column; min-width: 0; overflow: hidden; }

/* Header */
.header {
  position: sticky;
  top: 0;
  z-index: 30;
  height: var(--header-h);
  background: var(--color-header-bg);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--color-border);
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 0 20px;
}

.breadcrumb {
  display: flex;
  align-items: center;
  gap: 6px;
  color: var(--color-text-muted);
  font-size: 13px;
  font-family: 'Hanken Grotesk', sans-serif;
}
.breadcrumb a { color: var(--color-text-muted); }
.breadcrumb .sep { color: #CBD5E1; }
.breadcrumb .current { color: var(--color-text); font-weight: 500; }

.header-search {
  flex: 1;
  max-width: 420px;
  position: relative;
}
.header-search input {
  width: 100%;
  padding: 7px 12px 7px 34px;
  border: 1px solid var(--color-border);
  background: var(--color-search-bg);
  border-radius: 5px;
  font: inherit;
  font-size: 13px;
  color: var(--color-text);
}
.header-search input:focus {
  outline: none;
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
}
.header-search svg {
  position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
  width: 16px; height: 16px; color: var(--color-text-subtle);
}

.header-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }

/* ---- Next up — the one thing to do, in the space the search vacated ---------
   Read as a sentence rather than a widget: an icon, what it is, who it is with,
   how long you have, and a way to close it off.

   Heat is time, on the platform's own warmth ramp. Cool while the event is further
   off than five minutes; orange inside five; red inside two, or once it has
   arrived. The border stays the hairline it always was in every state — the
   escalation is carried by the strip's own ground and by the tile, because a
   tinted panel reads from across a desk and a coloured outline does not. The words
   stay in the page's ink, which is the rule the ramp is written under.

   It lives in every header and is empty most of the day. Nothing within the
   look-ahead window — a quarter of an hour unless a setting says otherwise — and the
   bar shows nothing at all, on every page, the dashboard included: "nothing is
   coming" is worth saying with blank space rather than with a chip that says it.
   Inside the window the dashboard carries it the whole way down, and every other
   header waits until five minutes out, because a countdown riding along on
   eighty-five pages for a quarter of an hour is furniture.
   -------------------------------------------------------------------------- */
.nextup[hidden] { display: none; }
.nextup {
  position: relative;                    /* the arrived call hangs off it — see below */
  display: inline-flex; align-items: center; gap: 2px;
  height: 34px;
  margin-right: auto;                    /* takes the left, never stretches to fill it */
  max-width: 520px;
  border: 1px solid var(--color-border);
  border-radius: 5px;
  background: var(--heat-bg, var(--color-surface));
  transition: background 200ms ease, border-color 200ms ease;
}

.nextup__go {
  display: inline-flex; align-items: center; gap: 9px;
  min-width: 0;
  height: 100%;
  padding: 0 9px 0 7px;
  color: var(--color-text);
  font-size: 13px;
  text-decoration: none;
  border-radius: 4px;
}
.nextup__go:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 2px var(--color-primary);
}

/* The tile is where the ramp is loudest. Cool, it is a tint with a coloured glyph;
   warm or hot, it goes solid and the glyph turns out of it — so the same square
   escalates without changing size or position. */
.nextup__icn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; flex: none;
  border-radius: 4px;
  background: var(--heat-50, var(--color-info-50));
  color: var(--heat, var(--color-info));
  transition: background 200ms ease, color 200ms ease;
}
.nextup__icn svg { width: 14px; height: 14px; }

.nextup__what {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  color: var(--color-text-muted);
}
.nextup__what b { font-weight: 600; color: var(--color-text); }

/* Tabular figures, or the strip twitches sideways once a second as the digits
   change width. */
.nextup__when {
  flex: none;
  font-variant-numeric: tabular-nums;
  font-size: 12px; font-weight: 600;
  color: var(--heat, var(--color-text-muted));
  padding-left: 2px;
}

/* ---- the ramp, as time ----------------------------------------------------
   Cool leaves the strip on the panel's own white: nothing is happening yet, and a
   header that is tinted all day teaches people to stop seeing the tint. */
.nextup.is-cool  { --heat: var(--wm-contacted); --heat-50: var(--color-info-50); }
.nextup.is-clear { --heat: var(--color-text-subtle); --heat-50: var(--color-surface-alt); }

.nextup.is-warm {
  --heat: var(--wm-engaged);
  --heat-50: var(--wm-engaged);
  --heat-bg: var(--color-warn-50);
  border-color: var(--color-warn-50);
}
.nextup.is-warm .nextup__icn { color: #fff; }

.nextup.is-hot {
  --heat: var(--color-danger);
  --heat-50: var(--color-danger);
  --heat-bg: var(--color-danger-50);
  border-color: var(--color-danger-50);
}
.nextup.is-hot .nextup__icn { color: #fff; }

/* Arrived. The only state that earns movement — it means the thing is happening
   now, which is the one case where a header should catch someone looking
   elsewhere. */
.nextup.is-now .nextup__icn { animation: nextup-pulse 1.8s ease-in-out infinite; }
@keyframes nextup-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(208,106,90,0.45); }
  50%      { box-shadow: 0 0 0 5px rgba(208,106,90,0); }
}
@media (prefers-reduced-motion: reduce) {
  .nextup.is-now .nextup__icn { animation: none; }
  .nextup, .nextup__icn { transition: none; }
}

/* ---- Done -----------------------------------------------------------------
   Most calls get made before the clock runs out, so this cannot wait for the
   deadline to appear. It rides in on hover at any point in the countdown, and once
   the moment arrives it stays without being asked. Growing the strip costs nothing:
   it is anchored left and the action cluster is pinned right, so it expands into
   header space nothing else wants. */
.nextup__done {
  display: inline-flex; align-items: center; gap: 5px;
  flex: none;
  max-width: 0; padding: 0; margin-right: 0;
  overflow: hidden;
  height: 24px;
  border: 1px solid transparent;
  border-radius: 4px;
  background: var(--color-surface);
  color: var(--color-text-muted);
  font: inherit; font-size: 12px; font-weight: 500;
  white-space: nowrap;
  opacity: 0;
  cursor: pointer;
  transition: max-width 160ms ease, opacity 120ms ease, padding 160ms ease,
              margin 160ms ease, color 120ms ease, border-color 120ms ease;
}
.nextup__done svg { width: 13px; height: 13px; flex: none; }

.nextup:hover .nextup__done,
.nextup:focus-within .nextup__done,
.nextup.is-now .nextup__done {
  max-width: 110px;
  padding: 0 9px;
  margin-right: 5px;
  opacity: 1;
  border-color: var(--color-border-strong);
}
.nextup__done:hover { color: var(--color-success); border-color: var(--color-success); }
.nextup__done:focus-visible {
  outline: none;
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
}
.nextup__done[hidden] { display: none; }

/* ---- The call, arrived ----------------------------------------------------
   Once the clock says now the strip grows a second storey: who is on the line, how
   to reach him, and what is open in his room — the things a rep reaches for in the
   first ten seconds, on the header that told him to make the call. It hangs below
   the strip rather than pushing the page: the header is a fixed row, and a page
   that jumps as a call arrives is worse than a card over its top edge. The strip is
   the card's header, and the moment the card opens it goes SOLID — the ramp's
   loudest form, the one the tile already takes at warm — with white ink on it,
   while the body sits on the ramp's tint below, the two inside one 2px frame of the
   ramp's own red. Two grounds, not one: a strip and card in the same tint read as a
   slab, and on the panel's white the card went unseen — a card that arrives on its
   own has to be seen before it is read. It stays until Done or Ignore: a call in
   progress does not time out. Sized from the strip's own width, and the
   floor that keeps the words whole is the STRIP's, not the card's — a floor on the
   card alone would let it run past a narrow strip's edge and open the seam. The
   strip grows into header space nothing else wants, and the sentence takes the
   room so Done keeps its distance from the edge. Only an item that carries a call
   has one. */
.nextup__call {
  display: none;
  position: absolute;
  /* -2px, not -1px: the frame is 2px here, and the card's outer edge has to sit on
     the strip's outer edge or the seam opens. No top edge of its own — the solid
     strip above is the seam. */
  top: 100%; left: -2px; right: -2px;
  min-width: 0;
  padding: 12px 14px 10px;
  border: 2px solid var(--heat, var(--color-border));
  border-top: 0;
  border-radius: 0 0 5px 5px;
  background: var(--heat-bg, var(--color-surface));
  box-shadow: 0 18px 36px -8px rgb(15 23 42 / 0.32), 0 6px 12px -6px rgb(15 23 42 / 0.18);
  font-size: 12px; line-height: 1.45;
  color: var(--color-text-muted);
  text-align: left;
  cursor: default;
}
.nextup.is-now.has-call .nextup__call { display: block; animation: nextup-drop 260ms ease-out; }

/* A STAGED ARRIVAL. The strip lands on its own and the card drops a beat later,
   so the card is watched arriving instead of being there the moment the strip
   is. The bar is identical either side of it — only the card is held back — and
   the page that stages the arrival drops this class when the beat is up. */
.nextup.is-now.has-call.is-collapsed .nextup__call { display: none; }

/* THE CARD OUTRANKS THE DOCK. When the strip takes the bar seat the chat dock
   stands down to the top-left corner just under the bar (js/client-chat.js) —
   the same corner the card drops into, and the dock's bubbles were painting over
   its left edge. Nothing given to the card fixes that: `.header` is sticky with a
   z-index, so it is a stacking context and everything inside it is stuck at 30,
   under the dock's fixed 190/191. The header itself has to rise, and only while a
   call is on it. Safe against the bar seat: the dock is only in the bar when the
   strip is not, and this card cannot exist without it.

   AND IT OUTRANKS AN OPEN PANEL. 205, not the 195 this began at: a slide-over's
   scrim is `.modal-overlay`'s 200, and at 195 it laid its dimming over the strip
   and swallowed Dismiss and Assist with it. A client walking in is an INTERRUPT
   — it is the one thing on the page that did not wait to be asked for, and a
   panel a rep opened themselves is not allowed to grey it out. Still under the
   toast host's 300 and the confirm dialog's 1990+: a question that must be
   answered before anything else still comes over the top of this.

   `:not([hidden])` is load-bearing, not belt and braces: the strip keeps its
   state classes while it is put away — app.js hides it by the attribute alone,
   and Stock's walk-in does the same on Dismiss — so without it the first call of
   the session would leave this header lifted over the dock for good. */
.header:has(.nextup.is-now.has-call:not([hidden])) { z-index: 205; }

/* The strip, solid. Same 34px — the box is border-box, so the 2px frame comes out
   of the inside, and the 24px tile and chips still centre. Everything that was ink
   on a tint turns white on the red; the tile inverts so it stays a tile; Done and
   Ignore are already white chips and read as they are. */
.nextup.is-now.has-call {
  min-width: 300px; border-bottom-left-radius: 0; border-bottom-right-radius: 0;
  border-width: 2px;
  border-color: var(--heat);
  background: var(--heat);
}
.nextup.is-now.has-call .nextup__what   { color: rgba(255,255,255,0.82); }
.nextup.is-now.has-call .nextup__what b { color: #fff; }
.nextup.is-now.has-call .nextup__when   { color: #fff; }
.nextup.is-now.has-call .nextup__icn {
  background: #fff; color: var(--heat);
  animation-name: nextup-pulse-on-red;             /* the red ring vanished on red; this one is white */
}
@keyframes nextup-pulse-on-red {
  0%, 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0.6); }
  50%      { box-shadow: 0 0 0 5px rgba(255,255,255,0); }
}
@keyframes nextup-drop {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .nextup.is-now.has-call .nextup__call { animation: none; }
  .nextup.is-now.has-call .nextup__icn  { animation: none; }   /* outranks the is-now rule above, so it must be said again here */
}
.nextup.is-now.has-call .nextup__go { flex-grow: 1; }

/* A face before the name. Initials in the platform's cobalt, not the ramp's red —
   the strip already says the call is urgent, and the person is not a status. */
.nextup__head { display: flex; align-items: center; gap: 10px; }
.nextup__ava {
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; flex: none;
  border-radius: 50%;
  background: var(--color-primary-50);
  color: var(--color-primary-700);
  font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
}
.nextup__who { display: block; font-size: 14px; font-weight: 600; line-height: 1.25; color: var(--color-text); }
.nextup__sub { display: block; margin-top: 1px; }
.nextup__reach { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 10px; }
.nextup__line { display: inline-flex; align-items: center; gap: 6px; color: var(--color-text); }
.nextup__line svg { width: 13px; height: 13px; flex: none; color: var(--color-text-subtle); }

/* The requests are a small list under a caption, each on its own hairline — the
   page's grey, which holds on the tint. The ask stays muted and
   the state comes up to ink, so the eye lands on where each one stands. */
.nextup__cap {
  display: block; margin-top: 12px; padding-top: 10px;
  border-top: 1px solid var(--color-border);
  font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--color-text-subtle);
}
.nextup__ctx { list-style: none; margin: 2px 0 0; padding: 0; }
.nextup__ctx li { display: flex; flex-direction: column; gap: 1px; padding: 7px 0; }
.nextup__ctx li + li { border-top: 1px solid var(--color-border); }
.nextup__ctx li:last-child { padding-bottom: 2px; }
.nextup__ctx b { font-size: 13px; font-weight: 600; color: var(--color-text); }
.nextup__state { color: var(--color-text); font-weight: 500; }

/* The name and each request are links out — to his record, to the inbox or the
   quotes or the calendar that holds the thing. They keep their own ink and weight
   and wear only the ramp's underline, so the card still reads as a card; hover
   turns them the ramp's red. All four name the same window ("alert"), so a rep who
   clicks twice gets one tab that moves, not a row of tabs. */
.nextup__link {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: color-mix(in oklab, var(--heat) 45%, transparent);
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
  border-radius: 3px;
}
.nextup__link:hover { color: var(--heat); text-decoration-color: var(--heat); }
.nextup__link:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* Ignore wears Done's clothes and shows only with the card. Unlike Done it does not
   ride in on hover — there is nothing to ignore until the moment arrives — and it is
   display:none rather than collapsed, so the keyboard never lands on a button it
   cannot see. Its hover firms up rather than colours: dropping a call is not an
   answer the way finishing one is, so it gets no green and no red. The 3px stands
   where Done's 5px does — with the strip's own 2px gap it puts the two buttons 5px
   apart, the distance Done keeps from the edge. */
.nextup__ignore { display: none; }
.nextup.is-now.has-call .nextup__ignore { display: inline-flex; margin-right: 3px; }
.nextup__ignore:hover { color: var(--color-text); border-color: var(--color-control-border); }

/* The header holds the search, the bell, help and the user before it runs out of
   room. The sentence loses its subject before it loses its clock — knowing
   something is due in four minutes is worth more than knowing what it is. */
@media (max-width: 1100px) { .nextup { max-width: 300px; } }
@media (max-width: 820px)  {
  .nextup__what { display: none; }
  /* The buttons give up their words the way the collapsed sidebar's links do — the
     name survives as the text — and the arrived strip drops to the width the card's
     own words need, which is the email line, not 300px. */
  .nextup.is-now .nextup__done { padding: 0 6px; gap: 0; font-size: 0; }
  .nextup.is-now.has-call { min-width: 200px; }
}

/* ---- Global search, collapsed ----------------------------------------------
   The header used to give its widest element to its least-used control: a 420px
   box for a search people reach for occasionally, sitting beside a table search
   people use all day. At rest it is now a button in the action cluster; open, it
   is the same .header-search field it always was, so Enter still reaches the
   handler in app.js and still lands on the results page.

   Not a bare magnifier. An icon alone next to the bell reads as one more status
   glyph and stops being seen; the word is what keeps it findable, and the key hint
   teaches the shortcut to the people who would use it most.

   It grows leftward at no cost, because .header-actions is pinned right by
   margin-left:auto — widening its first child moves the cluster, and the page
   under the header never reflows. */
.hsearch { display: flex; align-items: center; }

.hsearch__btn {
  display: inline-flex; align-items: center; gap: 7px;
  height: 34px; padding: 0 10px;
  border: 1px solid var(--color-border);
  border-radius: 5px;
  background: var(--color-search-bg);
  color: var(--color-text-muted);
  font: inherit; font-size: 13px;
  cursor: pointer;
  transition: border-color 120ms ease, color 120ms ease;
}
.hsearch__btn:hover { border-color: var(--color-border-strong); color: var(--color-text); }
.hsearch__btn:focus-visible {
  outline: none;
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
}
.hsearch__btn svg { width: 16px; height: 16px; flex: none; color: var(--color-text-subtle); }
.hsearch__word { font-size: 13px; }

/* The key hint is an aside to the word, not a second label — it goes first when
   the header runs out of room. */
.hsearch__key {
  font-size: 11px; line-height: 1;
  padding: 2px 5px;
  border: 1px solid var(--color-border);
  border-radius: 4px;
  background: var(--color-surface);
  color: var(--color-text-subtle);
}
/* 30% larger than the shared .kbd (10px, 1px 5px, 4px radius), owner
   2026-10-12; the compound selector outranks .kbd, which sits later. */
.hsearch__key.kbd { font-size: 13px; padding: 2px 7px; border-radius: 5px; }

/* Measured in its own width, not a flex basis, so the transition has two numbers
   to run between. 300px holds a client name and a product name uncut. */
.hsearch__field {
  width: 0;
  max-width: none;
  flex: none;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  transition: width 160ms ease, opacity 120ms ease;
}
.hsearch.is-open .hsearch__field { width: 300px; opacity: 1; pointer-events: auto; }
.hsearch.is-open .hsearch__btn { display: none; }

/* Out of the layout rather than hidden with display:none — a hidden label is not read. */
.hsearch__sr {
  position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
  white-space: nowrap;
}

/* A header that changes width under the cursor is the one thing that would make
   this worse than the box it replaces. */
@media (prefers-reduced-motion: reduce) {
  .hsearch__field { transition: none; }
}

/* Narrow screens never had room for the wide box either, and here the bare
   magnifier is the right call: nothing else is competing for the eye. */
@media (max-width: 720px) {
  .hsearch__word,
  .hsearch__key { display: none; }
  .hsearch__btn { padding: 0 8px; }
  .hsearch.is-open .hsearch__field { width: 190px; }
}
/* ---- Language — the reader's own setting, in whatever header holds it -------
   Deliberately the same quiet box as the showroom's ship-to control and no
   louder: it states a fact the reader already knows and only rarely corrects.
   It lives here rather than in showroom-default.css because the headers that
   host it — three showroom layouts, the older showroom generation, the share
   strips, the confirm page — do not all load that sheet, and a control that
   loses its border on four pages is worse than one defined a little early.
   It carries the flag and the two letters and no `Language` word: a header has
   room for the answer, not for the question. */
.lp-lang { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; font:inherit; font-size:13px; line-height:1.1; color:var(--color-text); background:var(--color-surface); border:1px solid var(--color-border-strong); border-radius:5px; cursor:pointer; transition:border-color 120ms, color 120ms; }
.lp-lang:hover { border-color:var(--color-primary); color:var(--color-primary-700); }
.lp-lang svg { width:15px; height:15px; flex:none; color:var(--color-text-muted); }
.lp-lang .c { font-weight:600; }

/* A buyer-facing header opens with the supplier's own mark and closes with the Vava
   product mark the shop runs on: the goods are theirs, the shop is ours. The product
   mark is two-colour, so a dark bar swaps it for its own dark-background file rather
   than inverting it — an invert would throw the second colour away. */
.sf-productmark { height: 26px; width: auto; flex: none; margin-left: 12px; }

.icon-btn {
  width: 34px; height: 34px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 5px;
  border: 1px solid transparent;
  background: transparent;
  color: var(--color-text-muted);
  transition: background 150ms, color 150ms, border-color 150ms;
  position: relative;
}
.icon-btn:hover { background: var(--color-primary-50); color: var(--color-primary-700); }
.icon-btn svg { width: 18px; height: 18px; }
.icon-btn .dot {
  position: absolute; top: 6px; right: 6px;
  width: 8px; height: 8px; border-radius: 999px;
  background: var(--color-danger);
  border: 2px solid #fff;
}

/* Page */
.page { padding: 20px 24px 40px; background: #f7f7f7; flex: 1; min-height: 0; overflow-y: auto; }

/* Page footer — one grey line, always parked at the bottom of the page: the page
   becomes a flex column so `margin-top:auto` pushes the footer down when the content
   is short, and children keep their natural height (`flex: none`) so a tall page
   still scrolls instead of squashing. The footer box drops the page's bottom padding and
   takes the same 56px height as the sidebar's Support block, so the copyright line sits
   on the Support item's line. */
.page:has(> .page-footer) { display: flex; flex-direction: column; padding-bottom: 0; }
.page:has(> .page-footer) > * { flex: none; }
.page-footer {
  margin-top: auto;
  justify-content: flex-end;
  align-items: center;
  min-height: 56px;
  padding: 0;
  border-top: 1px solid var(--color-border);
  color: var(--color-text-subtle);
  font-size: 10px;
}

/* Two columns, one row: the page's identity on the left, its actions on the right.
   The actions ride the MAIN label — the title — and not the second line under it.
   flex-end used to hang them off the bottom of the left column, which put them level
   with the subtitle or the meta line; centring lands them on the title on every page,
   whether the left column is title + subtitle or breadcrumb + title + meta.
   And the header does not wrap: wrapped, the actions drop to a full-width strip under
   the title and stop reading as a second column at all. Below 1200px there is no room
   for both, so the wrap comes back and the drop is deliberate. */
/* min-height pins what follows. The row is as tall as its tallest action, and the
   actions differ page to page — the 34px export images make it 36, a page with only
   36px-tall outline buttons makes it 33 — so everything below started 1-3px higher on
   some pages than others. 36px is the tallest the row ever is; a shorter action set
   now centres inside it instead of lifting the page. */
.page-header {
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  margin-bottom: 18px; flex-wrap: nowrap;
  min-height: 36px;
}
/* The left column is what gives when the row is tight — the actions never squash. */
.page-header > div:first-child { min-width: 0; }
/* The page search stands on the title line, to the right of the name, at the 320px it
   had on the page toolbar. Where the line is too short for both it drops under the
   title rather than being squeezed to nothing; the column's floor makes the header
   wrap its actions first. */
.page-header > div.page-header__title {
  display: flex; align-items: center; gap: 8px 14px; flex: 1; flex-wrap: wrap;
  min-width: min(100%, 280px);
}
.page-header__title .page-title { flex: none; }
.page-header__title .search { flex: 1 1 200px; min-width: 0; max-width: 320px; }
/* A crumb or subtitle keeps a line of its own, above or under the title and its search. */
.page-header__title > .page-subtitle { flex-basis: 100%; margin: 0; }
/* A filter button travels with its search, close against it: the pair wraps under the
   title as one piece, and the field gives up width before the button leaves its side. */
.page-header__find { display: flex; align-items: center; gap: 6px; flex: 1 1 248px; min-width: 0; max-width: 360px; }
.page-title { margin: 0; }
.page-subtitle {
  margin: 4px 0 0;
  color: var(--color-text-muted);
  font-size: 13px;
}
.page-actions { display: flex; gap: 8px; align-items: stretch; flex: none; }

@media (max-width: 1200px) {
  .page-header { flex-wrap: wrap; }
}
/* A dropdown in the action row is a wrapper round a button. The row stretches its
   children, so the WRAPPER matches its siblings — but the button inside it keeps its
   own height and hangs short, which is why a ⋯ menu sat a pixel or two above the row's
   baseline while the gear beside it did not. Making every wrapper a flex container is
   the general form of the settings-gear rule below; the menu is absolutely positioned,
   so it is not a flex item and its placement is unchanged. */
.page-actions > .dropdown { display: flex; }
/* let the settings-gear button fill the row height so it matches sibling buttons */
.page-settings { display: flex; }

/* -------------------------------------------------------------------------
   Cards / KPI
   ------------------------------------------------------------------------- */
.card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
}

.card-header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid var(--color-border);
}
.card-header h2,
.card-header h3 { margin: 0; }
.card-header .sub {
  color: var(--color-text-subtle);
  font-size: 12px;
  margin-top: 2px;
}
.card-body { padding: 16px; }
.card-footer {
  padding: 10px 16px;
  border-top: 1px solid var(--color-border);
  display: flex; align-items: center; justify-content: space-between;
  /* A minimum between the footer's own lines. Two of them still sit at opposite ends;
     it is when the page controls claim the right edge that the rest need a gap not to
     run together into one sentence. */
  gap: 14px;
  font-size: 12px; color: var(--color-text-subtle);
}

/* Selectable product-type tiles (New Product wizard) — lift them off the white
   parent card with a stronger border + drop shadow, and a hover affordance. */
.type-grid > button.card {
  border-color: var(--color-border-strong);
  box-shadow: var(--shadow-md);
  transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease;
}
.type-grid > button.card:hover {
  border-color: var(--color-primary);
  box-shadow: var(--shadow-lg);
  transform: translateY(-1px);
}
/* Keep the tiles in scale with the later wizard steps — tighter box, smaller icon,
   balanced rows. Column count = tile count when ≤ 4, otherwise ceil(count / 2) so the
   tiles split into two even rows — 6 tiles → 3 per row (set via grid-3 in the markup). */
.type-grid { gap: 12px; }
.type-grid > button.card .card-body { padding: 12px; }
.type-grid > button.card .thumb { width: 45px; height: 45px; }
.type-grid > button.card .thumb svg { width: 26px; height: 26px; }
/* Icon and type name share one row — name sits to the right of its own icon. */
.type-grid > button.card .type-head { display: flex; align-items: center; gap: 10px; }
.type-grid > button.card .divider { margin: 9px 0; }
/* Label and value sit tight against each other, centred as a pair — labels
   right-aligned, values left-aligned, so all three rows line up. */
.type-grid .type-spec {
  display: grid;
  grid-template-columns: max-content minmax(0, max-content);
  justify-content: center;
  gap: 6px;
}
.type-grid .type-spec > .subtle { text-align: right; }

.kpi {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  /* Fluid: full padding on a wide screen, tighter as the row narrows, so four
     cards keep their row instead of one dropping below. */
  padding: clamp(10px, 0.5vw + 5px, 14px) clamp(11px, 0.7vw + 4px, 16px);
  display: flex; flex-direction: column; gap: 4px;
  position: relative;
  overflow: hidden;
  transition: box-shadow 200ms, transform 200ms;
}
.kpi:hover { box-shadow: var(--shadow-md); }
.kpi .label {
  font-size: 11px;
  font-family: 'Hanken Grotesk', sans-serif;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-text-subtle);
}
.kpi .value {
  font-family: 'Hanken Grotesk', sans-serif;
  /* Scales down with the viewport so a narrow row costs width, not a wrap. */
  font-size: clamp(19px, 0.6vw + 12px, 24px);
  font-weight: 600;
  color: var(--color-text);
  line-height: 1.1;
}
.kpi .meta {
  display: flex; align-items: center; gap: 6px;
  font-size: 12px; color: var(--color-text-muted);
}
/* A KPI figure that carries a unit or qualifier (e.g. Stock: 112 In) — number
   first, label after, tight together rather than spread across the card. */
.kpi-part { display: inline-flex; align-items: baseline; gap: 5px; }
.kpi-part-label { font-size: 13px; color: var(--color-text-muted); }
/* Two figures in one KPI, kept together on the left — never spread across the
   card, where the second reads as the next card's (owner, 2026-10-07). A count
   and what it is worth (19 · $220.0K): the count leads, the second figure
   follows on its baseline a size smaller and lighter. The same count over two
   windows (each named under its own number in meta type) keeps both at full
   size, a wider gap apart. */
.kpi-split { display: grid; grid-template-columns: auto auto; justify-content: start; align-items: baseline; column-gap: 10px; row-gap: 4px; }
.kpi-split:has(> .meta) { column-gap: 24px; }
.kpi-split > .value:where(:nth-child(2):last-child) { font-size: clamp(15px, 0.4vw + 10px, 18px); font-weight: 500; }
/* Safety net: KPI meta icons must never fall back to Lucide's 24px default. */
.kpi .meta svg { width: 14px; height: 14px; flex-shrink: 0; }
.kpi .meta .trend-up,
.kpi .meta .trend-down,
.kpi .meta .trend-flat { display: inline-flex; align-items: center; gap: 3px; }

/* KPI summary — a page's KPI cards folded behind a glance bar. Collapsed by
   default: the glance answers the question most visits have, and the cards are one
   click away for the breakdown, so the table starts near the top of the page.
   Used by Stock, Catalog, Pricing and Warehouses; the toggle lives in js/app.js. */
.kpi-summary { margin-bottom: var(--page-gap); }
/* A tab strip below needs a wider standoff than a card does. An attached strip reads
   as the top edge of the panel it controls, so at --page-gap the summary bar, the tabs
   and the table below them stack into one undifferentiated band — the summary looks
   like a row of the table. 25px separates the two groups.

   It is stated about ANY box that ends above a strip, not about the summary bar alone,
   because the box differs page to page and each one was spending its own number: the
   summary gave 25, the CRM's KPI grid 16, Concierge's note band and the Showroom's
   summary 14, a client record's block of cards nothing at all. One rule, and the joint
   is the same on every page — including pages whose box has not been written yet.
   Margins do not collapse here (a `.page` with a footer is a flex column), so the
   standoff belongs to exactly one side of the joint: the box above.

   "The box above" is the LAST ONE STILL SHOWING, which is not the same as the previous
   sibling: Concierge keeps three panels between its note band and its board — stale,
   empty, loading — all hidden until the state that wants them. So each selector reads
   "a strip comes later, and nothing visible stands between us": the `~` finds the strip,
   the `:not(:has(…))` rules out anyone closer to it. Hidden panels take no space and so
   do not break the joint. The second selector is the same sentence for a strip that
   OPENS a wrapper rather than following one — Stock's studio, Concierge's board.

   `.page-header` is excluded on purpose: a strip that leads the page has no box above
   it, and the header's own 18px is that joint.

   A box the other licence owns (`data-licence-only`, hidden by the rule further down)
   takes no space either, so it counts as hidden too — Deliveries' By Road keeps one
   summary per licence, and the supplier's lost its 25 to the distributor's unseen one. */
:not(.page-header):has(~ .tabs--attached):not(:has(~ :not([hidden], html:not([data-licence="distributor"]) [data-licence-only="distributor"], html[data-licence="distributor"] [data-licence-only="supplier"]) ~ .tabs--attached)),
:not(.page-header):has(~ * > .tabs--attached:first-child):not(:has(~ :not([hidden], html:not([data-licence="distributor"]) [data-licence-only="distributor"], html[data-licence="distributor"] [data-licence-only="supplier"]) ~ * > .tabs--attached:first-child)) {
  margin-bottom: 25px;
}
/* A wrapper standing above a strip is a flex item on any page with a footer, and a flex
   item is its own formatting context — the summary's own 14px cannot collapse into the
   wrapper's standoff, so it lands on top of it and the joint comes out 14 too wide.
   The Showroom is the one page that reaches its strip this way; its summary closes
   the wrapper now that its filter rows sit inside their tabs. */
:has(~ .tabs--attached) > :is(.kpi-summary, .toolbar--page):last-child { margin-bottom: 0; }
/* The cards carry no bottom margin of their own. They used to carry 16 or 18 inline,
   which landed under the summary's own 14 and put 30px between the numbers and the
   table on twelve pages while eleven others sat at 14. */
.kpi-summary__cards { margin-bottom: 0; }
.kpi-summary__bar {
  width: 100%;
  display: flex; align-items: stretch; justify-content: space-between; gap: 12px;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  /* The figures carry the bar's padding themselves (see .kpi-glance-item), so the
     hairline between two of them can run the bar's full height. */
  padding: 0;
  cursor: pointer;
  font-family: inherit;
}
.kpi-summary__bar:hover { border-color: var(--color-border-strong); }
/* THE FIGURE LEADS ON A LINE OF ITS OWN and its words sit under it, the shape the
   quote's figure bar gives every reading (quotes.css, .req-stats--stack). One line
   of 13px ran "616 total items · 463 available · 135 reserved" as a sentence the
   eye had to parse; stacked, each figure is a column with the number on top in the
   value's own size and the caption in the label's, and a hairline between columns
   does the separating instead of a gap. The `<strong>` is the value line — it holds
   the figure and, where the reading is a status, the dot before it; whatever text
   follows the `<strong>` is the caption. */
.kpi-summary__glance { display: flex; align-items: stretch; gap: 0; flex-wrap: wrap; font-size: 12px; color: var(--color-text-muted); text-align: left; }
.kpi-glance-item { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; padding: 8px 14px; }
.kpi-glance-item + .kpi-glance-item { border-left: 1px solid var(--color-border); }
.kpi-glance-item strong { display: inline-flex; align-items: center; gap: 6px; color: var(--color-text); font-size: 15px; font-weight: 600; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
.kpi-glance-item svg { width: 14px; height: 14px; color: var(--color-warn); }
/* No "Show / Hide Details" link: the eye js/app.js builds into the page header,
   beside the video button, is the toggle. The markup's link is left unshown. */
.kpi-summary__cta { display: none; }
.kpi-summary__cards { margin-top: 12px; }
.kpi-summary[data-kpis-collapsed] .kpi-summary__cards { display: none; }

/* ---- TWO STATES OF ONE READOUT, NEVER BOTH AT ONCE ------------------------
   Owner ruling 2026-09-10. The glance is the first four cards said again —
   same figures, same captions, in the same order — so an expanded bar printed
   each of them twice, forty pixels apart, and the toggle read as "add more of
   what you are already looking at". It is a ZOOM, not an addition: the bar IS
   the cards, folded. The figures therefore belong to the folded state alone.

   Nothing of the bar survives into the open state. The way back is the eye in
   the page header, so the cards take the bar's place exactly and the gap under
   the row above is the same folded or open. (A link kept in the readout needed
   a line of its own, ~26px, and no corner of the cards was free on every page.) */
.kpi-summary:not([data-kpis-collapsed]) .kpi-summary__bar { display: none; }
.kpi-summary:not([data-kpis-collapsed]) .kpi-summary__cards { margin-top: 0; }

.trend-up   { color: var(--color-success); }
.trend-down { color: var(--color-danger); }
.trend-flat { color: var(--color-text-subtle); }

/* Compact spec pairs — label and value read as one unit instead of being spread to
   opposite edges of a row. `.spec-strip` = one read-only panel of behaviour defaults,
   split by rules — deliberately NOT separate chips, which read as selectable filters;
   `.spec-list` = two-up grid of tight label → value pairs (summaries). */
.section-h { margin: 0 0 10px; }
.spec-strip {
  display: flex; flex-wrap: wrap;
  background: var(--color-table-head);
  border: 1px solid var(--color-border-strong);
  border-radius: 7px;
  overflow: hidden;
}
.spec-strip .spec {
  display: inline-flex; align-items: baseline; gap: 7px;
  padding: 9px 15px;
  border-right: 1px solid var(--color-border-strong);
}
.spec-strip .spec:last-child { border-right: 0; }
.spec-k { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-text-muted); white-space: nowrap; }
.spec-v { font-size: 14px; font-weight: 700; color: var(--color-text); }
.spec-note { margin: 10px 0 0; font-size: 12px; line-height: 1.5; color: var(--color-text-subtle); }
.spec-list {
  display: grid; margin: 0;
  grid-template-columns: max-content minmax(0, 1fr) max-content minmax(0, 1fr);
  gap: 9px 10px; align-items: baseline;
}
.spec-list dt { font-size: 12px; color: var(--color-text-muted); text-align: right; }
.spec-list dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--color-text); }
@media (max-width: 1100px) {
  .spec-list { grid-template-columns: max-content minmax(0, 1fr); }
}

/* Product wizard — the one-line note under a control that says what the answer decides
   (Priced by, the packaging conversions). The panel primitives that used to sit here
   went with Step 3 when its Display and Selection groups were dropped and Priced by
   moved onto Step 2 as a plain card. */
.opt-hint { margin: 6px 0 0; font-size: 12px; line-height: 1.45; color: var(--color-text-subtle); }

/* -------------------------------------------------------------------------
   Buttons
   ------------------------------------------------------------------------- */
.btn {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 7px 12px;
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  border-radius: 5px;
  border: 1px solid transparent;
  background: transparent;
  color: var(--color-text);
  cursor: pointer;
  transition: background 150ms ease, color 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
  white-space: nowrap;
}
.btn svg { width: 16px; height: 16px; }

.btn-primary {
  background: var(--color-primary);
  color: #fff;
  box-shadow: 0 1px 2px rgba(29,78,216,0.20);
}
.btn-primary:hover { background: var(--color-primary-600); }
.btn-primary:focus-visible { box-shadow: 0 0 0 3px rgba(37,99,235,0.30); }

.btn-cta {
  background: var(--color-cta);
  color: #fff;
}
.btn-cta:hover { background: var(--color-cta-600); }

/* The secondary button carries a FACE, not just a border. On `--color-surface` it
   used to be `--color-surface` itself — a white chip on a white card, separated by
   one hairline — so it read as absent rather than quiet. `--color-surface-alt` is
   the step every theme already defines away from its card colour (and in dark it
   steps DOWN, #0E121C under #131826), so the tint works in both directions without
   a per-theme override. The hairline shadow is what carries the button when it
   sits ON a `--color-surface-alt` strip (e.g. `.sf-dns`), where the tint
   has nothing to separate from. Hover brightens to the card colour: the button
   lifts toward the surface instead of sinking into it. */
.btn-outline {
  border-color: var(--color-border-strong);
  color: var(--color-text);
  background: var(--color-surface-alt);
  box-shadow: 0 1px 1px rgba(16,24,40,0.05);
}
.btn-outline:hover {
  border-color: var(--color-primary);
  color: var(--color-primary-700);
  background: var(--color-surface);
}

/* A bordered button sitting directly on a white panel. .btn-outline's own ground is
   --color-surface-alt, which reads as the lighter thing on a card that is already
   tinted (a template tile, the page ground) but is white-on-white on a card header —
   the button stops being a button. Here the ground drops to the control ground so the
   box is a box, and hover lifts it toward white: the inversion memory.md 13 describes. */
.btn-sunk { background: var(--color-control); border-color: var(--color-control-border); }
.btn-sunk:hover { background: var(--color-surface-alt); border-color: var(--color-primary); }

.btn-ghost { color: var(--color-text-muted); }
.btn-ghost:hover { background: var(--color-primary-50); color: var(--color-primary-700); }

.btn-danger {
  background: var(--color-danger);
  color: #fff;
}
.btn-danger:hover { background: #DC2626; }

/* A destructive action that has to sit beside an ordinary one — Delete Client next to Edit
   Client in a card header. The button keeps its outline so the header stays a row of equals;
   only the glyph goes red. A full red button next to the blue primary turns the header into
   an alarm, and the header is not where the warning belongs — the confirm dialog is. Red is
   reserved for what does not come back, which is why Archive on the catalog pages is not red. */
.btn-danger-icon svg { color: var(--color-danger); }

/* A document download, wherever a document is listed (owner, 2026-10-13: "I want
   that download icon to be very visible"). Always an outline button that says
   Download (or names the file), the arrow in the primary blue, never a bare ghost
   icon. Data exports and import templates are not documents and keep their own
   buttons. Same shape as .btn-danger-icon: only the glyph takes the colour. */
.btn-download svg { color: var(--color-primary); }

/* Excel-green download button — signals the target is a downloadable .xlsx. Hoisted
   byte-identical from ten pages (the import wizards, the imports list, onboarding, and
   stock.html, which wrote the same declarations without spaces after the colons).
   Literal hexes moved verbatim; every other variant above uses tokens, so these are
   probable drift — but a restyle is a separate change. */
.btn-excel { background: #E9F5EE; color: #1E7145; border-color: #B7DFC6; }
.btn-excel:hover { background: #DBEEE2; border-color: #1E7145; color: #1E7145; }

/* Excel action WITHOUT the green face — a green fill collides with the Available status
   subtab three inches away in the same toolbar, so the signal moves to the asset: the same
   XLS sheet the page-header Export control uses, arrow reversed (img/ImportXLS.png).
   18px rather than the 16px of `.btn svg` — this is a raster carrying a readable "XLS"
   tab, and it loses that tab a pixel sooner than a one-stroke lucide glyph loses its shape. */
.btn-xls img { width: 18px; height: 18px; }

/* Link-buttons (<a class="btn">) must not inherit the global a:hover blue text + underline. */
a.btn:hover { text-decoration: none; }
.btn-primary:hover, .btn-cta:hover, .btn-danger:hover { color: #fff; }

/* A disabled button must read as unavailable, not merely refuse the click — the
   variant hover colors are cancelled so nothing invites a second attempt. */
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary:disabled:hover { background: var(--color-primary); }
.btn-cta:disabled:hover { background: var(--color-cta); }
.btn-danger:disabled:hover { background: var(--color-danger); }
.btn-outline:disabled:hover { border-color: var(--color-border); color: inherit; background: var(--color-surface-alt); }
.btn-ghost:disabled:hover { background: transparent; color: var(--color-text-muted); }
.btn-excel:disabled:hover { background: #E9F5EE; border-color: #B7DFC6; }

.btn-sm { padding: 5px 9px; font-size: 12px; }
.btn-sm svg { width: 14px; height: 14px; }
.btn-lg { padding: 9px 14px; font-size: 14px; }
.btn-block { display: flex; width: 100%; justify-content: center; }
.btn-xl { padding: 12px 16px; font-size: 14px; }

/* Commit action — deliberately brighter than --color-primary so the one step that
   actually creates something reads as what is about to happen. Fixed accent: it stays
   the same vivid blue in every theme. */
.btn-bright {
  background: #1D6BFF;
  border-color: #1D6BFF;
  color: #fff;
  box-shadow: 0 1px 3px rgba(29,107,255,0.40);
}
.btn-bright:hover { background: #0B57E3; border-color: #0B57E3; }
.btn-bright:focus-visible { box-shadow: 0 0 0 3px rgba(29,107,255,0.35); }

/* -------------------------------------------------------------------------
   Forms
   ------------------------------------------------------------------------- */
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.field label {
  font-size: 12px;
  font-family: 'Hanken Grotesk', sans-serif;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-text-muted);
  font-weight: 500;
}
/* Always-mandatory field marker. Static, unlike the wizard's toggleable `.req-star` — that one
   asks whether an attribute should be required; this one states that a field is. */
/* 1.5× the 12px label: an asterisk glyph inks far smaller than its nominal size, so at
   label size it reads as a speck — but at 2× it read as a mark of its own rather than
   part of the label. The negative margin eats the space character the markup puts before
   the span: the star belongs to the word, and any daylight between them breaks that.
   line-height:0 keeps the label row height unchanged, and the glyph's natural position
   high in the em box lifts it clear of the caps. */
.field label .required {
  color: var(--color-danger);
  font-weight: 700;
  margin-left: -2px;
  font-size: 18px;
  line-height: 0;
  vertical-align: -1px;
}
/* Number inputs: no spinner arrows anywhere in the app. */
input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

.input, .select, .textarea {
  width: 100%;
  padding: 8px 12px;
  font: inherit;
  font-size: 13px;
  color: var(--color-text);
  background-color: var(--color-control);
  border: 1px solid var(--color-control-border);
  border-radius: 5px;
  transition: border-color 120ms, box-shadow 120ms, background-color 120ms;
}
.input:focus, .select:focus, .textarea:focus {
  outline: none;
  background-color: var(--color-surface);
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
}
.input::placeholder, .textarea::placeholder { color: #94A3B8; }
/* A control switched off by another answer (e.g. Match type once a bundle is not sequenced). */
.input:disabled, .select:disabled, .textarea:disabled {
  background-color: var(--color-surface-alt);
  color: var(--color-text-subtle);
  cursor: not-allowed;
}

/* Search inputs get a light yellow tint everywhere (header + table toolbars). */
input[type="search"] { background: var(--color-search-bg); }
.select {
  appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 10px center;
  padding-right: 32px;
}

/* Type-to-search value picker — an input that filters an existing value list as you
   type and offers to add what you typed as a new company-level entry.
   Markup: <div class="combo"><input class="input" data-combo="A|B|C"></div> */
.combo { position: relative; display: inline-block; width: 168px; max-width: 100%; }
.combo--sm { width: 112px; }
/* An item name is longer than an attribute value — "Porcelain Hex Mosaic 12×24" truncates at 168. */
.combo--lg { width: 260px; }
/* Inside a `.field-grid` cell the cell is already the content sizing — a second fixed width would fight
   the span. Used by the country picker at data-span="6", which is sized for the longest LOCALIZED name
   ("Объединённые Арабские Эмираты"), not the longest English one. */
.combo--fill { display: block; width: 100%; }
.combo--fill .combo-menu { max-width: 100%; }
.combo > .input {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 10px center;
  padding-right: 32px;
}
.combo-menu {
  position: absolute; z-index: 40;
  top: calc(100% + 4px); left: 0; min-width: 100%; max-width: min(260px, 80vw);
  padding: 4px;
  max-height: 232px; overflow-y: auto;
  background: var(--color-surface);
  border: 1px solid var(--color-border-strong);
  border-radius: 5px;
  box-shadow: var(--shadow-lg);
}
.combo-opt {
  display: block; width: 100%;
  padding: 6px 8px;
  border: 0; border-radius: 5px;
  background: none;
  font: inherit; font-size: 13px; text-align: left; white-space: nowrap;
  color: var(--color-text);
  text-decoration: none;
  cursor: pointer;
}
.combo-opt:hover, .combo-opt.is-active { background: var(--color-primary-50); }
.combo-opt mark { background: none; color: var(--color-primary-700); font-weight: 600; }
.combo-opt.is-add {
  margin-top: 4px; padding-top: 8px;
  border-top: 1px solid var(--color-border);
  border-radius: 0;
  color: var(--color-primary-700); font-weight: 500;
  white-space: normal;   /* "Create “…” in Catalog →" is longer than the menu is wide */
}
.combo-opt.is-add:hover { color: var(--color-primary-700); }
.combo-empty { padding: 6px 8px; font-size: 12px; color: var(--color-text-subtle); }
/* Touch: options reach the 44px tap target on tablet and phone. */
@media (max-width: 768px) { .combo-opt { padding: 12px 8px; } }

.input-group { display: flex; align-items: stretch; }
/* The field shrinks, the unit never does — otherwise a narrow group wraps "$ / sqft". */
.input-group .input { border-radius: 5px 0 0 5px; border-right: 0; flex: 1 1 auto; min-width: 0; }
.input-group .addon {
  flex: none; white-space: nowrap;
  display: inline-flex; align-items: center; padding: 0 12px;
  border: 1px solid var(--color-border-strong);
  border-radius: 0 5px 5px 0;
  background: var(--color-surface-alt);
  color: var(--color-text-muted);
  font-size: 12px;
  font-family: 'Hanken Grotesk', sans-serif;
}

.toolbar {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  padding: 12px 16px;
  border-bottom: 1px solid var(--color-border);
  background: var(--color-surface);
  border-radius: var(--radius-md) var(--radius-md) 0 0;
}
/* THE search field, wherever it appears. It was scoped to .toolbar, so a search
   that led a page rather than capping a table got none of this and drifted into
   a second look: its own fill, its own size, its own icon in its own place. One
   rule, unscoped, is what keeps every search on the platform identical. */
.search { position: relative; min-width: 240px; flex: 1; max-width: 320px; }
.search input { padding-left: 32px; }
.search svg {
  position: absolute; left: 9px; top: 50%; transform: translateY(-50%);
  width: 14px; height: 14px; color: var(--color-text-subtle);
}
.toolbar .spacer { flex: 1; }
/* A toolbar hoisted out of its card to lead the page, above the KPI strip.
   Searching is the first thing done on these pages and the KPI strip can be
   several rows tall, so the one control that is always wanted stops being the
   one you scroll to.

   It caps nothing now, so it stops drawing a box: no fill, no border, no
   padding of its own. A band across the page for one search field is height
   spent on nothing, and the controls read perfectly well standing on the
   page's own ground — which is what the CRM's find row already does. */
.toolbar--page {
  padding: 0;
  background: transparent;
  border: 0;
  border-radius: 0;
  margin-bottom: var(--page-gap);
}
/* `.subtabs` carries an 8px bottom margin for a band of its own. On this row that
   margin is invisible height that grows the line and pushes the centred search field
   down a few pixels — the 4px Stock and Quotes were low by. */
.toolbar--page > .subtabs { margin-bottom: 0; }
/* Cancelled is not a chip (Quotes, Orders): Page Settings › View Cancelled swaps
   the list to it, and this line stands where the chips were, naming the list
   and leading back. */
.cancelled-bar { display: flex; align-items: center; gap: 12px; min-height: 34px; }
.cancelled-bar__title { font-size: 14px; font-weight: 600; color: var(--color-text); }
/* A .table-wrap draws a top rule to separate itself from the toolbar capping it.
   Where that toolbar has gone up to lead the page, the first table in a card
   has nothing above it and the rule lands on the card's own border, reading as
   one thick line. */
.card > .table-wrap:first-child,
.card > [data-tab-panel] > .table-wrap:first-child,
.card > [data-tab-panel] > [data-type-table]:not([hidden]) > .table-wrap:first-child {
  border-top: 0;
}

/* Table add — blue "+" in a table toolbar; the table's add action. Replaces the
   external page-header "New X" button. It is ALWAYS the first toolbar child —
   immediately to the LEFT of the search box when there is one. The add action is a
   fixed, always-available control, so it belongs at the toolbar's fixed edge; the
   search box grows and shrinks with the viewport, and keeping it to the right of the
   button means the "+" lands on the same pixel on every page and at every width. */
.btn-add { padding-left: 8px; padding-right: 8px; flex: none; }
.btn-add svg { width: 16px; height: 16px; }

/* Toolbar filter toggle — icon-only, sized to match the .btn-add plus button it sits
   beside (same box metrics ⇒ an even run of controls after the search box). */
.toolbar [data-filter-toggle],
.page-header__title [data-filter-toggle],
.btn-filter { padding: 7px 8px; flex: none; }
.toolbar [data-filter-toggle] svg,
.page-header__title [data-filter-toggle] svg,
.btn-filter svg { width: 16px; height: 16px; }
/* Active (filters applied) state — funnel with a bold × overlay (~3× the built-in filter-x ×). */
.toolbar [data-filter-toggle] .filter-x-icon,
.page-header__title [data-filter-toggle] .filter-x-icon { position: relative; display: inline-flex; width: 16px; height: 16px; }
.toolbar [data-filter-toggle] .filter-x-icon__x,
.page-header__title [data-filter-toggle] .filter-x-icon__x {
  position: absolute; top: -5px; right: -6px;
  width: 15px; height: 15px; stroke-width: 3;
  color: var(--color-danger);
}

/* Filter chip */
.chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 10px;
  border-radius: 5px;
  background: var(--color-primary-50);
  color: var(--color-primary-700);
  font-size: 12px;
  font-family: 'Hanken Grotesk', sans-serif;
  border: 1px solid #D6E2FA;
}
.chip button {
  background: transparent; border: 0; color: inherit; line-height: 0; padding: 0; cursor: pointer;
}
.chip button:hover { color: var(--color-danger); }

/* Applied-filters strip — sits directly under the toolbar and lists what is actually
   selected, one removable chip per value, plus a Clear All. It is injected by app.js
   after every toolbar holding a [data-filter-toggle], so a page never hand-writes it.
   Hidden entirely when nothing is selected: an empty strip is a row of dead space. */
.active-filters {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  padding: 8px 16px;
  border-bottom: 1px solid var(--color-border);
  background: var(--color-surface-alt);
  font-size: 13px;
}
.active-filters[hidden] { display: none; }
.active-filters__chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; flex: 1; }
.af-chip {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 6px 3px 9px;
  background: var(--color-primary-50); color: var(--color-primary-700);
  border: 1px solid #D6E2FA; border-radius: 5px;
  font-size: 12px; font-weight: 600; line-height: 1.4;
}
.af-chip button {
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0; background: transparent; border: 0; cursor: pointer;
  color: var(--color-primary-700); opacity: 0.65;
}
.af-chip button:hover { opacity: 1; }
.af-chip button svg { width: 13px; height: 13px; }
.active-filters__clear {
  margin-left: auto; padding: 0; flex: none;
  background: transparent; border: 0; cursor: pointer;
  font-family: inherit; font-size: 13px; font-weight: 500;
  color: var(--color-primary-700);
}
.active-filters__clear:hover { color: var(--color-primary); }

/* Faceted filter chips — every option visible, click to toggle (replaces filter dropdowns).
   No selection in a group = "any". Selected state matches .subtab.active / .badge-primary. */
/* Two columns that PACK, not two columns that align. A CSS grid sizes each row to its
   tallest cell, so a one-line group (Region: two chips) sitting beside a two-line group
   (Category: seven chips) left a hole exactly the height of the difference, and the next
   group waited for the row below to start. Multi-column lets each group begin directly
   under the previous one in its own column, so short groups close up automatically.
   It also gives the divider for free: column-rule is a real, continuous rule painted in
   the gap, replacing an :nth-child(even of …) selector plus negative margins that had to
   fake one — and which could not have known which column a group landed in once the
   groups stopped being laid out in fixed pairs. */
.filter-grid { columns: 2; column-gap: 44px; column-rule: 2px solid var(--color-border-strong); }
.filter-group { break-inside: avoid; margin-bottom: 26px; }
/* A full-width group interrupts the flow — columns above it, columns below it — which is
   why span-2 groups are written first or last in the dialog, never in the middle. */
.filter-group.span-2 { column-span: all; }
/* The margin-bottom of the last group in a column does not survive the column break, so a
   spanning group lands flush against the columns above it (0px on Stock, 8px on Pricing —
   balancing rounding, not a rule). State the gap on the spanner instead. Not on a leading
   spanner, which has nothing above it to clear. */
.filter-group.span-2:not(:first-child) { margin-top: 26px; }
/* Starts the second column at this group. `columns: 2` otherwise balances by height, so a
   facet lands in whichever column that pass happens to favour — and chip wrapping differs
   per dialog, so the same facet could sit left on one page and right on the next. These
   dialogs declare their split instead of inferring it. */
.filter-group.col-start { break-before: column; -webkit-column-break-before: column; }
/* The dialog body sits one shade below the chips, so the chips read as raised boxes on a
   panel instead of white-on-white outlines. */
.modal-body:has(> .filter-grid) { background: var(--color-surface-alt); }
.fg-label {
  display: block; margin-bottom: 8px;
  font-size: 12px; font-family: 'Hanken Grotesk', sans-serif;
  text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--color-text-muted); font-weight: 500;
}
.fchips { display: flex; flex-wrap: wrap; gap: 8px; }
.fchip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 7px 13px; font-size: 13px; font-family: inherit; line-height: 1.1;
  color: var(--color-text-muted);
  background: var(--color-surface-alt);
  border: 1px solid var(--color-border-strong);
  border-radius: 5px; cursor: pointer;
  transition: background 120ms, color 120ms, border-color 120ms, box-shadow 120ms;
}
.fchip:hover { color: var(--color-primary-700); border-color: var(--color-primary); }
/* On the tinted dialog panel the chips invert: surface-white box, full-strength label. Three
   readable levels — panel, box, value — instead of two near-identical greys. */
.filter-grid .fchip:not(.on) {
  background: var(--color-surface); color: var(--color-text);
  border-color: var(--color-border-strong); box-shadow: var(--shadow-sm);
}
.filter-grid .fchip:not(.on):hover { color: var(--color-primary-700); border-color: var(--color-primary); }
/* Selected = the wash every selector on the platform wears, which is what this chip
   always meant to be: the comment here used to say "the same primary-50 fill and
   primary-700 label as .subtab.active", and that is still the intent — the segment
   it was matching has simply moved. Nothing bold: the chip is already raised and
   already coloured. The hairline keeps its defined edge but takes the wash's own
   hue instead of a flat primary, so it belongs to the fill rather than boxing it. */
.fchip.on {
  background: color-mix(in oklab, var(--status-hue, var(--color-primary)) 20%, var(--color-surface));
  color: var(--color-text);
  border-color: color-mix(in oklab, var(--status-hue, var(--color-primary)) 42%, var(--color-border));
  box-shadow: 0 1px 3px rgb(15 23 42 / 0.10);
}
/* The count takes a deeper step of the chip's own hue, the same move as
   `.subtab.active .count`. */
.fchip.on .row-count {
  background: color-mix(in oklab, var(--status-hue, var(--color-primary)) 32%, var(--color-surface));
  color: var(--color-text);
}

/* Subsection select-all / clear-all — pair with a multi-select .fchips group. Sits at the
   far right of the subsection label row; `.on` = every chip in that group is selected. */
.fg-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.fg-head .fg-label { margin-bottom: 0; }
.fg-all {
  flex: none; padding: 2px 6px; border: none; border-radius: 5px; cursor: pointer;
  font-family: 'Hanken Grotesk', sans-serif; font-size: 12px; font-weight: 600;
  letter-spacing: 0.02em; color: var(--color-primary-700); background: none;
  transition: background 120ms, color 120ms, box-shadow 120ms;
}
.fg-all:hover { color: var(--color-primary); background: var(--color-primary-50); }
.fg-all.on { color: var(--color-primary-700); background: var(--color-primary-50); box-shadow: var(--shadow-sm); }

/* -------------------------------------------------------------------------
   Tables
   ------------------------------------------------------------------------- */
.table-wrap {
  overflow-x: auto;
  /* The vertical axis is pinned shut, and that is the whole point of this line.
     A non-visible `overflow-x` forces `overflow-y` to compute to `auto` — the same
     rule that bites `.tabs` further down — so this box quietly became a vertical
     scroller too, and nobody asked it to: a table is as tall as its rows and the
     PAGE is what scrolls them. It cost nothing until fractional display scaling
     (125%, the Windows default on most laptops) rounded the row heights up and
     left the box overflowing its own content by exactly 1px. Chrome draws a full
     scrollbar for that 1px, down the right edge of every table on the platform.
     Hidden, not auto: there is no height here to scroll to, so nothing is lost. */
  overflow-y: hidden;
  background: var(--color-surface);
  border-top: 1px solid var(--color-border);
  /* THE SIDE STANDOFF (memory.md §20). The table stops short of the panel's edges
     the way everything else in a card already does, so its header reads as a band
     INSIDE the panel rather than as the panel's own lid. Canonical look: the three
     CRM screens, which got this by accident — their panels are `.tab-shell`s, and
     the shell's own 16px side padding was standing their tables off. This makes it
     the table's own property, so a table gets it wherever it sits.

     On the container, not the table: it must not scroll away when a wide table
     scrolls sideways, and the wrap's top rule stays full-width because it separates
     two full-width things — the toolbar above it and the panel. */
  padding-inline: var(--table-inset);
}
/* Except where the box IMMEDIATELY around the table already provides the standoff —
   The standoff twice over is a different mistake from the one this
   fixes. Both exceptions are direct-child selectors on purpose: they are about the
   box the table actually sits in, not about the page it is somewhere inside.
     - a panel that merged into its shell (the CRM screens — the accident this rule
       generalises): the shell's own 16 is already standing the table off. A table
       in a card NESTED in a shell is not this case and keeps its inset, the same as
       that card would get anywhere else.
     - a table sitting straight in a modal body, which pads 16 on all four sides.
   `.entry-grid-wrap` needs no exception: it draws its own bordered box, and a table
   inside a box of its own belongs against that box's edges. */
.tab-shell > [data-tab-panel] > .table-wrap,
.tab-shell > [data-tab-panel] > .card > .table-wrap,
.modal-body > .table-wrap { padding-inline: 0; }
/* Column widths are declared, never inferred from the data. `table-layout: fixed`
   makes the header row's widths authoritative for the whole column, so a longer or
   shorter value wraps to a second line instead of moving the column.

   Those widths are percentages that sum to 100%, so a table always exactly fills its
   container: there is no leftover for the browser to redistribute (fixed layout
   spreads any slack equally across the columns, which silently defeats declared
   widths), and no single column has to soak it up and sit next to a void.

   Resizing the window scales every column by the same factor, between the bounds
   described at the width block below. Because the scaling is uniform, tables that
   swap in the same slot (tab-siblings — the stock tabs) can share a denominator:
   their common columns then carry identical percentages, and since both tables render
   at the same total width, the shared leading columns start at the same x AND the
   shared trailing columns stay anchored to the same right edge. Switching tabs moves
   nothing. Widths never depend on content, so data can't shift anything either. */
.data-table { table-layout: fixed; --table-pad-x: 12px; }
.data-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
/* Shared table-header typography — column headers AND a table's own title label
   (.table-title) use the exact same font size, weight, case and tracking. */
.data-table thead th,
.table-title {
  font-family: 'Hanken Grotesk', sans-serif;
  font-weight: 700;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.data-table thead th {
  position: sticky; top: 0; z-index: 1;
  background: var(--color-table-head);
  text-align: left;
  color: var(--color-text-muted);
  /* The vertical padding is deliberately uneven — 11 above, 9 below — and the two
     still add to the 20px the band has always been, so no header row changes height.

     A cell centres the BOX its text sits in, and that box reserves room under the
     baseline for descenders. Column headers are uppercase, so that room is always
     empty: the ink stops at the baseline while the box keeps going, and centring
     the box therefore parks the letters a pixel above the middle of the band. The
     extra pixel on top pushes the ink back down to where the eye expects it.
     Same reasoning as `.ct-recent__l`, and the reason `.add-row-btn` is nudged. */
  padding: 11px var(--table-pad-x) 9px;
  border-bottom: 1px solid var(--color-border);
  /* A long header wraps to a second line rather than widening its column. */
  white-space: normal;
  overflow-wrap: break-word;
}

/* A header band is a band INSIDE the panel, never the panel's own top edge. Every
   table on the platform keeps a band of the panel's ground above its first header
   row, so the band reads as part of the table rather than as the lid of the box it
   sits in. It matters most under a tab strip, where without it the header butts
   straight into the tabs and there is nothing to say a panel began.

   ONE NUMBER, ONE PLACE. `--table-head-band` in :root is the whole platform's
   band — 39 pages put a table under a tab strip, and this is the only value any of
   them reads. Change it there and every table moves together; a page that wants
   its own is a page that has left the platform.

   IT CANNOT GO TO ZERO. The selected tab is white and hangs into the panel, and this
   band is the white it lands on. At 0 the tab stands on the header band and comes
   unstuck from the panel it belongs to. It was briefly 4px — tight enough that under
   an attached strip, where no toolbar stands between tabs and table, the band read as
   a scrollbar track between the grey tabs and the blue-grey header. Pricing carried
   its own 4px before that and has none now: this is the platform's number and the
   only one any page reads.

   Drawn as a border on the header cells rather than as padding on .table-wrap for
   two reasons: not every table sits in one (the import wizards' entry grids use
   .entry-grid-wrap), and a border travels with a sticky header — padding on the
   wrapper would scroll away and leave the band flush against the top the moment
   the header stuck. The colour is the surface token, so a dark theme gets its own
   ground here rather than a white bar. */
.data-table thead th,
table.entry-grid thead th { border-top: var(--table-head-band) solid var(--color-surface); }
/* A table/panel's title label above the table (e.g. "RECENT CATALOG ACTIVITY").
   Same header typography as the columns; keeps default text color for prominence. */
.table-title { color: var(--color-text); }
.data-table thead th.sortable { cursor: pointer; user-select: none; }
.data-table thead th.sortable:hover { color: var(--color-primary-700); }
.data-table thead th.sortable .sort-ind {
  display: inline-block; margin-left: 4px; opacity: 0.4;
}
.data-table thead th.sortable.sort-asc .sort-ind,
.data-table thead th.sortable.sort-desc .sort-ind { opacity: 1; color: var(--color-primary); }

.data-table tbody td {
  padding: 10px var(--table-pad-x);
  /* The row boundary is the ::after mark below, not this border. The border stays
     declared, transparent, so every row still reserves its 1px — row heights and the
     mark's `bottom:-1px` anchor land exactly where they did before. */
  border-bottom: 1px solid transparent;
  color: var(--color-text);
  vertical-align: middle;
  /* Overlong words (SKUs, URLs, hashes) break instead of pushing the column wider. */
  overflow-wrap: break-word;
  position: relative;
}
/* ---- Row separator -------------------------------------------------------
   A full-width hairline at #F1F5F9 is a 3% step off white and disappears once a
   row is two lines tall, so rows read as one block. Each cell instead carries a
   mark that holds --color-border-strong for the first 35% of its own column,
   decays across the next 35% and leaves the last 30% clear: the boundary is
   stated where each column's content starts, and is spent before it reaches
   the next.

   Inset both ends by the cell's own --table-pad-x so the mark begins where the
   thumbnail and text begin, and so contiguous cells leave a clean gap at every
   column boundary rather than forming one unbroken rule.

   Proportional stops, not pixels: every column holds full weight for the same
   share of its own width, so a narrow column and a wide one read as the same
   gesture at different scales. Tokens, not a literal grey, so it follows all
   four themes.

   Safe as a global: `position:relative` on cells re-anchors nothing (the only
   absolutely-positioned cell contents sit inside their own positioned wrappers,
   and in-cell row menus are position:fixed), and the existing `thead th::after`
   column-flex marker is on thead, so there is no collision. */
.data-table {
  --row-mark-left: linear-gradient(to right,
    var(--color-border-strong) 0%,
    var(--color-border-strong) 35%,
    var(--color-border) 52.5%,
    transparent 70%);
  --row-mark-right: linear-gradient(to left,
    var(--color-border-strong) 0%,
    var(--color-border-strong) 35%,
    var(--color-border) 52.5%,
    transparent 70%);
  --row-mark-center: linear-gradient(to right,
    transparent 15%,
    var(--color-border) 23.75%,
    var(--color-border-strong) 32.5%,
    var(--color-border-strong) 67.5%,
    var(--color-border) 76.25%,
    transparent 85%);
}
.data-table tbody td::after {
  content: ""; position: absolute; bottom: -1px; height: 1px;
  left: var(--table-pad-x); right: var(--table-pad-x);
  background: var(--row-mark-left);
}
/* The mark follows the column's alignment. The boundary is stated where the
   content starts, and a right-aligned number starts at the right, a centred
   value in the middle: the left-anchored mark put its full weight under empty
   space and its fade under the figure. Right-aligned cells (`.num`, the actions
   columns and the last column) mirror the gradient; centred cells hold the same
   35% at full weight in the middle and decay both ways. Alignment is read from
   the shared classes. A column a page stylesheet aligns on its own sets its mark
   the same way, with one rule on its cells' ::after — `background:
   var(--row-mark-center)` — beside the text-align that centred it (quotes.css
   does this for Status, Decision and Source). Declared in the order text-align
   resolves them — `.num`, then `.center` (a `num center` cell is centred), then
   the last column, which is right-aligned whatever else it carries. */
.data-table tbody td.num::after,
.data-table tbody td.col-actions::after,
.data-table tbody td.col-actions-2::after { background: var(--row-mark-right); }
.data-table tbody td.center::after { background: var(--row-mark-center); }
.data-table tbody td:last-child::after { background: var(--row-mark-right); }
/* The last row needs no separator — the card's own edge already closes the table,
   and a mark there reads as a row waiting for one that never comes. */
.data-table tbody tr:last-child td::after { content: none; }
/* Dense picker tables opt back into a solid row border of their own (the five
   import pages set `.tbl-dense tbody td { border-bottom-color: … }`), so they must
   not also carry the mark — that would draw the boundary twice. */
.data-table.tbl-dense tbody td::after { content: none; }
.data-table tbody tr { transition: background 120ms ease; }
.data-table tbody tr:hover { background: #FAFAFE; }
.data-table tbody tr.selected { background: var(--color-primary-50); }

/* ---- Dense picker tables (the five import pages) --------------------------
   Hoisted from catalog-import, catalog-import-v2, pricing-import, stock-import
   and stock-import-v2, where all four rules were byte-identical. Verbatim: the
   two literal hexes below are not tokens, which looks like drift rather than
   intent, but a restyle is a separate change.

   Position matters. These tables are "data-table tbl-dense", so every rule here
   ties on specificity with a .data-table rule above and won only because it came
   later in the document. It must stay below them. */
.tbl-dense td, .tbl-dense th { padding-top: 6px; padding-bottom: 6px; }
.tbl-dense tbody tr:nth-child(even) { background: #E4EBF4; }
.tbl-dense tbody td { border-bottom-color: var(--color-border); }
.tbl-dense tbody tr:hover { background: #DAE3F0; }

/* ---- Import run state (the five import pages) -----------------------------
   The spinner + progress bar shown while an import runs. Hoisted byte-identical
   from the same five pages, keyframes included — they are referenced by nothing
   else in the surface. */
.import-run { text-align: center; padding: 44px 24px; }
.import-spinner { width: 52px; height: 52px; margin: 0 auto 18px; border-radius: 50%; border: 4px solid var(--color-primary-50); border-top-color: var(--color-primary); animation: imp-spin .8s linear infinite; }
@keyframes imp-spin { to { transform: rotate(360deg); } }
.import-progress { width: 280px; max-width: 100%; height: 8px; margin: 22px auto 0; background: var(--color-primary-50); border-radius: 5px; overflow: hidden; }
.import-progress > span { display: block; height: 100%; width: 0; background: var(--color-primary); border-radius: 5px; animation: imp-fill 4s ease-out forwards; }
@keyframes imp-fill { to { width: 100%; } }
.import-run .import-title { font-weight: 600; font-size: 17px; margin-bottom: 4px; }
.import-run .import-note { font-size: 13px; color: var(--color-text-muted); }

.data-table tbody tr.selected:hover { background: #D6E2FA; }

.data-table td.num,
.data-table th.num { text-align: right; font-family: 'Hanken Grotesk', sans-serif; font-variant-numeric: tabular-nums; }
.data-table td.center,
.data-table th.center { text-align: center; }

/* Numbers never wrap — a broken figure is unreadable. Long text columns may. */
.data-table td.num,
.data-table th.num { white-space: nowrap; }

/* The rightmost column is right-aligned, and its right padding is the same
   --table-pad-x the first column uses on the left, so the table's content is
   inset by an identical amount on both edges. Declared once here rather than
   per-cell in the markup. */
.data-table thead th:first-child,
.data-table tbody td:first-child { padding-left: var(--table-pad-x); }
.data-table thead th:last-child,
.data-table tbody td:last-child {
  text-align: right;
  padding-right: var(--table-pad-x);
}
/* A right-aligned last cell still lays its own contents out left-to-right, so
   flex/inline-flex content (badges, icon clusters) has to be pushed over too. */
.data-table tbody td:last-child > .cell-stack { align-items: flex-end; }
.data-table tbody td:last-child > .cell-media,
.data-table tbody td:last-child > .row-actions { justify-content: flex-end; }

/* .entry-grid is a data-entry component whose base styles live in the pages that
   use it, but the last-column rule is global, so it is declared once here. Its
   columns are already data-independent (every cell is a fixed-size input), so it
   needs the alignment and the symmetric edge inset, not fixed layout. */
.entry-grid { --table-pad-x: 8px; }
.entry-grid th:first-child,
.entry-grid td:first-child { padding-left: var(--table-pad-x); }
.entry-grid th:last-child,
.entry-grid td:last-child {
  text-align: right;
  padding-right: var(--table-pad-x);
}

/* -- Column widths: fluid shares, bounded ---------------------------------
   Columns are sized as a SHARE of the table (a percentage on the <th>), so they
   resize with the browser instead of holding a fixed pixel width. The share is
   per-table because a proportional share depends on that table's own column set,
   so it lives on the cell; the .col-* classes below stay for what they name and
   for alignment.

   Each column's bounds come from its own size: it may shrink to 50% of it and
   grow to 250%, past which the text wraps rather than the column widening. Since
   every column scales by the same factor, the whole set reaches its minimum at one
   table width and its maximum at another — so both bounds are enforced by a single
   min-width/max-width on the table, which is also the only place they CAN be
   enforced: max-width is ignored on a table column outright, min-width works only
   on <col>, and calc()/min()/max()/clamp() are all ignored under fixed layout.

   Below the table's min-width .table-wrap scrolls; above its max-width the table
   stops growing. .col-flex marks the column that takes up the slack when sibling
   tables (tab-swapped views) share a denominator so their common columns line up. */
/* The add control sits in the first column's header, on the list it adds to,
   rather than on a bar of its own above it. A row of chrome carrying one button
   is a row of height spent on one button.
   It was 19px, sized to the 11px header it shares a line with, and that was too
   small to press without aiming — under the 24px minimum target size, and only
   4px of square around a 12px glyph. 24px is the same mark a quarter larger, and
   it is what client.css and crm.css had already settled on independently.
   The negative block margin is what keeps the header row the height it was. The
   button is inline-level, so the LINE BOX is sized from its margin box, and
   going 19px -> 24px pushed every column header from 50px to 55px — five pixels
   of table chrome bought on every list in the portal to make one button bigger.
   -3px top and bottom hands those back: the mark is 24px, its line contribution
   is 18px, and the row lands within a pixel of the height it always had. */
.add-row-btn {
  display: inline-grid; place-items: center; width: 24px; height: 24px; padding: 0;
  margin-block: -3px;
  border: 1px solid var(--color-primary); border-radius: var(--radius-md);
  background: var(--color-primary); color: #fff; cursor: pointer;
  margin-right: 4px;
  /* `vertical-align: middle` centres the box on the baseline plus half the
     X-HEIGHT, but the eye reads the label centred on half its CAP height — and
     for the 11px header they share a line with, those differ by about a pixel.
     This aligns the button to the LABEL beside it. Where that pair sits inside
     the band is a separate question, answered by the header's uneven padding. */
  vertical-align: middle; position: relative; top: -1px;
}
/* Hover inverts the button rather than tinting it: the square drops to the pale
   blue and the plus goes dark, so the contrast between them is at its widest
   exactly when the pointer is on it. The colour is stated here because these
   are anchors on most pages, and `a:hover` would otherwise repaint the glyph.
   Both values are tokens, so the dark theme inverts with it. */
.add-row-btn:hover {
  background: var(--color-primary-50);
  color: var(--color-text);
}
.add-row-btn svg { width: 15px; height: 15px; }
/* WHAT YOU CAN PRESS IS WIDER THAN THE MARK. The + sits in a header cell that is
   often sortable, so a click a few pixels off it used to re-sort the column —
   and a re-sorted list looks exactly like nothing happening. The square stays
   24px; this box carries the press out to 38px. The button is already
   `position: relative`, and a click in here reports the button as its target, so
   the sort handler's own `closest('.add-row-btn')` stands aside for it. */
.add-row-btn::after {
  content: "";
  position: absolute;
  inset: -7px;
}

.data-table .col-actions   { text-align: right; }
/* Two-icon action cluster (2 x 34px .icon-btn) — needs to stay on one line. */
.data-table .col-actions-2 { text-align: right; white-space: nowrap; }

.sku { font-family: 'Hanken Grotesk', sans-serif; font-size: 12px; color: var(--color-text-muted); }

/* row meta layout */
.cell-stack {
  display: flex; flex-direction: column; gap: 1px; min-width: 0;
}
.cell-stack .main { color: var(--color-text); font-weight: 500; }
.cell-stack a.main { color: var(--color-primary-700); text-decoration: none; }
.cell-stack a.main:hover { color: var(--color-primary-700); text-decoration: none; }
.cell-stack .sub { color: var(--color-text-subtle); font-size: 11px; }

/* ==========================================================================
   REQUESTS QUEUE — campaigns, showroom, VIP offers
   ==========================================================================
   A request is a request whoever it reached us through. Somebody saw material
   somewhere we put it and asked us about it, and what we do next is the same
   work in every case: read what they asked for, then approve it, decline it,
   or mark it as never having been real. So the queue that holds it is ONE
   table, declared once here rather than three times in three page
   stylesheets, and only the first column's subject changes between modules —
   which campaign, which showroom page, which showroom.

   THERE IS NO TYPE COLUMN. It named the shape of the ask — a price, a hold, a
   swatch, a viewing — from the days when those were five separate lists. Two of
   them went when the samples and the showings did, and what is left of the
   distinction is written on the lines themselves, where the rep is looking when
   they answer it. A column repeating it earned a tenth of the row for a word
   nobody sorted, filtered or acted on. */
.req-queue { table-layout: fixed; }
/* WHEN IT WAS ASKED LEADS. A queue is read in the order things arrived and the
   first thing a rep wants off a row is how long it has been sitting there — that
   is the fact the whole list is sorted by in the head, so it is the fact the eye
   lands on first. The source follows: it carries three lines and the longest is a
   title somebody typed, so it takes better than a third of the row. The three
   after it are a product, a figure and a state, each sized for its widest
   plausible value rather than for its data. */
/* THE DATE IS FIXED, NOT A SHARE. Every other column holds something a person
   typed and we cannot predict; this one holds a date over how long ago, and the
   longest line it will ever carry is ours to choose — "Jul 2" over "Expires in
   46h", 69px of text. As a share of the table it was handed 303px on a wide
   screen to say that. 104px is the widest line plus the cell's own padding and
   a little air, which leaves the column about a tenth wider than its content
   asks for and well inside the quarter it is allowed to grow.

   The source absorbs what is left, and has to: under table-layout: fixed a px
   column only holds its width if some other column takes up the slack. With
   every column on a percentage the leftover is shared back out and the fixed
   one drifts with the table again. */
.req-queue th.rq-asked  { width: 104px; }
.req-queue th.rq-src    { width: auto; }
.req-queue th.rq-items  { width: 26%; }
.req-queue th.rq-value  { width: 15%; }
.req-queue th.rq-status { width: 17%; }
/* Status reads down the right edge, like the last column of every other table
   on the console. */
.req-queue th.rq-status,
.req-queue td.rq-status { text-align: right; }
/* The whole row opens its request. Cells keep their own targets — the quote a
   row links to, the client — and a click that lands on one of those belongs to
   it, not to the row. */
.req-queue tbody tr { cursor: pointer; }
/* Rows vary in height, so the cells top-align: the date, the source and the
   status badge sit on one line instead of each floating in the middle of its
   own cell. */
.req-queue tbody td { vertical-align: top; }

/* The collection the item sat in, when it sat in one. A request can come off a
   product that belongs to no collection at all, or off a wholesale inquiry that
   started with no product — and there the line is empty rather than filled with
   a word meaning nothing. It keeps its height either way, so the rows stand
   level down the queue instead of stepping in and out by a line. */
.req-queue .rq-coll:empty::before { content: "\00a0"; }

/* A request that came out of a VIP Offer room did not come off a shelf, so
   the same line names the room instead. It is the only word in the cell that
   is not grey: the offer gold the VIP mast already carries, at a weight the
   rest of the line does not have, because whether a client was invited or
   walked in changes how the answer is written. */
.req-queue .rq-vip { color: var(--color-vip); font-weight: 600; }

/* Where it came from, and who came back off it. The client sits a step above
   the source's own name in weight, because they are the reason the row is in
   a queue at all; the person to talk to and the request number go a step down
   again — that is how you address the answer, not what the answer is. */
.req-queue .req-who {
  color: var(--color-text);
  font-size: 12px;
  font-weight: 500;
  margin-top: 3px;
}
.req-queue .req-who .req-contact {
  color: var(--color-text-subtle);
  font-size: 10px;
  font-weight: 400;
  margin-left: 6px;
}


/* A filter group riding the trailing edge of the page's own row: which list, then
   which part of it, answered at the same height. `.rowfilter` is the name to reach
   for — the group is not always about requests, and a page carries one of these per
   tab, only ever one of them up. `.req-filter` is the original name, kept because it
   is on two pages already. */
.rowfilter,
.req-filter { margin: 0 0 0 auto; align-self: center; flex: none; }
/* On the page toolbar the search has moved up to the title line, so the group starts
   the row instead of banking right. */
.toolbar--page > .rowfilter,
.toolbar--page > .req-filter { margin-left: 0; }

.cell-media {
  display: flex; align-items: center; gap: 10px; min-width: 0;
}
.thumb {
  width: 36px; height: 36px;
  border-radius: 5px;
  background: #E6EEFC;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: 'Hanken Grotesk', sans-serif;
  font-weight: 600;
  color: var(--color-primary-700);
  font-size: 11px;
  flex-shrink: 0;
  border: 1px solid #D6E2FA;
}

/* A mark carrying real artwork — a client's own logo. CONTAIN, never cover: a logo squared off to
   fit a 36px box stops being the logo (the same reason .sf-brandrow__ctl gives). The image sits
   INSIDE the chip rather than being it, so the border and the radius still belong to the chip —
   unlike .thumb.is-photo, which is the 61px 1.7:1 product still and the wrong shape for a mark.
   White ground, because .thumb's pale #E6EEFC shows through a transparent PNG drawn for white. */
.thumb > img, .avatar > img {
  width: 100%; height: 100%;
  object-fit: contain;
  border-radius: inherit;
  display: block;
}
.thumb.has-logo, .avatar.has-logo { background: #fff; }

/* ---- Client logo row (New Client on clients.html / Edit Client on client.html) --------------
   GLOBAL, not page-scoped, because the two drawers are ONE field set asked for two ways — and
   the pages load different page sheets, so a rule in css/pages/clients.css reaches New Client
   and silently leaves Edit Client unstyled. That is exactly what happened the first time.

   The mark itself is the control's preview: the file that comes back takes the place of the
   initials beside it, because a button that only announces "uploaded" never says whether the
   file landed. Same reasoning as .sf-brandrow on the showroom branding card. */
.cl-logorow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* The mark keeps its table size here — what the drawer shows is what the row will show. */
.cl-logorow .thumb { width: 36px; height: 36px; flex: none; }
.cl-logorow__hint {
  font-size: 12px;
  color: var(--color-text-muted);
  /* Pushed to its own end of the row so the two buttons stay together as one group. */
  margin-left: auto;
}
@media (max-width: 480px) {
  /* The hint reads as a caption under the controls rather than being squeezed beside them. */
  .cl-logorow__hint { margin-left: 0; flex: 1 0 100%; }
}

/* -------------------------------------------------------------------------
   Badges / Status pills
   ------------------------------------------------------------------------- */
.badge {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px 8px;
  border-radius: 5px;
  font-size: 11px;
  font-family: 'Hanken Grotesk', sans-serif;
  font-weight: 500;
  border: 1px solid transparent;
  white-space: nowrap;
}
.badge .dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; }

/* ---- A BADGE THAT CARRIES ITS REFERENCE ----------------------------------
   A hold with a quote against it was two chips side by side, and in a column
   narrow enough to be a status column the second one fell to its own line and
   read as a separate fact. It is not one: the quote IS the hold — what the
   stone is being held for — so it goes inside the same pill, a step under the
   word and a step down in size. The word keeps the badge's own weight; the
   number takes 10px and a lighter ink, which is the order they are read in.

   Both lines sit to the right edge, because a status column does (memory.md
   §5) and a pill whose two lines disagree with the column reads as crooked. */
.badge--stacked { flex-direction: column; align-items: flex-end; gap: 0; padding: 3px 8px 4px; line-height: 1.3; }
.badge--stacked .badge__l { display: flex; align-items: center; gap: 4px; }
.badge--stacked .badge__ref {
  font-size: 10px;
  font-weight: 400;
  color: inherit;
  opacity: .78;
  text-decoration: none;
  font-variant-numeric: tabular-nums;
}
/* Pressing the state opens the quote it names, so the whole pill is the link —
   the word and the number are one fact and only half of it being clickable is a
   target you have to aim at. No underline; the number simply comes up to full
   ink and weight under the cursor. */
a.badge--stacked { text-decoration: none; cursor: pointer; }
a.badge--stacked:hover .badge__ref { opacity: 1; font-weight: 500; }

/* ---- WHEN THE REQUEST WENT ONTO MORE THAN ONE -----------------------------
   A quote is filled over several requests, so a request answered in passes can
   put some of its lines on the box already half loaded and the rest on a new
   one. The state has room for exactly one reference, so past one it counts them
   instead — and a count cannot be jumped to, which is the whole point of the
   single number. So the count opens a menu naming each quote and what it
   carried; three bare numbers would not tell a rep which one to open.

   The wrapper is only what the menu hangs off. The badge inside it is a button
   rather than a link and needs the cursor a link gets for free. */
.rq-quotes { position: relative; display: inline-block; }
button.badge--stacked { cursor: pointer; text-align: right; }
button.badge--stacked:hover .badge__ref { opacity: 1; font-weight: 500; }
/* IT OPENS OVER A WHITE CARD, so it cannot be white (memory.md §13 — nothing
   sits on the ground it stands on). A shadow was carrying the edge on its own
   and it was not enough: the menu read as a hole cut in the table rather than
   as something lying on top of it. So it takes the control ground and the
   control's own hairline, which is what every other box on a white panel does. */
.rq-quotes .dropdown-menu {
  min-width: 236px;
  background: var(--color-control);
  border-color: var(--color-control-border);
}
/* The line under the cursor lifts to white off that tray — the reverse of a
   white menu's tinted hover, and the only direction left once the tray is the
   tinted one. */
.rq-quotes .dropdown-item:hover { background: var(--color-surface); }
/* The quote a line went onto, over what it took with it — the same two-line
   shape the queue's own cells use, so the menu reads as more of the table. */
.rq-quotes .dropdown-item { padding: 7px 10px; }
.rq-quotes .dropdown-item .main { font-variant-numeric: tabular-nums; }

/* ---- THE FIVE STATES (memory.md §19) -------------------------------------
   One colour per meaning, and the meaning is what picks it — never the label.

     success  it is good / done / here      Live, In Stock, Approved, Accepted
     warn     it is in motion / owed        Reviewing, Reserved, Made to Order
     danger   it is gone / blocked          Expired, Out of Stock, Declined
     info     it is new / it needs you      New, On Request, Requested
     neutral  it is inert, or not a state   Draft, None, Off — and every CATEGORY

   The fill, the ink and the border all come off the same token, so a badge and
   a `.dot-status` saying the same thing are the same colour. They were not: the
   badges carried hardcoded emerald/crimson while the dots took the semantic
   tokens, which put two greens for one meaning on the same row.

   `badge-primary` is `badge-info`. They had the identical fill and differed only
   in ink, so "New" rendered two ways across the portal with nothing to tell them
   apart. It stays as an alias rather than being deleted: it is on 60-odd pages,
   and a class that resolves correctly is cheaper than a sweep that must not miss
   one. Reach for `badge-info` in anything new.

   The border is mixed rather than authored: a fixed pastel per state is four more
   values to keep in step with a theme, and every theme already restates the two
   this derives from. */
.badge-success { background: var(--color-success-50); color: var(--color-success-ink); border-color: color-mix(in oklab, var(--color-success) 34%, var(--color-success-50)); }
.badge-warn    { background: var(--color-warn-50);    color: var(--color-warn-ink);    border-color: color-mix(in oklab, var(--color-warn) 34%, var(--color-warn-50)); }
.badge-danger  { background: var(--color-danger-50);  color: var(--color-danger-ink);  border-color: color-mix(in oklab, var(--color-danger) 34%, var(--color-danger-50)); }
.badge-info,
.badge-primary { background: var(--color-info-50);    color: var(--color-info-ink);    border-color: color-mix(in oklab, var(--color-info) 34%, var(--color-info-50)); }
.badge-neutral { background: var(--color-neutral-50); color: var(--color-neutral-ink); border-color: color-mix(in oklab, var(--color-neutral-ink) 22%, var(--color-neutral-50)); }

/* Stock progress bar */
.stock-bar {
  position: relative;
  height: 6px;
  border-radius: 5px;
  background: #F1F5F9;
  overflow: hidden;
  min-width: 80px;
}
.stock-bar > span {
  position: absolute; inset: 0;
  background: var(--color-success);
  border-radius: 5px;
  transition: width 200ms ease;
}
.stock-bar.warn > span   { background: var(--color-warn); }
.stock-bar.danger > span { background: var(--color-danger); }

/* -------------------------------------------------------------------------
   Pagination
   ------------------------------------------------------------------------- */
.pagination {
  display: flex; align-items: center; justify-content: space-between;
  /* The controls close the row as one group: with three children and nothing to
     push them together, space-between would strand the size selector in the middle
     of the footer instead of beside the page numbers it belongs to. */
  gap: 14px;
  /* The footer is one line. Size selector then page numbers, side by side, never one
     stacked over the other — they are two halves of the same control. */
  flex-wrap: nowrap;
  padding: 10px 16px;
  border-top: 1px solid var(--color-border);
  background: var(--color-surface);
  border-radius: 0 0 var(--radius-md) var(--radius-md);
  font-size: 12px;
  color: var(--color-text-muted);
}
/* How many rows a page holds, sitting immediately left of the page numbers: how many,
   then which (memory.md §21). It reads a step quieter than the pager beside it — the
   page you are on is the answer, the size is the setting behind it.

   The select is sized to its own content rather than the `.input` full width it
   inherits: four values, none wider than three digits. */
.page-size { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; font-size: 10px; }
.page-size > span { color: var(--color-text-muted); }
.page-size .select { width: auto; padding: 4px 26px 4px 9px; font-size: 10px; background-position: right 8px center; }

/* Size selector and page numbers travel as one cluster at the end of the footer row,
   whatever else the footer carries — a count, a closing line of prose, or nothing at
   all. `margin-left: auto` is what puts it right in all three cases; the footer's own
   space-between only lands it there when something else is on the row. */
.page-controls { display: flex; align-items: center; gap: 12px; margin-left: auto; }

/* The pager's own hiding, kept off `display` so a page filter and this can both
   have an opinion about a row without either reading the other's. */
/* Two shapes of list page, so two shapes of row: a table row, and a material in a
   tree view's product rail. The rail is not a table and never will be — it is a
   two-level nav whose leaves are pieces — but it is a list you work through, and
   §21 is about those, not about `<table>`. */
tr.is-off-page,
.tree-node.is-off-page { display: none !important; }

.pagination .spacer { flex: 1; }
.pager { display: flex; align-items: center; gap: 4px; }
.pager button {
  min-width: 28px; height: 28px;
  border-radius: 5px;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  font: inherit;
  font-size: 12px;
  color: var(--color-text-muted);
  cursor: pointer;
  transition: all 120ms;
}
.pager button:hover { border-color: var(--color-primary); color: var(--color-primary-700); }
.pager button.active {
  background: var(--color-primary);
  color: #fff;
  border-color: var(--color-primary);
}
.pager button:disabled { opacity: 0.4; cursor: not-allowed; }

/* -------------------------------------------------------------------------
   Tabs
   ------------------------------------------------------------------------- */
/* Fluid tab row. Every dimension below is clamp(min, <slope>vw + <base>, max),
   tuned so a 1280px viewport reproduces the previous fixed values exactly (13px
   type, 14px side padding, 6px icon gap, 4px tab gap, 24px icon) — it grows on
   wide monitors and gives back space on narrow ones instead of scrolling sooner.
   Floor is 12px type / 18px icon: below that the row is unreadable, so overflow-x
   takes over. */
.tabs {
  display: flex; gap: clamp(2px, 0.16vw + 1.2px, 5px);
  border-bottom: 1px solid var(--color-border);
  /* Hug the panel below, stand clear of whatever is above: 8px under the row and
     10px over it, so the strip reads as grouped with the content it controls and
     not with the KPI row or header it follows. The 8px matches .subtabs. */
  margin-top: 10px;
  margin-bottom: 8px;
  /* Narrow viewports scroll the strip rather than squeezing it. Without this the
     flex items compress below their content: the longest label wraps to two lines
     and the icon — an SVG, so shrinkable — distorts. */
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}
.tab {
  /* Horizontal padding is the cheapest width in the strip: seven tabs pay it
     fourteen times over, so trimming ~2px a side buys back ~28px and is what
     keeps the row off a scrollbar in the 950–1100px band. */
  padding: clamp(8px, 0.55vw + 3px, 12px) clamp(7px, 0.62vw + 3.3px, 15px);
  font-size: clamp(12px, 0.35vw + 8.5px, 14px);
  color: var(--color-text-muted);
  border-bottom: 2px solid transparent;
  background: transparent;
  border-left: 0; border-right: 0; border-top: 0;
  cursor: pointer;
  transition: color 150ms, border-color 150ms;
  display: inline-flex; align-items: center; gap: clamp(4px, 0.3vw + 2.2px, 8px);
  font-family: inherit;
  /* Multi-word labels ("Tiles / Pavers") must not wrap — a two-line tab is taller
     than its siblings and knocks the whole row out of alignment. */
  white-space: nowrap;
  flex: none;
}
/* Keep the icon at its intrinsic size. Default flex-shrink lets the SVG compress
   horizontally when the row is tight, which visibly deforms grid-style glyphs. */
.tab svg {
  flex: none;
  width: clamp(15px, 1.15vw + 4px, 20px);
  height: clamp(15px, 1.15vw + 4px, 20px);
}
/* Per-glyph tuning for a tab whose icon needs it (Catalog's Pricing dollar-sign).
   Two adjustments, both scoped to this class — remove it from the button if the icon
   is ever swapped for one that fills its box at the default size.
   - Size: 25% down from the sibling tab icon, tracking the same fluid curve.
   - Gap: most tab icons fill their 24-unit viewBox to within ~2 units of each edge,
     but dollar-sign draws only to x=18, leaving ~4 units empty on the label side, so
     the same 6px gap reads wider than its neighbours'. 4 units at 18px is 3px, so
     that is what comes back off to make the OPTICAL distance match. */
.tab--icon-narrow svg {
  width: clamp(11.25px, 0.8625vw + 3px, 15px);
  height: clamp(11.25px, 0.8625vw + 3px, 15px);
  margin-right: -3px;
}
.tab:hover { color: var(--color-primary-700); }
/* A locked step in a wizard stepper — the tab still shows where the flow goes,
   it just can't be jumped to yet. */
.tab:disabled { opacity: 0.45; cursor: not-allowed; }
.tab:disabled:hover { color: var(--color-text-muted); }
/* A required field that is still blank. The message sits on the field itself —
   a summary in the wizard's navigation row names fields the user then has to hunt
   for, and competes with Back/Next for the same space. */
.field-req-note { margin: 5px 0 0; font-size: 12px; color: var(--color-danger); }
.field.is-missing .input,
.field.is-missing .select,
.field.is-missing .textarea { border-color: var(--color-danger); }
/* Summary of what another step is still missing, shown under the action it blocks
   (Create Product) — never in a navigation row. */
.step-hint { font-size: 12px; color: var(--color-danger); }
.tab.active {
  color: var(--color-primary-700);
  border-bottom-color: var(--color-primary);
  font-weight: 500;
  background: var(--color-primary-50);
  border-radius: 5px 5px 0 0;
}
/* ---- Attached: the strip joins the panel it controls --------------------
   The strip's own bottom rule becomes the panel's top edge and the active tab
   takes the panel surface, so tab and panel read as one continuous surface
   with nothing between them.

   The active indicator moves to the tab's TOP edge. On the bottom edge it
   draws a line exactly where the tab joins the panel — separating the two
   things it is meant to connect.

   Opt-in, because most strips do not qualify. Measured across all 25 `.tabs`
   in the portal: 6 sit above a two-column grid, where the panel is not one
   surface and a tab on the right of the strip would have nothing under it;
   the wizard steppers (import, receive, product-wizard, settings-api-v2) put
   a step header between the strip and the first card, 52–109px of it; and
   `stock.html` nests its card three levels inside a studio layout. Those keep
   the detached look. Nine strips qualify and carry this class.

   Panels are addressed with `~`, not `+`: only the first panel is the strip's
   adjacent sibling, so `+` would leave every other tab's panel with its top
   border intact and draw a line under the active tab as soon as you switched.

   `.tabs` computes `overflow-y: auto` — a non-visible `overflow-x` forces it —
   so the active tab cannot hang 1px below the strip to cover its rule without
   being clipped or raising a scrollbar. The strip carries 1px of bottom
   padding instead and the tab's overhang lands inside it. */
/* The strip gives up its own bottom rule and sits 1px INTO the panel, so the
   panel's own top border becomes the rule. That is what makes this work without
   knowing anything about the panel's structure: the rule is drawn by whatever is
   below, at whatever nesting depth, and the strip simply overlaps it. The active
   tab's 2px surface-coloured bottom border then paints over that 1px where the
   two meet, which is the whole join.

   The earlier version kept the strip's rule and removed the panel card's top
   border by selector. That needs one selector per layout, and panels differ
   *within* a page — clients.html reaches its card through `.card-header`,
   catalog-bundle through `.stack`, stock through the studio wrapper — so any
   list of selectors leaves some tab drawing a 2px double line. Overlapping is
   structure-independent and cannot double-draw. */
.tabs--attached {
  border-bottom: 0;
  /* No standoff of its own: the strip is a block in the page stack, and the box above
     it owns the joint (see the 25px rule by .kpi-summary). The 10px .tabs adds for
     strips that sit inside a card would land on top of that and make this one joint
     wider than every other joint on the page. */
  margin-top: 0;
  margin-bottom: -1px;
  padding-bottom: 0;
  position: relative;
  z-index: 1;
}
/* A strip that opens a dialog has nothing above it to stand away from — the
   header is right there and the body's padding is already the gap. */
.modal-body > .tabs:first-child { margin-top: 0; }
.tabs--attached .tab.active {
  background: var(--color-surface);
  border-bottom-color: var(--color-surface);
  box-shadow: inset 0 2px 0 0 var(--color-primary);
}
/* The panel draws its own top edge, so the strip has something to sit on no
   matter what the panel opens with — a card, a sub-tab row, an alert banner, a
   toolbar. This is what makes the join independent of panel contents: the tab
   attaches to the PANEL, not to whatever happens to be first inside it.

   It is an inset shadow rather than a border on purpose. A border would add 1px
   to the panel's box and push a flush card down by that much, leaving the
   panel's rule and the card's own top border as two lines a pixel apart. An
   inset shadow paints inside the box without occupying space, so a flush card's
   border lands on exactly the same pixel row and the two coincide as one line. */
.tabs--attached ~ [data-tab-panel],
.tabs--attached ~ * > [data-tab-panel] {
  box-shadow: inset 0 1px 0 0 var(--color-border);
}


/* ---- The tab shell: what attached tabs attach TO ---------------------------
   THE PLATFORM RULE, in two halves.

   1. The box a tab strip connects to is WHITE, on every page. On most pages
      that box is already the single `.card` the panel opens with, and nothing
      more is needed. Where a panel opens on a GRID instead — a row of dashboard
      cards, a board of columns — there is no card to attach to and the strip
      ends in mid-air with the grid loose on the page beneath it. `.tab-shell`
      is that missing panel: it wraps the panels, so the join is the same
      whether the tab opens on one card or on twenty.

   2. Nothing inside it repeats its colour. A white box on a white panel is not
      a box (memory.md §13), so the tiles, cards and columns that sit INSIDE a
      shell take the control ground and let the shell be the white one. Where a
      panel's whole content is a single full-width card, that card has nothing
      to be distinguished FROM — it is the panel — so it merges into the shell
      rather than being drawn on top of it.

   Square at the top, where the strip meets it: a rounded corner under the
   leftmost tab is the join coming apart. */
.tab-shell {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 0 0 var(--radius-md) var(--radius-md);
  box-shadow: var(--shadow-sm);
  /* THE LEAD-IN UNDER A TAB STRIP IS ONE NUMBER, EVERYWHERE ON THE PLATFORM.
     Every other tabbed page gets it from the white band a table header draws
     above itself, because on those pages the strip attaches straight to the card.
     A shell has padding of its own, so it READS THE SAME TOKEN rather than
     restating the number — it was a literal 10 beside a literal 10 until the band
     moved and these screens were left sitting six pixels lower than catalog,
     pricing, quotes, stock, campaigns and showrooms. The sides and the bottom keep
     the shell's own 16: the top edge is a JOIN, and it is the only one that has to
     agree with other pages. Content that carries its own standoff gives it up just
     below, so this is the whole gap and never the band on top of something else. */
  padding: var(--table-head-band) 16px 16px;
}
/* The panel drew its own top edge because it had none to sit on. Inside a shell
   that line lands a pixel under the shell's own border and reads as two. */
.tab-shell > [data-tab-panel] { box-shadow: none; }

/* A panel that IS one card: the shell is already the white box that card was
   going to be, so the card gives up its chrome instead of drawing a second one
   16px inside the first. Its header, body and dividers carry on unchanged. */
.tab-shell > [data-tab-panel] > .card {
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
}
/* With the card's own padding gone from the equation, its header sits on the
   shell's padding rather than adding to it. */
.tab-shell > [data-tab-panel] > .card > .card-header:first-child { padding-top: 0; }
/* Same correction for the two other things a card opens with, so the tab strip
   sits the same distance above a list as it does above an alert or a summary
   bar. A table header's white band and a card body's top padding both exist to
   stand their content away from the card's own top edge — and when the card is
   the FIRST thing in the panel, the shell's own lead-in above is already that
   standoff, so the gap was being paid twice: 26px before a table and 32px
   before a body, against 16 everywhere else. Zeroing them here is what lets the
   shell state the platform's 10 above and have it come out as 10.

   `:first-child` on the card is the whole point and is not tidiness. A panel
   that opens on an alert or a glance bar (Clients, Opportunities, Alerts) has
   something ABOVE its table, and there the band is the only thing separating
   the two — those panels must keep it. This narrows to the case where the
   shell's edge is what the content would otherwise crowd. */
.tab-shell > [data-tab-panel] > .card:first-child > .card-body:first-child { padding-top: 0; }
.tab-shell > [data-tab-panel] > .card:first-child > .table-wrap:first-child .data-table thead th { border-top: 0; }

/* Cosmetic only, and deliberately best-effort: square off the top corners of the
   panel card so the active tab does not sit over a rounded corner. Where a
   selector does not reach, the result is a 7px rounded corner under the leftmost
   tab — not a broken join, which is why this list does not have to be complete. */
.tabs--attached ~ .card,
.tabs--attached ~ [data-tab-panel] > .card:first-child,
.tabs--attached ~ [data-tab-panel] > .cols-2-1 > .card,
.tabs--attached ~ [data-tab-panel] > .cols-2-1 > :is(.stack, .pm-stack) > .card:first-child {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}
.tab .count,
.subtab .count,
/* A button may carry one too — an action that opens a list says how much is in
   it, in the same pill the strips use. */
.btn .count {
  background: var(--color-primary-50);
  color: var(--color-primary-700);
  padding: 0 6px;
  border-radius: 5px;
  font-size: 11px;
  font-family: 'Hanken Grotesk', sans-serif;
}
/* On a product-type tab the count READS AS PART OF THE LABEL — "7 Blocks", "642 Slabs" —
   so it leads the label and is set in the label's own type, with no chip around it. The
   count span therefore comes first in the markup, after the icon.
   - No chip: the shared rule above gives every `.count` a tinted background, side padding
     and a radius. All three are unset here, so nothing boxes the number in.
   - Same type as the label: `font-size`, `font-family` and `color` are reset to `inherit`
     rather than restated, so the count tracks `.tab`'s own fluid 12→14px curve and its own
     colour automatically — muted while the tab is idle, primary-700 once it is active. The
     shared rule's `--color-primary-700` is dropped: nothing should set the figure apart from
     the word it qualifies, since the two are read as one phrase.
   - Tight: `.tab` is a flex row and its `gap` would space the number from its label exactly
     as far as it spaces the icon, which is what made the old trailing count read as a
     floating figure. Cancelling the gap and re-stating it as a fixed 4px pairs the two.
     Subtracting the same clamp the gap uses keeps that 4px true at every viewport instead
     of drifting from 4px to 8px; the icon keeps the full fluid gap. */
.tab .count {
  background: none;
  padding: 0;
  border-radius: 0;
  font-size: inherit;
  font-family: inherit;
  color: inherit;
  margin-right: calc(4px - clamp(4px, 0.3vw + 2.2px, 8px));
}

/* Segmented sub-tabs — a nested tab row on a grey track (e.g. Catalog › Pricing).
   The global tab JS ignores .subtab / [data-subtab-panel]; a [data-subtabs] handler
   toggles .active.

   THE CHOSEN SEGMENT IS FILLED, and filled in the primary — the same ink the
   page's own primary button wears. It was the palest wash of that colour on a
   grey track, two values apart; on a page toolbar, where the track is a shade
   darker still, the chosen one all but disappeared and the bar read as a row of
   loose words with nothing on it saying which one you were looking at.

   AND IT REACHES THE BOX. The track used to hold its segments 4px off its own
   edge and 4px apart, so the chosen one read as a pill floating in a tray.
   Nothing separates them now and nothing insets them: the fill runs the full
   height of the control and out to its border, which is what makes a segmented
   control readable across a room. The padding the track gave up went into the
   segments themselves, so every bar still stands the height it always did. */
.subtabs {
  display: flex; gap: 0; flex-wrap: wrap;
  padding: 0;
  background: var(--color-surface-alt);
  /* Transparent here and coloured on the page toolbar below, but reserved either
     way so a bar keeps one height whichever ground it stands on. */
  border: 1px solid transparent;
  border-radius: 5px;
  margin-bottom: 8px;
  /* The end segments take the box's own corners rather than standing square
     inside them. */
  overflow: hidden;
}
.subtab {
  /* THE HUE THE OPTION ANSWERS IN. Primary where the row has no states to name —
     Blocks and Slabs are kinds, not states — and the state's own colour where it
     does, which is §19's contract: an option and the badge its rows carry are the
     same colour. Declared here rather than under `[data-status-filter]` so the
     chosen rule below can be written once for every bar on the platform. */
  --status-hue: var(--color-primary);
  /* The deeper step the chosen label and count are set in. */
  --status-ink: var(--color-primary-700);
  padding: 10px 13px;
  font-size: 13px;
  color: var(--color-text-muted);
  background: transparent;
  border: 0; border-radius: 0;
  cursor: pointer;
  display: inline-flex; align-items: center; gap: 6px;
  font-family: inherit;
  transition: background 120ms, color 120ms, box-shadow 120ms;
}
.subtab[data-state='success'] { --status-hue: var(--color-success);     --status-ink: var(--color-success-ink); }
.subtab[data-state='warn']    { --status-hue: var(--color-warn);        --status-ink: var(--color-warn-ink); }
.subtab[data-state='danger']  { --status-hue: var(--color-danger);      --status-ink: var(--color-danger-ink); }
.subtab[data-state='info']    { --status-hue: var(--color-info);        --status-ink: var(--color-info-ink); }
.subtab[data-state='neutral'] { --status-hue: var(--color-neutral-ink); --status-ink: var(--color-neutral-ink); }
/* The ring turns inward — drawn outside, the clipped track would eat it. */
.subtab:focus-visible { outline-offset: -2px; }
.subtab:hover { color: var(--color-text); background: color-mix(in oklab, var(--status-hue) 9%, transparent); }
/* CHOSEN is not here. Every variant below — the loose options of a pick-several
   bar, the two cells of a choice group — gives `.subtab` a ground of its own at
   the same specificity this rule would have, so a chosen rule written here loses
   to whichever of them comes later in the file and the option stays unchosen.
   It is written ONCE, after the last of them, under "CHOSEN" further down. */

/* ---- STATUS FILTER: the one way to pick a status (memory.md §19) -----------
   Stock, Quotes, Campaigns, Showroom and VIP Offers each grew their own control
   for the same question, so the same job looked like five jobs. `[data-status-filter]`
   is the mark that a bar's options are STATES, and it no longer carries any style
   of its own: the hue comes off each option's `data-state` and the chosen ring is
   the one `.subtab.active` above, which every bar on the platform now wears.

   The hue comes off `data-state`, NOT off the status word. The five states are
   §19's, named for the badge class each matches, because that is the contract: an
   option and the badge its rows carry are the same colour. The same word can be
   two states in two lists and this is what lets it — a live VIP Offers is
   `success`, a client request still awaiting an answer is `warn`, and both are
   correctly called "Active". */

/* ---- PICK SEVERAL: the track breaks up -------------------------------------
   THE ONE THING THE TWO FAMILIES NEVER AGREED ON. A pick-one strip and a
   pick-several bar were drawn identically and behaved differently, so the only
   way to learn which you were looking at was to press it and watch what happened
   to the option you had just left.

   Now the row says so before it is touched. Pick one keeps the continuous track
   below — the options touch, and the light moves along it, because there is one
   lamp and it is somewhere. Pick several breaks the track up: each option stands
   on the control ground as its own switch with air between them, because there
   are four lamps and any of them is on.

   Each loose option takes `--color-control` rather than standing on the page or
   card behind it (memory.md §13 — a control never repeats the ground it sits on).
   The track's own background and clipping go with it; the options carry their
   corners themselves.

   THE BORDER MOVES RATHER THAN GOING. `.subtabs` reserves one whether or not it
   draws it, so a bar keeps one height on either ground; with the track gone, that
   1px is reserved on each option instead and the row still stands exactly as tall
   as it did. */
.subtabs--many {
  gap: 6px;
  background: none;
  border: 0;
  overflow: visible;
}
.subtabs--many .subtab {
  background: var(--color-control);
  border: 1px solid transparent;
  border-radius: 5px;
}
/* ...AND ON THE PAGE'S OWN GROUND IT IS DRAWN. This is the same trap the track
   fell into one rule down: a page toolbar stands on the page, whose grey is a
   value off the control ground, so four loose options on it read as loose WORDS
   and the control stops looking like a control. The hairline the track used to
   take is taken by each option instead — the one the search field beside them
   already has. */
.toolbar--page .subtabs--many { background: none; border: 0; }
.toolbar--page .subtabs--many .subtab { border-color: var(--color-control-border); }

/* ---- SAVE AS DEFAULT: the last thing on a selector row ---------------------
   Built by app.js, never authored — see the block there for how a row opts in.

   It is an icon and not a labelled button because it closes a row of choices and
   must not read as one of them: a word at the end of four statuses is a fifth
   status. Quiet at rest, in the row's own muted ink, and it takes the primary
   only on hover — this is the least-pressed control on the row and should not be
   the loudest thing on it. */
.save-default {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  align-self: stretch;
  flex: none;
  padding: 0 9px;
  border: 0;
  background: none;
  color: var(--color-text-subtle);
  border-radius: 5px;
  cursor: pointer;
  transition: color 120ms, background 120ms;
}
.save-default:hover { color: var(--color-primary-700); background: var(--color-surface); }
.save-default svg { width: 15px; height: 15px; }

/* On a tab strip there is no track to sit on, so the hover fill would be a box in
   mid-air; the colour alone carries it, and the button clears the 2px underline
   the tabs reserve so it never looks like a tab that lost its own. */
.tabs .save-default { align-self: center; margin-bottom: 2px; }
.tabs .save-default:hover { background: none; }

/* Tree / flat view toggle: two icon cells (Stock, Quotes, Orders). */
.viewseg { display:inline-flex; height:34px; border:1px solid var(--color-border-strong); border-radius:6px; overflow:hidden; }
.viewseg-btn { display:inline-flex; align-items:center; justify-content:center; width:38px; border:0; background:var(--color-surface); color:var(--color-text-subtle); cursor:pointer; }
.viewseg-btn + .viewseg-btn { border-left:1px solid var(--color-border); }
.viewseg-btn:hover { background:var(--color-surface-alt); color:var(--color-text); }
.viewseg-btn.active { background:var(--color-primary-50); color:var(--color-primary-700); }
.viewseg-btn svg { width:17px; height:17px; }

/* A segmented control is a set of walled cells: the save icon becomes the last
   cell rather than floating inside the border. */
.viewseg .save-default {
  width: 38px;
  padding: 0;
  border-left: 1px solid var(--color-border);
  border-radius: 0;
  background: var(--color-surface);
}
.viewseg .save-default:hover { background: var(--color-surface-alt); }

/* Choice variant — a form answer (Yes / No, Priced by, …), not tab navigation.
   Every option carries a fill of its own, never white: white is the page's own
   surface, so a white option reads as background rather than as a choice. The
   chosen one takes the stronger of the two fills — a primary tint plus a soft
   lift. Options run two per row, sized by their column, so a long label can't
   push a group to one-option-per-row in a narrow column. */
/* ---------- Scope sub-tabs, inside the toolbar ---------------------------
   A `.subtabs` moved onto the toolbar row instead of sitting on a band of its
   own beneath it: whose records you are looking at narrows the same list the
   search narrows, so it belongs beside the search rather than under it.

   Sized down to stand level with the `.btn-sm` buttons it shares the row with
   — 5px 9px there, matched here — and it never grows or shrinks, so the search
   keeps the flexible width. Quotes and Clients both use it; it lived in
   quotes.css while it had one caller. */
.subtabs--inline {
  margin-bottom: 0; flex: none;
  padding: 0;
}
.subtabs--inline .subtab { padding: 7px 10px; font-size: 12px; }
.subtabs--inline .subtab svg { width: 14px; height: 14px; }

/* ---------- ...and standing on the page's own ground -------------------
   `.subtabs` was drawn against a white card, where its `--color-surface-alt`
   track is a visible step down from the panel. A `.toolbar--page` has no card
   under it: the row stands on the page, whose ground is the same grey the track
   is, and the track disappears. What is left is the CHOSEN option floating as the
   only thing on the row wearing a box, with the unchosen ones reading as loose
   text beside it — the control stops looking like a control.

   So on that row the track takes the control ground and the hairline the search
   field beside it already has (memory.md §13 — a control never repeats the ground
   it sits on). The border costs 2px of height; that comes back off the padding so
   the control still stands its old height, level with the buttons sharing the row. */
.toolbar--page .subtabs {
  background: var(--color-control);
  border-color: var(--color-control-border);
}

.subtabs--choice {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 4px;
  padding: 4px;
  /* Sized by its own options, not by the column — a Yes/No must not stretch across a
     card the way a four-option group does. Both options stay equal width (1fr each). */
  width: fit-content;
  max-width: 100%;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 7px;
}
.subtabs--choice .subtab {
  justify-content: center;
  text-align: center;
  /* Its own card, its own gaps — the two options stay rounded here. */
  border-radius: 5px;
  padding: 6px 10px;
  min-width: 72px;
  line-height: 1.2;
  background: var(--color-surface-alt);
}
.subtabs--choice .subtab:hover { background: var(--color-border); color: var(--color-text); }
/* Chosen is NOT restated here. It was a primary tint with a bold label and a soft
   lift — a third chosen style on a platform that now has one — and the shared
   wash below says the same thing in the same words as every other bar. Only the
   corners are the choice variant's own. */

/* ============================================================================
   CHOSEN: a white chip ringed in its own colour
   ============================================================================
   ONE TREATMENT FOR EVERY SELECTOR ON THE PLATFORM. The pick-one strips used to
   fill the chosen segment solid in the primary, the pick-several bars washed it in
   its state's colour, and the choice groups took a primary tint with a bold label
   — three answers to one question, so the same job looked like three controls and
   a reader moving between them read three. This is the one answer now.

   IT SITS LAST ON PURPOSE. Every variant above hands `.subtab` a ground of its own
   at two classes, which is exactly this rule's own specificity — so the one that
   comes later in the file wins, and that has to be this one. Written any higher it
   is silently overruled by `--many` and `--choice`, and a chosen option renders as
   an unchosen one with no error anywhere.

   A WHITE CHIP AND A 1PX RING, BOTH IN THE OPTION'S COLOUR (user, 2026-10-07,
   over a pale wash, a solid fill and a dark ink fill). The old 20%
   wash landed within a value of the grey control ground the unchosen ones stand
   on — a chosen Completed or Hold looked unchosen. White against that grey, ringed
   in the hue, cannot be mistaken. A loose chip's ring is its border plus a 0.5px
   inset shadow; a track segment has no border and draws a 1px inset alone.

   THE LABEL KEEPS ITS WEIGHT, AND A LOOSE CHIP'S RING IS 1.5PX, NOT 2 (user,
   2026-10-13): with the white ground already marking the choice, a heavier ring
   and a bolder label made the row read too heavy.

   `--color-surface` rather than a literal white keeps it right on the dark theme. */
.subtab.active {
  background: var(--color-surface);
  border-color: var(--status-hue);
  box-shadow: inset 0 0 0 1px var(--status-hue), 0 1px 3px rgb(15 23 42 / 0.10);
  color: var(--status-ink);
}
.subtabs--many .subtab.active { box-shadow: inset 0 0 0 0.5px var(--status-hue), 0 1px 3px rgb(15 23 42 / 0.10); }
/* The page toolbar draws each loose option's hairline three classes deep; the ring
   has to outrank it or a chosen chip keeps a grey outer edge. */
.toolbar--page .subtabs--many .subtab.active { border-color: var(--status-hue); }
/* THE COUNT ALWAYS SITS ONE STEP DEEPER THAN THE OPTION IT IS ON, chosen or not.
   It used to be a primary tint on every bar and a hue tint on the status ones,
   which is the same disagreement in miniature. Unchosen it takes a neutral step
   off the ink, so it is one value deeper than whatever ground it lands on — the
   grey track, the control ground of a loose chip — without carrying a colour of
   its own. Chosen it takes a deeper step of the option's OWN hue: a blue count on
   an amber panel is two colours saying one thing. */
.subtab .count {
  background: color-mix(in oklab, var(--color-text) 9%, transparent);
  color: var(--color-text-muted);
}
.subtab.active .count {
  background: color-mix(in oklab, var(--status-hue) 18%, var(--color-surface));
  color: var(--status-ink);
}

/* ---- A selector row on the tab strip ---------------------------------------
   The status chips, and whatever shared their row, ride the right end of the tab
   strip, level with the tabs, so the panel starts a row higher (user, 2026-10-11).
   One size slimmer so a chip stands inside a tab's height, and drawn as the page
   toolbar drew them: the strip stands on the page's own ground, where a borderless
   chip reads as a loose word. app.js fits the row to the room the strip has left —
   counts off first, all together, then chips fold from the end into "+N" — and
   builds the `.tabs-fold` it folds into. */
.tabs__tools { margin-left: auto; padding-left: 16px; display: flex; align-items: center; gap: 12px; flex: none; }
.tabs__tools .subtabs { flex: none; flex-wrap: nowrap; margin: 0; background: var(--color-control); border-color: var(--color-control-border); }
.tabs__tools .subtabs--many { gap: 4px; background: none; border: 0; }
.tabs__tools .subtab { padding: 7px 9px; line-height: 16px; white-space: nowrap; }
.tabs__tools .subtabs--many .subtab { border-color: var(--color-control-border); }
.tabs__tools .subtabs--many .subtab.active { border-color: var(--status-hue); }
.tabs__tools .subtab.is-folded,
.tabs__tools .subtabs.is-bare .subtab .count { display: none; }
/* The activity log's facets ride the strip too (user, 2026-10-13). The strip
   scrolls sideways, which would clip a menu hung under it, so a menu here is
   fixed and app.js's toggleDropdown places it under its button. */
.tabs__tools:has(.facet) { gap: 6px; }
.tabs__tools .dropdown-menu { position: fixed; right: auto; }
.tabs:has(.tabs__tools .dropdown-menu.open) { z-index: 5; }
.tabs-fold { flex: none; display: flex; }
.tabs-fold__btn {
  font-family: inherit; font-size: 12px; line-height: 16px; font-weight: 600;
  padding: 7px 9px;
  border: 1px solid var(--color-control-border); border-radius: 5px;
  background: var(--color-control); color: var(--color-text-muted);
  white-space: nowrap; cursor: pointer;
}
.tabs-fold__btn:hover,
.tabs-fold__btn.has-lit { color: var(--color-text); border-color: var(--color-text-subtle); }
/* Every chip folded: the button names the filter, and opens like one. */
.tabs-fold__btn.is-all::after {
  content: ''; display: inline-block; width: 5px; height: 5px; margin: 0 1px 3px 8px;
  border: solid currentColor; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg);
}
/* Against the window: the strip scrolls sideways, and its overflow would clip a menu
   hung inside it. The strip is its own layer, and the register's sticky header later
   on the page paints over it, so it rises while the menu is open. */
.tabs-fold__menu {
  position: fixed; z-index: 100; display: none; min-width: 190px; padding: 6px;
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius-md); box-shadow: var(--shadow-lg); text-align: left;
}
.tabs-fold__menu.open { display: block; }
.tabs:has(.tabs-fold__menu.open) { z-index: 5; }
.tabs-fold__row {
  --status-hue: var(--color-primary);
  display: flex; align-items: center; gap: 9px;
  width: 100%; padding: 7px 10px;
  border: 0; border-radius: var(--radius-sm); background: none;
  font-family: inherit; font-size: 13px; color: var(--color-text);
  text-align: left; white-space: nowrap; cursor: pointer;
}
.tabs-fold__row:hover { background: var(--color-primary-50); }
.tabs-fold__row[data-state='success'] { --status-hue: var(--color-success); }
.tabs-fold__row[data-state='warn']    { --status-hue: var(--color-warn); }
.tabs-fold__row[data-state='danger']  { --status-hue: var(--color-danger); }
.tabs-fold__row[data-state='info']    { --status-hue: var(--color-info); }
.tabs-fold__row[data-state='neutral'] { --status-hue: var(--color-neutral-ink); }
.tabs-fold__check {
  position: relative; flex: none; width: 14px; height: 14px;
  border: 1px solid var(--color-control-border); border-radius: 3px;
  background: var(--color-control);
}
.tabs-fold__row.is-on .tabs-fold__check { background: var(--status-hue); border-color: var(--status-hue); }
.tabs-fold__row.is-on .tabs-fold__check::after {
  content: ''; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px;
  border: solid var(--color-surface); border-width: 0 2px 2px 0; transform: rotate(45deg);
}
.tabs-fold__count { font-size: 12px; color: var(--color-text-subtle); }

/* -------------------------------------------------------------------------
   Expandable table rows (per-item price overrides as children) + toggle switch
   ------------------------------------------------------------------------- */
.row-toggle {
  border: 0; background: transparent; cursor: pointer;
  color: var(--color-text-subtle);
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0; width: 16px; height: 16px; flex: none;
  transition: transform 120ms, color 120ms;
}
.row-toggle svg { width: 15px; height: 15px; }
.row-toggle[aria-expanded="true"] { transform: rotate(90deg); color: var(--color-primary-700); }
.row-toggle-spacer { display: inline-block; width: 16px; flex: none; }
tr.price-child > td { background: var(--color-surface-alt); }
.child-branch { color: var(--color-text-subtle); flex: none; }

/* Row-level add — blue "+" to add a sub-price (override) to a product row.
   Hidden until the row is hovered / keyboard-focused (space is reserved, so no layout shift). */
.row-add {
  width: 20px; height: 20px; flex: none; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; border-radius: 5px;
  background: var(--color-primary); color: #fff;
  opacity: 0;
  transition: opacity 120ms ease, background 120ms ease;
}
tr:hover .row-add,
tr:focus-within .row-add { opacity: 1; }
.row-add:hover { background: var(--color-primary-600); }
.row-add svg { width: 13px; height: 13px; stroke-width: 2.6; }

/* Sub-price counter — shown only on products that have overrides */
.row-count {
  flex: none;
  font-size: 11px; font-weight: 600;
  color: var(--color-primary-700); background: var(--color-primary-50);
  padding: 0 7px; border-radius: 5px;
  font-variant-numeric: tabular-nums;
}

.switch-field {
  display: inline-flex; align-items: center; gap: 8px;
  font-size: 13px; color: var(--color-text-muted);
  cursor: pointer; user-select: none;
}
/* A control whose label runs to more than one row aligns to the *first* row —
   the main label — not to the middle of the block. The switch belongs to the
   setting's name; centred against a two- or three-line description it drifts
   down beside the explanation instead of the thing it switches. Scoped by
   `:has(.cell-stack)` so single-line toggles keep their centred alignment. */
.switch-field:has(.cell-stack) { align-items: flex-start; }
/* Same rule where the switch is a bare child of a generic .row rather than
   wrapped in its own .switch-field label (e.g. Settings › Product Types). */
.row:has(> .switch):has(> .cell-stack) { align-items: flex-start; }
/* Flush with the text box reads a touch high — a label's glyphs start below the
   top of their line box, the switch's pill does not. 1px puts the two on the
   same optical line. */
.switch-field:has(.cell-stack) > .switch,
.row:has(> .switch):has(> .cell-stack) > .switch { margin-top: 1px; }
.switch {
  position: relative; width: 34px; height: 20px; flex: none;
  border-radius: 5px; background: var(--color-border-strong);
  transition: background 140ms;
}
.switch::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 16px; height: 16px; border-radius: 4px;
  background: #fff; box-shadow: var(--shadow-sm);
  transition: transform 140ms;
}
.switch[aria-checked="true"] { background: var(--color-primary); }
.switch[aria-checked="true"]::after { transform: translateX(14px); }

/* -------------------------------------------------------------------------
   Visibility chip — an inherited setting with a per-row override

   Three stored states: `auto` (inherit), `show`, `hide`. `data-vis` carries what
   the row stores; `data-vis-resolved` carries what the reader actually gets, so a
   chip is always the colour of the answer it prints. Shared: the showroom items
   table, the showroom menu defaults, and the per-promotion menu override all use it.
   ------------------------------------------------------------------------- */
/* One width for every state — 72px, which is what "Default" needs at its own
   smaller type. Show and Hide are ~61px on their own content, and letting them
   size themselves slid the pair 11px sideways each time a chip was cycled: the
   column is right-aligned, so a narrower chip drags its neighbour with it. The
   box a control occupies must not depend on which state it is in. */
.sf-vischip {
  display: inline-flex; align-items: center; justify-content: center; gap: 5px;
  min-width: 72px; padding: 4px 8px;
  font-family: inherit; font-size: 12px; font-weight: 600; line-height: 18px;
  border: 1px solid var(--color-border-strong); border-radius: 5px;
  background: var(--color-surface); color: var(--color-text);
  cursor: pointer; white-space: nowrap;
  transition: color 140ms, background 140ms, border-color 140ms;
}
.sf-vischip svg { width: 13px; height: 13px; flex: none; }
.sf-vischip:hover { border-color: var(--color-primary); }
.sf-vischip:focus-visible { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px var(--color-primary-50); }

/* Inherited is the absence of an override, so it recedes: dashed and unfilled,
   subtle ink, and normal weight against the overrides' 600. Nothing has been
   decided on this row, and 38 of 44 chips are in this state — they have to read
   as quiet background so the handful of rows set by hand are what the eye finds
   scanning down the column. This is now the ONLY thing distinguishing the two,
   since an inherited chip prints the same Show/Hide word an override does.

   Held at the base 12px rather than dropping to the 10px floor. That drop was
   sized for the word "Default", which named the mechanism and was read down a
   column rather than read: the least important thing on screen, which is what
   the floor is reserved for. The word is now the answer itself, and an answer
   two points smaller than the answer on the row above it does not compare.

   The retreat is in the ink alone — dashed border, subtle colour, normal weight,
   tighter side padding and gap. `line-height` stays the base rule's fixed 18px
   and `min-width` its 72px, so the box is the same 72×28 in every state and
   neither the row nor the chip beside it moves as a chip toggles. */
.sf-vischip[data-vis="auto"] {
  border-style: dashed; color: var(--color-text-subtle);
  font-weight: 400;
  padding-left: 6px; padding-right: 6px; gap: 4px;
}
/* Keyed on the RESOLVED state, not the stored one, so a chip is the colour of the
   answer it prints. An inherited "Show" is as green as one set by hand — it was
   white before, which left the column showing two visibly different things under
   one word, and made a scan for hidden rows find only the ones hidden on purpose.
   Whether the row owns the decision is carried by the dashed border, subtle ink
   and lighter weight above; it is no longer carried by the colour. */
.sf-vischip[data-vis-resolved="show"] { border-color: var(--color-success); background: var(--color-success-50); }
.sf-vischip[data-vis-resolved="hide"] { border-color: var(--color-danger);  background: var(--color-danger-50); }
/* The state colour rides the icon, not the label — 12px bold text on a 50-tint
   would not clear contrast, and the icon carries the meaning either way. */
.sf-vischip[data-vis-resolved="show"] svg { color: var(--color-success); }
.sf-vischip[data-vis-resolved="hide"] svg { color: var(--color-danger); }

@media (max-width: 768px) { .sf-vischip { min-height: 44px; } }

/* -------------------------------------------------------------------------
   Alerts
   ------------------------------------------------------------------------- */
.alert {
  display: flex; align-items: flex-start; gap: 10px;
  padding: 10px 12px;
  border-radius: 5px;
  border: 1px solid;
  font-size: 13px;
}
.alert svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
/* An alert standing in the page stack rather than inside a card is a block of that
   stack, and keeps the stack's gap under it — the same way .toolbar--page does. */
.alert--page { margin-bottom: var(--page-gap); }
.alert-warn   { background: var(--color-warn-50);   color: #92400E; border-color: #FDE68A; }
.alert-info   { background: var(--color-info-50);   color: #1E40AF; border-color: #BFDBFE; }
.alert-danger { background: var(--color-danger-50); color: #991B1B; border-color: #FECACA; }

/* -------------------------------------------------------------------------
   Grid utilities (data-dense)
   ------------------------------------------------------------------------- */
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.grid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }

@media (max-width: 1100px) {
  .grid-4, .grid-5, .grid-6 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
  .grid-2, .grid-3, .grid-4, .grid-5, .grid-6 { grid-template-columns: 1fr; }
}

/* Tablet: KPI/stat rows flex-fill so trailing cards grow into the row instead
   of leaving an empty orphan slot (e.g. a 5-up row collapsing to 2 columns).
   The basis is what decides how many fit, not the content: a card's widest
   figure needs ~155px, but a 200px basis made four of them ask for 848px and
   drop the last onto a second row while every card still had ~100px spare.
   160px keeps a four-up row together down to a 688px container. */
@media (max-width: 1100px) {
  .grid:has(> .kpi) { display: flex; flex-wrap: wrap; }
  .grid:has(> .kpi) > .kpi { flex: 1 1 160px; }
}
@media (max-width: 640px) {
  .grid:has(> .kpi) > .kpi { flex: 1 1 100%; }
}

.cols-3-1 { display: grid; gap: 16px; grid-template-columns: 3fr 1fr; }
.cols-2-1 { display: grid; gap: 16px; grid-template-columns: 2fr 1fr; }
.cols-3-2 { display: grid; gap: 16px; grid-template-columns: 3fr 2fr; }
.cols-1-2 { display: grid; gap: 16px; grid-template-columns: 1fr 2fr; }
/* A grid column will not shrink below its widest child unless it is told it may,
   and one wide table is enough to make the whole row wider than the page: the
   fr tracks grow to fit it, the side column is pushed off the right edge, and
   half of it becomes unreachable — no page scrollbar appears to say so, because
   the overflow happens inside the row rather than at the document. min-width: 0
   lets the track shrink and hands the overflow back to the table's own frame,
   which scrolls sideways; that is what that frame is for. */
.cols-3-1 > *, .cols-2-1 > *, .cols-3-2 > *, .cols-1-2 > * { min-width: 0; }
@media (max-width: 1100px) {
  .cols-3-1, .cols-2-1, .cols-3-2, .cols-1-2 { grid-template-columns: 1fr; }
}

.stack { display: flex; flex-direction: column; gap: 12px; }
/* A stack that holds the page's own blocks is the page stack, whatever it is called:
   it spaces them by --page-gap and they bring no margin of their own, because in a
   flex column a margin lands on top of the gap instead of collapsing into it. */
.stack:has(> .kpi-summary) { gap: var(--page-gap); }
.stack:has(> .kpi-summary) > .kpi-summary,
.stack:has(> .kpi-summary) > .toolbar--page { margin-bottom: 0; }
.row   { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

/* -------------------------------------------------------------------------
   Misc
   ------------------------------------------------------------------------- */
.muted { color: var(--color-text-muted); }
.subtle { color: var(--color-text-subtle); }
.mono { font-family: 'Hanken Grotesk', sans-serif; }
/* The domain is the one part of the footer anybody is meant to read, and at the
   footer’s own 10px it is a shade under legible. 12px: two more than the line it
   sits in, still smaller than anything else on the page. Inline, so it shares the
   baseline with “Copyright @2026” rather than sitting proud of it, and
   .page-footer’s 56px min-height absorbs the extra leading. */
.footer-link { color: inherit; text-decoration: none; font-size: 12px; }
.footer-link:hover { color: inherit; text-decoration: none; font-weight: 600; }
.tnum { font-variant-numeric: tabular-nums; }
.divider { height: 1px; background: var(--color-border); border: 0; margin: 12px 0; }

/* The same five states as the badges (memory.md §19), in the form a row uses when
   it wants the state beside a figure rather than in a pill of its own. `new` and
   `idle` complete the set: without them a state that is new or inert could only
   be said as a badge, which is why those two kept turning up as pills in rows
   that wanted a mark. */
.dot-status { width: 6px; height: 6px; border-radius: 999px; display: inline-block; }
.dot-status.ok   { background: var(--color-success); }
.dot-status.warn { background: var(--color-warn); }
.dot-status.bad  { background: var(--color-danger); }
.dot-status.new  { background: var(--color-info); }
.dot-status.idle { background: var(--color-neutral); }

.kbd {
  display: inline-flex; align-items: center;
  padding: 1px 5px;
  border-radius: 4px;
  background: var(--color-surface-alt);
  border: 1px solid var(--color-border);
  border-bottom-width: 2px;
  font-family: 'Hanken Grotesk', sans-serif;
  font-size: 10px;
  color: var(--color-text-muted);
}

/* Bar gauge */
.bars { display: flex; align-items: flex-end; gap: 3px; height: 28px; }
.bars span {
  flex: 1;
  background: var(--color-primary);
  border-radius: 2px 2px 0 0;
  opacity: 0.9;
}

/* Donut placeholder */
.donut {
  width: 132px; height: 132px;
  border-radius: 999px;
  background:
    conic-gradient(var(--color-primary) 0 82%,
                   var(--color-secondary) 82% 94%,
                   var(--color-cta) 94% 98%,
                   #FBBF24 98% 100%);
  display: inline-flex; align-items: center; justify-content: center;
  position: relative;
}
.donut::after {
  content: '';
  position: absolute; inset: 18px;
  background: var(--color-surface);
  border-radius: 999px;
}
.donut-label {
  position: relative; z-index: 1; text-align: center;
  font-family: 'Hanken Grotesk', sans-serif;
}

/* Legend */
.legend { display: flex; flex-wrap: wrap; gap: 10px 14px; font-size: 12px; color: var(--color-text-muted); }
.legend .swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; margin-right: 6px; vertical-align: -1px; }

/* Drawer / Modal (kept simple, html-only) */
details.drawer { border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); }
details.drawer > summary {
  list-style: none;
  padding: 10px 14px;
  cursor: pointer;
  display: flex; align-items: center; justify-content: space-between;
  font-weight: 500;
}
details.drawer > summary::-webkit-details-marker { display: none; }
details.drawer[open] > summary { border-bottom: 1px solid var(--color-border); }
details.drawer .drawer-body { padding: 14px; }

/* Tablet (portrait): collapse the sidebar to an icon rail instead of hiding it,
   so navigation is never lost. Mirrors the .app.collapsed state. */
@media (max-width: 900px) {
  .app { grid-template-columns: var(--sidebar-w-collapsed) 1fr; }
  .sidebar-brand { overflow: hidden; background: url('../img/Vava-Logomark.svg') center / auto 35.3px no-repeat; }
  .sidebar-brand img { display: none; }
  .sidebar-section { font-size: 0; padding: 10px 0 0; margin-bottom: 0; }
  .nav-link { justify-content: center; gap: 0; font-size: 0; padding: 9px 0; }
  .nav-link svg { width: 20px; height: 20px; }
  .nav-badge:not(.nav-badge--alert):not(.nav-badge--new) { display: none; }
  .nav-badge--alert,
  .nav-badge--new { right: -15px; }
  .nav-badge--alert,
  .nav-badge--new { width: 0; margin-left: 0; }
  .sidebar-support .nav-badge--new,
  .nav-link[data-nav="concierge"] .nav-badge--alert { margin: 0; }
  .nav-group[hidden] { display: block; }
  .sidebar-section__count,
  .sidebar-section .nav-badge--alert { display: none; }
  .sidebar-nav .sidebar-section { pointer-events: none; outline: none; }
  .sidebar-nav { scrollbar-width: none; }
  .sidebar-footer { justify-content: center; padding: 12px 0; }
  .sidebar-footer > div,
  .sidebar-footer > .icon-btn { display: none; }
}

/* Print-friendly basics (not used here) */
@media print {
  .sidebar, .header, .page-actions { display: none; }
}

body > div:nth-of-type(1) > main > header > label > input {
  border-color: var(--border-input-1);
  background-color: var(--color-search-bg);
}

.value.tnum {
  color: var(--color-value-tnum);
}

/* ============================================================
   TOPBAR USER CLUSTER (V1-style) — name over a pin+location line
   (click -> Switch Location dropdown) + avatar (click -> user menu).
   Ported from the V1 app shell (np.vavastone.com js/shell.js + css/shell.css).
   ============================================================ */
.topbar-user-cluster{display:flex;align-items:center;gap:10px;padding:4px 4px 4px 8px;border-radius:var(--radius-md);}
.topbar-user-info{display:flex;flex-direction:column;align-items:flex-end;line-height:1.15;gap:2px;}
.topbar-user-info .topbar-user-name{font-size:13px;font-weight:600;color:var(--color-text);}
.topbar-loc,.topbar-user{position:relative;}
.topbar-user-info .topbar-loc{display:inline-flex;align-items:center;gap:4px;background:transparent;border:0;padding:0;cursor:pointer;font-size:12px;color:var(--color-text-muted);}
.topbar-user-info .topbar-loc:hover{color:var(--color-primary);}
.topbar-user-info .topbar-loc .loc-pin{color:var(--color-primary);flex-shrink:0;}
.topbar-user-info .topbar-loc .location-picker__label{color:var(--color-primary);font-weight:500;white-space:nowrap;}
.location-picker__menu{position:absolute;top:calc(100% + 6px);left:auto;right:0;min-width:280px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:var(--shadow-lg);padding:4px;z-index:100;display:none;text-align:left;}
.topbar-loc.open .location-picker__menu{display:block;}
.location-picker__option{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:var(--radius-sm);cursor:pointer;}
.location-picker__option:hover,.location-picker__option.active{background:var(--color-surface-alt);}
.location-picker__opt-text{display:flex;flex-direction:column;}
.location-picker__opt-name{font-size:13px;font-weight:600;color:var(--color-text);}
.location-picker__opt-sub{font-size:11px;color:var(--color-text-muted);}
.dropdown-section-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-muted);padding:6px 10px 4px;}
.topbar-user-cluster > .topbar-user{display:inline-flex;align-items:center;background:transparent;border:0;padding:0;cursor:pointer;}
.topbar-user-cluster .avatar{width:28px;height:28px;}
.topbar-user-cluster > .topbar-user:hover .avatar{outline:2px solid color-mix(in oklab, var(--color-primary) 50%, transparent);outline-offset:1px;}
.dropdown-menu{position:absolute;top:calc(100% + 6px);right:0;min-width:200px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:var(--shadow-lg);padding:6px;z-index:100;display:none;text-align:left;}
.dropdown-menu.open{display:block;}
/* A menu row is written as an <a> when it goes somewhere and a <button> when it acts here,
   and 116 rows across this prototype are buttons — client, crm, crm2, pricing, plus the ones
   js/app.js, js/language.js and js/client-record.js build. Everything from `appearance` on
   neutralises what a button brings of its own. Without it the row paints as a raised grey OS
   button in Arial, sized to its own text and centred, in the middle of a menu of anchors: the
   rule was authored for the anchor and the sheet has no button reset anywhere — line 257's
   `button, [role="button"] { cursor: pointer; }` is the whole of it. Five places had already
   written this reset out by hand (.lang-opt, .dropdown-submenu__toggle, .theme-opt below, and
   .row-menu .dropdown-item in css/pages/client.css); they stay, and are now redundant.
   ⛔ `font-family`, NEVER the `font` shorthand — the shorthand would sit after this rule's own
   `font-size:13px` and reset it to the body's 14px.
   `line-height` for the same reason: a button computes `normal` (~15px) where an anchor inherits
   1.5, and 19.5px is the single-line height `.page-settings .dropdown-item` is measured against
   further down. A local reset that omitted it left its rows 3px short of the anchors'. */
.dropdown-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:var(--radius-sm);font-size:13px;color:var(--color-text);text-decoration:none;cursor:pointer;appearance:none;background:none;border:0;width:100%;font-family:inherit;line-height:inherit;text-align:left;}
.dropdown-item:hover{background:var(--color-primary-50);}
/* A destructive item in a menu of harmless ones. It carries its colour before it
   is hovered — a delete that looks like every other row until the pointer is on
   it has already been clicked by then. */
/* A menu item that is not available yet. An anchor takes no `disabled`, so it
   says so with `aria-disabled` and this makes that visible — dimmed, and no
   hover, because a row that lights up under the pointer promises a press. The
   handler still has to honour the attribute; this only draws it. */
.dropdown-item[aria-disabled="true"]{opacity:.45;cursor:default;}
.dropdown-item[aria-disabled="true"]:hover{background:transparent;}
.dropdown-item--danger{color:var(--color-danger);}
.dropdown-item--danger:hover{background:var(--color-danger-50);}
.dropdown-divider{height:1px;background:var(--color-border);margin:4px 0;}
/* ---- Language, opened in place ---------------------------------------------
   The list is the house menu: .dropdown-submenu under the profile, an ordinary
   .dropdown-menu under a header button. These rules only dress it — nothing here
   positions or shows a panel, because those two already do. A language list that
   opted out of them would be the one white panel hanging under Bold's dark bar,
   and the one submenu in the profile menu that opened unlike its neighbour. */
.lang-pick { position:relative; display:inline-flex; }
/* Tall enough that the twelve never scroll on a normal window — a cap set near
   the list's own height buys a scrollbar for three pixels. It exists only so a
   short window cannot push the last rows off the bottom. */
.lang-menu { max-height:calc(100vh - 120px); overflow-y:auto; }
.dropdown-submenu__panel.lang-menu { min-width:190px; }
.lang-opt { width:100%; justify-content:flex-start; background:transparent; border:0; font-family:inherit; font-size:13px; text-align:left; }
.lang-opt .lang-check { margin-left:auto; width:15px; height:15px; flex-shrink:0; visibility:hidden; color:var(--color-primary-700); }
.lang-opt.active { color:var(--color-primary-700); font-weight:500; }
.lang-opt.active .lang-check { visibility:visible; }
/* A drawn flag is flat colour with no edge of its own; on a white row the pale
   ones would end nowhere without the hairline. */
.lang-flag { width:19px; height:13px; flex:none; border-radius:2px; box-shadow:0 0 0 1px rgba(0,0,0,.10); }
/* Page-level settings menus (dashboard/catalog/stock) — roomy, even rhythm; divider sits in the gap instead of carving a hole */
/* The menu is absolute, so the cluster has to be its containing block — every page
   that used this carried `position:relative` as an inline style to get it. */
.page-settings{position:relative;}
/* THE WORDS WRAP BESIDE THE ICON, NOT UNDER IT. `.dropdown-item` centres its
   row, which is right while a label is one line and wrong the moment one is two:
   the icon drifts to the middle of the block and the second line starts back at
   the icon's own edge, so the entry stops reading as one label with a mark on it.
   Pinning the row to the top and dropping the icon by the 2px that centred it on
   a single 19.5px line keeps every existing one-line entry exactly where it was. */
.page-settings .dropdown-item{padding:9px 12px;align-items:flex-start;}
.page-settings .dropdown-divider{margin:0;}
/* Lucide ships icons at 24px. In a 13px menu row that reads as an icon list with
   labels attached rather than a menu; 15px puts the icon under the cap height. */
.page-settings .dropdown-item svg{width:15px;height:15px;flex:none;margin-top:2px;}
/* Sized to the longest entry ("Showroom URL & Domain") so the menu does not
   redraw at a different width from one page to the next. */
.page-settings .dropdown-menu{min-width:232px;}

/* Help menu — the wrapper, the rows and both dialogs are built by js/app.js.
   Mirrors .page-settings: the menu is absolute, so the wrapper is its containing block. */
.topbar-help{position:relative;}
.topbar-help .dropdown-menu{min-width:226px;}
.topbar-help .dropdown-item{padding:9px 12px;}
.topbar-help .dropdown-item svg{width:15px;height:15px;flex:none;}
.topbar-help .dropdown-divider{margin:0;}

/* Keyboard Shortcuts dialog. Key first, description second, tight — a key column
   wide enough for "Esc" keeps the descriptions on one left edge. */
.help-sc{margin:0;display:flex;flex-direction:column;}
.help-sc__row{position:relative;display:flex;align-items:center;gap:11px;padding:9px 0;}
.help-sc__key{flex:none;min-width:38px;}
.help-sc__desc{margin:0;font-size:13px;color:var(--color-text);}

/* What's New dialog */
.help-wn{display:flex;flex-direction:column;}
.help-wn__item{position:relative;display:flex;flex-direction:column;gap:3px;padding:16px 0;}
.help-wn__item:first-child{padding-top:0;}
.help-wn__when{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-subtle);}
.help-wn__title{font-size:13px;font-weight:600;color:var(--color-text);}
.help-wn__desc{margin:0;font-size:12px;line-height:1.55;color:var(--color-text-muted);}

/* Same gesture as the table row separator above: hold --color-border-strong for the
   first 35% and decay across the rest, so the boundary is stated where the row's
   content starts and is spent before the dialog's edge. A full-width hairline here
   read as a band behind the rows. The last row needs none — the dialog closes it. */
.help-sc__row::after,
.help-wn__item::after{
  content:"";position:absolute;bottom:0;left:0;right:0;height:1px;
  background:linear-gradient(to right,
    var(--color-border-strong) 0%,
    var(--color-border-strong) 35%,
    var(--color-border) 65%,
    transparent 100%);
}
.help-sc__row:last-child::after,
.help-wn__item:last-child::after{content:none;}

/* Themes submenu — injected into the profile dropdown by js/app.js. Flyout opens
   to the LEFT of the item (the profile menu is pinned to the top-right corner). */
.dropdown-submenu{position:relative;}
.dropdown-submenu__toggle{width:100%;justify-content:flex-start;background:transparent;border:0;font:inherit;}
.dropdown-submenu__caret{margin-left:auto;width:14px;height:14px;flex-shrink:0;}
.dropdown-submenu__panel{position:absolute;top:-6px;right:100%;margin-right:5px;min-width:168px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:var(--shadow-lg);padding:6px;z-index:110;display:none;}
.dropdown-submenu.open > .dropdown-submenu__panel{display:block;}
.theme-opt{width:100%;justify-content:space-between;background:transparent;border:0;font:inherit;text-align:left;}
.theme-opt .theme-check{width:15px;height:15px;flex-shrink:0;visibility:hidden;color:var(--color-primary-700);}
.theme-opt.active{color:var(--color-primary-700);font-weight:500;}
.theme-opt.active .theme-check{visibility:visible;}

/* --- Demo licence (js/app.js). A licence is a supplier or a distributor, never
   both; <html data-licence> says which, and no attribute reads as supplier so the
   default never flashes. THE MODULES A LICENCE DOES NOT HAVE are listed here and
   nowhere else: a supplier has no Purchase Orders. Anything marked
   data-licence-only shows for that licence alone. */
html:not([data-licence="distributor"]) .nav-link[data-nav="purchase-orders"],
html:not([data-licence="distributor"]) [data-licence-only="distributor"],
html[data-licence="distributor"] [data-licence-only="supplier"]{display:none !important;}
/* The switch in the profile menu: [Supplier] switch [Distributor], the side in
   force dark and bold, nothing boxed behind it. */
.licence-switch{display:flex;align-items:center;gap:8px;padding:4px 10px 8px;}
.licence-switch__opt{background:none;border:0;padding:0;font:inherit;font-size:13px;color:var(--color-text-muted);cursor:pointer;}
.licence-switch__opt.is-active{color:var(--color-text);font-weight:600;}
/* The licence in force, under the logo and flush with its right edge: the brand
   becomes a one-column grid as wide as the wordmark, so the word ends where the
   logo ends. Gone on the icon rail, which has no wordmark. */
.sidebar-brand{display:grid;justify-content:center;align-content:center;gap:0;}
.sidebar-brand::after{content:'Supplier';justify-self:end;margin-top:-2px;font-size:10px;font-weight:500;line-height:12px;letter-spacing:.04em;color:var(--color-sidebar-text);}
html[data-licence="distributor"] .sidebar-brand::after{content:'Distributor';}
.app.collapsed .sidebar-brand::after{display:none;}
@media (max-width:900px){.sidebar-brand::after{display:none;}}

/* --- Dark-theme component tint fixes: icon tiles + status pills carry
   hardcoded light values that don't ride the token swap. Remap them onto the
   active theme's own tokens. Light themes stay untouched (base rules unchanged).

   Every body below is token-only, which is what lets Evening and Elegant share
   one rule: the fix is "use the theme's token", and each theme brings its own.
   `:is()` takes the specificity of its most specific argument — two attribute
   selectors, so this matches the single-selector specificity these rules had
   before Elegant joined them, and nothing re-orders. --- */
:is([data-theme="evening"], [data-theme="elegant"]) .thumb         { background: var(--color-primary-50); border-color: var(--color-border-strong); }
:is([data-theme="evening"], [data-theme="elegant"]) .badge-neutral { background: var(--bg-input-1); color: var(--color-text); border-color: var(--color-border-strong); }
:is([data-theme="evening"], [data-theme="elegant"]) .badge-info    { color: var(--color-info);    border-color: var(--color-border-strong); }
:is([data-theme="evening"], [data-theme="elegant"]) .badge-success { color: var(--color-success); border-color: var(--color-border-strong); }
:is([data-theme="evening"], [data-theme="elegant"]) .badge-warn    { color: var(--color-warn);    border-color: var(--color-border-strong); }
:is([data-theme="evening"], [data-theme="elegant"]) .badge-danger  { color: var(--color-danger);  border-color: var(--color-border-strong); }
:is([data-theme="evening"], [data-theme="elegant"]) .badge-primary { border-color: var(--color-border-strong); }

/* Table row grounds. The loudest leak in the portal: a near-white hover or a
   pale even-row stripe painted across a dark table is a bar of light where a
   row should be, and every list page has one. Dark themes lift a row rather
   than bleaching it. */
:is([data-theme="evening"], [data-theme="elegant"]) .data-table tbody tr:hover { background: var(--bg-input-1); }
:is([data-theme="evening"], [data-theme="elegant"]) .data-table tbody tr.selected:hover { background: var(--color-border); }
:is([data-theme="evening"], [data-theme="elegant"]) .tbl-dense tbody tr:nth-child(even) { background: var(--color-surface-alt); }
:is([data-theme="evening"], [data-theme="elegant"]) .tbl-dense tbody tr:hover { background: var(--bg-input-1); }

/* The chip family carries one shared mistake: a light-blue border drawn to pair
   with a pale `--color-primary-50`. Evening's primary-50 is dark, so the ring
   is the only bright thing left on the chip — the same split `.badge-primary`
   above was already fixed for. */
:is([data-theme="evening"], [data-theme="elegant"]) .chip,
:is([data-theme="evening"], [data-theme="elegant"]) .af-chip,
:is([data-theme="evening"], [data-theme="elegant"]) .assign-opt:has(input:checked) { border-color: var(--color-border-strong); }

/* Alerts have the badges' problem one size up: the ground rides the token swap
   and the text and border do not, which leaves dark ink on a dark panel inside
   a bright outline. */
:is([data-theme="evening"], [data-theme="elegant"]) .alert-warn   { color: var(--color-warn);   border-color: var(--color-border-strong); }
:is([data-theme="evening"], [data-theme="elegant"]) .alert-info   { color: var(--color-info);   border-color: var(--color-border-strong); }
:is([data-theme="evening"], [data-theme="elegant"]) .alert-danger { color: var(--color-danger); border-color: var(--color-border-strong); }

/* Excel green is a brand tint mixed for a white page. On dark it keeps the
   green and gives up the tint. */
:is([data-theme="evening"], [data-theme="elegant"]) .btn-excel,
:is([data-theme="evening"], [data-theme="elegant"]) .btn-excel:disabled:hover {
  background: var(--color-success-50); color: var(--color-success); border-color: var(--color-border-strong);
}
:is([data-theme="evening"], [data-theme="elegant"]) .btn-excel:hover { background: var(--color-border); border-color: var(--color-success); color: var(--color-success); }

/* A meter is its track plus its fill. The fill was themed; the track was a rail
   of light under it. */
:is([data-theme="evening"], [data-theme="elegant"]) .stock-bar { background: var(--bg-input-1); }

/* The ring cuts the notification dot out of whatever it sits on, so it has to
   BE that ground — which on dark is the header, not white. */
:is([data-theme="evening"], [data-theme="elegant"]) .icon-btn .dot { border-color: var(--color-surface); }

/* A separator has to be quieter than the things it separates; at #CBD5E1 on a
   dark bar it was brighter than both. */
:is([data-theme="evening"], [data-theme="elegant"]) .breadcrumb .sep { color: var(--color-text-subtle); }

/* --- Elegant only: the two places the accent's split duty shows through.
   Elegant spends --color-primary on being a button GROUND that carries a
   hardcoded white label (see the theme block for why a neutral accent cannot).
   Both rules below are the consequence; Evening needs neither, because its
   accent is light to begin with. --- */

/* The primary button's hover. The base rule
   lifts the fill to --color-primary-600, and the label is a hardcoded #fff:
   under Elegant that lightens the ground the white sits on and drops the label
   from 4.57:1 to 3.48:1 — a button that gets HARDER to read the moment you point
   at it. The fill therefore stays put here and the hover is carried by a lit
   edge instead, which is a lift on a dark theme just as a lighter fill is.
   `:not(:disabled)` because this rule sits after `.btn-primary:disabled:hover`
   and matches at equal-or-higher weight — without it a dead button would light
   up under the cursor. */
[data-theme="elegant"] .btn-primary:hover:not(:disabled),
[data-theme="elegant"] .btn-cta:hover:not(:disabled) {
  background: var(--color-primary);
  box-shadow: inset 0 0 0 1px var(--color-primary-700);
}

/* The accent written as TEXT. A ground dark enough to carry white ink is too
   dark to BE ink: 3.5:1 on a card, and 2.6:1 in the one place the portal writes
   the accent on its own --color-primary-50 fill. The handful of rules that use
   the accent as type take the light sibling instead — the token links already
   hover to. Ordinary links are unaffected: `a` reads --color-primary-600. */
[data-theme="elegant"] .fg-all:hover,
[data-theme="elegant"] .tree-label:hover,
[data-theme="elegant"] .active-filters__clear:hover,
[data-theme="elegant"] .topbar-user-info .topbar-loc:hover,
[data-theme="elegant"] .topbar-user-info .topbar-loc .loc-pin,
[data-theme="elegant"] .topbar-user-info .topbar-loc .location-picker__label { color: var(--color-primary-700); }

/* --- Ensure the hidden attribute always hides (incl. flex/grid elements) --- */
[hidden] { display: none !important; }

/* Add-warehouse tile — mirrors the catalog price-type add tile */
.wh-add-tile { width:110px; display:flex; flex-direction:column; gap:6px; font-size:12px; font-weight:500; align-items:center; justify-content:center; min-height:120px; border:1.5px dashed var(--color-border-strong); background:transparent; box-shadow:none; color:var(--color-text-subtle); cursor:pointer; font:inherit; transition:border-color 120ms, color 120ms; }
.wh-add-tile:hover { border-color:var(--color-primary); color:var(--color-primary); }

/* -------------------------------------------------------------------------
   Modal / dialog — shared overlay primitive (opened via data-modal-open="id",
   closed via data-modal-close / backdrop / Escape; engine in js/app.js)
   ------------------------------------------------------------------------- */
.modal-overlay {
  position: fixed; inset: 0; z-index: 200;
  display: flex; flex-direction: column; align-items: flex-start; justify-content: center;
  /* Dialogs open ABOVE the vertical midline. On a tall monitor a truly centred box
     reads as low, and the eye is already in the upper half of the screen.
     The lift is a spacer under the dialog (::after below): `.modal` centres itself
     with auto margins in the room above it, so a deeper spacer raises it. Done
     with layout rather than a transform, which `modal-pop` overwrites on its
     final frame. 27vh, half as high again as the old 18vh (user, 2026-10-07). */
  --modal-lift: 27vh;
  padding: 48px 16px; overflow-y: auto;
  background: rgba(15, 23, 42, 0.44);
  animation: modal-fade 120ms ease-out;
}
@keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }
/* THE LIFT GIVES WAY BEFORE THE DIALOG SCROLLS. The spacer shrinks (to nothing
   if need be) while the dialog never does, so a tall dialog takes the whole
   height and its body scrolls only when the screen itself is too short. As
   bottom padding the lift was a fixed cut from every dialog's height, and a
   Cancel Order scrolled on a laptop screen with room to spare (2026-10-15). */
.modal-overlay::after { content: ''; flex: 0 1 var(--modal-lift); }
.modal {
  width: 100%; max-width: 520px; margin: auto; flex-shrink: 0;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  display: flex; flex-direction: column; max-height: calc(100vh - 96px);
  animation: modal-pop 140ms ease-out;
}
@keyframes modal-pop { from { transform: translateY(6px); opacity: 0; } to { transform: none; opacity: 1; } }
.modal-sm { max-width: 400px; }
.modal-lg { max-width: 760px; }
.modal-header {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
  padding: 14px 16px; border-bottom: 1px solid var(--color-border); flex: none;
}
.modal-header h2 { margin: 0; font-size: 17px; }
.modal-header .sub { color: var(--color-text-subtle); font-size: 12px; margin-top: 2px; }
.modal-body { padding: 16px; overflow-y: auto; }
/* The confirm dialog's icon. Floated so the message wraps AROUND it — the first
   lines beside it, the rest running on underneath — rather than sitting in a
   flex row that wrapped the text onto its own line and left the icon stranded
   above it. `.modal-body` is already a scroll box, so the float is contained by
   it and needs no clearing rule of its own. */
.confirm-thumb {
  float: left; width: 38px; height: 38px; margin: 2px 12px 2px 0;
  background: var(--color-danger-50);
}
.confirm-msg { margin: 0; }
.modal-footer {
  display: flex; align-items: center; justify-content: flex-end; gap: 8px;
  padding: 12px 16px; border-top: 1px solid var(--color-border); flex: none;
}
.modal-footer .spacer { flex: 1; }
/* A slide-over foot lays out like a dialog foot, so the spacer works the same in both. */
.slideover-foot .spacer { flex: 1; }
.modal-close {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; border: 0; background: transparent; cursor: pointer;
  border-radius: var(--radius-sm); color: var(--color-text-muted);
}
.modal-close:hover { background: var(--color-surface-alt); color: var(--color-text); }
.modal-close svg { width: 18px; height: 18px; }
/* Stacked form rows inside modals. `.field-grid` is one of them: a grid row following a
   field is still a new row of the form, and leaving it out of this list is what glued
   "Call Back On" to the textarea above it in the CRM log dialog. `:is()` takes the
   specificity of its most specific argument, so this stays at (0,3,0) and the resets
   below — which are (0,4,0) and (0,3,0)-but-later — still win. */
.modal-body :is(.field, .field-grid, .form-row) + :is(.field, .field-grid, .form-row) { margin-top: 14px; }
/* Fields laid out horizontally in a form-row must not inherit the stacked-field top margin. */
.modal-body .form-row > .field + .field { margin-top: 0; }
/* Same for `.grid` — `.field + .field` above skips the FIRST field of a row, so the second
   column gets 14px the first never has, and the pair ends up bottom-aligned instead of
   sharing a baseline (Phone sitting 14px above Showroom Slug in the Location dialog).
   `.grid` carries `gap: 16px`, which already owns the spacing in both axes. Same reasoning
   and same cure as the `.field-grid` reset further down. */
.modal-body .grid > .field { margin-top: 0; }

/* Dialog form grid — a 12-column track for dialogs whose fields are not all the same width
   (Edit Product). Prefer this over stacked .form-row pairs: a .form-row row of one field
   leaves a half-width hole, and two dialogs mixing the systems stagger against each other.
   The span is declared per field with data-span, matching the attribute grid the product
   wizard uses, so an identity field and a type field line up on the same column edge. */
.field-grid {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: 16px;
}
/* `.modal-body .field + .field` above skips the FIRST field, so inside a grid it staggers a
   row rather than spacing it — the opening field sits 14px above its own row-mate. In here
   the grid's `gap` owns the spacing in both axes. */
.field-grid > .field { min-width: 0; }
.modal-body .field-grid > .field { margin-top: 0; }   /* .modal-body prefix to outweigh the rule above */
/* A read-only value standing where an input would stand — the display half of a record page
   (catalog-blocks / -bundles / -slabs / -individual "Product Attributes"). It exists because
   memory.md §14 forbids inline CSS and every one of those values was carrying its own
   `style="font-weight:500"`; new fields use this class. The existing inline copies are
   pre-existing debt and are deliberately left alone — migrate them when you next touch
   their line, not in a sweep. */
.field > .ro-value { font-weight: 500; }
/* 4 and 8 are what an address row needs — a street that takes the width and a
   suite number that does not. The collapse below keys off `[data-span]` itself,
   so every span declared here goes single-column on a phone without a new rule. */
.field-grid > .field[data-span='3'] { grid-column: span 3; }
.field-grid > .field[data-span='4'] { grid-column: span 4; }
.field-grid > .field[data-span='5'] { grid-column: span 5; }
.field-grid > .field[data-span='6'] { grid-column: span 6; }
.field-grid > .field[data-span='7'] { grid-column: span 7; }
.field-grid > .field[data-span='8'] { grid-column: span 8; }
.field-grid > .field[data-span='9'] { grid-column: span 9; }
.field-grid > .field[data-span='12'] { grid-column: span 12; }
/* Same breakpoint the global `.form-row` collapse uses, so a dialog mixing the two systems
   goes single-column in one step rather than two. */
@media (max-width: 768px) {
  /* [data-span] on the selector, not just `.field` — the span rules above carry an attribute each,
     so a bare `.field` collapse loses on specificity and silently leaves two columns on a phone. */
  .field-grid > .field[data-span] { grid-column: 1 / -1; }
}

/* Default Product Type dialog — the page setting that decides which product type
   a page opens on. Catalog, Stock and the campaign builder all ask it, and the
   list has to look the same in all three or the same question reads as three
   different controls. Catalog and Stock still carry their own copies of these
   rules in a page <style> block; those win by order and can go whenever either
   page is next opened. */
.dpt-list { display: flex; flex-direction: column; gap: 4px; }
.dpt-opt {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 10px; border: 1px solid var(--color-border); border-radius: 6px;
  cursor: pointer; font-size: 13px; color: var(--color-text);
  transition: background 120ms, border-color 120ms;
}
.dpt-opt:hover { background: var(--color-primary-50); }
.dpt-opt:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-50); }
.dpt-opt input { accent-color: var(--color-primary); }
.dpt-opt svg { width: 16px; height: 16px; color: var(--color-text-subtle); }

/* Tabbed dialog body — one <form>, two panels, one Save. scrollbar-gutter holds the
   scrollbar's width whether or not it shows, so switching to a taller tab cannot narrow the
   content box and shift every column left. */
.modal-body:has(> [data-modal-tabs]) { scrollbar-gutter: stable; }
.modal-tab-panel[hidden] { display: none; }
.modal-tab-hint { margin: 0 0 12px; font-size: 12px; color: var(--color-text-subtle); }

/* A second address folded behind a switch — New Client and Edit Client both ask it, and
   one rule is how the two stay the same shape. The switch is a row of its own under the
   grid it qualifies, not another field in it: `.switch-field` is inline-flex and would
   otherwise run up beside the last field of the row above. Its gap is wider than the
   grid's own row gap, because at grid spacing the switch reads as one more line of the
   address rather than a question asked about it. */
.ship-switch { display: flex; margin-top: 24px; }
.ship-block { margin-top: 14px; }

/* Tag entry — a pinned-width input the chips wrap around, rather than an input that stretches
   and pushes the first chip onto its own line. Replaces the inline width the wizard's own tag
   row still carries. */
.tag-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.tag-row .tag-input { flex: none; width: 160px; }

/* The dialog tab strip is the only new tap target in these dialogs — it takes the 44px mobile
   minimum without resizing every other .subtabs row in the app. */
@media (max-width: 768px) {
  [data-modal-tabs] .subtab { min-height: 44px; }
}

/* Reorderable settings list — Manage KPIs and the like: a switch that says whether a thing
   shows, and a grip that says where. The number is not decoration; drag is a gesture that
   leaves no trace once the finger lifts, and the position is what the user came to set.
   Stock, Catalog and Warehouses still carry their own copies of these rules in their page
   <style> blocks; this is the shared home they can move to. */
.reorder-list { display: flex; flex-direction: column; gap: 3px; }
.reorder-item { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border: 1px solid transparent; border-radius: 5px; background: var(--color-surface); transition: background 120ms, border-color 120ms, box-shadow 120ms; }
.reorder-item:hover { background: var(--color-primary-50); }
.reorder-item.dragging { opacity: .45; }
.reorder-grip { display: inline-flex; flex: none; color: var(--color-text-subtle); cursor: grab; }
.reorder-grip svg { width: 16px; height: 16px; }
.reorder-item:active .reorder-grip { cursor: grabbing; }
.reorder-num { flex: none; min-width: 22px; padding: 0 4px; height: 22px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; background: var(--color-primary-50); color: var(--color-primary-700); font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; }
.reorder-item .switch-field { flex: 1; }

/* Page Settings › Options dialog — one flat list of per-user page defaults, no subsections.
   Toggles stack as full rows (.switch-field is inline-flex by default, which would
   otherwise run them together on one line). The first toggle needs breathing room from
   the controls above it whether those sit in a bare .field or a .form-row wrapper. */
.opt-dialog .switch-field { display: flex; padding: 7px 0; }
.opt-dialog .field + .switch-field,
.opt-dialog .form-row + .switch-field { margin-top: 12px; }
.opt-dialog__reset { margin-right: auto; }

/* Assign-salesperson picker rows (account-ownership visibility) */
.assign-opt {
  gap: 10px; align-items: center;
  padding: 8px 10px; border: 1px solid var(--color-border);
  border-radius: 5px; cursor: pointer;
  transition: background 120ms, border-color 120ms;
}
.assign-opt:hover { border-color: var(--color-primary); background: var(--color-surface-alt); }
.assign-opt:has(input:checked) {
  background: var(--color-primary-50); border-color: #D6E2FA;
}

/* ---------- Visibility rule picker ---------------------------------------
   The company rule about who may open somebody else's record: Clients on CRM,
   Quotes on the Quotes list. Radio rows with the sentence that says what
   each rule costs. Each option runs to a line or two, so the icon aligns to the
   name it belongs to — centred against an explanation it drifts down beside
   the small print. Hoisted here off `css/pages/crm.css` on its second caller. */
.vis-pick { display: flex; flex-direction: column; gap: 6px; }
.vis-pick .assign-opt { display: flex; align-items: flex-start; }
/* Flex-start would leave the radio at the very top of a three-line row; this
   puts it on the middle of the icon tile it belongs to. */
.vis-pick .assign-opt input { margin: 8px 0 0; }
.vis-ico {
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; flex: 0 0 auto;
  border-radius: 5px;
  background: var(--color-control); color: var(--color-text-muted);
}
.vis-ico svg { width: 15px; height: 15px; }
/* The picked row has its own tinted ground, so its icon goes the other way up
   rather than disappearing into it. */
.vis-pick .assign-opt:has(input:checked) .vis-ico {
  background: var(--color-surface); color: var(--color-primary-700);
}
/* A reader who may see the rule but not set it gets the same dialog with the
   invitation taken out of it — the rows stop lighting up under a cursor that
   cannot change them, while the row in force keeps its tint. */
.vis-pick .assign-opt:has(input:disabled) { cursor: default; }
.vis-pick .assign-opt:has(input:disabled):hover:not(:has(input:checked)) {
  border-color: var(--color-border); background: none;
}
/* The follow-up the second rule opens. Indented past the radio and the icon
   tile so it reads as part of that option rather than as a third one. */
.vis-sub { display: flex; align-items: center; gap: 10px; margin: 8px 0 0 54px; }
.vis-sub label { font-size: 12px; color: var(--color-text-muted); }
.vis-sub .select { flex: 0 0 auto; width: 186px; }

.vis-note { margin: 12px 0 0; font-size: 12px; line-height: 1.45; }
[data-assign-hint] {
  display: flex; align-items: center; gap: 8px;
  padding: 9px 11px; border-radius: 5px; font-size: 12px;
  background: var(--color-surface-alt); color: var(--color-text-muted);
}
[data-assign-hint][data-state="gated"] { background: var(--color-primary-50); color: var(--color-primary-700); }
[data-assign-hint] svg { width: 14px; height: 14px; flex: none; }
/* Switches sit directly in centered table cells here (no flex row) — give them a box */
#users-roles .switch { display: inline-block; vertical-align: middle; }
.form-row { display: flex; gap: 12px; }
.form-row > .field { flex: 1; }

/* ---- Activity log chrome -------------------------------------------------
   The facet buttons, the active-filter chips, the sortable header arrow and
   the empty state. Shared, because more than one page shows an activity log
   and two copies of this is how the same log starts looking like two: the
   Activities page and the Activity tab on a client record both draw from
   here, the way both already draw their column widths from the `col-*`
   vocabulary above. The loading skeleton and the custom date range are here
   too now that the Quote and Order Activity tabs draw them (js/activity-page.js);
   the detail drawer stays with activity.html, the one page that has it. */
.facet { position: relative; }
.facet-opt { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; }
.facet-opt:hover { background: var(--color-primary-50); }
.facet-opt input { accent-color: var(--color-primary); }
.facet .facet-count { display: inline-flex; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 9px; background: var(--color-primary); color: #fff; font-size: 11px; align-items: center; justify-content: center; }

.activity-chips { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; }
.activity-chips__label { font-size: 12px; color: var(--color-text-subtle); }
.activity-chips__list { gap: 6px; flex-wrap: wrap; }
.activity-custom { display: flex; align-items: flex-end; gap: 10px; margin: 2px 0 10px; padding: 10px 12px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface-2, var(--color-surface)); }
.activity-loading { display: none; padding: 8px 0; }
.activity-loading[data-on] { display: block; }
.activity-loading .sk-row { height: 44px; margin: 6px 0; border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--color-border) 25%, var(--color-surface) 37%, var(--color-border) 63%); background-size: 400% 100%; animation: sk 1.2s ease-in-out infinite; }
@keyframes sk { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }

.data-table thead th.act-th { cursor: pointer; user-select: none; }
.data-table thead th.act-th:hover { color: var(--color-primary-700); }
.act-arrow { display: inline-block; width: 0; height: 0; margin-left: 4px; vertical-align: middle; opacity: .85; }
th.act-th[aria-sort="ascending"] .act-arrow { border-left: 4px solid transparent; border-right: 4px solid transparent; border-bottom: 5px solid var(--color-primary); }
th.act-th[aria-sort="descending"] .act-arrow { border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid var(--color-primary); }

.activity-empty { display: none; flex-direction: column; align-items: center; gap: 10px; padding: 48px 16px; color: var(--color-text-subtle); }
.activity-empty[data-on] { display: flex; }
.activity-empty i { width: 32px; height: 32px; }

/* THE ACTIVITY TABLE — activity.html's, on every Activity tab (owner,
   2026-10-07): When · Who · the thing acted on (`col-prod`) · Action, with the
   change written under the verb. Bounded columns rest at their minimum and grow
   with their own content to their maximum; the cap lives on a block INSIDE the
   cell, because max-width on a td is ignored under auto layout. Every column is
   text, so the last one opts out of app.css's right-aligned last cell.
   Values copied from activity.html's own `#tbl-flex-1` block. */
.act-table { table-layout: auto; min-width: 829px; }
.act-table .col-flex { width: 100%; }
.act-table thead th:last-child, .act-table tbody td:last-child { text-align: left; }
.act-table tbody td:last-child > .cell-stack { align-items: flex-start; }
.act-table thead th.col-when       { width: auto; min-width: 103px; }
.act-table tbody td.col-when > *   { width: max-content; max-width: 114px; }
.act-table thead th.col-who        { width: auto; min-width: 155px; }
.act-table tbody td.col-who > *    { width: max-content; max-width: 160px; }
.act-table thead th.col-prod       { width: auto; min-width: 209px; }
.act-table tbody td.col-prod > *   { width: max-content; max-width: 246px; }
.act-table thead th.col-action     { width: auto; min-width: 257px; }
.act-table tbody td.col-action > * { width: max-content; max-width: 277px; }
/* One line per changed field, only the first until "+N more" is pressed. */
.act-change { display: flex; flex-direction: column; gap: 1px; }
.act-change__line { display: block; }
.act-change__field { color: var(--color-text-subtle); margin-right: 4px; }
.act-sep { color: var(--color-text-subtle); }
.act-more { align-self: flex-start; margin-top: 1px; padding: 0; border: 0; background: none; color: var(--color-primary-700); font: inherit; cursor: pointer; text-decoration: underline; }

/* Toast — transient action feedback */
/* Below the header, not over it: the confirmation lands where the eye already
   is after a save, and the top bar's search and nav stay clickable. */
.toast-host { position: fixed; left: 50%; top: calc(var(--header-h) + 12px); transform: translateX(-50%); z-index: 300; display: flex; flex-direction: column; gap: 8px; align-items: center; }
.toast {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 10px 14px; border-radius: var(--radius-md);
  background: var(--color-text); color: #fff; font-size: 13px; font-weight: 500;
  box-shadow: var(--shadow-lg); animation: toast-in 160ms ease-out;
}
.toast svg { width: 16px; height: 16px; }
.toast.is-success { background: var(--color-success); }
@keyframes toast-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }
.wh-add-tile svg { width:48px; height:48px; stroke-width:3; }

/* ===================== STOCK REBUILD ADDITIONS ===================== */

/* Slide-over (right-anchored modal variant) */
/* Slide-overs start below the top bar (header stays visible); centered modals unaffected. */
/* Edge-anchored and full-height, so the centred dialogs' upward lift does not apply. */
.slideover-overlay { top: var(--header-h); flex-direction: row; align-items: stretch; justify-content: flex-end; padding: 0; --modal-lift: 0px; }
.slideover-overlay::after { content: none; }
.slideover {
  width: min(500px, 100%); max-width: 100%; height: 100%; max-height: 100%;
  margin: 0; border-radius: 0; border: 0; border-left: 1px solid var(--color-border);
  background: var(--color-surface); box-shadow: var(--shadow-lg);
  display: flex; flex-direction: column; animation: slideover-in 180ms ease-out;
}
@keyframes slideover-in { from { transform: translateX(24px); opacity: .6 } to { transform: none; opacity: 1 } }

/* A slide-over holding a record form, rather than a wizard or a viewer. A centred
   dialog is sized for a question — five fields and a Save; a record with an address,
   a second address and a description outgrows it, and the fields start clipping
   their own values before the box will grow. This is the same panel, set to the
   width a 12-column form needs: 640px gives a 4-span field ~200px, which is what an
   email address or a domain name takes to read in full.

   The head and foot are dressed like a dialog's because that is what this panel is:
   the wizards use the plain `.slideover-head` with their own step chrome, and they
   are unaffected. */
.slideover-form { width: min(640px, 100%); }
.slideover-form .slideover-head h2 { margin: 0; font-size: 17px; }
.slideover-form .slideover-foot { justify-content: flex-end; }
/* The scrollbar is reserved whether or not it shows, so switching to a taller tab
   cannot narrow the content box and shift every column left. */
.slideover-form .slideover-body:has(> [data-modal-tabs]) { scrollbar-gutter: stable; }
/* The stacked-field rhythm the dialogs have. Keyed on `.slideover-form` so the 17
   existing slide-overs, which space themselves, are left exactly as they are. */
.slideover-form .slideover-body :is(.field, .field-grid, .form-row) + :is(.field, .field-grid, .form-row) { margin-top: 14px; }
.slideover-form .slideover-body .form-row > .field + .field,
.slideover-form .slideover-body .field-grid > .field { margin-top: 0; }
/* A field following the folded delivery block starts a group of its own, so it takes
   the gap the switch takes above rather than the stacked-field 14px. Structural
   rather than a class, so both client forms get it from the same rule. */
.slideover-form .ship-block + .field { margin-top: 24px; }
.slideover-head {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
  padding: 16px; border-bottom: 1px solid var(--color-border); flex: none;
}
.slideover-head .sub { color: var(--color-text-subtle); font-size: 12px; margin-top: 3px; }
.slideover-body { padding: 16px; overflow-y: auto; flex: 1; }
.slideover-foot {
  display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 16px;
  border-top: 1px solid var(--color-border); flex: none; background: var(--color-surface-alt);
}

/* Slide-over product gallery — hero + up to 4 thumbnails, all crops of one --img source */
.so-gallery { margin-bottom: 16px; }
.so-gallery-hero {
  width: 100%; aspect-ratio: 1.7 / 1; border-radius: 5px; border: 1px solid var(--color-border);
  background-image: var(--img); background-size: cover; background-position: center; background-repeat: no-repeat;
}
.so-gallery-thumbs { display: flex; gap: 8px; margin-top: 8px; }
.so-thumb {
  width: 56px; height: 56px; flex: none; padding: 0; cursor: pointer;
  border-radius: 5px; border: 1px solid var(--color-border);
  background-image: var(--img); background-size: cover; background-repeat: no-repeat;
}
.so-thumb:hover { border-color: var(--color-border-strong); }
.so-thumb.active { border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary-50); }

/* Catalog product gallery — the thumb grid beside the hero. Named for the classes the Angular
   product-detail component already uses, so prototype and component address the same grid. */
.pi-thumbs {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: 10px; align-content: start;
}
.pi-thumbs .so-thumb { width: 100%; height: 84px; }

/* A video thumb reads as video before its glyph is parsed: the wash is what tells it apart at a
   glance, the play mark and the duration confirm it. Mirrors .itm-shot--video on stock. */
.so-thumb--video { position: relative; background-position: 50% 30%; }
.so-thumb--video::after {
  content: ""; position: absolute; inset: 0; border-radius: 4px; background: rgba(15, 23, 42, .30);
}
.so-thumb--video > svg {
  position: absolute; inset: 0; margin: auto; z-index: 1;
  width: 18px; height: 18px; color: #fff; fill: currentColor;
}
.so-thumb__len {
  position: absolute; right: 4px; bottom: 4px; z-index: 1;
  padding: 0 3px;
  font-size: 10px; line-height: 1.5; color: #fff;
  background: rgba(15, 23, 42, .78);
  border-radius: 3px;
}

/* Tree (hierarchical detail) */
.tree { display: flex; flex-direction: column; gap: 2px; font-size: 13px; }
.tree-node { display: flex; flex-direction: column; }
.tree-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 5px; }
.tree-row:hover { background: var(--color-surface-alt); }
.tree-toggle {
  flex: none; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center;
  border: 0; background: transparent; cursor: pointer; color: var(--color-text-muted); border-radius: 5px;
}
.tree-toggle svg { width: 15px; height: 15px; }
.tree-ico { width: 15px; height: 15px; color: var(--color-text-subtle); flex: none; }
.tree-label { color: var(--color-primary-700); text-decoration: none; font-weight: 500; }
.tree-label:hover { text-decoration: none; color: var(--color-primary); }
.tree-meta { margin-left: auto; color: var(--color-text-subtle); font-size: 12px; }
.tree-children {
  margin-left: 15px; padding-left: 12px; border-left: 1px solid var(--color-border);
  display: flex; flex-direction: column; gap: 2px; margin-top: 2px;
}
.tree-spacer { width: 20px; flex: none; }

/* Import step-flow */
.step-flow { display: flex; align-items: center; gap: 0; margin-bottom: 16px; }
.step { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
.step-num {
  flex: none; width: 28px; height: 28px; border-radius: 999px;
  display: inline-flex; align-items: center; justify-content: center;
  font-weight: 600; font-size: 13px; background: var(--color-surface-alt);
  color: var(--color-text-muted); border: 1px solid var(--color-border-strong);
}
.step.is-current .step-num { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.step.is-done .step-num { background: var(--color-success); color: #fff; border-color: var(--color-success); }
.step-label { font-size: 13px; font-weight: 500; white-space: nowrap; }
.step.is-current .step-label { color: var(--color-primary-700); }
.step-line { flex: 1; height: 1px; background: var(--color-border-strong); margin: 0 12px; }

/* Dropzone (import upload) */
.dropzone {
  border: 1.5px dashed var(--color-border-strong); border-radius: 5px;
  background: var(--color-primary-50); padding: 32px; text-align: center;
  display: flex; flex-direction: column; align-items: center; gap: 10px;
}
.dropzone .dz-ico { width: 40px; height: 40px; color: var(--color-primary); }

/* Media Uploader — up to 5 Images + 2 Videos per stock item.
   Tiles, counter, buttons and dropzone are all built by js/media-uploader.js;
   the page only writes <div class="media-up" data-media-up> + an optional seed list. */
.media-up-ui { display: flex; flex-direction: column; gap: 10px; }

.media-up-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.media-up-count { font-size: 12px; color: var(--color-text-muted); }
.media-up-count b { color: var(--color-text); font-weight: 600; }
.media-up-count .sep { margin: 0 7px; color: var(--color-border-strong); }
.media-up-actions { display: flex; gap: 6px; margin-left: auto; }

.media-up-grid {
  display: grid; gap: 8px;
  grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
}
.media-up-grid:empty { display: none; }

.media-tile {
  position: relative; aspect-ratio: 1.4 / 1; overflow: hidden;
  border: 1px solid var(--color-border); border-radius: var(--radius);
  background-color: var(--color-surface-alt);
  background-size: cover; background-repeat: no-repeat;
}
.media-tile--image { cursor: grab; }
.media-tile.is-dragging { opacity: .4; }
.media-tile.is-drop-target { border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary-50); }
.media-tile video { display: block; width: 100%; height: 100%; object-fit: cover; background: #000; }

.media-tile-layer { position: absolute; inset: 0; }

.media-tile-badge {
  position: absolute; left: 6px; top: 6px;
  display: inline-flex; align-items: center; gap: 4px;
  height: 20px; padding: 0 7px; border-radius: var(--radius);
  background: var(--color-primary); color: #fff;
  font-size: 12px; font-weight: 600; line-height: 1;
}
.media-tile-badge--video { background: rgba(20,22,26,.72); font-weight: 500; }
.media-tile-badge i, .media-tile-badge svg { width: 12px; height: 12px; }

.media-tile-acts {
  position: absolute; right: 5px; top: 5px;
  display: flex; gap: 4px; opacity: 0; transition: opacity .12s;
}
.media-tile:hover .media-tile-acts,
.media-tile:focus-within .media-tile-acts { opacity: 1; }
.media-tile-act {
  width: 26px; height: 26px; padding: 0; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid rgba(255,255,255,.35); border-radius: var(--radius);
  background: rgba(20,22,26,.62); color: #fff;
}
.media-tile-act:hover { background: rgba(20,22,26,.88); }
.media-tile-act--danger:hover { background: var(--color-danger); border-color: var(--color-danger); }
.media-tile-act i, .media-tile-act svg { width: 14px; height: 14px; }

.media-tile-name {
  position: absolute; inset: auto 0 0 0; padding: 12px 7px 4px;
  background: linear-gradient(to top, rgba(20,22,26,.8), rgba(20,22,26,0));
  color: #fff; font-size: 12px; line-height: 1.25;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* A linked file carries the glyph on its name line — the tile itself looks identical to an
   uploaded one, so the mark plus the host is what says the file lives somewhere else. */
.media-tile-name i, .media-tile-name svg { width: 12px; height: 12px; vertical-align: -2px; margin-right: 4px; }

.media-up-drop {
  display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap;
  padding: 14px; text-align: center;
  border: 1.5px dashed var(--color-border-strong); border-radius: var(--radius);
  background: var(--color-surface-alt);
  font-size: 13px; color: var(--color-text-muted);
}
.media-up-drop.is-over { border-color: var(--color-primary); background: var(--color-primary-50); }
.media-up-drop .dz-ico { width: 20px; height: 20px; color: var(--color-primary); }
.media-up.is-full .media-up-drop { display: none; }

/* Link row — the second way in, under the dropzone: a file already published on the web is
   linked rather than re-uploaded. Same shape as the catalog's Upload Images dialog. */
.media-up-link { display: flex; flex-direction: column; gap: 6px; }
.media-up-link__row { display: flex; gap: 8px; }
.media-up-link__row .input { flex: 1; min-width: 0; }
.media-up-link__row .btn { flex: none; }
.media-up-link__hint { font-size: 12px; color: var(--color-text-muted); }
/* Both buckets full — nothing more can be attached, by either route. */
.media-up.is-full .media-up-link { display: none; }

@media (max-width: 768px) {
  .media-up-grid { grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); }
  .pi-thumbs { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); }
  /* Tile actions are hover-revealed on desktop; on touch there is no hover, so they stay
     visible and grow to the 44px minimum tap target. */
  .media-tile-acts { opacity: 1; }
  .media-tile-act { width: 44px; height: 44px; }
  .media-tile-act i, .media-tile-act svg { width: 18px; height: 18px; }
  .media-up-actions .btn { min-height: 44px; }
  .media-up-link__row .input,
  .media-up-link__row .btn { min-height: 44px; }
}

@media (max-width: 480px) {
  .media-up-actions { width: 100%; margin-left: 0; }
  .media-up-actions .btn { flex: 1; justify-content: center; }
  /* A url is the longest thing typed into this widget — at phone width it takes the row
     to itself rather than sharing it with the button. */
  .media-up-link__row { flex-wrap: wrap; }
  .media-up-link__row .input { flex: 1 0 100%; }
  .media-up-link__row .btn { flex: 1; justify-content: center; }
}

/* Responsive 16:9 video embed (video-help modal) */
.video-embed { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 5px; overflow: hidden; background: #000; }
.video-embed iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

/* Product photo thumbnails (1.7 : 1) + person face avatars — background-image set by app.js.
   font-size:0 hides the initials visually while keeping them in the DOM for table search. */
.thumb.is-photo {
  width: 61px;                 /* 1.7 : 1 against the 36px base height */
  background-size: cover; background-position: center; background-repeat: no-repeat;
  color: transparent; font-size: 0; border-color: rgba(0,0,0,.08);
}
.thumb.is-face, .avatar.is-face {
  background-size: cover; background-position: center 30%; background-repeat: no-repeat;
  color: transparent; font-size: 0; border-color: rgba(0,0,0,.08);
}

/* No Photo — a product that has no photograph, or one whose photograph did not load. The photo's own
   box on the control ground with a faint picture mark: it says "no photo" without pulling the eye off
   the row, where initials read as a logo and the browser's broken-image glyph reads as a fault. The mark
   scales with the box (about 16px in a 36px thumb) and stops at 32px on a stage. `.thumb.no-photo`
   outranks `.thumb.is-photo`, which a failed row thumb also carries, so cover sizing cannot stretch it. */
.no-photo,
.thumb.no-photo {
  background-color: var(--color-control);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%238794A1' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='18' height='18' rx='2'/%3E%3Ccircle cx='9' cy='9' r='2'/%3E%3Cpath d='m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21'/%3E%3C/svg%3E");
  background-position: center; background-repeat: no-repeat;
  background-size: auto clamp(10px, 44%, 32px);
  border-color: var(--color-control-border);
  color: transparent; font-size: 0;
}

body > div:nth-of-type(1) > main > div {
  background-color: var(--bg-page-1);
}

/* ---- Import entry grid + image tiles --------------------------------------
   The inline editable grid in the import slide-overs, and the thumbnail grid
   beside it. Hoisted out of nine pages' <style> blocks (the eight import-*
   pages and stock.html), where every copy was byte-identical.
   Two rules stayed behind on purpose, because they are NOT identical:
     - `table.entry-grid .input` is absent from import-warehouse.html, which
       still has entry-grid inputs; hoisting it would resize them.
     - `table.entry-grid td.col-x` carries `text-align`/`padding-right` on the
       import pages but not on stock.html. Only `width` is shared, so only
       `width` is here. */
.entry-grid-wrap { overflow-x: auto; border: 1px solid var(--color-border); border-radius: 5px; }
table.entry-grid { width: 100%; border-collapse: collapse; }
table.entry-grid th {
  text-align: left; white-space: nowrap; font-size: 11px; text-transform: uppercase;
  letter-spacing: .04em; color: var(--color-text-muted); font-weight: 600;
  padding: 8px 8px; background: var(--color-table-head); border-bottom: 1px solid var(--color-border);
  }
table.entry-grid td { padding: 5px 6px; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
table.entry-grid tbody tr:last-child td { border-bottom: 0; }
table.entry-grid td.col-id .input { min-width: 108px; }
table.entry-grid td.col-prod .input { min-width: 124px; }
.entry-remove { background: none; border: 0; cursor: pointer; color: var(--color-text-subtle); display: inline-flex; padding: 4px; border-radius: 5px; }
.entry-remove:hover { color: var(--color-danger, #dc2626); background: var(--color-surface-alt); }
.entry-remove svg { width: 15px; height: 15px; }
.img-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 10px; margin-top: 14px; }
.img-tile { border: 1px solid var(--color-border); border-radius: 5px; overflow: hidden; background: var(--color-surface); }
.img-tile .ph { height: 86px; background: var(--color-primary-50); display: flex; align-items: center; justify-content: center; color: var(--color-primary-700); }
.img-tile .cap { font-size: 11px; padding: 5px 8px; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  table.entry-grid td.col-x { width: 34px; }

/* ---- Showroom buyer sign-in: email-only, link-sent state -----------------
   The buyer sign-in modal asks for an email and nothing else — clients never
   get a password (settings-security.html, "Client Access"). Sending swaps the
   form for a confirmation that names the address the link went to, because the
   commonest failure is typing the address wrong and waiting for mail that was
   never going to arrive.                                                     */
.si-hint { font-size: 12px; color: var(--color-text-muted); margin: 10px 0 0; line-height: 1.5; }
.si-sent { text-align: center; padding: 6px 0 2px; }
.si-sent__icon { width: 34px; height: 34px; color: var(--color-primary-700); margin-bottom: 8px; }
.si-sent__lead { font-size: 15px; font-weight: 600; margin: 0 0 4px; color: var(--color-text); }
.si-sent .sub { margin: 0; }
.si-sent .si-hint { margin-top: 10px; }
.modal.is-si-sent .modal-header .sub { display: none; }

/* ---- Request dialogs: who is asking ---------------------------------------
   The block asks only for what the Client record does not already hold, so it
   has three shapes — everything (a stranger), the gaps (signed in, partial
   record), or one line naming who this goes out as. `.rq-known` is that line;
   it needs an escape hatch, because a stored identity that is wrong is worse
   than no identity at all. See js/showroom-request-who.js.                 */
.rq-known { display: flex; align-items: center; gap: 8px; padding: 10px 12px; margin-bottom: 4px;
            border: 1px solid var(--color-border); border-radius: 5px; background: var(--color-surface-alt); }
.rq-known__icon { width: 16px; height: 16px; flex: none; color: var(--color-primary-700); }
.rq-known__txt { font-size: 13px; }
.rq-known__alt { font-size: 12px; margin-left: auto; flex: none; white-space: nowrap; }
.rq-signedin { font-size: 12px; color: var(--color-text-muted); margin-bottom: 10px; }
/* The join note sits under the email pair and explains that the same address is
   the whole of "signing in" — no detour, no second round trip. */
.rq-sec__note--join { margin-top: 8px; }

@media (max-width: 480px) {
  .rq-known { flex-wrap: wrap; }
  .rq-known__alt { margin-left: 0; }
}

/* ---- Resolve Buyer: one dialog behind Invite, Approve and Resolve Company ---
   The three surfaces answer the same four questions — which company, which
   person, what kind of account, whose rep — so they share one dialog and one
   stylesheet block. See js/resolve-buyer.js.

   The picker rows carry more than a name on purpose. A name alone cannot tell
   two real companies apart, and "disambiguate by city" fails the moment a city
   is blank, so a row states everything cheap that distinguishes one client from
   another: where, what tier, what segment, how many people, how long on file.  */
/* THE DIALOG DOES NOT SCROLL — ONE PANEL INSIDE IT DOES. Stacked as sections, the three
   steps made a column you scrolled past the answered parts of, and the scroll took the
   whole dialog with it: the tab strip and the button that judges your answers could both
   leave the screen. Header, tabs and footer are pinned; only the open panel moves. */
.rb-modal { overflow: hidden; }
.rb-body { flex: 1; min-height: 0; overflow-y: auto; }
/* A floor, so switching from a two-field tab to a five-row list does not make the dialog
   jump up the screen mid-decision. */
.rb-body .modal-tab-panel { min-height: 268px; }

/* The tab strip IS the summary: each tab names its section and, under it, the answer that
   section currently holds. Three answers readable at a glance, in the order asked — the
   alternative is opening all three to find out where you are. */
.rb-tabs { margin: 0 16px 0; flex: none; }
.rb-tab { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
          align-items: flex-start; text-align: left; padding: 7px 11px; }
.rb-tab__t { display: flex; align-items: center; gap: 7px; font-size: 13px; }
.rb-tab__n { display: inline-flex; align-items: center; justify-content: center;
             width: 17px; height: 17px; flex: none; border-radius: 50%;
             background: var(--color-primary-50); color: var(--color-primary-700);
             font-size: 10px; font-weight: 700; }
.rb-tab.active .rb-tab__n { background: var(--color-primary); color: #fff; }
.rb-tab__s { font-size: 11px; font-weight: 400; color: var(--color-text-muted);
             max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* An unanswered tab has to look unanswered. Same weight as an answer, it reads as one. */
.rb-tab__s.is-open { color: var(--color-text-subtle); font-style: italic; }
.rb-tab:disabled { opacity: .5; cursor: not-allowed; }

/* What the visitor typed, on one line above the tabs — it is the evidence for both the
   company answer and the person answer, so it belongs to neither tab. */
.rb-seedbar { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px;
              flex: none; padding: 9px 16px; font-size: 12px;
              background: var(--color-surface-alt); border-bottom: 1px solid var(--color-border); }
.rb-seedbar__h { color: var(--color-text-muted); }
.rb-seedbar__v { color: var(--color-text); font-weight: 600; }
.rb-seedbar__v + .rb-seedbar__v::before { content: '·'; margin-right: 10px;
                                          color: var(--color-text-subtle); font-weight: 400; }

.rb-list { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
.rb-opt { display: flex; align-items: flex-start; gap: 10px; padding: 9px 11px;
          border: 1px solid var(--color-border); border-radius: 5px; cursor: pointer; }
.rb-opt:hover { border-color: var(--color-border-strong); }
.rb-opt.is-chosen { border-color: var(--color-primary); background: var(--color-primary-50); }
.rb-opt input { margin-top: 2px; flex: none; }
.rb-opt__body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.rb-opt__name { font-size: 13px; font-weight: 600; display: flex; align-items: center;
                gap: 7px; flex-wrap: wrap; }
.rb-opt__meta { font-size: 12px; color: var(--color-text-muted); overflow-wrap: break-word; }
.rb-tag { font-size: 10px; }
/* Dead only where the server would refuse it — Invite throws on a client that
   already has an account here. Approve and Resolve keep the row live, because
   there an existing account is the answer, not the obstacle. */
.rb-opt.is-dead { opacity: .55; cursor: not-allowed; }
.rb-opt.is-dead:hover { border-color: var(--color-border); }
.rb-opt--new { border-style: dashed; }
.rb-none { font-size: 12px; color: var(--color-text-muted); margin: 6px 0; }

.rb-newbox { margin-top: 10px; padding: 12px 13px; border: 1px solid var(--color-border);
             border-radius: 5px; background: var(--color-surface-alt); }
.rb-newbox--solo { margin-top: 0; }
.rb-note { font-size: 12px; color: var(--color-text-muted); margin: 10px 0 0; line-height: 1.5; }
.rb-wait { font-size: 12px; color: var(--color-text-subtle); margin: 0; }
/* Under a select whose values collide with another select's. Client Type and Account
   Type both offer `Distributor` and `Designer` and mean different things by them, so
   each says which question it is asking. */
.rb-hint { display: block; margin-top: 5px; font-size: 11px; line-height: 1.45;
           color: var(--color-text-muted); }
/* A collision is a fact about the data, not an error the operator made — stated
   in the warn tone rather than the danger one, and it names who holds the address. */
.rb-dupe { font-size: 12px; line-height: 1.5; margin: 10px 0 0; padding: 8px 10px;
           border: 1px solid var(--color-warn); border-radius: 5px;
           background: var(--color-warn-50); color: var(--color-text); }
.rb-emailrow { display: flex; gap: 10px; padding: 11px 0 2px; }
/* The reason the primary is dead, stated where the dead button is. A disabled
   control with no explanation reads as a broken page. */
.rb-block { margin-right: auto; font-size: 12px; color: var(--color-text-muted); }

@media (max-width: 560px) {
  /* Three tabs side by side leave ~110px each, which truncates the answers they exist to
     show. Stacked, each keeps a full row and stays legible — the summaries are the point,
     not the horizontal arrangement. */
  .rb-tabs { flex-direction: column; }
  .rb-tab { flex: none; width: 100%; }
  .rb-body .modal-tab-panel { min-height: 0; }
  /* The blocked reason takes its own line here. `.modal-footer` is nowrap by default,
     so it has to be told to wrap or a full-width reason squeezes Cancel and the primary
     into slivers — and the primary is the one control the reason is talking about. */
  #modal-resolve-buyer .modal-footer { flex-wrap: wrap; }
  .rb-block { width: 100%; margin: 0 0 8px; }
}

/* -------------------------------------------------------------------------
   Product Variants — axis chips + the generated combination matrix
   -------------------------------------------------------------------------
   A Variant Axis is a menu the product offers (Thickness, Finish); a Variant is
   one cell of the cross-product of the values picked on each axis. Two renderings
   share these classes: the editable matrix (wizard, Edit slide-over) whose rows
   carry a `.switch`, and the read-only matrix (product detail) whose rows carry a
   `.dot-status`. Same row geometry either way, so a product reads the same in the
   form that authors it and the page that reports it.

   Chips, not switches, for the axis values: a seven-option Finish list rendered as
   seven switch rows would own the wizard's whole side column. The switch is
   reserved for the row it actually belongs to — one Variant's On/Off.

   PRODUCT OPTIONS reuse the row geometry below rather than restating it. An
   Option row (Finishing → Polished) and a Variant row (2 cm) are the same shape
   of thing to the eye and to the hand: one line, one switch, offered or not. They
   differ in what the switch MEANS — a Variant row is a priced line withdrawn, an
   Option row is a menu entry withheld — and that is carried by the card each sits
   in and by the summary line under it, not by a second set of measurements. The
   `.option-*` selectors are grouped into the `.variant-*` rules for exactly that
   reason: one geometry, two meanings, and no chance of the two drifting apart. */
.variant-axis { margin-bottom: 14px; }
.variant-axis:last-of-type { margin-bottom: 0; }
.vchips { display: flex; flex-wrap: wrap; gap: 6px; }
/* Same geometry as .fchip one size down — the card column is ~300px, and a 13px
   chip at 7×13 padding wraps every finish onto its own line. */
.vchip {
  display: inline-flex; align-items: center;
  padding: 5px 10px; font-size: 12px; font-family: inherit; line-height: 1.2;
  color: var(--color-text); background: var(--color-control);
  border: 1px solid var(--color-control-border);
  border-radius: 5px; cursor: pointer;
  transition: background 120ms, color 120ms, border-color 120ms, box-shadow 120ms;
}
.vchip:hover { color: var(--color-primary-700); border-color: var(--color-primary); }
/* Selected = the wash every other multi-select in the portal uses (.fchip.on,
   .subtab.active). Solid primary stays reserved for buttons. */
.vchip[aria-pressed="true"] {
  background: color-mix(in oklab, var(--status-hue, var(--color-primary)) 20%, var(--color-surface));
  color: var(--color-text);
  border-color: color-mix(in oklab, var(--status-hue, var(--color-primary)) 42%, var(--color-border));
  box-shadow: 0 1px 3px rgb(15 23 42 / 0.10);
}

/* The generated matrix. A list, not a table: it lives in a narrow side column and
   has to wrap at 375px, and it has exactly one column of data. */
.variant-matrix, .option-list { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
.variant-row, .option-row {
  display: flex; align-items: center; gap: 10px;
  padding: 6px 8px; border-radius: 5px;
  font-size: 13px; color: var(--color-text);
  transition: background 120ms;
}
.variant-row:hover, .option-row:hover { background: var(--color-surface-alt); }
/* Switch left, label right, label NOT bold (memory.md rule 5). The label is a value,
   not a section header. */
.variant-row > .switch, .option-row > .switch { order: -1; }
.variant-name, .option-name { font-weight: 400; min-width: 0; }
/* Off keeps its row and its price — it is a combination this product declares but is
   not selling today, which is a different fact from one it never declared. An Option
   row switched off is the same statement about a menu entry. */
.variant-row.is-off .variant-name,
.option-row.is-off .option-name { color: var(--color-text-subtle); }
.variant-row .variant-flag,
.option-row .option-flag { margin-left: auto; flex: none; }

/* One menu inside the Options card. Same rhythm as an axis group, so a card holding
   two menus reads like a card holding two axes. */
.option-group { margin-bottom: 14px; }
.option-group:last-of-type { margin-bottom: 0; }

/* The same card in a Stock slide-over (0144). It carries no `.card` chrome — the panel
   IS the container, and a bordered box inside it would claim a nesting that is not
   there — so all it needs is the bottom gap its neighbours use, matching the `.stack`
   of Piece Facts directly above it. */
.so-options { margin-bottom: 14px; }

/* The rate, read-only, quoted from the Pricing store. It sits TIGHT AGAINST THE NAME
   — 8px, `.option-row`'s 10px gap pulled in by 2 — and explicitly NOT floated to the
   right of the row: memory.md rule 6 bars a `label ……… value` pair spread to opposite
   ends of a cell, because the eye then has to cross whitespace to associate the two.
   The `Not offered` badge keeps the far right; it qualifies the ROW, not the number.

   The name reserves the width of the LONGEST value in the group (Sandblasted, 72px at
   13px) so the six rates start at one x instead of zig-zagging behind six names of
   different lengths. That is alignment bought without separation: the column sits 8px
   past the longest name, which is exactly the gap rule 6 asks for — the pairing is
   tight, and it is tight for every row at once. */
.option-rate {
  flex: none; margin-left: -2px;
  font-size: 12px; color: var(--color-text-muted);
  font-variant-numeric: tabular-nums;
}
.option-row .option-name { min-width: 80px; }
.option-row.is-off .option-rate { color: var(--color-text-subtle); }
/* No rate set is a STATE, not a zero — a finish nobody has priced yet must not read
   as one that is free. */
.option-rate--none { font-style: italic; color: var(--color-warn); }
/* With a rate in the row the badge no longer needs the whole gutter; auto would split
   the free space and push the two apart again. */
.option-row .option-rate ~ .option-flag { margin-left: auto; }

/* Read-only rendering — product detail pages. No switch to press, so the dot carries
   the state and an Off row says so in words rather than by a missing control. */
.variant-row--ro { padding: 5px 0; }
.variant-row--ro:hover { background: none; }

/* Footer line under the matrix: "4 variants · 3 on". Its own line rather than a badge,
   because it changes on every chip press and a moving badge in a card header reads as
   an alert. */
.variant-summary, .option-summary {
  margin: 10px 0 0; font-size: 12px; color: var(--color-text-muted);
  font-variant-numeric: tabular-nums;
}
/* The Options line runs longer than the variants one — it has to say what picking an
   option does to the price, which is the only place that rule appears on this page. */
.option-summary { line-height: 1.45; }
/* No axis value picked = no variants. Said plainly, because "nothing here" and "this
   product is sold one way" are the same state and the second is the useful reading.
   No option switched on reads the same way about the menu. */
.variant-empty, .option-empty {
  margin: 10px 0 0; font-size: 12px; line-height: 1.45; color: var(--color-text-subtle);
}

/* Read-only one-line summary of an axis inside a detail page's field grid —
   "Thickness   2 cm · 3 cm". Matches the weight of a plain `.field` value. */
.variant-axis-line { font-weight: 500; }

@media (max-width: 768px) {
  /* The switch stays 34×20 — the size every switch in the portal is — but a finger
     needs 44px (responsive skill). Grow the HIT AREA with a pseudo-element instead of
     the control, so it looks identical and the row keeps its 32px rhythm; `::after` is
     already the knob, hence `::before`.
     Scoped to `.option-row` on purpose. These rows are 0144's, and they are the only
     place `.option-row` now exists. `.variant-row` carries the portal's pre-existing
     target size on the wizard and the detail pages; closing that gap would reflow
     pages this change has no business touching. */
  .option-row .switch::before {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: 44px; height: 44px; transform: translate(-50%, -50%);
  }
}

@media (max-width: 480px) {
  /* A long combination ("Raw / Unfinished" over a 5 cm thickness) needs the full row
     on a phone; the flag drops under it rather than squeezing the name to two words.
     "Sandblasted" + "Not offered" is the same squeeze on an Option row. */
  .variant-row, .option-row { flex-wrap: wrap; }
  .variant-row .variant-flag,
  .option-row .option-flag { margin-left: 34px; }
}

/* A withdrawn variant keeps its row and its base in the pricing grid — that is the whole
   point of Off — so the row has to read as present-but-not-selling rather than disappear. */
tr.price-child--variant.is-off .cell-stack .main { color: var(--color-text-subtle); }

/* ---------------------------------------------------------------------------
   Showroom combination picker (js/showroom-variants.js)

   One row per axis: the axis name, then its declared values as chips. The name
   sits in a fixed column so two axes line up their chips instead of stepping
   right by the length of the word before them.
   --------------------------------------------------------------------------- */
.sv-pick { margin-bottom: 12px; }
.sv-axis { display: flex; align-items: flex-start; gap: 10px; }
.sv-axis + .sv-axis { margin-top: 8px; }
.sv-axis__name { flex: none; width: 72px; padding-top: 7px; font-size: 12px; color: var(--color-text-subtle); }
/* A value the current pick cannot reach. It stays on the page rather than
   disappearing: a chip that vanishes as you click elsewhere makes the set of
   choices feel unstable, and the buyer needs to see that 3 cm exists before
   understanding that it is not sold Honed. */
.fchip.is-out { opacity: .45; cursor: not-allowed; text-decoration: line-through; }
.fchip.is-out:hover { color: inherit; border-color: var(--color-border-strong); }
@media (max-width: 480px) {
  .sv-axis { flex-direction: column; gap: 4px; }
  .sv-axis__name { width: auto; padding-top: 0; }
}

body > div:nth-of-type(1) > aside {
  background-color: var(--bg-sidebar-1);
}

/* ---------------------------------------------------------------------------
   TEMPORARY — old/new prototype switch (2026-08-30)

   A slim strip above the page on the eight pages that exist in two versions,
   so the old and the new can be flipped between while they are compared. The
   dashed rule is the tell that it is scaffolding and not part of the product.

   To remove: delete this block and every <div class="cmpbar"> in
   html/portal/*.html — grep for TEMP:COMPARE.
   --------------------------------------------------------------------------- */
.cmpbar { display: flex; align-items: center; gap: 6px; flex: none; padding: 6px 24px; background: var(--color-control); border-bottom: 1px dashed var(--color-border-strong); font-size: 11px; }
.cmpbar__label { color: var(--color-text-subtle); text-transform: uppercase; letter-spacing: .06em; }
.cmpbar__link { padding: 2px 9px; border: 1px solid var(--color-border-strong); border-radius: 5px; color: var(--color-text); }
.cmpbar__link:hover { color: var(--color-text); border-color: var(--color-text); }
.cmpbar__link.is-current { background: var(--color-text); border-color: var(--color-text); color: var(--color-surface); font-weight: 600; }

:is([data-theme="evening"], [data-theme="elegant"]) body > div:nth-of-type(1) > aside {
  background-color: var(--bg-sidebar-1);
}

/* ---- Share a piece to a client's VIP Offers ------------------------------
   The dialog behind every share control on Stock. One question — which client —
   so the search and the rows it returns are the whole body, and everything that
   qualifies the send sits in the foot beside the button that performs it.
   See js/share-to-showroom.js.

   The rows carry more than a name for the same reason the buyer picker does: a
   company name alone cannot tell two clients apart, and the count of what is
   already on a client's shelf is what says whether this piece joins a curated
   room or opens an empty one. */
.sv-modal { max-width: 560px; }
/* A floor under the list, so narrowing the search from eight rows to one does not
   shrink the dialog out from under the pointer mid-decision. */
.sv-list { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; min-height: 232px; }
.sv-opt { display: flex; align-items: flex-start; gap: 10px; padding: 9px 11px;
          border: 1px solid var(--color-border); border-radius: 5px; cursor: pointer; }
.sv-opt:hover { border-color: var(--color-border-strong); }
.sv-opt.is-chosen { border-color: var(--color-primary); background: var(--color-primary-50); }
.sv-opt input { margin-top: 2px; flex: none; }
.sv-opt__body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.sv-opt__name { font-size: 13px; font-weight: 600; display: flex; align-items: center;
                gap: 7px; flex-wrap: wrap; }
.sv-opt__meta { font-size: 12px; color: var(--color-text-muted); overflow-wrap: break-word; }
.sv-tag { font-size: 10px; }
.sv-none { font-size: 12px; color: var(--color-text-muted); margin: 6px 0; }

/* Left of the spacer, so it reads as a condition on the button rather than as one
   more row of the list it sits under. */
.sv-notify { display: inline-flex; align-items: center; gap: 7px; font-size: 12px;
             color: var(--color-text-muted); cursor: pointer; }
.sv-notify input { margin: 0; }

/* The primary label is a sentence, not a verb, and on a phone it takes the width it
   takes — so the foot wraps and the checkbox claims its own line above the buttons
   rather than squeezing them off the edge. */
.sv-modal .modal-footer { flex-wrap: wrap; }
@media (max-width: 560px) {
  .sv-modal .sv-notify { flex: 1 0 100%; padding: 2px 0 6px; }
  .sv-modal .spacer { display: none; }
  .sv-modal .modal-footer .btn { flex: 1; justify-content: center; }
}

/* THE STEP DIALOGUE — a status badge that is a button opens it: a material on
   quote.html, an order on orders.html. It opens to the badge's LEFT, centred on it, where the row has
   room — the columns to the right are a menu and an edge. It is a card, not a
   menu: the record's name over the moves, and under each move a line saying
   what it does, at a pace a decision deserves. Opened and closed by the same
   `.dropdown-menu.open` every menu on the portal uses; only the place and the
   shape are overridden here. 7px is the house ceiling on a radius. */
.dropdown-menu.req-step-pop {
  top: 50%; right: calc(100% + 12px); left: auto; transform: translateY(-50%);
  width: 276px; padding: 14px 12px 8px; border-radius: 7px;
  text-align: left; cursor: default;
}
.req-step-head {
  display: flex; flex-direction: column; gap: 2px;
  padding: 0 6px 10px; margin-bottom: 6px;
  border-bottom: 1px solid var(--color-border);
}
.req-step-name { font-size: 13px; font-weight: 600; }
.req-step-sub { font-size: 11px; color: var(--color-text-subtle); }
.req-step-opt {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 9px 6px; border: 0; border-radius: 5px; background: none;
  font: inherit; text-align: left; color: var(--color-text); cursor: pointer;
  transition: background-color 120ms;
}
.req-step-opt:hover { background: var(--color-primary-50); }
.req-step-opt--no:hover { background: var(--color-danger-50); }
/* The direction is a glyph in its own small box, on the control ground (§13). */
.req-step-ico {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 28px; height: 28px; border-radius: 5px;
  background: var(--color-control); color: var(--color-text-muted);
}
.req-step-ico svg { width: 15px; height: 15px; }
.req-step-opt--no .req-step-ico { color: var(--color-danger); }
.req-step-txt { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.req-step-l { font-size: 13px; font-weight: 600; }
.req-step-opt--no .req-step-l { color: var(--color-danger); }
.req-step-h { font-size: 11px; color: var(--color-text-subtle); }

/* ---------- The view switch ----------------------------------------------------
   A second control INSIDE the panel (Linked Orders, Arrivals' four views,
   Bills' views, Reorder's views, Analytics sections, Suppliers' type,
   Shipments' Board / Map / List): words
   with a count, the one in force in the text ink at 600, the rest muted. Never
   boxed, never filled — it is not a second tab strip, and a box would make it
   read as one. The page script shows the matching `[data-view-panel]` and
   hides the rest; it never carries `data-tabs`. The count is a figure in the
   word's own type, not a chip. */
.po-viewbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: 4px 20px;
  padding: 0 0 10px;
}
.po-viewbar__btn {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 2px 0; border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 13px; font-weight: 500;
  color: var(--color-text-muted); white-space: nowrap;
  transition: color 120ms;
}
.po-viewbar__btn:hover { color: var(--color-text); }
.po-viewbar__btn.is-active { color: var(--color-text); font-weight: 600; }
.po-viewbar__btn svg { width: 14px; height: 14px; flex: none; }
.po-viewbar__btn .count {
  font-size: 12px; font-weight: 600; color: var(--color-text-subtle);
  font-variant-numeric: tabular-nums;
}
.po-viewbar__btn.is-active .count { color: var(--color-primary-700); }
[data-view-panel][hidden] { display: none; }
