/* ============================================================================
   MiniCRM design system
   ----------------------------------------------------------------------------
   Layers, in order: tokens -> reset -> layout shell -> components -> utilities.
   Three rules hold the whole thing together:
     1. Nothing hard-codes a colour, radius, shadow or duration; everything
        reads a token, so a change lands everywhere at once.
     2. Every interactive element has a visible :focus-visible ring. The app is
        operable end to end without a mouse.
     3. Every colour token is defined twice — once here for the dark theme and
        once in the `[data-theme="light"]` block below. Rule 1 is what makes
        that possible: the two themes differ only in this file's first hundred
        lines, and no component knows which one it is being painted in.

   Two kinds of colour token, and the distinction matters when adding one:
     --x        a fill, a border, a dot. Stays saturated in both themes.
     --x-text   the same hue used as *text*, so it has to earn its contrast
                against the surface behind it — light in the dark theme, dark
                in the light one. These are not interchangeable: `var(--red)`
                as a label is unreadable on white.
   ========================================================================== */

:root {
  color-scheme: dark;

  /* --- surface ramp: --field is always strictly recessed from --panel ------ */
  --bg: #080b14;
  --bg-grad: radial-gradient(1200px 640px at 78% -10%, rgba(77, 124, 255, 0.10), transparent 62%);
  --panel: #101627;
  --panel-2: #161e33;
  --panel-3: #1c2540;
  --field: #0b1120;
  --sidebar-grad: linear-gradient(180deg, var(--panel) 0%, #0d1220 100%);
  --code-bg: #070b16;
  /* theme-color is no longer one pair kept in step by hand. login.html and
     maintenance.html stay on --bg (#080b14 / #f4f6fb): they have no app bar, so
     their top edge really is the page background. index.html and js/theme.js
     write #0f1525 / #fefeff instead, which is .topbar's --float-bg composited —
     the app bar is what sits under the status area there, and matching --bg drew
     a seam across the notch. Reasoning in full at index.html:24-31; the two
     halves are deliberately allowed to differ and must not be "corrected". */

  --border: #212b45;
  --border-strong: #2e3a5c;
  /* Controls only. A card or a table row is identified by its fill and its
     contents; an empty input is identified by its outline and nothing else, so
     WCAG 1.4.11's 3:1 applies to that edge and not to the other two. Split
     rather than raised globally, which would make every hairline in the app
     heavier for one control type's sake. */
  --border-ctl: #505e93;
  --scroll-thumb: var(--border-strong);
  --scroll-thumb-hi: #3b4a75;

  /* Four tiers, each clear of AA on every surface it is used on. */
  --text: #e9edfa;
  --text-dim: #b6c0dd;
  --muted: #98a5c6;
  --faint: #7a87b0;

  /* --- accents ----------------------------------------------------------- */
  /* --accent and --accent-hi are hues — links, rails, rings. --accent-fill is
     the only accent surface white text ever sits on. */
  --accent: #5b86ff;
  --accent-hi: #7ea0ff;
  --accent-press: #4a72e8;
  --accent-fill: #3a63e0;
  --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
  --accent-soft-2: color-mix(in srgb, var(--accent) 22%, transparent);
  --accent-line: color-mix(in srgb, var(--accent) 32%, transparent);
  --accent-text: #a8c0ff;
  --accent-text-hi: #d3deff;
  --on-accent: #fff;

  --green: #3ecf8e;
  --amber: #f5b544;
  --red: #f2635f;
  /* One meaning: this row is not real. */
  --purple: #a978ff;

  --green-text: #64e0ac;
  --amber-text: #f7c76a;
  --red-text: #ff8b87;
  --purple-text: #bd9bff;
  /* The solid red ground that carries ink, split from --red for the same
     reason --green-fill is: the two themes need opposite answers. This theme's
     --red is light enough to carry dark ink at 5.87:1; the light theme's is
     not, and has to go a step darker to carry white. */
  --red-fill: var(--red);
  --on-red: #2a0a0a;
  /* A solid green fill and the ink that goes on it. Split from --green because
     the two themes need opposite answers: this theme's green is a bright mint
     that carries dark ink at about 8:1, while white on it is under 2:1. */
  --green-fill: var(--green);
  --on-green: #05291a;

  /* --- tint scale: three strengths per hue, written once ------------------- */
  /* wash = a fill, wash-2 = the stronger fill, line = a border. A theme
     override only has to restate the percentage. */
  --green-wash: color-mix(in srgb, var(--green) 14%, transparent);
  --green-wash-2: color-mix(in srgb, var(--green) 22%, transparent);
  --green-line: color-mix(in srgb, var(--green) 36%, transparent);
  --amber-wash: color-mix(in srgb, var(--amber) 14%, transparent);
  --amber-line: color-mix(in srgb, var(--amber) 36%, transparent);
  --red-wash: color-mix(in srgb, var(--red) 14%, transparent);
  --red-wash-2: color-mix(in srgb, var(--red) 22%, transparent);
  --red-line: color-mix(in srgb, var(--red) 36%, transparent);
  --purple-wash: color-mix(in srgb, var(--purple) 14%, transparent);
  --purple-line: color-mix(in srgb, var(--purple) 28%, transparent);

  /* --- washes: translucent layers that take the surface behind them -------- */
  --row-hover: rgba(255, 255, 255, .055);
  /* Touch has no hover to borrow, and a press has to be felt at once — so the
     press fill is roughly twice the hover wash rather than the same value. */
  --row-press: rgba(255, 255, 255, .10);
  --tint-1: rgba(255, 255, 255, .012);
  --hairline: rgba(255, 255, 255, .07);
  --overlay: rgba(4, 7, 14, .66);
  --edge-fade: rgba(0, 0, 0, .38);
  /* The frozen-column edge, cast sideways. Not part of the elevation ladder. */
  --pin-shadow: rgba(0, 0, 0, .32);
  /* 72%, not 55%: at 36x4px this is the only mark that says "sheet", and the
     weaker mix measured 2.54:1 in the light theme — under WCAG 1.4.11's 3:1
     for a graphical object. 72% clears it in both themes (3.56 / 4.16). */
  --grabber: color-mix(in srgb, var(--muted) 72%, transparent);

  /* --- floating materials: one blur, one opacity, one scrim ---------------- */
  --float-bg: color-mix(in srgb, var(--panel) 88%, transparent);
  --float-blur: blur(12px);
  --scrim-blur: blur(3px);

  /* The browser draws the date picker's glyph in black; invert it for a dark
     field, leave it alone on a light one. */
  --date-icon: invert(.72);

  /* --- shape: marks, controls, small surfaces, panels, pills --------------- */
  --r-xs: 3px;
  --r-sm: 7px;
  --r: 10px;
  --r-lg: 14px;
  --r-pill: 999px;

  /* --- elevation: a rim, then three cast shadows -------------------------- */
  /* --sh-1 alone moves a pixel by 2/3/6 units over --bg, so it does not read in
     the dark theme. The rim is what actually makes a surface look raised. */
  --rim: inset 0 1px 0 rgba(255, 255, 255, .055);
  --sh-1: 0 1px 2px rgba(0, 0, 0, .28);
  --sh-2: 0 4px 14px -6px rgba(0, 0, 0, .5), 0 1px 2px rgba(0, 0, 0, .3);
  --sh-3: 0 18px 44px -18px rgba(0, 0, 0, .68), 0 2px 8px rgba(0, 0, 0, .34);
  --sh-4: 0 40px 90px -28px rgba(0, 0, 0, .8), 0 4px 14px rgba(0, 0, 0, .4);

  /* --- motion: three durations, two curves, paired by job ------------------ */
  --dur-1: 110ms;
  --dur-2: 190ms;
  --dur-3: 300ms;
  --ease: cubic-bezier(.32, .72, .28, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
  /*
   * A third curve, and only for the two surfaces that move under a finger.
   *
   * --ease-out is a good approximation of an iOS entrance and is the right answer
   * for anything that fades or arrives on its own. It is the wrong answer for a
   * surface the operator is holding: a sheet let go of below its dismiss threshold
   * has to spring back, and a bezier cannot overshoot, so the sheet either
   * decelerated politely into place or — as it actually shipped — snapped home over
   * zero frames because the transform was cleared in the same tick as the class
   * suppressing the animation.
   *
   * `linear()` with an overshoot past 1.0 is what buys the settle. Used by the
   * sheet's spring-back and the drawer's, and by nothing else — a spring on a
   * control that was not being touched reads as a bug.
   */
  --ease-spring: linear(
    0, 0.006, 0.025 2.8%, 0.101 6.1%, 0.539 18.9%, 0.721 25.3%, 0.849 31.5%,
    0.937 38.1%, 0.968 41.8%, 0.991 45.7%, 1.006 50.1%, 1.015 55%, 1.017 63.9%,
    1.001 100%
  );

  /* --- space: seven steps ------------------------------------------------- */
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 24px;
  --sp-6: 32px;
  --sp-7: 48px;

  /* --- metrics ----------------------------------------------------------- */
  --sidebar-w: 232px;
  --topbar-h: 56px;
  /*
   * The bottom tab bar's height — zero here, and 56px only inside the 860px
   * block that draws it. Every layer anchored to the bottom of the screen adds
   * this term to the clearance it already gives the home indicator, so on a
   * desktop, on a landscape phone and in print — none of which have a tab bar —
   * the same calc resolves to exactly what it did before the bar existed. It
   * excludes --safe-b for that reason: the bar pads itself with the inset, and
   * every consumer already had a term for it.
   */
  --tabbar-h: 0px;
  --tap: 40px;
  --ctl-h: 34px;
  --ctl-h-sm: 28px;
  --gutter: 26px;
  --measure: 68ch;

  /* --- the physical screen ------------------------------------------------ */
  /*
   * index.html declares `viewport-fit=cover`, which hands the app the whole
   * display — notch, rounded corners, home indicator and all. Once that is
   * declared the browser stops insetting anything for us, so respecting the
   * safe area becomes this file's job. Named here because env() cannot be
   * reached from a shorthand a component may later want to override.
   */
  --safe-t: env(safe-area-inset-top, 0px);
  --safe-r: env(safe-area-inset-right, 0px);
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-l: env(safe-area-inset-left, 0px);
  /* Custom properties substitute at computed-value time, so these follow
     whichever --gutter rung is in force (42/26/20/15/12) without being
     restated at every breakpoint. */
  --gutter-l: max(var(--gutter), var(--safe-l));
  --gutter-r: max(var(--gutter), var(--safe-r));

  /* --- written by JS; defaulted here so the sheet is correct before it runs - */
  /* Exactly one writer each. Nothing else may set these. */
  --kb-inset: 0px;   /* on-screen keyboard height. WRITER: js/util.js         */
  --bulk-h: 0px;     /* measured bulk-bar height.  WRITER: js/views/leads.js  */

  /* Row geometry lives on tokens so the density switch can move all of it. */
  --row-pad-y: 10px;
  --row-pad-x: 11px;
  --row-lh: 1.35;

  /* --- typeface ----------------------------------------------------------- */
  --mono: ui-monospace, SFMono-Regular, "Cascadia Mono", "Cascadia Code", Consolas, monospace;
  /* system-ui first so Windows 11 resolves Segoe UI Variable, which is better
     fitted at 11-13px than the static family. */
  --sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

  /* --- weight: three, because Windows 10's static Segoe UI ships three ----- */
  --w-regular: 400;
  --w-medium: 600;
  --w-bold: 700;

  /* --- type ramp ---------------------------------------------------------- */
  /*
   * In `rem`, and that is the whole point of the unit here: `rem` resolves
   * against the root font size, which is the one thing in a page a reader is
   * allowed to change. In px this ramp was deaf to every text-size setting on
   * every platform — Android Chrome's accessibility font scale, a desktop
   * browser's minimum size, and iOS's Larger Text, which is the single
   * most-used accessibility setting on the device this app is read on.
   *
   * The px equivalent at a 16px root is kept beside each step, because a dozen
   * comments in this file argue in px and several rules still compute against
   * these values with `calc()`. Those stay correct: `calc(0.75rem + 4px)`
   * resolves the same way `calc(11px + 4px)` did.
   *
   * Only *type* is in rem. Space, shape, --tap and every metric stay in px on
   * purpose: a finger does not grow when the text does, and a 44px target that
   * scaled with the type would leave the layout with nothing fixed in it.
   */
  --fs-display: 1.75rem;      /* 28px */
  --lh-display: 1.15;
  --tr-display: -0.02em;

  --fs-title: 1.5rem;         /* 24px */
  --lh-title: 1.2;
  --tr-title: -0.015em;

  --fs-heading: 1.0625rem;    /* 17px */
  --lh-heading: 1.3;

  /* --fs-section is gone: it was byte-identical to --fs-body, so the ramp
     declared a step it did not have and a reader had two names for one size.
     Its leading survives — a section heading does sit tighter than prose. */
  --lh-section: 1.35;
  --fs-body: 0.875rem;        /* 14px */
  --lh-body: 1.5;
  --fs-dense: 0.8125rem;      /* 13px */
  /* --lh-dense is --row-lh for blocks outside a table; the table itself reads
     --row-lh so the density switch can move it. */
  --lh-dense: 1.35;
  --fs-label: 0.75rem;        /* 12px */
  --fs-caption: 0.71875rem;   /* 11.5px */
  --fs-caps: 0.6875rem;       /* 11px */
  --tr-caps: .05em;
  --fs-micro: 0.625rem;       /* 10px */
  /*
   * Positive tracking for the small steps, and it is the opposite of what the
   * ramp does above 17px for a reason: SF Text opens its letterforms up as they
   * get smaller, and this file was tightening the large steps while leaving the
   * small ones set solid — tighter than the system it is imitating, at the sizes
   * read furthest from the eye. Zero here, and only the phone turns it on: it is
   * a correction for a physical viewing distance, not for a size in the abstract.
   */
  --tr-small: 0;
}

/* ------------------------------ light theme ------------------------------ */
/*
 * Set by the theme toggle, which writes `data-theme` on <html> and remembers
 * the choice. Dark stays the default: an installation that never touches the
 * toggle looks exactly as it always did.
 *
 * Only colour is redefined here. Shape, motion and metrics are the same app in
 * either theme, and a token that appears in one block and not the other is a
 * bug waiting for whoever flips the switch.
 */
:root[data-theme="light"] {
  color-scheme: light;

  --bg: #f4f6fb;
  --bg-grad: radial-gradient(1200px 640px at 78% -10%, rgba(47, 95, 224, 0.07), transparent 62%);
  --panel: #ffffff;
  --panel-2: #f1f4fa;
  --panel-3: #e4e9f4;
  /* Recessed 1.055:1 from --panel, mirroring dark's 1.05:1. It was byte-
     identical to --panel, so every input and the progress track were the same
     colour as the card behind them. */
  --field: #f7f9fc;
  --sidebar-grad: linear-gradient(180deg, #ffffff 0%, #eef1f8 100%);
  --code-bg: #f6f8fc;

  --border: #d9e0ec;
  --border-strong: #bcc6d9;
  /* 3.06:1 on --field, where the shared --border is 1.26:1. */
  --border-ctl: #8290ae;
  --scroll-thumb: var(--border-strong);
  --scroll-thumb-hi: #9aa7c0;

  --text: #131a2b;
  /* The taper collapsed at the bottom — --muted and --faint were 1.15:1 apart,
     so three declared tiers delivered two. Darken the middle, not the bottom. */
  --text-dim: #333f5d;
  --muted: #4a5771;
  /* #66748f was 4.28:1 on --panel-2 — under AA for the 11px it is most often
     set at (menu hints, menu headings, table headers, the phone row-cards).
     #5c6a85 measures 4.94:1 on --panel-2 and 5.45:1 on white, and still sits
     1.33:1 back from --muted, so the four-tier taper survives. */
  --faint: #5c6a85;

  --accent: #2f5fe0;
  --accent-hi: #1f47c4;
  --accent-press: #17399f;
  --accent-fill: #2f5fe0;
  --accent-text: #2450cf;
  --accent-text-hi: #1a3fae;
  --on-accent: #fff;
  /* --accent-soft / --accent-soft-2 / --accent-line are not restated: the
     :root color-mix resolves against this block's --accent, so the two themes
     cannot drift apart the way a second literal rgba() did. */

  --green: #12a06a;
  --amber: #c9821a;
  --red: #dc4340;
  --purple: #7c4ddb;

  --green-text: #0a7148;
  /* A step darker than --amber #c9821a for the same reason --green-fill is a
     step darker than --green: amber is the hardest hue to place on white, and
     on --amber-wash the border hue itself measured 4.35:1. #8a590b clears AA
     on the wash and on --panel. Not a mistake to be "corrected" back. */
  --amber-text: #8a590b;
  --red-text: #c02320;
  --purple-text: #6134c0;
  /* This theme's --red #dc4340 carries neither ink at AA — dark is 4.29:1 and
     white is 4.27:1 — so the fill itself is the problem. #b81f1c takes white
     to 6.4:1. --red keeps its hue for every border, dot and wash. */
  --red-fill: #b81f1c;
  --on-red: #fff;
  /* --green at 3.4:1 is short of the 4.5 an 11px glyph needs, so the fill goes
     a step darker than the hue used for borders and washes. White on #0a7148
     measures about 5.2:1. */
  --green-fill: #0a7148;
  --on-green: #fff;

  /* Only the percentages that need more presence on white are restated. Amber
     at 10% over --panel #101627 is 1.198:1; over white it is 1.102:1. 18% over
     white measures 1.199:1, which is the dark theme's weight. */
  --green-wash: color-mix(in srgb, var(--green) 18%, transparent);
  --green-wash-2: color-mix(in srgb, var(--green) 26%, transparent);
  --green-line: color-mix(in srgb, var(--green) 44%, transparent);
  --amber-wash: color-mix(in srgb, var(--amber) 18%, transparent);
  --amber-line: color-mix(in srgb, var(--amber) 44%, transparent);

  /* Ink rather than white, and a shade cooler than pure black so the washes
     tint towards the app's blue instead of turning surfaces grey. */
  --row-hover: rgba(20, 30, 55, .055);
  --row-press: rgba(20, 30, 55, .09);
  --tint-1: rgba(20, 30, 55, .022);
  --hairline: rgba(20, 30, 55, .12);
  --overlay: rgba(17, 24, 43, .38);
  --edge-fade: rgba(20, 30, 55, .16);
  --pin-shadow: rgba(20, 30, 55, .14);

  --date-icon: none;

  /* Shadows on a light ground read as dirt long before they read as depth, so
     every step is roughly a third of its dark-theme opacity. A rim would only
     fog the top edge here, so it is a deliberate no-op. */
  --rim: inset 0 1px 0 rgba(255, 255, 255, 0);
  --sh-1: 0 1px 2px rgba(20, 30, 55, .07);
  --sh-2: 0 4px 14px -6px rgba(20, 30, 55, .16), 0 1px 2px rgba(20, 30, 55, .06);
  /* --sh-3 is the floating tier — toasts, menus, the bulk bar — and a soft cast
     shadow alone leaves a toast at roughly 1.6:1 against a pale page, i.e. a
     transient layer that does not read as being on top of anything. The third
     layer is a rim, not a blur: it buys the boundary back at a hard 3:1 without
     adding the smear the note above is about. Light theme only. */
  --sh-3: 0 18px 44px -18px rgba(20, 30, 55, .24),
          0 2px 8px rgba(20, 30, 55, .10),
          0 0 0 1px rgba(20, 30, 55, .10);
  --sh-4: 0 40px 90px -28px rgba(20, 30, 55, .28), 0 4px 14px rgba(20, 30, 55, .10);
}

/* Density is a scope, not an override list. The class is on the table wrapper,
   so it only has to restate three numbers and the whole feature follows. */
.density-compact {
  --row-pad-y: 4px;
  --row-pad-x: 8px;
  --row-lh: 1.25;
}

/* ------------------------------- reset --------------------------------- */

*, *::before, *::after { box-sizing: border-box; }

/* No `-webkit-text-size-adjust: 100%`. With a correct width=device-width
   viewport meta Safari's legacy text inflation never engages anyway, so the
   declaration's only live effect was opting the app out of Android Chrome's
   accessibility font-scale — the one text-size setting a low-vision operator
   has already configured. */
html {
  scrollbar-color: var(--scroll-thumb) transparent;
}

html, body { margin: 0; min-height: 100%; }

/*
 * Touch semantics, stated once.
 *
 * iOS paints its own grey flash on the element it guesses is the tap target,
 * which for a row whose handler is delegated to <tbody> is the wrong box
 * entirely — a cell lights up and the thing that acts does not. It comes off,
 * and in exchange every pressable surface in this file owes a real :active
 * state. `a:active` below is the one that had none.
 */
html { -webkit-tap-highlight-color: transparent; }

/*
 * `manipulation` drops the 300ms wait the browser spends deciding whether a tap
 * was the first half of a double-tap-to-zoom. Listed control by control rather
 * than declared on a container: a dense table still has to be pinchable, which
 * is why .table-wrap is deliberately absent.
 */
.btn, button, .tab, .pill, .nav-item, .menu-item, .switch, .icon-btn,
.cell-tick, label.inline, .filter-chip .chip-x, a, summary,
input[type="checkbox"], input[type="radio"] { touch-action: manipulation; }

body {
  background: var(--bg);
  background-image: var(--bg-grad);
  background-attachment: fixed;
  color: var(--text);
  font: var(--fs-body)/var(--lh-body) var(--sans);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-wrap: break-word;
}

/*
 * The lock behind an open sheet or drawer.
 *
 * `overflow: hidden` on <body> only ever reached the viewport by propagation,
 * and propagation stops the moment the root carries an overflow of its own —
 * so the one declaration doing the work was the one least in the file's
 * control. Saying it on the element that actually scrolls closes that.
 *
 * Deliberately not the `position: fixed` variant. Fixing the body needs JS to
 * capture the scroll offset on the way in and write it back on the way out, and
 * any path that misses the restore drops the operator at the top of a list they
 * were three screens down — a worse failure than the one being fixed, and a
 * trade that has to be made in util.js, which owns the class.
 *
 * util.js's lockScroll/unlockScroll are that sole owner, and they refcount, so
 * a dialog opened from the drawer does not unlock the page when only one of the
 * two closes. Nothing else may add or remove `no-scroll`.
 */
body.no-scroll { overflow: hidden; }
html:has(> body.no-scroll) { overflow: hidden; }

a { color: var(--accent-hi); text-decoration: none; }
/* Gated with the rest of them — see the note beside .version-badge. An
   underline that arrives on tap and stays until the next tap is the loudest
   latch in the file, because it also reflows nothing and so cannot be
   mistaken for anything but a state. */
@media (hover: hover) {
  a:hover { text-decoration: underline; }
}
/* The one press state the tap highlight was silently providing. A link is the
   only interactive element in the file with no chrome of its own to darken. */
@media (hover: none) {
  a:active { opacity: .6; }
}

/* Tracking follows size and nothing else: -0.02em at >=28px, -0.015em from
   17-24px, nothing below 17px. A blanket rule on h1-h4 tightened section
   headings below the size where tightening helps. */

::selection { background: var(--accent-line); color: var(--text); }

/*
 * One focus treatment for everything that can take focus, delivered as an
 * `outline` rather than a `box-shadow`.
 *
 * `:focus-visible` is a pseudo-class at specificity (0,1,0), so any later class
 * rule that declares `box-shadow` — .btn-primary, .skip-link, the corner badges
 * — used to take the property back and render no ring at all. `outline` is a
 * property nothing else is competing for, and it follows each element's own
 * border-radius, so pills and circles keep their shape.
 */
:focus { outline: none; }
:focus-visible {
  outline: 2px solid var(--accent-hi);
  outline-offset: 2px;
  position: relative;
  z-index: 3;
}

/* Scrollbars, so long lists do not look like a different app. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--scroll-thumb);
  border-radius: var(--r-pill);
  border: 2px solid transparent;
  background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hi); background-clip: padding-box; }

.skip-link {
  position: fixed; top: 8px; left: 8px; z-index: 200;
  transform: translateY(-160%);
  padding: 10px 16px; border-radius: var(--r);
  background: var(--accent-fill); color: var(--on-accent); font-weight: var(--w-medium);
  box-shadow: var(--sh-3);
  transition: transform var(--dur-2) var(--ease-out);
}
.skip-link:focus-visible { transform: translateY(0); text-decoration: none; }

/* ------------------------------- shell --------------------------------- */

#app {
  display: grid;
  grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
  grid-template-areas: "sidebar main";
  min-height: 100vh;
  min-height: 100dvh;
}

/* None of the three exist at desktop widths, where the permanent sidebar is the
   whole of the navigation. The first two come back in the phone-shell block
   below; the tab bar comes back in a narrower query of its own — a landscape
   phone is in that shell too and keeps the hamburger instead. */
.topbar { display: none; }
.nav-scrim { display: none; }
.tabbar { display: none; }

.sidebar {
  grid-area: sidebar;
  background: var(--sidebar-grad);
  border-right: 1px solid var(--border);
  padding: 16px 12px 14px;
  display: flex;
  flex-direction: column;
  gap: 3px;
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100dvh;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.brand {
  display: flex; align-items: center; gap: 10px;
  padding: 6px 10px 22px;
  font-weight: var(--w-bold); font-size: var(--fs-heading); letter-spacing: var(--tr-title);
}
/* min-width:0 so a long network name ellipsises inside the sidebar instead of
   widening the whole grid column and pushing the layout sideways. */
/* Scoped to .brand. These were bare element-level names, and "brand" means two
   different things in this product — the network's own name up here, and the
   advertiser a lead was sent to down in the table. An unscoped .brand-name set
   every Brand cell in the leads table at 24px bold. */
.brand .brand-text { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.brand .brand-name {
  font-size: var(--fs-title); font-weight: var(--w-bold); letter-spacing: var(--tr-display);
  line-height: 1.1; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The product name, deliberately quiet: it is the same on every installation,
   so it identifies nothing and should not compete with the name that does. */
.brand .brand-sub {
  font-size: var(--fs-caps); font-weight: var(--w-medium); letter-spacing: var(--tr-caps);
  line-height: 1.2; color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Only the app bar has one. In the sidebar the permanent nav rows already say
   which page you are on, and a title above them would be a third copy of it. */
.topbar-title { display: none; }
.brand .dot {
  width: 11px; height: 11px; border-radius: 50%;
  background: linear-gradient(140deg, var(--accent-hi), var(--accent));
  box-shadow: 0 0 0 3px var(--accent-soft), 0 0 12px var(--accent-line);
  flex: none;
}

.nav-item {
  /* justify-content is spelled out because the base `button` rule centres its
     content, which would push the <button> sign-out row out of line with the
     <a> rows beside it. */
  display: flex; align-items: center; justify-content: flex-start; gap: 11px;
  min-height: var(--tap);
  padding: 9px 11px;
  border: 0; width: 100%;
  border-radius: var(--r-sm);
  background: transparent;
  color: var(--muted);
  font: inherit; font-weight: var(--w-regular); text-align: left;
  cursor: pointer; user-select: none;
  position: relative;
  transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
}
/* The drawer is the one place a latch is genuinely confusing: tap a
   destination, the row keeps its lit fill, and the next time the drawer opens
   a row you are not on sits a shade away from .active. */
@media (hover: hover) {
  .nav-item:hover { background: var(--panel-2); color: var(--text); text-decoration: none; }
}
.nav-item:active { background: var(--panel-3); }
.nav-item.active { background: var(--accent-soft); color: var(--accent-text-hi); }
/* The active rail reads at a glance without relying on colour alone. */
/* Inset from the sidebar edge, so it reads as a rail rather than a stray mark
   pinned to the very edge of the window. */
.nav-item.active::before {
  content: ""; position: absolute; left: -6px; top: 50%;
  width: 3px; height: 18px; margin-top: -9px;
  border-radius: var(--r-pill);
  background: var(--accent-hi);
}
.nav-item .ico { display: inline-flex; width: 18px; height: 18px; opacity: .85; flex: none; }
.nav-item.active .ico { opacity: 1; }
.nav-spacer { flex: 1 1 auto; min-height: 12px; }

/* ---------------------------- network switcher --------------------------- */

/* Built by actionMenu, so it arrives carrying .menu-trigger.has-text — which
   sets its own padding, gap and letter-spacing for a table row. Put the nav
   item's geometry back, and give it a surface: this one names where you are
   rather than linking to a page, and should not read as a sixth destination. */
/* 8 + 1 and 10 + 1 restore the 9/11 content box the border-box border took
   away, so the glyph lands on the same 23px column as the six nav icons. */
.nav-item.nav-switch {
  gap: 11px;
  padding: 8px 10px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  color: var(--text-dim);
  margin-bottom: 6px;
}
@media (hover: hover) {
  .nav-item.nav-switch:hover { background: var(--panel-3); border-color: var(--border-strong); color: var(--text); }
}
/* Its own press beat: .nav-item:active is (0,2,0) and the switcher's surface
   above is (0,2,0) declared later, so the shared one never reaches it. */
.nav-item.nav-switch:active { background: var(--panel-3); border-color: var(--border-strong); }

/* A spacer, not a glyph: "⇄" is already the Integrations nav icon eight rows
   above, and one glyph should mean one thing. The switcher names the network in
   text inside its own bordered box and needs no mark of its own. */
.nav-item.nav-switch::before {
  content: "";
  width: 18px; flex: none;
}
.nav-item.nav-switch .menu-trigger-text {
  flex: 1 1 auto; min-width: 0;
  max-width: none;              /* the sidebar, not 15ch, is the constraint here */
  font-weight: var(--w-medium);
}
.nav-item.nav-switch .caret { flex: none; margin-left: 4px; }

.main {
  grid-area: main;
  padding: var(--sp-6) var(--gutter) var(--sp-7);
  min-width: 0;
}

/* One rule for the rhythm between top-level blocks on a page. The second
   selector reaches the views that mount their blocks inside a bare host div. */
.main > * + *, .main > div > * + * { margin-top: var(--sp-4); }

/* ---------------------------- corner badges ----------------------------- */

/* The build badge and the update notification share this row. It owns the
   fixed positioning so the badges themselves stay ordinary inline elements and
   can also be dropped into the app bar on a phone. */
.corner-badges {
  position: fixed;
  top: 12px; right: var(--gutter);
  z-index: 40;
  display: flex; align-items: center; gap: var(--sp-2);
}

/* Opaque, which is what the phone block already does. Translucency here bought
   nothing — these badges mostly float over empty page background — and it made
   the two halves of a deliberately matched cluster disagree on hover. */
.version-badge {
  display: inline-flex; align-items: center; gap: 7px;
  min-height: var(--ctl-h-sm);
  padding: 4px 11px;
  border-radius: var(--r-pill);
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--muted);
  font-size: var(--fs-caption); font-family: var(--mono);
  cursor: pointer; user-select: none;
  box-shadow: var(--rim), var(--sh-1);
  transition: color var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease),
              transform var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}
/*
 * Every :hover in this file is gated behind a device that can actually hover.
 *
 * iOS has no hover, so it synthesises one on tap and then leaves it latched
 * until something else is tapped — a badge that lifts, a row that stays lit, a
 * button stuck in its hover fill. The gate is written around each rule where
 * the rule already lives rather than collected at the foot of the file,
 * because several of these are load-bearing on source order.
 *
 * `(hover: hover)` alone, deliberately: `pointer` reports the *primary*
 * pointing device, not whether a mouse exists. A Surface with the keyboard
 * folded back, a touchscreen all-in-one and a Windows tablet all report
 * `pointer: coarse` while rendering the full desktop shell at 1368px and
 * wider — pairing the two would have switched every hover affordance in the
 * app off on exactly the machines that still have a cursor on screen.
 * `(hover: none)` is its exact complement, which is what the press-state
 * blocks below key off.
 *
 * The :active counterparts stay outside the gate: a press is real on both.
 *
 * Four rules are deliberately not gated, and they are the only four: the
 * scrollbar thumb, which touch never reaches, and the three that name :hover
 * and :active in one selector list — .fraud-pill.static, .th-sort and
 * .col-hit. Those are press states that happen to answer to a cursor too, and
 * two of them exist to cancel a lift the base control rule would otherwise
 * apply, so gating them would put the lift back on touch.
 */
@media (hover: hover) {
  .version-badge:hover { color: var(--text); border-color: var(--border-strong); transform: translateY(-1px); box-shadow: var(--rim), var(--sh-2); }
}
.version-badge:active { transform: translateY(0); }
.version-badge .env { color: var(--accent-hi); font-weight: var(--w-medium); }
.version-badge .hash { opacity: .7; }
.version-badge .dot-live {
  width: 6px; height: 6px; border-radius: 50%; background: var(--green);
  box-shadow: 0 0 0 3px var(--green-wash);
}

/* ------------------------------ theme toggle ----------------------------- */

/*
 * Rides in the corner row beside the build badge, and moves into the app bar on
 * a phone with it. Sized to match the badge so the two read as one cluster
 * rather than a control that wandered in.
 */
.theme-toggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--ctl-h-sm); height: var(--ctl-h-sm);
  min-height: 0; padding: 0; flex: none;
  border-radius: var(--r-pill);
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--muted);
  cursor: pointer;
  box-shadow: var(--rim), var(--sh-1);
  transition: color var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease),
              transform var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}
/* No background of its own: `button:hover` now applies to both badges
   identically, which is what stopped them diverging. */
@media (hover: hover) {
  .theme-toggle:hover {
    color: var(--text); border-color: var(--border-strong); transform: translateY(-1px);
    box-shadow: var(--rim), var(--sh-2);
  }
}
.theme-toggle:active { transform: translateY(0); }
.theme-toggle svg { display: block; }

/* Only one of the two glyphs is ever drawn: the icon shows the theme you would
   get by pressing it, not the one you are already in. */
.theme-toggle .ico-sun { display: none; }
:root[data-theme="light"] .theme-toggle .ico-sun { display: block; }
:root[data-theme="light"] .theme-toggle .ico-moon { display: none; }

/* Its phone size lives with the rest of the app bar, in the shell block. */

/* --------------------------- update available --------------------------- */

/* Sits to the left of the build badge and is the only accent-coloured thing in
   the corner, so it reads as "something is waiting for you" without being an
   interruption. Nothing here blocks the page — the operator picks the moment. */
.update-badge {
  order: -1;
  display: inline-flex; align-items: center; gap: 7px;
  min-height: var(--ctl-h-sm);
  padding: 4px 12px;
  border-radius: var(--r-pill);
  border: 1px solid var(--accent-line);
  background: var(--accent-soft);
  color: var(--accent-hi);
  font-size: var(--fs-caption); font-weight: var(--w-medium);
  cursor: pointer; user-select: none;
  box-shadow: var(--rim), var(--sh-1);
  transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease),
              transform var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}
@media (hover: hover) {
  .update-badge:hover { background: var(--accent-soft-2); transform: translateY(-1px); box-shadow: var(--rim), var(--sh-2); }
}
.update-badge:active { transform: translateY(0); }

.update-badge-dot {
  width: 6px; height: 6px; border-radius: 50%; background: var(--accent-hi);
  /* Three pulses, not forever. The dot's job is to catch the eye once; a mark
     that never stops moving in the corner of a screen an operator works in all
     day is an interruption, which is the one thing this badge is not. */
  animation: update-pulse 2.4s var(--ease) 3;
}
@keyframes update-pulse {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent-hi) 50%, transparent); }
  50%      { box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent-hi) 0%, transparent); }
}
/* A pulsing dot is an accessibility problem for anyone who asked the system to
   stop moving things. The badge is still visible; it just holds still. */
@media (prefers-reduced-motion: reduce) {
  .update-badge-dot { animation: none; }
}

.update-versions {
  display: flex; align-items: center; gap: 18px;
  padding: 14px var(--sp-4); margin-bottom: 18px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: var(--r);
}
.update-arrow { color: var(--muted); font-size: 18px; }
.update-h4 { margin: 0 0 10px; font-size: var(--fs-body); line-height: var(--lh-dense); font-weight: var(--w-medium); }

.change-list { margin: 0; padding: 0; list-style: none; max-height: 220px; overflow-y: auto; }
.change-list li {
  display: flex; align-items: baseline; justify-content: space-between; gap: 14px;
  padding: 8px 0;
  border-bottom: 1px solid var(--border);
}
.change-list li:last-child { border-bottom: 0; }
.change-subject { min-width: 0; overflow-wrap: anywhere; }

/* The consequence of pressing the button, stated before the button is pressed. */
/* The stripe is an inset shadow rather than a 3px border: a border of a
   different width miters across the corner arc and fans the stripe out through
   the curve, where an inset shadow follows the radius exactly. */
.update-warn {
  margin-top: 18px; padding: var(--sp-3) 14px;
  box-shadow: inset 3px 0 0 var(--amber);
  border-radius: var(--r-sm);
  background: var(--amber-wash);
  font-size: var(--fs-dense); color: var(--text-dim);
}
.update-warn strong { color: var(--text); }

.update-progress-panel { padding: 4px 0; }

.progress-bar {
  height: 8px;
  border-radius: var(--r-pill);
  background: var(--field);
  border: 1px solid var(--border);
  overflow: hidden;
}
.progress-fill {
  height: 100%; width: 0%;
  border-radius: var(--r-pill);
  background: var(--accent-fill);
  transition: width .9s var(--ease);
}
/* Before the first reading there is no number to show, so the bar says
   "something is happening" rather than claiming a value it does not have. */
.progress-bar.unknown .progress-fill {
  width: 34% !important;
  opacity: .6;
  animation: progress-sweep 1.5s ease-in-out infinite;
}
@keyframes progress-sweep { 0% { margin-left: -34%; } 100% { margin-left: 100%; } }
.progress-bar.failed .progress-fill { background: var(--amber); animation: none; }

.progress-meta {
  display: flex; justify-content: space-between;
  margin-top: 9px;
  font-size: var(--fs-label); color: var(--faint);
  font-variant-numeric: tabular-nums;
}

@media (prefers-reduced-motion: reduce) {
  .progress-fill { transition: none; }
  .progress-bar.unknown .progress-fill { animation: none; }
}

/* ------------------------------ fraud score ----------------------------- */

.fraud-pill {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 3px 10px 3px 4px;
  border-radius: var(--r-pill);
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--text-dim);
  font-size: var(--fs-caption); font-weight: var(--w-medium);
  cursor: pointer;
  transition: border-color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease),
              box-shadow var(--dur-1) var(--ease);
}
@media (hover: hover) {
  .fraud-pill:hover { border-color: var(--border-strong); transform: translateY(-1px); box-shadow: var(--sh-2); }
}
/* The most-pressed pill in the table had no press beat at all: .fraud-pill:hover
   is (0,2,0) and out-ranks `button:active` at (0,1,1). */
.fraud-pill:active { transform: translateY(0); }
.fraud-pill.static { cursor: default; }
.fraud-pill.static:hover { transform: none; box-shadow: none; }

/* A paid, one-shot check arriving in the cell the operator pressed. */
.fraud-pill.is-new { animation: pill-in var(--dur-2) var(--ease-out) both; }
@keyframes pill-in {
  from { opacity: 0; transform: scale(.92); }
  to { opacity: 1; transform: none; }
}

.fraud-pill-score {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 26px; height: 20px; padding: 0 5px;
  border-radius: var(--r-pill);
  background: var(--panel-3);
  font-family: var(--mono); font-size: 11px;
  font-variant-numeric: tabular-nums;
}
.fraud-pill-label { padding-right: 2px; }


/* Colour carries the same information as the label, never instead of it —
   the band name is always present for anyone who cannot separate the hues. */
.fraud-pill.clean      { border-color: var(--green-line); color: var(--green-text); }
.fraud-pill.clean      .fraud-pill-score { background: var(--green-wash); }
.fraud-pill.suspicious { border-color: var(--amber-line); color: var(--amber-text); }
.fraud-pill.suspicious .fraud-pill-score { background: var(--amber-wash); }
.fraud-pill.high       { border-color: var(--red-line); color: var(--red-text); }
.fraud-pill.high       .fraud-pill-score { background: var(--red-wash); }
.fraud-pill.fraud      { border-color: var(--red); color: var(--text); background: var(--red-wash-2); }
/* --red-fill, not --red: the light theme's --red carries neither ink at AA, so
   the score — which is the payload, at 11px — needs the deeper ground. */
.fraud-pill.fraud      .fraud-pill-score { background: var(--red-fill); color: var(--on-red); }

/*
 * A fraud lead is marked once, at the left edge.
 *
 * It used to be marked four times — this wash, this edge, a solid red pill in
 * the Fraud column, and the red chip in the Lead cell. Four devices for one
 * fact, and with a quarter of the rows washed red the wash stopped meaning
 * "exception" and started reading as an error state covering the table. The
 * edge is the one that survives: it is the only one that works at the scale
 * this is scanned at, it costs no colour inside the row, and it leaves the
 * chip's own red the loudest thing on the line rather than the third loudest.
 *
 * Set through --row-bg where a background is still needed, because a frozen
 * column has to paint the same colour as the row it belongs to — see the
 * row-shading block further down.
 */
tbody tr.row-fraud > td:first-child { box-shadow: inset 3px 0 0 var(--red); }

.fraud-headline {
  display: flex; align-items: center; gap: 18px;
  padding: var(--sp-4); margin-bottom: 18px;
  border-radius: var(--r);
  border: 1px solid var(--border);
  background: var(--panel-2);
}
/* A 0-100 score, not a hero number: on the display step, not two above it. */
.fraud-headline-score {
  font-size: var(--fs-display); font-weight: var(--w-medium); letter-spacing: var(--tr-display);
  font-variant-numeric: tabular-nums; line-height: 1;
  min-width: 62px; text-align: center;
}
.fraud-headline.clean      { border-color: var(--green-line); }
.fraud-headline.clean      .fraud-headline-score { color: var(--green-text); }
.fraud-headline.suspicious { border-color: var(--amber-line); }
.fraud-headline.suspicious .fraud-headline-score { color: var(--amber-text); }
.fraud-headline.high       { border-color: var(--red-line); }
.fraud-headline.high       .fraud-headline-score { color: var(--red-text); }
.fraud-headline.fraud      { border-color: var(--red); background: var(--red-wash); }
.fraud-headline.fraud      .fraud-headline-score { color: var(--red-text); }

.fraud-escalated {
  padding: var(--sp-3) 14px; margin-bottom: 4px;
  box-shadow: inset 3px 0 0 var(--amber);
  border-radius: var(--r-sm);
  background: var(--amber-wash);
  font-size: var(--fs-dense);
}
.fraud-escalated ul { margin: 6px 0 0; padding-left: 18px; }

.fraud-section { padding: 14px 0; border-top: 1px solid var(--border); }
.fraud-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.fraud-section-head h4 { margin: 0; font-size: var(--fs-body); line-height: var(--lh-dense); font-weight: var(--w-medium); }

.fraud-flags { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 6px; }
.fraud-flags li { display: flex; align-items: baseline; gap: 8px; font-size: var(--fs-dense); color: var(--text-dim); }
.fraud-flag-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; background: var(--amber); }
.fraud-flags li.critical { color: var(--text); }
.fraud-flags li.critical .fraud-flag-dot { background: var(--red); }

.fraud-detail td { padding: 5px 10px 5px 0; vertical-align: top; }
.fraud-detail tr + tr td { border-top: 1px solid var(--border); }

/* ------------------------------ page head ------------------------------- */

.page-head {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: var(--sp-4); flex-wrap: wrap;
}
.page-head h1 {
  margin: 0; font-size: var(--fs-title); line-height: var(--lh-title);
  letter-spacing: var(--tr-title); font-weight: var(--w-medium);
}
.page-head .sub {
  margin: 5px 0 0; color: var(--muted);
  font-size: var(--fs-dense); line-height: var(--lh-body); max-width: var(--measure);
}
.head-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; align-items: center; }
/* A 25px pill in a row of 34px buttons breaks the toolbar's top and bottom
   edge; the 28px step is the one the rest of the system already has. */
.head-actions > .pill { min-height: var(--ctl-h-sm); padding-inline: var(--sp-3); }
/* A flex row with a gap still occupies its gap when it holds nothing. The docs
   tab renders an actions row with no actions, and it was leaving 12px of head
   behind it. */
.head-actions:empty { display: none; }

/* ------------------------------ surfaces -------------------------------- */

.card {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: var(--sp-4);
  box-shadow: var(--rim), var(--sh-1);
}
.card h3 { margin: 0 0 var(--sp-3); font-size: var(--fs-body); line-height: var(--lh-section); font-weight: var(--w-medium); }

/*
 * A card whose only child is its own scroller. `overflow: hidden` clips at the
 * padding box, which with zero padding and a 1px border is exactly the
 * concentric inner radius — so the opaque sticky header stops terminating in a
 * hard right angle inside the card's arc. Scoped to `.flush` because clipping
 * every card would cut focus rings off across the app.
 */
.card.flush { padding: 0; overflow: hidden; }

/* Prose in a card gets a measure. The child combinator matters: as a descendant
   selector this would also clamp the fraud flag list and the log entries. */
.card > p, .card > ul, .empty > div { max-width: var(--measure); }
.empty > div { margin-inline: auto; }

/* Row of secondary actions at the foot of a card. */
.card-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; align-items: center; }
@media (max-width: 560px) {
  .card-actions > button, .card-actions > .btn { flex: 1 1 calc(50% - 4px); justify-content: center; }
}

.grid { display: grid; gap: var(--sp-3); }
.grid > label.f { margin-bottom: 0; }
/* Cards of genuinely different length (a 2-row list beside a 12-row one) look
   broken when stretched to match — hundreds of pixels of empty panel. */
.grid.align-start { align-items: start; }
.grid.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }

/* Same material as .card and as its own skeleton: a gradient standing in for
   elevation was the one place in the file where two surfaces at one height
   were made of different things. */
.stat {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: var(--sp-3) var(--sp-4);
  box-shadow: var(--rim), var(--sh-1);
}
/* No :hover lift — it is a plain div with no handler, and a false affordance is
   not improved by making it more convincing. */
.stat .label { color: var(--muted); font-size: var(--fs-caps); font-weight: var(--w-medium); }
.stat .value {
  font-size: var(--fs-display); font-weight: var(--w-medium); margin-top: 7px;
  letter-spacing: var(--tr-display); font-variant-numeric: tabular-nums;
  line-height: var(--lh-display);
}
.stat .foot { color: var(--faint); font-size: var(--fs-caption); margin-top: 5px; }
/* A smaller tile, for a dialog. A modifier, not an inline style. */
.stat.stat-sm { padding: var(--sp-3); }
/* Tracking follows size: 22px is inside the 17-24px band, so it takes the
   title step rather than keeping the display step .value sets at 28px. */
.stat.stat-sm .value { font-size: 22px; letter-spacing: var(--tr-title); }
.stat .value.green { color: var(--green-text); }
.stat .value.amber { color: var(--amber-text); }
.stat .value.red { color: var(--red-text); }

/* ------------------------------- buttons -------------------------------- */

/*
 * `:where()` contributes zero specificity, so `.bare` can opt a control out
 * without the base rule out-ranking .btn-primary and friends. `button:not(.bare)`
 * would be (0,1,1) and would strip the fill off every button variant.
 */
.btn, button:where(:not(.bare)) {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  font: inherit; font-weight: var(--w-medium);
  min-height: var(--ctl-h);
  padding: 7px 13px;
  border-radius: var(--r-sm);
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--text);
  cursor: pointer; white-space: nowrap;
  transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease),
              transform var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}
/* The load-bearing one: this reaches .icon-btn, both corner badges and every
   variant below, so leaving it ungated would make every other gate cosmetic. */
@media (hover: hover) {
  .btn:hover, button:where(:not(.bare)):hover {
    background: var(--panel-3); border-color: var(--border-strong);
    text-decoration: none; color: var(--text);
  }
}
.btn:active, button:where(:not(.bare)):active { transform: translateY(1px); }
.btn[aria-disabled="true"], button:where(:not(.bare)):disabled {
  opacity: .5; cursor: not-allowed; transform: none; background: var(--panel-2);
}

/*
 * Chrome-less controls. Never `all: unset` — that resets box-shadow at class
 * specificity and would take the focus ring off every bare button, which is the
 * cascade bug the outline ring exists to fix.
 */
.bare {
  min-height: 0; padding: 0;
  border: 0; border-radius: 0;
  background: none; color: inherit; font: inherit;
  display: inline-flex; align-items: center;
  cursor: pointer;
}

/* Flat, because white on the dark theme's gradient measured 2.51:1 at its top
   stop. --accent-fill is 5.20:1 resting and --accent-press 4.32:1 on hover, and
   the fill still holds 3.47:1 against --panel so the button reads as an object. */
.btn-primary {
  background: var(--accent-fill);
  border-color: var(--accent-press);
  color: var(--on-accent);
  font-weight: var(--w-medium);
  --rim: inset 0 1px 0 rgba(255, 255, 255, .16);
  box-shadow: var(--rim), var(--sh-1);
}
/*
 * Each variant's hover is gated where it stands, and all four have to be.
 * Gating the base rule alone is the half-fix that looks like a whole one: these
 * out-rank it only by source order, so on a phone the base fill would come off
 * and the variant's would stay latched — a ghost button left looking solid, a
 * Delete left sitting in its red wash, until something else is tapped.
 */
@media (hover: hover) {
  .btn-primary:hover { background: var(--accent-press); border-color: var(--accent); color: var(--on-accent); }
}
.btn-primary:disabled { background: var(--accent-fill); }

.btn-danger { color: var(--red-text); }
@media (hover: hover) {
  .btn-danger:hover {
    background: var(--red-wash);
    border-color: var(--red-line);
    color: var(--red-text);
  }
}

.btn-sm { min-height: var(--ctl-h-sm); padding: 4px 10px; font-size: var(--fs-label); }
.btn-ghost { background: transparent; border-color: transparent; }
@media (hover: hover) {
  .btn-ghost:hover { background: var(--panel-2); border-color: var(--border); }
}
/* A third tier that keeps a resting edge, so it does not read as static text. */
.btn-quiet { background: transparent; border-color: var(--border); color: var(--text-dim); }
@media (hover: hover) {
  .btn-quiet:hover { background: var(--panel-2); color: var(--text); }
}

/*
 * The square icon button, unbound from --tap so it can serve the dialog close
 * and the toast dismiss as well as the phone's nav toggle.
 */
.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--ctl-h); height: var(--ctl-h);
  min-height: 0; padding: 0; flex: none;
  border-radius: var(--r-sm);
  border: 1px solid transparent;
  background: transparent; color: var(--text-dim);
  cursor: pointer; line-height: 1;
}
.icon-btn.sm { width: 26px; height: 26px; }
.icon-btn.lg { width: var(--tap); height: var(--tap); }
@media (hover: hover) {
  .icon-btn:hover { background: var(--panel-2); border-color: var(--border); color: var(--text); }
}
/* The resting chrome-lessness is right for a bar glyph and stays. What it owed
   was a press: `button:active`'s 1px nudge does not reach the `.bare` inspector
   close, and with the tap highlight gone there was nothing at all. */
.icon-btn:active { background: var(--panel-3); }

/*
 * The busy state. The label stays in the box holding the width, so a button
 * cannot resize itself mid-press, and `disabled` is never set — disabling a
 * focused button drops the focus and strands a keyboard user mid-action.
 * var(--text), not currentColor: currentColor is transparent on this element.
 */
/* .switch is excluded: it paints its new state optimistically on click, which
   is stronger feedback than a spinner, and it has its own busy rule below. */
button:where(:not(.switch))[aria-busy="true"], .btn[aria-busy="true"] { cursor: progress; color: transparent; position: relative; }
button:where(:not(.switch))[aria-busy="true"]::after, .btn[aria-busy="true"]::after {
  content: ''; position: absolute; inset: 0; margin: auto;
  width: 14px; height: 14px; border-radius: 50%;
  border: 2px solid color-mix(in srgb, var(--text) 25%, transparent);
  border-top-color: var(--text);
  animation: spin .7s linear infinite;
}
.btn-primary[aria-busy="true"]::after {
  border-color: color-mix(in srgb, var(--on-accent) 25%, transparent);
  border-top-color: var(--on-accent);
}
@keyframes spin { to { transform: rotate(360deg); } }
/* The global reduced-motion block flattens animations to 1ms, which would
   freeze the spinner mid-arc. Say it in words instead. */
@media (prefers-reduced-motion: reduce) {
  button:where(:not(.switch))[aria-busy="true"], .btn[aria-busy="true"] { color: inherit; opacity: .6; }
  button:where(:not(.switch))[aria-busy="true"]::after, .btn[aria-busy="true"]::after { display: none; }
}

/* ------------------------------- inputs --------------------------------- */

/* --border-ctl rather than --border. An empty field is a rectangle of almost
   page-coloured fill: its outline is the only thing that says a control is
   there, so WCAG 1.4.11's 3:1 applies to that edge. Buttons stay on --border —
   they carry a fill and a label and are identifiable without it.
   Global on purpose, and not inside a media query by oversight: 1.4.11 is not
   a phone rule, and a filter bar or a settings form that only meets it at
   860px and under would be the odd claim, not this one. It does make every
   form in the product read a shade heavier than it did. */
input, select, textarea {
  font: inherit; width: 100%;
  min-height: var(--ctl-h);
  padding: 7px 10px;
  border-radius: var(--r-sm);
  border: 1px solid var(--border-ctl);
  background: var(--field);
  color: var(--text);
  transition: border-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}
input::placeholder, textarea::placeholder { color: var(--faint); }
@media (hover: hover) {
  input:hover, select:hover, textarea:hover { border-color: var(--border-strong); }
}
/*
 * Fields take the same ring as everything else.
 *
 * This used to opt out — `outline: none` plus a 3px --accent-soft glow — and
 * that glow measures about 1.2:1 against the surfaces either side of it, so a
 * treatment declared as a 3px ring rendered as a border hue change and nothing
 * more. The one control type a phone user hits most was the one type exempted
 * from the app's single stated focus treatment. The border shift stays as
 * reinforcement; the global rule supplies the actual 2px ring.
 *
 * Worth stating plainly: a text input matches :focus-visible on *any* focus,
 * including a tap, so this is "every focused field now shows the system ring".
 */
input:focus, select:focus, textarea:focus,
input:focus-visible, select:focus-visible, textarea:focus-visible {
  border-color: var(--accent);
}
/*
 * Three control types that cannot be left to the heuristic.
 *
 * ":focus-visible after a mouse click" is only guaranteed for a field that
 * takes typed characters. A <select>, a colour well and a date picker are
 * button-shaped to the engine, and the engines disagree about whether a click
 * on one is "visible" focus — so `:focus { outline: none }` five hundred lines
 * up could leave them with a border-colour shift and no ring at all, which is
 * less than the 3px glow they had before it. Say the ring for them outright;
 * (0,1,1) beats the bare `:focus` that turns it off.
 */
select:focus, input[type="color"]:focus, input[type="date"]:focus {
  outline: 2px solid var(--accent-hi);
  outline-offset: 2px;
}
input[type="checkbox"], input[type="radio"] {
  width: 17px; height: 17px; min-height: 0;
  accent-color: var(--accent);
  cursor: pointer;
}
input[type="color"] { padding: 3px; height: var(--ctl-h); cursor: pointer; }
/* The native date/time glyphs are black by default; --date-icon inverts them
   for a dark field and leaves them alone on a light one. */
input[type="date"]::-webkit-calendar-picker-indicator { filter: var(--date-icon); cursor: pointer; }
input[type="date"] { min-width: 0; }

/* Monospace says "compare this character by character", which is a claim about
   a JSON body and not about a sentence somebody typed. */
textarea { min-height: 92px; font-size: var(--fs-dense); line-height: var(--lh-body); resize: vertical; }
/* A monospace metric, not a ramp step — the same optical correction .mono
   documents further down. The mono stack runs a size large on screen, so it is
   set off the ramp deliberately and should not be repointed at a token. */
textarea.code { font-family: var(--mono); font-size: 12.5px; line-height: 1.55; tab-size: 2; }
/* The integrations spec editor's floor. It lived as an inline style, where no
   media query could reach it — 360px of textarea is most of a phone screen. */
.spec-editor { min-height: 360px; }

select {
  appearance: none;
  padding-right: 30px;
  background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
                    linear-gradient(135deg, var(--muted) 50%, transparent 50%);
  background-position: calc(100% - 15px) calc(50% + 1px), calc(100% - 10px) calc(50% + 1px);
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}

label.f { display: block; margin-bottom: var(--sp-3); }
label.f > span { display: block; font-size: var(--fs-label); font-weight: var(--w-medium); color: var(--text-dim); margin-bottom: 6px; }
label.f > small { display: block; color: var(--faint); margin-top: 5px; font-size: var(--fs-caption); }
label.f > span .req { color: var(--red-text); margin-left: 3px; }

/* Grids own the spacing inside themselves; this puts the rhythm back between
   them, which the label margin was silently providing. */
.modal-body > .grid + .grid,
.modal-body > .grid + label.f,
.modal-body > label.f + .grid { margin-top: var(--sp-3); }

/*
 * The error state, which keeps a ring of its own. It no longer has to be
 * ordered after the focus rule to survive — that rule stopped declaring a
 * box-shadow — and at (0,2,1) against (0,1,1) specificity settles it anyway.
 */
label.f.is-invalid > span { color: var(--red-text); }
label.f.is-invalid input,
label.f.is-invalid select,
label.f.is-invalid textarea {
  border-color: var(--red);
  box-shadow: 0 0 0 3px var(--red-wash);
}
.f-err { display: block; color: var(--red-text); margin-top: 5px; font-size: var(--fs-caption); line-height: 1.5; }

.inline { display: flex; align-items: center; gap: var(--sp-2); }
.inline input[type=checkbox] { width: 17px; }
label.inline { cursor: pointer; min-height: var(--tap); }

/* ------------------------------- tables --------------------------------- */

.table-wrap {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  /* Fading edges signal "there is more sideways" without an extra element. */
  background:
    linear-gradient(to right, var(--panel) 30%, transparent) left / 34px 100% no-repeat local,
    linear-gradient(to left, var(--panel) 30%, transparent) right / 34px 100% no-repeat local,
    radial-gradient(farthest-side at 0 50%, var(--edge-fade), transparent) left / 14px 100% no-repeat scroll,
    radial-gradient(farthest-side at 100% 50%, var(--edge-fade), transparent) right / 14px 100% no-repeat scroll;
}

/* A decided leading, not body prose inherited: 13px x 1.55 gave a 20.15px line
   box in a table of one-line cells, and it was the one part of row height the
   density control could not reach. Tabular figures are a property of the table
   rather than of whichever component someone remembered. */
table {
  width: 100%; border-collapse: separate; border-spacing: 0;
  font-size: var(--fs-dense); line-height: var(--row-lh);
  font-variant-numeric: tabular-nums;
}
th, td {
  text-align: left; padding: var(--row-pad-y) var(--row-pad-x);
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}
/**
 * `position: sticky` on a header only works against a scrollport that actually
 * scrolls in that axis. `.table-wrap` is `overflow-x: auto`, which makes it a
 * scroll container in both axes, so a page-level scroll never triggers it —
 * the header just scrolled away. `.table-wrap.tall` gives the container its own
 * vertical scroll, which is what the sticky header needs, and keeps the filters
 * and pager on screen while you move through rows.
 */
.table-wrap.tall {
  max-height: min(68dvh, 760px);
  overflow-y: auto;
}

/* Sentence case: uppercase strips the ascenders and descenders that let an
   operator find "Registration" among fourteen headers at a glance. The header
   row loses nothing — the panel-2 fill and the strong rule are what actually
   say "this is not data". */
/*
 * Small, wide and quiet — the header should name the columns, not compete with
 * them. It was 11.5px --muted against 13px body text: one and a half pixels
 * and one colour step, which at a glance read as another row of data. Dropping
 * a size, adding tracking and going to --faint separates it by kind rather
 * than by degree, and stops it fighting the badges directly beneath it.
 *
 * The material is translucent so rows are felt moving underneath rather than
 * disappearing behind a solid bar — which is the thing that tells you the
 * header is pinned and the list is not simply being clipped. Opaque
 * --panel-2 stays as the fallback, and is what every browser without
 * backdrop-filter gets.
 */
thead th {
  position: sticky; top: 0; z-index: 2;
  background: var(--panel-2);
  color: var(--faint); font-weight: var(--w-medium);
  font-size: var(--fs-caps); letter-spacing: .04em;
  white-space: nowrap;
  border-bottom: 1px solid var(--border-strong);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  thead th {
    background: color-mix(in srgb, var(--panel-2) 82%, transparent);
    -webkit-backdrop-filter: saturate(180%) blur(20px);
    backdrop-filter: saturate(180%) blur(20px);
  }
}
/* ---------------------------- row shading -------------------------------- */
/*
 * Every row states its own background in one variable, and the cells read it.
 *
 * The indirection earns its keep as soon as a column is frozen: a sticky cell
 * has the rest of the table sliding underneath it, so it cannot be transparent,
 * and hard-coding a panel colour into it would leave the frozen cells the same
 * shade while the row behind them striped, highlighted on hover, or turned red
 * for fraud. One variable, set on the row, keeps every cell in step.
 */
/* The transition lives where the paint happens. `tr` never paints a background,
   so a transition declared on it was inert and both hover and selection
   snapped. `background-color`, not the shorthand — the shorthand resets
   background-image, which is how a frozen cell carries the same tint. */
/*
 * Hairlines, not stripes.
 *
 * Both were on: --row-alt at .038 alpha under a 1px --border on the bottom of
 * every cell. Two devices for one job, and at that alpha the stripe never won
 * — it was doing nothing but making the table slightly muddier. The hairline
 * stays because it is the one that survives a frozen column sliding over it.
 * It runs the full width rather than inset to the content: the first cell is
 * sticky, and a line that stopped short of it would break the moment the table
 * was scrolled sideways.
 */
tbody tr { --row-bg: transparent; }
@media (hover: hover) {
  tbody tr:hover { --row-bg: var(--row-hover); }
}
tbody tr.is-selected { --row-bg: var(--accent-soft-2); }
@media (hover: hover) {
  tbody tr.is-selected:hover { --row-bg: color-mix(in srgb, var(--accent) 28%, transparent); }
}
/*
 * The touch equivalent, and the reason it is a class rather than `:active`:
 * the row's click handler is delegated to <tbody>, and `:active` on a phone
 * only fires for the element the browser itself decided was the target — which
 * for a delegated row is a cell. leads.js adds .is-pressed on pointerdown and
 * clears it on pointerup, pointercancel and any scroll.
 */
@media (hover: none) {
  .leads-table tbody tr.is-pressed { --row-bg: var(--row-press); }
}
tbody tr > td {
  background-color: var(--row-bg);
  transition: background-color var(--dur-1) var(--ease);
}
tbody tr:last-child td { border-bottom: 0; }

/* A selected row says so at its left edge as well, so a scattered selection is
   countable down the page rather than only in the pager's total. */
tbody tr.is-selected > td:first-child {
  box-shadow: inset 3px 0 0 var(--accent);
  transition: background-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}

/* Sort controls are real buttons so the keyboard reaches them. Carrying `.bare`
   they start with no chrome, so this is geometry only. */
.th-sort {
  align-items: center; justify-content: flex-start; gap: 3px;
  padding: 2px 4px; margin: -2px -4px;
  text-transform: inherit; letter-spacing: inherit;
}
/* Both states, so Space does not move the label 1px inside a sticky header. */
.th-sort:hover, .th-sort:active { color: var(--text); transform: none; }
/*
 * The caret is reserved rather than injected, and `flex: none` on it with
 * `min-width: 0` on the label is what guarantees the label ellipsises and the
 * sort indicator is never the thing clipped.
 */
.th-sort .th-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.th-sort .caret {
  flex: none; display: inline-block; font-size: 8px; opacity: 0;
  transition: opacity var(--dur-1) var(--ease), transform var(--dur-2) var(--ease);
}
/* Split, because only the hover half latches: on touch the caret would come up
   on a column that is not the sorted one and stay there. The keyboard half
   belongs to focus and is right at any pointer. */
@media (hover: hover) {
  th:hover .th-sort .caret { opacity: .45; }
}
.th-sort:focus-visible .caret { opacity: .45; }
th[aria-sort] .th-sort .caret { opacity: .9; }
th[aria-sort="ascending"] .th-sort .caret { transform: rotate(180deg); }
th[aria-sort] .th-sort { color: var(--text); }

td.num, th.num { text-align: right; }
/* Otherwise the sort button stays pinned left inside a right-aligned cell. */
th.num .th-sort { justify-content: flex-end; }

/* --------------------------- the leads table ----------------------------- */
/*
 * Fixed layout, widths from the <colgroup>.
 *
 * With the default algorithm the browser sizes each column from its contents,
 * so one unusually long brand name on page two moves every column on the
 * screen — the table has to be re-read from scratch on each page. Fixed layout
 * means a column is where it was last time, and it is also what makes a frozen
 * column's offset calculable before anything is measured.
 */
.table-fixed { table-layout: fixed; }

/*
 * The tick box is 17px in a 38px column; the label is what makes the whole
 * cell the target. Padding on the label rather than `height: 100%`, which does
 * not resolve reliably inside a <td>.
 */
th.cell-select, td.cell-select { padding: 0; }
.cell-tick {
  display: flex; align-items: center; justify-content: center;
  width: 100%; min-height: 26px; padding: 6px 0;
  cursor: pointer;
}

/* The row-action cell holds buttons, not text, so it needs no ellipsis — and
   `overflow: hidden` there would clip the focus ring off every one of them. */
.leads-table td.cell-actions { overflow: visible; }
/* Chrome's scroll-into-view does not account for a sticky header. */
.leads-table tbody td { scroll-margin-top: 44px; }
/* The row that lights up on hover is now the thing that acts. */
.leads-table tbody tr { cursor: pointer; }

/*
 * One line per cell, with an ellipsis where it does not fit. The full value is
 * on the cell's title attribute, so nothing is actually lost — and a wrapped
 * cell would push its row to two lines and destroy the horizontal rhythm that
 * makes a long table scannable at all.
 */
.leads-table td, .leads-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* The lead's name, with its id underneath: one column, one glance, and the
   thing every other cell on the row is read against. */
.cell-lead { white-space: normal; }
/* The name takes only the width it needs so the country sits against it rather
   than out at the cell's right edge; min-width:0 is what lets a long name
   ellipsise instead of pushing the flag out of the cell. */
.cell-lead .lead-top { display: flex; align-items: center; gap: 6px; }
.cell-lead .lead-name {
  display: block; flex: 0 1 auto; min-width: 0;
  color: var(--text); font-weight: var(--w-medium);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Sized to the meta row below it, not to the name it follows: the name is the
   fact being read and the country qualifies it, so it should not compete. */
.cell-lead .lead-country {
  flex: none; gap: 4px;
  font-size: var(--fs-micro); color: var(--muted);
}
/* Small enough that the line stays as tall as the name sets it — a 15px flag
   would add two pixels to every row in the table. */
.cell-lead .lead-country .flag { width: 14px; height: 11px; }
.cell-lead .lead-meta {
  display: flex; align-items: center; flex-wrap: wrap; gap: 5px; row-gap: 3px;
  margin-top: 2px;
  font-size: var(--fs-caps); color: var(--muted);
}
/* The id sits at the meta row's own size rather than inheriting the cell's, so
   the second line is one size throughout; tabular figures keep #7 and #196
   from starting the row at a different x down the column. */
.cell-lead .lead-meta a {
  color: var(--muted); text-decoration: none;
  font-size: var(--fs-caps); font-variant-numeric: tabular-nums;
}
@media (hover: hover) {
  .cell-lead .lead-meta a:hover { color: var(--accent); text-decoration: underline; }
}
/*
 * A state with a real consequence — this row stays out of the figures and must
 * not be worked — should not be announced in the smallest type in the product.
 * It was --fs-micro, which is 10px, roughly 7.5pt.
 *
 * The leading is `--fs-caps + 4px` rather than a ratio so the box stays a whole
 * number at both rungs of the token: 11 + 4 + the pill's 1px border top and
 * bottom is a 17px object on a desktop, and 12 + 4 + 2 is 18px on a phone. The
 * fraud chip and the brand status below are set to the same expression, so all
 * three are one height and the meta row does not grow or reflow whatever a
 * particular lead happens to carry.
 */
.cell-lead .lead-meta .pill {
  padding: 0 5px;
  font-size: var(--fs-caps); line-height: calc(var(--fs-caps) + 4px);
}

/* The fraud chip: shield, number, and a border that carries the band. Sized to
   the same box as the pills beside it so the meta row stays one line high
   whatever a lead happens to carry. */
.cell-lead .lead-fraud {
  display: inline-flex; align-items: center; gap: 3px;
  /* This is a <button>, so it inherits min-height: var(--ctl-h) from the base
     control rule — a 34px tap target next to 16px pills. Down here it is a
     label that happens to be pressable, and the row it sits on is the tap
     target, so the floor comes off. */
  min-height: 0;
  padding: 0 5px 0 4px;
  border: 1px solid var(--border); border-radius: var(--r-pill);
  background: var(--panel-2); color: var(--muted);
  /* The same expression and the same 1px border as the pills beside it, so both
     land on the same whole-pixel box: 17px on a desktop, 18px on a phone. */
  font-size: var(--fs-caps); line-height: calc(var(--fs-caps) + 4px);
  cursor: pointer;
  transition: border-color var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
}
@media (hover: hover) {
  .cell-lead .lead-fraud:hover { border-color: var(--border-strong); color: var(--text); }
}
/* It opens the fraud breakdown, so it owes the finger an acknowledgement the
   gate above just took away. Colour only — the chip is sized to the pills
   beside it and must not move the meta row. */
.cell-lead .lead-fraud:active { border-color: var(--border-strong); color: var(--text); }
.cell-lead .lead-fraud svg { flex: none; }
/* The same face and weight as the pills it sits beside. The monospace this used
   to be was the same 10px on paper but read a size larger on screen — Consolas
   and Cascadia carry tall digits on a wide advance — so the score shouted next
   to "sent" instead of matching it. Tabular figures still keep 8 and 81 from
   shifting the chip's width about. */
.cell-lead .lead-fraud-score {
  font-variant-numeric: tabular-nums;
  font-weight: var(--w-medium);
}
/* Same four bands as the full pill, so a colour means the same thing in both
   places. The number is what is read; the hue only makes it findable. */
.cell-lead .lead-fraud.clean      { border-color: var(--green-line); color: var(--green-text); }
.cell-lead .lead-fraud.suspicious { border-color: var(--amber-line); color: var(--amber-text); }
.cell-lead .lead-fraud.high       { border-color: var(--red-line);   color: var(--red-text); }
/* The top band is a wash and a firm border, not a solid fill: the row it sits
   on is already red-washed with a red edge, and a third solid red object in the
   same 20 pixels reads as damage rather than as a score. Matches the full pill. */
.cell-lead .lead-fraud.fraud      {
  border-color: var(--red); background: var(--red-wash-2); color: var(--red-text);
}
/* Never checked: present but quiet, so an unchecked lead is visibly unchecked
   rather than looking like a clean one. */
.cell-lead .lead-fraud.unchecked { border-style: dashed; }

/* On the name's line now, so it takes what is left after the name and the flag
   and never more. The inner span is what ellipsises: text-overflow needs a
   block box, and the pill itself is an inline-flex. */
/* Third of the three, and it was the one out of step — 1.5 where the other two
   were 1.4. All three now read the same expression. */
.cell-lead .lead-brandstatus {
  flex: none; max-width: 96px;
  font-size: var(--fs-caps); padding: 0 6px;
  line-height: calc(var(--fs-caps) + 4px);
}
.cell-lead .lead-brandstatus > span {
  display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The symbol carries the badge, the amount only sizes it — so the symbol keeps
   the pill's weight and the number sits a shade back from it. */
/* Initials, so they need the tracking that lower-case words do not: "AL" set
   solid reads as one glyph at 10px. Everything else comes from .pill, which is
   what keeps it the same height and weight as the badges either side. */
.cell-lead .lead-al { font-weight: var(--w-bold); letter-spacing: .04em; }
/* The absence, drawn as an absence: dashed and unfilled, the same grammar the
   never-checked fraud shield uses. It is information, not an alarm — plenty of
   brands simply do not offer a link. */
.cell-lead .lead-al.none {
  border-style: dashed; background: transparent; color: var(--muted);
}

/*
 * The FTD mark: a solid coin, not another outlined pill.
 *
 * This is the one thing in the row that says the lead made money, and it was
 * competing with four washed-out pills that all look alike at a glance. A
 * filled disc in the only saturated green on the row separates itself by shape
 * and weight rather than by being read — which is the point, because an
 * operator scanning a page of leads is looking for exactly this and nothing
 * else. The ring holds it off the pills either side so the fill stays a
 * distinct object rather than merging into the row's own tint.
 */
.cell-lead .lead-ftd {
  display: inline-flex; align-items: center; justify-content: center;
  width: 16px; height: 16px; padding: 0;
  border-radius: 50%;
  background: var(--green-fill); color: var(--on-green);
  font-size: 11px; font-weight: var(--w-bold); line-height: 1;
  box-shadow: 0 0 0 2px var(--green-wash);
}
.cell-lead .lead-fraud.is-new { animation: pill-in var(--dur-2) var(--ease-out) both; }

/* ------------------------------ inspector -------------------------------- */
/*
 * The lead detail, docked to the right of the list instead of on top of it.
 *
 * Fixed rather than a grid column, so opening one cannot re-flow or re-mount
 * the view behind it; `.main` takes matching padding, which is what makes the
 * table narrow beside the panel rather than slide under it. It is not modal:
 * no backdrop, no scroll lock, no focus trap — the list stays live and stays
 * scrollable while a lead is open, which is the entire point of the change.
 */
/* 400 rather than 420: the default column set is 824px, and this is what keeps
   the table inside its card at 1512px with a lead open — the width the panel
   costs is width the list no longer has. */
:root { --inspector-w: 400px; }

.inspector {
  position: fixed; top: 0; right: 0; bottom: 0; z-index: 40;
  width: var(--inspector-w);
  display: flex; flex-direction: column;
  background: var(--panel);
  border-left: 1px solid var(--border-strong);
  box-shadow: var(--sh-3);
  animation: inspector-in var(--dur-2) var(--ease-out) both;
}
@keyframes inspector-in {
  from { transform: translateX(12px); opacity: 0; }
  to { transform: none; opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .inspector { animation: none; }
}

.inspector-head {
  display: flex; align-items: center; gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-4) var(--sp-3);
  border-bottom: 1px solid var(--border);
}
.inspector-title {
  flex: 1; min-width: 0; margin: 0;
  font-size: var(--fs-body); font-weight: var(--w-medium);
  line-height: var(--lh-section);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.inspector-body { flex: 1; overflow-y: auto; padding: var(--sp-4); }
.inspector-foot {
  display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
  padding: var(--sp-3) var(--sp-4);
  border-top: 1px solid var(--border);
  background: var(--panel-2);
}

/* The list gives up exactly the panel's width, so nothing it holds is hidden. */
body.has-inspector .main { padding-right: calc(var(--inspector-w) + var(--sp-4)); }
body.has-inspector .corner-badges { right: calc(var(--inspector-w) + var(--sp-4)); }

/* -------------------------- the detail grid ------------------------------ */
/*
 * Label and value, both halves of the record in one table so every row has a
 * single height. The label is a `th` and reads as the quiet one: it names the
 * value, and a label set at the value's own size makes the reader decide which
 * is which on every line.
 */
.detail-grid { width: 100%; }
.detail-grid th, .detail-grid td {
  padding: 7px var(--sp-3) 7px 0;
  border-bottom: 1px solid var(--border);
  vertical-align: baseline;
}
.detail-grid tr:last-child th, .detail-grid tr:last-child td { border-bottom: 0; }
.detail-grid th {
  width: 108px;
  font-size: var(--fs-caps); font-weight: var(--w-regular);
  letter-spacing: .03em; color: var(--faint);
  text-align: left; white-space: nowrap;
}
.detail-grid td { color: var(--text); word-break: break-word; }

/* A value and its copy button on one line. `min-width: 0` on the value is what
   keeps the button from being the thing that overflows a 400px panel — the URL
   ellipsises and the control stays whole. */
.detail-copy { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.detail-copy > :first-child { min-width: 0; }
.detail-copy > button {
  flex: none;
  /* Quiet until it is reached: this is a convenience beside a value, not an action
     the record is about. It keeps a real target — the phone shell's floor reaches it
     through .btn-sm — and only the ink is dialled back. */
  padding-inline: var(--sp-2); color: var(--faint);
}
@media (hover: hover) {
  .detail-copy > button:hover { color: var(--text); }
}
.detail-copy > button:active { color: var(--accent-text-hi); }

/* ---------------------------- segmented control -------------------------- */
/*
 * Two or more buttons that are one decision. The children give up their own
 * border and radius to the group, so the pair reads as a single object with
 * two ends rather than as two controls that happen to be adjacent.
 */
/*
 * No `overflow: hidden`. z-index cannot lift a child out of an ancestor's clip,
 * so the group was quietly cutting the focus ring off its own children: on a
 * two-button pair the only segment that survived was the one lying over the
 * neighbour, i.e. focus read as a 2px bar in the middle of the control and
 * looked almost identical whichever half had it.
 *
 * The end radii move onto the children instead, and they are load-bearing
 * rather than decorative — without the clip, a child's hover fill would paint
 * square corners over the group's arc.
 */
.segmented {
  display: inline-flex;
  border: 1px solid var(--border); border-radius: var(--r-sm);
  background: var(--panel-2);
}
.segmented > button {
  border: 0; border-radius: 0; background: transparent;
  box-shadow: none;
}
/* -1px: the group's border sits outside the children, so the concentric inner
   arc is the group's radius less its own border. */
/* Longhands, not the shorthand: a one-button group matches both rules, and two
   competing shorthands would leave it with only the second one's corners. */
.segmented > button:first-child {
  border-top-left-radius: calc(var(--r-sm) - 1px);
  border-bottom-left-radius: calc(var(--r-sm) - 1px);
}
.segmented > button:last-child {
  border-top-right-radius: calc(var(--r-sm) - 1px);
  border-bottom-right-radius: calc(var(--r-sm) - 1px);
}
.segmented > button + button { border-left: 1px solid var(--border); }
@media (hover: hover) {
  .segmented > button:hover:not(:disabled) { background: var(--panel-3); }
}
/* The ring still has to sit above the neighbour's border. */
.segmented > button:focus-visible { z-index: 2; }

/* ------------------------- the two-line columns -------------------------- */
/*
 * Contact and Brand carry a pair each, on the two lines the Lead cell already
 * set the row to. Same construction in both: a primary line in body colour and
 * a secondary in --muted at the meta size, so the second line reads as support
 * rather than as a second value of equal standing.
 */
.cell-contact, .cell-brand { white-space: normal; }
.cell-contact > span, .cell-brand > span, .cell-brand > button { display: block; }
.contact-phone, .lead-brand {
  color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.contact-phone { font-variant-numeric: tabular-nums; }
.contact-email, .lead-brand-sub {
  margin-top: 2px;
  font-size: var(--fs-caps); color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The failure reason is a button, so it has to be stripped back to the line of
   text it looks like — the base control rule would otherwise give it a 34px
   box, a border and a fill inside a table cell. */
.cell-brand .lead-brand-sub.is-failure {
  min-height: 0; padding: 0; border: 0; background: none;
  width: 100%; text-align: left;
  color: var(--red-text); cursor: pointer;
}
@media (hover: hover) {
  .cell-brand .lead-brand-sub.is-failure:hover { text-decoration: underline; background: none; }
}
/* Same trade as the fraud chip: this opens the failure detail, and with the
   hover gone it would otherwise be the one pressable thing in the row that
   never answers. */
.cell-brand .lead-brand-sub.is-failure:active { text-decoration: underline; background: none; }

/* Supporting detail — addresses, devices, timestamps — reads quieter than the
   facts an operator is scanning for. */
.leads-table td.muted-cell { color: var(--muted); }

/* ----------------------------- frozen columns ---------------------------- */
/*
 * Columns the operator has pinned stay against the left edge while the rest of
 * the table scrolls under them. Without it, scrolling right to reach Brand
 * status leaves a row of statuses belonging to nobody in particular.
 *
 * `left` is written inline by the view, because it is the sum of the widths
 * before it and only the view knows which columns are frozen today.
 */
.leads-table th.is-pinned, .leads-table td.is-pinned { position: sticky; z-index: 1; }
/* Above the body's frozen cells, and above the ordinary sticky header. */
.leads-table thead th.is-pinned { z-index: 4; }

/*
 * A frozen cell must be opaque — the rest of the table is sliding underneath
 * it — but it still has to show the row's own shade. So: the panel colour
 * underneath as a solid, and the row's tint painted over it as an image.
 * Setting --row-bg alone would leave the frozen cells see-through, since that
 * variable is a translucent tint and, for an ordinary cell, the card behind it
 * supplies the solid.
 */
.leads-table td.is-pinned {
  background-color: var(--panel);
  background-image: linear-gradient(var(--row-bg), var(--row-bg));
  transition: background-image var(--dur-1) var(--ease);
}
.leads-table thead th.is-pinned { background: var(--panel-2); }

/*
 * The boundary, as the cell's own outer box-shadow.
 *
 * The previous ::after was `translateX(100%)` inside a cell with
 * `overflow: hidden`, so it was parked entirely in clipped territory and never
 * rendered once — while the `border-right` beside it drew unconditionally,
 * which is the exact inversion of the intent. An outer shadow is not clipped by
 * the element's own overflow, and the pinned cell's z-index already lifts it
 * above the scrolling cells.
 */
.leads-table .is-pin-edge { transition: box-shadow var(--dur-1) var(--ease); }
.leads-wrap.is-scrolled .leads-table .is-pin-edge { box-shadow: 8px 0 10px -8px var(--pin-shadow); }
/* The two compound cases have to re-declare the inset edge they would drop. */
.leads-wrap.is-scrolled tbody tr.is-selected > td:first-child.is-pin-edge {
  box-shadow: inset 3px 0 0 var(--accent), 8px 0 10px -8px var(--pin-shadow);
}
.leads-wrap.is-scrolled tbody tr.row-fraud > td:first-child.is-pin-edge {
  box-shadow: inset 3px 0 0 var(--red), 8px 0 10px -8px var(--pin-shadow);
}

/*
 * The leads table's own scroll cue. The shared four-layer version paints on
 * .table-wrap's background, i.e. under every cell — and the first column here
 * is always frozen and always opaque, so the left pair was covered in every
 * row. The pin-edge shadow is that side's cue; only the right side needs a fade.
 */
.leads-wrap {
  background:
    linear-gradient(to left, var(--panel) 30%, transparent) right / 34px 100% no-repeat local,
    radial-gradient(farthest-side at 100% 50%, var(--edge-fade), transparent) right / 14px 100% no-repeat scroll;
  transition: opacity var(--dur-1) var(--ease);
}
/*
 * Refining a list does not destroy it. Opacity only — no pointer-events: none,
 * because the operator is still reading and scrolling those rows, and a click
 * on a stale row is harmless: selection is keyed by lead id.
 */
.leads-wrap.is-stale { opacity: .55; }

/*
 * The pager is the card's last row rather than a sibling below it, so Prev and
 * Next sit permanently at the foot of the rows, mirroring the sticky header at
 * the top. Not `position: sticky` — the pager is the last child of its parent
 * in every version of this layout, so sticky has exactly zero travel.
 */
.leads-surface { display: flex; flex-direction: column; max-height: min(72dvh, 816px); }

/*
 * The leads page owns the viewport.
 *
 * `height`, not `min-height`: a flex container only has free space to hand out
 * when its own height is definite. As a grid item `.main` stretches to its
 * content, so `flex: 1` on the card distributed nothing and the table simply
 * grew to 3500px and pushed the page into a scroll.
 *
 * With a definite 100dvh the leftover height goes to the card, the sticky
 * header and the pager stay put, and the rows scroll inside — which is the
 * layout the pager-in-the-card work was for.
 */
/* The height bound matches the shell's: at 932x430 the phone shell is active,
   .main sits under a 56px sticky app bar, and a 100dvh .main would overflow
   the viewport by exactly that bar and take the pager off the bottom. The
   second branch is the same one the tablet rung carries — a short window with
   a mouse is not a landscape phone, and it still wants the fixed-height
   layout rather than a 3500px table. */
@media (min-width: 861px) and (min-height: 501px),
       (min-width: 861px) and (pointer: fine) {
  .main.view-fill {
    display: flex;
    flex-direction: column;
    height: 100dvh;
    /* --sp-7 is trailing space under the last card of a page that scrolls. Here
       nothing follows the table, so it is just height the rows could have had. */
    padding-bottom: var(--sp-5);
    /* Only ever engages if the head and an open filter bar leave less than the
       card's floor. Better a scrollbar in that corner case than clipped rows. */
    overflow: auto;
  }
  .main.view-fill > * { flex: none; }
  .main.view-fill > .leads-surface {
    flex: 1 1 auto;
    max-height: none;
    /* A floor, so a tall filter bar squeezes the page rather than the rows. */
    min-height: 320px;
  }
}
.leads-scroll { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.leads-scroll > .table-wrap.tall { flex: 1 1 auto; min-height: 0; max-height: none; }
.leads-pager {
  flex: none;
  display: flex; justify-content: space-between; align-items: center; gap: 10px;
  padding: 10px 14px;
  background: var(--panel-2);
  border-top: 1px solid var(--border);
  border-radius: 0 0 calc(var(--r-lg) - 1px) calc(var(--r-lg) - 1px);
}

/*
 * The right-hand freeze. `actions` is `locked` and sanitisePinned refuses to
 * pin it, so the column the view itself calls indispensable was off-screen at
 * rest on a 1440 or 1512 laptop with no way for the operator to rescue it.
 */
.leads-table th.is-pinned-right, .leads-table td.is-pinned-right { position: sticky; right: 0; z-index: 1; }
.leads-table thead th.is-pinned-right { z-index: 4; background: var(--panel-2); }
.leads-table td.is-pinned-right {
  background-color: var(--panel);
  background-image: linear-gradient(var(--row-bg), var(--row-bg));
  transition: background-image var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}
.leads-wrap.is-scrolled-end .leads-table .is-pinned-right { box-shadow: -8px 0 10px -8px var(--pin-shadow); }
@media (max-width: 560px) {
  .table-wrap.as-cards th.is-pinned-right,
  .table-wrap.as-cards td.is-pinned-right { position: static; box-shadow: none; }
}

/*
 * The row focus indicator has to be an outline. An inset box-shadow on a <tr>
 * paints in the row's own background layer, which every cell background paints
 * over and which the opaque frozen cells hide entirely. All three overrides
 * undo the global ring: z-index in particular, because `z-index: 3` would lift
 * a focused row above the sticky header at 2.
 */
.leads-table tbody tr:focus-visible {
  outline: 2px solid var(--accent-hi);
  outline-offset: -2px;
  box-shadow: none; border-radius: 0; z-index: auto;
}

/* The queue says it has changed rather than reordering under the operator. */
.new-leads-bar {
  flex: none;
  display: flex; justify-content: center;
  padding: 8px;
  border-bottom: 1px solid var(--border);
  background: var(--accent-soft);
}

/*
 * Two grouping devices for one job, and the louder one was the boxes-and-borders
 * device. The stripe survives horizontal scrolling for free through --row-bg;
 * the hairline does not. Kept on every other table in the app.
 */
.leads-table tbody td { border-bottom: 0; }

.empty { padding: var(--sp-6) var(--sp-5); text-align: center; color: var(--muted); }
.empty strong { display: block; color: var(--text); font-size: var(--fs-body); margin-bottom: 6px; font-weight: var(--w-medium); }
/* The quieter register: a fact, not a full empty state with an action. */
.empty-inline { padding: 14px 0; text-align: center; color: var(--muted); font-size: var(--fs-dense); }

/* ------------------------------- pills ---------------------------------- */

/* 11.5px status text on a tinted chip needs 600 to hold; this is the one place
   the medium weight is doing legibility work rather than hierarchy. */
.pill {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 2px 9px; border-radius: var(--r-pill);
  font-size: var(--fs-caption); font-weight: var(--w-medium); line-height: 1.65;
  background: var(--panel-2); color: var(--muted);
  border: 1px solid var(--border);
  white-space: nowrap;
}
.pill.green { background: var(--green-wash); color: var(--green-text); border-color: var(--green-line); }
.pill.red { background: var(--red-wash); color: var(--red-text); border-color: var(--red-line); }
.pill.amber { background: var(--amber-wash); color: var(--amber-text); border-color: var(--amber-line); }
.pill.blue { background: var(--accent-soft); color: var(--accent-text); border-color: var(--accent-line); }
.pill.purple { background: var(--purple-wash); color: var(--purple-text); border-color: var(--purple-line); }

/* ------------------------------ switches -------------------------------- */

/* An on/off control that saves on click. Overrides the generic button chrome:
   the track is the affordance, so the surrounding box would only add noise. */
.switch {
  align-items: center; gap: var(--sp-2);
  min-height: var(--ctl-h-sm); padding: 3px 4px;
  border-radius: var(--r-pill);
  color: var(--muted);
  font-size: var(--fs-label); font-weight: var(--w-medium);
}
@media (hover: hover) {
  .switch:hover { color: var(--text-dim); }
}
.switch.on { color: var(--green-text); }
/* Mixing towards --text lifts it in the dark theme and deepens it in the light
   one; either way hovering reads as more emphasis, not a different hue. */
@media (hover: hover) {
  .switch.on:hover { color: color-mix(in srgb, var(--green-text) 78%, var(--text)); }
}

.switch-track {
  position: relative; flex: none;
  width: 34px; height: 20px;
  border-radius: var(--r-pill);
  background: var(--panel-3);
  border: 1px solid var(--border-strong);
  transition: background var(--dur-2) var(--ease), border-color var(--dur-2) var(--ease);
}
.switch-knob {
  position: absolute; top: 2px; left: 2px;
  width: 14px; height: 14px; border-radius: 50%;
  background: var(--faint);
  transition: transform var(--dur-2) var(--ease), background var(--dur-2) var(--ease);
}
@media (hover: hover) {
  .switch:hover .switch-track { border-color: var(--accent-line); }
}
.switch.on .switch-track {
  background: var(--green-wash-2);
  border-color: var(--green-line);
}
/* --green-fill, not --green: on --green-wash-2 the light theme's mid green
   measured 2.54:1 against its own track, i.e. the one graphical object that
   carries the state was invisible in the theme most likely to be used outdoors.
   --green-fill is `var(--green)` in dark, so nothing moves there. */
.switch.on .switch-knob { transform: translateX(14px); background: var(--green-fill); }
/* The press, which the hover gate above just took away from touch. The track
   is the affordance, so the track is what answers. */
.switch:active .switch-track { border-color: var(--accent); }
.switch[aria-busy="true"] { cursor: progress; opacity: .72; }

@media (prefers-reduced-motion: reduce) {
  .switch-track, .switch-knob { transition: none; }
}

.flag {
  width: 20px; height: 15px; border-radius: var(--r-xs); object-fit: cover;
  vertical-align: -2px; background: var(--panel-2); flex: none;
  box-shadow: 0 0 0 1px var(--hairline);
}
.country { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }

/* ------------------------------- filters -------------------------------- */

/*
 * Stable columns, so reaching for Country becomes muscle memory instead of a
 * re-find. A flex wrap put nothing on line two under anything on line one, and
 * moved its own wrap point whenever a facet list changed a label's width.
 */
/*
 * One grid, declared once and used by both rows.
 *
 * The search row was a flex row with a 260px box while the filters below were a
 * grid of ~183px tracks, so the two never lined up — the thing an operator
 * reaches for first sat in its own column system. Sharing the track definition
 * means every control's left edge falls on the same column at every width.
 */
.filter-bar { --filter-cols: repeat(auto-fill, minmax(170px, 1fr)); }

.filters, .filter-row {
  display: grid;
  grid-template-columns: var(--filter-cols);
  gap: var(--sp-2) var(--sp-3);
  align-items: end;
}
/*
 * Both selectors are (0,2,1) on purpose: `label.f` is (0,1,1), so a bare
 * `.filter-search` at (0,1,0) loses to it and the label keeps the 12px
 * `margin-bottom` every stacked form field gets. That margin is part of the
 * item's margin box, so it sized the grid row to 49px and left the search box
 * sitting at the top of it while the toggle centred 6px lower — the two on
 * different centre lines for a reason invisible in either rule on its own.
 */
.filters label.f, .filter-row label.f { display: flex; flex-direction: column; margin-bottom: 0; }
.filters > button { justify-self: start; align-self: end; }

/* Search, the toggle and the active-filter chips, always visible. */
.filter-row { align-items: center; margin-bottom: var(--sp-3); }
/* Two tracks: 170px is right for a facet dropdown and cramped for a search
   box. Its left edge still lands on column 1, which is the alignment that
   reads.

   On the always-visible row it takes every track up to the toggle instead:
   at 1512px the old two-track box left ~700px of nothing between Filters and
   the card's right edge, so the row had one end and then stopped. */
.filter-search { grid-column: span 2; }
.filter-row .filter-search { grid-column: 1 / -2; }
/* Natural width rather than a button stretched to a full track — it starts on
   column 3 either way. */
.filter-bar .filter-toggle { justify-self: start; }
/* Their own row: a chip list is as long as the filters that are set, and
   squeezing it into whatever track is left would truncate it. */
.filter-chips { grid-column: 1 / -1; }

/* The bar collapses at every width, not only on a phone: twelve controls plus a
   Reset button lay out at ~2170px of track against 1156px of content at 1440,
   i.e. ~135px of permanent chrome between the page head and the first row. */
.filter-bar:not(.open) .filters { display: none; }
.filter-toggle { display: inline-flex; align-items: center; gap: var(--sp-2); }
.filter-toggle .count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 20px; height: 20px; padding: 0 6px;
  border-radius: var(--r-pill);
  background: var(--accent-fill); color: var(--on-accent);
  font-size: var(--fs-caps); font-weight: var(--w-medium);
}

/* The chips are what make collapsing safe: dropping one filter costs open,
   find, Any, close without them. */
.filter-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.filter-chips:empty { display: none; }
.filter-chip { gap: 4px; padding-right: 3px; }
.filter-chip .chip-x {
  min-height: 0; width: 18px; height: 18px; padding: 0; flex: none;
  border: 0; background: none; color: inherit;
  font-size: var(--fs-caption); line-height: 1; border-radius: var(--r-pill);
}
@media (hover: hover) {
  .filter-chip .chip-x:hover { background: var(--accent-soft-2); border: 0; color: inherit; }
}

/* ------------------------------ utilities ------------------------------- */

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

/*
 * Relative, deliberately not a token. An absolute 12px pinned a phone number a
 * pixel below the email beside it and refused to follow the density switch or
 * the phone breakpoint; 0.92em is an optical correction for the mono stack's
 * larger x-height and lands it at 11.96px inside the 13px table.
 */
.mono { font-family: var(--mono); font-size: 0.92em; font-variant-numeric: tabular-nums; }
.muted { color: var(--muted); }
.small { font-size: 0.92em; }
.nowrap { white-space: nowrap; }
.right { text-align: right; }
.sep { height: 1px; background: var(--border); margin: var(--sp-4) 0; border: 0; }
/* A literal value beside a copy button, not a heading. */
.value-block { padding: 10px var(--sp-3); background: var(--panel-2); border-radius: var(--r-sm); word-break: break-all; }
.tnum { font-variant-numeric: tabular-nums; }

/*
 * max-width is ignored on a td in auto table layout, so clamp an inner span.
 *
 * The pixel cap belongs to the table, not to the utility: a fixed 220px inside
 * a fluid card throws away width the layout already has, which is the phone
 * reading as though it had less room than it does. Outside a cell the clamp is
 * simply "do not exceed your parent". display stays inline-block — a view sets
 * it inline on an <a class="truncate"> and td.truncate > * assumes this model.
 */
.truncate {
  display: inline-block; max-width: 100%;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  vertical-align: bottom;
}
td .truncate, td.truncate { max-width: 220px; }
td.truncate > * { max-width: 100%; }

/* 12px is a monospace metric, not a ramp step: the mono stack's x-height runs
   a size large, so a code block set at --fs-dense would out-shout the prose
   around it. Same reasoning as .mono's 0.92em above. */
pre.code {
  background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r);
  padding: 13px; overflow: auto; font-family: var(--mono); font-size: 12px;
  max-height: 340px; margin: 0; line-height: 1.6;
  tab-size: 2;
}

/*
 * Every scroller nested inside something else that scrolls. Without this, a
 * flick that reaches the end of a code block or a column list hands the rest of
 * the gesture to the page or the sheet behind it, and the operator's scroll
 * jumps to a surface they were not touching. `contain` only stops the chaining;
 * it does not stop the scroll itself, so nothing becomes unreachable.
 */
pre.code, .change-list, .col-list, .inspector-body { overscroll-behavior: contain; }

/* -------------------------------- bars ---------------------------------- */

.bar-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; margin-bottom: 9px; }
.bar-label { display: flex; align-items: center; gap: 7px; font-size: var(--fs-dense); min-width: 0; }
.bar-label .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { height: 6px; background: var(--panel-3); border-radius: var(--r-pill); overflow: hidden; margin-top: 5px; }
/* Flat: the fill's paint box *is* the datum, so a gradient made the mark's
   colour encode a second, meaningless variable — a short bar showed one end of
   the ramp and a full-width one the other. */
.bar-fill {
  height: 100%; border-radius: var(--r-pill);
  background: var(--accent-fill);
  transform-origin: left;
  animation: bar-in var(--dur-3) var(--ease-out) both;
}
@keyframes bar-in { from { transform: scaleX(0); } to { transform: scaleX(1); } }

/*
 * A scroller of its own, not a clip.
 *
 * Every column carries a min-width so a quiet day is still tappable, so a long
 * period simply makes the plot wider than the card. A desktop plots a day per
 * column at every range, floored at 2px: 90 days is 180px of column plus 178px
 * of gap and still fits the ~520px half of a cols-2 row on a 1440 laptop, but a
 * year asks for 1458px and does not. A phone buckets past 60 days yet plots its
 * default 30 as days, which at the thumb floor is 30 x 20px plus 29 gaps —
 * 687px inside a ~330px card.
 *
 * `clip` was there to keep that out of the document, because a page that
 * slides sideways is the one motion a page must never have. But it bought that
 * by painting most of the range outside the box, where it is invisible and
 * untappable while the axis underneath still names the last date. A clipped
 * column is not a narrow column, it is a missing one, and the chart said
 * nothing about the ones it had dropped.
 *
 * `overflow-x: auto` keeps the sideways motion inside the card, where it is a
 * chart the operator pushes rather than a page that slips; the scrollbar is
 * also the only honest signal that there is more of the range to see.
 * `overscroll-behavior-x: contain` stops the gesture chaining out to the
 * document or to the browser's back swipe. It engages only when the plot does
 * not fit, so 7 and 30 days on a desktop render exactly as before.
 *
 * The 4px of padding replaces `overflow-clip-margin`, which only applies to
 * `clip`: a scrollport clips at the padding box, so this is the same room the
 * 2px ring at 2px offset needs around a column that stretches the full height
 * of the plot — the hazard .card.flush's comment names. All four sides,
 * because in a scroller the first column's ring would otherwise fall off the
 * left of the scroll origin.
 */
.spark {
  display: flex; align-items: flex-end; gap: 2px; height: 96px;
  padding: 4px;
  overflow-x: auto; overflow-y: hidden;
  overscroll-behavior-x: contain;
  /*
   * Snap to a column. The plot is a row of discrete days and the readout beneath it
   * names whichever one the scroll came to rest on — so a flick that stops between
   * two columns leaves the chart reporting a day the eye is not on.
   *
   * `proximity`, never `mandatory`: a year is meant to be pushed through in one
   * gesture, and mandatory snapping would catch every column on the way past.
   */
  scroll-snap-type: x proximity;
}
.spark .col-hit { scroll-snap-align: center; }
/*
 * The hit area is the full height of the plot, not the height of the bar.
 * A column standing for a quiet day is a few pixels tall, so the day with the
 * least data was the hardest one to ask about — the target now runs from the
 * axis to the top of the chart and the bar is just what is drawn inside it.
 *
 * The 6px floor is a real floor now rather than a promise: .spark scrolls, so
 * a column that will not fit is pushed past the edge and can still be reached,
 * instead of shrinking to a hairline or being dropped.
 */
.spark .col-hit {
  display: flex; align-self: stretch; align-items: flex-end;
  flex: 1 1 0; min-width: 6px; padding: 0; background: none; border: 0;
}
/*
 * That floor is a thumb's, and a cursor that lands on one pixel has no use for
 * it. Bucketing is gated on the same 860px inside dashboard.js plotUnit(), so a
 * desktop plots one column per day at every range: 90 of them at the touch floor
 * is 718px of plot inside that ~520px half-row, and a chart that fitted before
 * this pass would have become a scroller. 2px is the width the desktop plot was
 * drawn around. A year overflows even at it, which is what the scroller is for.
 *
 * Stated here rather than beside the phone block's 20px so that a 932pt phone on
 * its side — which clears 861 on width and is still a thumb — reads the later
 * rule and keeps the touch floor.
 */
@media (min-width: 861px) {
  .spark .col-hit { min-width: 2px; }
}
/* "This one", rather than "the render changed" — and stated on the hit area,
   not on the bar. The bar is a span inside the button now, so it is never the
   focused element and, above a short column, never the hovered one either. */
.col-hit:hover > .col, .col-hit:focus-visible > .col, .col-hit:active > .col { opacity: 1; }
/* The columns are buttons so touch and keyboard can read their values; strip
   the button chrome back to a bare bar. */
.spark .col {
  flex: 1 1 0; min-width: 2px;
  background: var(--accent-fill);
  border-radius: var(--r-xs) var(--r-xs) 0 0;
  opacity: .8;
  transition: opacity var(--dur-1) var(--ease);
  animation: col-in var(--dur-3) var(--ease-out) both;
}
@keyframes col-in { from { transform: scaleY(0); transform-origin: bottom; } to { transform: scaleY(1); } }

/* The chart card is paired with a list card of unknown length, so let the plot
   grow into whatever height the row ends up being instead of leaving a void. */
.card.stretch { display: flex; flex-direction: column; }
.chart-flex { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.chart-flex .spark { flex: 1 1 auto; min-height: 96px; }

.chart-axis {
  display: flex; justify-content: space-between; gap: 10px;
  /* The same 4px the plot above reserves for its focus ring, so the first and
     last date stay under the first and last column rather than 4px outboard
     of them. */
  padding: 0 4px;
  margin-top: 9px; font-variant-numeric: tabular-nums;
}
/* Rewritten on every pointer move, so its digits have to hold still. */
.chart-readout {
  margin-top: 8px; padding-top: 9px;
  border-top: 1px solid var(--border);
  font-size: var(--fs-dense); min-height: var(--ctl-h);
  font-variant-numeric: tabular-nums;
  display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
}
@media (max-width: 860px) {
  /* Taller and pitched wider, because the plot has the whole width of a phone
     card rather than half a desktop row. The column's own floor is not here:
     the bar is a span inside .col-hit and fills it at `flex: 1 1 0`, so what
     has to be thumb-sized is the button — see .spark .col-hit in the phone
     block, which is where that argument now lives. */
  .spark { gap: 3px; height: 110px; }
}

/* -------------------------------- modal --------------------------------- */

/*
 * ===========================================================================
 * THE LAYER LEDGER
 *
 * Every app-level floating layer, in the order it stacks. This list is the
 * single source of truth: nothing anywhere else invents a value, and a new
 * layer is added here first and to the stylesheet second. Values under 10 are
 * not on it — those are local stacking inside one component (a sticky thead
 * against its own rows, the focus ring against its neighbour's border).
 *
 *   40  .corner-badges, .inspector
 *   44  .new-leads-bar (phone, fixed — sits under the app bar)
 *   45  .topbar, .tabbar, .bulk-bar
 *   55  .nav-scrim
 *   60  .sidebar
 *   70  .modal-backdrop   <-- raised from 50. The drawer is 60, so a dialog
 *                             opened from a nav row was painted underneath
 *                             the thing that opened it.
 *   90  .menu-panel       (menus open from inside dialogs)
 *  100  #toasts, #toasts-alert
 *  200  .skip-link
 *
 * The tab bar shares 45 with the app bar on purpose: they are the app's two
 * edges, they cannot overlap, and everything that is meant to take the screen
 * over from them — the scrim, the drawer, a sheet, a toast — is above both.
 *
 * One value sits below the list and above the local tier: .leads-pager takes
 * 20 when it goes sticky on a phone. It is in flow inside .main, so it only
 * ever has to clear the rows it is pinned over — and it is under every fixed
 * layer above, which is the point of writing it here rather than guessing.
 * ===========================================================================
 */
.modal-backdrop {
  position: fixed; inset: 0; z-index: 70;
  background: var(--overlay);
  -webkit-backdrop-filter: var(--scrim-blur);
  backdrop-filter: var(--scrim-blur);
  display: flex; align-items: flex-start; justify-content: center;
  padding: var(--sp-7) var(--sp-5);
  overflow-y: auto; overscroll-behavior: contain;
  animation: fade-in var(--dur-2) var(--ease-out) both;
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }

/* A 620px dialog being rounder than a 1100px card is backwards; --r-lg is the
   panel-sized radius and the modal is a panel. */
.modal {
  background: var(--panel);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-lg);
  width: 100%; max-width: 620px;
  box-shadow: var(--sh-4);
  animation: modal-in var(--dur-3) var(--ease-out) both;
}
@keyframes modal-in {
  from { opacity: 0; transform: translateY(14px) scale(.985); }
  to { opacity: 1; transform: none; }
}
.modal.wide { max-width: 940px; }

/*
 * Drag-to-dismiss, and the one declaration that makes it possible.
 *
 * On a phone `.modal` carries `animation: sheet-in … both`, and a
 * forwards-filling animation outranks an inline style — so util.js could write
 * `transform: translateY(90px)` on every pointermove and the sheet would not
 * move a pixel. The class is added on pointerdown and dropped on release.
 */
.modal.is-dragging { animation: none !important; transition: none !important; }
/*
 * The release, and the reason it is a second class rather than the absence of the
 * first.
 *
 * `.is-dragging` has to say `transition: none !important` — without it the sheet
 * would lag a frame behind the finger — and an `!important` declaration beats the
 * inline style a spring-back would otherwise be written as. So dropping the class
 * on release restored `animation: sheet-in … both`, whose forwards fill pins
 * `transform: none`, and the sheet arrived home in a single frame: the one motion in
 * the file that reads as a rendering fault, as the reward for changing your mind.
 *
 * `.is-settling` keeps the entrance animation off and puts a transition back, so the
 * transform can travel to zero on the spring. util.js swaps one class for the other
 * and clears both on transitionend.
 */
.modal.is-settling {
  animation: none !important;
  transition: transform var(--dur-3) var(--ease-spring) !important;
}
/* The scrim follows the finger. On iOS a sheet's backdrop lightens as the sheet is
   pulled down, and that coupling is most of what makes the gesture feel like it is
   dismissing something rather than nudging it. The backdrop's own entrance is
   `fade-in … both`, whose fill would outrank an inline opacity — hence the class. */
.modal-backdrop.is-dragging { animation: none !important; }

/* Dialogs leave the way they arrived, instead of being present then absent
   between frames. */
.modal-backdrop.is-closing { animation: fade-out var(--dur-1) var(--ease) both; }
@keyframes fade-out { to { opacity: 0; } }
.modal-backdrop.is-closing .modal { animation: modal-out var(--dur-1) var(--ease) both; }
@keyframes modal-out { to { opacity: 0; transform: translateY(6px) scale(.99); } }

/* One inset for all three parts; the vertical padding stays per part. */
.modal-head {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  padding: var(--sp-4); border-bottom: 1px solid var(--border);
}
.modal-head h2 {
  margin: 0; font-size: var(--fs-heading); line-height: var(--lh-heading);
  letter-spacing: var(--tr-title); font-weight: var(--w-medium); min-width: 0;
}
.modal-body { padding: var(--sp-4); }
.modal-foot {
  display: flex; justify-content: flex-end; gap: var(--sp-2); flex-wrap: wrap;
  padding: 14px var(--sp-4); border-top: 1px solid var(--border);
  background: var(--tint-1);
  border-radius: 0 0 calc(var(--r-lg) - 1px) calc(var(--r-lg) - 1px);
}
/* Pulled back so a 34px box does not crowd the head's 16px inset. */
.close-x { margin-right: -5px; font-size: 19px; }

/*
 * The gap between a footer's left-hand and right-hand action groups, as a real
 * element rather than `style="flex:1"` written out at five call sites. On a
 * phone every footer button goes full width, so the spacer would otherwise
 * take a whole row of its own — see the 860px block.
 */
.foot-spacer { flex: 1 1 auto; }

/*
 * The landing-page editor's field groups. Open on a desktop, where the whole
 * form fits; collapsed on a phone, where it is eight screens of scrolling
 * before the operator can tell what the page even contains. The view decides
 * which by matchMedia — CSS cannot open a <details> — and `scroll-margin-top`
 * keeps a summary from opening underneath the sticky sheet head.
 */
.sheet-section > summary {
  cursor: pointer; list-style: none; font-weight: var(--w-medium);
  scroll-margin-top: calc(var(--topbar-h) + var(--sp-4));
}

/* ---------------------------- views + columns --------------------------- */

/* One "this is on" treatment. aria-pressed is the state carrier, so the look an
   operator learns on the Views button is the same look in the columns dialog. */
/* A fill, not a tint. The old --accent-soft wash was a 1.10:1 change in light
   and about 1.00:1 in dark — deciding whether a saved view was on meant
   comparing two near-identical greys across a wrapping row. --on-accent on
   --accent-fill measures 5.20:1 light and 5.19:1 dark.
   Deliberately global, and it does change the desktop leads toolbar: with a
   saved view applied, Views becomes a filled blue button in a row of neutral
   34px ones. That is the point — 1.10:1 is not a state on a 27in display
   either, and one "this is on" treatment means one, not one per breakpoint. */
.btn-view.is-active, button[aria-pressed="true"] {
  border-color: var(--accent-fill);
  background: var(--accent-fill);
  color: var(--on-accent);
}
@media (hover: hover) {
  .btn-view.is-active:hover, button[aria-pressed="true"]:hover {
    background: var(--accent-press); border-color: var(--accent-press); color: var(--on-accent);
  }
}
/*
 * Two controls opt out, and the opt-out is not optional: `button[aria-pressed]`
 * is (0,1,1) and out-ranks both of them, so without this the login form's
 * Show/Hide would be a saturated blue button competing with Sign in from
 * inside the password field, and the Columns dialog would grow a column of
 * solid blue Frozen pills against its own stated "quiet until it is on".
 * Both are chrome-less in-field controls: they carry the state on their edge.
 */
.pw-toggle[aria-pressed="true"], .col-pin[aria-pressed="true"] {
  background: transparent;
  border-color: var(--accent);
  color: var(--accent-text);
}
/* The hover pair has to opt out too, or (0,2,1) above would put the fill back
   the moment a pointer crossed it — the same trap the fill rule's own hover
   rule was written to avoid. */
@media (hover: hover) {
  .pw-toggle[aria-pressed="true"]:hover, .col-pin[aria-pressed="true"]:hover {
    background: var(--accent-soft);
    border-color: var(--accent);
    color: var(--accent-text-hi);
  }
}

/* One column, not two: the list order *is* the table order, so a grid that
   wrapped into a second column would be showing an arrangement the table does
   not have. */
.col-list {
  display: flex; flex-direction: column; gap: 2px;
  max-height: min(56vh, 460px); overflow-y: auto;
  padding-right: var(--sp-1);
}
/* A real transition, so ↑/↓ is something the eye can follow rather than a
   teleport that has to be re-read. The reduced-motion block neutralises it. */
.col-row {
  display: flex; align-items: center; gap: var(--sp-1);
  min-height: var(--ctl-h); padding: 2px 6px 2px 2px;
  border-radius: var(--r-sm);
  border: 1px solid transparent;
  background: var(--panel);
  transition: transform var(--dur-2) var(--ease-out);
}
@media (hover: hover) {
  .col-row:hover { background: var(--panel-2); }
}
.col-row.is-dragging { opacity: .5; border-color: var(--accent-line); background: var(--panel-3); }

.col-grip {
  flex: none; width: 22px; text-align: center;
  color: var(--faint); font-size: 15px; line-height: 1;
  cursor: grab; user-select: none;
}
.col-row:active .col-grip { cursor: grabbing; }
/* A grab handle that cannot be grabbed is a lie about what the row does. The
   ↑/↓ buttons beside it are the reorder affordance on touch, and they work. */
@media (hover: none) and (pointer: coarse) {
  .col-grip { display: none; }
}

.col-toggle {
  display: flex; align-items: center; gap: 10px;
  flex: 1; min-width: 0; min-height: var(--ctl-h); padding: 4px 6px;
  border-radius: var(--r-sm); cursor: pointer;
}
.col-toggle input { width: 17px; flex: none; }
.col-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.col-note { flex: none; }
.col-row.is-locked .col-toggle { cursor: default; opacity: .72; }

.col-moves { display: flex; gap: 2px; flex: none; }
.col-moves button { min-width: 28px; padding: 3px 6px; line-height: 1; }
.col-moves button:disabled { opacity: .3; cursor: default; }

/* Freeze. Quiet until it is on, then it is one of the few things on this
   dialog that changes how the table behaves rather than what it contains. */
.col-pin {
  flex: none; min-width: 74px; padding: 3px 8px; line-height: 1.5;
  font-size: var(--fs-caps); white-space: nowrap; opacity: .55;
}
@media (hover: hover) {
  .col-row:hover .col-pin { opacity: 1; }
}
.col-pin:focus-visible { opacity: 1; }
/* "Quiet until it is on" needs a way to become loud. On touch there is no
   hover to lift the dimming with, so it would simply be a permanently faint
   control — the resting opacity comes off instead. Bounded at 561px because
   below it Freeze is not offered at all: the table is cards, there is no
   horizontal scroller for a sticky column to hold still against, and the
   card-mode block hides the button outright. */
@media (hover: none) and (min-width: 561px) {
  .col-pin { opacity: 1; }
}
/* Only what the generic aria-pressed rule above cannot express. */
.col-pin[aria-pressed="true"] { opacity: 1; }
.col-row.is-pinned { border-color: var(--accent-line); }

/* --------------------------- tables as cards ---------------------------- */

/**
 * Below 560px a 16-column table is a horizontal-scrolling maze. `.as-cards`
 * restacks each row into a labelled card: the `data-label` on every cell
 * becomes its own heading, so the same markup serves both layouts and nothing
 * is hidden from the phone.
 */
/* Four scalars — priority, weight, share and cap — merged into one line by the
   view for card mode. Never shown on a desktop, where each has its own column
   and its own header. */
.cell-routing { display: none; }

@media (max-width: 560px) {
  .table-wrap.as-cards { overflow: visible; background: none; }
  .table-wrap.as-cards table,
  .table-wrap.as-cards tbody,
  .table-wrap.as-cards tr,
  .table-wrap.as-cards td { display: block; width: auto; }

  .table-wrap.as-cards thead { display: none; }

  /*
   * Two concentric boundaries, not three. The card, the row and the pill each
   * drew their own 1px --border around one lead's phone number; the outer one
   * goes (see .leads-surface below) and the row becomes the outermost surface,
   * so a 4px inset against a 10px arc reads as concentric with nothing left to
   * disagree with. The row's own border stays: --panel-2 on --bg measures
   * 1.01:1 in the light theme, and borderless rows would simply vanish there.
   */
  .table-wrap.as-cards tr {
    position: relative;
    margin: 0 4px 4px;
    padding: 6px 4px 4px;
    border: 1px solid var(--border);
    border-radius: var(--r);
    background: var(--panel-2);
  }
  .table-wrap.as-cards tr:first-child { margin-top: 4px; }
  /* A phone table row is not a link with a hover state — it is a card that gets
     pressed, and the press is --row-press on the cells. Row-level touch-action
     lives here rather than in the global list, so the 561-860 table, which is
     still a real 16-column grid, keeps double-tap zoom. */
  .leads-table tbody tr { touch-action: manipulation; }
  /*
   * A card is pressed, not read character by character.
   *
   * leads.js keeps drag-select inside a cell deliberately, and on a desktop that is
   * right — a phone number is something a cursor picks out. On a phone the same
   * gesture is what a slow press *is*, so pressing a row to open it put iOS's
   * selection handles and its callout bar over the list instead. And nothing here is
   * lost by taking it away: the values worth extracting have a copy button on the
   * record itself, and this product never dials the number anyway.
   *
   * Scoped to card mode, so the 561-860px table — still a real table, read with a
   * cursor as often as a thumb — is untouched.
   */
  .table-wrap.as-cards tbody tr {
    user-select: none; -webkit-user-select: none;
    -webkit-touch-callout: none;
  }

  .table-wrap.as-cards td {
    display: grid;
    /* Tighter since --fs-caps went to 12px on a phone: the label track was
       sized for 11px caps and would otherwise take a third of the card. */
    grid-template-columns: minmax(72px, 28%) minmax(0, 1fr);
    gap: 12px; align-items: center;
    /* Without this a pill or a badge stretches to fill the value column. */
    justify-items: start;
    padding: 7px 10px;
    border-bottom: 1px solid var(--border);
    text-align: left;
  }
  /* A card is read at arm's length, not scanned down a column, so the density
     that pays for a 16-column table buys nothing here.
     No size of its own any more: --fs-dense *is* 15px inside the phone shell now,
     so this was the ramp's own value written out by hand — and the two would have
     drifted the first time either moved. The cells simply inherit. */
  /* `last-of-type`, not `last-child`: on a phone the row can carry a `.swipe-actions`
     div after its cells, and `td:last-child` then matches nothing at all — leaving
     the real last cell drawing a hairline against the card's own bottom edge. */
  .table-wrap.as-cards td:last-of-type { border-bottom: 0; }
  /* One of the two surviving caps treatments: a label inside a container that
     offers no other separation from the value beside it. */
  .table-wrap.as-cards td::before {
    content: attr(data-label);
    color: var(--muted); font-size: var(--fs-caps); font-weight: var(--w-medium);
    text-transform: uppercase; letter-spacing: var(--tr-caps);
  }
  /*
   * Every real child is pinned to the value track. The grid has two columns and
   * a two-child cell contributes three items with the ::before, so the second
   * line — an email under a phone number, a failure reason under a brand — was
   * auto-placed into the label column and read as though it belonged to the
   * label rather than to the value above it.
   */
  .table-wrap.as-cards td[data-label] > * { grid-column: 2; }
  /* Cells with nothing to label (the row checkbox) should not reserve a column. */
  .table-wrap.as-cards td:not([data-label]) { display: block; }
  .table-wrap.as-cards td.num { text-align: left; }
  .table-wrap.as-cards td .truncate { max-width: 100%; }

  /* The select box rides in the corner instead of taking a whole row. */
  .table-wrap.as-cards td.cell-select {
    position: absolute; top: 6px; right: 8px;
    padding: 0; border: 0; width: auto;
  }
  .table-wrap.as-cards .cell-tick { min-width: var(--tap); min-height: var(--tap); }
  /*
   * The corner the checkbox is parked in, reserved. One rule for both cells
   * that need it: a second `padding` shorthand on either of them would silently
   * overwrite this one and the reservation would vanish from whichever lost.
   */
  .table-wrap.as-cards td.cell-lead,
  .table-wrap.as-cards td[data-label="Lead ID"] { padding: 12px 56px 10px 10px; }
  /*
   * The identity cell is the card's heading, not one labelled row among
   * fifteen. It leaves the label grid entirely — a card does not need a caps
   * label reading LEAD above the lead's own name — and the name takes the
   * heading step so the eye lands on it first.
   */
  .table-wrap.as-cards td.cell-lead { display: block; }
  .table-wrap.as-cards td.cell-lead::before { content: none; }
  .table-wrap.as-cards .cell-lead .lead-name {
    font-size: var(--fs-heading); line-height: var(--lh-heading);
    font-weight: var(--w-medium);
  }
  .table-wrap.as-cards td.cell-actions { text-align: left; }
  /* A row whose only action is "nothing to do here" should not spend a whole
     line of the card saying so with an em dash. */
  .table-wrap.as-cards td.cell-actions:has(.is-empty-action) { display: none; }
  .table-wrap.as-cards tr:has(td.cell-actions .is-empty-action)
    td:nth-last-child(2) { border-bottom: 0; }
  /* Card mode merges the four routing scalars into one line and hides the
     columns they came from. */
  .table-wrap.as-cards td.cell-routing { display: grid; }
  .table-wrap.as-cards td.cell-priority,
  .table-wrap.as-cards td.cell-weight,
  .table-wrap.as-cards td.cell-share,
  .table-wrap.as-cards td.cell-cap { display: none; }

  /* 6px is the gap between two 34px glyph buttons in a desktop table. Once
     every one of them is 44px tall, adjacent targets sit inside each other's
     envelope and a thumb strike is ambiguous between them. Scoped to card mode
     rather than declared on .row-actions: at 561-860px the cell is still a
     column in a real table, and widening it there pushes the table sideways. */
  .table-wrap.as-cards .row-actions {
    justify-content: flex-start; flex-wrap: wrap; gap: var(--sp-2);
  }
  .table-wrap.as-cards .row-actions > button { flex: 1 1 auto; }
  /* The tablet exemption ends here: below 560 the table is cards, the row is
     no longer the tap target, and a single-glyph Refresh needs both axes.
     Prefixed with the wrapper because the exemption it undoes is declared in
     the 860px block, which is further down the file. */
  .table-wrap.as-cards .leads-table td .btn-sm { min-height: var(--tap); min-width: var(--tap); }
  /* Freezing a column is inert once there are no columns to freeze. */
  .col-pin { display: none; }

  /* The nested scroller has nothing to scroll once rows are stacked. */
  .table-wrap.as-cards.tall { max-height: none; overflow-y: visible; }

  /*
   * Card mode undoes the grid the desktop table is built on, and every part of
   * the frozen-column machinery goes with it: there is no sideways scroll to
   * anchor against, the cells are blocks rather than a row, and the inline
   * `left` offsets would push them off the card. `!important` because those
   * offsets are written as inline styles by the view.
   */
  .table-wrap.as-cards colgroup { display: none; }
  .table-wrap.as-cards table.table-fixed { table-layout: auto; }
  .table-wrap.as-cards th.is-pinned,
  .table-wrap.as-cards td.is-pinned {
    position: static;
    left: auto !important;
    border-right: 0;
  }
  .table-wrap.as-cards .is-pin-edge { box-shadow: none; }
  /*
   * One line with an ellipsis is a desktop-column rule; on a card the value has
   * the full width of the screen and should use it. The unwrap has to name the
   * inner spans as well as the cell — the email, the brand sub-line and the
   * failure reason each carry their own clamp, sized to a desktop column, and
   * they survived a rule that only reached `td`.
   */
  .table-wrap.as-cards .leads-table td { white-space: normal; overflow: visible; }
  .table-wrap.as-cards .contact-phone,
  .table-wrap.as-cards .contact-email,
  .table-wrap.as-cards .lead-brand,
  .table-wrap.as-cards .lead-brand-sub {
    white-space: normal; overflow: visible; text-overflow: clip;
    overflow-wrap: anywhere;
  }
  .table-wrap.as-cards .cell-lead .lead-name { white-space: normal; }
  /* 60%, not none: a verbose brand status would otherwise take a whole line of
     the card and become the loudest thing on it. */
  .table-wrap.as-cards .cell-lead .lead-brandstatus { max-width: 60%; }

  /*
   * ==========================================================================
   * The card as a list cell, rather than a table stood on its end.
   *
   * `.cards-plain` is written by the view when the visible columns are exactly the
   * default five. Then the three labelled rows under the heading — Contact, Brand,
   * Registered — are values that say what they are: a phone number over an email, a
   * brand over its status, a relative time. A caps label in front of each is the
   * responsive-table idiom announcing facts their own shape already carries, and it
   * costs a third of the card's width to do it.
   *
   * Add any column and every label comes back, in the same breath: an IP address,
   * an affiliate id or a landing page slug is not self-evident, and the grid is
   * what makes those readable. So this is a special case for the common case, not a
   * replacement for the general one.
   * ==========================================================================
   */
  .table-wrap.as-cards.cards-plain td[data-label]:not(.cell-actions) {
    display: block;
    padding-top: 3px; padding-bottom: 3px;
    border-bottom: 0;
  }
  .table-wrap.as-cards.cards-plain td[data-label]:not(.cell-actions)::before { content: none; }
  /* The value track is gone with the grid, so the pins to column 2 go with it —
     otherwise every child is placed into a column that no longer exists. */
  .table-wrap.as-cards.cards-plain td[data-label] > * { grid-column: auto; }
  /* The heading keeps its own inset and the rest close up under it: with the labels
     gone the rows are lines of one paragraph rather than cells of a table. */
  .table-wrap.as-cards.cards-plain td.cell-lead { padding-bottom: 6px; }
  .table-wrap.as-cards.cards-plain tr { padding-bottom: 8px; }
  /* The time reads as the quiet trailing line it is, rather than as the last value
     in a list of equals. */
  .table-wrap.as-cards.cards-plain td[data-label="Registered"] {
    color: var(--faint); font-size: var(--fs-caption);
  }
  /*
   * With the default columns the Actions cell goes and the swipe replaces it.
   *
   * It was a full-width button bar — about 50px on every card — for one control
   * that is also in the record's own footer, one tap away through the card the
   * operator is already pressing. Only in `.cards-plain`: with an added column the
   * labelled grid is back and so is the row, because then the swipe is competing
   * with a card that has genuinely more in it than a gesture can stand in for.
   */
  .table-wrap.as-cards.cards-plain td.cell-actions { display: none; }

  /*
   * The date headings between groups of cards.
   *
   * A phone list is read by going down it, and with the pager replaced by a growing
   * list there is no page number left to say roughly where you are — the heading is
   * what says it. Sticky, so the day currently being read stays named at the top of
   * the screen, under the app bar and the arrivals pill that share that edge.
   *
   * The row has to be dragged back out of the card treatment above it: no card fill,
   * no border, no radius, no press.
   */
  .table-wrap.as-cards tbody tr.date-group {
    position: sticky;
    top: calc(var(--topbar-h) + var(--safe-t));
    z-index: 3;
    margin: 0; padding: 0;
    background: none; border: 0; border-radius: 0;
    transform: none;
  }
  .table-wrap.as-cards tbody tr.date-group > td {
    display: block;
    padding: 10px 6px 4px;
    border-bottom: 0;
    background: var(--float-bg);
    -webkit-backdrop-filter: var(--float-blur);
    backdrop-filter: var(--float-blur);
    color: var(--muted);
    font-size: var(--fs-caps); font-weight: var(--w-medium);
    text-transform: uppercase; letter-spacing: var(--tr-caps);
  }
  .table-wrap.as-cards tbody tr.date-group > td::before { content: none; }

  /*
   * The swipe layer, parked in the strip the card vacates.
   *
   * It is a child of the row — a <tr> has nowhere else to put it — so it inherits
   * the row's own translation and has to be counter-translated back out of it. At
   * rest both are zero and it sits over the card's right edge, which is why it is
   * transparent to pointers and to the eye until the gesture starts.
   */
  .table-wrap.as-cards .swipe-actions {
    position: absolute; top: 0; bottom: 0; right: 0;
    display: flex; align-items: stretch;
    width: 96px;
    transform: translateX(calc(var(--swipe, 0px) * -1));
    opacity: 0; pointer-events: none;
  }
  .table-wrap.as-cards tr.is-swiping .swipe-actions,
  .table-wrap.as-cards tr.is-swiped .swipe-actions { opacity: 1; pointer-events: auto; }
  .table-wrap.as-cards .swipe-action {
    flex: 1; min-height: 0;
    flex-direction: column; gap: 2px;
    border: 0; border-radius: var(--r);
    background: var(--accent-fill); color: var(--on-accent);
    font-size: var(--fs-caption); font-weight: var(--w-medium);
    white-space: nowrap;
  }
  .table-wrap.as-cards .swipe-action:active { background: var(--accent-press); }
  /* The card itself. `--swipe` is written by the gesture and the class carries the
     resting state, so the transition only ever runs on release. */
  .table-wrap.as-cards tbody tr { transform: translateX(var(--swipe, 0px)); }
  .table-wrap.as-cards tbody tr.is-swiped { --swipe: -96px; }
  .table-wrap.as-cards tbody tr:not(.is-swiping) {
    transition: transform var(--dur-2) var(--ease-out);
  }

  /*
   * The disclosure accessory: this card opens.
   *
   * On a desktop the row says so with a cursor and a hover fill. On a phone the only
   * feedback was the press wash, which arrives *after* the decision to tap it. This
   * is the iOS accessory, and it is on the heading line rather than centred on the
   * card's right edge for a reason that is geometry and not taste: the select box is
   * absolutely positioned into the top-right corner with 56px of the identity cell
   * reserved for it, so a chevron centred vertically would collide with it on any
   * card short enough for the two to meet. Here it cannot — `.lead-top` already ends
   * where that reservation begins.
   *
   * '›' and not '>' or an SVG: it is the glyph the platform draws, it takes `color`,
   * and it shares a baseline with the name beside it.
   */
  .table-wrap.as-cards .cell-lead .lead-top::after {
    content: '›';
    margin-left: auto;
    padding-left: var(--sp-2);
    color: var(--faint);
    font-size: 1.35em; line-height: 1;
    font-weight: var(--w-regular);
    flex: none;
  }

  /*
   * The fraud chip is the one thing in the meta row that is pressed, and in a
   * table it deliberately gives up its control floor so the row stays one line
   * high. On a card there is no row rhythm to protect, so it takes the floor
   * back — as real height, not as a 44px ::after overlay, which would have
   * stolen the row-open tap for 44px in every direction around it. The gap
   * grows with it so the chip does not crowd the pills either side.
   */
  .table-wrap.as-cards .cell-lead .lead-meta { gap: 8px; row-gap: 8px; }
  .table-wrap.as-cards .cell-lead .lead-fraud {
    min-height: var(--tap); padding: 0 12px; font-size: var(--fs-dense);
  }

  /*
   * The state rails, restored. Both were inset shadows on `td:first-child`,
   * which in card mode is the absolutely-positioned checkbox cell in the top
   * right corner — so the one mark that says "this lead is fraud" was being
   * painted three pixels wide behind a tick box. On a card the rail belongs to
   * the card. Selection is declared last because it wins: a selected fraud row
   * is being acted on, and that is the fact the operator is tracking.
   * The left padding gives back the 2px the thicker border took.
   */
  .table-wrap.as-cards tbody tr.row-fraud {
    border-color: var(--red-line);
    border-left: 3px solid var(--red);
    padding-left: 2px;
  }
  .table-wrap.as-cards tbody tr.is-selected {
    border-color: var(--accent-line);
    border-left: 3px solid var(--accent);
    padding-left: 2px;
  }
  .table-wrap.as-cards tbody tr.row-fraud > td:first-child,
  .table-wrap.as-cards tbody tr.is-selected > td:first-child { box-shadow: none; }

  /*
   * The outer card's chrome comes off so the row cards are the surface. Border,
   * fill and shadow only — never `display` or `padding`: .leads-surface is the
   * fixed-height flex column that keeps the pager as its last row. The pager
   * gives up its bottom arc in the same breath, or it would be rounding
   * against a corner that is no longer there.
   */
  .leads-surface { border: 0; background: none; box-shadow: none; }
  .leads-pager { border-radius: 0; }

  /* The lead sheet's values need no rule: .detail-grid is a table, tables read
     --fs-dense, and inside the phone shell that is the 15px this used to state. */

  /*
   * The cold-load stub has to be the shape of the thing that replaces it. A
   * skeleton of flat rows resolving into a stack of cards is a layout jumping
   * at the exact moment the operator started reading it.
   */
  .sk-table-cards > *:first-child { display: none; }
  .sk-table-cards > * + * { border-top: 0; }
  /* 4px, not 10: the same inset and the same stack pitch as the real card at
     `.table-wrap.as-cards tr` above. At 10 every stub sat 6px in from where
     its card would land and the pitch was 6px loose, so the whole list slid
     sideways and re-spaced itself the moment the data arrived — which is the
     one thing this stub exists to prevent. */
  .sk-table-cards > *:not(:first-child) {
    display: block !important;
    margin: 0 4px 4px;
    padding: 22px 10px !important;
    border: 1px solid var(--border); border-radius: var(--r);
    background: var(--panel-2);
  }
  /* The first row is hidden as the stand-in thead, so the second is the one
     that mirrors `tr:first-child`'s top margin. */
  .sk-table-cards > *:nth-child(2) { margin-top: 4px; }

  /*
   * The two key/value reference tables in Settings. They are not card-mode
   * tables — there is nothing to label, the left cell *is* the label — so they
   * simply stack, with the value directly under the name it belongs to.
   */
  .kv-table td { display: block; width: auto; }
  .kv-table td.kv-key { border-bottom: 0; padding-bottom: 0; }
  .kv-table .mono { overflow-wrap: anywhere; }

  /* A placeholder token is one unbreakable word in a 230px column; on a phone
     it has to be allowed to wrap rather than push the table sideways. */
  .docs-ref td.mono { white-space: normal; overflow-wrap: anywhere; width: auto; }

  /*
   * The pager's segmented group comes apart. A 2-up segmented control is a
   * 34px-tall object whose two halves share a border, which at 390px is two
   * targets that miss as often as they hit; as separate buttons they can each
   * take the floor. The fill is required — the group used to paint it, and the
   * children were left transparent on that assumption. `overflow: visible` is
   * belt and braces: the group's own clip came off with the focus-ring fix, and
   * this says so at the one place that would notice if it came back.
   */
  /*
   * The pager stops being a bar.
   *
   * With the list continuous below 560px there is no page to be on, and what is left
   * is a count and one button — so it belongs at the end of the list rather than
   * pinned above the tab bar. That also ends the seam J3 names: two translucent
   * blurred bars of the same recipe stacked at the bottom of the screen, reading as
   * one thick bar with a line through it.
   *
   * `static` overrides the sticky positioning the 860px block sets, and the material
   * goes with it — parked in the flow of the card there is nothing behind it to be
   * translucent against.
   */
  .leads-pager {
    position: static;
    background: none;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    justify-content: center;
    gap: var(--sp-3);
    flex-wrap: wrap; row-gap: 10px;
  }
  .leads-pager > button { min-height: var(--tap); padding-inline: 20px; }
  /*
   * The rules that took the pager's segmented Prev/Next apart into two separately
   * tappable buttons are gone from here, and deliberately not moved anywhere.
   *
   * This block is `max-width: 560px`, and below it renderPager no longer builds a
   * segmented control at all — the pager is a count and one Load more button. The
   * 561-860px band still gets the paged list, and it gets the desktop group, which
   * is correct: at that width the two halves are 34px in a row that is not being
   * thumbed at 390px. Kept as a note rather than as dead rules, because a selector
   * for an element that is never rendered is a claim about the markup that is not
   * true any more.
   */
}

/* ----------------------------- action menu ------------------------------ */

/* Glyph spacing for the three '·' characters the trigger emits when it has no
   text — not typography, which is why it is a literal and why it is scoped so
   it can never reach a real word. */
.menu-trigger:not(.has-text) { letter-spacing: .09em; padding-left: 9px; padding-right: 9px; }

.menu-panel {
  position: fixed; z-index: 90;
  min-width: 176px; padding: var(--sp-1);
  background: var(--panel-2);
  border: 1px solid var(--border-strong);
  border-radius: var(--r);
  /* --sh-3, the floating tier the bulk bar already uses. At --sh-2 a menu on a
     pale page had a total boundary of roughly half the 3:1 a transient layer
     needs to read as being on top of the content rather than part of it. */
  box-shadow: var(--sh-3);
  display: flex; flex-direction: column; gap: 1px;
  max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain;
  animation: menu-in var(--dur-2) var(--ease-out) both;
}
@keyframes menu-in {
  from { opacity: 0; transform: translateY(-4px) scale(.98); }
  to { opacity: 1; transform: none; }
}
/* A menu grows from the edge it is anchored to, which is the only thing the
   animation is for. Class selector, so it beats the shorthand on .menu-panel. */
.menu-panel.is-above { animation-name: menu-in-up; }
@keyframes menu-in-up {
  from { opacity: 0; transform: translateY(4px) scale(.98); }
  to { opacity: 1; transform: none; }
}
/* A menu that arrives with an animation and then vanishes between frames reads
   as a glitch. util.js adds the class, waits for animationend and removes the
   node; pointer-events comes off so the dying panel cannot eat the next tap. */
.menu-panel.is-closing {
  pointer-events: none;
  animation: menu-out var(--dur-1) var(--ease) both;
}
@keyframes menu-out { to { opacity: 0; transform: translateY(-4px) scale(.98); } }
.menu-trigger.has-text { gap: 6px; padding-left: 11px; padding-right: 9px; }
.menu-trigger .caret { font-size: 9px; opacity: .7; }
.menu-trigger-text { overflow: hidden; text-overflow: ellipsis; max-width: 15ch; }

.menu-item {
  display: grid;
  grid-template-columns: 14px minmax(0, 1fr) auto;
  gap: 8px; align-items: center;
  justify-content: flex-start; text-align: left;
  width: 100%; min-height: var(--ctl-h);
  padding: 7px 11px;
  /* Concentric: the panel's radius minus its padding and border. */
  border-radius: calc(var(--r) - var(--sp-1) - 1px);
  font-weight: var(--w-regular); color: var(--text-dim); white-space: nowrap;
}
@media (hover: hover) {
  .menu-item:hover { background: var(--panel-3); color: var(--text); }
}
/* A fill rather than a move: a 1px shift inside a fixed popover reads as a
   glitch, but the item still has to acknowledge the press. */
.menu-item:active { background: var(--accent-soft); }
.menu-item.danger { color: var(--red-text); }
/* Gated like the ordinary row two lines up. Ungated it was worse than a plain
   latch: on touch a Delete row came away wearing a red fill that no other row
   in the menu could get. */
@media (hover: hover) {
  .menu-item.danger:hover { background: var(--red-wash); color: var(--red-text); }
}
.menu-item.is-checked { color: var(--text); }
.menu-check { color: var(--accent-hi); font-size: var(--fs-caps); text-align: center; }
.menu-label { overflow: hidden; text-overflow: ellipsis; }
.menu-hint { color: var(--faint); font-size: var(--fs-caption); font-weight: var(--w-regular); }

.menu-sep { height: 1px; margin: 5px 3px; background: var(--border); }
/* The other surviving caps treatment. */
.menu-heading {
  padding: 7px 11px 4px; color: var(--faint);
  font-size: var(--fs-caps); font-weight: var(--w-medium);
  text-transform: uppercase; letter-spacing: var(--tr-caps);
}

.row-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; flex-wrap: nowrap; }

/* --------------------------------- logs --------------------------------- */

.log-list {
  display: flex; flex-direction: column; gap: 8px;
  max-height: 460px; overflow-y: auto; padding-right: 4px;
  overscroll-behavior: contain;
}
/*
 * A log, not a stack of cards.
 *
 * Twenty-one entries meant twenty-one borders, twenty-one radii, twenty-one
 * fills and twenty-one Copy buttons — the longest thing in the panel and the
 * most chrome per line in the product. A hairline between entries says
 * "separate" just as well and lets the eye run down the messages, which is
 * what a log is read for. The status edge survives on the entries that have
 * one, because that is the only mark that distinguishes them.
 */
.log-entry {
  border: 0; border-radius: 0; background: none;
  padding: 10px 0 10px 11px;
  box-shadow: inset 2px 0 0 var(--border-strong);
}
.log-entry + .log-entry { border-top: 1px solid var(--border); }
.log-entry.is-error { box-shadow: inset 2px 0 0 var(--red); }
/* The per-entry Copy is a hover affordance rather than a permanent control:
   twenty-one of them down the panel was a column of buttons beside a column of
   text. Always present for keyboard and touch, which have no hover to reveal
   it with. */
@media (hover: hover) {
  .log-entry .log-copy { opacity: 0; transition: opacity var(--dur-1) var(--ease); }
  .log-entry:hover .log-copy,
  .log-entry .log-copy:focus-visible { opacity: 1; }
}
.log-entry-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
/* Prose inside a table keeps a prose ratio. */
.log-msg { flex: 1; min-width: 160px; font-size: var(--fs-dense); line-height: var(--lh-body); word-break: break-word; }

.log-details { margin-top: 10px; }
.log-details > summary {
  cursor: pointer; color: var(--muted); font-size: var(--fs-dense);
  padding: 4px 0; user-select: none; list-style: none;
  min-height: 26px; display: flex; align-items: center; gap: 4px;
}
.log-details > summary::-webkit-details-marker { display: none; }
.log-details > summary::before { content: '▸'; transition: transform var(--dur-1) var(--ease); }
.log-details[open] > summary::before { transform: rotate(90deg); }
@media (hover: hover) {
  .log-details > summary:hover { color: var(--text); }
}
/* The accent rather than --text, so a press on a disclosure row reads as an
   action and not as the same emphasis hovering gave. */
.log-details > summary:active { color: var(--accent-text-hi); }

.log-block { margin-top: 10px; }
.log-block-head {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
  font-size: var(--fs-caption); font-weight: var(--w-medium);
  color: var(--muted); margin-bottom: 6px;
}

/* -------------------------------- toasts -------------------------------- */

#toasts, #toasts-alert {
  position: fixed; right: var(--gutter); bottom: 18px; z-index: 100;
  display: flex; flex-direction: column; gap: 9px;
  max-width: min(400px, calc(100vw - var(--gutter) * 2));
  pointer-events: none;
}
.toast {
  display: flex; align-items: flex-start; gap: 10px;
  padding: 11px 12px 11px 14px; border-radius: var(--r);
  font-size: var(--fs-dense);
  background: var(--panel-2); border: 1px solid var(--border-strong);
  /* --sh-3: a toast confirming a destructive bulk action was 1.02:1 against a
     light page behind a 1.59:1 hairline, which is a layer you can miss. */
  box-shadow: var(--sh-3);
  pointer-events: auto;
  /* `backwards`, not `both`. A forwards-filling animation locks the final
     opacity and transform at animation specificity, which outranks the
     transition .toast.out uses to leave — so the exit never rendered and the
     toast simply disappeared. `backwards` keeps the pre-start frame, which is
     all the entrance actually needed. */
  animation: toast-in var(--dur-3) var(--ease-out) backwards;
}
@keyframes toast-in {
  from { opacity: 0; transform: translateY(10px) scale(.97); }
  to { opacity: 1; transform: none; }
}
.toast.out { opacity: 0; transform: translateY(6px); transition: opacity var(--dur-2) var(--ease), transform var(--dur-2) var(--ease); }
/* The stripe as an inset shadow, so it follows the corner arc instead of
   mitring a coloured wedge into it. */
/* All three restate the shadow, and util.js always emits one of them — so
   editing .toast's declaration alone would have changed nothing that ships. */
.toast.ok { box-shadow: var(--sh-3), inset 3px 0 0 var(--green); }
.toast.err { box-shadow: var(--sh-3), inset 3px 0 0 var(--red); }
.toast.info { box-shadow: var(--sh-3), inset 3px 0 0 var(--accent); }
/* Green versus red is the worst pair for a peripheral read, so the outcome is
   also stated in a glyph. ✓ / ✕ / i, never ⚠ or ℹ — those render
   emoji-presentation in Chrome on Windows, ignore `color`, and will not share
   a baseline with the text beside them. */
.toast-ico { flex: none; font-size: var(--fs-dense); line-height: 1.5; }
.toast.ok .toast-ico { color: var(--green-text); }
.toast.err .toast-ico { color: var(--red-text); }
.toast.info .toast-ico { color: var(--accent-text); }
.toast-msg { flex: 1; min-width: 0; }
.toast-x { color: var(--faint); font-size: 15px; }

/* ------------------------------- banners -------------------------------- */

.banner {
  padding: 11px 14px; border-radius: var(--r); font-size: var(--fs-dense);
  background: var(--amber-wash);
  border: 1px solid var(--amber-line);
  color: var(--amber-text);
}
.banner.info { background: var(--accent-soft); border-color: var(--accent-line); color: var(--accent-text); }
/* Amber means caution and in progress. A caught exception and a rejected lead
   are neither. */
.banner.err { background: var(--red-wash); border-color: var(--red-line); color: var(--red-text); }

/*
 * Out of flow, so the surface never jolts under the pointer at the start and
 * end of a selection run. margin-bottom: 0 is load-bearing — .banner sets 14px
 * and it would otherwise sit 14px high.
 */
/* `translate`, not `transform`. The entrance animation ends on
   `transform: none`, which used to wipe the centring out from under the bar on
   its last frame; the independent property composes with the animation instead
   of competing with it. */
.bulk-bar {
  position: fixed; left: 50%; translate: -50%; bottom: 20px; z-index: 45;
  display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
  width: auto; margin-bottom: 0;
  padding: 10px 12px 10px var(--sp-4);
  border-radius: var(--r-pill);
  background: var(--panel-3);
  -webkit-backdrop-filter: var(--float-blur);
  backdrop-filter: var(--float-blur);
  border: 1px solid var(--border-strong);
  box-shadow: var(--sh-3);
  animation: toast-in var(--dur-3) var(--ease-out) both;
}
/*
 * A material degrades to an opaque fill, never to naive alpha: unblurred
 * translucency over moving text is the worst-looking failure a faux-native bar
 * has. The opaque --panel-3 above is the base and the translucency is the
 * upgrade — the same shape thead already uses. Top level rather than inside the
 * phone block, because the base rule is not media-scoped and a guard written in
 * there would take the blur off the desktop.
 */
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .bulk-bar { background: color-mix(in srgb, var(--panel-3) 92%, transparent); }
}
/* The bar leaves the way it arrived. Without it a selection cleared by the
   keyboard made the whole thing vanish between frames. */
.bulk-bar.is-leaving { animation: bulk-out var(--dur-2) var(--ease) both; }
@keyframes bulk-out { to { opacity: 0; transform: translateY(10px) scale(.97); } }
.bulk-count { font-weight: var(--w-medium); }
@media (max-width: 860px) {
  .bulk-bar {
    left: max(12px, var(--safe-l)); right: max(12px, var(--safe-r)); translate: none;
    /* calc, not max: the home indicator's inset is 34px and the constant is 12,
       so max() returned the constant every time and cleared nothing. The bar it
       now rests on is the third term; it is 0px wherever there is no tab bar. */
    bottom: calc(12px + var(--safe-b) + var(--tabbar-h));
    border-radius: var(--r-lg);
  }
  .bulk-bar > button { flex: 1 1 auto; min-height: var(--tap); }
  .bulk-count { flex: 1 1 100%; }
}
.banner strong { color: inherit; }

/* --------------------------------- tabs --------------------------------- */

.tabs {
  display: flex; gap: 2px; border-bottom: 1px solid var(--border);
  flex-wrap: wrap;
}
.tab {
  padding: 9px 14px; min-height: var(--tap);
  align-items: center;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  color: var(--muted); font-weight: var(--w-medium);
  border-radius: var(--r-sm) var(--r-sm) 0 0;
  transition: color var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), background var(--dur-1) var(--ease);
}
@media (hover: hover) {
  .tab:hover { color: var(--text); background: var(--panel-2); border-bottom-color: var(--border-strong); }
}
.tab.active { color: var(--text); border-bottom-color: var(--accent-hi); background: none; }

/* ------------------------------ skeletons ------------------------------- */

.loading { padding: var(--sp-6); text-align: center; color: var(--muted); }
/* The section head Integrations builds by hand, twice, at two values. It had
   spacing and no level: set at body size in body colour, it announced a new
   part of the page with nothing but a gap. --fs-heading is the step above
   body, which is what a heading between cards should be. */
.section-head {
  margin: var(--sp-5) 0 var(--sp-3);
  font-size: var(--fs-heading); line-height: var(--lh-heading);
  font-weight: var(--w-medium); letter-spacing: var(--tr-title);
  color: var(--text);
}

.skeleton {
  border-radius: var(--r-sm);
  background: linear-gradient(100deg, var(--panel-2) 22%, var(--panel-3) 42%, var(--panel-2) 62%);
  background-size: 260% 100%;
  animation: shimmer 1.5s linear infinite;
}
@keyframes shimmer { from { background-position: 160% 0; } to { background-position: -60% 0; } }

.sk-stat {
  height: 92px; border-radius: var(--r-lg);
  border: 1px solid var(--border); background: var(--panel);
  padding: var(--sp-3) var(--sp-4);
}
.sk-card { border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--panel); padding: var(--sp-4); }
/* The skeleton takes the edges of the thing it stands in for, rather than
   putting a bordered box inside a bordered box and doubling the border for the
   length of the request. */
.sk-table { border: 0; border-radius: inherit; overflow: hidden; }
.sk-table > * + * { border-top: 1px solid var(--border); }

/* --------------------------- view transition ---------------------------- */

/* Applied when the route changes, not after its data arrives. A whole-page
   transform layer buys nothing over an opacity fade. */
.view-enter { animation: view-in var(--dur-2) var(--ease-out) both; }
@keyframes view-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* ========================================================================= */
/*                              RESPONSIVE                                   */
/* ========================================================================= */

/* Wide desktop: give the shell more room to breathe. The gutter is the one
   metric that is genuinely responsive, and the fixed layers read it too. */
@media (min-width: 1600px) {
  :root { --sidebar-w: 250px; --gutter: 42px; }
  .main { padding-top: 40px; padding-bottom: 80px; }
}

/* Laptop / small desktop. */
@media (max-width: 1280px) {
  .grid.cols-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* Tablet: the 3-column dashboard rows are too cramped before this point, and
   cols-2 has had no rung at all between 1600 and 861 — which is exactly the
   width an iPad landscape and a half-screen browser use. */
/*
 * The height bound keeps this off a short landscape phone, which takes the
 * phone shell below rather than a tablet's sidebar and gutter — but on its own
 * it also dropped a short *desktop* window through every rung: 1000x480 with a
 * mouse cleared neither this query nor the phone one below, which asks for
 * `pointer: coarse`, and got the full 232px sidebar and three-column rows
 * inside a half-screen split. The second branch is that window. Written as a
 * comma rather than `and (not (...))` so it does not need Media Queries 4
 * boolean logic, which iPadOS only learned in 16.4.
 */
@media (max-width: 1100px) and (min-height: 501px),
       (max-width: 1100px) and (pointer: fine) {
  :root { --sidebar-w: 200px; --gutter: 20px; }
  .grid.cols-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .grid.cols-2 { grid-template-columns: minmax(0, 1fr); }
  .main { padding-top: 30px; padding-bottom: 64px; }
}

/* ------------------------- phone / small tablet -------------------------- */

/*
 * Width alone did not describe the device. A 15/16 Pro Max in landscape is
 * 932x430, so it cleared 860 and got the desktop shell: a permanent 200px
 * sidebar eating a fifth of the width of a 430pt-tall screen, plus centred
 * desktop dialogs. iOS treats a landscape phone as a compact-height
 * environment, not as a small iPad — the drawer stays a drawer and sheets stay
 * sheets. `pointer: coarse` is what separates a phone on its side from a
 * short browser window, and the 1100px bound is what keeps a touchscreen
 * Windows laptop with a shortened window out of it.
 */
@media (max-width: 860px), (max-height: 500px) and (pointer: coarse) and (max-width: 1100px) {
  /*
   * The whole ladder, not the bottom two rungs of it.
   *
   * The first mobile pass raised --fs-micro and --fs-caps here and stopped,
   * which left the argument it made — a phone is read further from the eye and
   * in motion, so the smallest screen getting the smallest type is backwards —
   * applied to a lead's id and its test flag and to nothing else. Everything
   * downstream of --fs-body stayed at its desktop size: banners and toasts at
   * 13px, pills at 11.5, form hints and errors at 13, card prose at 14. Two
   * places had the increase written out as a literal 15px instead (card cells
   * and the page sub-heading), which is the same fix applied twice by hand at a
   * value the ramp did not have.
   *
   * The ladder here is iOS's: 17 / 15 / 13 / 12.5 / 12 / 11.5 at a 17px root,
   * six distinct rungs with no two the same — the rule that retired --fs-section.
   */
  :root {
    --tap: 44px; --gutter: 15px;
    --fs-title: 1.3125rem;      /* 21px */
    --fs-body: 1rem;            /* 16px */
    --fs-dense: 0.9375rem;      /* 15px */
    --fs-label: 0.8125rem;      /* 13px */
    --fs-caption: 0.78125rem;   /* 12.5px */
    --fs-caps: 0.75rem;         /* 12px */
    --fs-micro: 0.71875rem;     /* 11.5px */
    --tr-small: .006em;
  }

  /*
   * Dynamic Type, which is what makes the rem ramp above more than a unit change.
   *
   * `font: -apple-system-body` resolves to the size the reader chose in Settings ›
   * Display & Brightness › Text Size, so the root — and every rem on top of it —
   * follows it. At the default setting it is 17px, which is why the ladder above
   * is written to land on iOS's own steps: --fs-body becomes exactly 17.
   *
   * The shorthand also sets family, weight and leading, so the two that matter
   * are put back immediately. Only the size is wanted.
   *
   * Scoped to the phone shell deliberately. This is where the setting is used and
   * where the layout survives it: one column, grids already collapsed, tables
   * already scrolling or stacked. Left global it would also move every metric on
   * a 27in display for a preference set on a phone.
   *
   * @supports rather than a UA test — Safari is the engine that ships the keyword,
   * and anything that does not simply keeps the 16px root and the ladder above.
   */
  @supports (font: -apple-system-body) {
    :root {
      font: -apple-system-body;
      font-family: var(--sans);
      line-height: var(--lh-body);
    }
  }

  /*
   * `display: block`, not a grid.
   *
   * A grid item cannot be `position: sticky` against the page, so the app bar
   * declared `sticky` and then scrolled away with everything else. The other
   * children are already out of flow — .sidebar and .nav-scrim are fixed, and
   * .corner-badges is display: none — so .main is the only one left and normal
   * flow gives the identical layout with a bar that actually sticks.
   */
  #app { display: block; }

  /*
   * A flick that runs past the end of the document hands the rest of the
   * gesture to the browser, which arms pull-to-refresh — including behind an
   * open sheet, where the page is not supposed to be reachable at all. The
   * containment costs the reload gesture; every reload this app performs is
   * already a control the operator presses (the update dialog, the arrivals
   * pill), so nothing becomes unreachable.
   *
   * Scoped to this block rather than declared on html globally. Both halves of
   * the argument are this breakpoint's — pull-to-refresh is a touch gesture and
   * the sheet it fires behind only exists down here — while at desktop widths
   * the same declaration does one thing only: cancel the rubber-band at the
   * ends of the document for a trackpad, which is browser behaviour this file
   * has no reason to take away.
   */
  html { overscroll-behavior-y: contain; }

  /* A real app bar, not a wrapped pile of links. */
  .topbar {
    grid-area: topbar;
    display: flex; align-items: center; gap: 10px;
    position: sticky; top: 0; z-index: 45;
    /* The bar grows by the status-area inset rather than sliding under it: it
       is the one surface in the app that sits against the top edge, and the
       premise stated with the tokens is that `viewport-fit=cover` makes that
       this file's job. Zero in Safari, where the browser chrome already covers
       the notch; it earns its keep installed to the home screen. */
    height: calc(var(--topbar-h) + var(--safe-t));
    /* In left-landscape the hamburger would otherwise sit under the housing. */
    padding: var(--safe-t) max(12px, var(--safe-r)) 0 max(6px, var(--safe-l));
    background: color-mix(in srgb, var(--bg) 82%, var(--panel));
    -webkit-backdrop-filter: var(--float-blur);
    backdrop-filter: var(--float-blur);
    /*
     * The hairline arrives when something is behind the bar, and that arrival is
     * the cue: a permanent separator makes a bar read as drawn onto the page,
     * where iOS's scroll-edge behaviour makes it read as floating over it. The
     * colour is what transitions, not the width — animating a border-width would
     * move every pixel of content under it by one.
     *
     * Declared as a real border in the resting state so a browser that never gets
     * the class (a broken script, an old engine) keeps the line rather than losing
     * it. `at-top` is written by app.js's watchScrollEdges.
     */
    border-bottom: 1px solid var(--border);
    transition: border-color var(--dur-2) var(--ease);
  }
  body.at-top .topbar { border-bottom-color: transparent; }
  /*
   * The opaque fill above is the base and the translucency is the upgrade —
   * iOS 17 and earlier only ship the prefixed filter, so this bar was declaring
   * a material it did not get while thead, four hundred lines up, guarded the
   * same thing correctly. Nested here rather than at top level because .topbar
   * is display: none above this breakpoint and the rule it overrides is inside
   * this query.
   */
  @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .topbar { background: var(--float-bg); }
  }
  /* The app bar is one tap-height row, so the lines tighten rather than wrap.
     flex, not a spacer element beside it: with the controls on the right of a
     390px bar, everything left over is what the text has to work with. */
  .topbar .brand { flex: 1 1 auto; padding: 0; gap: 8px; min-width: 0; }
  /*
   * The page's title leads, at the heading step. This is the line that changes
   * from screen to screen and it is the one the bar exists to carry.
   *
   * Tracking follows size and nothing else, and --fs-heading is 17px — the
   * boundary the rule names — so it takes the title track rather than nothing.
   */
  .topbar .topbar-title {
    display: block;
    font-size: var(--fs-heading); line-height: 1.15;
    font-weight: var(--w-medium); letter-spacing: var(--tr-title);
    color: var(--text);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /*
   * The network name, demoted to the subtitle it now is. It is still here and
   * still first among the two quiet lines — it is what says which installation
   * is about to be changed — but it no longer competes with the page it is on.
   * Both quiet lines are one step: two subtitles at two sizes under one title is
   * a third level of hierarchy for a line of chrome.
   */
  .topbar .brand-name {
    font-size: var(--fs-caps); font-weight: var(--w-medium);
    letter-spacing: 0; line-height: 1.2; color: var(--muted);
  }
  /* The product name goes. It is identical on every installation and on every
     screen, so under a title and a network name it is the third line in a 56px
     bar that identifies nothing. It is still in the sidebar, on the login page
     and in the browser tab. */
  .topbar .brand-sub { display: none; }
  /* With no title yet — the first paint, before the first route resolves — the
     network name is the only line and takes the leading role rather than sitting
     at caption size on its own. */
  .topbar .topbar-title:empty + .brand-name {
    font-size: var(--fs-heading); color: var(--text); letter-spacing: var(--tr-title);
  }
  .topbar-spacer { display: none; }
  .badge-slot { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
  /* Both badges have moved into the app bar; the fixed row would only be an
     invisible box in the corner. */
  .corner-badges { display: none; }

  /* Sidebar becomes an off-canvas drawer. */
  .sidebar {
    position: fixed; inset: 0 auto 0 0; z-index: 60;
    width: min(280px, 84vw); height: 100dvh;
    grid-area: auto;
    /*
     * Which way this drawer closes, as a number the script can read.
     *
     * -1 is "attached to the left, so closing travels left"; the tab-bar query
     * further down re-attaches it to the right and flips this to 1. app.js reads it
     * off the computed style at the start of a drag rather than repeating either
     * breakpoint, so the gesture cannot end up pushing the drawer into the screen on
     * one of the two shells. A custom property is the only channel here that follows
     * the cascade — a class would need the same media query written twice.
     */
    --drawer-side: -1;
    /* Longhands, never the shorthand: the drawer is the one surface guaranteed
       to be against the physical left edge, so in left-landscape its nav labels
       sit under the housing — and a shorthand here would have to restate all
       four sides to inset one of them. */
    padding-top: calc(18px + var(--safe-t));
    padding-bottom: calc(18px + var(--safe-b));
    padding-left: max(14px, var(--safe-l));
    padding-right: 14px;
    border-right: 1px solid var(--border-strong);
    box-shadow: var(--sh-4);
    transform: translateX(-102%);
    visibility: hidden;
    transition: transform var(--dur-3) var(--ease-out), visibility var(--dur-3);
  }
  #app.nav-open .sidebar { transform: none; visibility: visible; }
  /* While a finger is on it the drawer keeps up rather than easing behind, exactly
     as the sheet does — and the spring is what lands it when the finger lets go
     short of the threshold. app.js swaps the two classes. */
  .sidebar.is-dragging { transition: none; }
  .sidebar.is-settling { transition: transform var(--dur-3) var(--ease-spring); }
  .nav-item { min-height: 46px; font-size: 15px; }
  .nav-item.active::before { left: -7px; }

  .nav-scrim {
    display: block;
    position: fixed; inset: 0; z-index: 55;
    background: var(--overlay);
    -webkit-backdrop-filter: var(--scrim-blur);
    backdrop-filter: var(--scrim-blur);
    opacity: 0; visibility: hidden;
    /* So a drag on the scrim dismisses the drawer instead of rubber-banding the
       document behind it. */
    touch-action: none;
    /* The same clock as the panel it dims. At --dur-2 the scrim finished first
       and the drawer slid the last third of its travel over a bare page. */
    transition: opacity var(--dur-3) var(--ease-out), visibility var(--dur-3);
  }
  #app.nav-open .nav-scrim { opacity: 1; visibility: visible; }

  /* The gutter tokens, so landscape content clears the notch. The 72px already
     cleared the 34px home indicator on its own; --tabbar-h is what the tab bar
     adds on top of it, and without it the last row of every list in the app
     comes to rest underneath the bar. */
  .main {
    padding-top: 20px; padding-bottom: calc(72px + var(--tabbar-h));
    padding-left: var(--gutter-l); padding-right: var(--gutter-r);
  }

  /* The badge stops floating and rides in the app bar instead. */
  .version-badge {
    position: static; margin: 0;
    font-size: var(--fs-caps);
    min-height: var(--tap); padding-inline: 14px;
  }
  .version-badge .hash { display: none; }
  .update-badge { min-height: var(--tap); padding-inline: 14px; }
  .theme-toggle { width: var(--tap); height: var(--tap); }

  /* gap: 0 — with the heading and the sub-line both out of flow below, the only
     thing left in here is the actions row, and a flex gap is still occupied when
     the item beside it has collapsed. */
  .page-head { align-items: flex-start; gap: 0; }
  /*
   * The page's own heading goes: the app bar carries it now, and it carries it
   * permanently rather than until the first flick. Two copies of one string, one
   * of which scrolls away, is not a hierarchy.
   *
   * `visibility` would keep its box; this gives the height back. The heading stays
   * in the DOM and stays the document's h1 — it is what a screen reader's heading
   * outline is walked by, and the bar's own title is a span precisely so that
   * outline does not gain a second level.
   */
  .page-head h1 { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
                  overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  /*
   * And the sentence under it goes with it.
   *
   * This is one sentence explaining a page the operator opens fifty times a day,
   * and on /leads it was the single most expensive thing above the data: two lines
   * of 15px prose inside the ~330px of chrome a 844pt screen was spending before
   * the first lead. The first pass made it *larger* here, which was the right
   * instinct — it was the hardest paragraph in the app to read — applied to the
   * wrong element. The right answer on a phone is that it is not shown.
   *
   * Not `display: none`: several views put live state in it (the dashboard's sync
   * interval and last run), so it stays available to a screen reader rather than
   * being deleted for everyone.
   */
  .page-head .sub { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
                    overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  .head-actions { width: 100%; }
  .head-actions > * { flex: 1 1 auto; min-height: var(--tap); justify-content: center; }

  .grid.cols-2, .grid.cols-3, .grid.cols-4 { grid-template-columns: minmax(0, 1fr); }
  /*
   * Stat rows are the exception: four numbers in one column is four screens of
   * scrolling to read a dashboard, and the tiles are short enough to pair.
   * `.tiles` is on the skeleton as well as the live row, or the page visibly
   * reflows from one column to two the moment the data lands.
   */
  .grid.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2); }
  .grid.tiles > .stat, .grid.tiles > .sk-stat { padding: 11px 12px; }
  .grid.tiles > .stat .value { font-size: 20px; letter-spacing: var(--tr-title); }

  /* No border-radius here: .stat, .sk-card and .sk-stat were never included, so
     a skeleton visibly changed its corners at the moment its data landed. */
  .card { padding: 15px; }
  .stat { padding: 13px 15px; }
  /* 24px is inside the 17-24px band, so it takes the title step — the display
     step also fights the tabular figures it is set in. */
  .stat .value { font-size: 24px; letter-spacing: var(--tr-title); }

  /*
   * 16px is the threshold below which iOS Safari zooms the viewport on focus, and
   * `max()` is what keeps that true now the ramp is relative: --fs-body is 1rem,
   * which is 16px at the default root and 17px under Dynamic Type — but a reader
   * who has asked for *smaller* text would otherwise drop every field in the app
   * under the threshold and get a zooming viewport as a reward for the setting.
   * The floor is a device constant; the size above it follows the reader.
   */
  input, select, textarea { font-size: max(16px, var(--fs-body)); min-height: var(--tap); }
  /* --tap, not 38px. The floor exists so a control can be hit by a thumb in
     motion, and 38 is not that floor — it was the old desktop --tap value that
     never got raised with the token. */
  .btn, button:where(:not(.bare)) { min-height: var(--tap); }
  .btn-sm { min-height: var(--tap); padding-inline: 14px; }
  /*
   * The tablet band is the exception. Between 561 and 860px the leads table is
   * still a real table, and raising every in-row button to 44px inflates every
   * row by ten pixels for the sake of a control that is already inside a row
   * the whole width of which is the tap target. Below 560px it is cards, and
   * there the floor comes back — see the 560px block.
   *
   * The fraud chip in the meta row is the other control this covers, and it is
   * deliberately left under the floor wherever the table is still a table — the
   * 561-860 band and a 932x430 phone on its side, which this query now also
   * reaches. It is a 17px label inside a two-line cell; a 44px chip takes the
   * row from about 59px to about 85px, which is precisely the inflation the
   * exemption is written against, for the one thing in the row whose immediate
   * neighbour is the row itself. Card mode takes the floor back, because there
   * is no row rhythm left to protect there.
   */
  .leads-table td .btn-sm, .leads-table th .btn-sm { min-height: var(--ctl-h); padding-inline: 10px; }
  /*
   * The row checkbox is not covered by any of that, and is the one control in
   * the row that genuinely is its own target: tapping it selects the lead
   * rather than opening it, so the row underneath cannot stand in for it. The
   * floor costs the table nothing — the lead cell already takes the row to
   * about 59px, and the 26px label simply was not claiming the height it had.
   */
  .cell-tick { min-height: var(--tap); }
  /* Both set an explicit height at class specificity, so min-height cannot
     reach them — dismiss a toast and close a sheet are the two most-tapped
     controls in the app. */
  .icon-btn, .icon-btn.sm { width: var(--tap); height: var(--tap); }
  /* The field's own name, at the same step as the hint and the error below it.
     At --fs-label it was the smallest text in the group — the name of the thing
     set under the sentence explaining it, and under the message saying it is
     wrong — which is the inversion the rest of this block exists to undo. Weight
     and colour are what separate the three, not size. */
  label.f > span { font-size: var(--fs-dense); }
  /* A hint under a field and an error under a field are both prose, and both
     were set at the caption step where the field itself is at 16px. */
  label.f > small { font-size: var(--fs-dense); line-height: 1.45; }
  .f-err { font-size: var(--fs-dense); }

  .filter-toggle { width: 100%; justify-content: space-between; min-height: var(--tap); }
  /* Narrower tracks on a phone — set on the token, so the search row and the
     filter panel stay on the same columns rather than drifting apart here. */
  .filter-bar { --filter-cols: repeat(auto-fill, minmax(150px, 1fr)); }
  /* The phone grid, which is a boxed panel rather than the desktop's bare row. */
  .filter-bar.open .filters {
    gap: 10px;
    padding: 14px;
    border: 1px solid var(--border); border-radius: var(--r-lg);
    background: var(--panel);
  }
  .filters label.f { width: auto; }
  .filters > button { grid-column: 1 / -1; min-height: var(--tap); justify-self: stretch; }
  /*
   * Search takes the row on a phone; the toggle takes the next one.
   *
   * Written against `.filter-row`, and that is the entire fix. The desktop rule
   * that clamps search to `1 / -2` is (0,2,0); the phone rule that stood here
   * was a bare `.filter-search` at (0,1,0), and media queries contribute no
   * specificity — so the intent had been in the file, and inert, all along. At
   * 390px the row resolves to two 177px tracks, which left the most-used
   * control in the product sharing half a screen with a disclosure button.
   */
  .filter-row .filter-search { grid-column: 1 / -1; }
  .filter-row .filter-toggle { grid-column: 1 / -1; }
  .filter-bar .filter-toggle { justify-self: stretch; }

  /*
   * Dropping a filter is an 18px target on a phone, and dropping the wrong one
   * costs open, find, Any, close. It grows to 28px painted and borrows the
   * remaining 8px of reach invisibly, through a ::after rather than through
   * padding — the chip is a .pill and the pill's geometry is shared with every
   * other badge, so the floor has to be met without the chip moving a pixel.
   *
   * The row-gap is what pays for that, and it is load-bearing rather than
   * breathing room: a 44px hit box centred in a 34px chip overhangs 5px top and
   * bottom, so at .filter-chips' own 6px gap two stacked ×'s would overlap and
   * the top one would take both taps. 18px clears both overhangs outright, and
   * the 8px grid gap above the chip row clears the filter toggle by the same
   * arithmetic.
   */
  /* --fs-dense, up from .pill's --fs-caption. A chip's label is the only thing on
     screen naming which filters are narrowing the list — state with a consequence,
     and it was at the second-smallest step in the file. The same argument the
     first pass made for the test flag and the fraud score, at a place it did not
     reach. The × keeps its own geometry below; only the text moves. */
  .filter-chip { padding-right: 4px; font-size: var(--fs-dense); }
  .filter-chip .chip-x { position: relative; width: 28px; height: 28px; }
  .filter-chip .chip-x::after { content: ""; position: absolute; inset: -8px; }
  .filter-chips { gap: var(--sp-2); row-gap: 18px; }
  .filter-chip .chip-x:active { background: var(--panel-3); }

  /* Modals become bottom sheets. */
  /* overflow: hidden, because for one frame a sheet is parked a full screen
     below the fold and would otherwise make the backdrop itself scrollable. */
  .modal-backdrop { padding: 0; align-items: flex-end; overflow: hidden; }
  /*
   * J2 — "the page behind a sheet should step back" — was implemented here and
   * reverted. The finding is real and the fix is not this one; it is recorded so the
   * next person does not spend the afternoon rediscovering why.
   *
   * iOS scales the presenting view down behind a sheet, and `transform: scale(.98)`
   * on `#app` draws exactly that. It also breaks two things, because a transform
   * makes an element the containing block for every `position: fixed` descendant,
   * and `#app` holds four of them:
   *
   *   - `.tabbar` and `.new-leads-bar` stop being anchored to the viewport and
   *     anchor to `#app` instead — which on a long page is the *document*, so the
   *     tab bar travels to the bottom of the leads list.
   *   - the `overflow: hidden` needed to make the corner radius actually clip turns
   *     `#app` into a scroll container, and `.topbar`'s `position: sticky` then
   *     sticks to the top of that container rather than to the screen. Open a sheet
   *     three screens down and the app bar is gone.
   *
   * Both are invisible at the full detent, where the sheet covers everything, and
   * both are plainly visible at the medium one — which is the detent this same pass
   * added. Doing it properly needs the fixed layers moved out of the transformed
   * subtree, i.e. a change to index.html's structure, which is more than a low
   * polish finding is worth. Left undone deliberately.
   */
  .modal, .modal.wide {
    max-width: none;
    /* The keyboard's height, so a sheet whose field is focused shortens rather
       than being pushed half off the top of the screen. Both read --kb-inset,
       which is 0px until util.js's visualViewport watcher says otherwise, and
       neither is transitioned — the inset arrives in one step. */
    margin-bottom: var(--kb-inset);
    max-height: calc(94dvh - var(--kb-inset));
    border-radius: var(--r-lg) var(--r-lg) 0 0;
    border-bottom: 0;
    display: flex; flex-direction: column;
    animation: sheet-in var(--dur-3) var(--ease-out) both;
  }
  /*
   * The medium detent.
   *
   * A sheet sized to its content is already short when its content is short, which
   * is most of the win and was already right. What was missing is the other case:
   * a *long* sheet was always 94% of the screen, and the grabber is the iOS promise
   * of more than one height. The lead record is the sheet that wants it — triage is
   * read-a-row, decide, move on, and the desktop already docks that record beside
   * the list rather than over it (openInspector, above 1100px) for exactly that
   * reason. A medium detent is the phone's version of keeping the list in view.
   *
   * A caller asks for this; nothing takes it by default. Dragging up promotes the
   * sheet to full and dragging down returns it — util.js swaps the class.
   */
  .modal.detent-medium { max-height: calc(min(58dvh, 620px) - var(--kb-inset)); }
  /* Height, not max-height, once expanded: without it a sheet whose content is
     shorter than the full detent would spring open to nothing visible. */
  .modal.detent-full { max-height: calc(94dvh - var(--kb-inset)); }
  /* The travel between the two rungs. Suppressed while a finger is on it by
     .is-dragging's own `transition: none !important`. */
  .modal.detent-medium, .modal.detent-full {
    transition: max-height var(--dur-3) var(--ease-out);
  }
  /* From off the bottom edge, not from 28px up with a fade. A sheet arrives by
     travelling; the opacity ramp was what made this read as a dialog that
     happened to be aligned low. */
  @keyframes sheet-in {
    from { transform: translateY(100%); }
    to { transform: none; }
  }
  .modal-backdrop.is-closing .modal { animation: sheet-out var(--dur-2) var(--ease) both; }
  @keyframes sheet-out { to { transform: translateY(100%); } }
  /* The backdrop outlives the sheet by 60ms so the sheet is seen leaving rather
     than blinking out with the scrim on top of it. Phone only — the desktop
     dialog's matched 110/110 pair is correct for a fade in place. */
  .modal-backdrop.is-closing {
    animation: fade-out var(--dur-2) var(--ease) both;
    animation-delay: 60ms;
  }
  .modal-head {
    position: sticky; top: 0; z-index: 2;
    background: var(--panel); border-radius: var(--r-lg) var(--r-lg) 0 0;
    /* The drag surface. `none` rather than `pan-x`: a downward drag on the head
       is the dismiss gesture and must not be shared with the body's scroll. */
    touch-action: none;
  }
  /* A mark that says "sheet", painted from its own token rather than borrowing
     an unrelated border colour. It is now honest: util.js drags the sheet from
     the head and releases past 90px to close. Escape, the ×, the backdrop and
     the back gesture all still close it too. */
  .modal-head::before {
    content: ""; position: absolute; top: 6px; left: 50%;
    width: 36px; height: 4px; margin-left: -18px;
    border-radius: var(--r-pill); background: var(--grabber);
  }
  .modal-head { padding-top: 18px; }
  /* `overscroll-behavior: contain` for the same reason every other nested scroller
     in this file has it — a flick that runs off the end of a sheet's content should
     not hand the rest of the gesture to the page behind it — and because the
     drag-from-body gesture needs the sheet's own rubber-band out of the way to tell
     "pulling the sheet down" from "bouncing the content". */
  .modal-body {
    overflow-y: auto; -webkit-overflow-scrolling: touch; flex: 1; padding: var(--sp-4);
    overscroll-behavior: contain;
  }
  /* calc against the named token, the same as .bulk-bar and #toasts and for the
     same two reasons: max() returns the constant every time, since 14 is never
     greater than the home indicator's 34px, and env() reached for directly here
     would leave this the one bottom-anchored rule in the file that cannot be
     moved through --safe-b. No --tabbar-h term — a sheet covers the tab bar
     rather than resting on top of it. */
  .modal-foot { border-radius: 0; padding-bottom: calc(14px + var(--safe-b)); }
  .modal-foot > button, .modal-foot > .btn { flex: 1 1 auto; min-height: var(--tap); }
  /* Every footer button goes full width here, so a flexible gap between the two
     action groups would take a whole row of its own and read as a missing
     button. */
  .modal-foot > .foot-spacer { display: none; }
  /* A stepper in a footer is one control, not two buttons: it takes the row and
     splits it. (0,2,1) so it beats .btn-sm's own floor. */
  .modal-foot > .segmented { flex: 1 1 auto; }
  .modal-foot .segmented > button { flex: 1 1 auto; min-height: var(--tap); }

  .grid.cols-2 > *, .grid.cols-3 > * { min-width: 0; }
  /* The heading now carries its own weight, so it needs less air above it than
     it did when the gap was the only thing announcing a new section. */
  .section-head { margin-top: var(--sp-4); }

  #toasts, #toasts-alert {
    right: max(12px, var(--safe-r)); left: max(12px, var(--safe-l));
    /* calc, not max: 12 is never greater than the 34px home indicator inset.
       A toast is read and dismissed, so it sits above the tab bar rather than
       over it — the bar is where the next tap is going. */
    bottom: calc(12px + var(--safe-b) + var(--tabbar-h));
    max-width: none;
  }
  .toast { width: 100%; }

  /*
   * The bulk bar's own height, measured, rather than a constant that was right
   * for one line of buttons. leads.js writes --bulk-h from a ResizeObserver and
   * puts it back to 0px when the selection empties, so both of these collapse
   * to their normal value on their own — and both stack on the tab bar, which
   * the bulk bar is now floating above rather than sharing an edge with.
   */
  body.has-selection .main { padding-bottom: calc(var(--bulk-h) + 24px + var(--safe-b) + var(--tabbar-h)); }
  body.has-selection #toasts,
  body.has-selection #toasts-alert { bottom: calc(var(--bulk-h) + 24px + var(--safe-b) + var(--tabbar-h)); }

  /*
   * A sheet is anchored to the bottom of the screen and so were the toasts, so
   * the confirmation of what the operator just did in the sheet landed on the
   * sheet's own footer buttons. They move to the top for as long as one is
   * open. Keyed off the backdrop, never off `.no-scroll` — the nav drawer sets
   * that too, and the drawer does not cover the bottom of the screen.
   * The 66px right inset keeps the sheet's own close × tappable.
   */
  /*
   * The 66px right inset is gone, and the stack keeps both rails.
   *
   * It was there to keep the sheet's own close × tappable, which is real — but it
   * bought that by making the toast stack 66px narrower than every other layer in
   * the app and centred on nothing. The row above the sheet is the same width as the
   * sheet; what has to move is the toast's *top*, which now clears a 44px close
   * button and its inset rather than dodging sideways past it.
   */
  body:has(.modal-backdrop) #toasts,
  body:has(.modal-backdrop) #toasts-alert {
    bottom: auto; top: calc(max(12px, var(--safe-t)) + var(--tap) + var(--sp-2));
    flex-direction: column-reverse;
  }
  body:has(.modal-backdrop) .toast { animation-name: toast-in-top; }
  @keyframes toast-in-top {
    from { opacity: 0; transform: translateY(-10px) scale(.97); }
    to { opacity: 1; transform: none; }
  }

  .log-list { max-height: none; }
  td .truncate, td.truncate { max-width: 190px; }
  /* Outside a cell there is no column to protect, so a value in a card should
     use the width the card has rather than ellipsising at a desktop constant
     and leaving a third of the row empty. Child combinator: as a descendant
     selector this would also unwrap the truncated cells of a card-mode table. */
  .card > .truncate { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }

  /* Stop shrinking below the desktop, rather than jumping to 15px and widening
     the 16-column tablet table. A phone is read further from the eye and in
     motion; the smallest screen getting the smallest type is backwards. */
  table { font-size: var(--fs-dense); }
  th, td { padding: 11px 10px; }
  /* A nested vertical scroller fights momentum scrolling on touch. */
  .table-wrap.tall { max-height: none; overflow-y: visible; }
  .leads-surface { max-height: none; }

  /*
   * The lead sheet's field names: 11px, regular weight, --faint, in a fixed
   * 108px column — the weakest combination in the file, naming every value on
   * the record. A percentage column also lets a long label wrap instead of
   * forcing the value into a narrower gutter than the label beside it.
   */
  /* --fs-dense, not --fs-label: the values beside these are at --fs-dense, and a
     label a step under the value it names makes the reader work out which is
     which on every line of the record. The first pass raised these from 11px
     regular --faint and stopped one rung short — weight and colour are what
     separate a label from a value here, which is exactly why size does not have
     to as well. */
  .detail-grid th {
    width: 38%;
    font-size: var(--fs-dense); font-weight: var(--w-medium);
    color: var(--muted); letter-spacing: 0; white-space: normal;
  }

  /* A menu row is the one control in the app with no chrome to aim at, so it
     has to carry the floor itself. The panel widens with it, or three-word
     labels wrap inside a 176px popover. */
  .menu-item { min-height: var(--tap); padding: 11px 14px; }
  .menu-panel { min-width: 240px; }
  /*
   * Only the glyph-only trigger is squared. `.has-text` is on the sidebar's
   * network switcher and the Views button, which are already tap-height and
   * would be turned into 44px squares with their labels overflowing.
   */
  .menu-trigger:not(.has-text) {
    min-width: var(--tap); min-height: var(--tap);
    padding-left: 0; padding-right: 0;
  }

  /* The columns dialog is entirely small controls in a tight column. */
  .col-moves { gap: var(--sp-2); }
  .col-moves button { min-width: var(--tap); min-height: var(--tap); }
  .col-row { min-height: var(--tap); }
  .col-toggle { min-height: var(--tap); }

  /*
   * The switch grows with the rest, and the knob's travel has to grow with the
   * track or it under-shoots and parks mid-way — which reads as "half on".
   * 46 - 24 - 2 - 2 = 18. `margin-inline-start` alone rather than the two-sided
   * shorthand: the negative pull is there to line the track up with the text
   * above it, and inside a cell there is padding it must not run under.
   */
  .switch { min-height: var(--tap); margin-inline-start: -8px; }
  .switch-track { width: 46px; height: 28px; }
  .switch-knob { width: 24px; height: 24px; top: 2px; left: 2px; }
  .switch.on .switch-knob { transform: translateX(18px); }
  td .switch { margin-inline-start: 0; }

  /* A disclosure row is a one-line target in a wall of log text. */
  .log-details > summary { min-height: var(--tap); padding: 10px 0; font-size: var(--fs-dense); }
  /* An accordion summary in a sheet is the same case. */
  .sheet-section > summary { min-height: var(--tap); }

  /* A code block on a phone should not be a scroller inside a scroller with a
     fixed ceiling; the sheet already scrolls. The 12px stays — see pre.code. */
  pre.code { max-height: none; overscroll-behavior-x: contain; padding: 11px; }
  /* The same floor, for the same reason: 16px beats the 12.5px monospace metric,
     which iOS would zoom on focus — and a zoomed viewport over a JSON editor is a
     trap. */
  textarea.code { font-size: max(16px, var(--fs-body)); line-height: 1.5; }
  .spec-editor { min-height: 200px; }

  /* A 6px column is not a target for a thumb; the hit area is what widens.
     The default 30-day range does not fit at this floor and is not meant to —
     .spark carries the overflow sideways inside its own card, which is the
     trade: a thumb-sized column you have to push to, over a chart that fits by
     dropping half of itself off the edge. */
  .spark .col-hit { min-width: 20px; }

  /*
   * The pager follows the rows instead of sitting at the bottom of a list
   * three screens long. It goes static the moment a selection exists, so it
   * never stacks against the bulk bar, which occupies the same corner.
   */
  .leads-pager {
    /* It pins to the top of the tab bar rather than to the bottom edge, so the
       clearance moved out of its padding and into the offset: the bar carries
       the home indicator's inset now, and stacking both would have left a band
       of empty pager above it. On a landscape phone, which has no tab bar, this
       resolves to exactly the inset the padding used to hold. */
    position: sticky; bottom: calc(var(--tabbar-h) + var(--safe-b)); z-index: 20;
    background: var(--float-bg);
    -webkit-backdrop-filter: var(--float-blur);
    backdrop-filter: var(--float-blur);
  }
  /* Static again: parked in the flow of the card it is nowhere near the bottom
     edge, and the bulk bar — which is what occupies that corner while a
     selection exists — is floating over the tab bar in its place. */
  body.has-selection .leads-pager { position: static; }

  /*
   * The arrivals notice stops being a row at the top of the table and becomes
   * a pill under the app bar — in flow it pushed the whole list down by 37px
   * the moment new leads landed, which is the one thing a queue that promises
   * not to reorder under the operator must not do.
   */
  /* `translate` carries the centring and `transform` carries the entrance, for
     the same reason the bulk bar splits them: an animation ending on
     `transform: none` would otherwise drop the bar half a screen to the right
     on its last frame. */
  .new-leads-bar {
    position: fixed; z-index: 44;
    /* Anchored under the app bar, which is now --topbar-h plus the status
       inset tall — without the same term the pill would ride up under it. */
    top: calc(var(--topbar-h) + var(--safe-t) + 8px); left: 50%; translate: -50%;
    width: auto; padding: 6px 8px;
    border: 0; border-radius: var(--r-pill);
    box-shadow: var(--sh-2);
    animation: arrivals-in var(--dur-3) var(--ease-out) backwards;
  }
  @keyframes arrivals-in {
    from { opacity: 0; transform: translateY(-14px); }
    to { opacity: 1; transform: none; }
  }

  /* The sort control is 21px of text inside a 43px header cell. Grow its hit
     area to fill the cell without moving the label. */
  .th-sort { padding: 11px 5px; margin: -11px -5px; }

  /*
   * The small steps, opened up by --tr-small.
   *
   * The file's stated rule is that tracking follows size and nothing else, and it
   * only ever tightened: -0.02em at 28px and above, -0.015em from 17 to 24, zero
   * below. SF Text goes the other way — it adds tracking as it gets smaller,
   * which is what keeps iOS's caption sizes open at arm's length — so below 17px
   * this app was set tighter than the system it borrows its face from.
   *
   * Named selectors rather than a blanket rule on everything under 17px: the caps
   * treatments already carry --tr-caps and must not have a second value added to
   * them, and a `*` rule would reach the mono stack, where tracking is what keeps
   * a column of digits in line. These are the small steps that are *words*.
   */
  .pill,
  .cell-lead .lead-meta,
  .stat .label, .stat .foot,
  .menu-hint,
  .tab-label,
  .toast,
  .empty-inline,
  label.f > small,
  .f-err { letter-spacing: var(--tr-small); }
}

/* --------------------------- pull to refresh ----------------------------- */

/*
 * The affordance the phone shell's `overscroll-behavior-y: contain` took away,
 * given back to the one screen that means something by it.
 *
 * Drawn rather than borrowed, because there is nothing left to borrow. It rides
 * under the app bar — `--topbar-h` plus the status inset, the same anchor the
 * arrivals pill uses — and travels on `--ptr-pull`, a plain number in pixels that
 * util.js writes on every move. A number and not a length so the same value can
 * drive both the travel and the rotation.
 */
.ptr {
  position: fixed; z-index: 44;
  top: calc(var(--topbar-h) + var(--safe-t));
  left: 50%;
  /* -100% parks it above its own anchor when nothing is being pulled, so it is off
     screen at rest without needing a second state. */
  transform: translate(-50%, calc(-100% + var(--ptr-pull, 0) * 1px));
  display: flex; align-items: center; justify-content: center;
  width: 32px; height: 32px;
  border-radius: 50%;
  background: var(--float-bg);
  -webkit-backdrop-filter: var(--float-blur);
  backdrop-filter: var(--float-blur);
  border: 1px solid var(--border);
  box-shadow: var(--sh-2);
  opacity: calc(var(--ptr-pull, 0) / 72);
  pointer-events: none;
}
.ptr-spinner {
  width: 15px; height: 15px; border-radius: 50%;
  border: 2px solid var(--border-strong);
  border-top-color: var(--accent-hi);
  /* Turns with the pull, so the gesture is driving something rather than filling a
     progress bar that then does its own thing. */
  transform: rotate(calc(var(--ptr-pull, 0) * 4deg));
}
/* Past the threshold: let go and it will reload. The colour is the whole signal —
   there is no room for a word at 32px. */
.ptr.is-armed { border-color: var(--accent); }
.ptr.is-busy .ptr-spinner { animation: spin .7s linear infinite; transform: none; }
/* The pull itself has to be immediate, but the release should land rather than
   snap. Only the travel is transitioned, and only when the finger is off it. */
.ptr:not(.is-armed):not(.is-busy) { transition: transform var(--dur-2) var(--ease-out), opacity var(--dur-2) var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .ptr-spinner { transform: none; }
}

/* --------------------------- bottom tab bar ------------------------------ */

/*
 * The one change of shape in the phone pass.
 *
 * Seven top-level destinations were behind a hamburger in the top-left corner —
 * a toll gate on every navigation in the app, in the corner of the screen a
 * thumb reaches last, and with nothing on screen ever saying which of the seven
 * you were on. The four an operator lives in come out onto a bar under the
 * thumb; the long tail stays in the drawer behind More, which is the same
 * drawer opened by a different control.
 *
 * The bar exists wherever the phone shell does — this query is PHONE_SHELL,
 * character for character — and that is a change from the first pass, which
 * gated it on `min-height: 501px` and so took it away the moment a phone was
 * turned on its side.
 *
 * The old reasoning was right about height and silent about place: 56 + 56 + the
 * home indicator really is a third of a 430pt screen. But the answer to "there is
 * not enough height for this bar" is a shorter bar, not a different navigation —
 * as written, rotating a phone moved every destination in the app back behind a
 * hamburger in the corner the bar was built to stop using, and an operator's
 * muscle memory changed with the orientation. iOS keeps the tab bar in landscape
 * and compacts it, glyph beside label. So does this now: the block after this one
 * takes it to 42px and turns each tab into a row.
 *
 * The hamburger is hidden inside this same query, so wherever the bar is not
 * drawn it comes back and the two can never both go missing. Above 860px — and
 * outside the shell — neither exists: the sidebar is permanent there and owns the
 * navigation outright.
 */
@media (max-width: 860px), (max-height: 500px) and (pointer: coarse) and (max-width: 1100px) {
  :root { --tabbar-h: 56px; }

  .tabbar {
    display: flex; align-items: stretch;
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 45;
    /* The bar grows by the home indicator's inset rather than sliding under it,
       the way the app bar grows by the status inset at the other edge — the
       labels are the bottom row of text in the app and cannot sit under it. */
    height: calc(var(--tabbar-h) + var(--safe-b));
    padding-bottom: var(--safe-b);
    padding-left: var(--safe-l); padding-right: var(--safe-r);
    background: color-mix(in srgb, var(--bg) 82%, var(--panel));
    /* The other half of the scroll-edge pair — see .topbar. The test for this edge
       is not "has anything scrolled" but "is there anything under here at all":
       a page shorter than the viewport ends above the bar, and a line there is
       separating it from the page background. */
    border-top: 1px solid transparent;
    /*
     * The keyboard: the bar goes.
     *
     * This was `translateY(var(--kb-inset))`, which derived the behaviour from a
     * measurement rather than stating it — and the derivation inverted per platform.
     * On iOS --kb-inset is the keyboard's height and a `position: fixed` bar is
     * already behind the keyboard, so translating by it pushed the bar a second
     * keyboard-height off screen; on Android with a resizing layout viewport
     * innerHeight falls with visualViewport.height, --kb-inset computes to 0, and
     * the whole term was inert. Two platforms, two unrelated reasons, one accident
     * that looked like a rule.
     *
     * What was wanted is what iOS does: a tab bar is not part of a typing session,
     * so while a keyboard is up it is not there. One statement, the same meaning on
     * both platforms. `keyboard-open` is written by util.js from the same watcher
     * that measures --kb-inset, so the two cannot disagree.
     */
    transform: translateY(0);
    transition: border-color var(--dur-2) var(--ease), transform var(--dur-2) var(--ease);
  }
  body.keyboard-open .tabbar { transform: translateY(100%); }
  body.page-scrollable .tabbar { border-top-color: var(--border); }
  /* Opaque fill as the base, translucency as the upgrade — iOS 17 and earlier
     only ship the prefixed filter, the same guard .topbar and thead use. */
  @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .tabbar {
      background: var(--float-bg);
      -webkit-backdrop-filter: var(--float-blur);
      backdrop-filter: var(--float-blur);
    }
  }

  /* The whole tab is the target — a fifth of the width and the full height of
     the bar, both well past the 44px floor — rather than an 18px glyph with a
     word under it. */
  .tab-item {
    position: relative;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    flex: 1 1 0; min-width: 0;
    gap: 3px;
    min-height: var(--tap);
    padding: 6px 2px;
    border: 0; border-radius: 0;
    background: none;
    color: var(--muted);
    font: inherit; font-weight: var(--w-regular); text-align: center;
    text-decoration: none;
    cursor: pointer; user-select: none;
    -webkit-tap-highlight-color: transparent;
    transition: color var(--dur-1) var(--ease);
  }
  /* Painted in currentColor, like the drawer rows these glyphs are cloned from:
     the active state and both themes come out of the one `color` above instead
     of four declarations. Left at their native 18px and not scaled up to fill
     the bar — the viewBox is 18 and the family is one 1.5 stroke, so a 22px box
     would make these the only glyphs in the app drawn at 1.83. */
  .tab-item .ico { display: inline-flex; width: 18px; height: 18px; opacity: .85; flex: none; }
  .tab-item.active .ico { opacity: 1; }
  .tab-label {
    font-size: var(--fs-caps); line-height: 1.15;
    /* Not --tr-caps: these are words, not the caps track that token is for. */
    letter-spacing: 0;
    max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }

  /* --accent-hi, not --accent-text-hi. That token is #d3deff in the dark theme,
     which against --muted #98a5c6 is two pale blue-greys one lightness step
     apart — the rail below was carrying the whole state. This is unmistakably
     blue in both themes and still clears AA at this size on --float-bg. */
  .tab-item.active { color: var(--accent-hi); }
  /* The glyph gets heavier as well as tinted. An iOS tab bar switches its symbol
     to a filled variant, which is the most recognisable single thing about one;
     this family is a single 1.5 stroke with no filled counterpart, and stepping
     the stroke is the same signal drawn with the glyphs that exist. Set on the
     <svg> through the attribute-backed property, so it reaches every path in the
     clone without the icons needing a second copy. */
  .tab-item.active .ico svg { stroke-width: 2.05; }
  /* The drawer's active rail, turned through 90° onto the edge this bar meets
     the page at. Same reason it exists there: which tab you are on should not be
     carried by colour alone. -1px so it sits on the border rather than under it. */
  .tab-item.active::before {
    content: ""; position: absolute; top: -1px; left: 50%;
    width: 26px; height: 2px; margin-left: -13px;
    border-radius: var(--r-pill);
    background: var(--accent-hi);
  }
  @media (hover: hover) {
    .tab-item:hover { color: var(--text); text-decoration: none; }
  }
  /* The press the links in this app already have, said again here so More — a
     button, and outside that rule — does not sit still while the tab next to it
     dims. Opacity and not the accent: the tint means "this is the page you are
     on", and a press is not that. */
  @media (hover: none) {
    .tab-item:active { opacity: .6; }
  }

  /*
   * The hamburger goes. With More opening the drawer it is a second control for
   * one job, sitting in the corner the bar exists to avoid, and the brand takes
   * the leading position it was pushing aside. It stays in the DOM with every
   * attribute setNav writes to it intact: it is still the only opener everywhere
   * this query does not reach — above 860px, and on a phone lying on its side —
   * and both the drawer's focus trap and its close path name it.
   * `visibility: hidden` would keep its 44px of bar; this gives the width back
   * to the one string that says which installation you are about to change.
   */
  #nav-toggle { display: none; }
  /* 6px was the hamburger's outer margin, and the button carried its own padding
     inside that. With the brand against the leading edge the padding has to be
     on the bar. */
  .topbar { padding-left: max(14px, var(--safe-l)); }

  /*
   * The drawer arrives from the edge its control is on.
   *
   * More is the last item on a bar pinned to the bottom of the screen, and the
   * panel it opened slid in from the far left — a control at one corner opening a
   * surface from the opposite one, which is a web drawer wearing a native bar. On
   * iOS what a bottom-bar item opens comes from the bottom or from the side it was
   * tapped on; it does not cross the screen to get there.
   *
   * Scoped to this query and not to the phone shell, which is the whole point:
   * wherever the bar is *not* drawn the opener is the hamburger in the top-left
   * corner again, and there the left edge is the correct origin. One query decides
   * both, so the drawer and its opener can never end up on opposite sides.
   */
  .sidebar {
    inset: 0 0 0 auto;
    transform: translateX(102%);
    border-right: 0;
    border-left: 1px solid var(--border-strong);
    padding-left: 14px;
    padding-right: max(14px, var(--safe-r));
    /* Attached to the right now, so closing travels right. */
    --drawer-side: 1;
  }
  /* The active rail moves to the edge the panel is attached to, or it would be
     pointing off into the screen from the far side of every row. */
  .nav-item.active::before { left: auto; right: -7px; }

  /*
   * The four rows the tab bar is already carrying come out of the drawer.
   *
   * Every row was in here at every width, so More opened onto a list whose first
   * four entries were the bar the operator had just tapped — and the current page
   * wore two active marks at once, the drawer's rail and the tab's. iOS's More
   * lists what the bar does not.
   *
   * The class is written by buildTabBar() onto the rows it actually consumed, so
   * this can never hide a row whose tab was not built — a viewer has no
   * Integrations tab, and their Integrations row is not deleted by this, it is
   * deleted by applyRole before the bar is built at all.
   */
  .nav-item.is-on-tabbar { display: none; }
}

/*
 * The compact bar, for a phone on its side.
 *
 * Glyph beside label rather than above it, at 42px instead of 56 — which is the
 * shape iOS gives a tab bar in a compact-height environment, and is what makes
 * keeping the bar in landscape affordable: 42 + 56 + the indicator against 430pt
 * is a fifth of the screen rather than a third.
 *
 * The query is written as the phone shell's two branches with `and (max-height:
 * 500px)` folded into each, and it has to be: it must be a strict *subset* of the
 * bar's own query above. A bare `(max-height: 500px) and (max-width: 1100px)`
 * looks equivalent and is not — at 1000x480 with a mouse the shell does not
 * match, no bar is drawn, and that query would still set --tabbar-h to 42px,
 * leaving every calc() in the file reserving space at the bottom of the screen
 * for a bar that is not there.
 */
@media (max-width: 860px) and (max-height: 500px),
       (max-height: 500px) and (pointer: coarse) and (max-width: 1100px) {
  :root { --tabbar-h: 42px; }

  .tab-item {
    flex-direction: row;
    gap: 7px;
    padding: 4px 6px;
    /* The floor comes off the height and stays on the width: a 42px bar cannot
       give each tab 44px of height, and the whole bar is still the target — a
       fifth of a 852pt screen is 170px wide. Below the floor in one axis only,
       for a control whose neighbour on every side is another tab. */
    min-height: 0;
  }
  /* The rail runs along the tab rather than sitting over its centre: at this
     height a 2px mark 26px wide above a horizontal row reads as an underline that
     has come loose. */
  .tab-item.active::before { width: 34px; margin-left: -17px; }
  /* One line, and it is beside the glyph now, so it takes what is left of the
     track after an 18px icon and a 7px gap. */
  .tab-label { font-size: var(--fs-micro); }
}

/* Small phone. Rules that have to out-order the 860px block live here rather
   than in the card-mode block further up the file, which is declared before it. */
@media (max-width: 560px) {
  /*
   * The build badge sheds everything but what it is for. `.env` is the whole
   * point of the badge — which installation is this — so it is never the part
   * that goes; the version string and the build hash are, since neither is
   * read at a glance and both are still in the dialog behind it.
   */
  .version-badge > span:not(.env):not(.hash) { display: none; }
  /* padding-inline, never the shorthand: the 860px block sets 14px on that axis
     to buy the 44px floor and nothing on the block axis, so a shorthand here
     silently took the 14px back while restating the 4px that was already in
     force. Two rules editing two axes should look like it. */
  .version-badge { padding-inline: 9px; gap: 6px; }
}

@media (max-width: 480px) {
  /* rem like the rest of the ramp: left in px this was the one step that would
     not follow the reader's text size, and it is the page's own heading. */
  :root { --gutter: 12px; --fs-title: 1.1875rem; /* 19px */ }
  /* The tab-bar term comes with it: restating the constant alone would put the
     last row of every list back under the bar on the narrowest screens. */
  .main { padding-top: var(--sp-4); padding-bottom: calc(72px + var(--tabbar-h)); }
  /* Share a row rather than stacking: three full-width buttons pushed the data
     itself below the fold. The padding is what gives way to fit them, not the
     type — at --fs-label these were the smallest labels in the product on the
     narrowest screen it runs on, which is the wrong way round.
     The <select> is excluded rather than given a second size: this rule is
     (0,1,0) and out-ranks the element-level floor, so a select caught by it was
     the one control left that zoomed iOS on focus — and giving it 16px back left
     one toolbar row carrying two type sizes, a select three points larger than
     the button beside it. Not selecting it in the first place is what makes the
     row agree with itself. */
  .head-actions > *:not(select) { flex: 1 1 auto; min-width: 0; font-size: var(--fs-dense); padding-left: 8px; padding-right: 8px; }
  .head-actions > select { flex: 1 1 auto; min-width: 0; }
  .filter-bar.open .filters { grid-template-columns: minmax(0, 1fr); }
  td .truncate, td.truncate { max-width: 150px; }
  /* No .col-pin rule here. 480 is inside 560, where the button is display:none
     — sizing a control that is not on screen only reads as a second opinion
     about whether Freeze is offered on a phone. It is not. */
}

/* The smallest screens still in service. Two stat tiles across a 320px screen
   leaves each number about 140px, which is narrower than the label above it. */
@media (max-width: 360px) {
  .grid.tiles { grid-template-columns: minmax(0, 1fr); }
}

/*
 * Short landscape phones: the sheet should not eat the whole screen. The 860px
 * bound is gone — a 932pt landscape iPhone is wider than 860 and is exactly the
 * device this was written for — but 1100px takes its place, because this query
 * has to read exactly like the phone shell's second branch and nothing else.
 *
 * Unbounded it also reached a touchscreen desktop with a shortened window, at
 * say 1400x480, where the shell query does not match: the dialog there is still
 * a centred `.modal`, which carries no max-height of its own, so it took square
 * corners and a full viewport of height inside a backdrop that pads --sp-7 top
 * and bottom — and the backdrop scrolled.
 */
@media (max-height: 500px) and (pointer: coarse) and (max-width: 1100px) {
  .modal, .modal.wide { max-height: calc(100dvh - var(--kb-inset)); border-radius: 0; }
  .modal-head, .modal-foot { border-radius: 0; }
  .modal-head::before { display: none; }
  /*
   * The sheet goes full-bleed here, which is the one place in the app where a
   * content column is against the physical left and right edges of a notched
   * phone lying on its side. Two longhands only, so .modal-foot keeps the
   * bottom clearance it sets for itself. Inert everywhere the insets are 0 —
   * every portrait phone, every iPad, every desktop.
   */
  .modal-head, .modal-body, .modal-foot {
    padding-left: max(var(--sp-4), var(--safe-l));
    padding-right: max(var(--sp-4), var(--safe-r));
  }
}

/* --------------------------- user preferences ---------------------------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    scroll-behavior: auto !important;
  }
}

/*
 * Increase Contrast is meant to sharpen a design that already conforms, not to
 * be the only configuration that does — so this block is not where 1.4.11 gets
 * met, it is where it gets exceeded. The previous values left the boundary a
 * control is identified by at about 2.2:1, i.e. the mode changed four tokens
 * and delivered nothing at the one place it was asked for.
 *
 * --border is promoted to what --border-strong already was (3.06:1 on --field
 * in dark), and --border-strong moves up to keep the two tiers apart. --field
 * is deliberately not touched: it is also the progress track and the code-block
 * ground, and deepening it flattens both against --bg.
 */
@media (prefers-contrast: more) {
  :root:not([data-theme="light"]) {
    --border: #5a6a99;
    --border-strong: #7482b0;
    --muted: #a9b5d4;
    --faint: #8592b8;
  }
  :root[data-theme="light"] {
    --border: #737f9b;
    --border-strong: #5f6b84;
    --muted: #3a465f;
    --faint: #56637d;
  }
}

/*
 * Reduce Transparency. Every other user preference in this section is honoured
 * and this one was simply missing — it is the setting Apple ships for people
 * who cannot read text over a blurred substrate, and Safari surfaces it here.
 *
 * This deliberately overrides the sticky-header decision argued for at length
 * beside `thead th`: with an opaque bar, rows are no longer felt moving
 * underneath the header, and the operator loses the cue that says the list is
 * pinned rather than clipped. That is a real loss and the correct trade for a
 * reader who has asked the system for it.
 *
 * The two scrims null their blur but stay translucent. Their alpha is not a
 * material — it is dimming, and its whole job is to show that the app is still
 * there behind the layer on top of it. An opaque backdrop would not be a
 * clearer dialog, it would be a dialog on a blank screen.
 */
@media (prefers-reduced-transparency: reduce) {
  /* Two branches, because --overlay is one of the tokens being moved and the
     two themes hold it at different alphas. */
  :root:not([data-theme="light"]) {
    --float-blur: none;
    --scrim-blur: none;
    --overlay: rgba(4, 7, 14, .88);
  }
  :root[data-theme="light"] {
    --float-blur: none;
    --scrim-blur: none;
    --overlay: rgba(17, 24, 43, .62);
  }
  .topbar { background: var(--panel); }
  .tabbar { background: var(--panel); }
  .bulk-bar { background: var(--panel-3); }
  .leads-pager { background: var(--panel-2); }
  thead th { background: var(--panel-2); }
}

/* ---------------------------------- print -------------------------------- */

@media print {
  /* `:root[data-theme]` as well, so this still wins once a theme has been
     chosen — the light block would otherwise out-specify a bare `:root`. */
  :root, :root[data-theme] {
    --bg: #fff; --panel: #fff; --panel-2: #fff; --text: #000; --muted: #444;
    --row-hover: transparent;
  }
  body { background: #fff; color: #000; }
  .sidebar, .topbar, .tabbar, .version-badge, .theme-toggle, #toasts, .head-actions, .filters, .filter-toggle { display: none !important; }
  #app { grid-template-columns: 1fr; }
  .main { padding: 0; }
  .card, .stat { border: 1px solid #ccc; box-shadow: none; break-inside: avoid; }
  a { color: #000; text-decoration: underline; }
}
