/* SW Crawler dashboard — design tokens lifted from the approved mockup (SW Crawler.dc.html) */

.pp-doc-link {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid transparent;
  /* Now a <button> (see ppDownloadPlanFile in settings.js), not an <a> - reset everything a button
     brings that a plain title-as-link never had. */
  background: none; border-left: none; border-right: none; border-top: none;
  font: inherit; padding: 0; cursor: pointer;
}
.pp-doc-link:hover {
  border-bottom-color: var(--muted-2);
}
.pp-doc-link:disabled {
  cursor: default; opacity: .7;
}

:root {
  /* Motion curves. The built-in ease/ease-out are too soft to read as intentional. */
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
  --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
  --bg: #fafaf9;
  --ink: #0c0a09;
  --ink-soft: #292524;
  --muted: #57534e;
  --muted-2: #78716c;
  --faint: #a8a29e;
  --line: #e7e5e4;
  --line-soft: #f5f5f4;
  --white: #fff;
  --green-bg: #f0fdf4;
  --green-fg: #15803d;
  --green-dot: #22c55e;
  --red-bg: #fef2f2;
  --red-fg: #b91c1c;
  --red-dot: #ef4444;
  --amber-bg: #fef3c7;
  --amber-fg: #a16207;
  --blue-bg: #eff6ff;
  --blue-fg: #1d4ed8;
  --blue-dot: #3b82f6;
  /* SW Corp navy (#13395A) tinted to the same weight as --green-bg — marks our own *brands* on
     Market Share, where --green-bg already means "our own listing" on the other tabs. */
  --swcorp-bg: #eaf1f8;
  /* One elevation step above --white: the table thead/footer, a row's hover state, and other
     "chrome" surfaces that sit inside a --white card and need to read as raised, not recessed.
     Same pixel value as --bg in light (the two are close enough there not to matter); dark gives
     it its own value so these don't collapse into the page's darkest tone. */
  --surface-2: var(--bg);
  --radius: 12px;
  --radius-sm: 7px;
  /* Foreground on an --ink surface (primary button, active chip, chart tooltip, sheet tab). Not
     the same thing as --white, which is a surface color: the two happen to share a value today,
     but they answer different questions and a palette swap moves them apart. */
  --on-ink: #fff;
  --on-ink-soft: #d6d3d1;
  /* A line that has to carry more weight than --line: the scrollbar thumb and the dashed
     empty-state border, both of which sit on --bg rather than on a card. */
  --line-strong: #d6d3d1;
  /* Translucent white over the page: the sticky topbar's frosted backdrop, and the hover state of
     an inactive tab. Both have to let the content under them show through. */
  --glass: rgba(255, 255, 255, .85);
  --glass-soft: rgba(255, 255, 255, .6);
  /* Every shadow in this file is this color at some alpha, so it is stored as bare channels for
     rgb(). One value to change when shadows need to go deeper. */
  --shadow-rgb: 12, 10, 9;
  /* Drawer/modal scrim. Dark mode needs about twice the alpha: black at .28 over #121212 barely
     registers as dimming. */
  --scrim-alpha: .28;
}

/* Dark palette — same token names, values tuned against SW Crawler Dark v2.dc.html (the approved
   dark-mode reference: elevation via lightness steps off a #121212 page — never pure black or
   pure white; body text sits at 87% white, not solid, so it doesn't glare; the four semantic
   colors are the light set pushed 15-25% brighter and desaturated so they hold on a dark ground;
   pill/alert fills are the semantic color at 15% opacity rather than a solid tint). Applied
   automatically when the OS is set to dark (prefers-color-scheme) unless the user has explicitly
   picked "light" via the topbar toggle (data-theme="light"), and forced on regardless of OS when
   they've picked "dark" (data-theme="dark"). main.js reads/writes the choice in localStorage; see
   setTheme() there. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --bg: #121212; --ink: #e4e4e4; --ink-soft: #f2f2f2; --muted: rgba(255, 255, 255, .6); --muted-2: rgba(255, 255, 255, .5); --faint: rgba(255, 255, 255, .38); --line: rgba(255, 255, 255, .07); --line-soft: #1c1c1c; --white: #1e1e1e; --surface-2: #232323; --green-bg: rgba(74, 222, 128, .15); --green-fg: #4ade80; --green-dot: #4ade80; --red-bg: rgba(248, 113, 113, .15); --red-fg: #f87171; --red-dot: #f87171; --amber-bg: rgba(252, 211, 77, .15); --amber-fg: #fcd34d; --blue-bg: rgba(96, 165, 250, .15); --blue-fg: #60a5fa; --blue-dot: #60a5fa; --swcorp-bg: rgba(96, 165, 250, .12); --on-ink: #121212; --on-ink-soft: #3a3a3a; --line-strong: rgba(255, 255, 255, .12); --glass: rgba(30, 30, 30, .85); --glass-soft: rgba(30, 30, 30, .6); --shadow-rgb: 0, 0, 0; --scrim-alpha: .55; }
}
:root[data-theme="dark"] { --bg: #121212; --ink: #e4e4e4; --ink-soft: #f2f2f2; --muted: rgba(255, 255, 255, .6); --muted-2: rgba(255, 255, 255, .5); --faint: rgba(255, 255, 255, .38); --line: rgba(255, 255, 255, .07); --line-soft: #1c1c1c; --white: #1e1e1e; --surface-2: #232323; --green-bg: rgba(74, 222, 128, .15); --green-fg: #4ade80; --green-dot: #4ade80; --red-bg: rgba(248, 113, 113, .15); --red-fg: #f87171; --red-dot: #f87171; --amber-bg: rgba(252, 211, 77, .15); --amber-fg: #fcd34d; --blue-bg: rgba(96, 165, 250, .15); --blue-fg: #60a5fa; --blue-dot: #60a5fa; --swcorp-bg: rgba(96, 165, 250, .12); --on-ink: #121212; --on-ink-soft: #3a3a3a; --line-strong: rgba(255, 255, 255, .12); --glass: rgba(30, 30, 30, .85); --glass-soft: rgba(30, 30, 30, .6); --shadow-rgb: 0, 0, 0; --scrim-alpha: .55; }

/* No blanket color transition on `*`. The theme switch is a reload whose view-transition already
   does the reveal (see theme-vt below), so a global fade never got to run for that, and all it did
   was put 200ms of lag on every row, link and menu hover across the app. Components that want a
   color fade declare their own. */

/* Tells the browser which palette its own native chrome should follow — the <select> popup list
   (the closed box is ours via appearance:none above, but the open dropdown is UA-drawn and stays
   invisible-on-invisible without this), autofill, and default form-control colors. */
:root { color-scheme: light; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { color-scheme: dark; }
}
:root[data-theme="dark"] { color-scheme: dark; }

html { scrollbar-gutter: stable; }
* { box-sizing: border-box; }

button, input, select, textarea { font-family: inherit; line-height: 1.4; }

/* Smooth cross-page navigation (native browser View Transitions — Chromium/Edge; other browsers
   just ignore this and navigate normally, no fallback needed). Every page is still a full
   server-rendered load, but the browser captures a snapshot of the old/new page and crossfades
   between them instead of showing a blank flash. topbar/footer get their own named group and have
   their animation disabled below so they read as one persistent bar the content navigates under,
   instead of re-fading identical pixels (which itself can look like a flicker, especially with
   the topbar's backdrop-filter blur). */
@view-transition { navigation: auto; }
.topbar { view-transition-name: topbar; }
.footer { view-transition-name: footer; }
::view-transition-old(topbar), ::view-transition-new(topbar),
::view-transition-old(footer), ::view-transition-new(footer) { animation: none; }
/* Only the page content moves. Background is identical page to page, so root itself does nothing;
   .screen gets its own group: old content leaves fast (opacity only), new content arrives a little
   slower with a 6px rise. Asymmetric on purpose: a symmetric crossfade spends most of its time
   with two tables double-exposed on top of each other. */
/* Named only while a navigation is actually transitioning (html.vt-nav, set by main.js on
   pageswap and theme-init.js on pagereveal). A view-transition-name makes its element a stacking
   context, and a permanent one trapped every drawer and .table-modal inside .screen (z-index 60)
   under the topbar (20) and the footer. */
html.vt-nav .screen { view-transition-name: screen; }
::view-transition-old(root), ::view-transition-new(root) { animation: none; }
::view-transition-old(screen) { animation: vt-out 90ms var(--ease-out) both; }
::view-transition-new(screen) { animation: vt-in 200ms var(--ease-out) both; }
/* The active-link underline (.nav-ind in base.html) is one shared element across pages, so the
   browser slides it from the old link to the new one: says where you went. */
.nav-ind { view-transition-name: nav-ind; }
::view-transition-group(nav-ind) { animation-duration: 220ms; animation-timing-function: var(--ease-in-out); }
@keyframes vt-out { to { opacity: 0; } }
@keyframes vt-in { from { opacity: 0; transform: translateY(6px); } }
@keyframes vt-fade { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  ::view-transition-new(screen) { animation-name: vt-fade; }
  ::view-transition-group(nav-ind) { animation: none; }
}
/* The theme reveal below animates root; named groups would be cut out of it and crossfade on
   their own, so the circle would only wipe the background. */
html.theme-vt .screen, html.theme-vt .nav-ind { view-transition-name: none; }

/* Theme toggle: the reload above is still a page-to-page view-transition, so it gets the generic
   .15s crossfade by default. html.theme-vt (set by main.js's click handler on the outgoing page,
   and by theme-init.js on the incoming one, both keyed off --vt-x/--vt-y/--vt-r) swaps that for a
   circular reveal expanding from the toggle button instead — old page stays put, new one wipes
   over it, so the switch reads as "coming from" the button rather than an undifferentiated fade. */
@media (prefers-reduced-motion: no-preference) {
  html.theme-vt::view-transition-old(root) { animation: none; }
  html.theme-vt::view-transition-new(root) { animation: theme-vt-reveal .5s ease both; }
}
@keyframes theme-vt-reveal {
  from { clip-path: circle(0px at var(--vt-x, 50%) var(--vt-y, 50%)); }
  to { clip-path: circle(var(--vt-r, 150%) at var(--vt-x, 50%) var(--vt-y, 50%)); }
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: 'Inter', system-ui, sans-serif;
  font-size: 13px;
  line-height: 1.4;
  -webkit-font-smoothing: antialiased;
}

a { color: var(--ink); text-decoration: none; }
a:hover { color: var(--ink-soft); }
/* Any real link underlines on hover so it reads as clickable. Excluded: elements that already
   look like controls (nav items, buttons, chips/tabs) — an underline there reads as a glitch. */
a:hover:not(.nav-link):not(.brand):not(.btn):not(.chip):not(.tab):not(.sheet-tab):not(.cat-item):not(.pp-doc-link) {
  text-decoration: underline;
  text-underline-offset: 3px;
}

.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 6px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--faint); }

.shell {
  height: 100vh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* Top navbar */
.topbar {
  flex-shrink: 0;
  z-index: 20;
  background: var(--glass);
  backdrop-filter: saturate(1.4) blur(10px);
  border-bottom: 1px solid var(--line);
}
.topbar-inner {
  max-width: 1400px;
  margin: 0 auto;
  padding: 0 clamp(14px,2vw,32px);
  height: 56px;
  display: flex;
  align-items: center;
  gap: clamp(12px,2vw,32px);
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark {
  width: 32px; height: 32px;
  object-fit: contain;
}
.brand-mark-dark { display: none; }
:root[data-theme="dark"] .brand-mark-light { display: none; }
:root[data-theme="dark"] .brand-mark-dark { display: block; }
.brand-name { font-weight: 650; font-size: 16px; letter-spacing: -.01em; }

.nav { display: flex; align-items: stretch; gap: 2px; flex: 1; align-self: stretch; }
.nav-link {
  position: relative;
  display: flex;
  align-items: center;
  height: 100%;
  box-sizing: border-box;
  flex: 0 0 auto;
  padding: 0 14px;
  white-space: nowrap;
  color: var(--muted);
  font-weight: 500;
  letter-spacing: -.005em;
  transition: color .15s ease;
}
.nav-link:hover { color: var(--ink); }
.nav-link::after {
  content: '';
  position: absolute;
  left: 14px;
  right: 14px;
  bottom: 0;
  height: 2px;
  background: var(--faint);
  border-radius: 1px 1px 0 0;
  transform: scaleX(0);
  transform-origin: center;
  transition: transform .18s ease, background-color .15s ease;
}
/* Mouse only: a tap on a touch screen fires :hover too, and the underline would stick on the
   tapped link after the finger lifts. */
@media (hover: hover) and (pointer: fine) {
  .nav-link:hover::after { transform: scaleX(1); }
}
.nav-link-active {
  color: var(--ink);
  font-weight: 600;
}
/* Already drawn on arrival - no grow-in. Every navigation lands here, so the animation played
   dozens of times a day without saying anything the page change itself hadn't. It's a real span
   (.nav-ind), not ::after, so the cross-page view transition can slide it link to link. */
.nav-link-active::after { display: none; }
.nav-ind {
  position: absolute;
  left: 14px;
  right: 14px;
  bottom: 0;
  height: 2px;
  background: var(--ink);
  border-radius: 1px 1px 0 0;
}

/* Dark-mode switch. Both icons sit stacked in the same cell; the one matching the current theme
   shows. With no explicit choice yet (no data-theme), the sun shows — the OS-dark case still
   looks right because the whole page has already gone dark, so a moon there would be redundant. */
.theme-toggle {
  display: flex; align-items: center; justify-content: center;
  width: 28px; height: 28px;
  margin-left: 6px;
  color: var(--muted);
  flex-shrink: 0;
  background: none;
  border: 0;
  border-radius: var(--radius-sm);
  padding: 0;
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
}
.theme-toggle:hover { color: var(--ink); background: var(--line-soft); }
.theme-toggle svg { grid-area: 1 / 1; }
.theme-toggle { display: grid; }
.theme-icon-moon { display: none; }
:root[data-theme="dark"] .theme-icon-sun { display: none; }
:root[data-theme="dark"] .theme-icon-moon { display: block; }

.acumatica-open-external {
  align-items: center; justify-content: center;
  width: 28px; height: 28px;
  margin-left: 6px;
  color: var(--muted);
  flex-shrink: 0;
  background: none;
  border: 0;
  border-radius: var(--radius-sm);
  padding: 0;
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
}
.acumatica-open-external:not([hidden]) { display: flex; }
.acumatica-open-external:hover { color: var(--ink); background: var(--line-soft); }

/* Burger toggle + collapsed nav — hidden until the topbar links run out of room. */
.nav-burger {
  display: none;
  align-items: center;
  justify-content: center;
  width: 28px; height: 28px;
  margin-left: auto;
  color: var(--ink);
  flex-shrink: 0;
  background: none;
  border: 0;
  padding: 0;
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
}
.nav-mobile { display: none; }

@media (max-width: 860px) {
  .nav:not(.nav-mobile) { display: none; }
  .nav-burger { display: flex; }
  .nav-mobile {
    display: flex;
    flex-direction: column;
    align-self: stretch;
    gap: 0;
    /* height 0 -> auto, not a max-height guess: with max-height the curve played out over 60vh
       while the menu is only a few links tall, so most of the timing was spent on empty space and
       closing sat still before anything moved. interpolate-size lets `auto` transition (Chromium);
       elsewhere it just snaps open and shut, which is fine. */
    interpolate-size: allow-keywords;
    height: 0;
    overflow: hidden;
    border-top: 1px solid transparent;
    transition: height .2s var(--ease-out);
  }
  .topbar.nav-open .nav-mobile {
    height: auto;
    max-height: 60vh;
    overflow-y: auto;
    border-top-color: var(--line);
  }
  .nav-mobile .nav-link {
    height: auto;
    padding: 12px clamp(14px,2vw,32px);
    justify-content: center;
    text-align: center;
  }
  .nav-mobile .nav-link::after { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .nav-mobile { transition: none; }
}

/* Screen container */
.screen {
  max-width: 1400px;
  width: 100%;
  margin: 0 auto;
  padding: clamp(16px,2.2vh,26px) clamp(14px,2vw,32px);
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  /* Reserve the scrollbar's width even when the page fits, so a short view isn't laid out
     ~10px wider than a scrolling one — same reasoning as .table-scroll / .panel-scroll. */
  scrollbar-gutter: stable;
}

.page-head { margin-bottom: 14px; flex-shrink: 0; }
.eyebrow {
  font-size: 11px; font-weight: 500; color: var(--faint);
  text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px;
}
.page-title { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
/* One short sentence per page, and the same block on every page: clamped to two lines so a long
   caveat can never push the page's controls down, and width-capped so the line breaks where it reads
   instead of running the full width of a 4K window. Anything that doesn't fit belongs in a title
   tooltip, not here. */
.page-sub {
  color: var(--muted-2); margin-top: 4px; line-height: 1.45; max-width: 92ch;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
}

/* Cards / KPIs */
.card {
  background: var(--white);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.kpi-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(165px,1fr));
  gap: 12px;
  margin-bottom: 14px;
}
.kpi { padding: 14px 16px; }
.kpi-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 10px; }
.kpi-label { font-size: 11px; font-weight: 500; color: var(--muted-2); text-transform: uppercase; letter-spacing: .06em; }
.kpi-delta { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 999px; }
.kpi-value { font-size: clamp(20px,2vw,26px); font-weight: 600; letter-spacing: -.02em; line-height: 1; }
.kpi-sub { color: var(--faint); font-size: 11.5px; margin-top: 5px; }

/* Tables */
.data-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
.data-table thead tr { color: var(--muted-2); font-weight: 500; position: sticky; top: 0; z-index: 1; }
/* Headers are centered everywhere; body cells keep their own left/right alignment. Background
   lives on the th, not the thead tr: border-collapse strips a <tr>'s own background paint, so a
   sticky header with only the row colored let scrolled-under rows show straight through it.
   border-collapse stayed "collapse" here until a long scrolling table (Position Tracker's Amazon
   batch history) showed rows bleeding through the sticky header: with collapsed borders, the first
   tbody row's border-top paints above the sticky th's background instead of under it. "separate"
   with zero spacing keeps the same look and fixes the paint order.
   z-index:1 turned out to matter too: without it, Chromium sometimes paints a sticky <tr> BEHIND
   the static tbody row currently scrolling under it instead of in front, so that row's text shows
   straight through the header background (reproduced on Position Tracker's fullscreen batch
   history, scrolled a few hundred px). A sticky element with z-index:auto is not guaranteed to
   win against in-flow siblings in every engine — giving it an explicit stacking order fixes it. */
.data-table th { padding: 9px 16px; font-weight: 500; white-space: nowrap; text-align: center; background: var(--surface-2); }
.data-table td { padding: 9px 16px; border-top: 1px solid var(--line-soft); vertical-align: middle; }
/* Click-to-sort header (makeTableSort in main.js). The button keeps the th's own alignment and the
   arrow hangs off its right edge, absolutely placed in the th's padding, so the label sits exactly
   where it did before and never shifts when the arrow appears. Hidden at rest, faint on hover,
   solid on the sorted column. */
.th-sort { all: unset; position: relative; display: inline-flex; align-items: center; max-width: 100%; cursor: pointer; color: inherit; border-radius: 3px; }
.th-sort:hover { color: var(--ink); }
.th-sort:focus-visible { outline: 2px solid var(--blue-dot); outline-offset: 3px; }
.th-sort-arrow { position: absolute; left: calc(100% + 3px); top: 50%; margin-top: -5px; opacity: 0; transition: opacity .12s, transform .16s var(--ease-out); }
.th-sort:hover .th-sort-arrow { opacity: .45; }
.data-table th[aria-sort="ascending"] .th-sort, .data-table th[aria-sort="descending"] .th-sort { color: var(--ink); font-weight: 600; }
.data-table th[aria-sort="ascending"] .th-sort-arrow, .data-table th[aria-sort="descending"] .th-sort-arrow { opacity: 1; }
.data-table th[aria-sort="ascending"] .th-sort-arrow { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) { .th-sort-arrow { transition: none; } }
.data-table tbody tr:hover { background: var(--surface-2); }
/* An empty-state row ("No SKU matches...", "No promo workbook loaded yet...") is the only row in
   the table, so the browser stretches it to fill .table-wrap's 300px min-height and centers the
   text in that space -- exactly what it should look like. The one thing to fix is the hover: it is
   text, not a clickable row, so pointing anywhere in that same stretched space shouldn't light up
   a "you can click this" background the way a real row's hover does. */
.data-table tbody tr.tr-empty:hover { background: none; }
.data-table tbody tr.tr-empty td { cursor: default; }

/* A row the Monitoring poll just inserted (see monitoring.js monPollRows): brief highlight +
   slide-down so a new job registers as "just arrived" instead of silently appearing mid-scroll. */
.mon-row-new { animation: mon-row-new 1.8s ease-out; }
@keyframes mon-row-new {
  /* Tinted from --ink, which is dark on light and light on dark - a fixed black tint vanished
     against the dark theme's background. */
  0% { background: color-mix(in srgb, var(--ink) 8%, transparent); transform: translateY(-6px); opacity: 0; }
  20% { transform: translateY(0); opacity: 1; }
  100% { background: transparent; }
}

/* Monitoring's Inserted column as an odometer (see monitoring.js monOdoHtml/monAnimateCounter):
   each digit is its own clipped window over a vertical strip of digits, and a rolling strip
   translates up through every value it passes. Fixed --odo-h on both the window and each digit so
   the strip's steps land exactly on a digit, never half-way. */
.mon-odo { --odo-h: 1.3em; display: inline-flex; height: var(--odo-h); line-height: var(--odo-h); vertical-align: top; font-variant-numeric: tabular-nums; }
.odo-col { display: block; height: var(--odo-h); overflow: hidden; }
.odo-strip { display: flex; flex-direction: column; }
.odo-strip > span { display: block; height: var(--odo-h); }
.odo-strip.rolling { transition: transform .9s cubic-bezier(.16,1,.3,1); }
/* A running job's Duration ticks every second, so its roll has to settle well inside one tick. */
.mon-dur .odo-strip.rolling { transition-duration: .45s; }

/* Settings tables: the first column stays left-aligned — it's the row's identity and ragged-right
   text is easier to scan — while every other column is centered. */
.center-table td, .center-table th { text-align: center; }
.center-table td:first-child, .center-table th:first-child { text-align: left; }
.center-table td .sk-cell { align-items: center; }

/* Panel swap: the incoming section fades up instead of popping in. Short enough (.18s) that
   it never delays interaction — the panel is already clickable while it animates. */
@keyframes panel-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}
.panel-in { animation: panel-in .18s var(--ease-out) both; }
/* Settings tabs: a plain crossfade, same length as the page-to-page view-transition. Tabs get
   switched constantly, so it stays short enough not to read as waiting. */
@keyframes tab-crossfade {
  from { opacity: 0; }
  to { opacity: 1; }
}
.tab-crossfade { animation: tab-crossfade .15s var(--ease-out) both; }
@media (prefers-reduced-motion: reduce) {
  .panel-in, .tab-crossfade { animation: none; }
}

/* Boxed chevron that opens a row's edit drawer — a real button so the hit area is the box only,
   not the whole row. */
.row-open {
  width: 28px; height: 28px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--white); color: var(--faint); cursor: pointer;
}
.row-open:hover { border-color: var(--faint); color: var(--ink); background: var(--surface-2); }

/* Pills / status */
.pill {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 2px 9px 2px 7px; border-radius: 999px; font-size: 11px; font-weight: 500;
}
.pill-dot { width: 5px; height: 5px; border-radius: 50%; }
.pill-ok { background: var(--green-bg); color: var(--green-fg); }
.pill-ok .pill-dot { background: var(--green-dot); }
.pill-fail { background: var(--red-bg); color: var(--red-fg); }
.pill-fail .pill-dot { background: var(--red-dot); }
.pill-running { background: var(--blue-bg); color: var(--blue-fg); }
.pill-running .pill-dot { background: var(--blue-dot); }
.pill-queued { background: var(--surface-2); color: var(--muted-2); }
.pill-queued .pill-dot { background: var(--faint); }

/* Buttons */
.btn {
  padding: 6px 12px; border: 1px solid var(--line); background: var(--white);
  border-radius: var(--radius-sm); color: var(--muted); cursor: pointer; font-weight: 500;
  display: inline-flex; align-items: center; gap: 6px; font-family: inherit; font-size: 12.5px;
}
.btn:hover { border-color: var(--faint); }
/* Every JS-driven "disable this button while busy" (14 call sites across the app - Run now,
   Save, Delete, exports...) relied on the native `disabled` attribute alone, which this cursor:
   pointer above overrides - a disabled button looked pixel-identical to an enabled one, cursor
   included, so clicking it (correctly a no-op) still looked clickable. */
.btn:disabled { opacity: .5; cursor: not-allowed; pointer-events: none; }
/* An icon-only .btn. Without text there is no line box, so the button collapses to the icon's 12px
   and comes out shorter than the labelled buttons and chips it sits next to. This puts that line box
   back from the same numbers .btn already uses: 1.4em is its line-height (line 31), 14px its 6px
   vertical padding plus 1px borders — so the height tracks .btn instead of being pinned to 31.5px. */
.btn-icon { height: calc(1.4em + 14px); padding: 0 12px; justify-content: center; }
.btn-primary { border-color: transparent; background: var(--ink); color: var(--on-ink); }
.btn-primary:hover { background: var(--ink-soft); }

/* Accounting-style money cell (see fmtMoneyCell in main.js): sign left, digits right, tabular
   figures so decimals stack across rows. min-width:100% makes it span the cell regardless of the
   td's own text-align. */
.acct {
  display: inline-flex; justify-content: space-between; align-items: baseline; gap: 8px;
  min-width: 100%; font-variant-numeric: tabular-nums;
}
.acct-cur { color: var(--faint); }

/* Boxed external-link glyph for table cells (Top Sellers "Link" column) */
.link-box {
  display: inline-flex; align-items: center; justify-content: center;
  width: 20px; height: 20px; border: 1px solid var(--line); border-radius: 5px;
  background: var(--white); color: var(--muted); font-size: 11px; text-decoration: none;
  line-height: 1;
}
.link-box:hover { border-color: var(--faint); color: var(--ink); }

/* Empty state (used while a screen's data wiring is pending) */
.empty-state {
  flex: 1 1 auto; min-height: 260px;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  border: 1px dashed var(--line-strong); border-radius: var(--radius); background: var(--white);
  color: var(--muted-2); text-align: center; padding: 40px;
}
.empty-state .icon {
  width: 44px; height: 44px; border-radius: 10px; background: var(--surface-2);
  display: grid; place-items: center; margin-bottom: 14px; color: var(--muted-2);
}
.empty-state .title { font-size: 15px; font-weight: 600; color: var(--ink); letter-spacing: -.01em; }
.empty-state .desc { margin-top: 6px; max-width: 440px; font-size: 12.5px; }
.empty-state .badge {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 16px;
  padding: 5px 11px; background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 999px; font-size: 11px; color: var(--muted-2);
}
.empty-state .badge .dot { width: 5px; height: 5px; background: var(--amber-fg); border-radius: 50%; }

/* A full-navigation 404 (dead route/link) — not .empty-state, whose dashed "card" border reads as
   "feature not built yet". This is a real destination in its own right, so it fills the screen and
   centers on it rather than sitting inside a bordered box. */
.notfound {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  text-align: center; padding: 40px 20px;
}
.notfound .code {
  font-family: 'JetBrains Mono', ui-monospace, monospace;
  font-size: 13px; font-weight: 500; color: var(--faint);
  text-transform: uppercase; letter-spacing: .08em; margin-bottom: 16px;
}
.notfound .icon { width: 56px; height: 56px; margin-bottom: 20px; }
.notfound .icon img { width: 100%; height: 100%; object-fit: contain; }
.notfound .title { font-size: 20px; font-weight: 600; color: var(--ink); letter-spacing: -.01em; }
.notfound .desc { margin-top: 8px; max-width: 380px; font-size: 13px; color: var(--muted-2); line-height: 1.5; }
.notfound .btn { margin-top: 22px; }

/* Skeleton loading placeholders */
@keyframes shimmer { 0% { background-position: -300px 0; } 100% { background-position: 300px 0; } }
.skel {
  background: linear-gradient(90deg, var(--line-soft) 25%, var(--line) 37%, var(--line-soft) 63%);
  background-size: 400px 100%;
  /* linear: constant motion. ease-in-out made the highlight stall at both ends of every sweep. */
  animation: shimmer 1.4s linear infinite;
  border-radius: 5px;
}
/* Swap the sweep for a still placeholder that breathes in place - still reads as "loading". */
@keyframes skel-pulse { 50% { opacity: .55; } }
@media (prefers-reduced-motion: reduce) {
  .skel { animation: skel-pulse 1.6s ease-in-out infinite; }
}
.skel-line { height: 12px; margin: 6px 0; }
.skel-row td { padding: 11px 20px; }

/* Spinning glyph for a button whose work runs on the server (see ICON_SPINNER in main.js). The
   skeletons above cover "this area is loading"; this covers "this button is still working" — the
   button stays in place, so it cannot be replaced by a placeholder. */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin-pulse { 50% { opacity: .3; } }
.spin { animation: spin .8s linear infinite; transform-origin: 50% 50%; }
/* Reduced motion does NOT get `animation: none` here, unlike the decorative .panel-in above. This
   glyph's entire job is to say "still working" — frozen, it reads as a hung button, which is worse
   than the movement the setting asks us to avoid. So the rotation is swapped for an opacity pulse:
   same message, no movement (the setting is about vestibular motion, not about fades). */
@media (prefers-reduced-motion: reduce) {
  .spin { animation: spin-pulse 1.6s ease-in-out infinite; }
}

/* Corner notices (toast() in main.js). Above .table-modal's z-index 60 on purpose: an export
   started from inside a full-screen modal still has to be able to report back. Bottom-right, so
   it stays clear of the left-hand content where reading starts. */
.toast-host {
  position: fixed; right: 18px; bottom: 18px; z-index: 80;
  display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
  pointer-events: none;
}
.toast {
  position: relative; overflow: hidden;
  display: flex; align-items: center; gap: 10px;
  /* Only the X (.toast-close) dismisses, so the body is not a click target - no pointer cursor. */
  pointer-events: auto; cursor: default;
  /* Shrink-wraps to content (block width is auto on a non-stretched flex item) up to this cap, so
     a one-line "ready" toast stays one line instead of matching a longer "Building…" toast's width.
     Capped at 25% of the viewport on anything wide enough for that to make sense; the 240px floor
     is what keeps it readable on a phone where 25vw is too narrow for the shortest message. */
  max-width: clamp(240px, 25vw, 420px);
  padding: 12px 14px; border: 1px solid var(--line); border-left: 3px solid var(--faint);
  border-radius: 12px; background: var(--white); color: var(--ink);
  font-size: 12.5px; line-height: 1.45; box-shadow: 0 10px 28px rgba(var(--shadow-rgb), .14);
  /* Slides in from the right edge it lives against and leaves the same way (.toast-out), so where
     a notice goes is where it came from. Transitions, not keyframes: dismissing a toast that is
     still sliding in retargets it from wherever it is instead of jumping to a keyframe's start.
     Strong ease-out covers most of the distance in the first frames, so the 400ms entrance reads
     as quick; exit is half that - the user is done with it. */
  opacity: 1; transform: translateX(0);
  transition: transform .4s var(--ease-out), opacity .4s var(--ease-out);
}
/* Entrance start state. Browsers without @starting-style just show the toast in place. Skipped for
   the intro toast (see playIntroFromCenter in main.js), whose inline styles fly it in from screen
   center instead. */
@starting-style {
  .toast:not(.toast-intro) { opacity: 0; transform: translateX(calc(100% + 18px)); }
}
.toast-out {
  opacity: 0; transform: translateX(calc(100% + 18px));
  transition: transform .2s var(--ease-out), opacity .2s ease;
  pointer-events: none;
}
/* Bottom-left stack (toast(..., {side: 'left'})): mirrored, so it slides in from and out to the left
   edge it lives against. */
.toast-host-left { right: auto; left: 18px; align-items: flex-start; }
@starting-style {
  .toast-host-left .toast:not(.toast-intro) { transform: translateX(calc(-100% - 18px)); }
}
.toast-host-left .toast-out { transform: translateX(calc(-100% - 18px)); }
@media (prefers-reduced-motion: reduce) {
  /* A fade only - no travel across the screen. */
  .toast, .toast-out, .toast-host-left .toast-out { transform: none; transition: opacity .16s ease; }
  @starting-style {
    .toast:not(.toast-intro) { transform: none; }
  }
}
/* Spotlight layer behind an intro toast (see playIntroFromCenter) - everything else on the page
   blurs so the toast, which stays sharp, reads as the thing you're meant to look at. Sits just
   under .toast-host's z-index so it never blurs the toast itself. */
.toast-intro-backdrop {
  position: fixed; inset: 0; z-index: 79; pointer-events: none;
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  opacity: 0; transition: opacity .4s ease;
}
@media (prefers-reduced-motion: reduce) {
  .toast-intro-backdrop { display: none; }
}
.toast-ok { border-left-color: var(--green-dot); }
.toast-error { border-left-color: var(--red-dot); }
/* Icon badge - one flat-colored circle per kind, the glyph always white on top of it regardless of
   theme so it doesn't need its own light/dark handling. align-items:center on .toast (not
   flex-start) is what actually centers this against a two-line title+detail body - centering the
   icon inside its own circle alone isn't enough when the circle sits in a taller flex row. */
.toast-icon {
  flex-shrink: 0; width: 22px; height: 22px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center; color: #fff; background: var(--faint);
}
.toast-icon-ok { background: var(--green-dot); }
.toast-icon-error { background: var(--red-dot); }
.toast-body { flex: 1; min-width: 0; }
/* Sentence case: the first letter up, the rest as written. Applied here rather than in every
   toast()/message string in JS - one rule covers every call site (including dynamic ones built from
   a raw e.message, which often starts lowercase). It used to be text-transform: capitalize, which
   title-cased every word ("This May Take A Moment"); names keep their own case either way. */
.toast-title::first-letter, .toast-detail::first-letter { text-transform: uppercase; }
.toast-title { font-weight: 600; }
/* tabular-nums: harmless for ordinary detail text, but load-bearing for a ticking countdown (the
   Schedule unlock toast) - without it, a proportional font's "1" is narrower than its "0" and the
   toast still visibly twitches in width every second even with a fixed-length "09:59" string. */
.toast-detail { margin-top: 2px; font-size: 11px; font-weight: 400; color: var(--faint); font-variant-numeric: tabular-nums; }
.toast-error { background: var(--red-bg); color: var(--red-fg); }
.toast-error .toast-detail { color: inherit; opacity: .75; }
.toast-close {
  flex-shrink: 0; border: 0; background: transparent; padding: 3px; margin: -3px -5px -3px 0;
  color: var(--faint); cursor: pointer; display: flex; border-radius: 4px;
}
.toast-close:hover { color: var(--ink); background: var(--gray-bg, rgba(var(--shadow-rgb), .06)); }
/* Countdown to auto-dismiss. animation-duration is set inline per toast (matches its TOAST_MS), so
   the bar reaching empty and the close() timer firing are the same instant by construction rather
   than two durations kept in sync by hand. .toast's own overflow:hidden clips it to the rounded
   corners; transform (not width) so the shrink stays off the main thread. */
.toast-bar {
  position: absolute; left: 0; right: 0; bottom: 0; height: 2.5px;
  background: var(--faint); opacity: .4; transform-origin: left center;
  animation-name: toast-bar-shrink; animation-timing-function: linear; animation-fill-mode: forwards;
}
.toast-ok .toast-bar { background: var(--green-dot); }
.toast-error .toast-bar { background: var(--red-dot); }
@keyframes toast-bar-shrink {
  from { transform: scaleX(1); }
  to { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
  .toast-bar { animation: none; }
}

/* Two/three column split layouts (Price Tracker, Top Sellers) */
.split {
  display: grid;
  gap: 10px;
  flex: 1 1 auto;
  min-height: 360px;
  /* .split is itself a flex item of .screen (.screen's own flex-direction:column). Without this,
     a wide-enough table several levels down (e.g. Settings' SKU Mappings, forced to min-width:900px
     to stay readable on a phone) drags .split's own box wider than the viewport instead of staying
     contained in its own .table-scroll -- and since .screen's overflow-y:auto makes its overflow-x
     resolve to auto too (the CSS visible/non-visible pairing rule), the WHOLE page (page-head, tabs,
     everything sharing .screen) becomes one horizontally-scrolling unit instead of just the table. */
  min-width: 0;
}
.split-2 { grid-template-columns: minmax(190px,270px) minmax(0,1fr); }
.split-3 { grid-template-columns: minmax(160px,220px) minmax(0,1fr); }

.panel { display: flex; flex-direction: column; overflow: hidden; }
/* scrollbar-gutter: filtering the list down to a few rows removes the scrollbar, and without a
   reserved gutter the rows shift sideways by its width on every keystroke. */
.panel-scroll { overflow-y: auto; flex: 1; scrollbar-gutter: stable; }

.search-box {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 10px; background: var(--white);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  color: var(--muted-2);
}
.search-box input {
  border: 0; outline: 0; background: transparent; flex: 1; font-size: 12.5px; min-width: 0;
  font-family: inherit; color: var(--ink);
}
.search-clear {
  border: 0; background: transparent; padding: 2px; margin: 0; flex-shrink: 0;
  display: flex; align-items: center; cursor: pointer; color: var(--faint); border-radius: 4px;
}
.search-clear:hover { color: var(--muted); background: var(--line-soft); }

.sku-item {
  padding: 10px 14px; border-bottom: 1px solid var(--line-soft);
  border-left: 2px solid transparent; cursor: pointer;
  display: flex; justify-content: space-between; align-items: center; gap: 8px;
}
.sku-item:hover { background: var(--surface-2); }
/* A flat --surface-2 fill here read the same as :hover — both close enough to the card underneath
   to blend in dark. color-mix scales with --ink itself (dark text tinting a light card in light
   mode, light text tinting a dark card in dark mode), so "selected" stays visibly stronger than
   "hovering some other row" in both themes instead of the two colliding into one shade. */
.sku-item.active { background: color-mix(in srgb, var(--ink) 12%, transparent); border-left-color: var(--ink); }
.sku-item .sku-id { font-weight: 500; }
.sku-item .sku-desc { font-size: 11px; color: var(--faint); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* The way out of an empty Price Tracker chart: the listings AcuXref filed under the SKU that are
   not the SKU's own product. Each one charts on its own, so they are buttons, not text. */
.related-listings { margin-top: 14px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.related-title { font-size: 11px; color: var(--faint); margin-bottom: 2px; }
.related-item {
  display: flex; align-items: center; gap: 10px; cursor: pointer;
  background: var(--white); border: 1px solid var(--line); border-radius: 6px;
  padding: 6px 10px; font-size: 12px; color: var(--ink);
}
.related-item:hover { background: var(--surface-2); border-color: var(--ink); }
.related-meta { font-size: 11px; color: var(--faint); }

/* Marks a row that is a CHANNEL listing rather than one of our SKUs — a different product that
   AcuXref folded onto the SKU it names (Home Depot files three tubs under 6131KW). It charts on
   its own, so the tag is what says the id belongs to the channel, not to Acumatica. */
.xref-tag {
  font-family: 'Inter', system-ui, sans-serif; font-size: 9.5px; font-weight: 600; letter-spacing: .02em;
  color: var(--faint); background: var(--line-soft); border-radius: 3px;
  padding: 1px 4px; margin-left: 4px; vertical-align: 1px; white-space: nowrap;
}

/* The product description under the big SKU in a detail panel (Price Tracker, Position Tracker).
   Capped at two lines: these descriptions run to a full spec sheet ("Venetian 4272: Vshaped-Jets:
   Right: White: Dual: Friction Heater: Chroma: Aroma: Chrome Electronic Control…") and an uncapped
   one pushed the tabs, the chart and the stats down by a different amount on every SKU, so the whole
   panel jumped as you moved through the list. The full text is on the element's title. */
.detail-desc {
  font-size: 13.5px; color: var(--muted); margin-top: 5px; line-height: 1.35;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
/* The trackers' header: one line for every SKU, so the chart below starts at the same height
   whichever one is picked. The rest is cut with an ellipsis and shown in full by the tooltip
   (data-tip-overflow, set where the header is rendered). */
#tr-header .detail-desc { display: block; white-space: nowrap; text-overflow: ellipsis; }

/* Tabs / chips */
/* --bg, not --line-soft: the pill that marks the active tab is --white, and in dark mode --white
   and --line-soft sit only a couple of lightness steps apart (near-invisible container). --bg is
   the page's darkest tone, so the pill reads clearly against it in both themes. But --bg is also
   the page's own background in light mode (they're only a hair apart on purpose), which made the
   whole group vanish there — no visible edge to say "these five tabs are one control". The border
   is what actually draws that edge now, in both themes; the fill contrast is only for the pill. */
.tabs { display: flex; gap: 2px; padding: 3px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; width: fit-content; position: relative; }
.tab {
  padding: 6px 14px; border: 1px solid transparent; background: transparent; color: var(--muted);
  border-radius: 6px; cursor: pointer; font-weight: 500; font-size: 12.5px; font-family: inherit;
  white-space: nowrap;
  transition: background-color .16s ease, color .16s ease, box-shadow .16s ease, border-color .16s ease;
  position: relative; z-index: 1;
}
.tab:hover:not(.active) { color: var(--ink); background: var(--glass-soft); }
.tab.tab-off { display: none; }   /* a range tab with no history to cut - see trackerSyncRangeTabs */
/* The trackers' range box holds the four-tab width (trackerLockRangeWidth) and the tabs still shown
   share it out; .tr-range-measure brings the hidden ones back for the measurement only. */
#tr-range .tab { flex: 1 1 auto; }
#tr-range.tr-range-measure .tab.tab-off { display: block; }
/* The box-shadow is the light-mode depth cue; it disappears on a dark surface (a black shadow on
   a near-black page shows nothing), so the border carries the definition there instead — it's
   see-through in light (transparent up top) and only actually visible once --line turns into a
   real rgba stroke in dark. */
.tab.active { background: var(--white); color: var(--ink); border-color: var(--line); box-shadow: 0 1px 2px rgba(var(--shadow-rgb), .06); }

/* The sliding pill. main.js drops one .tab-pill into every .tabs and moves it onto the active
   tab, so the white block travels between tabs instead of blinking from one to the next. The
   .tab.active background above stays as the no-JS fallback: it is only handed over to the pill
   once .has-pill says the pill actually exists and has been measured. */
.tabs.has-pill .tab.active { background: transparent; box-shadow: none; border-color: transparent; }
.tab-pill {
  position: absolute; top: 0; left: 0; width: 0; height: 0;
  background: var(--white); border: 1px solid var(--line); border-radius: 6px;
  box-shadow: 0 1px 2px rgba(var(--shadow-rgb), .06);
  pointer-events: none; z-index: 0;
  /* Sliding across the row is movement within the screen, so ease-in-out: a strong one, since the
     stock curves are too soft to read as deliberate. */
  transition: transform .22s var(--ease-in-out), width .22s var(--ease-in-out),
              height .22s var(--ease-in-out);
}
/* First placement, a re-render, or a resize must not look like a slide from the top-left corner,
   so the pill jumps to its mark with the transition off and re-arms on the next frame. */
.tab-pill.no-anim { transition: none; }
@media (prefers-reduced-motion: reduce) { .tab-pill { transition: none; } }

.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.chip {
  padding: 6px 14px; border: 1px solid var(--line); background: var(--white);
  color: var(--muted); border-radius: 7px; cursor: pointer; font-weight: 500;
  font-family: inherit; font-size: 12.5px;
  transition: background-color .16s ease, color .16s ease, border-color .16s ease;
}
.chip:hover { border-color: var(--faint); }

/* Single-line, fade-to-scroll chip row (Schedule Jobs' source filter): nowrap + overflow-x instead
   of chip-row's wrap, native touch-swipe covers mobile, and the fade button gives desktop a click
   target that does the same scroll a swipe would. Scrollbar hidden since the fade is the affordance. */
.chip-row-scroll { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; min-width: 0; }
.chip-row-scroll::-webkit-scrollbar { display: none; }
.chip-row-scroll .chip { flex-shrink: 0; }
/* min-width:0 twice over: .chip-scroll-wrap is a grid item (#set-schedule's grid-template-rows)
   and .chip-row-scroll is a flex item of it -- both default to min-width:auto (their content's
   width), which ignored the 1fr track and pushed the whole page into a horizontal scrollbar
   instead of scrolling just the chip row. */
.chip-scroll-wrap { position: relative; min-width: 0; }
/* .tabs-wrap (Settings' tab strip) is a direct child of .screen (display:flex; flex-direction:
   column), so any "display" it declares is blockified to plain block per the flex-item spec --
   the inline-block + max-width:100% this used to rely on to clamp its width never actually took
   effect. Below the breakpoint, force #set-tabs itself to the width its parent actually has
   instead of trusting width:fit-content (.tabs, line ~608) to clamp there, which it wasn't doing
   reliably either -- past that, overflow-x:auto scrolls the tab row instead of the whole page. */
.tabs-scroll { overflow-x: auto; scrollbar-width: none; }
.tabs-scroll::-webkit-scrollbar { display: none; }
@media (max-width: 700px) {
  .tabs-scroll { width: 100%; max-width: 100%; }
}
.chip-scroll-fade {
  position: absolute; top: 0; bottom: 0; width: 40px; border: none; cursor: pointer;
  display: flex; align-items: center; color: var(--muted);
}
.chip-scroll-fade-right {
  right: 0; justify-content: flex-end; padding-right: 2px;
  background: linear-gradient(to right, transparent, var(--bg) 55%);
}
.chip-scroll-fade-left {
  left: 0; justify-content: flex-start; padding-left: 2px;
  background: linear-gradient(to left, transparent, var(--bg) 55%);
}
/* Nothing to scroll to that side (short list, or already at that end) — hidden via JS by toggling
   this class rather than always-on, so a fade doesn't sit over the edge chip with no reason to
   click it. */
.chip-scroll-fade.at-end { display: none; }

/* Sheet-tab pills — preview strip for a multi-sheet Excel export (Top Sellers, Home Depot), plus
   the separate Distribution tab riding alongside it (#ts-hd-dist in top_sellers.html) past a
   divider — same look, but it isn't one of the sheets the export button counts/downloads. */
.sheet-tabs { padding: 10px 20px; border-bottom: 1px solid var(--line); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
.sheet-tab {
  padding: 5px 11px; border: 1px solid var(--line); background: var(--white);
  color: var(--muted); border-radius: 7px; font-size: 11.5px; font-weight: 400;
  cursor: pointer; font-family: inherit; white-space: nowrap;
  transition: background-color .16s ease, color .16s ease, border-color .16s ease;
}
.sheet-tab:hover:not(.active) { border-color: var(--faint); color: var(--ink); }
.sheet-tab.active { border-color: var(--ink); background: var(--ink); color: var(--on-ink); font-weight: 500; }
.chip.active { background: var(--ink); color: var(--on-ink); border-color: var(--ink); }

/* Category list (Top Sellers) */
.cat-item {
  padding: 9px 14px; font-size: 12.5px; cursor: pointer;
  display: flex; justify-content: space-between; align-items: center;
  border-left: 2px solid transparent;
}
.cat-item:hover { background: var(--surface-2); }
.cat-item.active { background: color-mix(in srgb, var(--ink) 12%, transparent); color: var(--ink); border-left-color: var(--ink); font-weight: 500; }
/* Affordance chevron: faint at rest, solid on hover/selection — the row is a drill-in, not a label. */
.cat-chevron { flex-shrink: 0; margin-left: 8px; color: var(--line); transition: color .12s ease; }
.cat-item:hover .cat-chevron { color: var(--muted-2); }
.cat-item.active .cat-chevron { color: var(--ink); }

/* Top Sellers: one bordered container with an internal divider, instead of two separate
   gapped cards — matches the approved mockup's denser, more unified panel browser. */
.ts-browser {
  display: grid;
  /* Fixed-width category column, not minmax(180px,240px): minmax sizes the track from its
     content, so filtering the category list (search) changed the longest visible name and the
     divider jumped between 180px and 240px on every keystroke, re-laying out the whole table. */
  grid-template-columns: clamp(180px, 22%, 240px) minmax(0, 1fr);
  flex: 1 1 auto;
  min-height: 360px;
  overflow: hidden;
}
.ts-cat-col { border-right: 1px solid var(--line); display: flex; flex-direction: column; overflow: hidden; }
.ts-products-col { display: flex; flex-direction: column; overflow: hidden; }
.ts-col-head { height: 54px; box-sizing: border-box; padding: 0 12px; border-bottom: 1px solid var(--line); display: flex; align-items: center; flex-shrink: 0; }

/* Chart container */
.chart-box { flex: 1; min-height: 150px; position: relative; }
.chart-box svg { display: block; width: 100%; height: 100%; cursor: crosshair; }
.chart-box canvas { width: 100% !important; height: 100% !important; }
.chart-tooltip {
  position: absolute; pointer-events: none; z-index: 5;
  background: var(--ink); color: var(--on-ink); border-radius: 8px;
  padding: 8px 10px; font-size: 11.5px; line-height: 1.5;
  box-shadow: 0 8px 20px rgba(var(--shadow-rgb), .22);
  opacity: 0; transition: opacity .1s ease;
  white-space: nowrap;
}
.chart-tooltip.visible { opacity: 1; }
.chart-tooltip .tt-date { color: var(--on-ink-soft); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; margin-bottom: 4px; }
.chart-tooltip .tt-row { display: flex; align-items: center; gap: 6px; }
.chart-tooltip .tt-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.chart-tooltip .tt-price { font-family: 'JetBrains Mono', monospace; font-weight: 600; margin-left: auto; padding-left: 10px; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); }
.legend-item { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.legend-swatch { width: 14px; height: 0; border-top: 2px solid; flex-shrink: 0; }

/* Cells clip instead of wrapping — needed by every table-layout:fixed table (Top Sellers
   rankings, Settings, Monitoring), so a long title can't push its column out of shape. */
.ts-table th, .ts-table td,
.ellipsis-cells th, .ellipsis-cells td {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* Grouped two-row header (Market Share: Top 25 / 50 / 100 blocks). Both header rows are sticky,
   so the label row needs an explicit offset equal to the group row's height — hence the fixed
   header cell heights below. */
.ts-table thead th { height: 32px; }
.ts-table thead.has-groups .th-group {
  height: 27px; padding: 4px 16px; font-size: 10px; font-weight: 600;
  text-transform: uppercase; letter-spacing: .08em; color: var(--muted-2);
}
.ts-table thead.has-groups tr:last-child { top: 27px; }
.ts-table thead.has-groups tr:last-child th { border-top: 1px solid var(--line-soft); }
/* Divider between metric blocks — drawn on the first cell of each block, header and body alike. */
.ts-table .group-sep, #rep-table .group-sep { border-left: 1px solid var(--line); }

/* Report / table toolbar */
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
.select-btn {
  display: flex; align-items: center; gap: 9px; padding: 6px 12px;
  background: var(--white); border: 1px solid var(--line); border-radius: 8px;
  min-width: 260px; cursor: pointer; text-align: left; font-family: inherit; font-size: 12.5px; font-weight: 500;
}
.select-btn:hover { border-color: var(--faint); }

/* Every <select> in the app drops its native chrome and draws the app's own chevron, so the closed
   box matches the popover main.js opens over it (selectMenu). background-image, not a pseudo-
   element: a <select> can't have children, and this keeps the control a single element that the
   page's existing JS (.value, innerHTML rebuilds, change events) still owns. */
select.select-btn, select.field {
  appearance: none; -webkit-appearance: none;
  color: var(--ink);
  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='%23a8a29e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 10px center;
  padding-right: 28px;
}
/* Open state: the box reads as pressed while its menu is on screen. */
select.menu-open { border-color: var(--ink); }

/* The popover itself. Fixed + appended to <body> so a toolbar with overflow:hidden, a drawer or
   the table's scroll container can never clip it; it closes on scroll rather than tracking it. */
.menu-pop {
  position: fixed; z-index: 400;
  max-height: min(52vh, 320px); overflow-y: auto;
  background: var(--white); border: 1px solid var(--line); border-radius: 11px;
  box-shadow: 0 16px 40px rgba(var(--shadow-rgb), .16);
  padding: 6px;
}
/* <optgroup> label — a quiet section header, not a row you can land on. */
.menu-pop-group {
  font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint);
  padding: 9px 10px 4px;
}
.menu-pop-group:first-child { padding-top: 4px; }
.menu-opt {
  width: 100%; text-align: left; border: 0; background: transparent; border-radius: var(--radius-sm);
  padding: 8px 10px; cursor: pointer; font-family: inherit; font-size: 12.5px;
  display: flex; gap: 8px; align-items: baseline;
  color: var(--muted); font-weight: 500;
}
/* Hover and keyboard cursor are the same highlight on purpose — only one row is ever marked, so
   there is never a second "which one is it" candidate on screen. */
.menu-opt:hover, .menu-opt.menu-opt-cursor { background: var(--line-soft); }
.menu-opt[aria-selected="true"] { color: var(--ink); font-weight: 600; background: var(--line-soft); }
.menu-opt-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Optional trailing count/hint, from data-meta on the <option>. */
.menu-opt-meta { margin-left: auto; font-size: 10.5px; color: var(--faint); flex-shrink: 0; }
.menu-opt:disabled { color: var(--faint); cursor: default; background: transparent; }

/* The category cascade both history trackers hang off their download button (categoryMenu in main.js).
   Everything visual is inherited from .menu-pop / .menu-opt above — only what a cascade needs is here:
   a two-line row and the chevron that says a row has children. */
.cat-menu-opt { gap: 12px; }
.cat-menu-text { display: flex; flex-direction: column; min-width: 0; }
.cat-menu-name {
  font-size: 12.5px; font-weight: 500; color: var(--ink); letter-spacing: -.005em;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cat-menu-sub { font-size: 11px; color: var(--muted-2); margin-top: 2px; font-weight: 400; }
/* The chevron goes on the side the submenu will open from, so it always points at where the panel is
   about to appear. The count keeps the right-hand column the other popovers put it in either way. */
.cat-menu-chev { flex-shrink: 0; color: var(--faint); display: inline-flex; align-self: center; }
.cat-menu-chev-left { margin-right: 2px; }
/* Same gutter on a row with no children, so the names in a panel stay in one column. */
.cat-menu-chev-empty { visibility: hidden; }
.cat-menu-opt .menu-opt-meta { margin-left: auto; }
/* The root panel grows in from the button (catMenuPositionRoot sets transform-origin on the edge
   facing it): 150ms ease-out from .97 and transparent - an occasional popover, so a short entrance
   that says where it came from. The child panels have none: they open on hover while the reader
   runs down the rows, and anything animated there would lag behind the pointer. Closing fades every
   panel in 100ms (catMenuClose). */
.cat-menu-pop { transition: opacity .1s ease, transform .1s ease; }
.cat-menu-pop[data-depth="0"] { transition: opacity .15s var(--ease-out), transform .15s var(--ease-out); }
@starting-style {
  .cat-menu-pop[data-depth="0"] { opacity: 0; transform: scale(.97); }
}
.cat-menu-pop.cat-menu-closing { opacity: 0; pointer-events: none; transition: opacity .1s ease; }
@media (prefers-reduced-motion: reduce) {
  @starting-style {
    .cat-menu-pop[data-depth="0"] { transform: none; }
  }
}

.table-wrap { flex: 1 1 auto; min-height: 300px; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
/* Schedule Jobs' password gate: the table card is the dialog's containing block, so the nav, other
   tabs and the chip row stay clickable -- only the job rows are behind the lock (.pw-dialog below). */
.card.lock-scoped { position: relative; }
/* #cat-delete-overlay only: .pw-dialog-overlay is absolute (scoped to a card) by default. This one
   has to stack ON TOP of the category edit drawer it's opened from (.drawer-overlay, z-index:60,
   position:fixed) instead of behind it - viewport-fixed and a higher z-index than any drawer. */
#cat-delete-overlay { position: fixed; z-index: 70; }
/* Pieces shared by every password dialog (.pw-dialog below): the field with its show/hide eye and
   the error line. */
.lock-box-error { font-size: 12px; color: var(--red-fg); min-height: 16px; }
.lock-box-field { position: relative; display: flex; }
.lock-box-field .field { flex: 1; padding-right: 34px; }
.lock-box-eye {
  position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
  width: 20px; height: 20px; border: none; background: transparent; border-radius: 0;
  display: grid; place-items: center; color: var(--muted-2); padding: 0;
}
.lock-box-eye:hover { color: var(--ink); background: transparent; }

/* The password dialog, everywhere one is asked for: Monitoring's resolve-a-failed-job (5 clicks on
   a Failed pill), Settings' Schedule Jobs unlock and category delete. Modeled on Motion's Base UI
   dialog example (motion.dev/examples/react-base-dialog), values measured off the live example:
   the backdrop fades in over 200ms, then the box swings in from a tilted, pushed-back, blurred
   pose - perspective(500px) translateZ(-100px) rotateX(5deg) rotateY(25deg), blur(10px) - over
   500ms after a 200ms delay. Closing runs the same pose back in 300ms, accelerating out, with no
   delay. Rare by design (a password prompt, not an everyday action), which is what earns it a
   longer, more characterful entrance than the app's everyday 150-250ms motion. Transitions, not
   keyframes, so a close mid-entrance reverses from wherever the box is. Scoped to the rows only:
   the card header, the column headers and the foot stay sharp. On Monitoring .mon-rows-area wraps
   the scroll container as its containing block and `top` is the thead's height
   (monOpenResolveConfirm); on Schedule Jobs the card is, with top/bottom set in schOpenLock. */
.mon-rows-area { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.pw-dialog-overlay {
  position: absolute; inset: 0; z-index: 10; display: none;
  align-items: center; justify-content: center; padding: 16px;
  background: rgba(0, 0, 0, .5);
  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
  opacity: 0;
  /* Close: overlay out with the box; `display` held until the box's 300ms is done. Clicks pass
     through for those 300ms - the page is already back in the reader's hands. */
  pointer-events: none;
  transition: opacity .19s ease, display .3s allow-discrete;
  /* The dialog opens mid-burst of clicks on the Failed pill; the leftover clicks land here and a
     triple-click would select the nearest text (the Resolve label). The password field stays
     selectable. */
  user-select: none; -webkit-user-select: none;
}
.pw-dialog-overlay input { user-select: text; -webkit-user-select: text; }
.pw-dialog-overlay.open { display: flex; opacity: 1; pointer-events: auto; transition: opacity .2s ease; }
.pw-dialog {
  width: 260px; max-width: 100%;
  background: var(--white); border: 1px solid var(--line); border-radius: 10px;
  box-shadow: 0 16px 40px rgba(var(--shadow-rgb), .18);
  padding: 14px 16px;
  opacity: 0; filter: blur(10px);
  transform: perspective(500px) translateZ(-100px) rotateX(5deg) rotateY(25deg);
  transition: opacity .3s cubic-bezier(.67, .17, .62, .64), filter .3s cubic-bezier(.67, .17, .62, .64),
              transform .3s cubic-bezier(.67, .17, .62, .64);
}
.pw-dialog-overlay.open .pw-dialog {
  opacity: 1; filter: blur(0);
  /* Same function list as the closed pose, zeroed, so the browser interpolates each term rather
     than falling back to a matrix blend. */
  transform: perspective(500px) translateZ(0) rotateX(0) rotateY(0);
  transition: opacity .5s ease-out .2s, filter .5s cubic-bezier(.17, .67, .51, 1) .2s,
              transform .5s cubic-bezier(.17, .67, .51, 1) .2s;
}
@starting-style {
  .pw-dialog-overlay.open { opacity: 0; }
  .pw-dialog-overlay.open .pw-dialog {
    opacity: 0; filter: blur(10px);
    transform: perspective(500px) translateZ(-100px) rotateX(5deg) rotateY(25deg);
  }
}
/* Reduced motion: no swing, no blur - a short fade of the box after the backdrop. */
@media (prefers-reduced-motion: reduce) {
  .pw-dialog, .pw-dialog-overlay.open .pw-dialog { transform: none; filter: none; }
  .pw-dialog { transition: opacity .15s ease; }
  .pw-dialog-overlay.open .pw-dialog { transition: opacity .2s ease .1s; }
  @starting-style {
    .pw-dialog-overlay.open .pw-dialog { transform: none; filter: none; }
  }
}
.pw-dialog-title { margin: 0 0 4px; font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
.pw-dialog-sub { margin: 0 0 10px; font-size: 10.5px; line-height: 1.4; color: var(--muted); }
.pw-dialog .lock-box-error { margin-top: 4px; font-size: 11.5px; min-height: 14px; }
.pw-dialog-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 4px; }
.pw-dialog-actions .btn { padding: 5px 10px; border-radius: 7px; font-size: 11px; }
/* Cancel is a bare text button, the one filled button is the action - as in the example. */
.pw-dialog-cancel { border-color: transparent; background: transparent; color: var(--ink); }
.pw-dialog-cancel:hover { border-color: transparent; background: var(--line-soft); }
/* scrollbar-gutter: a filter that returns few enough rows to fit removes the vertical scrollbar,
   and without a reserved gutter the table widens by its width — reads as the columns shifting. */
.table-scroll { flex: 1; min-height: 0; min-width: 0; overflow: auto; scrollbar-gutter: stable; }
/* "Sorted by Finished ↓ ✕" (makeTableSort in main.js): sits right after the row counter - the auto
   right margin soaks up the foot's free space so it does not drift to the middle. */
.sort-chip { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; padding: 2px 4px 2px 8px; border-radius: 999px; background: var(--blue-bg); color: var(--blue-fg); font-weight: 600; white-space: nowrap; }
.sort-chip[hidden] { display: none; }
.sort-chip-x { all: unset; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; cursor: pointer; }
.sort-chip-x:hover { background: var(--blue-fg); color: var(--blue-bg); }
.sort-chip-x:focus-visible { outline: 2px solid var(--blue-fg); outline-offset: 1px; }
.table-foot {
  flex-shrink: 0; border-top: 1px solid var(--line); background: var(--surface-2);
  padding: 9px 18px; display: flex; justify-content: space-between; align-items: center; gap: 12px;
  font-size: 11px; color: var(--muted-2);
}

/* Monitoring: every status pill (Done/Running/Queued/Solved/Failed) presses in on click as plain
   visual feedback, and none of them are text-selectable. Only an unresolved Failed pill
   (.mon-fail-pill, see monitoring.js) additionally counts clicks toward the password dialog - the
   press itself is the same for all of them. A transition on :active rather than a keyframe: the
   Failed pill takes rapid repeat clicks, and a keyframe restarted from zero on each one stutters
   where a transition just retargets. Shrinking, not growing, so it reads as being pushed. */
.mon-status-pill {
  display: inline-block; cursor: pointer; user-select: none; -webkit-user-select: none;
  transition: transform .12s var(--ease-out);
}
.mon-status-pill:active { transform: scale(.95); }

/* Monitoring channel pills */
.pill-card {
  background: var(--white); border: 1px solid var(--line); border-radius: 10px;
  padding: 11px 13px; cursor: pointer;
}
.pill-card:hover { border-color: var(--faint); }
.pill-card.active { background: var(--ink); border-color: var(--ink); color: var(--on-ink); }
/* Now keyboard-operable (role="button" tabindex="0") - the default ring is low-contrast against
   the dark .active background, so it gets its own visible outline instead of just inheriting it. */
.pill-card:focus-visible { outline: 2px solid var(--blue-fg); outline-offset: 2px; }
.pill-card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 8px; }
/* Faint on an idle card, the card's own color on the dark .active one - inherited, so it fades
   with the card instead of being re-rendered inline. */
.pill-card-label { font-size: 11px; color: var(--faint); text-transform: uppercase; letter-spacing: .06em; }
.pill-card.active .pill-card-label { color: inherit; }

/* The job table while a filter click's fetch is in flight (monFilterChanged in monitoring.js):
   the old rows dim so they don't read as the answer to the new filter. The 80ms delay keeps a
   quick response from flickering; coming back is the base transition, faster and undelayed. */
#mon-rows { transition: opacity .15s var(--ease-out); }
#mon-rows.mon-loading { opacity: .5; transition: opacity .12s ease .08s; }
.pill-card .dot { width: 6px; height: 6px; border-radius: 50%; position: relative; }
.pill-card .dot.dot-pulse::before,
.pill-card .dot.dot-pulse::after {
  content: "";
  position: absolute;
  top: 50%; left: 50%;
  width: 100%; height: 100%;
  background: inherit;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  animation: dot-pulse-ripple 2s infinite linear;
  opacity: .3;
}
.pill-card .dot.dot-pulse::after { animation-delay: 1s; }
@keyframes dot-pulse-ripple {
  0% { transform: translate(-50%, -50%) scale(1); opacity: .6; }
  100% { transform: translate(-50%, -50%) scale(2.5); opacity: 0; }
}
/* Endless expanding rings are exactly the movement reduced motion asks to drop. The dot itself
   keeps its color, which is what actually says "running". */
@media (prefers-reduced-motion: reduce) {
  .pill-card .dot.dot-pulse::before, .pill-card .dot.dot-pulse::after { animation: none; opacity: 0; }
}

/* Settings: forms, inline-editable fields, toggle pills */
input.field, select.field {
  padding: 6px 9px; border: 1px solid var(--line); border-radius: 6px;
  font-size: 12.5px; font-family: inherit; background: var(--white); color: var(--ink);
}
input.field:focus, select.field:focus { outline: 0; border-color: var(--ink); }
.toggle-pill { border: 0; cursor: pointer; font-family: inherit; }
.settings-form {
  display: grid; grid-template-columns: repeat(auto-fit,minmax(140px,1fr)); gap: 10px;
  background: var(--white); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 14px 16px; margin-bottom: 12px; align-items: end;
}
.settings-form label { font-size: 11px; color: var(--faint); text-transform: uppercase; letter-spacing: .06em; display: block; margin-bottom: 4px; }
.save-toast {
  font-size: 11px; color: var(--green-fg); opacity: 0; transition: opacity .2s ease;
}
.save-toast.visible { opacity: 1; }

/* Settings: side drawers (SKU detail, category editor) */
/* Kept in the layout (not display:none) so the slide-in has something to transition from --
   toggling display would jump the drawer straight to its open position with no animation. */
.drawer-overlay {
  position: fixed; inset: 0; z-index: 60; display: flex; justify-content: flex-end;
  visibility: hidden; pointer-events: none;
  /* Delayed on the way out so it stays visible through the close transition below; the delay is
     zeroed on .open so opening is instant. */
  transition: visibility 0s linear .2s;
}
.drawer-overlay.open { visibility: visible; pointer-events: auto; transition-delay: 0s; }
.drawer-scrim {
  position: absolute; inset: 0; background: rgba(var(--shadow-rgb), var(--scrim-alpha));
  opacity: 0; transition: opacity .18s ease;
}
.drawer-overlay.open .drawer-scrim { opacity: 1; }
.drawer {
  position: relative; width: 520px; max-width: 92vw; height: 100%;
  background: var(--bg); border-left: 1px solid var(--line);
  display: flex; flex-direction: column;
  box-shadow: -16px 0 48px rgba(var(--shadow-rgb), .10);
  transform: translateX(28px); opacity: 0;
  /* This is the closing transition (the state it returns to); opening is set on .open below. Close
     is faster than open - the user is done with it and shouldn't wait on it. Kept under the
     overlay's .2s visibility delay above so the drawer is gone before the overlay hides. */
  transition: transform .18s var(--ease-out), opacity .14s ease;
}
.drawer-overlay.open .drawer {
  transform: none; opacity: 1;
  transition: transform .24s var(--ease-drawer), opacity .16s ease;
}
@media (prefers-reduced-motion: reduce) {
  .drawer-scrim, .drawer, .drawer-overlay.open .drawer { transition: none; }
}
.drawer-sm { width: 460px; }
.drawer-head {
  padding: 20px 24px 16px; border-bottom: 1px solid var(--line); background: var(--white);
  display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-shrink: 0;
}
.drawer-close {
  border: 1px solid var(--line); background: var(--white); border-radius: 7px;
  width: 28px; height: 28px; display: grid; place-items: center; cursor: pointer;
  color: var(--muted-2); flex-shrink: 0;
}
.drawer-close:hover { background: var(--line-soft); }
.drawer-body { flex: 1; overflow-y: auto; scrollbar-gutter: stable; padding: 18px 24px; display: flex; flex-direction: column; gap: 12px; }
.drawer-foot {
  padding: 14px 24px; border-top: 1px solid var(--line); background: var(--white);
  display: flex; justify-content: flex-end; align-items: center; gap: 8px; flex-shrink: 0;
}
/* Footer-corner (i), same shape as .drawer-close. */
.icon-btn {
  border: 1px solid var(--line); background: var(--white); border-radius: 7px;
  width: 28px; height: 28px; display: grid; place-items: center; cursor: pointer;
  color: var(--muted-2); flex-shrink: 0;
  /* The UA's button padding (1px 6px) leaves the 22px variants a content box narrower than the
     icon, which then overflows to the right and sits off-center. */
  padding: 0;
}
/* display:grid above outranks the UA's [hidden] rule, so a hidden icon-btn (e.g. the category
   drawer's delete button on "New category") would still show without this. */
.icon-btn[hidden] { display: none; }
.icon-btn:hover { background: var(--line-soft); }
/* Small icon buttons in a .table-foot: the negative margin keeps the foot as tall as a text-only one. */
.icon-btn-sm { width: 22px; height: 22px; border-radius: 6px; }
.foot-tools { display: flex; align-items: center; gap: 6px; margin: -3px 0; }
.icon-btn-danger:hover { background: var(--red-bg); color: var(--red-fg); border-color: var(--red-fg); }

/* Press feedback for everything clickable: a slight shrink on :active so a click registers the
   moment it lands, before whatever it triggers has had time to respond. Sits after every one of
   these selectors' own rules on purpose - it replaces their `transition`, so the color properties
   those already faded are carried over here. */
.btn, .chip, .tab, .sheet-tab, .icon-btn, .drawer-close, .pill-card, .theme-toggle, .row-open,
.select-btn, .toast-close {
  transition: transform .16s var(--ease-out), background-color .16s ease, color .16s ease,
              border-color .16s ease, box-shadow .16s ease;
}
.btn:active, .chip:active, .tab:active, .sheet-tab:active, .icon-btn:active, .drawer-close:active,
.theme-toggle:active, .row-open:active, .select-btn:active, .toast-close:active {
  transform: scale(.97);
}
/* A card is much wider than a button, so the same .97 would pull its edges in several pixels. */
.pill-card:active { transform: scale(.985); }

/* The one tooltip (see the tooltip block at the end of main.js). Fixed and appended to <body>, so
   a cell with overflow:hidden, a drawer body or a scroll container can never clip it, and it can
   flip below / clamp sideways at the viewport edge. Same face as a .notice, since the first ones
   were the drawers' (i) notices.
   The shape follows Motion's Base UI tooltip (motion.dev/examples/react-base-tooltip): a 4px
   corner and an arrow pointing at the trigger; the colors stay ours. Its motion does not: Motion's
   600ms spring from scale(.8) with a 25% overshoot was tried and read as wobbly on something opened
   tens of times a day over truncated cells. So: 200ms ease-out from scale(.96) and 4px toward the
   trigger, no bounce; out in 120ms. main.js sets transform-origin to the trigger's center on the
   near edge and --tip-dy to the side the bubble opens from. */
.tip {
  position: fixed; top: 0; left: 0; z-index: 500;
  max-width: min(320px, calc(100vw - 16px));
  padding: 7px 11px; border: 1px solid var(--line); border-radius: 4px;
  background: var(--line-soft); color: var(--muted-2); font-size: 11.5px; line-height: 1.45;
  box-shadow: 0 8px 24px rgba(var(--shadow-rgb), .12);
  white-space: normal; overflow-wrap: anywhere; pointer-events: none;
  --tip-dy: 4px;
  opacity: 0; visibility: hidden; transform: translateY(var(--tip-dy)) scale(.96);
  /* Closing: fade out, then drop visibility once it's invisible. */
  transition: opacity .12s var(--ease-out), transform .12s var(--ease-out), visibility 0s linear .12s;
}
.tip.tip-open {
  opacity: 1; visibility: visible; transform: none;
  transition: opacity .15s var(--ease-out), transform .2s var(--ease-out), visibility 0s;
}
/* Moving from one trigger straight onto the next while a tooltip is showing: the new one is just
   there, no delay and no animation - it's what makes scanning a row of them feel fast. */
.tip.tip-instant { transition: none; }
/* data-tip-interactive: the open bubble takes the pointer, so a link inside it can be clicked. */
.tip.tip-interactive.tip-open { pointer-events: auto; }
.tip a { color: var(--blue-fg); text-decoration: underline; text-underline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
  .tip, .tip.tip-open { transform: none; }
}
/* The arrow: Base UI's 20x10 curved-tip shape, filled with the bubble's background and stroked
   with its border along the curve only. The flat base overlaps the bubble's edge by 1.5px so the
   stroke's straight ends sit exactly on the border line and the two read as one outline. main.js
   sets `left`; .tip-below flips it to the top edge. */
.tip-arrow { position: absolute; left: 0; top: calc(100% - 1.5px); width: 20px; height: 10px; transform: rotate(180deg); }
.tip-below .tip-arrow { top: auto; bottom: calc(100% - 1.5px); transform: none; }
.tip-arrow .tip-arrow-fill { fill: var(--line-soft); }
.tip-arrow .tip-arrow-stroke { fill: none; stroke: var(--line); stroke-width: 1; }
/* Rich content (data-tip-from): a bubble carrying more than one rule keeps them as separate
   paragraphs -- sentences run together read as one. An empty one takes no space. */
.tip-body > div + div { margin-top: 7px; }
.tip-body > div:empty { display: none; }
/* The warning kinds this file raised, name over consequence, under the sentence that introduces
   them. Two lines rather than "Name — effect" on one: the names run to four words and the effects
   to six, and wrapped inline they lose which half belongs to which. */
.notice-kind { margin-top: 6px; }
.notice-kind-name { display: block; color: var(--ink); font-weight: 500; }

/* Full-screen table modal (Top Sellers). The live .sheet-tabs + .table-scroll + .table-foot nodes
   are MOVED into .table-modal-slot on open and appended back to their host column on close — no
   cloning, so the scroll listener, infinite-scroll state and row DOM survive the trip. The tabs
   come along so sheets can be switched without leaving full screen. */
.table-modal {
  position: fixed; inset: 0; z-index: 60; display: none;
  padding: clamp(28px, 6vh, 64px) clamp(32px, 6vw, 120px);
}
.table-modal.open { display: flex; }
.table-modal.open .drawer-scrim { opacity: 1; }
/* Open/close motion. display:none -> flex skips transitions, so the open state starts from
   @starting-style, and `display ... allow-discrete` keeps the modal on screen through the close.
   Centered scale, not origin-aware: a modal isn't anchored to its trigger. On close the card goes
   at once and only the scrim fades (150ms, faster than the 200ms open): every caller moves its
   content back into the page BEFORE dropping .open, so a fading card would be an empty one.
   Browsers without these features just snap, as before. */
.table-modal { transition: display .15s allow-discrete; }
.table-modal .drawer-scrim { transition: opacity .15s ease; }
.table-modal.open .drawer-scrim { transition-duration: .2s; }
.table-modal:not(.open) .table-modal-card { opacity: 0; }
.table-modal.open .table-modal-card { transition: opacity .2s var(--ease-out), transform .2s var(--ease-out); }
@starting-style {
  .table-modal.open .drawer-scrim, .table-modal.open .table-modal-card { opacity: 0; }
}
/* Reduced motion keeps the fade, drops the scale. */
@media (prefers-reduced-motion: no-preference) {
  @starting-style {
    .table-modal.open .table-modal-card { transform: scale(.97); }
  }
}
.table-modal-card {
  position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column;
  background: var(--white); border: 1px solid var(--line); border-radius: 12px;
  overflow: hidden; box-shadow: 0 24px 64px rgba(var(--shadow-rgb), .18);
}
.table-modal-head {
  padding: 13px 18px; border-bottom: 1px solid var(--line); flex-shrink: 0;
  display: flex; justify-content: space-between; align-items: center; gap: 16px;
}
.table-modal-slot { flex: 1; min-height: 0; display: flex; flex-direction: column; }

/* ---------------------------------------------------------------------------
   .tr-* — the chrome the two history trackers share.

   Price Tracker and Position Tracker are the same screen twice: a SKU list on the left, a chart
   with its observation table on the right, and a full-screen modal for the chart. These rules used
   to exist twice as well, as .po-* and .pt-* blocks inside each template, identical but for the
   prefix — the Price Tracker's copy even carried the note "if one of the two changes, change both".
   That is a comment describing a bug, so the two are one block now.

   What stays per-page is what genuinely differs: the Position Tracker's KPI tiles and its category
   select live in its own <style>, because the Price Tracker has neither.
   --------------------------------------------------------------------------- */

/* The product page is reached from the SKU heading: one 20px .link-box square right after the key,
   the same glyph Top Sellers uses. Only the SKU text may shrink, so a long key ellipsises instead
   of pushing the square under the Show points button. */
.tr-sku-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
.tr-sku-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.tr-sku-line > .link-box, .tr-sku-line .xref-tag { flex-shrink: 0; }

/* Nothing to show (no SKU matches, none tracked): the message alone, centered both ways in the card.
   Every other block is hidden, not just emptied: an empty .tabs still draws its bordered box, and
   #tr-stats keeps its top rule. */
.tr-is-empty > :not(#tr-topbar) { display: none !important; }
/* !important: #tr-topbar sets align-items/justify-content inline, which beats any plain rule. */
.tr-is-empty #tr-topbar { flex: 1; align-items: center !important; justify-content: center !important; }
.tr-is-empty #tr-header { text-align: center; }
.tr-is-empty #tr-controls { display: none !important; }

/* Floats over the plot's top-right corner rather than sitting in a toolbar: the chart is the only
   thing it acts on, and that corner is empty in every series (the line runs left-to-right along
   the middle). Half-faded until the chart is hovered so it does not compete with the data. */
.tr-expand {
  position: absolute; top: 6px; right: 6px; z-index: 4; width: 24px; height: 24px;
  opacity: .35; transition: opacity .15s ease;
}
.chart-box:hover .tr-expand, .tr-expand:focus-visible { opacity: 1; }

/* Chart left, observations right. The side panel is fixed-width because its columns are narrow and
   known — letting it flex would steal width from the plot, which is the reason the modal was opened
   at all. Below 1100px it drops under the chart instead of squeezing both. */
.tr-modal-body { flex: 1; min-height: 0; display: flex; }
.tr-modal-chart {
  flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; padding: 16px 18px;
  overflow: hidden;
}
/* Docked, the box gets its top margin from its chart host; in the modal it is a direct child, so
   the same 14px has to be restated or the plot butts against the pills. */
.tr-modal-chart > .chart-box { margin-top: 14px; }
.tr-modal-side {
  width: 340px; flex-shrink: 0; border-left: 1px solid var(--line); background: var(--bg);
  display: flex; flex-direction: column; min-height: 0;
}
.tr-modal-side-scroll { flex: 1; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; }
@media (max-width: 1100px) {
  .tr-modal-body { flex-direction: column; }
  .tr-modal-side { width: auto; border-left: 0; border-top: 1px solid var(--line); max-height: 40%; }
}

/* Generic label-collapse for a narrow phone screen — a button keeps its icon/symbol and drops its
   text (e.g. "+ New category" -> "+"), so it stays tappable width instead of forcing the toolbar
   it sits in to overflow or shrink its neighbors further. */
@media (max-width: 480px) {
  .hide-narrow { display: none; }
}

/* Toolbar search collapse, shared by every Settings screen with a .toolbar search box that's too
   wide to sit next to its chips/button on a phone (Top Sellers categories, Brands, Promo Plan,
   SKU Mapping): the search box has no icon-size form -- unlike "+ New category" above, its whole
   point is the text input -- so under 480px it hides completely behind a plain icon button
   (.search-toggle) instead of shrinking. Tapping that button adds .search-open to the .toolbar
   (settings.js), which hides that toolbar's channel chips, if it has any, in its place and gives
   the input the row -- the same width-budget problem the label-collapse above solves, just solved
   by swapping rows instead of shrinking one.
   .search-toggle is sized off the same 1.4em/14px .btn-icon uses (border 1px + 6px padding each
   side, at .btn/.chip's 12.5px font) rather than a pinned px guess, so it comes out exactly as
   tall as the chips and the "+" button beside it whatever the font metrics turn out to be, in
   every one of these toolbars — width matches too, which .btn-icon's own padding doesn't, so the
   icon reads as square rather than a stretched pill. */
.search-toggle { display: none; }
@media (max-width: 480px) {
  .search-toggle {
    display: grid; place-items: center; padding: 0;
    width: calc(1.4em + 14px); height: calc(1.4em + 14px);
  }
  .search-collapse { display: none; }
  .search-open .search-toggle { display: none; }
  .search-open .chip-scroll-wrap { display: none; }
  .search-open .search-collapse { display: flex; flex: 1 1 auto; min-width: 0; }
}

/* Second line under the date in an observation row: the Position Tracker puts the batch id there
   (it is what makes two readings on the same day tellable apart), the Price Tracker the channel
   (with every channel on one chart, a price means nothing until you know whose it is). Add .mono
   where the value is an identifier rather than a name. */
.tr-obs-meta { display: block; font-size: 10px; color: var(--faint); }

/* The per-channel tabs are hidden while the chart is docked: the channel is already chosen by the
   chip row above, and a second channel control right under it would be two answers to one question.
   They come back inside the full-screen modal, where the chip row is not visible and jumping between
   a SKU's channels without leaving the chart is the point. */
.tr-channel-tabs.is-docked { display: none; }

.dc-card { background: var(--white); border: 1px solid var(--line); border-radius: 11px; padding: 14px 16px; display: flex; flex-direction: column; gap: 11px; }
/* Salsify-fed (read-only) channels: dimmed + greyed so it reads as "can't touch this", distinct
   from editable (Menards/Ferguson/Wayfair) cards which stay full-opacity/normal to invite editing. */
.dc-card-locked { opacity: .6; background: var(--bg); }
.dc-card-locked input[readonly] { cursor: default; }
.dc-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.dc-name { font-size: 13.5px; font-weight: 600; letter-spacing: -.01em; }
.dc-fields { display: grid; grid-template-columns: 64px 1fr; gap: 8px 10px; align-items: center; }
.dc-fields label { font-size: 11px; color: var(--faint); text-transform: uppercase; letter-spacing: .06em; }
/* A .pill that is also the control that changes it: green active / red inactive, click to flip.
   Both Settings drawers use it — the SKU drawer's per-channel scraping switch and the Top Sellers
   category status. It replaced a two-button Active|Inactive pair where the reader had to work out
   which half was the state and which was the action; this is the same pill the tables already show,
   so a row and its drawer read alike. */
.pill-toggle { border: 1px solid transparent; cursor: pointer; font-family: inherit; }
.pill-toggle:hover { border-color: currentColor; }

.notice {
  font-size: 11.5px; color: var(--muted-2); background: var(--line-soft);
  border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; margin-bottom: 10px;
}

/* Schedule Jobs drawer: "this run_at is aspirational" warning for a category/watchlist row
   gated behind its own source's product task (see schOpenDrawer / pipeline.settings.
   list_schedule's gated_after field). Amber, not the neutral .notice tone above - same
   caution language the unlock toast/tab icon already use elsewhere on this page. */
.sch-gate-notice {
  margin-top: 8px; font-size: 11px; line-height: 1.5; color: var(--amber-fg);
  background: var(--amber-bg); border: 1px solid var(--line); border-radius: 8px;
  padding: 8px 10px;
}

/* Footer */
.footer {
  border-top: 1px solid var(--line);
  padding: 16px 32px;
  color: var(--faint);
  font-size: 11.5px;
  display: flex;
  justify-content: space-between;
  max-width: 1400px;
  margin: 0 auto;
  width: 100%;
}

/* The preview drawer's four counts. Not .kpi-grid: four columns inside a 520px drawer leave ~110px
   a cell, so "Expectations" wrapped and "SKUs" did not, and the row of cards read as four different
   sizes. Two by two, each cell min-width:0, so all four are the same box and every label fits on one
   line. margin-bottom is dropped because .drawer-body already gaps its children. */
/* One row of four. A single grid row is stretched to its tallest cell by default, so all four are
   the same height for free -- the 2x2 it replaced needed grid-auto-rows to get there. At ~110px a
   cell the label is the constraint, hence 10px type on one line with room to spare. */
.pp-counts {
  /* Column count comes from the markup: a file with no warnings has no warnings card, and three
     cards should fill the row rather than leave a gap where the fourth was. */
  display: grid; grid-template-columns: repeat(var(--pp-cols, 4), minmax(0, 1fr)); gap: 6px; margin: 0;
}
.pp-counts .kpi { min-width: 0; padding: 10px 11px; }
.pp-counts .kpi-label {
  font-size: 10px; letter-spacing: .04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pp-counts .kpi-value { margin-top: 5px; font-size: 18px; font-variant-numeric: tabular-nums; }
/* Each tile explains itself on hover/focus (data-tip); the help cursor says there is something to read. */
.pp-counts .kpi[data-tip] { cursor: help; }
.pp-counts .kpi:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; }

/* Compact .btn (Promo lines' Show more, Workbook's Warnings download). */
.pp-btn-sm { padding: 3px 9px; font-size: 11.5px; flex: 0 0 auto; }

/* Workbook card's Warnings field: a compact download of the warning list, rebuilt from the
   stored file. Same box as the drawer's other small buttons. */
.pp-warn-dl { color: var(--ink); max-width: 100%; }
.pp-warn-dl:disabled { cursor: progress; color: var(--muted); }

/* Promo lines card (the stored plan's Promo lines tile opens it). Fixed layout with set column
   widths, so a page that adds a longer SKU or a wider price never shifts the columns; the list
   scrolls in its own box so a few pages do not push the rest of the drawer out of reach. */
.pp-lines-tools { display: flex; gap: 6px; }
.pp-lines-tools .field { flex: 1 1 0; min-width: 0; }
.pp-lines-tools select.field { flex: 0 1 180px; }
.pp-lines-wrap { max-height: 360px; overflow: auto; border: 1px solid var(--line-soft); border-radius: var(--radius-sm); }
/* Always last in the drawer, collapsed: the summary is the card's head (caret, title, line count),
   and the body only takes the card's gap once it is open. */
.pp-lines-card > summary { cursor: pointer; list-style: none; }
.pp-lines-card > summary::-webkit-details-marker { display: none; }
.pp-lines-card:not([open]) { gap: 0; }
.pp-lines-title { display: flex; align-items: center; gap: 6px; }
.pp-lines-card[open] > summary .warn-caret { transform: rotate(90deg); }
.pp-lines-total { font-size: 11.5px; color: var(--faint); }
/* 16px of side padding per cell is the page tables' rhythm; at ~425px wide it left the prices
   cut to "$3...". Tighter cells, a short window ("Nov 1-30") and "All" for the channel give the
   two price columns room to print in full. */
.pp-lines-table { table-layout: fixed; width: 100%; font-size: 11.5px; }
.pp-lines-table th, .pp-lines-table td { padding: 7px 8px; }
.pp-lines-table th:first-child, .pp-lines-table td:first-child { padding-left: 12px; }
.pp-lines-table th:last-child, .pp-lines-table td:last-child { padding-right: 12px; }
.pp-lines-table th:nth-child(1) { width: 24%; text-align: left; }
.pp-lines-table th:nth-child(2) { width: 17%; text-align: left; }
.pp-lines-table th:nth-child(3) { width: 13%; text-align: left; }
.pp-lines-table th:nth-child(4), .pp-lines-table th:nth-child(5) { width: 23%; text-align: right; }
.pp-lines-table td:nth-child(-n+3) { text-align: left; }
.pp-lines-table td:nth-child(n+4) { text-align: right; }
.pp-lines-table thead th { position: sticky; top: 0; background: var(--surface-2); z-index: 1; }
.pp-lines-table td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pp-lines-sub { font-size: 10.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; }
.pp-lines-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 11.5px; color: var(--faint); }

/* Per-channel coverage: how many of the workbook's SKUs have a listing that channel can be audited
   against. A bar because the question is "most of them or hardly any", which a bare "916 of 6,210"
   makes the reader compute five times over. The count stays, right-aligned and tabular, for the
   reader who does want the figure. */
.cov-row { display: flex; align-items: center; gap: 12px; }
.cov-name { flex: 0 0 104px; width: 104px; font-size: 12.5px; }
.cov-track {
  flex: 1; height: 6px; border-radius: 3px; background: var(--line-soft); overflow: hidden;
}
.cov-fill { height: 100%; border-radius: 3px; background: var(--ink); }
.cov-n {
  flex: 0 0 116px; width: 116px; text-align: right; font-size: 11.5px; color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* Workbook card: File / Uploaded / Loaded as / Version, two per row -- a label/value pair reads
   as a field, not a table row, so it sits next to its neighbor instead of stacking under it. */
.pp-wb-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 18px; }
.pp-wb-field { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.pp-wb-field .pp-wb-k { font-size: 10.5px; color: var(--faint); text-transform: uppercase; letter-spacing: .07em; }
.pp-wb-field .pp-wb-v {
  font-size: 12.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* Windows card: a Gantt strip of the period so an overlap ("shorter one wins") is seen, not just
   read off a table, plus the same rows underneath as a legend with exact dates and line counts. */
.pp-win-scale { display: flex; justify-content: space-between; }
.pp-win-scale span { font-size: 10.5px; color: var(--faint); }
.pp-win-track { position: relative; height: 24px; }
.pp-win-bar {
  position: absolute; top: 0; height: 20px; border-radius: 5px; display: flex; align-items: center;
  padding: 0 7px; overflow: hidden; box-sizing: border-box;
}
.pp-win-bar span { font-size: 10.5px; font-weight: 500; white-space: nowrap; }
.pp-win-bar.pp-win-base { background: var(--ink); border: 1px solid var(--ink); }
.pp-win-bar.pp-win-base span { color: var(--white); }
.pp-win-bar.pp-win-event { background: var(--amber-bg); border: 1px solid #fde68a; }
.pp-win-bar.pp-win-event span { color: var(--amber-fg); }
.pp-win-legend { display: flex; flex-direction: column; gap: 1px; }
.pp-win-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--line-soft); font-size: 12px; }
.pp-win-row:first-child { border-top: none; }
.pp-win-dot { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 2px; background: var(--ink); }
.pp-win-dot.pp-win-event { background: #f59e0b; }
.pp-win-row-name { flex: 1 1 0; min-width: 0; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-win-row-range { color: var(--muted); white-space: nowrap; }
.pp-win-row-lines { flex: 0 0 auto; min-width: 64px; text-align: right; white-space: nowrap; color: var(--faint); }

/* Promo workbook warnings: one collapsible group per warning kind. The old markup was a bare
   <details> per kind with the samples as loose lines, so the row number, the SKU and the sentence
   all ran together and 25 of them read as a paragraph. Here the summary is the group's header row
   (count, kind, what it costs the load) and each sample is a three-column row, so a reader scans
   down the SKU column instead of reading prose. Still <details>/<summary>: no JS, no inline
   handlers, keyboard and find-in-page work as they already did. */
.warn-group { background: var(--white); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
.warn-group > summary {
  list-style: none; cursor: pointer; background: var(--surface-2); padding: 11px 15px;
  display: flex; align-items: baseline; gap: 10px;
  /* An open group scrolls inside itself, so its header stays put at the top of that box: the kind
     whose rows you are reading stays named, and the click that collapses it is always in reach. */
  position: sticky; top: 0; z-index: 1;
}
.warn-group > summary::-webkit-details-marker { display: none; }
.warn-group > summary:hover { filter: brightness(1.08); }
.warn-caret { flex: 0 0 11px; width: 11px; color: var(--faint); font-size: 10px; transition: transform .12s ease; }
.warn-group[open] > summary .warn-caret { transform: rotate(90deg); }
.warn-head { flex: 1; min-width: 0; }
.warn-kind { font-size: 12.5px; color: var(--ink); }
/* What the warning costs the load is the answer to "and then what?", which is a question only the
   reader who opened the group is asking. Closed, the header carries the kind and the count and
   nothing else; open, the consequence appears under the kind as its subtitle. */
.warn-effect { display: none; font-size: 11px; color: var(--faint); margin-top: 3px; }
.warn-group[open] > summary .warn-effect { display: block; }
/* Right-aligned and tabular so the counts of stacked groups line up as a column. */
.warn-n { flex: 0 0 auto; margin-left: auto; font-size: 12.5px; font-weight: 500; color: var(--ink);
          font-variant-numeric: tabular-nums; }
/* Two scrollbars, and both usable. What made the wheel feel broken was never the nesting itself but
   how much scroll the inner box held: Chrome latches a wheel gesture to the inner scroller until it
   reaches its own bottom, so a box holding all 187 rows (~6,000px) meant the drawer behind it never
   moved. The rows arrive 25 at a time on a click instead, so the loaded list is a few hundred pixels
   -- three or four ticks and the gesture hands over to the drawer. Pages arrive as the box is
   scrolled, so the inner scroll only grows for the kinds a reader actually walks. */
.warn-body { border-top: 1px solid var(--line-soft); max-height: 240px; overflow-y: auto; }
/* Outside .warn-body on purpose: a progress line that scrolls away with the rows it counts is a
   line nobody sees. */
.warn-more:empty { display: none; }
.warn-row {
  display: flex; gap: 12px; align-items: baseline; padding: 8px 15px;
  border-bottom: 1px solid var(--bg); font-size: 11.5px;
}
.warn-row:last-child { border-bottom: 0; }
.warn-row-n { flex: 0 0 64px; width: 64px; color: var(--faint); }
.warn-row-sku { flex: 0 0 132px; width: 132px; font-weight: 500; letter-spacing: -.01em; }
.warn-row-detail { flex: 1; color: var(--muted); line-height: 1.5; }
.warn-more { padding: 9px 15px; font-size: 11.5px; color: var(--faint); border-top: 1px solid var(--line-soft); }

/* Dashboard (dashboard.js). Channel cards reuse Monitoring's .pill-card; the rest is the two
   overview pivots and the per-channel SKU table. */
#dash-overview[hidden], #dash-detail[hidden], #dash-status[hidden], #dash-stock[hidden] { display: none; }
.dash-pills {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px;
  margin-bottom: 14px; flex-shrink: 0;
}
.dash-pill-date { font-size: 10.5px; color: var(--faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
.dash-pill-value { font-size: 22px; font-weight: 600; letter-spacing: -.02em; line-height: 1; font-variant-numeric: tabular-nums; }
.dash-pill-sub { font-size: 11px; color: var(--faint); margin-top: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pill-card.active .dash-pill-date, .pill-card.active .dash-pill-sub { color: var(--on-ink-soft); }
/* Distribution under each card's headline. flex-grow carries the count, so the segments share the
   width exactly with no percentages to round. */
.dash-bar {
  display: flex; gap: 1px; height: 4px; margin-top: 10px; border-radius: 999px; overflow: hidden;
  background: var(--line-soft);
}
.dash-bar-seg { flex: 0 1 0; min-width: 2px; }
.seg-match_map, .seg-stock_in { background: var(--green-dot); }
.seg-match_promo { background: var(--blue-dot); }
.seg-over { background: var(--amber-fg); }
.seg-below { background: var(--red-dot); }
.seg-no_map { background: var(--line-strong); }
.seg-not_found, .seg-stock_out { background: var(--faint); }

#dash-overview { flex-shrink: 0; }
.dash-section { margin-bottom: 14px; overflow: hidden; }
.dash-section-head {
  display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap;
  padding: 12px 18px; border-bottom: 1px solid var(--line);
}
.dash-section-title { font-weight: 600; letter-spacing: -.01em; }
.dash-section-note { font-size: 11px; color: var(--faint); }
.dash-table-scroll { overflow-x: auto; }
.dash-pivot { font-variant-numeric: tabular-nums; }
.dash-pivot th.dash-pivot-name { text-align: left; }
.dash-pivot tbody th {
  padding: 9px 16px; border-top: 1px solid var(--line-soft); background: none;
  font-weight: 500; color: var(--ink); white-space: nowrap;
}
.dash-pivot th.dash-group { padding-bottom: 4px; }
.dash-pivot th.dash-sub { padding-top: 4px; font-size: 11px; color: var(--faint); }
.dash-pivot td.num, #dash-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
/* The pivots' columns hold small integers; right-aligned in a wide column they drift away from
   their header, so center them instead. */
.dash-pivot td.num { text-align: center; }
.dash-pivot tbody tr:hover { background: none; }

/* A count you can click: the cell's number itself, with a hit area a bit larger than the text. */
.dash-cell {
  all: unset; box-sizing: border-box; cursor: pointer; display: inline-block;
  padding: 2px 8px; margin: -2px -8px; border-radius: 5px; font-variant-numeric: tabular-nums;
  transition: transform .12s var(--ease-out), background-color .12s ease;
}
.dash-cell:focus-visible { outline: 2px solid var(--blue-dot); outline-offset: 1px; }
.dash-cell:active { transform: scale(.95); }
@media (hover: hover) and (pointer: fine) {
  .dash-cell:hover { background: var(--line-soft); }
}
.is-ok { color: var(--green-fg); }
.is-promo { color: var(--blue-fg); }
.is-bad { color: var(--red-fg); }
.is-warn { color: var(--amber-fg); }
.is-total { font-weight: 600; color: var(--ink); }
.dash-zero { color: var(--faint); }
.dash-muted { color: var(--muted-2); }
.dash-date { text-align: center; color: var(--muted-2); white-space: nowrap; font-variant-numeric: tabular-nums; }

#dash-detail { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.dash-tab-n { margin-left: 6px; color: var(--faint); font-variant-numeric: tabular-nums; font-size: 11.5px; }
#dash-table td { white-space: nowrap; }

.pill-warn { background: var(--amber-bg); color: var(--amber-fg); }
.pill-warn .pill-dot { background: var(--amber-fg); }
.dash-pill .pill-card-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
/* The promo-source pill in the MAP status header: a link to Settings > Promo Plan. */
.dash-promo-pill {
  max-width: 360px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  text-decoration: none; transition: transform .12s var(--ease-out);
}
.dash-promo-pill:active { transform: scale(.97); }
.dash-section-head:has(.dash-promo-pill) { align-items: center; padding-top: 10px; padding-bottom: 10px; }
/* Second header line in the channel table: where the column's number comes from. Ours (Salsify,
   Promo Plan) is tinted so it parts from what was read off the retailer's page. */
.dash-th-src { display: block; font-size: 10px; font-weight: 400; color: var(--faint); margin-top: 1px; }
.dash-th-src.is-ours { color: var(--blue-fg); }
/* A header with a tooltip behind it: dotted underline, the app's cue for "hover for more". */
.dash-th-help { text-decoration: underline dotted var(--faint); text-underline-offset: 3px; cursor: help; }
.dash-info { width: 22px; height: 22px; border-radius: 6px; }
.dash-src-tag {
  display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px;
  background: var(--surface-2); color: var(--muted-2); border: 1px solid var(--line);
}
.dash-src-tag.is-plan { background: var(--blue-bg); color: var(--blue-fg); border-color: transparent; }
/* The sort button (main.js makeTableSort) is a one-line inline-flex; with a source line it stacks. */
#dash-table .th-sort:has(.dash-th-src) { flex-direction: column; }
