/* =========================================================================
   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;
  /* 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:        161px;
  --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 storefront 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 (storefront 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 */
.app.collapsed .sidebar-brand { overflow: hidden; background: url('../img/vava-sales-logomark.svg') center / auto 28px 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 { top: 1px; right: 12px; }
.app.collapsed .sidebar-footer { justify-content: center; padding: 12px 0; }
.app.collapsed .sidebar-footer > div,
.app.collapsed .sidebar-footer > .icon-btn { display: none; }

/* Sidebar */
.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);
}

.sidebar-brand {
  display: flex;
  align-items: 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 .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; }

/* 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; }

.sidebar-nav { padding: 4px 8px 16px; flex: 1; overflow-y: auto; }

.nav-link {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-radius: 5px;
  color: var(--color-sidebar-text);
  font-size: 13px;
  margin-top: 3px;
  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;
}

/* Loud count bubble for a queue that is waiting on someone. It floats over the
   row's top-right corner rather than taking a column of its own — a two-word
   label like "VIP Concierge" would wrap if the bubble claimed real width — and
   keeps the same yellow in every theme so it never blends into a light sidebar.
   Collapsed sidebars pull it in over the icon; see the collapsed rules below.
   `--new` is that same bubble 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. */
.nav-badge--alert,
.nav-badge--new {
  position: absolute;
  top: 3px;
  right: 4px;
  margin-left: 0;
  min-width: 16px;
  text-align: center;
  color: #1F2937;
  font-weight: 700;
  pointer-events: none;
}
.nav-badge--alert { background: #F1C452; }
.nav-badge--new   { background: #BFDBFE; }

.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; }

.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; }

/* 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);
}

/* 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 storefront'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 storefront-default.css because the headers that
   host it — three storefront layouts, the older storefront 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; }
.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 such figures in one KPI — the same count over two windows. Both numbers on
   one row, each window named under its own number in meta type, and each pair
   holds half the card so the second starts at the middle. */
.kpi-split { display: grid; grid-template-columns: 1fr 1fr; column-gap: 8px; row-gap: 4px; }
/* 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 one-line 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 Storefront'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. */
:not(.page-header):has(~ .tabs--attached):not(:has(~ :not([hidden]) ~ .tabs--attached)),
:not(.page-header):has(~ * > .tabs--attached:first-child):not(:has(~ :not([hidden]) ~ * > .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 Storefront is the one page that reaches its strip this way. */
:has(~ .tabs--attached) > .kpi-summary: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: center; justify-content: space-between; gap: 12px;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: 9px 14px;
  cursor: pointer;
  font-family: inherit;
}
.kpi-summary__bar:hover { border-color: var(--color-border-strong); }
.kpi-summary__glance { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; font-size: 13px; color: var(--color-text-muted); }
.kpi-glance-item { display: inline-flex; align-items: center; gap: 6px; }
.kpi-glance-item strong { color: var(--color-text); font-weight: 700; font-variant-numeric: tabular-nums; }
.kpi-glance-item svg { width: 14px; height: 14px; color: var(--color-warn); }
.kpi-glance-sep { width: 1px; height: 15px; background: var(--color-border); }
.kpi-summary__cta { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--color-primary-700); white-space: nowrap; }
.kpi-summary__cta svg { width: 15px; height: 15px; transition: transform 160ms ease; }
.kpi-summary__cards { margin-top: 12px; }
.kpi-summary[data-kpis-collapsed] .kpi-summary__cards { display: none; }
.kpi-summary:not([data-kpis-collapsed]) .kpi-summary__cta svg { transform: rotate(180deg); }

.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); }

/* 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);
}
/* The page search box lands on the SAME PIXEL on every page — same left edge, same
   width, whatever else shares the row. The generic `.search` is elastic (flex:1
   between 240 and 320), so a crowded row robbed it: Stock came out at 276px, Quotes
   and Activity at ~245px on a 1280 screen, Catalog at the full 320px. Pinned here so
   the row's other controls take the give and the field never moves.
   Wrapping, not shrinking, is what happens when the row runs out of width. */
.toolbar--page > .search { flex: none; width: 320px; max-width: 100%; align-self: flex-start; }
/* `.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; }
/* 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] { padding: 7px 8px; flex: none; }
.toolbar [data-filter-toggle] 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 { position: relative; display: inline-flex; width: 16px; height: 16px; }
.toolbar [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 = a tinted state, not a button. Solid primary is reserved for buttons, so the
   chosen chip takes the same primary-50 fill + primary-700 label as .subtab.active, with a
   primary hairline for a defined edge. */
.fchip.on {
  background: var(--color-primary-50); color: var(--color-primary-700);
  border-color: var(--color-primary); font-weight: 600; box-shadow: var(--shadow-sm);
}
/* The count pill shares the chip's own tint once selected, so it flips to surface (same
   move as .subtab.active .count). */
.fchip.on .row-count { background: var(--color-surface); }

/* 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 and
   decays across the rest: 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 tbody td::after {
  content: ""; position: absolute; bottom: -1px; height: 1px;
  left: var(--table-pad-x); right: var(--table-pad-x);
  background: linear-gradient(to right,
    var(--color-border-strong) 0%,
    var(--color-border-strong) 35%,
    var(--color-border) 65%,
    transparent 100%);
}
/* 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, storefront, VIP showrooms
   ==========================================================================
   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 storefront page, which showroom.

   Four states and no "all": what is waiting, what was agreed, what was turned
   down, and what was nonsense. They answer different questions and a list
   holding all four at once answers none of them, so exactly one is in force
   at any moment and the queue opens on the one that is work. */
.req-queue { table-layout: fixed; }
/* The first column carries three lines and the longest of them is a title
   somebody typed, so it takes a third of the row. The four that follow are a
   word, a date, a product and a figure, and each is sized for its widest
   plausible value rather than for its data. */
.req-queue th.rq-src    { width: 34%; }
.req-queue th.rq-type   { width: 10%; }
.req-queue th.rq-asked  { width: 10%; }
.req-queue th.rq-items  { width: 20%; }
.req-queue th.rq-value  { width: 11%; }
.req-queue th.rq-status { width: 15%; }
/* 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 source, the type 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; }

/* 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;
}

/* What KIND of request it is, named in words rather than in a badge. The
   status column already carries a badge, and two badges on one row compete
   for the same glance — this one is a fact about the request, not a state to
   act on. */
.req-queue .rq-type {
  color: var(--color-text-muted);
  font-size: 12px;
  white-space: nowrap;
}
.req-queue .rq-type i { width: 13px; height: 13px; vertical-align: -2px; margin-right: 4px; }

/* 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; }

.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 storefront 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; }

/* ---- 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 selected segment MUST read as clearly chosen — a primary
   ring + lift, never a bare white pill on the grey track (see "always contrast"). */
.subtabs {
  display: flex; gap: 4px; flex-wrap: wrap;
  padding: 4px;
  background: var(--color-surface-alt);
  border-radius: 5px;
  margin-bottom: 8px;
}
.subtab {
  padding: 7px 13px;
  font-size: 13px;
  color: var(--color-text-muted);
  background: transparent;
  border: 0; border-radius: 5px;
  cursor: pointer;
  display: inline-flex; align-items: center; gap: 6px;
  font-family: inherit;
  transition: background 120ms, color 120ms, box-shadow 120ms;
}
.subtab:hover { color: var(--color-primary-700); }
.subtab.active {
  background: var(--color-primary-50);
  color: var(--color-primary-700);
  font-weight: 600;
  box-shadow: var(--shadow-sm);
}
/* on the tinted active pill the count chip would blend — flip it to white */
.subtab.active .count { background: var(--color-surface); }

/* ---- STATUS FILTER: the one way to pick a status (memory.md §19) -----------
   Stock, Quotes, Campaigns, Storefront and VIP Showrooms each grew their own
   control for the same question, so the same job looked like five jobs. This is
   STOREFRONT's bar — the segmented track on the search row — made the platform's,
   with two things added that it did not have: colour, and more than one answer.

   The track and the pill come from `.subtabs`, unchanged. What this adds is the
   hue, and only on the segments actually CHOSEN: an unpicked status is plain text
   on the grey track, and picking one lights it in the faintest wash of its own
   colour. That is what makes the colour worth having under multi-select — the bar
   shows you which states you are looking at, in their own colours, all at once.

   Nothing here is bold. The chosen segment is already a raised pill on a flat
   track and already carries its colour; weight on top of that is a third way of
   saying the same thing, and four bold labels in a row read as a heading.

   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: a
   segment 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 Showroom is
   `success`, a client request still awaiting an answer is `warn`, and both are
   correctly called "Active".

   Mixing toward `--color-surface` rather than toward a literal white keeps the
   wash correct on the dark theme, where the surface is not white. */
[data-status-filter] [data-state='success'] { --status-hue: var(--color-success); }
[data-status-filter] [data-state='warn']    { --status-hue: var(--color-warn); }
[data-status-filter] [data-state='danger']  { --status-hue: var(--color-danger); }
[data-status-filter] [data-state='info']    { --status-hue: var(--color-info); }
[data-status-filter] [data-state='neutral'] { --status-hue: var(--color-neutral-ink); }
[data-status-filter] .subtab { font-weight: 400; }
[data-status-filter] .subtab:hover { background: color-mix(in oklab, var(--status-hue) 7%, transparent); }
[data-status-filter] .subtab.active {
  background: color-mix(in oklab, var(--status-hue) 16%, var(--color-surface));
  color: var(--color-text);
  font-weight: 400;
  box-shadow: var(--shadow-sm);
}
/* The count sits ON the wash once chosen, so it takes a deeper step of the same
   hue rather than the shared primary tint — a blue count on an amber pill is two
   colours saying one thing. */
[data-status-filter] .subtab.active .count {
  background: color-mix(in oklab, var(--status-hue) 30%, var(--color-surface));
  color: var(--color-text);
}

/* ---- 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; }

/* 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: 3px;
}
.subtabs--inline .subtab { padding: 5px 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: 1px solid var(--color-control-border);
  padding: 3px;
}
.toolbar--page .subtabs--inline { padding: 2px; }

@media (max-width: 480px) {
  /* A page-toolbar carries more than a search — a funnel, and on Catalog two segmented
     controls as well. At ~320px of usable width the search field's 240px floor takes the
     whole line on its own and pushes the funnel onto one of its own, which reads as a
     control that fell off the row. Let it shrink here: it is still the widest thing on
     the row, and the funnel belongs beside the field it narrows. */
  .toolbar--page .search { min-width: 0; }
}

.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;
  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-primary-700); }
.subtabs--choice .subtab.active {
  background: var(--color-primary-50);
  color: var(--color-primary-700);
  font-weight: 600;
  box-shadow: var(--shadow-sm);
}

/* -------------------------------------------------------------------------
   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 storefront 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; }
@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-sales-logomark.svg') center / auto 28px 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 { top: 1px; right: 12px; }
  .nav-group[hidden] { display: block; }
  .sidebar-section__count,
  .sidebar-section .nav-badge--alert { display: 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 ("Storefront 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;}

/* --- 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; 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; 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 bottom padding: `.modal` centres itself with auto margins inside
     this content box, so a deeper bottom inset raises it. Done with padding rather
     than a transform, which `modal-pop` overwrites on its final frame. */
  --modal-lift: 18vh;
  padding: 48px 16px calc(48px + var(--modal-lift)); 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; } }
.modal {
  width: 100%; max-width: 520px; margin: auto;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  /* The lift eats into the height a dialog may claim, so it is subtracted here —
     otherwise a full-height modal would push past the bottom inset and scroll the
     overlay. Falls back to 0 for any `.modal` used outside `.modal-overlay`. */
  display: flex; flex-direction: column; max-height: calc(100vh - 96px - var(--modal-lift, 0px));
  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 Storefront 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. Page-only chrome (the detail drawer, the loading
   skeleton, the custom date range) stays with the page that has it. */
.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); }

.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; }

/* 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); align-items: stretch; justify-content: flex-end; padding: 0; --modal-lift: 0px; }
.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);
}

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; }

/* ---- Storefront 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/storefront-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 tinted state every other multi-select in the portal uses
   (.fchip.on, .subtab.active). Solid primary stays reserved for buttons. */
.vchip[aria-pressed="true"] {
  background: var(--color-primary-50); color: var(--color-primary-700);
  border-color: var(--color-primary); font-weight: 600; box-shadow: var(--shadow-sm);
}

/* 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); }

/* ---------------------------------------------------------------------------
   Storefront combination picker (js/storefront-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 Showroom ------------------------------
   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; }
}
