/**
 * app.css — hand-written "console" design system. No build step, no CDN,
 * no Tailwind (that pivot happened in W15 — see .claude/tasks/W15-design-console.md
 * for the full spec this file implements literally: dark-first, magenta accent,
 * 44x44 grid background, translucent blur panels, mono data typography, a
 * six-value status scale, sticky-shell layout, data-grid tables, one 820px
 * breakpoint).
 *
 * Rule for every component below: zero hardcoded colors. Everything reads a
 * `var(--...)` token from the "Tokens" section (dark values on `:root`,
 * light overrides under `[data-theme="light"]`). If a new component needs a
 * color that doesn't exist yet, add a token first, never inline a hex value
 * in a component rule.
 */

/* ==========================================================================
   1. Tokens
   ========================================================================== */
:root {
  color-scheme: dark;

  /* --- Layout constants -------------------------------------------------- */
  --sidebar-w: 232px;
  --topbar-h: 60px;
  --grid-cell: 44px;
  --breakpoint: 820px;
  --btn-h: 38px;
  --input-h: 40px;

  /* --- Radii / motion ------------------------------------------------------ */
  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 14px;
  --radius-full: 999px;
  --transition-fast: 120ms ease;
  --transition-base: 180ms ease;

  /* --- Typography ----------------------------------------------------------- */
  --font-ui: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Menlo, Consolas, monospace;

  /* --- Base surface (dark, default) ---------------------------------------- */
  --bg: #0e1116;
  --grid-line: rgba(255, 255, 255, 0.05);
  --surface: rgba(22, 27, 34, 0.72);
  --surface-solid: #161b22;
  --surface-raised: #1c2229;
  --border: rgba(255, 255, 255, 0.09);
  --border-strong: rgba(255, 255, 255, 0.16);
  --text: #e6edf3;
  --text-muted: #8b949e;
  --text-dim: #6e7681;

  --shadow-panel: 0 8px 30px rgba(0, 0, 0, 0.45);
  --shadow-raised: 0 2px 10px rgba(0, 0, 0, 0.35);
  /* Fixed per spec: the brand mark's shadow must actually read as a shadow —
     a 14%-alpha version is nearly invisible against the dark panel behind it. */
  --shadow-mark: 0 6px 16px rgba(224, 57, 158, 0.45);

  /* --- Brand accent: magenta gradient 140deg --------------------------------- */
  --accent-start: #e0399e;
  --accent-end: #8f0177;
  --accent: #e0399e;
  --accent-gradient: linear-gradient(140deg, var(--accent-start), var(--accent-end));
  --accent-soft: rgba(224, 57, 158, 0.14);
  --accent-ring: rgba(224, 57, 158, 0.45);
  --accent-contrast: #ffffff;

  /* --- Status scale: color is never the only signal (always paired with
     text in .pill / cov-bar legend items) ------------------------------------- */
  --status-done: #2ea043;
  --status-done-soft: rgba(46, 160, 67, 0.16);
  --status-pending: #d29922;
  --status-pending-soft: rgba(210, 153, 34, 0.16);
  --status-failed: #f85149;
  --status-failed-soft: rgba(248, 81, 73, 0.16);
  --status-processing: #58a6ff;
  --status-processing-soft: rgba(88, 166, 255, 0.16);
  --status-requested: #a371f7;
  --status-requested-soft: rgba(163, 113, 247, 0.16);
  --status-empty: #6e7681;
  --status-empty-soft: rgba(110, 118, 129, 0.16);

  /* --- Scrollbars: thin, translucent, same token family ---------------------- */
  --scrollbar-thumb: rgba(255, 255, 255, 0.16);
  --scrollbar-thumb-hover: rgba(255, 255, 255, 0.28);
  --scrollbar-track: transparent;
}

/* Light theme: same variable set, darker accent for contrast on white
   (per spec, #B3147A rather than the dark-theme #E0399E). */
[data-theme="light"] {
  color-scheme: light;

  --bg: #f3f4f6;
  --grid-line: rgba(15, 23, 42, 0.05);
  --surface: rgba(255, 255, 255, 0.78);
  --surface-solid: #ffffff;
  --surface-raised: #ffffff;
  --border: rgba(15, 23, 42, 0.09);
  --border-strong: rgba(15, 23, 42, 0.16);
  --text: #1b1f24;
  --text-muted: #57606a;
  --text-dim: #6e7681;

  --shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.1);
  --shadow-raised: 0 2px 8px rgba(15, 23, 42, 0.08);
  --shadow-mark: 0 6px 16px rgba(179, 20, 122, 0.35);

  --accent-start: #b3147a;
  --accent-end: #7a0f57;
  --accent: #b3147a;
  --accent-soft: rgba(179, 20, 122, 0.1);
  --accent-ring: rgba(179, 20, 122, 0.4);
  --accent-contrast: #ffffff;

  --scrollbar-thumb: rgba(15, 23, 42, 0.18);
  --scrollbar-thumb-hover: rgba(15, 23, 42, 0.3);
}

/* ==========================================================================
   2. Reset / base
   ========================================================================== */
*,
*::before,
*::after {
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

body {
  margin: 0;
  font-family: var(--font-ui);
  font-size: 14px;
  line-height: 1.5;
  color: var(--text);
  background-color: var(--bg);
  /* Fixed 44x44 grid, two linear-gradients instead of an image asset. */
  background-image: linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size: var(--grid-cell) var(--grid-cell);
  background-attachment: fixed;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img,
svg {
  display: block;
  max-width: 100%;
}

a {
  color: inherit;
}

button {
  font: inherit;
  color: inherit;
}

h1,
h2,
h3,
p {
  margin: 0;
}

/* `hidden` must actually hide. The UA sheet's `[hidden] { display: none }` is
   an author-vs-UA loss against any authored `display` — `.btn` is
   `inline-flex`, so `el.hidden = true` left the button on screen. Belongs to
   the base layer, not to the one component that tripped over it. */
[hidden] {
  display: none !important;
}

::selection {
  background: var(--accent-ring);
  color: var(--accent-contrast);
}

/* Visible, high-contrast focus ring everywhere — never `outline: none`
   without a replacement (docs/rules/41). */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* Respect the OS-level "reduce motion" preference (docs/rules/08 §2). This
   is also what disables the .pulse ping, toast slide-in, and save-flash
   animation, per the design spec. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Thin, translucent scrollbars everywhere (WebKit + Firefox). */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}

*::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}

*::-webkit-scrollbar-track {
  background: transparent;
}

*::-webkit-scrollbar-thumb {
  background-color: var(--scrollbar-thumb);
  border-radius: var(--radius-full);
  border: 2px solid transparent;
  background-clip: content-box;
}

*::-webkit-scrollbar-thumb:hover {
  background-color: var(--scrollbar-thumb-hover);
}

/* ==========================================================================
   3. Typography helpers
   ========================================================================== */
.mono {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.tabular-nums {
  font-variant-numeric: tabular-nums;
}

.section-title {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.stat-value {
  margin-top: 6px;
  font-size: 30px;
  font-weight: 600;
  letter-spacing: -1px;
  color: var(--text);
}

/* Percent change against the preceding period (analytics, W25). Colour is a
   second signal only: the sign is always printed (`+12.4%` / `-3.1%`) and the
   words "vs previous"/"unchanged" sit beside it, so the direction survives
   greyscale and colour-blindness (rule 08/41). No new tokens — the same
   done/failed palette the status badges already use. */
.delta {
  margin-top: 6px;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px;
  color: var(--text-muted);
}

.delta-up {
  color: var(--status-done);
}

.delta-down {
  color: var(--status-failed);
}

.delta-flat,
.delta-unknown {
  color: var(--text-dim);
}

.text-muted {
  color: var(--text-muted);
}

.text-dim {
  color: var(--text-dim);
}

.text-sm {
  font-size: 13px;
}

.text-xs {
  font-size: 12px;
}

.truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

/* An outbound destination link. Printed as "host · ASIN" with an arrow instead
   of the raw URL: inside a fixed-width grid cell the URL clipped to
   `https://www.ama…`, which identifies precisely nothing — every Amazon link
   looked the same. The full URL lives in `title` (W21). */
.ext-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  color: var(--text);
}

.ext-link:hover {
  color: var(--accent);
}

.ext-link .icon {
  flex: none;
  width: 12px;
  height: 12px;
  color: var(--text-dim);
}

.ext-link:hover .icon {
  color: var(--accent);
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Skip link (WCAG 2.4.1 "Bypass Blocks"). */
.skip-link {
  position: absolute;
  left: 12px;
  top: -48px;
  z-index: 100;
  padding: 10px 16px;
  border-radius: var(--radius-md);
  background: var(--accent-gradient);
  color: var(--accent-contrast);
  font-size: 13px;
  font-weight: 600;
  text-decoration: none;
  transition: top var(--transition-fast);
}

.skip-link:focus {
  top: 12px;
}

/* ==========================================================================
   4. Shell layout — sidebar 232px | 1fr, sticky topbar 60px
   ========================================================================== */
/* The shell is pinned to the viewport so `.main` is the *only* scroll
   container (one scrollbar, on the right). With `min-height` the `1fr` row
   grew past the viewport, the page scrolled on <body>, and `.main` never
   became a scroller — which silently broke every `position: sticky` inside it
   (a sticky element anchored to a scrollport that never moves simply never
   sticks). Sticky table headers/footers depend on this. */
.shell {
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  grid-template-rows: var(--topbar-h) 1fr;
  grid-template-areas:
    "sidebar topbar"
    "sidebar main";
  height: 100vh;
  overflow: hidden;
}

.sidebar {
  grid-area: sidebar;
  position: sticky;
  top: 0;
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  background: var(--surface);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border-right: 1px solid var(--border);
  z-index: 30;
}

.brand {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 16px;
  flex-shrink: 0;
}

.brand .mark {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: var(--radius-md);
  background: var(--accent-gradient);
  color: var(--accent-contrast);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 700;
  box-shadow: var(--shadow-mark);
}

.brand-name {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

.nav {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 10px;
  overflow-y: auto;
}

.nav-group {
  margin-top: 14px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}

.nav-group-label {
  padding: 0 12px 6px;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.nav-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border-radius: var(--radius-md);
  font-size: 13px;
  font-weight: 500;
  color: var(--text-muted);
  text-decoration: none;
  transition: background var(--transition-fast), color var(--transition-fast);
}

.nav-item:hover {
  background: var(--border);
  color: var(--text);
}

/* Active item: accent-soft fill + accent text, never a border alone. */
.nav-item.is-active {
  background: var(--accent-soft);
  color: var(--accent-start);
}

.nav-item .icon {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
}

.sidebar-foot {
  flex-shrink: 0;
  padding: 12px 16px;
  border-top: 1px solid var(--border);
  font-size: 11px;
}

.topbar {
  grid-area: topbar;
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  gap: 14px;
  /* A grid item defaults to `min-width: auto`, so a `1fr` track grows to the
     widest item's min-content — and this row's min-content is the sum of a
     no-wrap title, a select, the live indicator and the user block. Without
     this, one long pulse label stretched the whole shell wider than the
     viewport (measured: a 583px track at a 500px viewport), which shoved
     "Log out" off-screen with no scrollbar to reach it. Pinning the track to
     the viewport pushes the squeeze back onto the items that can absorb it
     (the title collapses, the select and the pulse label ellipsise). */
  min-width: 0;
  height: var(--topbar-h);
  padding: 0 20px;
  background: var(--surface);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border-bottom: 1px solid var(--border);
}

.topbar-title {
  flex: 1;
  min-width: 0;
  font-size: 15px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Account-scope switcher (W18). Owns its sizing instead of borrowing another
   feature's `.select-sm`: it must fit the 60px topbar next to the pulse and the
   user block, and a long "portfolio · niche" label must not stretch the row —
   hence the cap plus ellipsis on the closed control. */
.scope-select {
  width: auto;
  /* A `<select>` defaults to `min-width: auto` (content-sized) and so refuses
     to shrink, which on a narrow topbar means every pixel of the squeeze lands
     on its neighbours — the live indicator included (section 10). It already
     ellipsises, and its value is one click from being re-read, so it can carry
     its share. */
  min-width: 0;
  max-width: 220px;
  height: 32px;
  padding: 0 26px 0 10px;
  font-size: 12px;
  text-overflow: ellipsis;
}

/* The switcher form borrows `.cluster`, which wraps — and wrapping inside a
   fixed-height topbar pushes the "all accounts on this page" note out below
   the bar and over the content. The row must stay on one line; the note is
   what gets dropped first when there is no room for it. */
#account-scope-form {
  flex-wrap: nowrap;
  min-width: 0;
}

#account-scope-note {
  white-space: nowrap;
}

@media (max-width: 1100px) {
  #account-scope-note { display: none; }
}

/* Accent = "the list below is narrowed to this account". Set only on pages
   that really apply the scope (see `scope_applies`, apps/portfolios/context.py). */
.scope-select.is-scoped {
  border-color: var(--accent);
  color: var(--accent);
}

.topbar-user {
  display: flex;
  align-items: center;
  gap: 12px;
  padding-left: 14px;
  border-left: 1px solid var(--border);
}

.user-name {
  font-size: 12px;
}

/* No *vertical* padding on the scroller: a sticky child anchors to the
   scrollport's padding edge, so `padding-top` would park a sticky table header
   that far below the topbar (and a sticky footer that far above the bottom),
   leaving a gap with rows visibly scrolling through it. The vertical rhythm is
   re-added on the first/last child, which does not move those anchors. */
.main {
  grid-area: main;
  min-width: 0;
  overflow-y: auto;
  padding: 0 24px;
}

.main > *:first-child { margin-top: 24px; }
.main > *:last-child { margin-bottom: 24px; }

/* ==========================================================================
   5. Panels / cards
   ========================================================================== */
.card {
  background: var(--surface);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-panel);
  padding: 20px;
}

.card-title {
  margin-bottom: 14px;
  font-size: 13px;
  font-weight: 600;
  color: var(--text);
}

/* ==========================================================================
   6. Grid / stack utilities — the small hand-written set templates use in
      place of Tailwind's utility classes (kept intentionally short)
   ========================================================================== */
.grid-4 {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 16px;
}

.grid-2 {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px;
}

/* Three-up: the analytics "Overall performance" block is six cards, which reads
   as 3x2 (Pinterest's own layout) rather than 4+2 with a ragged second row. */
.grid-3 {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 16px;
}

.stack-6 > * + * {
  margin-top: 24px;
}

.stack-3 > * + * {
  margin-top: 12px;
}

/* Two lines that belong to one cell (pin title + its id/link in a data-grid
   row) — a column, not a cluster, so the second line wraps under the first. */
.stack-tight {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.cluster {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

/* The page-header shape — a title on the left, its actions on the right. It was
   written inline as `style="justify-content:space-between"` on nineteen
   clusters across the app, which is the definition of "actually repeats"
   (W21). */
.cluster-between {
  justify-content: space-between;
}

/* A title inside a cluster is a *row item*, not a block heading: its bottom
   margin would push the buttons beside it out of line. Every such heading
   carried `style="margin:0"` to say so; the rule says it once. */
.cluster > .card-title,
.cluster > .section-title {
  margin: 0;
}

/* The submit/reset row that closes a form panel (filters, horizon, settings).
   Separated from the last field by the same 16px the fields use between
   themselves. */
.form-actions {
  margin-top: 16px;
}

.list-rows {
  list-style: none;
  margin: 0;
  padding: 0;
}

.list-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}

.list-row:last-child {
  border-bottom: none;
}

/* Dashboard "Today" rows (W20): a fixed preview + a label that must truncate
   instead of shoving the row's action (Retry, time) off the card. `min-width:0`
   on the flex child is what actually lets `.truncate` inside it work. */
.list-row-lead {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
  flex: 1 1 auto;
}

.list-row-main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  flex: 1 1 auto;
}

.thumb-xs {
  width: 40px;
  height: 40px;
  flex-shrink: 0;
  display: block;
  border-radius: var(--radius-sm);
  object-fit: cover;
}

/* Placeholder for a pin whose image has not been generated yet — same box, so
   a list of half-generated pins does not jitter row to row. */
.thumb-empty {
  background: var(--surface-raised);
  border: 1px dashed var(--border-strong);
}

/* Contextual bulk bar (W20). Idle, it's a single line — the hint text sits
   next to the page title and "Import CSV", not a full-width card of its own.
   Armed, the action row (its own `.card`) appears below with the buttons;
   `[data-bulk-actions]`'s `hidden` attribute removes that whole card, not
   just its contents. No new colours: the "armed" state is a bold count
   instead of a muted hint, which reads in both themes and is not colour-only. */
.bulk-bar {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.bulk-bar-count {
  margin: 0;
  font-weight: 600;
  color: var(--text);
}

.bulk-bar.is-empty .bulk-bar-count {
  font-weight: 400;
  color: var(--text-muted);
}

/* ==========================================================================
   7. Buttons
   ========================================================================== */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  height: var(--btn-h);
  padding: 0 16px;
  border: none;
  border-radius: var(--radius-md);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: background var(--transition-fast), color var(--transition-fast),
    box-shadow var(--transition-fast);
}

.btn:disabled {
  pointer-events: none;
  opacity: 0.5;
}

.btn-primary {
  background: var(--accent-gradient);
  color: var(--accent-contrast);
  box-shadow: var(--shadow-raised);
}

.btn-primary:hover {
  filter: brightness(1.08);
}

.btn-secondary {
  background: var(--surface-raised);
  color: var(--text);
  border: 1px solid var(--border-strong);
}

.btn-secondary:hover {
  background: var(--border);
}

.btn-danger {
  background: var(--status-failed);
  color: var(--accent-contrast);
}

.btn-danger:hover {
  filter: brightness(1.1);
}

/* Ghost button for destructive actions that repeat once per table row. Red is
   a scarce signal: spent on every row it stops meaning "careful" and just makes
   the table shout. The weight moves to the confirm dialog instead, which is
   where the decision is actually taken. Both themes use --text-muted on the
   panel surface (>= 4.5:1 either way), with hover promoting to --text. */
.btn-quiet {
  background: transparent;
  color: var(--text-muted);
  border: 1px solid transparent;
}

.btn-quiet:hover {
  background: var(--surface-raised);
  color: var(--text);
  border-color: var(--border-strong);
}

.btn-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--btn-h);
  height: var(--btn-h);
  padding: 0;
  border: none;
  border-radius: var(--radius-md);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition: background var(--transition-fast), color var(--transition-fast);
}

.btn-icon:hover {
  background: var(--border);
  color: var(--text);
}

.btn-icon svg {
  width: 20px;
  height: 20px;
}

.btn.w-full {
  width: 100%;
}

/* Theme toggle: swap the two icons by the current [data-theme]. */
.theme-toggle .icon-sun {
  display: none;
}

.theme-toggle .icon-moon {
  display: block;
}

[data-theme="light"] .theme-toggle .icon-sun {
  display: block;
}

[data-theme="light"] .theme-toggle .icon-moon {
  display: none;
}

/* ==========================================================================
   8. Forms
   ========================================================================== */
.field {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.field + .field {
  margin-top: 16px;
}

/* ...but only when the fields are *stacked*. Inside a layout container the
   rhythm already comes from `gap`, and this margin then pushes every field
   after the first one 16px down — which is exactly why the filter rows read as
   a broken baseline ("Status" sitting a line above "Board"). The container owns
   the spacing; the stacking margin must not leak into it. */
.grid-2 > .field + .field,
.grid-3 > .field + .field,
.grid-4 > .field + .field,
.cluster > .field + .field,
.upload-bar > .field + .field {
  margin-top: 0;
}

.label {
  font-size: 13px;
  font-weight: 500;
  color: var(--text-muted);
}

/* NB: border-width and padding must be explicit and non-zero, or the field
   renders as an invisible sliver — a border *color* alone is not enough.
   Regression-guarded in tests/apps/test_auth_and_shell.py. */
.input {
  display: block;
  width: 100%;
  height: var(--input-h);
  padding: 0 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  color: var(--text);
  font-size: 13px;
  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}

.input::placeholder {
  color: var(--text-dim);
}

.input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-ring);
}

.input[aria-invalid="true"] {
  border-color: var(--status-failed);
}

/* A <textarea> must override the single-line `.input` height/padding — the
   fixed `height` + `padding: 0` jam the text against the top edge and forbid
   growth. Give it vertical padding, a multi-line min-height, and vertical
   resize so descriptions read naturally instead of top-aligned. */
textarea.input {
  height: auto;
  min-height: calc(var(--input-h) * 2.2);
  padding: 10px 12px;
  line-height: 1.5;
  resize: vertical;
}

/* A file input keeps the native control (the OS file picker, keyboard focus and
   screen-reader semantics all come free), but its *button* is restyled to match
   `.btn-secondary` — otherwise an upload field reads as a grey OS widget
   dropped into the console UI. The filename the browser prints next to the
   button is the field's real state, so it is left in place, dimmed. */
/* The height is pinned to `--input-h` rather than left to `auto`: the native
   button plus padding made this control 46px against a 40px `<select>` beside
   it, and `.upload-bar`'s bottom alignment turned those 6px into two labels on
   different lines. Same box as every other control -> one baseline. */
input[type="file"].input {
  display: flex;
  align-items: center;
  gap: 12px;
  height: var(--input-h);
  padding: 0 12px;
  color: var(--text-dim);
  font-size: 13px;
  cursor: pointer;
}

input[type="file"].input::file-selector-button {
  flex: none;
  height: calc(var(--btn-h) - 8px);
  margin: 0;
  padding: 0 14px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  color: var(--text);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background var(--transition-fast), border-color var(--transition-fast);
}

input[type="file"].input:hover::file-selector-button {
  background: var(--border);
}

/* Focus lands on the input itself, so the ring goes there (mirrors
   `.input:focus`) and the button gets the accent border as the visible cue. */
input[type="file"].input:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-ring);
}

input[type="file"].input:focus-visible::file-selector-button {
  border-color: var(--accent);
}

.field-error {
  font-size: 12px;
  color: var(--status-failed);
}

.alert {
  padding: 12px 14px;
  border-radius: var(--radius-md);
  border: 1px solid var(--status-failed);
  background: var(--status-failed-soft);
  color: var(--status-failed);
  font-size: 13px;
}

/* A standing *state* banner, not an error: "N pins are drafts", "publishing is
   paused". Same box, `pending` palette — red would train the operator to read a
   normal, expected condition as something broken. */
.alert.is-notice {
  border-color: var(--status-pending);
  background: var(--status-pending-soft);
  color: var(--text);
}
.alert.is-notice strong { color: var(--status-pending); }

/* --- Switch (toggle) — a native checkbox underneath carries keyboard and
   screen-reader semantics; track/thumb on top are purely visual. ------------ */
.switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
}

.switch input[type="checkbox"] {
  position: absolute;
  inset: 0;
  width: 38px;
  height: 22px;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.switch .track {
  position: relative;
  width: 38px;
  height: 22px;
  border-radius: var(--radius-full);
  background: var(--border-strong);
  transition: background var(--transition-fast), box-shadow var(--transition-base);
  flex-shrink: 0;
}

.switch .thumb {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--accent-contrast);
  transition: transform var(--transition-fast);
}

.switch input:checked ~ .track {
  background: var(--accent-gradient);
}

.switch input:checked ~ .track .thumb {
  transform: translateX(16px);
}

.switch input:focus-visible ~ .track {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Save-flash: a green ring confirming a toggle's new state was persisted,
   applied briefly by app.js on `change` — never the only feedback (the
   toggle's own position is the primary signal, this is a bonus cue). */
.switch.is-saved .track {
  box-shadow: 0 0 0 4px var(--status-done-soft);
}

.switch-label {
  font-size: 13px;
  color: var(--text);
}

/* ==========================================================================
   9. Status pills + .cov-bar
   ========================================================================== */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 10px 3px 8px;
  border-radius: var(--radius-full);
  font-size: 12px;
  font-weight: 600;
  line-height: 1.4;
  white-space: nowrap;
}

.pill-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
}

.pill[data-status="done"] {
  background: var(--status-done-soft);
  color: var(--status-done);
}

.pill[data-status="pending"] {
  background: var(--status-pending-soft);
  color: var(--status-pending);
}

.pill[data-status="failed"] {
  background: var(--status-failed-soft);
  color: var(--status-failed);
}

.pill[data-status="processing"] {
  background: var(--status-processing-soft);
  color: var(--status-processing);
}

.pill[data-status="requested"] {
  background: var(--status-requested-soft);
  color: var(--status-requested);
}

.pill[data-status="empty"] {
  background: var(--status-empty-soft);
  color: var(--status-empty);
}

.cov-bar {
  display: flex;
  width: 100%;
  height: 8px;
  border-radius: var(--radius-full);
  overflow: hidden;
  background: var(--border);
}

.cov-bar-seg {
  height: 100%;
}

.cov-bar-seg[data-status="done"] {
  background: var(--status-done);
}

.cov-bar-seg[data-status="pending"] {
  background: var(--status-pending);
}

.cov-bar-seg[data-status="failed"] {
  background: var(--status-failed);
}

.cov-bar-seg[data-status="processing"] {
  background: var(--status-processing);
}

.cov-bar-seg[data-status="requested"] {
  background: var(--status-requested);
}

.cov-bar-seg[data-status="empty"] {
  background: var(--status-empty);
}

.cov-bar-legend {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 12px 16px;
  margin: 12px 0 0;
  padding: 0;
}

.cov-bar-legend-item {
  display: flex;
  align-items: center;
  gap: 6px;
}

.cov-bar-legend-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}

.cov-bar-legend-dot[data-status="done"] {
  background: var(--status-done);
}

.cov-bar-legend-dot[data-status="pending"] {
  background: var(--status-pending);
}

.cov-bar-legend-dot[data-status="failed"] {
  background: var(--status-failed);
}

.cov-bar-legend-dot[data-status="processing"] {
  background: var(--status-processing);
}

.cov-bar-legend-dot[data-status="requested"] {
  background: var(--status-requested);
}

.cov-bar-legend-dot[data-status="empty"] {
  background: var(--status-empty);
}

/* ==========================================================================
   10. .pulse — coordinator/queue live indicator (topbar signature component)
   ========================================================================== */
/* The label became *dynamic* in W20 ("12 due now · next Aug 05 06:37"), which
   the original fixed-height pill could not survive: squeezed below its text
   width in the topbar flex row, the label wrapped to a second line (measured:
   36px of text in a 26px pill at a 560px viewport) and spilled out of the pill
   and past the bottom edge of the bar.

   The fix is "shrink gracefully", not "never shrink". `flex-shrink: 0` also
   stops the wrap, but it makes the pill push the topbar wider than the viewport
   instead — at 500px that shoved the Log out button off-screen, trading a
   visual bug for a functional one. So: the text never wraps (`nowrap`) and
   truncates under pressure (`ellipsis`), the pill is allowed to shrink to make
   that happen (`min-width: 0`, which a flex item does not get by default), and
   the height is a floor rather than a ceiling so nothing can overflow it. The
   dot keeps its own `flex-shrink: 0` below — at the narrowest widths the state
   colour survives even if the wording does not. */
.pulse {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  min-height: 26px;
  padding: 0 12px;
  border-radius: var(--radius-full);
  background: var(--surface-raised);
  border: 1px solid var(--border);
  font-size: 12px;
  font-weight: 500;
  color: var(--text-muted);
}

.pulse-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pulse-dot {
  position: relative;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--status-empty);
  flex-shrink: 0;
}

.pulse[data-state="idle"] .pulse-dot {
  background: var(--text-dim);
}

.pulse[data-state="run"] .pulse-dot {
  background: var(--status-done);
}

.pulse[data-state="error"] .pulse-dot {
  background: var(--status-failed);
}

/* The one decorative animation in the whole app, and it carries meaning: a
   pulsing ring only appears while the coordinator/queue is actually running.
   Disabled under prefers-reduced-motion (section 2, above). */
.pulse[data-state="run"] .pulse-dot::after {
  content: "";
  position: absolute;
  inset: -4px;
  border-radius: 50%;
  border: 2px solid var(--status-done);
  animation: pulse-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite;
}

@keyframes pulse-ping {
  0% {
    transform: scale(1);
    opacity: 0.7;
  }
  100% {
    transform: scale(2.4);
    opacity: 0;
  }
}

/* ==========================================================================
   11. Data-grid tables
   ========================================================================== */
/* No inner scroll: the table scrolls with the page (`.main`), so there is one
   scrollbar instead of two nested ones, and `thead` sticks under the topbar
   rather than to the top of a small inner box. `overflow: visible` also lets
   header filter popovers escape the table's bounds. Columns are
   `table-layout: fixed`, so wide content compresses instead of forcing a
   horizontal scrollbar. */
.table-wrap {
  position: relative;
  overflow: visible;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--surface);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
}

table.data-grid {
  width: 100%;
  min-width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
}

table.data-grid thead {
  position: sticky;
  top: 0;
  z-index: 2;
}

table.data-grid thead th {
  position: relative;
  padding: 10px 14px;
  text-align: left;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  /* The background belongs on the CELLS, not on <thead>: with
     `border-collapse: collapse` the browser paints no background for the
     row-group box, so a sticky header would stay see-through and the rows
     scrolling underneath would show through it. Opaque `--surface-solid`, not
     the translucent `--surface`, for the same reason. */
  background: var(--surface-solid);
  /* border-collapse hides a plain bottom border on a sticky <thead>; an inset
     box-shadow survives sticky positioning where a border doesn't. */
  box-shadow: inset 0 -1px 0 var(--border-strong);
}

table.data-grid tbody td {
  padding: 10px 14px;
  font-size: 13px;
  color: var(--text);
  border-bottom: 1px solid var(--border);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Date + time cells (Publish at / Added): date on its own line, time below
   it, instead of one long "Y-m-d H:i" string competing with the column's
   fixed width. */
.dt-stack {
  display: flex;
  flex-direction: column;
  line-height: 1.4;
}

.dt-time {
  color: var(--text-muted);
  font-size: 12px;
}

table.data-grid tbody tr:hover td {
  background: var(--accent-soft);
}

/* A row whose click opens something (edit modal, detail page). `data-row-click`
   is the HTMX-modal variant (data-grid rows); `data-row-href` is the plain-
   navigation variant (.file-row — see app.js). Both just need the pointer
   cursor; the click-vs-nested-control split is handled by the trigger filter
   (hx-trigger selector or the app.js click listener), not by CSS. */
table.data-grid tbody tr[data-row-click],
[data-row-href] {
  cursor: pointer;
}

table.data-grid tbody tr:last-child td {
  border-bottom: none;
}

table.data-grid .cell-empty {
  padding: 48px 14px;
}

/* Thumbnail hover-zoom (W26b) — a second, absolutely-positioned <img> (same
   URL, no extra request) blown up to ~200x200 on hover/focus. The specificity
   override below MUST match (or outrank) `table.data-grid tbody td` above —
   that rule sets `overflow: hidden`, which would otherwise clip the enlarged
   preview to the tiny thumbnail cell (the same trap `.th-filterable` below
   already documents for header popovers). */
table.data-grid tbody td.cell-thumb {
  overflow: visible;
  position: relative;
}

/* The checkbox column is narrower than the base cell padding leaves room for
   (40-44px minus 28px of padding), so the plain `overflow:hidden` +
   `text-overflow:ellipsis` cell rule above was clipping the checkbox itself
   and rendering a stray "…"-style dot next to it. Checkboxes never need
   ellipsis truncation, so both header and body cells opt out. */
table.data-grid thead th.cell-select,
table.data-grid tbody td.cell-select {
  overflow: visible;
  text-overflow: clip;
}

/* A cell that needs to stand out as urgent (e.g. "less than 7 days of
   scheduled pins left") — soft-red fill + red text, same tokens as
   `.alert`/`.field-error`. Color is never the only signal (rule 08): every
   caller pairs this with wording ("in 3d", "none scheduled"), not a bare
   colored number. */
table.data-grid tbody td.cell-warn {
  background: var(--status-failed-soft);
  color: var(--status-failed);
  font-weight: 600;
}

.thumb-zoom {
  position: relative;
  display: block;
}

.thumb {
  width: 32px;
  height: auto;
  border-radius: 4px;
  display: block;
}

.thumb-preview {
  display: none;
  position: absolute;
  top: -12px;
  left: 40px;
  /* An opaque, padded card around the enlarged image — not a bare `<img>` —
     so it reads as a deliberate zoomed preview floating above the table
     instead of a stray/broken thumbnail bleeding into the rows underneath. */
  width: 216px;
  height: 316px;
  box-sizing: border-box;
  padding: 8px;
  background: var(--surface-raised);
  border-radius: var(--radius-md);
  border: 1px solid var(--border-strong);
  box-shadow: var(--shadow-raised);
  /* Above the sticky thead (z-index 2) and .table-foot, below an open header
     popover (20) — a popover the operator opened on purpose outranks a
     preview they merely hovered past. */
  z-index: 15;
}

.thumb-preview img {
  display: block;
  width: 100%;
  height: 100%;
  /* Pin images are tall (roughly 2:3, matching this box) — `object-fit:
     contain` keeps the whole pin visible instead of `cover` cropping the
     top/bottom off, which used to hide exactly the title/CTA text that
     matters most. */
  object-fit: contain;
}

.thumb-zoom:hover .thumb-preview,
.thumb-zoom:focus-within .thumb-preview {
  display: block;
}

/* Column resize handle (JS-driven, static/js/app.js initColumnResize), width
   persisted to localStorage per table id. */
.col-resizer {
  position: absolute;
  top: 0;
  right: -4px;
  width: 8px;
  height: 100%;
  cursor: col-resize;
  z-index: 3;
  touch-action: none;
}

.col-resizer::after {
  content: "";
  position: absolute;
  top: 20%;
  bottom: 20%;
  left: 50%;
  width: 1px;
  background: var(--border-strong);
}

.col-resizer:hover::after,
.col-resizer.is-dragging::after {
  background: var(--accent);
  width: 2px;
}

/* Loading row: the first row of a data-grid while an async fetch is in
   flight (per spec, a "Loading…" row, not a full-table spinner overlay). */
.row-loading td {
  color: var(--text-muted);
  font-style: italic;
}

/* --------------------------------------------------------------------------
   Data-grid: sortable + type-aware filter header
   -------------------------------------------------------------------------- */

/* A filtering header cell holds a label row plus a control, so it opts out of
   the plain-label treatment above (single line, clipped, uppercased). Only the
   label keeps the uppercase look — placeholders and checkbox labels must read
   as normal text, and popovers must not be clipped by the cell.

   `table.data-grid thead th` (3 elements + 1 class) outranks a bare
   `.th-filterable` (1 element + 1 class) regardless of source order, so
   `overflow: visible` here never actually applied — the "ANY DATE" popover
   below has been silently clipped to the header row's own height since it
   was built. Unnoticed until now (2026-08-05) because a *native*
   `<input type=date>`'s own calendar flyout paints in the browser's own
   compositor layer, immune to page CSS entirely; the new hand-rolled
   date-picker (static/js/datepicker.js) is a plain DOM popover and made the
   pre-existing clip visible for the first time. Matching the table
   selector's specificity (not just the class) actually wins now. */
table.data-grid thead th.th-filterable {
  vertical-align: top;
  padding: 8px 10px;
  overflow: visible;
  white-space: normal;
  text-transform: none;
  letter-spacing: normal;
  font-weight: 400;
}

.th-head { display: flex; align-items: center; gap: 6px; white-space: nowrap; }

.th-label {
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.sort-btn {
  padding: 0 2px;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-dim);
  font: inherit;
  font-size: 11px;
  text-decoration: none;
  cursor: pointer;
}
.sort-btn:hover { color: var(--text); }
.sort-btn.is-sorted { color: var(--accent); }

/* Multi-select filter: a native <details> popover, so it works without JS. */
.th-filter { position: relative; margin-top: 6px; }

.th-filter > summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  height: 28px;
  padding: 0 8px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--text-dim);
  font-size: 12px;
  cursor: pointer;
  list-style: none;
}
.th-filter > summary::-webkit-details-marker { display: none; }
.th-filter[open] > summary { border-color: var(--accent); color: var(--text); }

.th-filter .popover {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  z-index: 20;
  min-width: 210px;
  padding: 10px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--surface-solid);
  box-shadow: var(--shadow-raised);
}

.th-filter .popover label {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 2px;
  font-size: 12px;
  font-weight: 400;
  color: var(--text);
  cursor: pointer;
}

.filter-input {
  width: 100%;
  height: 28px;
  margin-top: 6px;
  padding: 0 8px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--text);
  font: inherit;
  font-size: 12px;
}
.filter-input::placeholder { color: var(--text-dim); }
.filter-input:focus,
.th-filter > summary:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-ring);
}

/* Marks a column whose filter is narrowing the list right now. */
.filter-active-dot {
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
}

/* Table footer (rows-per-page + pagination) pinned to the bottom of the
   scrollport, so paging controls stay reachable in a long table. */
.table-foot {
  position: sticky;
  bottom: 0;
  z-index: 6;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 10px 14px;
  border: 1px solid var(--border);
  border-top: none;
  border-radius: 0 0 var(--radius-lg) var(--radius-lg);
  background: var(--surface-solid);
}

/* A table that carries a sticky footer squares off its own bottom corners so
   the two read as one panel. */
.table-wrap.has-foot { border-radius: var(--radius-lg) var(--radius-lg) 0 0; }

.select-sm {
  width: auto;
  height: 30px;
  padding: 0 26px 0 10px;
  font-size: 12px;
}

/* --------------------------------------------------------------------------
   Batch list (Bulk upload): account groups + one row per imported file
   -------------------------------------------------------------------------- */

.chips { display: inline-flex; gap: 2px; }
.chips a,
.chips button {
  display: inline-flex;
  align-items: center;
  height: 30px;
  padding: 0 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-dim);
  font: inherit;
  font-size: 12px;
  text-decoration: none;
  cursor: pointer;
}
.chips [aria-pressed="true"],
.chips .is-active {
  border-color: var(--accent);
  background: var(--surface-raised);
  color: var(--text);
}

.group {
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--surface);
}
.group + .group { margin-top: 10px; }

.group > summary {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  list-style: none;
}
.group > summary::-webkit-details-marker { display: none; }
.group > summary::before {
  content: "";
  width: 0;
  height: 0;
  border-left: 5px solid var(--text-dim);
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  transition: transform var(--transition-fast);
}
.group[open] > summary::before { transform: rotate(90deg); }
.group > summary:hover { background: var(--surface-raised); }
.group-body { padding: 0 6px 6px; }

.file-row {
  display: grid;
  grid-template-columns: 52px minmax(180px, 1.5fr) 84px minmax(170px, 1fr) 108px auto;
  gap: 14px;
  align-items: center;
  padding: 11px 12px;
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  color: inherit;
  text-decoration: none;
}
.file-row:hover { background: var(--surface-raised); border-color: var(--border); }
.file-name { font-size: 13px; font-weight: 600; }
.file-meta { font-size: 12px; color: var(--text-dim); }

.mini-bar {
  height: 4px;
  border-radius: 2px;
  background: var(--border);
  overflow: hidden;
}
.mini-bar > span { display: block; height: 100%; background: var(--status-done); }

/* Compact one-line import form: account | file | submit, labels aligned. */
.upload-bar {
  display: grid;
  /* Two fields (account, file) since the file button absorbed the old
     separate Import button — a leftover third `auto` track here had no item
     to size, so the flexible `1fr` next to it soaked up all the freed width
     and stretched the file button (a column-flex child) to fill it. */
  grid-template-columns: minmax(200px, 260px) minmax(220px, auto);
  gap: 12px;
  align-items: end;
}
/* The file button is a flex child of a *column*-direction `.field` (cross
   axis = horizontal), so it inherits `align-items: stretch` and fills the
   field's full width — and since no track here is `fr`, an `auto` track
   still absorbs the row's leftover space per the grid sizing algorithm, so
   widening `.field` itself doesn't help either. Only pinning the button's
   own cross-axis alignment keeps it sized to its content. */
.upload-bar .field > label.btn {
  align-self: flex-start;
}

.upload-bar .label {
  display: block;
  margin-bottom: 6px;
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.crumbs { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); }
.crumbs a { color: var(--text-dim); }
.crumbs a:hover { color: var(--text); }

/* ==========================================================================
   12. Pagination
   ========================================================================== */
.pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 4px 0;
  flex-wrap: wrap;
}

.pagination-count {
  font-size: 13px;
  color: var(--text-muted);
}

.pagination-actions {
  display: flex;
  gap: 8px;
}

/* ==========================================================================
   13. Empty state
   ========================================================================== */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 56px 24px;
  text-align: center;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-lg);
}

.empty-state .icon {
  width: 36px;
  height: 36px;
  color: var(--text-dim);
}

.empty-state-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--text);
}

.empty-state-message {
  max-width: 32em;
  font-size: 13px;
  color: var(--text-muted);
}

/* ==========================================================================
   14. Modal / confirm dialog (native <dialog>)
   ========================================================================== */
dialog.modal {
  display: flex;
  flex-direction: column;
  width: min(440px, calc(100vw - 32px));
  max-height: min(88vh, 760px);
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--surface-solid);
  color: var(--text);
  box-shadow: var(--shadow-panel);
}

/* Wider variant for content-heavy modals (pin edit) — the base 440px stays
   the default for confirm/small-form dialogs. */
dialog.modal.modal-lg {
  width: min(720px, calc(100vw - 32px));
  max-height: min(92vh, 860px);
}

dialog.modal::backdrop {
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(2px);
}

.modal-panel {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}

.modal-header,
.modal-footer {
  flex: 0 0 auto;
}

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 16px 20px;
  border-bottom: 1px solid var(--border);
}

.modal-header h2 {
  font-size: 14px;
  font-weight: 600;
}

.modal-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 18px 20px;
  font-size: 13px;
  color: var(--text-muted);
}

.modal-footer {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  padding: 14px 20px;
  border-top: 1px solid var(--border);
}

.modal-close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-dim);
  cursor: pointer;
}

.modal-close:hover {
  background: var(--border);
  color: var(--text);
}

/* Uniform rhythm between mixed row types (grid rows, standalone .field
   rows, the "Advanced" <details>) inside the pin-edit modal's compact
   layout. `.field + .field` only fires between two sibling .field elements,
   not between a .grid-*/details wrapper and the next row — this owns that
   gap instead. Deliberately tighter than the usual 16px stacking margin:
   with 8 rows in this form, every extra px here is the difference between
   fitting the viewport and needing the `.modal-body` scroll fallback. */
.modal-field-stack > * + * {
  margin-top: 12px;
}

/* Pin thumbnail + read-only Pinterest IDs side by side, instead of a large
   image stacked above a list — the identity strip at the top of the pin-edit
   modal. */
.pin-preview-row {
  display: flex;
  gap: 14px;
  margin-bottom: 12px;
}

.pin-preview-thumb {
  width: 80px;
  height: 112px;
  flex-shrink: 0;
  object-fit: contain;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
}

.pin-preview-meta {
  flex: 1 1 auto;
  min-width: 0;
}

/* ==========================================================================
   15. Toast — bottom-right stack, colored left border carries ok/err
       meaning, always paired with the message text (never color alone)
   ========================================================================== */
#toast-stack {
  position: fixed;
  right: 16px;
  bottom: 16px;
  left: 16px;
  z-index: 60;
  display: flex;
  flex-direction: column-reverse;
  align-items: flex-end;
  gap: 10px;
  pointer-events: none;
}

.toast {
  pointer-events: auto;
  display: flex;
  align-items: flex-start;
  gap: 10px;
  width: 100%;
  max-width: 360px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-left: 4px solid var(--status-empty);
  border-radius: var(--radius-md);
  background: var(--surface-solid);
  box-shadow: var(--shadow-panel);
  font-size: 13px;
  color: var(--text);
  animation: toast-in var(--transition-base);
}

.toast[data-variant="ok"] {
  border-left-color: var(--status-done);
}

.toast[data-variant="err"] {
  border-left-color: var(--status-failed);
}

.toast p {
  flex: 1;
}

.toast .js-toast-dismiss {
  flex-shrink: 0;
  display: inline-flex;
  width: 20px;
  height: 20px;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-dim);
  cursor: pointer;
}

.toast .js-toast-dismiss:hover {
  color: var(--text);
}

@keyframes toast-in {
  from {
    transform: translateY(8px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

/* ==========================================================================
   16. Login page
   ========================================================================== */
.auth-shell {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}

.auth-card {
  width: 100%;
  max-width: 360px;
}

.auth-brand {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  text-align: center;
  margin-bottom: 28px;
}

.auth-brand .mark {
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-lg);
  background: var(--accent-gradient);
  color: var(--accent-contrast);
  font-family: var(--font-mono);
  font-size: 15px;
  font-weight: 700;
  box-shadow: var(--shadow-mark);
}

.auth-brand h1 {
  font-size: 16px;
  font-weight: 600;
}

.auth-brand p {
  font-size: 13px;
  color: var(--text-muted);
}

/* ==========================================================================
   17. Single breakpoint: 820px
   ========================================================================== */
@media (max-width: 820px) {
  .shell {
    grid-template-columns: 1fr;
    grid-template-rows: var(--topbar-h) auto 1fr;
    grid-template-areas:
      "topbar"
      "sidebar"
      "main";
  }

  .sidebar {
    position: static;
    height: auto;
    flex-direction: row;
    align-items: center;
    border-right: none;
    border-bottom: 1px solid var(--border);
    overflow-x: auto;
  }

  .brand {
    flex-shrink: 0;
  }

  .nav {
    flex-direction: row;
    align-items: center;
    padding: 8px;
    overflow-x: auto;
    overflow-y: visible;
  }

  .nav-group {
    margin-top: 0;
    padding-top: 0;
    border-top: none;
    padding-left: 10px;
    border-left: 1px solid var(--border);
    display: flex;
    align-items: center;
    gap: 2px;
  }

  .nav-group-label {
    padding: 0 8px 0 0;
  }

  .nav-item .label {
    white-space: nowrap;
  }

  .sidebar-foot {
    display: none;
  }

  .main {
    padding: 16px;
  }

  .grid-4,
  .grid-3 {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .grid-2 {
    grid-template-columns: minmax(0, 1fr);
  }

  #toast-stack {
    right: 12px;
    bottom: 12px;
    left: 12px;
  }

  .toast {
    max-width: none;
  }
}

/* ==========================================================================
   Custom date/datetime picker (static/js/datepicker.js)
   ==========================================================================
   Every native `input[type=date]`/`[type=datetime-local]` on a page is
   progressively replaced by this: the native input still holds the real
   value and still submits with the form (JS only sets its `.value` and
   hides it), but its *displayed* format is no longer at the mercy of the
   browser/OS UI language — Chromium ties the native control's rendering to
   that, not to the page's `lang`, so a Ukrainian-locale browser showed
   `dd.mm.yyyy` regardless (2026-08-05). This widget renders its own English
   month/day names, so the display is the same for every operator.
   ========================================================================== */

.date-field-btn {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  height: var(--input-h);
  padding: 0 10px 0 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  color: var(--text);
  font: inherit;
  font-size: 13px;
  cursor: pointer;
  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.date-field-btn:hover { border-color: var(--border); }
.date-field-btn:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-ring);
}
.date-field-btn .date-field-text.is-empty { color: var(--text-dim); }
.date-field-btn svg { width: 16px; height: 16px; flex: none; color: var(--text-dim); }

/* Small variant for filter popovers (bulk_upload_file.html) — matches
   `.filter-input`'s 28px row instead of a full-size `.input`. */
.date-field-btn.date-field-btn-sm {
  height: 28px;
  padding: 0 6px 0 8px;
  font-size: 12px;
  margin-top: 6px;
}

.date-picker {
  position: fixed;
  z-index: 200;
  width: 264px;
  padding: 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--surface-solid);
  box-shadow: var(--shadow-raised);
  display: none;
}
.date-picker.is-open { display: block; }

.date-picker-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
}
.date-picker-header .date-picker-label {
  font-size: 13px;
  font-weight: 600;
}
.date-picker-header button {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-dim);
  cursor: pointer;
}
.date-picker-header button:hover { background: var(--border); color: var(--text); }
.date-picker-header svg { width: 16px; height: 16px; }

.date-picker-weekdays,
.date-picker-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 2px;
}
.date-picker-weekdays {
  margin-bottom: 4px;
  font-size: 11px;
  color: var(--text-dim);
  text-align: center;
}
.date-picker-day {
  height: 30px;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  font-size: 12px;
  cursor: pointer;
}
.date-picker-day:hover { background: var(--border); }
.date-picker-day:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--accent-ring);
}
.date-picker-day.is-outside { color: var(--text-dim); }
.date-picker-day.is-today { box-shadow: inset 0 0 0 1px var(--border-strong); }
.date-picker-day.is-selected { background: var(--accent-gradient); color: var(--accent-contrast); }

.date-picker-time {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}
.date-picker-time label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: var(--text-dim);
}
.date-picker-time input {
  width: 48px;
  height: 28px;
  padding: 0 6px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--text);
  font: inherit;
  font-size: 12px;
  text-align: center;
}

.date-picker-footer {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  margin-top: 10px;
}
.date-picker-footer .btn { height: 28px; padding: 0 10px; font-size: 12px; }

/* ==========================================================================
   Code blocks with tabs (templates/partials/_code_block.html)
   ==========================================================================
   Copyable code samples on the API/MCP docs pages (templates/web/docs_api.html,
   docs_mcp.html) — one `.code-block` per example, one or more `.code-panel`s
   switched by `.code-tab` buttons, a `.code-copy` button copying whichever
   panel is currently visible. Switching/copying is static/js/app.js, scoped
   via `closest(".code-block")` — no per-block ids needed, so a page can hold
   any number of these.
   ========================================================================== */
.code-block {
  position: relative;
}

.code-tabs {
  display: flex;
  align-items: center;
  gap: 4px;
  margin-bottom: 6px;
}

.code-tab {
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  padding: 4px 2px;
  margin-right: 12px;
  font-size: 12px;
  font-weight: 600;
  color: var(--text-muted);
  cursor: pointer;
  transition: color var(--transition-fast), border-color var(--transition-fast);
}

.code-tab:hover {
  color: var(--text);
}

/* Same accent language as `.nav-item.is-active` (accent text, no fill here —
   a fill would fight the underline for "this one is selected"). */
.code-tab.is-active {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

.code-tabs .code-copy {
  margin-left: auto;
}

.code-panel {
  margin: 0;
  overflow-x: auto;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 12px 14px;
  font-size: 12px;
  line-height: 1.6;
  white-space: pre;
}

.code-panel[hidden] {
  display: none;
}
