/* ============================================================================
   Jumpking Invoice Studio: application design system
   ----------------------------------------------------------------------------
   One accent, semantic tokens only, real light and dark themes.
   Nothing in here styles the invoice paper itself: that lives in invoice.css
   so the document keeps its own typography independent of the app chrome.
   ========================================================================== */

/* ── Reset ───────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
  min-height: 100dvh;
  font-family: var(--font-ui);
  font-size: 15px;
  line-height: 1.55;
  color: var(--text);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}
svg, video, canvas { display: block; max-width: 100%; }
/* Not display:block. See assets/js/studio/pdf.js, buildDigitalPdf: that one
   declaration silently moved every glyph in every generated PDF. */
img { max-width: 100%; vertical-align: middle; }
button, input, select, textarea { font: inherit; color: inherit; }
button { background: none; border: 0; }
a { color: inherit; }
[hidden] { display: none !important; }

/* ── Tokens: dark is the default surface ─────────────────────────────────── */
:root {
  /* Which build of this stylesheet is actually loaded.
     Not for styling anything: it is here so the app can read back the version
     of the CSS the browser is really using and compare it with the version on
     the server. Three rounds of "that change did not work" turned out to be
     one stale stylesheet, and there was no way to tell from the screen.
     Rewritten by the build script; never edit by hand. */
  --css-build: "6cedb32f";

  /* Manrope first, then the platform face. On a Mac and an iPhone that is
     SF Pro, which is what the owner asked for and is already installed. */
  --font-ui: 'Manrope', -apple-system, BlinkMacSystemFont, 'SF Pro Text',
             ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
  --font-display: 'Outfit', 'Manrope', -apple-system, BlinkMacSystemFont, sans-serif;
  --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Mono', Menlo, monospace;

  /* Surfaces, from furthest back to nearest front */
  --bg:        #0a0d13;
  --surface:   #10141d;
  --surface-2: #161b26;
  --surface-3: #1d2431;
  --overlay:   rgba(4, 6, 10, 0.72);
  /* Translucent stand-ins for what used to be color-mix(). html2canvas walks
     every computed style in the cloned document and throws on a colour
     function it cannot parse, which killed the PDF render outright. */
  --surface-veil: rgba(16, 20, 29, 0.94);
  --bg-veil:      rgba(10, 13, 19, 0.80);

  /* Text */
  --text:    #e9edf5;
  --text-2:  #a3adc0;
  --text-3:  #6b7688;
  --text-on-accent: #1a1204;

  /* Lines */
  --line:   rgba(255, 255, 255, 0.09);
  --line-2: rgba(255, 255, 255, 0.15);

  /* The three house colours, named once so nothing has to guess at them.
     They are the ring colours of the mark, outside in. */
  --brand-red:  #e4002b;
  --brand-gold: #e8a317;
  --brand-ink:  #101010;

  /* Accent: the gold ring. Red is kept for the mark and for emphasis on the
     invoice, never for interface chrome, where it would read as an error. */
  --accent:      #e8a317;
  --accent-hover:#f2b437;
  --accent-soft: rgba(232, 163, 23, 0.14);
  --accent-line: rgba(232, 163, 23, 0.34);
  --accent-text: #f0bb4d;
  --accent-deep: #8a5f06;

  /* Status */
  --ok:      #3ecf8e;  --ok-soft:   rgba(62, 207, 142, 0.14);   --ok-line:   rgba(62, 207, 142, 0.30);
  --warn:    #f0b429;  --warn-soft: rgba(240, 180, 41, 0.14);   --warn-line: rgba(240, 180, 41, 0.30);
  --danger:  #ff6b6b;  --danger-soft: rgba(255, 107, 107, 0.14);--danger-line: rgba(255, 107, 107, 0.32);
  --info:    #62a8ff;  --info-soft: rgba(98, 168, 255, 0.14);   --info-line: rgba(98, 168, 255, 0.30);
  --violet:  #a78bfa;  --violet-soft: rgba(167, 139, 250, 0.14);--violet-line: rgba(167, 139, 250, 0.30);

  /* Chart series, distinguishable without relying on hue alone */
  --c1: #d4a94e; --c2: #62a8ff; --c3: #3ecf8e; --c4: #a78bfa; --c5: #ff8f6b; --c6: #4fd1c5;

  /* Spacing: a 4px rhythm */
  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px;
  --s5: 20px; --s6: 24px; --s7: 32px; --s8: 40px; --s9: 56px;

  /* Radii */
  --r-sm: 6px; --r: 10px; --r-lg: 14px; --r-xl: 20px; --r-full: 999px;

  /* Elevation */
  --sh-1: 0 1px 2px rgba(0, 0, 0, 0.35);
  --sh-2: 0 4px 16px rgba(0, 0, 0, 0.40);
  --sh-3: 0 18px 50px rgba(0, 0, 0, 0.55);

  /* Motion */
  --dur-fast: 130ms;
  --dur: 200ms;
  --dur-slow: 320ms;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-in:  cubic-bezier(0.55, 0, 1, 0.45);

  /* Layering */
  --z-nav: 40; --z-sticky: 60; --z-drawer: 80; --z-modal: 100; --z-toast: 120; --z-palette: 140;

  /* Layout */
  --sidebar-w: 248px;
  --sidebar-w-collapsed: 68px;
  --topbar-h: 56px;
  --bottomnav-h: calc(60px + env(safe-area-inset-bottom, 0px));

  color-scheme: dark;
}

/* Light theme, designed rather than inverted */
:root[data-theme="light"] {
  --bg:        #f4f5f8;
  --surface:   #ffffff;
  --surface-2: #f7f8fb;
  --surface-3: #eef0f5;
  --overlay:   rgba(16, 20, 29, 0.42);

  --text:    #131720;
  --text-2:  #515c6e;
  --text-3:  #7d8798;
  --text-on-accent: #241906;

  --line:   rgba(19, 23, 32, 0.10);
  --line-2: rgba(19, 23, 32, 0.18);

  --accent:      #b87708;
  --accent-hover:#a06806;
  --accent-soft: rgba(184, 119, 8, 0.11);
  --accent-line: rgba(184, 119, 8, 0.30);
  --accent-text: #8f5c05;
  --accent-deep: #6b4404;
  --surface-veil: rgba(255, 255, 255, 0.94);
  --bg-veil: rgba(245, 246, 248, 0.80);

  --ok:      #0f8a56;  --ok-soft:   rgba(15, 138, 86, 0.10);    --ok-line:   rgba(15, 138, 86, 0.26);
  --warn:    #9a6a00;  --warn-soft: rgba(154, 106, 0, 0.10);    --warn-line: rgba(154, 106, 0, 0.26);
  --danger:  #c22c2c;  --danger-soft: rgba(194, 44, 44, 0.10);  --danger-line: rgba(194, 44, 44, 0.26);
  --info:    #1f5fbf;  --info-soft: rgba(31, 95, 191, 0.10);    --info-line: rgba(31, 95, 191, 0.26);
  --violet:  #6d47d9;  --violet-soft: rgba(109, 71, 217, 0.10); --violet-line: rgba(109, 71, 217, 0.26);

  --c1: #8a6512; --c2: #1f5fbf; --c3: #0f8a56; --c4: #6d47d9; --c5: #c2542c; --c6: #0e8a80;

  --sh-1: 0 1px 2px rgba(19, 23, 32, 0.07);
  --sh-2: 0 4px 16px rgba(19, 23, 32, 0.09);
  --sh-3: 0 18px 50px rgba(19, 23, 32, 0.16);

  color-scheme: light;
}

/* Follow the operating system when the user has not chosen explicitly */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]):not([data-theme="light"]) {
    --bg: #f4f5f8; --surface: #ffffff; --surface-2: #f7f8fb; --surface-3: #eef0f5;
    --overlay: rgba(16, 20, 29, 0.42);
    --text: #131720; --text-2: #515c6e; --text-3: #7d8798; --text-on-accent: #241906;
    --line: rgba(19, 23, 32, 0.10); --line-2: rgba(19, 23, 32, 0.18);
    --accent: #b87708; --accent-hover: #a06806; --accent-soft: rgba(184, 119, 8, 0.11);
    --accent-line: rgba(184, 119, 8, 0.30); --accent-text: #8f5c05;
    --ok: #0f8a56; --ok-soft: rgba(15,138,86,.10); --ok-line: rgba(15,138,86,.26);
    --warn: #9a6a00; --warn-soft: rgba(154,106,0,.10); --warn-line: rgba(154,106,0,.26);
    --danger: #c22c2c; --danger-soft: rgba(194,44,44,.10); --danger-line: rgba(194,44,44,.26);
    --info: #1f5fbf; --info-soft: rgba(31,95,191,.10); --info-line: rgba(31,95,191,.26);
    --violet: #6d47d9; --violet-soft: rgba(109,71,217,.10); --violet-line: rgba(109,71,217,.26);
    --c1: #8a6512; --c2: #1f5fbf; --c3: #0f8a56; --c4: #6d47d9; --c5: #c2542c; --c6: #0e8a80;
    --sh-1: 0 1px 2px rgba(19,23,32,.07);
    --sh-2: 0 4px 16px rgba(19,23,32,.09);
    --sh-3: 0 18px 50px rgba(19,23,32,.16);
    color-scheme: light;
  }
}

/* ── Typography scale ────────────────────────────────────────────────────── */
h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-weight: 600; line-height: 1.25; letter-spacing: -0.02em;
}
h1 { font-size: 26px; }
h2 { font-size: 20px; }
h3 { font-size: 16px; }
h4 { font-size: 14px; }

.t-display { font-size: 32px; font-weight: 700; letter-spacing: -0.025em; line-height: 1.15; }
.t-lead    { font-size: 16px; color: var(--text-2); }
.t-sm      { font-size: 13px; }
.t-xs      { font-size: 12px; }
.t-muted   { color: var(--text-2); }
.t-faint   { color: var(--text-3); }
/* Used in several screens already and defined nowhere, so an error read
   in the same grey as everything around it. */
.t-danger  { color: var(--danger); }
.t-warn    { color: var(--warn); }
.t-ok      { color: var(--ok); }
.t-num     { font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }
.t-mono    { font-family: var(--font-mono); font-size: 0.92em; }

.eyebrow {
  font-size: 11px; font-weight: 600; letter-spacing: 0.09em;
  text-transform: uppercase; color: var(--text-3);
}

/* ── Focus, selection, scrollbars ────────────────────────────────────────── */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}
/* Full strength, not the 14 per cent tint --accent-soft carries. Selected
   text was almost invisible against the dark ground, which made it impossible
   to tell whether a drag had caught anything. */
::selection { background: var(--accent); color: var(--text-on-accent); }

* { scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: var(--r-full); border: 3px solid transparent; background-clip: content-box; }
*::-webkit-scrollbar-thumb:hover { background: var(--text-3); background-clip: content-box; }

.skip-link {
  position: absolute; left: var(--s4); top: -60px; z-index: var(--z-toast);
  background: var(--accent); color: var(--text-on-accent);
  padding: var(--s3) var(--s5); border-radius: var(--r); font-weight: 600;
  transition: top var(--dur) var(--ease-out);
}
.skip-link:focus { top: var(--s4); }

/* ── Icons ───────────────────────────────────────────────────────────────── */
.icon { width: 20px; height: 20px; flex: none; stroke-width: 1.75; }

/* The theme button carries both a moon and a sun and shows one. This was done
   in JS, so between first paint and app.js running both were in the button.
   Two 20px icons and a gap do not fit a 40px icon button with no padding, and
   the moon sat off to the left until the script caught up. The current theme
   is already on :root, so CSS can decide, correctly, before any script runs. */
.theme-icon-light { display: none; }
:root[data-theme="light"] .theme-icon-dark  { display: none; }
:root[data-theme="light"] .theme-icon-light { display: block; }
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]):not([data-theme="light"]) .theme-icon-dark  { display: none; }
  :root:not([data-theme="dark"]):not([data-theme="light"]) .theme-icon-light { display: block; }
}
.icon-sm { width: 16px; height: 16px; }
.icon-lg { width: 24px; height: 24px; }

/* ── App shell ───────────────────────────────────────────────────────────── */
/* minmax(0, 1fr), never a bare 1fr.
 *
 * A bare 1fr is minmax(auto, 1fr), and that auto minimum means the column
 * refuses to become narrower than the widest thing inside it. One nowrap strip
 * of chips, one table carrying min-width: 640px, one long unbroken token, and
 * the column grows, the shell grows with it, and the whole app ends up wider
 * than the phone: the top bar cut off at the right, cards running past the
 * edge, the page sliding sideways under a finger.
 *
 * The zero minimum lets the column be as narrow as the screen and hands the
 * overflow back to whichever child actually has it, which is where it can be
 * dealt with by scrolling that one element instead of the document. */
.app {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: var(--topbar-h) 1fr;
  grid-template-areas: 'topbar' 'main';
  min-height: calc(var(--vh) * 100);
}
@media (min-width: 1024px) {
  .app {
    grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
    grid-template-areas: 'sidebar topbar' 'sidebar main';
  }
  .app[data-rail="1"] { grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr); }
}

/* Topbar */
.topbar {
  grid-area: topbar; min-width: 0;
  position: sticky; top: 0; z-index: var(--z-sticky);
  display: flex; align-items: center; gap: var(--s3);
  height: var(--topbar-h); padding: 0 var(--s4);
  background: var(--surface-veil);
  backdrop-filter: saturate(1.4) blur(12px);
  border-bottom: 1px solid var(--line);
}
.topbar-title { font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
.topbar-sub { font-size: 12px; color: var(--text-3); }
.topbar-spacer { flex: 1; }
.topbar-actions { display: flex; align-items: center; gap: var(--s2); }

/* The app's mark in the bar, phones only. */
.topbar-brand { flex: none; display: grid; place-items: center; text-decoration: none; }
.topbar-brand svg { display: block; }

/* A phone's bar was the desktop's, at 56px, which is shorter than the buttons
   standing in it: the New invoice button reached the bar's edge top and bottom
   with nothing around it. Taller, and with room above for a notch, which the
   bar is the only thing on the page tall enough to have to think about. */
@media (max-width: 1023px) {
  :root { --topbar-h: 66px; }
  .app { grid-template-rows: calc(var(--topbar-h) + env(safe-area-inset-top, 0px)) 1fr; }
  .topbar {
    height: calc(var(--topbar-h) + env(safe-area-inset-top, 0px));
    padding-top: env(safe-area-inset-top, 0px);
    padding-left: var(--s3); padding-right: var(--s3);
    gap: var(--s2);
  }
  .topbar-title { font-size: 16px; }
}

/* Sidebar
 *
 * Three bands: the mark, the destinations, and the three buttons that are not
 * destinations. Only the middle one scrolls.
 *
 * The mark and the footer used to be sticky ends of a single scrolling column,
 * held in place with negative offsets because a sticky offset resolves against
 * the scrollport's content edge rather than its padding edge. It worked until
 * the navigation grew past the screen: the list then ran underneath the footer,
 * so the last group was sliced through the middle of a row and the two items
 * below it could not be reached however far the column was scrolled. A column
 * that does not scroll, holding a list that does, cannot do that. */
.sidebar {
  grid-area: sidebar; display: none;
  flex-direction: column;
  background: var(--surface);
  border-right: 1px solid var(--line);
  position: sticky; top: 0; height: calc(var(--vh) * 100); overflow: hidden;
}
@media (min-width: 1024px) { .sidebar { display: flex; } }

/* min-height:0 or a flex item refuses to shrink below its content and the
   overflow lands back on the column, which is the whole fault above. */
.sidebar-nav {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column; gap: var(--s2);
  padding: var(--s4) var(--s3);
  overscroll-behavior: contain;
}

/* Settings, Theme and Sign out, at the bottom of the column and never over
   the list. flex:none so a long navigation cannot squeeze them. */
.sidebar-foot {
  flex: none;
  background: var(--surface);
  padding: var(--s3) calc(var(--s3) * 2) var(--s4);
  border-top: 1px solid var(--line);
  display: flex; flex-direction: column; gap: 2px;
}

/* The mark, at the top of the column and out of the scrolling list. */
.brand {
  flex: none;
  display: flex; align-items: center; gap: var(--s3);
  text-decoration: none; min-width: 0;
  background: var(--surface);
  padding: calc(var(--s4) + var(--s2)) calc(var(--s3) * 2) var(--s4);
  border-bottom: 1px solid var(--line);
}
.brand-text { min-width: 0; }
.brand-mark {
  width: 32px; height: 32px; border-radius: 9px; flex: none;
  display: grid; place-items: center;
  background: linear-gradient(150deg, var(--accent), var(--accent-deep));
  color: var(--text-on-accent); font-weight: 800; font-size: 14px; letter-spacing: -0.02em;
}
/* The mark is artwork in its own right, so it gets no tile behind it. */
.brand-logo { flex: none; display: grid; place-items: center; }
.brand-logo svg { width: 34px; height: 34px; }
.app[data-rail="1"] .brand { justify-content: center; padding-left: 0; padding-right: 0; }

.brand-name { display: block; font-weight: 700; font-size: 14px; letter-spacing: -0.01em; line-height: 1.25; }
.brand-sub { display: block; font-size: 11px; color: var(--text-3); line-height: 1.3; }

.nav-group { display: flex; flex-direction: column; gap: 2px; }
.nav-group + .nav-group { margin-top: var(--s5); }
.nav-label { padding: 0 var(--s3) var(--s2); }

.nav-item {
  display: flex; align-items: center; gap: var(--s3);
  min-height: 40px; padding: 0 var(--s3);
  border-radius: var(--r); color: var(--text-2);
  font-size: 14px; font-weight: 500; cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
  text-decoration: none; width: 100%; text-align: left;
}
.nav-item:hover { background: var(--surface-2); color: var(--text); }
/* The page you are on, with a bar down its edge. The tint alone was too faint
   to find at a glance in a list of fifteen. */
.nav-item[aria-current="page"], .nav-item.is-active {
  background: var(--accent-soft); color: var(--accent-text); font-weight: 650;
  box-shadow: inset 3px 0 0 var(--accent);
}
.nav-item[aria-current="page"] .icon { color: var(--accent); }
.nav-item .badge-count { margin-left: auto; }

/* Collapsed to a rail: the three bands keep their full width, and it is the
   rows inside them that centre. align-items on the column itself would shrink
   the list and the footer to the width of their widest icon and leave the
   backgrounds short of the edges. */
.app[data-rail="1"] .sidebar-nav,
.app[data-rail="1"] .sidebar-foot { padding-left: var(--s2); padding-right: var(--s2); }
.app[data-rail="1"] .nav-item span:not(.badge-count),
.app[data-rail="1"] .brand-text,
.app[data-rail="1"] .nav-label { display: none; }
.app[data-rail="1"] .nav-item { justify-content: center; padding: 0; }

/* Main region */
.main { grid-area: main; min-width: 0; padding-bottom: var(--bottomnav-h); }
@media (min-width: 1024px) { .main { padding-bottom: 0; } }
.page { padding: var(--s5) var(--s4) var(--s8); max-width: 1440px; margin: 0 auto; }
@media (min-width: 768px) { .page { padding: var(--s6) var(--s6) var(--s9); } }

.page-head { display: flex; align-items: flex-start; gap: var(--s4); flex-wrap: wrap; margin-bottom: var(--s6); }
.page-head-text { min-width: 0; flex: 1; }
.page-head h1 { margin-bottom: 2px; }

/* Bottom navigation, phones only */
.bottomnav {
  position: fixed; inset: auto 0 0 0; z-index: var(--z-nav);
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  height: var(--bottomnav-h);
  padding-bottom: env(safe-area-inset-bottom, 0px);
  background: var(--surface-veil);
  backdrop-filter: blur(14px);
  border-top: 1px solid var(--line);
}
@media (min-width: 1024px) { .bottomnav { display: none; } }

.bnav-item {
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  min-height: 56px; color: var(--text-3); font-size: 11px; font-weight: 500;
  cursor: pointer; position: relative; touch-action: manipulation;
  transition: color var(--dur-fast) var(--ease-out);
}
.bnav-item.is-active { color: var(--accent); }
.bnav-item.is-active::before {
  content: ''; position: absolute; top: 0; left: 50%; transform: translateX(-50%);
  width: 26px; height: 2px; border-radius: 0 0 3px 3px; background: var(--accent);
}
.bnav-item:active { transform: scale(0.96); }

/* ── Buttons ─────────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s2);
  min-height: 40px; padding: 0 var(--s4);
  border-radius: var(--r); border: 1px solid var(--line-2);
  background: var(--surface-2); color: var(--text);
  font-size: 14px; font-weight: 550; letter-spacing: -0.005em;
  cursor: pointer; white-space: nowrap; text-decoration: none;
  touch-action: manipulation;
  transition: background var(--dur-fast) var(--ease-out),
              border-color var(--dur-fast) var(--ease-out),
              transform var(--dur-fast) var(--ease-out),
              opacity var(--dur-fast) var(--ease-out);
}
.btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--line-2); }
.btn:active:not(:disabled) { transform: scale(0.975); }
.btn:disabled { opacity: 0.42; cursor: not-allowed; }

.btn-primary {
  background: var(--accent); border-color: var(--accent); color: var(--text-on-accent); font-weight: 650;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }

.btn-ghost { background: transparent; border-color: transparent; color: var(--text-2); }
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }

.btn-outline { background: transparent; }
.btn-outline:hover:not(:disabled) { background: var(--surface-2); }

.btn-danger { background: var(--danger-soft); border-color: var(--danger-line); color: var(--danger); }
.btn-danger:hover:not(:disabled) { background: var(--danger-soft); }

.btn-ok { background: var(--ok-soft); border-color: var(--ok-line); color: var(--ok); }
.btn-ok:hover:not(:disabled) { background: var(--ok-soft); }

.btn-sm { min-height: 32px; padding: 0 var(--s3); font-size: 13px; border-radius: var(--r-sm); }
.btn-lg { min-height: 48px; padding: 0 var(--s6); font-size: 15px; }
.btn-block { width: 100%; }

/* Icon-only buttons still meet the 44px target through padding */
.btn-icon { min-width: 40px; padding: 0; }
.btn-icon.btn-sm { min-width: 32px; }
.btn-icon.btn-lg { min-width: 48px; }

.btn.is-loading { pointer-events: none; position: relative; color: transparent !important; }
.btn.is-loading::after {
  content: ''; position: absolute; width: 16px; height: 16px;
  border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%;
  color: var(--text); animation: spin 640ms linear infinite;
}
.btn-primary.is-loading::after { color: var(--text-on-accent); }
@keyframes spin { to { transform: rotate(360deg); } }

.btn-row { display: flex; gap: var(--s2); flex-wrap: wrap; align-items: center; }

/* Segmented control */
.segmented {
  display: inline-flex; padding: 3px; gap: 2px;
  background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r);
}
.segmented button {
  min-height: 32px; padding: 0 var(--s4); border-radius: var(--r-sm);
  font-size: 13px; font-weight: 550; color: var(--text-2); cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.segmented button:hover { color: var(--text); }
/* The chosen one is filled, not merely a shade lighter than the track it sits
   in. --surface on --surface-2 is a few per cent apart and did not read as a
   choice at all, which matters most here: exactly one of these is always on,
   so if none of them looks on, the control looks broken. */
.segmented button[aria-pressed="true"] {
  background: var(--accent); color: var(--text-on-accent);
  font-weight: 650; box-shadow: var(--sh-1);
}
.segmented button[aria-pressed="true"]:hover { color: var(--text-on-accent); }

/* ── Forms ───────────────────────────────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--s4); min-width: 0; }
.field > label, .field-label {
  font-size: 12px; font-weight: 600; color: var(--text-2); letter-spacing: 0.01em;
}
.field-hint { font-size: 12px; color: var(--text-3); }
.field-error { font-size: 12px; color: var(--danger); display: flex; align-items: center; gap: 5px; }
.req::after { content: '*'; color: var(--danger); margin-left: 3px; }

.input, .select, .textarea {
  width: 100%; min-height: 42px; padding: 9px var(--s3);
  background: var(--surface-2); color: var(--text);
  border: 1px solid var(--line-2); border-radius: var(--r);
  font-size: 15px; outline: none;
  transition: border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
}
@media (min-width: 768px) { .input, .select, .textarea { font-size: 14px; min-height: 38px; } }
.input::placeholder, .textarea::placeholder { color: var(--text-3); }
.input:hover, .select:hover, .textarea:hover { border-color: var(--line-2); background: var(--surface-3); }
.input:focus, .select:focus, .textarea:focus {
  border-color: var(--accent); background: var(--surface-2);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
/* A locked field: a saved value, read only until its padlock is clicked. It
   is still the field it was, just visibly not up for editing by accident. */
.input.is-locked, .textarea.is-locked, .select.is-locked {
  background: var(--surface); color: var(--text-2);
  border-style: dashed; cursor: default;
}
.input.is-locked:hover, .textarea.is-locked:hover { background: var(--surface); border-color: var(--line-2); }
.field label [data-unlock] { opacity: .6; line-height: 1; }
.field label [data-unlock]:hover { opacity: 1; }

.input[aria-invalid="true"], .textarea[aria-invalid="true"] {
  border-color: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft);
}
.input:disabled, .textarea:disabled, .select:disabled { opacity: 0.5; cursor: not-allowed; }
.textarea { min-height: 84px; resize: vertical; line-height: 1.6; }

.select {
  appearance: none; padding-right: var(--s7); cursor: pointer;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7688' 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 var(--s3) center;
}

/* ── Reach marks: WhatsApp and Gmail, one click from any number or address ──
   Read-only values carry .reach after the text. Fields carry the mark inside
   their right edge, wrapped by reach.js. WhatsApp's own green, because the
   mark is recognised by its colour before its shape. */
.reach { display: inline-flex; align-items: center; gap: 2px; vertical-align: middle; margin-left: 4px; }
.reach a, .reach-in {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; border-radius: var(--r-sm, 6px);
  color: var(--text-3); text-decoration: none;
  transition: color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
}
.reach a:hover, .reach-in:hover { background: var(--surface-2); }
.reach-wa:hover, .reach-wa:focus-visible { color: #25d366; }
.reach-mail:hover, .reach-mail:focus-visible { color: var(--accent); }
.reach a[hidden], .reach-in[hidden] { display: none; }

/* A value with its marks, on one line, and the next value under it. */
.reach-line { display: flex; align-items: center; gap: 2px; min-width: 0; }
.reach-line > .truncate { min-width: 0; }
.t-nowrap { white-space: nowrap; }

.reach-wrap { display: block; position: relative; width: 100%; min-width: 0; }
.input-group > .reach-wrap { flex: 1; }
.reach-wrap > .input.has-reach { padding-right: 40px; }
.reach-in {
  position: absolute; top: 50%; right: 5px; transform: translateY(-50%);
  width: 30px; height: 30px;
}
/* A touch target on a phone is the height of the field. */
@media (pointer: coarse) { .reach-in { width: 34px; height: 34px; right: 4px; } }

.input-group { display: flex; gap: var(--s2); align-items: center; }
.input-group > .input { flex: 1; min-width: 0; }

.input-icon { position: relative; }
.input-icon > .icon {
  position: absolute; left: var(--s3); top: 50%; transform: translateY(-50%);
  color: var(--text-3); pointer-events: none;
}
.input-icon > .input { padding-left: 40px; }

.grid-2 { display: grid; grid-template-columns: 1fr; gap: var(--s3); }
.grid-3 { display: grid; grid-template-columns: 1fr; gap: var(--s3); }
@media (min-width: 560px) {
  .grid-2 { grid-template-columns: 1fr 1fr; }
  .grid-3 { grid-template-columns: repeat(3, 1fr); }
}

/* Checkbox and switch */
.check { display: flex; align-items: center; gap: var(--s3); cursor: pointer; min-height: 44px; }
.check input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); flex: none; cursor: pointer; }
.check span { font-size: 14px; }

.switch { position: relative; display: inline-flex; align-items: center; gap: var(--s3); cursor: pointer; min-height: 44px; }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.switch-track {
  width: 42px; height: 24px; border-radius: var(--r-full); flex: none;
  background: var(--surface-3); border: 1px solid var(--line-2);
  transition: background var(--dur) var(--ease-out), border-color var(--dur) var(--ease-out);
  position: relative;
}
.switch-track::after {
  content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
  border-radius: 50%; background: var(--text-2);
  transition: transform var(--dur) var(--ease-out), background var(--dur) var(--ease-out);
}
.switch input:checked + .switch-track { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .switch-track::after { transform: translateX(18px); background: var(--text-on-accent); }
.switch input:focus-visible + .switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Colour input paired with a hex field */
.color-row { display: grid; grid-template-columns: 1fr 42px; gap: var(--s2); align-items: center; }
.color-swatch {
  width: 42px; height: 42px; padding: 0; border: 1px solid var(--line-2);
  border-radius: var(--r); background: none; cursor: pointer;
}
.color-swatch::-webkit-color-swatch-wrapper { padding: 3px; }
.color-swatch::-webkit-color-swatch { border: none; border-radius: 7px; }

/* ── Cards and panels ────────────────────────────────────────────────────── */
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-lg); overflow: hidden;
}
.card-pad { padding: var(--s5); }
.card-head {
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s4) var(--s5); border-bottom: 1px solid var(--line);
}
.card-head h3 { flex: 1; min-width: 0; }
.card-body { padding: var(--s5); }
.card-foot { padding: var(--s4) var(--s5); border-top: 1px solid var(--line); background: var(--surface-2); }

.card-link {
  display: block; text-decoration: none; cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}
.card-link:hover { border-color: var(--accent-line); }
.card-link:active { transform: scale(0.995); }

.stack { display: flex; flex-direction: column; gap: var(--s4); }
.stack-sm { display: flex; flex-direction: column; gap: var(--s2); }
.cluster { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.ml-auto { margin-left: auto; }
.row { display: flex; align-items: center; gap: var(--s3); }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }

.section-title {
  display: flex; align-items: center; gap: var(--s3);
  margin: var(--s7) 0 var(--s4);
}
.section-title:first-child { margin-top: 0; }
.section-title h2 { flex: none; }
.section-title .rule { flex: 1; height: 1px; background: var(--line); }

/* ── KPI tiles ───────────────────────────────────────────────────────────── */
.kpi-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--s3); }
@media (min-width: 768px) { .kpi-grid { grid-template-columns: repeat(4, 1fr); gap: var(--s4); } }

.kpi {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-lg); padding: var(--s4);
  display: flex; flex-direction: column; gap: var(--s2); min-width: 0;
}
.kpi-label { font-size: 12px; font-weight: 600; color: var(--text-3); letter-spacing: 0.02em; }
.kpi-value {
  font-size: 24px; font-weight: 700; letter-spacing: -0.025em; line-height: 1.1;
  font-variant-numeric: tabular-nums; overflow-wrap: anywhere;
}
@media (min-width: 768px) { .kpi-value { font-size: 28px; } }
.kpi-delta { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; }
.kpi-delta.up { color: var(--ok); }
.kpi-delta.down { color: var(--danger); }
.kpi-delta.flat { color: var(--text-3); }

/* ── Badges, chips, pills ────────────────────────────────────────────────── */
.badge {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 3px 9px; border-radius: var(--r-full);
  font-size: 12px; font-weight: 600; line-height: 1.5;
  border: 1px solid transparent; white-space: nowrap;
}
.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
.badge-neutral { background: var(--surface-3); color: var(--text-2); border-color: var(--line); }
.badge-ok      { background: var(--ok-soft);     color: var(--ok);     border-color: var(--ok-line); }
.badge-warn    { background: var(--warn-soft);   color: var(--warn);   border-color: var(--warn-line); }
.badge-danger  { background: var(--danger-soft); color: var(--danger); border-color: var(--danger-line); }
.badge-info    { background: var(--info-soft);   color: var(--info);   border-color: var(--info-line); }
.badge-accent  { background: var(--accent-soft); color: var(--accent-text); border-color: var(--accent-line); }
.badge-violet  { background: var(--violet-soft); color: var(--violet); border-color: var(--violet-line); }

.badge-count {
  min-width: 20px; height: 20px; padding: 0 6px; border-radius: var(--r-full);
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--surface-3); color: var(--text-2);
  font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
}

.chip {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 32px; padding: 0 var(--s3); border-radius: var(--r-full);
  background: var(--surface-2); border: 1px solid var(--line-2);
  font-size: 13px; font-weight: 500; cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
}
.chip:hover { background: var(--surface-3); }
.chip[aria-pressed="true"] {
  background: var(--accent-soft); border-color: var(--accent);
  color: var(--accent-text); font-weight: 650;
}
.chip-remove { display: grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; color: var(--text-3); }
.chip-remove:hover { background: var(--danger-soft); color: var(--danger); }

.chip-row { display: flex; flex-wrap: wrap; gap: var(--s2); }

/* ── Tables ──────────────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--surface); }
.table { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 640px; }
.table thead th {
  position: sticky; top: 0; z-index: 1;
  padding: var(--s3) var(--s4); text-align: left;
  font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--text-3); background: var(--surface-2);
  border-bottom: 1px solid var(--line); white-space: nowrap;
}
.table tbody td { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); vertical-align: middle; }
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr { transition: background var(--dur-fast) var(--ease-out); }
.table tbody tr:hover { background: var(--surface-2); }
.table tbody tr.is-clickable { cursor: pointer; }
/* A ticked row, marked down its edge as well as tinted. */
.table tbody tr[aria-selected="true"] {
  background: var(--accent-soft);
  box-shadow: inset 3px 0 0 var(--accent);
}
/* Left, everywhere in a table.
   Money and counts used to sit right, which is the accountant's convention and
   the right one for a column of figures you are adding up. These are not that:
   they are rows you scan across, and one column jumping to the far edge breaks
   the line your eye is following. The tabular figures stay, so the digits still
   line up under each other. */
.table .num,
.table th.num,
.table td.num,
.table .text-right,
.table th.text-right,
.table td.text-right { text-align: left; }
.table .num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.table th.sortable { cursor: pointer; user-select: none; }
.table th.sortable:hover { color: var(--text-2); }

/* Phone layout: each row becomes a stacked card */
@media (max-width: 767px) {
  .table-cards { min-width: 0; }
  .table-cards thead { display: none; }
  .table-cards tbody tr {
    display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px var(--s3);
    padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line);
  }
  .table-cards tbody td { display: block; padding: 0; border: 0; }
  .table-cards tbody td[data-label]::before {
    content: attr(data-label) ': '; color: var(--text-3); font-size: 12px; font-weight: 600;
  }
  .table-cards tbody td.cell-primary { grid-column: 1; font-weight: 600; }
  .table-cards tbody td.cell-primary::before { content: none; }
  .table-cards tbody td.cell-end { grid-column: 2; grid-row: 1 / span 2; text-align: left; }
  .table-cards tbody td.cell-end::before { content: none; }

  /* A row with a checkbox needs a column for it. Stacked, the row is a two
     column grid, and the pick cell arriving first took the slot the invoice
     number was placed in: every row then had one item more than it had places,
     which pushed the list wider than the screen. Rows without a checkbox keep
     the two column layout, so nothing else on the app changes. */
  .table-cards tbody tr:has(.cell-pick) { grid-template-columns: auto minmax(0, 1fr) auto; }
  .table-cards tbody tr:has(.cell-pick) td.cell-pick {
    /* Aligned to the first line, which is the invoice number. Centred, it
       floated against the middle of a card that is four lines tall and read as
       belonging to nothing in particular. */
    grid-column: 1; grid-row: 1 / span 2; align-self: start; width: auto;
    margin-top: 2px;
  }
  .table-cards tbody tr:has(.cell-pick) td.cell-primary { grid-column: 2; }
  .table-cards tbody tr:has(.cell-pick) td.cell-end { grid-column: 3; }
  /* The action button is reachable from the row menu on a phone, and a third
     fixed column beside the value leaves the client name nowhere to go. */
  .table-cards tbody td:last-child:not([data-label]):not(.cell-end) { display: none; }

  /* Everything that is not one of the three placed cells belongs in the middle
     column. Without this they auto-placed into whichever slot came next,
     including the one the amount was using, and the row grew wider than the
     screen to fit them. */
  .table-cards tbody tr:has(.cell-pick) td:not(.cell-pick):not(.cell-end) { grid-column: 2; }
  .table-cards tbody tr:has(.cell-pick) td.cell-end { grid-column: 3; grid-row: 1; }

  /* The same rule for a row that has no tick box, which had none.
     Only rows WITH a checkbox were told where their cells go. A card with more
     than two cells and no checkbox auto-placed them across both columns, so
     labels landed on top of each other and anything that fell into the second
     column, an `auto` track, was squeezed to its minimum width. For a long
     unbroken token like CAM180320261918 that minimum is one character, and the
     invoice number came out running down the card a letter at a time. */
  .table-cards tbody tr:not(:has(.cell-pick)) td:not(.cell-end) { grid-column: 1; }

  /* Whatever the row, a value with nothing to break on must not set the width
     of the card. */
  .table-cards tbody td { overflow-wrap: anywhere; min-width: 0; }

  /* A card is a summary, so it is set smaller than body copy, and the figure
     is the one thing that must never wrap or be cut. */
  .table-cards tbody td { font-size: 13px; line-height: 1.45; }
  .table-cards tbody td.cell-primary { font-size: 13px; }
  .table-cards tbody td.cell-end { font-size: 13px; white-space: nowrap; }
  .table-cards tbody td[data-label]::before { font-size: 11px; }

  /* An address or a key is one long token with no natural break, and a table
     card is narrow. These are the cells where breaking mid-token is right. */
  .table-cards tbody td.t-mono,
  .table-cards tbody td.t-mono * { overflow-wrap: anywhere; font-size: 12px; }
}

/* ── A follow up worth making ─────────────────────────────────────────────
   The green label beside a quotation nobody has answered. Green, not red: a
   customer who has not replied in four days is an opportunity somebody should
   act on, and a list of red rows is a list people stop reading.

   It is a button inside a row that is itself clickable, so it has to look like
   something separate. The border and the ground do that at a glance without
   shouting over the status badge beside it. */
.chip-remind {
  display: inline-flex; align-items: center; gap: 6px;
  margin-left: 6px; padding: 2px 9px 2px 7px;
  border-radius: 999px; border: 1px solid var(--ok-line);
  background: var(--ok-soft); color: var(--ok);
  font-size: 11px; font-weight: 600; line-height: 18px; white-space: nowrap;
  cursor: pointer;
  transition: filter var(--dur-fast) var(--ease-out);
}
.chip-remind:hover { filter: brightness(1.06); }
.chip-remind:focus-visible { outline: 2px solid var(--ok); outline-offset: 1px; }
.chip-remind-dot {
  width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none;
}
/* On a phone the row is a card and the status cell is a line of its own, so the
   label sits under the badge rather than pushing it off the edge. */
@media (max-width: 720px) {
  .chip-remind { margin-left: 0; margin-top: 4px; }
}

/* ── A two way switch ─────────────────────────────────────────────────────
   Smaller than .tabs and used inside a sheet, where a full tab strip with its
   own bottom rule reads as a second page rather than a choice of one field. */
.seg {
  display: inline-flex; padding: 2px; gap: 2px;
  border-radius: var(--r); border: 1px solid var(--line); background: var(--surface-2);
}
.seg-btn {
  min-height: 30px; padding: 0 var(--s4); border-radius: calc(var(--r) - 2px);
  font-size: 13px; font-weight: 550; color: var(--text-3);
  background: transparent; border: 0; cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.seg-btn.is-on { background: var(--surface); color: var(--text-1); box-shadow: var(--shadow-1, none); }

/* ── Tabs ────────────────────────────────────────────────────────────────── */
.tabs {
  display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none;
  border-bottom: 1px solid var(--line); margin-bottom: var(--s5);
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
  min-height: 42px; padding: 0 var(--s4); position: relative;
  font-size: 14px; font-weight: 550; color: var(--text-3); cursor: pointer; white-space: nowrap;
  transition: color var(--dur-fast) var(--ease-out);
  /* Without this the last tab is the one flex squeezes, so "Appearance" came
     out clipped at the right edge of the settings drawer. The strip scrolls
     with its scrollbar hidden, so a clipped tab reads as a broken one. */
  flex: 0 0 auto;
}
/* Below the width where all five settings tabs fit, wrap rather than scroll:
   a hidden horizontal scrollbar gives no hint that there is more to the right. */
@media (max-width: 700px) {
  .tabs { flex-wrap: wrap; overflow-x: visible; }
}
/* The settings tabs live in a drawer a few hundred pixels wide, whatever the
   window is doing, so a viewport media query never fired for them and
   "Appearance", the last of the five, stayed clipped on a wide screen. The
   drawer always wraps. */
#settings-drawer .tabs { flex-wrap: wrap; overflow-x: visible; row-gap: 0; }
.tab:hover { color: var(--text-2); }
.tab[aria-selected="true"] { color: var(--text); }
.tab[aria-selected="true"]::after {
  content: ''; position: absolute; inset: auto 0 -1px 0; height: 2px;
  background: var(--accent); border-radius: 2px 2px 0 0;
}

/* ── Settings drawer: one row of tabs, and they stay put ──────────────────
   The strip measures 495px with these five labels, and a 480px drawer leaves
   440px inside .modal-body's padding. So the last tab was always clipped, and
   because .tabs hides its scrollbar there was nothing to say it could be
   scrolled to: Appearance simply looked cut in half. 600px leaves 560px, which
   fits the row with room for a longer label later. Six of them measure 576px,
   so the drawer is 680px: 640px inside, which holds the row and leaves room
   for one more tab before this has to be looked at again.

   Sticky needs the strip pulled up into the padding it sits below, otherwise
   it parks 20px down and the content scrolls through the gap above it. The
   negative margins put its background across the body's full width, so nothing
   shows around the edges as the panel moves underneath. */
@media (min-width: 640px) {
  .drawer { width: min(680px, 100%); }

  #settings-body > .tabs {
    /* Pinned against the padding box, not the content box. Chrome resolves a
       sticky offset from the scrollport's content edge, so top:0 parked the
       strip 20px down and the panel scrolled through the band above it. */
    position: sticky; top: calc(var(--s5) * -1); z-index: 3;
    background: var(--surface);
    margin-top: calc(var(--s5) * -1);
    padding-top: var(--s5);
    margin-inline: calc(var(--s5) * -1);
    padding-inline: var(--s5);
  }
}

/* One percent of the viewport, in the units of the box that reads it.
   A dvh inside a zoomed element is the real viewport's, which the element then
   scales: at 150% a sidebar asking for 100dvh drew half a screen past the
   bottom edge, and at 85% it stopped short and left a band of empty page under
   its footer. Dividing by the zoom cancels it. Written on :root as well, so
   anything outside the zoomed boxes still gets a plain dvh. */
:root { --vh: 1dvh; }

/* ── Interface size ──────────────────────────────────────────────────────
   One person's own, set on <html> before anything is drawn.

   zoom rather than a root font size, because this stylesheet states its sizes
   in px: 15px body, 13px .t-sm, 32px .t-display. Scaling html's font-size
   moves nothing that is written in px, so it would change almost nothing on
   the screen. zoom scales the whole box model, which is what "make it bigger"
   actually means, and it reflows rather than smearing the pixels the way
   transform: scale would.

   Named one by one rather than put on body, so the off-screen stage that
   assets/js/studio/pdf.js appends to the body is left alone. That stage is
   measured in real pixels to build an A4 page, and scaling it would change
   the size of every PDF this app produces. */
/* body > .bulk-bar, because the invoice list renders its bar inside .app and is
   already carried by the zoom on that, while the catalogue and the file library
   append theirs to the body, where nothing would scale it. The child selector
   is what keeps the first one from being scaled twice. */
.app, .bottomnav, .overlay, .palette-overlay, .drawer, .toast-region, .pwa-install,
body > .bulk-bar {
  zoom: var(--ui-scale, 1);
  --vh: calc(1dvh / var(--ui-scale, 1));
  /* And a width to go with the height. A vw is resolved against the real window
     and then multiplied by the zoom like everything else in here, so a box
     capped at 100vw ends up wider than the screen at any size above 100: 27px
     over each edge at 125 on a 375px phone, 70px at 150, which carries the
     dismiss button off the corner. */
  --vw: calc(1vw / var(--ui-scale, 1));
}

/* The invoice preview is a picture of a sheet of A4 and has its own zoom
   control, so it is put back to true size. Without this, a person working at
   125% would be judging margins on a document 25% larger than the one the
   customer opens. */
.canvas { zoom: calc(1 / var(--ui-scale, 1)); --vh: 1dvh; }

/* ── Sent ────────────────────────────────────────────────────────────────
   A table, headers once at the top and sticky while it scrolls. One row per
   customer per brand; the messages behind a row open underneath it rather
   than repeating the brand, the customer and the sender on every line. */
.history-filters {
  display: grid; gap: var(--s3);
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  align-items: end;
}
.history-filters-actions { display: flex; align-items: flex-end; }

.hist-table tbody tr.hist-row { cursor: pointer; }
.hist-table tbody tr.hist-row:hover { background: var(--surface-2); }
.hist-table tbody tr.hist-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.hist-chevron { color: var(--text-3); transition: transform var(--dur) var(--ease-out); }
.hist-row.is-open .hist-chevron { transform: rotate(180deg); }

/* The brand cell: its mark and its name on one line, the mark sized for a row
   rather than for the card this used to be. */
.hist-brand { display: inline-flex; align-items: center; gap: var(--s2); white-space: nowrap; }
.hist-mark {
  flex: 0 0 auto; width: 24px; height: 24px; border-radius: 50%;
  display: grid; place-items: center; overflow: hidden;
  background: var(--surface-2);
  box-shadow: inset 0 0 0 2px var(--mark, var(--text-3));
}
.hist-mark img { width: 100%; height: 100%; object-fit: contain; padding: 3px; }
.hist-mark-letter { font-weight: 700; font-size: 11px; color: var(--mark, var(--text-3)); }

/* The opened messages. Indented under their row and on a different ground, so
   it reads as belonging to the row above rather than as more rows. */
.hist-detail > td { padding: 0 !important; background: var(--surface-2); }
.hist-list { list-style: none; margin: 0; padding: 0; }
.hist-item {
  display: flex; flex-wrap: wrap; gap: var(--s1) var(--s3);
  justify-content: space-between; align-items: baseline;
  padding: var(--s2) var(--s4) var(--s2) var(--s7);
  border-bottom: 1px solid var(--line);
  font-size: 13px;
}
.hist-item:last-child { border-bottom: 0; }
.hist-item-what { flex: 1 1 14rem; min-width: 0; overflow-wrap: break-word; }
.hist-item-meta { flex: 0 0 auto; color: var(--text-3); font-size: 12px; }

/* ── Pinned and dragged ──────────────────────────────────────────────────
   One person's own arrangement of the Products and Brand library shelves.
   Deliberately quiet: a pinned card is marked, not decorated, because a shelf
   with a dozen pins should still read as a shelf. */
.pin-btn { flex: 0 0 auto; color: var(--text-3); }
.pin-btn:hover { color: var(--text); }
.pin-btn.is-pinned { color: var(--accent); }
.is-pinned-card { box-shadow: inset 3px 0 0 var(--accent); }

/* Half-transparent rather than hidden: the gap it leaves is what shows where
   it would land, and removing it from the flow makes the grid jump instead. */
.is-dragging { opacity: .4; }
[data-order-id] { cursor: grab; }
[data-order-id]:active { cursor: grabbing; }
/* A drag needs a pointer. On a touch screen the pin button does the job and
   the browser keeps its own scrolling. */
@media (pointer: coarse) { [data-order-id] { cursor: default; } }

/* ── Sortable, searchable table headers ──────────────────────────────────
   One treatment for every table in the app. The header stays sticky, and the
   row of search boxes sticks under it, so both survive a long list. */
.tk-sortable {
  cursor: pointer; user-select: none;
  padding-right: calc(var(--s4) + 12px) !important; position: relative;
}
.tk-sortable:hover { color: var(--text); background: var(--surface-3, var(--surface-2)); }
.tk-sortable:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* An arrow that is faint until the column is the one being sorted by. */
.tk-arrow {
  position: absolute; right: var(--s3); top: 50%; transform: translateY(-50%);
  width: 0; height: 0; opacity: .25;
  border-left: 4px solid transparent; border-right: 4px solid transparent;
  border-top: 5px solid currentColor;
}
[data-tk-dir="1"] .tk-arrow {
  opacity: 1; border-top: 0; border-bottom: 5px solid var(--accent);
}
[data-tk-dir="-1"] .tk-arrow { opacity: 1; border-top-color: var(--accent); }

.table thead tr.tk-filters th {
  /* The header's measured height, published by tablekit.js. */
  position: sticky; top: var(--tk-head-h, 2.35rem); z-index: 1;
  padding: var(--s1) var(--s2) var(--s2);
  background: var(--surface-2); border-bottom: 1px solid var(--line);
}
.tk-search {
  width: 100%; min-width: 0; padding: 4px var(--s2);
  font: inherit; font-size: 12px; font-weight: 400; text-transform: none; letter-spacing: 0;
  color: var(--text); background: var(--surface);
  border: 1px solid var(--line); border-radius: var(--r-sm);
}
.tk-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.tk-search::placeholder { color: var(--text-3); }

/* On a phone .table-cards turns every row into a card, and a row of search
   boxes has no column headings to sit under. The filters go with them. */
@media (max-width: 767px) {
  .table-cards thead tr.tk-filters { display: none; }
}

/* ── Choosing which columns to show ──────────────────────────────────────
   Sits above its own table, so two tables on one screen each get their own. */
.tk-cols { position: relative; display: flex; justify-content: flex-end; margin-bottom: var(--s2); }
.tk-cols-btn {
  display: inline-flex; align-items: center; gap: var(--s2);
  min-height: 30px; padding: 0 var(--s3);
  font-size: 12px; font-weight: 600; color: var(--text-2);
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm);
  cursor: pointer;
}
.tk-cols-btn:hover { color: var(--text); border-color: var(--line-2); }
.tk-cols-btn[aria-expanded="true"] { color: var(--accent-text); border-color: var(--accent); }

.tk-cols-menu {
  position: absolute; top: 100%; right: 0; z-index: var(--z-sticky);
  min-width: 13rem; max-height: 60vh; overflow-y: auto;
  padding: var(--s2); margin-top: 4px;
  background: var(--surface); border: 1px solid var(--line-2);
  border-radius: var(--r); box-shadow: var(--sh-3);
}
.tk-cols-item {
  display: flex; align-items: center; gap: var(--s2);
  padding: var(--s2) var(--s2); border-radius: var(--r-sm);
  font-size: 13px; cursor: pointer;
}
.tk-cols-item:hover { background: var(--surface-2); }
/* A column with no heading is the tick box or the row menu: there is nothing
   in it to read, so turning it off would only break the row. */
.tk-cols-item.is-fixed { opacity: .45; cursor: not-allowed; }
.tk-cols-all {
  width: 100%; margin-top: var(--s2); padding: var(--s2);
  font-size: 12px; font-weight: 600; color: var(--accent-text);
  background: none; border: 0; border-top: 1px solid var(--line);
  cursor: pointer; text-align: left;
}

/* ── Reading, not doing ──────────────────────────────────────────────────
   Panels that only tell you something, set apart from the ones you act in.
   Several screens put a read-only summary in the same white card as the
   buttons, so it read as a form that would not accept typing. This is the
   quieter ground for the half that is only there to be read. */
.info-panel {
  background: var(--surface-2); border: 1px dashed var(--line-2);
  border-radius: var(--r); padding: var(--s4);
  color: var(--text-2);
}
.info-panel .info-panel-title {
  display: flex; align-items: center; gap: var(--s2);
  font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--text-3); margin: 0 0 var(--s2);
}
/* Text that is a value being shown rather than a value being entered. */
.info-value {
  font-family: var(--font-mono, monospace); font-size: 12px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-sm); padding: var(--s2) var(--s3);
  color: var(--text-2); word-break: break-word;
}
/* A whole card given over to explanation. */
.card.is-info { background: var(--surface-2); border-style: dashed; }
.card.is-info .card-head { background: none; }
/* A row of tiles that report rather than control. The links inside them stay
   at full strength: they are the way to act on what the tile says. */
.is-info-grid > .kpi { background: var(--surface-2); border-style: dashed; }

/* ── A dropdown you can search ───────────────────────────────────────────
   The list is a child of <body> and positioned from script, so no ancestor
   with overflow can clip it: the field mapping lives inside .table-wrap,
   which is overflow-x: auto and would have cut off the last rows' lists. */
.combo { position: relative; display: flex; align-items: center; }
.combo-input { width: 100%; padding-right: 2.25rem; }
.combo-toggle {
  position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
  display: grid; place-items: center; width: 28px; height: 28px;
  border: 0; border-radius: var(--r-sm); background: none;
  color: var(--text-3); cursor: pointer;
}
.combo-toggle:hover { color: var(--text); background: var(--surface-2); }
.combo-input[aria-expanded="true"] + .combo-toggle { color: var(--accent); }

.combo-list {
  position: fixed; z-index: var(--z-modal);
  margin: 0; padding: var(--s1); list-style: none;
  overflow-y: auto; overscroll-behavior: contain;
  background: var(--surface); border: 1px solid var(--line-2);
  border-radius: var(--r); box-shadow: var(--sh-3);
}
.combo-option {
  padding: var(--s2) var(--s3); border-radius: var(--r-sm); cursor: pointer;
  font-family: var(--font-mono, monospace); font-size: 12px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.combo-option.is-active { background: var(--accent-soft); color: var(--text); }
.combo-option.is-none { font-family: var(--font-ui); color: var(--text-3); font-style: italic; }
.combo-empty { padding: var(--s3); color: var(--text-3); font-size: 13px; }

/* ── Store import ────────────────────────────────────────────────────────
   Four numbered steps that have to happen in order, so each carries its number
   where the card's icon would otherwise go. */
.step-dot {
  flex: 0 0 auto; width: 24px; height: 24px; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--accent); color: #fff;
  font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums;
}

.woo-store {
  display: flex; flex-wrap: wrap; gap: var(--s3); align-items: flex-start;
  padding: var(--s3) 0; border-bottom: 1px solid var(--line);
}
.woo-store:last-child { border-bottom: 0; padding-bottom: 0; }
/* min-width:0 so a long shop address wraps instead of widening the row past
   the card, which is the same trap the studio panes fell into. */
.woo-store-main { flex: 1 1 16rem; min-width: 0; overflow-wrap: break-word; }
.woo-store-actions { display: flex; gap: var(--s2); flex-wrap: wrap; }
.woo-store [data-result] { flex: 1 1 100%; }

/* The navigation sheet's footer: the three things that are not destinations,
   held where they can always be reached. Three across, because stacked they
   would take a third of a phone screen to say what a single row can. */
.modal-foot.nav-sheet-foot {
  /* Both class names, so this beats .modal-foot's display:flex wherever the
     two happen to sit in this file rather than by being lucky about order. */
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s2);
  background: var(--surface); padding: var(--s3) var(--s4);
  border-top: 1px solid var(--line);
}
.nav-sheet-foot .nav-item {
  flex-direction: column; gap: 4px; min-height: 60px;
  justify-content: center; text-align: center; padding: var(--s2);
}
.nav-sheet-foot .nav-item span { font-size: 11px; }

/* ── A card that folds ───────────────────────────────────────────────────
   Used where a panel is worth having but not worth the room it takes when
   nobody is editing it. details/summary rather than a script, so it opens
   before any JavaScript has run and keeps working if none does. */
.fold > summary {
  cursor: pointer; list-style: none;
  display: flex; align-items: center; gap: var(--s3);
}
.fold > summary::-webkit-details-marker { display: none; }
.fold > summary:hover { background: var(--surface-2); }
.fold > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.fold-chevron { transition: transform var(--dur) var(--ease-out); flex: 0 0 auto; }
.fold[open] > summary .fold-chevron { transform: rotate(180deg); }
/* The subtitle explains what is folded away; once it is open the panel says so
   itself and the line is just noise. */
.fold[open] > summary .fold-hint { display: none; }
@media (max-width: 560px) { .fold-hint { display: none; } }

/* ── Modals and sheets ───────────────────────────────────────────────────── */
.overlay {
  position: fixed; inset: 0; z-index: var(--z-modal);
  background: var(--overlay); backdrop-filter: blur(3px);
  display: grid; place-items: center; padding: var(--s4);
  opacity: 0; visibility: hidden;
  transition: opacity var(--dur) var(--ease-out), visibility var(--dur);
}
.overlay.is-open { opacity: 1; visibility: visible; }

.modal {
  width: 100%; max-width: 560px; max-height: min(calc(var(--vh) * 86), 760px);
  display: flex; flex-direction: column;
  background: var(--surface); border: 1px solid var(--line-2);
  border-radius: var(--r-xl); box-shadow: var(--sh-3);
  transform: translateY(14px) scale(0.985);
  transition: transform var(--dur) var(--ease-out);
}
.overlay.is-open .modal { transform: none; }
.modal-wide { max-width: 880px; }

.modal-head {
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s4) var(--s5); border-bottom: 1px solid var(--line); flex: none;
}
.modal-head h2 { flex: 1; min-width: 0; font-size: 17px; }
/* min-height: 0 is what makes overflow-y: auto mean anything here.
   .modal is a flex column with a max-height; this is the flex item that is
   meant to absorb the difference. A flex item's default minimum is its content,
   so without the zero it refuses to become shorter than everything inside it:
   the body grows, the dialog grows past its max-height, and the fields at the
   bottom are drawn outside the rounded edge instead of scrolling. */
.modal-body { padding: var(--s5); overflow-y: auto; flex: 1; min-height: 0; }

/* A form between the dialog and its body breaks the chain.
   .modal is a flex column and .modal-body is the item meant to absorb the
   height. Several dialogs wrap their body and footer in a <form>, and a plain
   block is not a flex container: flex:1 on .modal-body then governs nothing,
   the body grows to its content, and the fields at the bottom are drawn
   outside the rounded edge instead of scrolling. Making the form the flex item
   it stands in for restores it. */
.modal > form {
  display: flex; flex-direction: column;
  flex: 1 1 auto; min-height: 0;
}
.modal-foot {
  display: flex; gap: var(--s2); justify-content: flex-end;
  padding: var(--s4) var(--s5); border-top: 1px solid var(--line); flex: none; flex-wrap: wrap;
}

/* On phones a modal becomes a bottom sheet */
@media (max-width: 639px) {
  .overlay { place-items: end center; padding: 0; }
  .modal {
    max-width: none; max-height: calc(var(--vh) * 92);
    border-radius: var(--r-xl) var(--r-xl) 0 0; border-bottom: 0;
    transform: translateY(100%);
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }
  .modal-foot > .btn { flex: 1; }
}

.sheet-grabber {
  width: 36px; height: 4px; border-radius: var(--r-full); background: var(--line-2);
  margin: var(--s2) auto 0; display: none;
}
@media (max-width: 639px) { .sheet-grabber { display: block; } }

/* Right-hand drawer, used for settings */
.drawer {
  position: fixed; inset: 0 0 0 auto; z-index: var(--z-drawer);
  width: min(480px, 100%); display: flex; flex-direction: column;
  background: var(--surface); border-left: 1px solid var(--line);
  box-shadow: var(--sh-3); transform: translateX(100%);
  transition: transform var(--dur-slow) var(--ease-out);
}
.drawer.is-open { transform: none; }
@media (max-width: 639px) {
  .drawer { inset: auto 0 0 0; width: 100%; height: calc(var(--vh) * 92); border-left: 0; border-top: 1px solid var(--line); border-radius: var(--r-xl) var(--r-xl) 0 0; transform: translateY(100%); }
  .drawer.is-open { transform: none; }
}

/* Scrim behind the settings drawer */
.scrim {
  position: fixed; inset: 0; z-index: calc(var(--z-drawer) - 1);
  background: var(--overlay); backdrop-filter: blur(2px);
}
.drawer[aria-hidden="true"] { pointer-events: none; }

/* ── Command palette ─────────────────────────────────────────────────────── */
.palette-overlay {
  position: fixed; inset: 0; z-index: var(--z-palette);
  background: var(--overlay); backdrop-filter: blur(4px);
  display: grid; align-items: start; justify-items: center;
  padding: calc(var(--vh) * 12) var(--s4) var(--s4);
  opacity: 0; visibility: hidden;
  transition: opacity var(--dur-fast) var(--ease-out), visibility var(--dur-fast);
}
.palette-overlay.is-open { opacity: 1; visibility: visible; }
.palette {
  width: 100%; max-width: 620px; overflow: hidden;
  background: var(--surface); border: 1px solid var(--line-2);
  border-radius: var(--r-lg); box-shadow: var(--sh-3);
  transform: translateY(-10px) scale(0.99);
  transition: transform var(--dur-fast) var(--ease-out);
}
.palette-overlay.is-open .palette { transform: none; }
.palette-input-row { display: flex; align-items: center; gap: var(--s3); padding: var(--s4); border-bottom: 1px solid var(--line); }
.palette-input { flex: 1; background: none; border: 0; outline: none; font-size: 16px; color: var(--text); }
.palette-input::placeholder { color: var(--text-3); }
.palette-list { max-height: calc(var(--vh) * 52); overflow-y: auto; padding: var(--s2); }
.palette-section { padding: var(--s3) var(--s3) var(--s2); }
.palette-item {
  display: flex; align-items: center; gap: var(--s3); width: 100%;
  min-height: 44px; padding: 0 var(--s3); border-radius: var(--r);
  font-size: 14px; color: var(--text-2); cursor: pointer; text-align: left;
}
.palette-item:hover, .palette-item.is-active { background: var(--accent-soft); color: var(--accent-text); }
.palette-item .kbd { margin-left: auto; }

.kbd {
  display: inline-flex; align-items: center; gap: 2px;
  padding: 2px 6px; border-radius: var(--r-sm);
  background: var(--surface-3); border: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--text-3);
}

/* ── Toast ───────────────────────────────────────────────────────────────── */
/* Above the buttons, never on them.
 *
 * The studio has a fixed action bar with Print, PDF and Send in it, and a toast
 * sat 45px over the top of it: the message telling you the invoice had saved
 * covered the button you were reaching for.
 *
 * Two numbers feed this, and the larger wins.
 * --bottom-bar-h      is the tallest bar at the foot of the page as app.js
 *                     measured it. Exact, which matters for the bulk bar,
 *                     whose height depends on what it says and wraps to a
 *                     second row in a narrow window. Present only while that
 *                     script is the current one.
 * --bottom-bar-floor  is declared beside each bar, out of that bar's own
 *                     tokens. It needs no script, so a browser holding an
 *                     older app.js still clears the buttons. That was the
 *                     whole of this bug the second time it was reported:
 *                     nothing published a height, this fell back to zero, and
 *                     the toast sat 44.7px into the bar.
 * Both are unset on a page with no action bar, and max() of nothing is 0, so
 * this one expression serves every page. */
:root {
  --bottom-bar-clear: max(var(--bottom-bar-h, 0px), var(--bottom-bar-floor, 0px));
}

.toast-region {
  position: fixed; z-index: var(--z-toast);
  left: 50%; transform: translateX(-50%);
  bottom: calc(var(--bottomnav-h) + var(--bottom-bar-clear) + var(--s3));
  display: flex; flex-direction: column-reverse; gap: var(--s2);
  width: min(440px, calc(var(--vw) * 100 - 32px)); pointer-events: none;
}
/* On a wide screen the toast moves to the corner, and the action bar is still
   under it: the bar runs the full width from the sidebar to the right edge, so
   the corner is exactly where Send is. The same clearance applies. */
@media (min-width: 1024px) {
  .toast-region {
    bottom: calc(var(--bottom-bar-clear) + var(--s5));
    left: auto; right: var(--s5); transform: none;
  }
}

.toast {
  display: flex; align-items: flex-start; gap: var(--s3);
  padding: var(--s3) var(--s4); border-radius: var(--r);
  background: var(--surface-3); border: 1px solid var(--line-2);
  box-shadow: var(--sh-2); font-size: 14px; pointer-events: auto;
  animation: toast-in var(--dur) var(--ease-out);
}
.toast.is-leaving { animation: toast-out var(--dur-fast) var(--ease-in) forwards; }
.toast-ok     { border-color: var(--ok-line); }
.toast-ok .icon { color: var(--ok); }
.toast-error  { border-color: var(--danger-line); }
.toast-error .icon { color: var(--danger); }
.toast-warn   { border-color: var(--warn-line); }
.toast-warn .icon { color: var(--warn); }
.toast-text { flex: 1; min-width: 0; }
.toast-title { font-weight: 600; }
.toast-desc { font-size: 13px; color: var(--text-2); }

@keyframes toast-in  { from { opacity: 0; transform: translateY(10px) scale(0.98); } }
@keyframes toast-out { to   { opacity: 0; transform: translateY(6px) scale(0.98); } }

/* ── Empty, loading and skeleton states ──────────────────────────────────── */
.empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--s3); padding: var(--s9) var(--s5); text-align: center; color: var(--text-2);
}
.empty-icon {
  width: 52px; height: 52px; border-radius: var(--r-lg);
  display: grid; place-items: center;
  background: var(--surface-2); border: 1px solid var(--line); color: var(--text-3);
}
.empty h3 { color: var(--text); }
.empty p { max-width: 42ch; font-size: 14px; }

.skeleton {
  background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-3) 37%, var(--surface-2) 63%);
  background-size: 400% 100%;
  animation: shimmer 1.3s ease-in-out infinite;
  border-radius: var(--r-sm);
}
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
.skeleton-line { height: 12px; margin-bottom: var(--s2); }
.skeleton-line:last-child { width: 60%; }

.spinner {
  width: 18px; height: 18px; border: 2px solid var(--line-2);
  border-top-color: var(--accent); border-radius: 50%;
  animation: spin 640ms linear infinite;
}

.progress { height: 4px; background: var(--surface-3); border-radius: var(--r-full); overflow: hidden; }
.progress > i { display: block; height: 100%; background: var(--accent); border-radius: inherit; transition: width var(--dur) var(--ease-out); }

/* ── Callouts ────────────────────────────────────────────────────────────── */
.callout {
  display: flex; gap: var(--s3); padding: var(--s3) var(--s4);
  border-radius: var(--r); border: 1px solid var(--line-2);
  background: var(--surface-2); font-size: 13px; line-height: 1.6;
}
.callout .icon { flex: none; margin-top: 1px; }
.callout-info   { background: var(--info-soft);   border-color: var(--info-line); }
.callout-info .icon { color: var(--info); }
.callout-warn   { background: var(--warn-soft);   border-color: var(--warn-line); }
.callout-warn .icon { color: var(--warn); }
.callout-danger { background: var(--danger-soft); border-color: var(--danger-line); }
.callout-danger .icon { color: var(--danger); }
.callout-ok     { background: var(--ok-soft);     border-color: var(--ok-line); }
.callout-ok .icon { color: var(--ok); }

/* ── Charts ──────────────────────────────────────────────────────────────── */
.chart { width: 100%; min-height: 200px; }
.chart-legend { display: flex; flex-wrap: wrap; gap: var(--s3); font-size: 12px; color: var(--text-2); }
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend-swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }
.bar-track { height: 8px; border-radius: var(--r-full); background: var(--surface-3); overflow: hidden; }
.bar-fill { height: 100%; border-radius: inherit; }

/* ── Recording and capture affordances ───────────────────────────────────── */
.mic-btn {
  position: relative; width: 64px; height: 64px; border-radius: 50%;
  display: grid; place-items: center; flex: none;
  background: var(--accent); color: var(--text-on-accent);
  border: none; cursor: pointer; box-shadow: var(--sh-2);
  transition: transform var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
}
.mic-btn:active { transform: scale(0.94); }
.mic-btn.is-recording { background: var(--danger); color: #fff; }
.mic-btn.is-recording::after {
  content: ''; position: absolute; inset: -6px; border-radius: 50%;
  border: 2px solid var(--danger); animation: pulse-ring 1.4s var(--ease-out) infinite;
}
@keyframes pulse-ring {
  from { transform: scale(0.92); opacity: 0.9; }
  to   { transform: scale(1.22); opacity: 0; }
}

.level-meter { display: flex; align-items: flex-end; gap: 3px; height: 26px; }
.level-meter i { width: 3px; border-radius: var(--r-full); background: var(--accent); height: 20%; transition: height 80ms linear; }

.capture-preview {
  position: relative; border-radius: var(--r-lg); overflow: hidden;
  border: 1px solid var(--line-2); background: var(--surface-2);
}
.capture-preview img { display: block; width: 100%; height: auto; }
.capture-meta {
  position: absolute; inset: auto var(--s2) var(--s2) var(--s2);
  display: flex; align-items: center; justify-content: space-between; gap: var(--s2);
  padding: 6px var(--s3); border-radius: var(--r-sm);
  background: rgba(4, 6, 10, 0.72); color: #e9edf5; font-size: 12px;
  backdrop-filter: blur(6px);
}

/* ── Split workspace: editor beside the invoice preview ──────────────────── */
.workspace { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s4); min-height: 0; }
@media (min-width: 1024px) {
  .workspace { grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); align-items: start; }
  .workspace-wide { grid-template-columns: minmax(420px, 520px) minmax(0, 1fr); }
}
.workspace-pane { min-width: 0; }
@media (min-width: 1024px) {
  .workspace-sticky { position: sticky; top: calc(var(--topbar-h) + var(--s4)); max-height: calc(var(--vh) * 100 - var(--topbar-h) - var(--s7)); overflow-y: auto; }
}

.canvas {
  display: grid; place-items: start center; padding: var(--s4);
  background: var(--surface-2); border: 1px solid var(--line);
  border-radius: var(--r-lg); overflow: auto;
}
.canvas-zoom { transform-origin: top center; transition: transform var(--dur) var(--ease-out); }

/* ── Utilities ───────────────────────────────────────────────────────────── */
.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;
}
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* These hide, and must not do anything else.
 *
 * This was ".hide-mobile { display: none }" with "display: revert" back on at
 * 768px, and revert means the user agent's value, not the one the stylesheet
 * had given. A button's user agent display is inline-block, so on a desktop
 * this rule quietly cancelled .btn's inline-flex, and with it align-items and
 * justify-content. The top bar theme button is the only theme button carrying
 * the class, which is why its moon alone sat off centre.
 *
 * Setting display only inside the range where the element is hidden leaves
 * every other rule to say what it is the rest of the time. */
@media (max-width: 767.98px) { .hide-mobile  { display: none !important; } }
@media (min-width: 768px)    { .hide-desktop { display: none !important; } }
.flex-1 { flex: 1; min-width: 0; }

/* ── A box that knows its own unit ────────────────────────────────────────
   The unit sits beside the value rather than inside it. Typed into the value
   it would be saved, and the next draw would add a second one; shown as a tag,
   the person types 7 and everyone reads 7 ft. */
.input-unit { position: relative; display: block; }
.input-unit > .input { width: 100%; padding-right: 34px; }
.input-unit-tag {
  position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
  font-size: 11px; font-weight: 600; color: var(--text-3);
  pointer-events: none; user-select: none;
}
.input-unit-sm > .input { padding-right: 26px; }
.input-unit-sm .input-unit-tag { right: 7px; font-size: 10px; }

/* ── Choosing which columns to show ───────────────────────────────────────
   Eighteen ticks read as a wall, so they sit in two columns of a grid where
   the eye can take the whole list in at once. */
.column-picker { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 12px; }
.column-picker .check { min-height: 36px; padding: 4px; border-radius: var(--r-sm, 6px); }
.column-picker .check:hover { background: var(--surface-2); }
/* The name is the sticky column the rest of the table scrolls behind, so it
   cannot be turned off. Shown greyed rather than hidden, so its absence from
   the list is never mistaken for a missing column. */
.column-picker .check.is-fixed { opacity: 0.6; cursor: default; }
@media (max-width: 460px) {
  .column-picker { grid-template-columns: minmax(0, 1fr); }
}
/* A row of controls that would otherwise push a modal wider than the screen. */
.row.wrap { flex-wrap: wrap; }
/* The full spacing scale. Half of these were missing, so a screen written with
   mb-6 simply had no gap at all and its cards sat flush against each other. */
.mt-0 { margin-top: 0; }    .mt-1 { margin-top: var(--s1); }  .mt-2 { margin-top: var(--s2); }
.mt-3 { margin-top: var(--s3); } .mt-4 { margin-top: var(--s4); } .mt-5 { margin-top: var(--s5); }
.mt-6 { margin-top: var(--s6); } .mt-7 { margin-top: var(--s7); } .mt-8 { margin-top: var(--s8); }
.mb-0 { margin-bottom: 0; } .mb-1 { margin-bottom: var(--s1); } .mb-2 { margin-bottom: var(--s2); }
.mb-3 { margin-bottom: var(--s3); } .mb-4 { margin-bottom: var(--s4); } .mb-5 { margin-bottom: var(--s5); }
.mb-6 { margin-bottom: var(--s6); } .mb-7 { margin-bottom: var(--s7); } .mb-8 { margin-bottom: var(--s8); }
.gap-1 { gap: var(--s1); } .gap-2 { gap: var(--s2); } .gap-3 { gap: var(--s3); }
.gap-4 { gap: var(--s4); } .gap-5 { gap: var(--s5); } .gap-6 { gap: var(--s6); }

/* A safety net so two stacked blocks can never end up touching, whatever the
   page forgot to add. */
.page > .card + .card,
.page > .callout + .card,
.page > .card + .callout,
.page > .table-wrap + .card,
.page > section + section { margin-top: var(--s4); }

/* A callout dropped in after a line of text needs its own room too. */
.card-body > * + .callout,
.modal-body > * + .callout,
.page * > p + .callout { margin-top: var(--s3); }
.text-right { text-align: right; } .text-center { text-align: center; }
.w-full { width: 100%; }

/* ── Touch: coarse pointers get comfortable hit areas ────────────────────── */
@media (pointer: coarse) {
  .btn-sm { min-height: 44px; padding: 0 var(--s4); }
  .btn-icon.btn-sm { min-width: 44px; }
  .segmented button { min-height: 44px; }
  .chip { min-height: 40px; }
  .tab { min-height: 48px; }
  .nav-item { min-height: 48px; }
  .bnav-item { min-height: 60px; }
  .table tbody td { padding: var(--s4) var(--s4); }
}

/* ── Motion preferences ──────────────────────────────────────────────────── */
@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;
  }
}

/* ── Print: only the invoice paper survives ──────────────────────────────── */
@media print {
  .topbar, .sidebar, .bottomnav, .toast-region, .overlay, .drawer,
  .palette-overlay, .no-print { display: none !important; }
  body { background: #fff; }
  .main, .page { padding: 0; margin: 0; }
  .canvas { padding: 0; border: 0; background: none; display: block; }
}

/* ── PDF preview ──────────────────────────────────────────────────────────
   The preview frames the file that was just built, not a re-drawing of it,
   so what is on screen is byte for byte what the customer opens. */
/* --vw, not vw: this box is inside .overlay, which is zoomed, so a plain
   vw would be the real window scaled up again. See the --vw note above. */
.modal-wide { max-width: min(980px, var(--vw, 1vw) * 94); }

.pdfpv { display: flex; flex-direction: column; gap: var(--s3); }
.pdfpv-note { font-size: 13px; color: var(--text-2); }
.pdfpv-stage {
  display: grid; place-items: center;
  background: var(--surface-2); border: 1px solid var(--line);
  border-radius: var(--r-lg); padding: var(--s4);
  min-height: 300px;
}
.pdfpv-frame { width: 100%; height: 68vh; border: 0; border-radius: var(--r); background: #fff; }

/* The phone copy is judged by how it reads on a phone, so it is shown in one. */
.pdfpv-phone {
  width: 330px; max-width: 100%;
  border: 10px solid #16181d; border-radius: 30px;
  background: #16181d; box-shadow: var(--sh-3); overflow: hidden;
}
.pdfpv-phone .pdfpv-frame { height: 62vh; border-radius: 20px; }

.pdfpv-empty { display: grid; place-items: center; gap: var(--s3); padding: var(--s7) var(--s4); text-align: center; }

/* One scrollbar, not two.
   .modal-body scrolls by default and .pdfpv-frame was a fixed 68vh, so the
   frame regularly stood taller than the space the dialog had for it. That put
   the dialog's own scrollbar beside the PDF viewer's, and dragging the outer
   one moved the whole document rather than the pages. The dialog now holds its
   height and the frame takes what is left, so the only scrollbar belongs to
   the PDF. */
/* A preview is read, not glanced at, so this dialog is allowed to be taller
   than the 760px cap the shared .modal rule sets for ordinary sheets. */
#pdf-preview .modal { max-height: calc(var(--vh) * 94); height: calc(var(--vh) * 94); }
#pdf-preview .modal-body { overflow: hidden; display: flex; min-height: 0; padding: var(--s3); }
#pdf-preview .pdfpv { flex: 1; min-height: 0; }
/* The stage takes flex:1 and the note is a flex item beside it, so with the
   dialog under height pressure the note was shrunk away to nothing and the
   line at the top of the preview simply vanished. Text does not shrink. */
#pdf-preview .pdfpv-share-note { flex: none; }
.pdfpv-share-note {
  font-size: 13px; line-height: 1.5; color: var(--text);
  background: var(--surface-2); border: 1px solid var(--line);
  border-left: 3px solid var(--accent);
  border-radius: var(--r); padding: var(--s3);
  white-space: pre-line;
}
/* The stage centres its child, which would leave the frame at its content
   height. Stretch it instead so it reaches the bottom of the dialog. */
#pdf-preview .pdfpv-stage { flex: 1; min-height: 0; place-items: stretch; }
#pdf-preview .pdfpv-frame { height: 100%; min-height: 320px; }
/* The phone copy is judged on its handset shape, so it stays centred and
   sized rather than stretched to the full width of the dialog. */
/* The handset keeps its width and its shape, but takes the full height it is
   given. max-height alone left it sized by its content, and its content is an
   iframe asking for 100% of a parent with no height of its own, which collapses
   to a small box adrift in a tall dialog. */
#pdf-preview .pdfpv-stage:has(.pdfpv-phone) { place-items: center; }
#pdf-preview .pdfpv-phone {
  height: 100%; max-height: 100%; margin: 0 auto;
  display: flex; flex-direction: column;
}
#pdf-preview .pdfpv-phone .pdfpv-frame { flex: 1; height: auto; min-height: 0; }

.pdf-row {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s3);
  padding: var(--s3); border: 1px solid var(--line); border-radius: var(--r);
  background: var(--surface-2);
}
.pdf-row-name { display: flex; align-items: center; gap: var(--s3); font-size: 14px; font-weight: 550; }

/* Each brand carries two marks: one for the colour copy, whose header is
   dark, and one for the printed copy, which is white. They are shown on those
   two grounds so a file that will not read on one of them is obvious here
   rather than in a customer's inbox. */
.logo-grid { display: flex; flex-direction: column; gap: var(--s3); }
/* Brands read as a set of cards, not a stack of full width bands.
   One row per brand across a wide screen gave each logo a preview box a
   thousand pixels wide holding a 48px mark, and seven of those buried the
   library underneath. Cards wrap at a readable width and the whole set is
   visible at once, which is what a list of seven brands should be. */
#brand-list {
  display: grid; gap: var(--s3);
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
}
.logo-row {
  display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s2) var(--s3);
  align-items: start; padding: var(--s3);
  background: var(--surface-2); border: 1.5px solid var(--line); border-radius: var(--r);
}
.logo-row.is-active { border-color: var(--accent); }
.logo-pick { text-align: left; cursor: pointer; padding: 0; }
.logo-name { font-size: 13px; font-weight: 650; letter-spacing: .2px; }
.logo-row.is-active .logo-name { color: var(--accent-text); }
.logo-drop { grid-column: 2; grid-row: 1; }
.logo-pair { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; gap: var(--s3); }
@media (max-width: 420px) { .logo-pair { grid-template-columns: 1fr; } }
.logo-slot { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
.logo-slot-lbl { font-size: 11px; color: var(--text-3); letter-spacing: .3px; }
.logo-slot-art {
  height: 56px; display: grid; place-items: center; padding: var(--s2);
  border-radius: var(--r); border: 1px solid var(--line); overflow: hidden;
}
.logo-slot-art img { display: block; max-height: 48px; max-width: 100%; object-fit: contain; }
.logo-slot-dark .logo-slot-art { background: #101010; }
.logo-slot-light .logo-slot-art { background: #ffffff; }
.logo-slot-empty { font-size: 10px; color: #8c8c8c; text-align: center; padding: 0 var(--s2); }
.input-sm { min-height: 32px; font-size: 12px; padding: 0 var(--s2); }

/* ── Install to home screen ──────────────────────────────────────────────── */
/* Sits above the bottom navigation rather than over it, so the app stays
   usable while the offer is on screen and ignoring it costs nothing. */
.pwa-install {
  position: fixed;
  left: var(--s3); right: var(--s3);
  /* Same z-index as a toast and the same corner of the window, so the same
     clearance: without it this panel covers Print, PDF and Send too. */
  bottom: calc(var(--bottomnav-h) + var(--bottom-bar-clear) + var(--s3));
  z-index: var(--z-toast);
  display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
  padding: var(--s3) var(--s4);
  background: var(--surface); border: 1px solid var(--line-2);
  border-radius: var(--r-lg); box-shadow: var(--sh-3);
  animation: pwa-rise var(--dur) var(--ease-out) both;
}
@keyframes pwa-rise { from { opacity: 0; transform: translateY(12px); } }
@media (prefers-reduced-motion: reduce) { .pwa-install { animation: none; } }

.pwa-install-icon { border-radius: var(--r); flex: none; }
.pwa-install-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 140px; }
.pwa-install-actions { display: flex; gap: var(--s2); margin-left: auto; }

/* Desktop never sees it, but a stray width change should not park it mid-screen. */
@media (min-width: 768px) { .pwa-install { display: none !important; } }

/* ── Installed app: hold the frame still ─────────────────────────────────── */
/* A page scrolls sideways the moment one child is wider than the screen, and
   in a browser that reads as a wide page. Installed, with no address bar to
   anchor it, it reads as the app coming apart. Nothing here is allowed to
   push the document sideways; anything genuinely wide scrolls inside itself. */
/* overflow-x is NOT hidden on html or body, deliberately.
   "overflow: hidden" on one axis makes the element a scroll container on both,
   and a scroll container the page is not expecting swallows a one finger drag:
   in the installed app the whole document needed two fingers to move. "clip"
   trims the same overflow without creating a scroller, so touch scrolling stays
   where it belongs, on the document. overscroll-behavior-x stops the sideways
   rubber band; the vertical one is the pull the platform expects, so it stays. */
html, body { max-width: 100%; overscroll-behavior-x: none; }
.app { overflow-x: clip; }
@supports not (overflow: clip) {
  /* Older engines: accept the scroller rather than let a wide child stretch
     the page, since only one of the two can be true there. */
  .app { overflow-x: hidden; }
}
.app, .main, .page { max-width: 100%; min-width: 0; }

/* A wide table needs a scroller, but it must stay a table.
   This was ".page table { display: block }", and the invoice paper is inside
   .page: that one declaration turned the line items, the GST breakdown and the
   terms card into stacked blocks and destroyed every column on the document.
   The same trap the canvas rule at the top of this file warns about.
   The scroller goes on a wrapper instead, and the paper is never touched. */
.table-scroll { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.page pre { max-width: 100%; overflow-x: auto; }
/* Data tables in cards, never the document. */
.page .card > .table-scroll > table { min-width: max-content; }

/* Long unbroken strings (an email, a share URL, a GST number) are the other
   way a card grows past the viewport.
   break-word, not anywhere. "anywhere" also counts as a break opportunity when
   the browser works out min-content width, so a narrow flex or grid child
   collapses to one character per line: that is what happened to the passkey
   credential id on the Security page. break-word only breaks a word that would
   otherwise overflow, which is the whole intent. */
.card, .page-head, .kpi, .empty { min-width: 0; overflow-wrap: break-word; }

/* Top and bottom chrome stay put while the middle scrolls. .topbar is already
   sticky; in standalone it needs a solid ground, because a translucent bar
   over a scrolled page has no browser chrome above it to read against. */
@media (display-mode: standalone) {
  /* Solid rather than translucent: installed there is no browser chrome above
     the bar for a blur to read against. */
  .topbar { position: sticky; top: 0; background: var(--surface); backdrop-filter: none; }
  .bottomnav { position: fixed; inset: auto 0 0 0; background: var(--surface); backdrop-filter: none; }
  /* Deliberately no "body { position: fixed }" here. Pinning the body and
     making .app the only scroller does hold the two bars still, and it also
     takes scrolling away from every pane that manages its own, which is most
     of the studio: the details panel opened cropped at both ends with no way
     to reach the rest of it. Sticky positioning already keeps the bars in
     place, and it does not decide who scrolls. */
}

/* The reporting period buttons wrapped on top of each other on a phone: four
   of them in a row that was never allowed to become two. */
.segmented { flex-wrap: wrap; row-gap: var(--s2); }
@media (max-width: 520px) {
  .segmented { width: 100%; display: grid; grid-template-columns: repeat(2, 1fr); }
  .segmented button { width: 100%; }
  #range-custom { width: 100%; }
  #range-custom .input { flex: 1 1 40%; min-width: 0; }
}

/* A subtitle is a sentence. Without a minimum it was laid out beside the
   heading in a column a few characters wide, one word per line. */
.page-head-text { min-width: min(100%, 18rem); }
.page-head-text p { max-width: 62ch; text-wrap: pretty; }

/* Print builds a fresh A4 document from the invoice on screen, and on a phone
   that element is not rendered, so the exporter has nothing to lay out. The
   PDF buttons do the same job and work here. */
@media (max-width: 767px) { [data-act="print"] { display: none !important; } }

/* ── Installed app: layout corrections ───────────────────────────────────── */
/* The studio's pane switcher is sticky under a sticky topbar. Without a top
   offset it sat at y=0, which is behind the bar, so the tabs and the first
   field both read as overlapping it. */
@media (max-width: 767px) {
  .studio-switch {
    position: sticky; top: var(--topbar-h); z-index: calc(var(--z-sticky) - 1);
    background: var(--bg); padding: var(--s2) 0;
    margin-top: 0;
  }
  /* The page already carries the topbar's height above it; a second margin
     here is the gap that looked like an empty screen. */
  .studio-page { padding-top: var(--s2); }

  /* Every pane keeps its content inside the viewport. A single input at
     100% + padding inside a grid column that was never told it may shrink is
     what pushed the right edge off screen. */
  .workspace, .workspace-pane, .studio-grid { min-width: 0; max-width: 100%; }
  .studio-grid > * { min-width: 0; }
  .input, .select, .textarea { max-width: 100%; }

  /* Room to scroll past the bottom bar, on every screen rather than only the
     ones that remembered to ask for it. */
  /* .main already reserves the bottom bar's height, so adding it again here
     is the empty screen that appeared under the Send form. Only the breathing
     room belongs to the page. */
  .page { padding-bottom: var(--s6); }
}


/* Invoice rows are a grid of fixed columns; on a phone the row was wider than
   the screen and the whole list slid sideways. Let the cells shrink and the
   long values wrap instead. */
@media (max-width: 767px) {
  .inv-row, .list-row, .row-grid { min-width: 0; }
  .inv-row > *, .list-row > * { min-width: 0; overflow-wrap: anywhere; }
}

/* ── Bulk selection ──────────────────────────────────────────────────────── */
.cell-pick { width: 34px; padding-right: 0 !important; }
.pick {
  display: inline-flex; align-items: center; justify-content: center;
  /* A 34px cell with a 16px box leaves a target too small for a thumb, so the
     label carries the hit area rather than the input. */
  min-width: 34px; min-height: 34px; margin: -8px 0; cursor: pointer;
}
.pick input { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }

/* The settings drawer is reached from the navigation sheet on a phone, so it
   has to sit above it rather than at --z-drawer underneath --z-modal. Closing
   the sheet is still the right thing to do and settings.js does it; this makes
   the drawer usable even in the moment before that happens. */
#settings-scrim { z-index: calc(var(--z-modal) + 4); }
#settings-drawer { z-index: calc(var(--z-modal) + 5); }
tr[aria-selected="true"] { background: var(--accent-soft); }

/* While this bar is up it is the thing pinned to the foot of the window, so it
   claims the same clearance the action bar claims and a toast passes above it
   rather than onto it. :not([hidden]) because the invoice list renders the bar
   with the page and hides it until something is ticked, while the catalogue and
   the file library build theirs only when they need one.
   The floor is one row of buttons. The bar wraps to two when the window is
   narrow and the buttons are many, and nothing measures it, so in that case a
   toast can still reach the upper row. */
:root:has(.bulk-bar:not([hidden])) {
  /* The bar's own lift off the navigation, then its border, padding and one row
     of buttons. */
  --bottom-bar-floor: calc(var(--s3) + 1px + var(--s3) * 2 + 44px + env(safe-area-inset-bottom, 0px));
}

.bulk-bar {
  position: fixed; z-index: var(--z-sticky);
  left: var(--s4); right: var(--s4);
  bottom: calc(var(--bottomnav-h) + var(--s3));
  display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
  padding: var(--s3) var(--s4);
  background: var(--surface); border: 1px solid var(--line-2);
  border-radius: var(--r-lg); box-shadow: var(--sh-3);
}
.bulk-count { font-weight: 600; font-size: 14px; }
.bulk-actions { display: flex; gap: var(--s2); margin-left: auto; }
@media (min-width: 1024px) {
  /* No bottom navigation on a desktop, so it sits at the foot of the window,
     and centred rather than stretched across a very wide screen. */
  .bulk-bar { bottom: var(--s5); left: 50%; right: auto; transform: translateX(-50%);
              min-width: 420px; }
}

/* A row of choices inside a sheet: the label carries the hit area so the text
   is tappable, not just the 16px box beside it. */
.pick-row {
  display: flex; align-items: flex-start; gap: var(--s3);
  padding: var(--s2) 0; cursor: pointer; font-size: 14px;
}
.pick-row input { width: 16px; height: 16px; margin-top: 3px; flex: none; accent-color: var(--accent); }

/* ── Products, as a sheet ────────────────────────────────────────────────── */
/* Dense on purpose: the point of the table is seeing forty products at once
   and changing three of them, so the rows are tighter than a reading table
   and every cell is the input itself rather than text that turns into one. */
.table-sheet { font-size: 13px; }
.table-sheet th { white-space: nowrap; }
.table-sheet td { padding: var(--s1) var(--s2); vertical-align: middle; }
.table-sheet .input-sm { width: 100%; min-height: 30px; }
/* An edited row is marked until it is saved, so nothing is lost by scrolling
   away from it. */
.table-sheet tr.is-edited td { background: var(--accent-soft); }
.table-sheet tr.is-edited td:first-child { box-shadow: inset 3px 0 0 var(--accent); }

/* Sixteen fields is wider than any screen, so the sheet scrolls sideways. The
   tick and the name stay behind while it does: a row of boxes with nothing
   saying which product they belong to cannot be filled in safely. */
.table-sheet th.cell-pick, .table-sheet td.cell-pick { position: sticky; left: 0; z-index: 2; width: 42px; background: var(--surface); }
.table-sheet th:nth-child(2), .table-sheet td:nth-child(2) { position: sticky; left: 42px; z-index: 2; background: var(--surface); }
.table-sheet thead th.cell-pick, .table-sheet thead th:nth-child(2) { z-index: 3; }
.table-sheet td:nth-child(2)::after, .table-sheet th:nth-child(2)::after {
  content: ''; position: absolute; top: 0; right: -1px; bottom: 0; width: 1px; background: var(--line);
}
.table-sheet tr.is-edited td.cell-pick, .table-sheet tr.is-edited td:nth-child(2) { background: var(--accent-soft); }

/* The headings stay while the rows scroll. Eighteen columns over forty products
   means the labels are off the top of the screen by the third row, and a column
   of numbers with no heading above it is a column of numbers.

   The sheet scrolls inside its own box rather than with the page, because
   position: sticky needs a scrolling ancestor with a height to stick within.
   The height is the viewport less the toolbar and the page's own chrome. */
.table-wrap.table-wrap-sheet {
  max-height: calc(100vh - 260px);
  min-height: 320px;
  overflow: auto;
}
.table-sheet thead th {
  position: sticky; top: 0; z-index: 3;
  background: var(--surface-2, var(--surface));
  /* A border on a sticky cell scrolls away with the box it is drawn on, so the
     line under the headings is a shadow, which does not. */
  box-shadow: inset 0 -1px 0 var(--line);
}
/* The two corners are sticky in both directions at once and have to sit above
   both the row that scrolls under them and the column that scrolls beside. */
.table-sheet thead th.cell-pick,
.table-sheet thead th:nth-child(2) { z-index: 5; background: var(--surface-2, var(--surface)); }

/* ── Diagnostics ───────────────────────────────────────────────────────────
   Settings, Diagnostics: one line per check, and the two logs underneath.
   The state is a word rather than a colour alone, so the report still reads
   when it is pasted into a message, which is what usually happens to it. */
.diag-area { display: flex; flex-direction: column; gap: 2px; }
/* The state, then the check and what it found stacked beside it. Three
   columns read better on a wide screen, but this panel lives in a drawer whose
   width has nothing to do with the window's, so a media query cannot tell when
   there is room and the detail ended up in a two word column. */
.diag-row {
  display: grid; grid-template-columns: 4.6rem 1fr;
  align-items: start; gap: 2px var(--s3);
  padding: var(--s2) 0;
}
.diag-row + .diag-row { border-top: 1px solid var(--line); }
.diag-row .badge { justify-self: start; }
.diag-label { font-size: 13px; font-weight: 550; color: var(--text); }
.diag-detail { grid-column: 2; font-size: 12px; color: var(--text-3); overflow-wrap: anywhere; }

/* Long enough to hold a session's worth of lines, short enough that the panel
   below it is still reachable without a long scroll. */
.diag-log {
  max-height: 16rem; overflow-y: auto; overscroll-behavior: contain;
  border: 1px solid var(--line); border-radius: var(--r);
  background: var(--surface-2); padding: var(--s3);
  margin-bottom: var(--s4);
}
.diag-line {
  font-family: var(--font-mono, ui-monospace, monospace);
  font-size: 11px; line-height: 1.6; color: var(--text-2);
  padding: 3px 0; overflow-wrap: anywhere;
  /* The log is written with tabs between the time, the kind and the message,
     and HTML would collapse all three columns into one space. */
  white-space: pre-wrap; tab-size: 4;
}
.diag-line + .diag-line { border-top: 1px solid var(--line); }

/* A message as the customer will read it: the line breaks kept, and the long
   share links wrapped instead of pushing the panel sideways. */
.msg-preview {
  white-space: pre-wrap; overflow-wrap: anywhere;
  font-size: 13px; line-height: 1.6; color: var(--text-2);
}

/* ══ Products ═════════════════════════════════════════════════════════════════════════
   Written into assets.css to begin with, which is the Brand library's own
   sheet and is not loaded on catalog.php. .product-shot therefore had no
   width and no overflow, so a thumbnail that was in the markup all along
   rendered sixteen pixels wide and looked like a missing picture. */

/* ── A product's square ───────────────────────────────────────────────────
   Uploaded pictures are cropped square and stored as WebP, so the grid can
   show them at one size. The box holds its shape whether or not there is a
   picture in it: a card with none must not be a different height from the one
   beside it. */
.product-shot {
  flex: none; width: 88px; height: 88px;
  border-radius: var(--r); overflow: hidden;
  background: var(--surface-2); border: 1px solid var(--line);
  display: grid; place-items: center;
}
.product-shot-sm { width: 64px; height: 64px; }
/* The size a table row can carry without every row growing to fit it. */
.product-shot-xs { width: 40px; height: 40px; border-radius: var(--r-sm, 6px); }
.product-shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.product-shot-empty { color: var(--text-3); display: grid; place-items: center; }

/* ── What a product goes out with ─────────────────────────────────────────
   One line per attachment: what it is, what it is called, how big. The kind
   sits first and at a fixed width so a column of five reads as a list rather
   than as five sentences of different lengths. */
.file-list-items { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
.file-row {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 8px; border-radius: var(--r); border: 1px solid var(--line);
  background: var(--surface-2); font-size: 13px;
}
.file-kind {
  flex: none; min-width: 96px; font-size: 10px; letter-spacing: 1px;
  text-transform: uppercase; color: var(--text-3);
}
.file-title { flex: 1; min-width: 0; }
/* The asset behind it has been deleted, so the promise cannot be kept. The row
   stays visible for exactly that reason: removing it quietly would hide it. */
.file-row.is-missing { border-color: var(--danger-line, var(--line)); opacity: 0.75; }

/* A product's details: label above value, two to a row, nothing shown for a
   field nobody filled in. The server has already dropped those, so anything
   drawn here is real. */
/* A product's details on its card: label and value on one line, one field per
   line, ten of them in the space the old layout used for four.

   The old rule asked for as many equal columns as would fit at 96px. On a card
   260px wide that resolves to one column, and one column of a dt/dd list is a
   label on its own line and its value on the next: "Model", "56432", "Length",
   "244" reads as eight facts rather than four, and a pool with three dimensions
   filled the whole card.

   Two columns, the first as wide as the longest label and no wider, is the
   shape a specification has on paper. */
.product-specs {
  margin: 0; display: grid; gap: 1px 10px;
  grid-template-columns: max-content minmax(0, 1fr);
  align-items: baseline;
  font-size: 11.5px; line-height: 1.45;
}
.product-specs dt {
  font-size: 10px; letter-spacing: 0.3px;
  color: var(--text-3); font-weight: 600; white-space: nowrap;
}
.product-specs dd {
  margin: 0; color: var(--text-2); min-width: 0;
  /* One line each. A description that will not fit is trimmed rather than
     allowed to wrap into three, which is what made the card a wall. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Long free text is the exception: accessories and a note are sentences, and a
   sentence cut at one line is a sentence nobody can use. */
.product-specs dd.is-long { white-space: normal; overflow-wrap: anywhere; }

/* ══ Reminders ═══════════════════════════════════════════════════════════════
   Two controls that do not exist anywhere else in this app: a box of things to
   tick, and a row of weekdays. */

/* Products and catalogue files, side by side, each scrolling in its own box so
   a long catalogue cannot push the schedule off the bottom of the sheet. */
.pick-box {
  max-height: 200px; overflow: auto;
  border: 1px solid var(--line); border-radius: var(--r);
  background: var(--surface-2);
}
.pick-row {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 8px; cursor: pointer; font-size: 13px;
  border-bottom: 1px solid var(--line);
}
.pick-row:last-child { border-bottom: 0; }
.pick-row:hover { background: var(--surface); }
.pick-row input { flex: none; accent-color: var(--accent); }
.pick-row:has(input:checked) { background: var(--accent-soft); }

/* The weekdays. Seven targets on one line, each big enough for a thumb, which
   is what stops this being a multi-select nobody can use on a phone. */
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; }
.day {
  min-width: 46px; min-height: 40px; padding: 0 10px;
  border-radius: var(--r); border: 1px solid var(--line);
  background: var(--surface-2); color: var(--text-2);
  font-size: 13px; font-weight: 550; cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.day:hover { background: var(--surface); }
.day.is-on {
  background: var(--accent); border-color: var(--accent);
  /* The accent is dark enough that white reads on it in both themes. */
  color: #fff;
}

/* ══ The product card ════════════════════════════════════════════════════════
   A card is scanned, not read. Somebody looking for the seven metre dome wants
   the name, the price and the size, and wants them in the same place on every
   card so the eye can run down a column without re-reading each one.

   The old card put a 15px semibold name in a 210px column, so "Mushroom dome"
   wrapped with one word stranded on a second line, and the tick beside it
   floated against a title that was sometimes one line and sometimes two. Under
   it, one field was shown out of the sixteen the catalogue holds, and the three
   buttons were pushed into the right corner leaving a strip of nothing beside
   them. */

.pcard {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px;
  border-radius: var(--r-lg);
  border: 1px solid var(--line);
  background: var(--surface);
  /* Nothing sets the height: cards line up on their grid row and a card with
     four fields is not padded out to match one with twelve. */
}
.pcard:hover { border-color: var(--line-2); }

/* Each card its own height. A grid stretches every cell in a row to the tallest
   one, so a product with nine fields left a hand's width of nothing under the
   two beside it, which is the space this whole card was rebuilt to save. */
#product-grid { align-items: start; }

/* ── The title row ────────────────────────────────────────────────────────
   One line, one baseline, four things. align-items:center is what fixes the
   tick: it was against a title box whose height changed with the title. */
.pcard-head {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.pcard-head .pick { flex: none; margin: 0; min-height: 0; }

/* 13.5px and 600, not 15px and 650. The size was chosen for a heading and this
   is a label on a tile: at the smaller size two thirds of the catalogue's names
   fit on one line, and the ones that do not are trimmed rather than wrapped,
   because a name broken across two lines moves everything under it. */
.pcard-name {
  flex: 1;
  min-width: 0;
  margin: 0;
  font-size: 13.5px;
  font-weight: 600;
  line-height: 1.25;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The brand, at the size of a tag rather than of a button. */
.pcard-brand {
  flex: none;
  max-width: 92px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 10px;
  padding: 2px 7px;
}

/* ── Picture, price, description ──────────────────────────────────────────
   Side by side, so the square is not a band of its own across a card that is
   mostly empty underneath it. */
.pcard-body { display: flex; gap: 10px; align-items: flex-start; min-width: 0; }
.pcard-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }

.pcard-price {
  display: flex;
  align-items: baseline;
  gap: 8px;
  font-size: 16px;
  font-weight: 650;
  line-height: 1.1;
}
/* The rate beside the price rather than a badge on its own row: it is a
   qualifier on the number, and it was taking a whole line to say so. */
.pcard-rate {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.2px;
  color: var(--text-3);
  white-space: nowrap;
}

/* Two lines, then trimmed. A description is context, not the point of the card,
   and left alone the long ones set the height of the whole grid row. */
.pcard-desc {
  margin: 0;
  font-size: 11.5px;
  line-height: 1.4;
  color: var(--text-2);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ── Every field the product has ──────────────────────────────────────────
   Label and value on one line, one field per line, in two columns where the
   card is wide enough for two. This is where the empty middle of the old card
   went: sixteen possible fields, each costing one short line instead of one
   paragraph, and the empty ones never drawn at all. */
.pcard-spec {
  margin: 0;
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  gap: 0 10px;
  align-items: baseline;
  font-size: 11px;
  line-height: 1.5;
  border-top: 1px solid var(--line);
  padding-top: 7px;
}
.pcard-spec dt {
  color: var(--text-3);
  font-weight: 600;
  white-space: nowrap;
}
.pcard-spec dd {
  margin: 0;
  color: var(--text-2);
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── The foot ─────────────────────────────────────────────────────────────
   The provenance on the left, the actions on the right, on one row. Two rows
   for six words and three buttons was the other half of the wasted space. */
.pcard-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: auto;
  padding-top: 8px;
  border-top: 1px solid var(--line);
  min-width: 0;
}
.pcard-meta {
  flex: 1;
  min-width: 0;
  font-size: 10.5px;
  color: var(--text-3);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.pcard-acts { flex: none; display: flex; align-items: center; gap: 4px; }
/* 32px rather than the 44px tap target the old card forced: these sit inside a
   dense grid on a desktop, and the phone rule below gives them the height back
   where a thumb is actually doing the pressing. */
.pcard-acts .btn { min-height: 32px; }
.pcard-acts .btn-icon { width: 32px; padding: 0; }

@media (max-width: 720px) {
  /* A thumb needs the room a mouse does not. */
  .pcard-acts .btn { min-height: 40px; }
  .pcard-acts .btn-icon { width: 40px; }
  /* One column of fields: two at this width trims every value to nothing. */
  .pcard-spec { grid-template-columns: max-content minmax(0, 1fr); }
}
