/* EYE — admin console.
 *
 * Hand-written, no build step, no external fonts. `style-src` is `'self'`
 * with no unsafe-inline, so every rule has to live in a same-origin
 * stylesheet like this one, and there are no `style=` attributes anywhere in
 * the markup or the JS. That has not changed.
 *
 * What HAS changed: `script-src` now carries exactly one third-party origin,
 * cdn.jsdelivr.net, for the dashboard's charting library — pinned by version
 * and verified by Subresource Integrity (contrib/echarts.lock.json,
 * html/admin/js/charts.js). No STYLE may come from there; the chart palette
 * below is served from this file and read out of it by JS.
 *
 * Palette is assets/EYE-graphic-identity-package/STYLE-GUIDE.md verbatim.
 * Rule 8 of that guide — "never communicate state using color alone" — is
 * enforced structurally here: `.badge` variants all pair their colour with a
 * `.badge-dot` glyph and a text label, and nothing in this file encodes
 * meaning in hue without an accompanying word.
 */

:root {
  --eye-cyan:#00D4FF;
  --eye-blue:#0A84FF;
  --eye-bg:#0F172A;
  --eye-surface:#111827;
  --eye-text:#E5E7EB;
  --eye-muted:#9CA3AF;
  --eye-subtle:#6B7280;
  --eye-border:#374151;
  --eye-success:#10B981;
  --eye-warning:#F59E0B;
  --eye-error:#EF4444;

  /* derived surfaces — kept few and neutral (style guide rule 2: cyan is an
     accent, not a background flood) */
  --surface-2:#161F2F;
  --surface-3:#1C2739;
  --field-bg:#0B1220;
  --row-hover:#18202F;
  --shadow:rgba(0,0,0,.45);

  /* ---- chart palette ---------------------------------------------------
     Read by html/admin/js/charts.js through getComputedStyle, so the brand
     lives here and only here — the charting library never sees a hard-coded
     hex.

     These are NOT eyeballed. Every value was checked by script against the
     #111827 chart surface for: OKLCH lightness inside the dark band
     (0.48–0.67), chroma >= 0.10, protan/deutan separation (Machado 2009 at
     severity 1.0, OKLab ΔE×100 >= 8 over ALL pairs, not just adjacent ones),
     a normal-vision worst-pair floor of 15, and >= 3:1 WCAG contrast against
     the surface. The categorical trio scores: worst CVD pair 11.0, worst
     normal-vision pair 26.8, all three >= 3:1.

     --chart-1/2/3 are the CATEGORICAL slots, assigned in fixed order and
     never cycled: person, vehicle, unknown — in the stacked area, in the
     Sankey's first stage and in the tracks breakdown alike, so a colour
     always means the same thing across the page. 1 and 2 are the brand cyan
     and amber hues stepped down into the dark band; the brand's own
     --eye-cyan/--eye-warning sit at L 0.80/0.77, well above it, and reading
     as a set matters more for a chart than matching an accent swatch. They
     are deliberately different hexes from the status tokens so a series can
     never be mistaken for a warning.

     --chart-ord-* is an ORDINAL ramp (one hue, monotone lightness), used for
     the pipeline funnel where the order of the stages IS the meaning:
     observations -> tracks -> events. Validated separately: monotone L, every
     adjacent gap >= 0.06, darkest step 3.32:1 on the surface. */
  --chart-1:#00A2CB;
  --chart-2:#CD7900;
  --chart-3:#A638B7;
  --chart-ord-1:#00739b;
  --chart-ord-2:#0098c2;
  --chart-ord-3:#00bfe9;
  /* Deliberately uncoloured: the Sankey's middle stage, where eight competing
     hues would drown the two stages that carry meaning. */
  --chart-neutral:#6B7280;
  --chart-grid:rgba(55,65,81,.55);

  --r-lg:12px; --r-md:8px; --r-sm:6px;
  --sidebar-w:232px;
  --mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", Menlo, Consolas, monospace;
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
}

* { box-sizing:border-box; }
html, body { height:100%; }
/* Always show the document scrollbar so a short page and a long one have the
   same content width — the layout must not shift sideways when you navigate
   between them. The scrollbar itself is themed just below. */
html { overflow-y:scroll; }
body {
  margin:0;
  background:
    radial-gradient(1100px 480px at 50% -220px, #14213a, transparent 70%),
    var(--eye-bg);
  color:var(--eye-text);
  font:14px/1.5 var(--sans);
  -webkit-font-smoothing:antialiased;
}
[hidden] { display:none !important; }

/* --------------------------------------------------------- scrollbars ---
   Themed to the palette, thin, and unobtrusive. Firefox takes the two
   standard properties; Chromium/WebKit take the -webkit pseudo-elements.
   Inner scroll areas (nav, table wrappers, modal bodies) reserve their
   gutter with `scrollbar-gutter: stable` so revealing a scrollbar there
   does not reflow the content beside it either. */
* { scrollbar-width:thin; scrollbar-color:var(--eye-border) transparent; }
::-webkit-scrollbar { width:11px; height:11px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb {
  background:var(--eye-border); border-radius:999px;
  border:2px solid transparent; background-clip:padding-box;
}
::-webkit-scrollbar-thumb:hover { background:var(--eye-subtle); background-clip:padding-box; }
::-webkit-scrollbar-corner { background:transparent; }
.nav, .view, .table-wrap, .modal-body, .chart-data .table-wrap,
.zc-points, .kv, pre.json { scrollbar-gutter:stable; }
h1,h2,h3,h4 { margin:0; line-height:1.2; font-weight:600; }
h2 { font-size:1rem; }
h3 { font-size:.9rem; }
h4 { font-size:.8rem; }
a { color:var(--eye-cyan); }
a:hover { color:var(--eye-blue); }
code, .mono, pre { font-family:var(--mono); font-size:.85em; }
:focus-visible { outline:2px solid var(--eye-cyan); outline-offset:2px; }

/* ------------------------------------------------------------------ boot */
.boot {
  position:fixed; inset:0; display:flex; flex-direction:column;
  align-items:center; justify-content:center; gap:18px; text-align:center;
}
.boot p { color:var(--eye-muted); margin:0; font-size:.9rem; }
.boot-mark { width:64px; height:64px; }
.boot-bar { width:180px; height:3px; border-radius:2px; overflow:hidden; background:var(--eye-border); }
.boot-bar span {
  display:block; height:100%; width:40%;
  background:linear-gradient(90deg, var(--eye-cyan), var(--eye-blue));
  animation:sweep 1.6s ease-in-out infinite;
}
@keyframes sweep { 0% { transform:translateX(-100%); } 100% { transform:translateX(350%); } }
@media (prefers-reduced-motion: reduce) {
  .boot-bar span { animation:none; width:100%; }
}

/* ----------------------------------------------------------------- login */
.login { min-height:100vh; display:flex; align-items:center; justify-content:center; padding:24px; }
.login-card { width:100%; max-width:380px; display:flex; flex-direction:column; gap:14px; }
.login-brand { display:flex; align-items:center; gap:10px; }
.login-brand img { width:34px; height:34px; border-radius:8px; }
.login-brand strong { font-size:1.15rem; letter-spacing:.14em; }
.login-brand em { font-style:normal; color:var(--eye-muted); font-size:.75rem; letter-spacing:.14em; text-transform:uppercase; }
.login-sub { margin:0; color:var(--eye-muted); font-size:.85rem; }
.login-error {
  margin:0; padding:10px 12px; border-radius:var(--r-sm);
  background:rgba(239,68,68,.1); border:1px solid var(--eye-error);
  color:#FCA5A5; font-size:.85rem;
}
.login-note { margin:0; color:var(--eye-subtle); font-size:.75rem; }

/* ----------------------------------------------------------------- shell */
.shell { display:flex; min-height:100vh; }

/* Viewer mode: a chrome-free wall for accounts that hold only camera.live.
   No sidebar, no nav toggle, a trimmed topbar. app.js also locks routing to
   the wall for these accounts. */
.shell.viewer .sidebar { display:none; }
.shell.viewer .topbar .nav-toggle { display:none; }
.shell.viewer .whoami-perms { display:none; }
.shell.viewer .topbar-title { font-size:1rem; opacity:.8; }
.shell.viewer .view { padding:0; }

.sidebar {
  width:var(--sidebar-w); flex:0 0 var(--sidebar-w);
  background:var(--eye-surface); border-right:1px solid var(--eye-border);
  display:flex; flex-direction:column; position:sticky; top:0; height:100vh;
  transition:width .16s ease, flex-basis .16s ease;
}
@media (prefers-reduced-motion: reduce) { .sidebar { transition:none; } }
.brand { display:flex; align-items:center; gap:9px; padding:14px 14px 12px; border-bottom:1px solid var(--eye-border); }
.brand img { width:26px; height:26px; border-radius:6px; }
.brand-text { display:flex; flex-direction:column; line-height:1.1; }
.brand-text strong { font-size:.95rem; letter-spacing:.16em; }
.brand-text em { font-style:normal; font-size:.63rem; letter-spacing:.13em; text-transform:uppercase; color:var(--eye-subtle); }
/* The rail toggle. Always shown: on desktop it collapses the sidebar to an
   icon rail (← / →); under 880px it closes the off-canvas drawer (✕). */
.sidebar-close { margin-left:auto; display:inline-flex; }

.nav { flex:1; overflow-y:auto; padding:10px 8px; }
.nav-group { display:flex; flex-direction:column; }
/* The group header is a button now — collapsing/expanding its section. Reset
   the button chrome, keep the small uppercase label look. */
.nav-sep {
  display:flex; align-items:center; gap:6px; width:100%;
  background:none; border:0; cursor:pointer; text-align:left; font:inherit;
  font-size:.63rem; letter-spacing:.14em; text-transform:uppercase;
  color:var(--eye-subtle); padding:12px 8px 5px;
}
.nav-sep:hover { color:var(--eye-muted); }
.nav-sep-chev {
  margin-left:auto; display:inline-flex; color:var(--eye-subtle);
  transition:transform .14s ease;
}
.nav-group.is-collapsed .nav-sep-chev { transform:rotate(-90deg); }
.nav-group.is-collapsed .nav-group-items { display:none; }
@media (prefers-reduced-motion: reduce) { .nav-sep-chev { transition:none; } }
.nav-item {
  display:flex; align-items:center; gap:9px; padding:7px 9px; margin-bottom:1px;
  border-radius:var(--r-sm); color:var(--eye-muted); text-decoration:none; font-size:.86rem;
  border:1px solid transparent;
}
.nav-item:hover { background:var(--surface-2); color:var(--eye-text); }
.nav-item.active {
  background:rgba(0,212,255,.1); color:var(--eye-cyan);
  border-color:rgba(0,212,255,.28);
}
.nav-item .ni-icon { display:flex; flex:0 0 17px; }
.nav-item .ni-soon {
  margin-left:auto; font-size:.6rem; letter-spacing:.06em; text-transform:uppercase;
  color:var(--eye-subtle); border:1px solid var(--eye-border); border-radius:3px; padding:0 4px;
}
.sidebar-foot {
  padding:10px 14px; border-top:1px solid var(--eye-border);
  font-size:.68rem; color:var(--eye-subtle); display:flex; flex-direction:column; gap:3px;
}

.main { flex:1; min-width:0; display:flex; flex-direction:column; }
.topbar {
  display:flex; align-items:center; gap:12px; padding:10px 18px;
  border-bottom:1px solid var(--eye-border); background:rgba(17,24,39,.85);
  position:sticky; top:0; z-index:20; backdrop-filter:blur(6px);
}
.topbar-title { font-size:1rem; }
.topbar-actions { display:flex; gap:8px; align-items:center; margin-left:8px; }
.topbar-right { margin-left:auto; display:flex; align-items:center; gap:12px; }
.whoami { display:flex; flex-direction:column; align-items:flex-end; line-height:1.25; }
.whoami-name { font-size:.82rem; }
.whoami-perms { font-size:.66rem; color:var(--eye-subtle); }
/* `.topbar` prefix, not a bare `.nav-toggle`: `.icon-btn` (which this button
   also carries) sets `display:inline-flex` later in the file at equal
   specificity and would otherwise win, leaving the burger visible on desktop
   where it only dims the page with a scrim and no drawer behind it. */
.topbar .nav-toggle { display:none; }
.view { padding:18px; display:flex; flex-direction:column; gap:14px; }
.scrim { position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:25; }

/* Collapsed rail — desktop only. Under 880px the sidebar is an off-canvas
   drawer that always opens at full width, so the `nav-collapsed` class is
   inert there (the media query below re-asserts the full variable). */
@media (min-width: 881px) {
  .shell.nav-collapsed { --sidebar-w:60px; }
  .shell.nav-collapsed .brand { justify-content:center; padding-left:0; padding-right:0; gap:0; }
  .shell.nav-collapsed .brand img { display:none; }
  .shell.nav-collapsed .brand-text,
  .shell.nav-collapsed .nav-sep,
  .shell.nav-collapsed .nav-item > span:not(.ni-icon),
  .shell.nav-collapsed .nav-item .ni-soon,
  .shell.nav-collapsed .sidebar-foot { display:none; }
  .shell.nav-collapsed .sidebar-close { margin-left:0; }
  .shell.nav-collapsed .nav { padding-left:6px; padding-right:6px; }
  .shell.nav-collapsed .nav-item { justify-content:center; padding-left:0; padding-right:0; }
  /* The rail is icons-only with no group headers, so per-group collapse has
     nothing to act on — show every icon regardless of its group's state. */
  .shell.nav-collapsed .nav-group.is-collapsed .nav-group-items { display:block; }
}

@media (max-width: 880px) {
  .sidebar {
    position:fixed; left:0; top:0; z-index:30; transform:translateX(-100%);
    transition:transform .18s ease;
  }
  /* A shadow so the dark drawer reads as a panel above the dark scrim,
     instead of looking like the page just dimmed with nothing on top. */
  .shell.nav-open .sidebar { transform:none; box-shadow:0 0 40px rgba(0,0,0,.65); }
  .topbar .nav-toggle { display:inline-flex; }
  .view { padding:14px; }
  .whoami { display:none; }
}

/* ---------------------------------------------------------------- panels */
.panel {
  background:var(--eye-surface); border:1px solid var(--eye-border);
  border-radius:var(--r-lg); padding:16px;
}
.panel-tight { padding:0; overflow:hidden; }
.panel-head {
  display:flex; align-items:center; gap:10px; padding:12px 16px;
  border-bottom:1px solid var(--eye-border);
}
.panel-head h2 { flex:0 0 auto; }
.panel-body { padding:16px; }
.row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.row-top { align-items:flex-start; }
.grid { display:flex; flex-direction:column; gap:12px; }
.spacer { flex:1 1 auto; }
.muted { color:var(--eye-muted); }
.subtle { color:var(--eye-subtle); }
.small { font-size:.78rem; }
.tiny { font-size:.7rem; }
.empty { padding:28px 16px; text-align:center; color:var(--eye-muted); font-size:.86rem; }
.kicker { font-size:.63rem; letter-spacing:.16em; text-transform:uppercase; color:var(--eye-cyan); }
.wrap-anywhere { overflow-wrap:anywhere; }

/* stat tiles */
.stats { display:grid; grid-template-columns:repeat(auto-fill, minmax(178px, 1fr)); gap:12px; }
.stat {
  background:var(--eye-surface); border:1px solid var(--eye-border);
  border-radius:var(--r-lg); padding:14px 16px; display:flex; flex-direction:column; gap:5px;
}
.stat-label { font-size:.68rem; letter-spacing:.12em; text-transform:uppercase; color:var(--eye-subtle); }
.stat-value { font-size:1.55rem; font-weight:600; line-height:1.1; }
.stat-value.stat-na { font-size:1rem; color:var(--eye-subtle); font-weight:400; }
.stat-note { font-size:.72rem; color:var(--eye-muted); }
.stat-pending { border-style:dashed; }
.stat-pending .stat-value { color:var(--eye-subtle); }

/* ---------------------------------------------------------------- badges */
.badge {
  display:inline-flex; align-items:center; gap:5px;
  padding:1px 8px; border-radius:999px; font-size:.72rem; line-height:1.7;
  border:1px solid var(--eye-border); background:var(--surface-3); color:var(--eye-muted);
  white-space:nowrap;
}
.badge-dot { font-size:.9em; line-height:1; }
.badge-ok { border-color:rgba(16,185,129,.45); background:rgba(16,185,129,.12); color:#6EE7B7; }
.badge-warn { border-color:rgba(245,158,11,.45); background:rgba(245,158,11,.12); color:#FCD34D; }
.badge-bad { border-color:rgba(239,68,68,.45); background:rgba(239,68,68,.12); color:#FCA5A5; }
.badge-info { border-color:rgba(0,212,255,.4); background:rgba(0,212,255,.1); color:var(--eye-cyan); }
.badge-off { border-color:var(--eye-border); background:transparent; color:var(--eye-subtle); }
.chip {
  display:inline-block; padding:1px 7px; border-radius:var(--r-sm);
  border:1px solid var(--eye-border); background:var(--field-bg);
  font-family:var(--mono); font-size:.72rem; color:var(--eye-muted); margin:0 4px 4px 0;
}
.chip-on { border-color:rgba(0,212,255,.4); color:var(--eye-cyan); background:rgba(0,212,255,.08); }
.chips { display:flex; flex-wrap:wrap; }

/* --------------------------------------------------------------- buttons */
.btn {
  display:inline-flex; align-items:center; justify-content:center; gap:6px;
  padding:6px 13px; border-radius:var(--r-sm); font:inherit; font-size:.84rem;
  border:1px solid var(--eye-border); background:var(--surface-3); color:var(--eye-text);
  cursor:pointer; user-select:none; -webkit-user-select:none;
}
.btn:hover:not(:disabled) { background:var(--row-hover); border-color:var(--eye-subtle); }
.btn:disabled { opacity:.45; cursor:not-allowed; }
.btn-primary {
  background:linear-gradient(180deg, var(--eye-cyan), var(--eye-blue));
  border-color:transparent; color:#04121C; font-weight:600;
}
.btn-primary:hover:not(:disabled) { filter:brightness(1.08); background:linear-gradient(180deg, var(--eye-cyan), var(--eye-blue)); }
.btn-danger { border-color:rgba(239,68,68,.5); color:#FCA5A5; background:rgba(239,68,68,.1); }
.btn-danger:hover:not(:disabled) { background:rgba(239,68,68,.2); }
.btn-ghost { background:transparent; }
.btn-sm { padding:4px 10px; font-size:.78rem; }
.btn-xs { padding:2px 8px; font-size:.73rem; }
.btn-block { width:100%; }
.btn-group { display:inline-flex; gap:6px; flex-wrap:wrap; }
.icon-btn {
  display:inline-flex; align-items:center; justify-content:center;
  width:30px; height:30px; padding:0; border-radius:var(--r-sm);
  border:1px solid var(--eye-border); background:transparent; color:var(--eye-muted); cursor:pointer;
}
.icon-btn:hover { background:var(--surface-3); color:var(--eye-text); }
.linky { background:none; border:0; padding:0; color:var(--eye-cyan); cursor:pointer; font:inherit; text-decoration:underline; }

/* ---------------------------------------------------------------- fields */
.field { display:flex; flex-direction:column; gap:4px; }
.field-label { font-size:.72rem; letter-spacing:.06em; text-transform:uppercase; color:var(--eye-subtle); }
.field-help { font-size:.72rem; color:var(--eye-muted); }
/* A checkbox reads naturally beside its label, not stacked above it like a
   text-input's label — used by the timelapse create/edit form's toggles. */
.checkbox-field { flex-direction:row; align-items:center; gap:8px; }
/* Right-aligned, non-wrapping — a table column of small action buttons. */
.col-actions { text-align:right; white-space:nowrap; }
input[type=text], input[type=password], input[type=search], input[type=number],
input[type=email], input[type=datetime-local], select, textarea {
  font:inherit; font-size:.86rem; color:var(--eye-text);
  background:var(--field-bg); border:1px solid var(--eye-border);
  border-radius:var(--r-sm); padding:7px 9px; width:100%; min-width:0;
}
input:focus, select:focus, textarea:focus { border-color:var(--eye-cyan); outline:none; }
input:disabled, select:disabled, textarea:disabled { opacity:.5; cursor:not-allowed; }
textarea { resize:vertical; font-family:var(--mono); }
.check { display:flex; align-items:flex-start; gap:8px; font-size:.85rem; cursor:pointer; }
.check input { width:auto; margin:2px 0 0; accent-color:var(--eye-cyan); flex:0 0 auto; }
.fieldset {
  border:1px solid var(--eye-border); border-radius:var(--r-md); padding:12px;
  display:flex; flex-direction:column; gap:10px; margin:0;
}
.fieldset > legend { padding:0 6px; font-size:.72rem; letter-spacing:.08em; text-transform:uppercase; color:var(--eye-subtle); }
.field-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:10px; }
.perm-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(190px, 1fr)); gap:6px 12px; }

.notice {
  padding:10px 12px; border-radius:var(--r-md); font-size:.8rem;
  border:1px solid var(--eye-border); background:var(--surface-2); color:var(--eye-muted);
}
.notice-warn { border-color:rgba(245,158,11,.45); background:rgba(245,158,11,.08); color:#FCD34D; }
.notice-bad { border-color:rgba(239,68,68,.45); background:rgba(239,68,68,.08); color:#FCA5A5; }
.notice-info { border-color:rgba(0,212,255,.35); background:rgba(0,212,255,.06); color:#9BE7FF; }
.notice strong { color:inherit; }

/* ---------------------------------------------------------------- tables */
.table-wrap { overflow-x:auto; }
table.tbl { width:100%; border-collapse:collapse; font-size:.83rem; }
table.tbl th {
  text-align:left; padding:8px 12px; font-size:.68rem; letter-spacing:.1em;
  text-transform:uppercase; color:var(--eye-subtle); font-weight:600;
  border-bottom:1px solid var(--eye-border); white-space:nowrap; background:var(--surface-2);
}
table.tbl td { padding:8px 12px; border-bottom:1px solid rgba(55,65,81,.5); vertical-align:middle; }
table.tbl tbody tr:hover { background:var(--row-hover); }
table.tbl tbody tr:last-child td { border-bottom:0; }
table.tbl td.num, table.tbl th.num { text-align:right; font-variant-numeric:tabular-nums; }
table.tbl td.actions { text-align:right; white-space:nowrap; }
table.tbl td.mono { font-family:var(--mono); font-size:.78rem; color:var(--eye-muted); }
.pager { display:flex; align-items:center; gap:8px; padding:10px 12px; border-top:1px solid var(--eye-border); }

/* ---------------------------------------------------------------- toasts */
.toasts {
  position:fixed; right:16px; bottom:16px; z-index:80;
  display:flex; flex-direction:column; gap:8px; align-items:flex-end; pointer-events:none;
}
.toast {
  pointer-events:auto; max-width:380px; padding:9px 13px; border-radius:var(--r-md);
  background:var(--surface-3); border:1px solid var(--eye-border); color:var(--eye-text);
  font-size:.83rem; box-shadow:0 8px 24px var(--shadow);
  animation:toast-in .18s ease-out;
}
.toast-ok { border-color:rgba(16,185,129,.5); }
.toast-warn { border-color:rgba(245,158,11,.5); }
.toast-err { border-color:rgba(239,68,68,.55); }
.toast-out { opacity:0; transition:opacity .22s ease; }
@keyframes toast-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
@media (prefers-reduced-motion: reduce) { .toast { animation:none; } }

/* ---------------------------------------------------------------- modals */
.modal-scrim {
  position:fixed; inset:0; z-index:60; background:rgba(3,7,18,.72);
  display:flex; align-items:flex-start; justify-content:center; padding:5vh 16px; overflow-y:auto;
}
.modal { width:100%; max-width:520px; padding:0; }
.modal-wide { max-width:820px; }
.modal-head { display:flex; align-items:center; gap:10px; padding:13px 16px; border-bottom:1px solid var(--eye-border); }
.modal-body { padding:16px; display:flex; flex-direction:column; gap:12px; max-height:64vh; overflow-y:auto; }
.modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:12px 16px; border-top:1px solid var(--eye-border); }

pre.json {
  margin:0; padding:10px 12px; border-radius:var(--r-md); overflow-x:auto;
  background:var(--field-bg); border:1px solid var(--eye-border);
  color:var(--eye-muted); font-size:.76rem; line-height:1.5;
}
.kv { display:grid; grid-template-columns:minmax(110px, auto) 1fr; gap:4px 14px; font-size:.8rem; }
.kv dt { color:var(--eye-subtle); }
.kv dd { margin:0; overflow-wrap:anywhere; }

/* ------------------------------------------------------------------- ptz */
.ptz-layout { display:grid; grid-template-columns:minmax(260px, 320px) 1fr; gap:14px; align-items:start; }
@media (max-width: 780px) { .ptz-layout { grid-template-columns:1fr; } }

.dpad { display:grid; grid-template-columns:repeat(3, 1fr); gap:8px; max-width:250px; margin:0 auto; }
.dpad button {
  aspect-ratio:1; display:flex; align-items:center; justify-content:center;
  border:1px solid var(--eye-border); border-radius:var(--r-md);
  background:var(--surface-3); color:var(--eye-text); cursor:pointer;
  touch-action:none; -webkit-user-select:none; user-select:none; padding:0;
}
.dpad button:hover:not(:disabled) { border-color:var(--eye-cyan); color:var(--eye-cyan); }
.dpad button:disabled { opacity:.4; cursor:not-allowed; }
.dpad button.held { background:rgba(0,212,255,.18); border-color:var(--eye-cyan); color:var(--eye-cyan); }
.dpad .dpad-stop {
  border-color:rgba(239,68,68,.5); background:rgba(239,68,68,.12); color:#FCA5A5;
  font-size:.68rem; letter-spacing:.1em; text-transform:uppercase; font-weight:600;
}
.dpad .dpad-stop:hover:not(:disabled) { background:rgba(239,68,68,.24); border-color:var(--eye-error); color:#FCA5A5; }
.zoom-row { display:grid; grid-template-columns:1fr 1fr; gap:8px; max-width:250px; margin:10px auto 0; }
.zoom-row button { padding:9px; }

.ptz-move-state {
  display:flex; align-items:center; justify-content:center; gap:7px;
  padding:7px; border-radius:var(--r-md); font-size:.78rem;
  border:1px solid var(--eye-border); background:var(--field-bg); color:var(--eye-subtle);
}
.ptz-move-state.is-moving { border-color:var(--eye-warning); background:rgba(245,158,11,.12); color:#FCD34D; }

.readout { display:grid; grid-template-columns:repeat(3, 1fr); gap:10px; }
.readout div { background:var(--field-bg); border:1px solid var(--eye-border); border-radius:var(--r-md); padding:9px 11px; }
.readout .stat-label { margin-bottom:3px; }
.readout .readout-value { font-family:var(--mono); font-size:1.05rem; }

/* --------------------------------------------------------- video surface */
/* The picture itself. Everything here is driven by the `data-state` attribute
   js/video-surface.js sets — never by an inline style, which the CSP forbids
   and util.js's el() throws on. State is ALSO always spelled out in a text
   badge next to the tile (style guide rule 8): the visual treatments below
   are reinforcement, never the only signal. */
.vs {
  position:relative; aspect-ratio:16/9; width:100%; overflow:hidden;
  background:
    repeating-linear-gradient(45deg, #0b1220 0 10px, #0d1526 10px 20px);
  border-radius:var(--r-md);
  display:flex; align-items:center; justify-content:center;
}
.vs-img { width:100%; height:100%; object-fit:contain; display:block; }
.vs-img:not([src]) { visibility:hidden; }
.vs[data-state="error"] .vs-img { opacity:.3; filter:grayscale(1); }
.vs[data-state="error"] { box-shadow:inset 0 0 0 2px rgba(239,68,68,.65); }
.vs[data-state="paused"] .vs-img { opacity:.5; }
.vs[data-state="paused"] { box-shadow:inset 0 0 0 2px rgba(107,114,128,.6); }
.vs[data-state="connecting"] { box-shadow:inset 0 0 0 2px rgba(0,212,255,.35); }

/* Streaming video draws into a <video>; polled JPEG draws into an <img>. Both
   fill the same box, so a WebRTC -> LL-HLS -> snapshot fallback never resizes
   the tile under the operator. */
.vs-video { width:100%; height:100%; object-fit:contain; display:block; background:#000; }
.vs[data-state="error"] .vs-video { opacity:.3; filter:grayscale(1); }
.vs[data-state="paused"] .vs-video { opacity:.5; }

/* The auto surface's stable wrapper. Its child is swapped when the transport
   changes; the wrapper itself is created once and never replaced, which is
   what lets the monitor layout, the tile link and the PTZ overlay keep
   pointing at one element across a fallback. `:empty` carries the placeholder
   box so the tile has its final size before the first transport is chosen —
   otherwise a wall would visibly reflow a moment after it loads. */
.vs-auto { display:block; width:100%; }
.vs-auto:empty {
  aspect-ratio:16/9; border-radius:var(--r-md);
  background:repeating-linear-gradient(45deg, #0b1220 0 10px, #0d1526 10px 20px);
}

/* Transport HUD: which pipe is carrying this picture, and how far behind
   real life it is. Text, never colour alone (style guide rule 8) — the colour
   only reinforces a word that is always spelled out. */
.vs-hud {
  position:absolute; left:6px; bottom:6px; z-index:1;
  display:inline-flex; align-items:center; gap:2px;
  padding:2px 7px; border-radius:999px;
  font:500 11px/1.5 var(--mono);
  letter-spacing:.02em; white-space:nowrap;
  background:rgba(6,12,22,.78); color:#cbd5e1;
  border:1px solid rgba(148,163,184,.25);
  backdrop-filter:blur(2px);
  pointer-events:none;
}
.vs-hud-stats { color:#94a3b8; }
.vs-hud[data-transport="webrtc"] { color:#7dd3fc; border-color:rgba(0,212,255,.45); }
.vs-hud[data-transport="hls"] { color:#a7f3d0; border-color:rgba(52,211,153,.35); }
.vs-hud[data-transport="snapshot"] { color:#fcd34d; border-color:rgba(251,191,36,.35); }
.vs-hud[data-transport="none"] { display:none; }
@media (prefers-reduced-motion: reduce) { .vs-hud { backdrop-filter:none; } }

/* ------------------------------------------------------------ live monitor */
.mon-bar { display:flex; flex-direction:column; gap:10px; }
.mon-field { display:flex; align-items:center; gap:6px; }
.mon-interval { width:auto; min-width:120px; }

.mon-grid {
  display:grid; gap:12px;
  grid-template-columns:repeat(auto-fill, minmax(300px, 1fr));
}
.mon-tile {
  background:var(--eye-surface); border:1px solid var(--eye-border);
  border-radius:var(--r-lg); overflow:hidden; display:flex; flex-direction:column;
}
.mon-tile.is-controlled { border-color:var(--eye-cyan); box-shadow:0 0 0 1px rgba(0,212,255,.35); }
.mon-tile.is-moving { border-color:var(--eye-warning); }

.mon-shot { position:relative; display:block; text-decoration:none; color:inherit; }
.mon-open {
  position:absolute; right:8px; top:8px; padding:2px 8px; border-radius:999px;
  font-size:.68rem; letter-spacing:.08em; text-transform:uppercase;
  background:rgba(15,23,42,.82); border:1px solid var(--eye-border); color:var(--eye-muted);
  opacity:0; transition:opacity .14s ease;
}
.mon-shot:hover .mon-open, .mon-shot:focus-visible .mon-open { opacity:1; }
@media (prefers-reduced-motion: reduce) { .mon-open { transition:none; } }

.mon-meta { padding:9px 11px; display:flex; flex-direction:column; gap:7px; }
.mon-title { display:flex; flex-direction:column; gap:1px; min-width:0; }
.mon-title strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mon-badges { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.mon-state { display:inline-flex; }
.mon-actions { display:flex; gap:6px; }
.mon-speed { min-width:200px; max-width:280px; }
.mon-control.is-on {
  border-color:var(--eye-cyan); background:rgba(0,212,255,.14); color:var(--eye-cyan);
}

/* Single-camera view: picture left, controls right, stacking on narrow screens. */
.mon-single { display:grid; grid-template-columns:minmax(0, 1fr) minmax(280px, 340px); gap:14px; align-items:start; }
@media (max-width: 980px) { .mon-single { grid-template-columns:1fr; } }
.mon-stage { display:flex; flex-direction:column; gap:10px; }
.mon-stage .vs { aspect-ratio:16/9; }

/* ------------------------------------------------------------------- wall */
/* The wall: a fixed monitoring-station display. Minimal on purpose — see
   screens/wall.js's header. Tiles fill their grid cell edge-to-edge (unlike
   .mon-tile, which keeps a 16:9 box and lets a card breathe around it)
   because this screen has no card chrome to breathe into. */
/* The wall owns the whole content area: the view stops padding it and lets it
   grow to the full height between the topbar and the window edge, so the tile
   grid fills the screen instead of collapsing to its tiles' min-height. */
.view:has(.wall) { flex:1 1 auto; min-height:0; }
.wall { display:flex; flex-direction:column; gap:10px; background:var(--eye-bg); flex:1 1 auto; min-height:0; }
.wall:fullscreen { padding:10px; height:100vh; }

.wall-topbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; flex:0 0 auto; }
.wall-field { display:flex; align-items:center; gap:6px; }
.wall-window { width:auto; }

/* The grid host takes every pixel the topbar and footer do not. */
.wall-gridhost { flex:1 1 auto; min-height:0; display:flex; flex-direction:column; }
.wall-foothost { flex:0 0 auto; }

.wall-grid { display:grid; gap:6px; flex:1 1 auto; min-height:0; }
/* Fixed layouts: exact tile counts, arranged the way the spec names them. */
.wall-grid[data-layout="1"] { grid-template-columns:1fr; }
.wall-grid[data-layout="2"] { grid-template-columns:repeat(2, 1fr); }
.wall-grid[data-layout="4"] { grid-template-columns:repeat(2, 1fr); grid-template-rows:repeat(2, 1fr); }
.wall-grid[data-layout="6"] { grid-template-columns:repeat(3, 1fr); grid-template-rows:repeat(2, 1fr); }
/* Large: as many equal columns as fit; auto-fit lets the browser compute the
   row count so it survives a resize/fullscreen toggle with no JS involved.
   `align-content:start` keeps the rows at their 16:9 height instead of
   stretching them to fill the now-flexible grid. */
.wall-grid[data-layout="large"] { grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); align-content:start; overflow-y:auto; }

.wall-tile {
  position:relative; background:#000; border:1px solid var(--eye-border);
  border-radius:var(--r-sm); overflow:hidden; min-height:120px;
}
/* Fixed layouts fill the grid cell's full height, not just a 16:9 slice of
   it — the whole point of a wall is using the screen, and object-fit:contain
   on the surface's own <img>/<video> already keeps the picture undistorted. */
.wall-tile .vs, .wall-tile .vs-auto { position:absolute; inset:0; aspect-ratio:auto; width:100%; height:100%; }
.wall-tile-auto { aspect-ratio:16/9; min-height:0; } /* Large layout: no fixed row to fill, so keep the camera's own shape. */

.wall-empty {
  display:flex; align-items:center; justify-content:center; height:100%;
  color:var(--eye-subtle); font-size:.8rem;
}

/* The assignment control: an invisible <select> covering the whole tile.
   Clicking anywhere opens the native picker; hover/focus reveals a subtle
   frame so a keyboard user (and a sighted operator) can tell the tile is
   assignable at all, without adding chrome the rest of the time. */
.wall-assign {
  position:absolute; inset:0; z-index:2; width:100%; height:100%;
  opacity:0; cursor:pointer; border:none; background:transparent; color:transparent;
}
.wall-tile:hover .wall-assign, .wall-assign:focus {
  opacity:1; background:rgba(15,23,42,.55); color:var(--eye-text);
  outline:2px solid var(--eye-cyan); outline-offset:-2px;
}

/* The hover/focus-only camera label — never shown at rest (style guide: the
   image is the whole of the tile at rest). `:focus-within` covers the
   invisible <select> receiving keyboard focus. */
.wall-label {
  position:absolute; left:6px; bottom:6px; z-index:1;
  padding:2px 7px; border-radius:999px; font-size:.7rem;
  background:rgba(15,23,42,.82); color:#e2e8f0; border:1px solid var(--eye-border);
  opacity:0; pointer-events:none; transition:opacity .12s ease;
}
.wall-tile:hover .wall-label, .wall-tile:focus-within .wall-label { opacity:1; }
@media (prefers-reduced-motion: reduce) { .wall-label { transition:none; } }

/* The always-on overlay caption — a browser-rendered replacement for a
   camera's burnt-in OSD, so that text can be turned off on the camera (where
   it sits in the frame the detector runs on). Rendered per tile from a
   template with {name} {time} {date} {datetime} {camera_id} tokens; the
   1 s clock lives in wall.js. Distinct from .wall-label (the hover chip). */
.wall-overlay {
  position:absolute; z-index:1; max-width:calc(100% - 12px);
  padding:2px 7px; border-radius:var(--r-sm);
  font:600 .72rem/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
  white-space:pre; letter-spacing:.02em;
  background:rgba(15,23,42,.72); color:#f1f5f9;
  text-shadow:0 1px 2px rgba(0,0,0,.9); pointer-events:none;
}
.wall-ov-tl { top:6px; left:6px; }
.wall-ov-tr { top:6px; right:6px; text-align:right; }
.wall-ov-bl { bottom:6px; left:6px; }
.wall-ov-br { bottom:6px; right:6px; text-align:right; }
.wall-overlay[hidden] { display:none; }

.wall-more { padding:6px 2px; color:var(--eye-muted); font-size:.78rem; }

/* Footer: a strip, not a chart — the whole point is that it stays small next
   to the pictures it annotates. */
.wall-footer { display:flex; flex-direction:column; gap:4px; }
.wall-footer-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.wall-trend-key { color:var(--eye-warning); }
/* Fine-grained: many thin bars, hairline gaps, with the moving-average spline
   laid over them. `position:relative` anchors the overlay <svg>. */
.wall-bars { position:relative; display:flex; align-items:flex-end; gap:1px; height:56px; }
.wall-bar { flex:1 1 0; min-width:1px; height:100%; display:flex; align-items:flex-end; text-decoration:none; }
.wall-bar-cell { width:100%; height:100%; display:flex; align-items:flex-end; }
.wall-bar-fill { width:100%; height:var(--h,0%); min-height:1px; background:var(--eye-cyan); opacity:.55; border-radius:1px 1px 0 0; }
.wall-bar:hover .wall-bar-fill, .wall-bar:focus .wall-bar-fill { opacity:1; background:#7dd3fc; }
.wall-trend { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; overflow:visible; }
.wall-trend-line { fill:none; stroke:var(--eye-warning); stroke-width:1.5; stroke-linejoin:round; stroke-linecap:round; opacity:.95; }

/* Live detection-box overlay (detection-overlay.js). Sits above the video,
   below the label/controls; never intercepts pointer events. */
.vs-detections { position:absolute; inset:0; width:100%; height:100%; z-index:1; pointer-events:none; }

/* Per-tile control chip — mirrors the surface's own HUD chip, top-right. */
.wall-ctl {
  position:absolute; top:6px; right:6px; z-index:3;
  display:flex; align-items:center; gap:4px; padding:3px;
  border-radius:999px; background:rgba(15,23,42,.82); border:1px solid var(--eye-border);
  opacity:0; transition:opacity .12s ease;
}
.wall-tile:hover .wall-ctl, .wall-tile:focus-within .wall-ctl, .wall-tile-solo .wall-ctl { opacity:1; }
@media (prefers-reduced-motion: reduce) { .wall-ctl { transition:none; } }
.wall-ctl-btn {
  display:inline-flex; align-items:center; justify-content:center;
  width:24px; height:24px; padding:0; border-radius:999px;
  border:1px solid transparent; background:transparent; color:var(--eye-muted); cursor:pointer;
}
.wall-ctl-btn:hover:not([disabled]) { background:var(--surface-3); color:var(--eye-text); }
.wall-ctl-btn.is-on { background:rgba(0,212,255,.16); color:var(--eye-cyan); border-color:rgba(0,212,255,.35); }
.wall-ctl-btn[disabled] { opacity:.35; cursor:not-allowed; }
/* The record button while a recording is running. Red rather than the cyan
   `is-on` used by every other toggle in this chip: the other toggles change
   how the wall LOOKS, and this one is writing a file to a volume that no
   retention sweep will ever clean up. Those should not share an affordance. */
.wall-ctl-btn.is-rec {
  background:rgba(239,68,68,.20); color:#fca5a5; border-color:rgba(239,68,68,.45);
}
.wall-ctl-btn.is-rec:hover:not([disabled]) { background:rgba(239,68,68,.32); color:#fff; }
.wall-ctl-pick {
  max-width:140px; height:24px; padding:0 6px; font-size:.74rem;
  border-radius:999px; background:var(--field-bg); color:var(--eye-text); border:1px solid var(--eye-border);
}

/* REC badge — top-LEFT, opposite the control chip, and always visible.

   Deliberately not inside `.wall-ctl`, which fades to opacity 0 when the
   pointer leaves: a monitoring station is mostly left alone, and 'is this
   camera being recorded' has to be answerable from across the room rather
   than on hover. The dot pulses so it reads as live at a glance; the
   animation is dropped under prefers-reduced-motion, where the badge is
   still red, still labelled REC and still perfectly legible. */
.wall-rec {
  position:absolute; top:6px; left:6px; z-index:3;
  display:inline-flex; align-items:center; gap:5px;
  padding:3px 8px; border-radius:999px;
  background:rgba(127,29,29,.85); border:1px solid rgba(239,68,68,.55);
  color:#fecaca; font-size:.66rem; font-weight:700; letter-spacing:.08em;
}
.wall-rec-dot { width:7px; height:7px; border-radius:50%; background:#ef4444; animation:eye-rec-pulse 1.6s ease-in-out infinite; }
@keyframes eye-rec-pulse { 0%,100% { opacity:1; } 50% { opacity:.25; } }
@media (prefers-reduced-motion: reduce) { .wall-rec-dot { animation:none; } }

/* Click target over the picture — the whole tile except the chip. */
.wall-tile-hit { position:absolute; inset:0; z-index:2; border:0; background:transparent; cursor:zoom-in; }
.wall-tile:focus-within .wall-tile-hit, .wall-tile:hover .wall-tile-hit { outline:none; }

/* Fill vs contain. video-surface's media is object-fit:contain by default;
   `.wall-fill` switches the tile to cover (crop to fill). */
.wall-fill .vs img, .wall-fill .vs video,
.wall-fill .vs-auto img, .wall-fill .vs-auto video { object-fit:cover; }

/* Single-camera view. */
.wall-solo { display:flex; flex-direction:column; gap:8px; height:100%; min-height:0; }
.wall-solo-bar { display:flex; align-items:center; gap:10px; flex:0 0 auto; }
.wall-solo-name { font-size:.9rem; }
.wall-solo-stage { flex:1 1 auto; min-height:0; display:flex; gap:10px; }
.wall-tile-solo { flex:1 1 auto; min-height:0; }
.wall-ptz {
  flex:0 0 auto; width:220px; display:flex; flex-direction:column; gap:10px;
  padding:12px; border:1px solid var(--eye-border); border-radius:var(--r-md); background:var(--eye-surface);
  overflow-y:auto;
}

/* ------------------------------------------------------- detection settings */
.settings-top { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; margin-bottom:4px; }
.settings-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(230px, 1fr)); gap:12px 18px; }
.settings-check { display:flex; align-items:center; gap:8px; font-size:.85rem; }
.settings-range { display:flex; align-items:center; gap:8px; }
.settings-range input[type=range] { flex:1 1 auto; }
.settings-range-val { min-width:2.5em; text-align:right; font-family:var(--mono); font-size:.8rem; color:var(--eye-muted); }

/* ------------------------------------------------------------- heatmap */
/* The map fills the window, the same way the wall does (see `.view:has(.wall)`
   above): the shell is already a flex column down to `.view`, so one more
   `flex:1 1 auto; min-height:0` chain hands the leftover viewport height to
   the stage instead of letting the page grow past the fold. */
.view:has(.heatmap-root) { flex:1 1 auto; min-height:0; }
/* `flex:1 1 0`, NOT `1 1 auto`, and that zero basis is load-bearing.

   screens/heatmap.js sizes the stage from a ResizeObserver on .heatmap-fit,
   and the stage is .heatmap-fit's own content. With a content-derived basis
   (`auto`) those two form a loop the moment the viewport is too short to have
   any leftover height to distribute: the box takes its size from the stage,
   the observer reports it, the stage grows to match, the box grows with it. A
   zero basis makes these boxes take their height purely from the space left
   over, so the measurement can never be a function of what it produced.

   `min-height` on the fit box is a floor, not a content dependency, so it does
   not reintroduce the loop — it just stops the map from collapsing to nothing
   on a very short window, where the page is simply going to have to scroll.

   The panel carries a matching floor for the fit box PLUS the legend, because
   `.panel-tight` is `overflow:hidden`: without it, a window too short for both
   would shrink the panel and clip the colour key off the bottom rather than
   letting the page scroll. Clipping the thing that explains the picture is the
   one failure mode worth spending a scrollbar to avoid. */
.heatmap-root { flex:1 1 0; min-height:0; }
.heatmap-panel { flex:1 1 0; min-height:250px; display:flex; flex-direction:column; align-items:center; gap:10px; padding:10px; }
.heatmap-fit { flex:1 1 0; min-height:180px; width:100%; display:flex; align-items:center; justify-content:center; }

/* The stage is bounded by the VIEWPORT, not by the column width.

   A full-width 16/9 box runs off the bottom of a 1080p screen, and a map you
   have to scroll to see the bottom of is not a map. `width` therefore takes
   whichever of "the column" and "the tallest box that still fits" is smaller,
   and `aspect-ratio` turns that back into a height — so the frame shrinks to
   fit in whichever direction binds first and is never cropped in either.

   `--heat-ar` is the CAMERA's own aspect, written by screens/heatmap.js from
   the snapshot's naturalWidth/naturalHeight (via setProperty — inline styles
   are CSP-forbidden). It defaults to 16/9 for the case that has no picture at
   all: a caller with `analytics.view` but not `camera.live`, who gets the grid
   over the hatch below instead of over the scene.

   Sizing the box to the real frame is what keeps the overlay HONEST. The grid
   is normalized 0..1 over the camera image, so a 4:3 camera letterboxed inside
   a fixed 16/9 stage would draw every hot cell a third too wide and in the
   wrong place. object-fit:contain would hide that by looking tidy. */
.heatmap-stage {
  --heat-ar: 1.7778;
  /* Overwritten in px by screens/heatmap.js from a ResizeObserver on
     .heatmap-fit — the exact leftover height, rather than a guess at how tall
     the topbar, the filter row and the legend happen to be. This value is only
     what is used for the first frame, before the observer has measured. */
  --heat-maxh: max(280px, calc(100vh - 330px));
  position:relative;
  width:min(100%, calc(var(--heat-maxh) * var(--heat-ar)));
  aspect-ratio:var(--heat-ar);
  background:#0b1220;
  background-image:linear-gradient(var(--eye-border) 1px, transparent 1px), linear-gradient(90deg, var(--eye-border) 1px, transparent 1px);
  background-size:calc(100%/16) calc(100%/9);
  overflow:hidden; border-radius:var(--r-sm);
}
/* `--heat-base-brightness` is the image brightness slider (screens/heatmap.js),
   0 (black) to 2 (double), default 1 (unchanged) — dialling it to 0 is a
   second way to see "only the heatmap", by removing the scene entirely
   rather than fading the overlay up. */
.heatmap-base {
  position:absolute; inset:0; width:100%; height:100%; object-fit:contain;
  filter:brightness(var(--heat-base-brightness, 1));
}
/* `--heat-overlay-opacity` is the transparency slider (screens/heatmap.js);
   defaults to fully opaque so a page that never touches the slider looks
   exactly as it did before the slider existed. */
.heatmap-canvas {
  position:absolute; inset:0; width:100%; height:100%; mix-blend-mode:screen;
  opacity:var(--heat-overlay-opacity, 1);
}
.heatmap-conf { margin-top:2px; }
.heatmap-conf .settings-range { max-width:520px; }

/* The colour key. Below the picture rather than floating over a corner of it:
   the corners of a surveillance frame are where things happen, and a legend is
   read once and then ignored. */
.heatmap-legendhost:empty { display:none; }
.heatmap-key {
  display:flex; align-items:center; gap:8px; flex-wrap:wrap;
  padding:6px 10px; border-radius:var(--r-md);
  border:1px solid var(--eye-border); background:var(--field-bg);
}
.heatmap-key-title { font-size:.74rem; font-weight:600; }
.heatmap-key-end { font-size:.7rem; color:var(--eye-muted); font-family:var(--mono); }
.heatmap-key-strip { display:block; height:10px; border-radius:3px; border:1px solid var(--eye-border); }

/* ------------------------------------------------------------- key legend */
.kbd-hint {
  display:flex; flex-direction:column; gap:8px;
  padding:10px 12px; border-radius:var(--r-md);
  border:1px solid var(--eye-border); background:var(--field-bg);
}
.kbd-hint.is-armed { border-color:rgba(0,212,255,.4); }
.kbd-status { display:inline-flex; align-items:center; gap:5px; font-size:.72rem; color:var(--eye-subtle); }
.kbd-hint.is-armed .kbd-status { color:var(--eye-cyan); }
.kbd-row { display:flex; flex-wrap:wrap; gap:6px 12px; }
.kbd-pair { display:inline-flex; align-items:center; gap:5px; }
.kbd {
  display:inline-flex; align-items:center; justify-content:center;
  min-width:26px; height:22px; padding:0 6px;
  border:1px solid var(--eye-border); border-bottom-width:2px; border-radius:4px;
  background:var(--surface-3); color:var(--eye-text);
  font-family:var(--mono); font-size:.7rem; line-height:1;
}
.kbd.is-down {
  background:rgba(0,212,255,.2); border-color:var(--eye-cyan); color:var(--eye-cyan);
  border-bottom-width:1px; transform:translateY(1px);
}
.kbd-label { font-size:.7rem; color:var(--eye-muted); }

/* ------------------------------------------------------------ placeholder */
.soon {
  display:flex; flex-direction:column; gap:12px; align-items:flex-start;
  border-style:dashed;
}
.soon-mark { color:var(--eye-subtle); }
.soon ul { margin:0; padding-left:20px; color:var(--eye-muted); font-size:.84rem; }
.soon li { margin-bottom:4px; }

/* --------------------------------------------------------- authenticated media */
.media-box {
  position:relative; display:flex; align-items:center; justify-content:center;
  min-height:120px; background:var(--field-bg); border:1px solid var(--eye-border);
  border-radius:var(--r-md); overflow:hidden; padding:8px;
}
.media-box[data-state="error"] { border-color:rgba(239,68,68,.45); }
.media-el { max-width:100%; max-height:60vh; display:block; border-radius:var(--r-sm); }
/* Shrink-wraps the image so the detection overlay aligns to the pixels, not
   to the padded/centred .media-box. */
.media-el-wrap { position:relative; display:inline-block; line-height:0; max-width:100%; }
.media-el-overlay { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; overflow:visible; }
.media-el-tag {
  position:absolute; left:var(--x,0); top:var(--y,0);
  transform:translateY(-100%); margin-top:-1px;
  padding:1px 5px; font-size:.68rem; line-height:1.35; white-space:nowrap;
  border-radius:3px 3px 3px 0; background:var(--tone,#9ca3af); color:#0b1220; font-weight:600;
}
.media-item { display:flex; flex-direction:column; gap:6px; }
.media-item-head { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.media-grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); }

/* ---------------------------------------------------------- filter rows */
/* `.filters` is the control strip on the list screens (people, vehicles,
   tracks, observations, zones, faces). It had no rule at all until now, which
   meant every one of those strips rendered as a vertical stack of full-width
   inputs — the form controls above default to `width:100%`, and a plain
   block container gives them the whole row each. This is the missing rule,
   not a restyle: it wraps them into a line, and the min-width keeps a
   datetime field from collapsing to an unusable sliver on a narrow screen. */
.filters { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
.filters > * { flex:1 1 160px; min-width:150px; max-width:260px; }

/* ---- "Where this came from" (screens/media.js#contextCard) ---------------
   The way back from a crop to the frame and the event it belongs to. */
.media-ctx-events { display:flex; flex-wrap:wrap; gap:8px; }
.media-ctx-event {
  display:inline-flex; align-items:center; gap:8px;
  padding:5px 10px; border-radius:var(--r-md);
  border:1px solid var(--eye-border); background:var(--field-bg);
  text-decoration:none; color:inherit;
}
.media-ctx-event:hover { border-color:rgba(0,212,255,.45); background:var(--surface-3); }

/* ------------------------------------------------------------- crop grids */
/* The person grid (screens/people.js) and the face grid (screens/faces.js),
   both built by js/crop-grid.js.

   The single most important rule here is that `.crop-shot` carries a fixed
   aspect-ratio and its own background. Tiles are created EMPTY and filled
   later, as each one scrolls into view and its authenticated blob arrives —
   so if the box did not already occupy its final size, a wall of tiles would
   reflow under the operator's cursor for as long as the page kept loading.
   Same reasoning as `.vs-auto:empty` on the monitor wall, and the same hatch
   fill, so "nothing here yet" looks the same in both places.

   3:4 rather than 16:9: these are crops of standing people and of faces, both
   of which are taller than they are wide. */
.crop-grid { display:grid; gap:12px; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); }
.crop-tile {
  display:flex; flex-direction:column;
  background:var(--eye-surface); border:1px solid var(--eye-border);
  border-radius:var(--r-md); overflow:hidden;
}
.crop-pic { display:block; text-decoration:none; color:inherit; }
a.crop-pic:hover .crop-shot, a.crop-pic:focus-visible .crop-shot { box-shadow:inset 0 0 0 2px rgba(0,212,255,.5); }
.crop-shot {
  position:relative; aspect-ratio:3/4; width:100%; overflow:hidden;
  display:flex; align-items:center; justify-content:center;
  background:repeating-linear-gradient(45deg, #0b1220 0 10px, #0d1526 10px 20px);
}

/* mediaNode() hands back a `.media-box` built for a detail screen — padded,
   bordered, capped at 60vh. Inside a tile it has to become the tile, so these
   two rules neutralise that chrome for this context only. The element and its
   states are untouched everywhere else. */
.crop-shot .media-box {
  width:100%; height:100%; min-height:0; padding:0;
  border:0; border-radius:0; background:transparent;
}
/* `contain`, not `cover`. A person crop is a tall thin box and the tile is
   3/4, so `cover` filled the tile by cutting the overflow off — which for a
   standing figure means slicing the head off, discarding exactly the part an
   operator is scanning the grid to see. `contain` fits the whole subject and
   letterboxes the remainder against the tile's hatched background, which is
   there precisely to make that padding read as intentional. */
.crop-shot .media-el { width:100%; height:100%; max-width:none; max-height:none; object-fit:contain; border-radius:0; }
.crop-shot .notice { margin:6px; font-size:.68rem; }

/* ---- the capture library (screens/_captures.js) -------------------------

   Same grid, three differences, all of them consequences of what a capture
   IS rather than styling preference:

   1. 16/9, not 3/4. A person or face crop is a tall box cut out of a frame;
      a capture is the whole frame, and letterboxing a widescreen still into
      a portrait tile would waste half the grid to show a smaller picture.
   2. `cover`, not `contain`. Nothing is cut off that matters here — the tile
      is a thumbnail of a whole scene, and the detail view is one click away —
      so filling the tile reads better than hatched bars around every frame.
   3. Actions layered over the picture. They fade in on hover like the wall's
      control chip, and are always visible on touch/keyboard focus, where
      there is no hover to fade in from. */
.crop-shot-wide { aspect-ratio:16/9; }
.crop-shot-wide .media-el { object-fit:cover; }
.crop-frame { position:relative; }
.crop-actions {
  position:absolute; top:6px; right:6px; z-index:3;
  display:flex; gap:4px; padding:3px;
  border-radius:999px; background:rgba(15,23,42,.82); border:1px solid var(--eye-border);
  opacity:0; transition:opacity .12s ease;
}
.crop-tile:hover .crop-actions, .crop-tile:focus-within .crop-actions { opacity:1; }
@media (hover: none) { .crop-actions { opacity:1; } }
@media (prefers-reduced-motion: reduce) { .crop-actions { transition:none; } }
.crop-act {
  display:inline-flex; align-items:center; justify-content:center;
  width:24px; height:24px; padding:0; border-radius:999px;
  border:1px solid transparent; background:transparent; color:var(--eye-muted);
  cursor:pointer; text-decoration:none;
}
.crop-act:hover { background:var(--surface-3); color:var(--eye-text); }
.crop-act-danger:hover { background:rgba(239,68,68,.22); color:#fca5a5; }
.crop-act-rec { color:#fca5a5; background:rgba(239,68,68,.20); border-color:rgba(239,68,68,.45); }
.crop-act-rec:hover { background:rgba(239,68,68,.34); color:#fff; }


/* Status-only tiles: rows where the pipeline produced no picture and said
   why. They are deliberately NOT hidden — "the detector ran here and found
   nothing" is the evidence that makes the tiles which do exist believable —
   so the reason gets the space the picture would have had. */
.crop-none {
  padding:8px; text-align:center; font-size:.72rem; line-height:1.35;
  color:var(--eye-subtle); text-transform:lowercase;
}

.crop-cap { padding:7px 9px; display:flex; flex-direction:column; gap:4px; min-width:0; }
.crop-cap-title { font-size:.8rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.crop-cap-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }

/* The Table/Grid view switch. The active side is a filled button rather than
   only a colour change, and the buttons also carry aria-pressed, so which
   view is showing is never conveyed by hue alone (style guide rule 8). */
.mode-on {
  background:rgba(0,212,255,.14); border-color:rgba(0,212,255,.45); color:var(--eye-cyan);
}
.mode-on:hover:not(:disabled) { background:rgba(0,212,255,.2); border-color:var(--eye-cyan); }

/* The consequences list in the biometric enable dialog. Plain, readable
   prose — this is the one place in the UI where an operator is being asked to
   agree to something, so it is not compressed into chips or badges. */
.warn-list { margin:0; padding-left:20px; color:var(--eye-muted); font-size:.8rem; }
.warn-list li { margin-bottom:6px; }

/* ---------------------------------------------------------------- detail views */
.detail-grid { display:grid; gap:14px; grid-template-columns:minmax(0, 1.3fr) minmax(260px, 1fr); align-items:start; }
@media (max-width: 980px) { .detail-grid { grid-template-columns:1fr; } }
.bbox-note { font-size:.72rem; color:var(--eye-subtle); font-family:var(--mono); }

/* -------------------------------------------------------------- zone drawing */
.zc-wrap { display:flex; flex-direction:column; gap:10px; }
.zc-stage {
  position:relative; width:100%; background:#0b1220;
  border:1px solid var(--eye-border); border-radius:var(--r-md); overflow:hidden;
}
.zc-img { position:absolute; inset:0; width:100%; height:100%; object-fit:fill; display:block; }
.zc-svg { position:absolute; inset:0; width:100%; height:100%; cursor:crosshair; }
/* stroke-width here is in SCREEN PIXELS, not user units, because of
   `vector-effect:non-scaling-stroke` — the spec makes that property govern
   stroke-width, dasharray and dashoffset in screen space. These were
   originally fractions (.0035 etc), sized for the `0 0 1 1` viewBox, which
   under non-scaling-stroke meant a stroke ~0.0035 of a pixel wide: the shape
   was drawn correctly and was simply invisible. Pixels are also the right
   unit for the job — a boundary line should stay equally legible whether the
   editor is 400 px or 1200 px wide. */
.zc-context-shape { fill:rgba(107,114,128,.12); stroke:var(--eye-subtle); stroke-width:1.5px; stroke-dasharray:6 4; vector-effect:non-scaling-stroke; }
.zc-active-shape { fill:rgba(0,212,255,.16); stroke:var(--eye-cyan); stroke-width:2.5px; stroke-linejoin:round; paint-order:stroke; vector-effect:non-scaling-stroke; }
.zc-marker { fill:var(--eye-cyan); stroke:#04202b; stroke-width:1.5px; cursor:grab; vector-effect:non-scaling-stroke; }
.zc-marker:hover { fill:#fff; }
.zc-marker-label {
  fill:var(--eye-text); font-family:var(--mono);
  paint-order:stroke; stroke:#000; stroke-width:.015; pointer-events:none;
}
.zc-status { margin:0; }
.zc-points { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:4px; max-height:180px; overflow-y:auto; }
.zc-point-row { display:flex; align-items:center; gap:8px; padding:4px 6px; border-radius:var(--r-sm); background:var(--field-bg); }
.zc-point-row code { flex:0 0 18px; text-align:center; color:var(--eye-cyan); }
.zc-point-row .mono { flex:1 1 auto; }

/* --------------------------------------------------------------- plate search */
.plate-search { display:flex; flex-direction:column; gap:10px; padding:12px; border:1px solid var(--eye-border); border-radius:var(--r-md); background:var(--field-bg); }
.plate-hits { display:flex; flex-wrap:wrap; gap:6px; }

/* A row that navigates on click must look like it does — a bare <tr> offers
   no affordance, so an operator cannot tell a clickable row from a static
   one. Applied by util.js's table() only when an onRowClick was supplied, so
   read-only tables keep the default cursor and do not lie about being
   interactive. */
table.tbl tbody tr.row-clickable { cursor:pointer; }

/* --------------------------------------------------------------- meters
   A <progress>, not a div with an inline width: `style=` is forbidden by the
   CSP (see the header of this file), and value/max are real attributes that
   also hand the bar an accessible value for free. Used by the storage screen
   for volume usage and per-class share.

   Colour is not load-bearing here — every meter in this UI is rendered
   alongside the same number in text and, where it means a state, a badge with
   a word in it (style guide rule 8). */
progress.meter { -webkit-appearance:none; appearance:none; width:100%; height:8px; border:0; display:block; }
progress.meter::-webkit-progress-bar { background:var(--field-bg); border:1px solid var(--eye-border); border-radius:999px; }
progress.meter::-webkit-progress-value { background:linear-gradient(90deg, var(--eye-blue), var(--eye-cyan)); border-radius:999px; }
progress.meter::-moz-progress-bar { background:linear-gradient(90deg, var(--eye-blue), var(--eye-cyan)); border-radius:999px; }

/* Plate timeline histogram. Deliberately hand-rolled rather than a charting
   library: the CSP forbids a CDN and vendoring a whole dependency for one bar
   chart is not a trade worth making. Bar height is data, so plates.js sets
   --h through the CSSOM — a style attribute in markup would be blocked. */
.histogram { display:flex; align-items:flex-end; gap:3px; min-height:96px; padding:8px 0; overflow-x:auto; }
.histogram-bar { display:flex; flex-direction:column; justify-content:flex-end; align-items:center; min-width:22px; height:96px; }
.histogram-fill { width:100%; height:var(--h,0%); min-height:2px; background:var(--eye-cyan); border-radius:2px 2px 0 0; }
.histogram-label { margin-top:4px; font-size:.65rem; color:var(--eye-muted); white-space:nowrap; }

/* ================================================================ charts
   Layout and furniture for html/admin/js/charts.js. The plot boxes get their
   height from a CLASS, never an inline style: ECharts measures its container
   at init, so the height has to exist in CSS before the library ever sees the
   element. (The library then sets sizes on its own inner nodes through
   element.style, which is CSSOM and not what style-src governs.) */

.chart-grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fit, minmax(340px, 1fr)); align-items:start; }
.chart-grid > .chart-span { grid-column:1 / -1; }

.chart-figure {
  margin:0; padding:14px 16px 12px;
  background:var(--eye-surface); border:1px solid var(--eye-border); border-radius:var(--r-lg);
  display:flex; flex-direction:column; gap:10px; min-width:0;
}
.chart-head { display:flex; flex-direction:column; gap:2px; }
.chart-title { margin:0; font-size:.92rem; font-weight:600; letter-spacing:.01em; }
.chart-note { margin:0; font-size:.72rem; color:var(--eye-muted); }

.chart-plot { width:100%; min-width:0; }
.chart-sm { height:130px; }
.chart-md { height:210px; }
.chart-lg { height:280px; }
.chart-xl { height:400px; }

/* The honest-empty state. A chart of nothing must not look like a chart of
   zeroes, so it is not drawn at all — this replaces the plot entirely. The
   dashed border is the same signal .stat-pending uses for "no data here". */
.chart-empty {
  display:flex; align-items:center; justify-content:center; gap:8px;
  min-height:120px; padding:18px 14px; text-align:center;
  border:1px dashed var(--eye-border); border-radius:var(--r-md);
  background:var(--field-bg); color:var(--eye-muted); font-size:.82rem;
}
.chart-empty-mark { color:var(--eye-subtle); font-size:1.1rem; }

/* Data-table fallback under every chart. Collapsed by default so it does not
   compete with the graphic, present always so the numbers are reachable as
   text by a screen reader, by find-in-page and by anyone who simply wants the
   value rather than the shape. */
.chart-data { margin-top:2px; }
.chart-data > summary { font-size:.72rem; color:var(--eye-subtle); cursor:pointer; }
.chart-data > summary:hover { color:var(--eye-muted); }
.chart-data table.tbl { font-size:.76rem; }
.chart-data .table-wrap { max-height:260px; overflow:auto; }

/* ECharts renders its tooltip as HTML in the document, so these apply. */
.tip-dim { color:var(--eye-subtle); font-size:.72rem; }

/* --------------------------------------------------------- range selector
   A radio group, not buttons: the three windows are mutually exclusive and
   exactly one is always chosen, which is what a radio group means to
   assistive technology. The inputs are visually replaced by their labels but
   remain focusable and remain the thing that holds the state. */
.seg { display:inline-flex; border:1px solid var(--eye-border); border-radius:var(--r-sm); overflow:hidden; background:var(--surface-3); }
.seg input { position:absolute; opacity:0; pointer-events:none; width:0; height:0; }
.seg label {
  padding:5px 11px; font-size:.76rem; color:var(--eye-muted); cursor:pointer;
  border-right:1px solid var(--eye-border); user-select:none; line-height:1.2;
}
.seg label:last-of-type { border-right:0; }
.seg label:hover { background:var(--row-hover); color:var(--eye-text); }
.seg input:checked + label { background:rgba(0,212,255,.12); color:var(--eye-cyan); font-weight:600; }
.seg input:focus-visible + label { outline:2px solid var(--eye-cyan); outline-offset:-2px; }

/* ------------------------------------------------------------ dashboard */
.dash-meta { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:.72rem; color:var(--eye-subtle); }
.dash-section { margin:0 0 10px; font-size:.68rem; letter-spacing:.16em; text-transform:uppercase; color:var(--eye-subtle); font-weight:600; }

/* ---------------------------------------------------- NVR playback screen */
/* /admin/playback: the video, its transport row, and the coverage timeline.
   The bar's segments are positioned from data (left/width %) in JS — the one
   place inline geometry is unavoidable, and style-src already permits it. */
.pb-panel { padding:10px; display:grid; gap:10px; }
.pb-video {
  width:100%; max-height:70vh; background:#000;
  border-radius:var(--r-sm); border:1px solid var(--eye-border);
}
.pb-controls { align-items:center; gap:6px; flex-wrap:wrap; }
.pb-clock { font:600 .8rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; color:var(--eye-text); }

.pb-timeline { display:grid; gap:6px; }
.pb-scale, .pb-legend { align-items:center; }
.pb-key { gap:6px; align-items:center; }
.pb-ev-swatch { width:9px; height:9px; border-radius:2px; display:inline-block; }

/* Taller than the original 34px: the bar is the primary control on this
   screen — it is clicked to seek and it carries the event ticks — and a thin
   strip is both hard to hit and hard to read. */
.pb-bar {
  position:relative; height:64px; border-radius:var(--r-sm);
  background:repeating-linear-gradient(45deg, #0b1120, #0b1120 6px, #0d1526 6px, #0d1526 12px);
  border:1px solid var(--eye-border); cursor:crosshair; overflow:hidden;
}
.pb-bar:focus-visible { outline:2px solid var(--eye-cyan); outline-offset:2px; }
.pb-seg {
  position:absolute; top:0; bottom:0;
  background:linear-gradient(180deg, rgba(34,211,238,.55), rgba(34,211,238,.30));
  border-left:1px solid rgba(34,211,238,.65);
}

/* Event marks. Full-height hairlines so a single event in a 7-day window is
   still findable, with a wider transparent hit area so they can be clicked. */
.pb-ev {
  position:absolute; top:0; bottom:0; width:2px; margin-left:-1px;
  cursor:pointer; z-index:2;
}
.pb-ev::after {
  content:""; position:absolute; top:0; bottom:0; left:-4px; right:-4px;
}
.pb-ev-alert { background:#f87171; box-shadow:0 0 4px rgba(248,113,113,.9); }
.pb-ev-warn  { background:#fbbf24; box-shadow:0 0 4px rgba(251,191,36,.8); }
.pb-ev-info  { background:#38bdf8; box-shadow:0 0 4px rgba(56,189,248,.7); }
.pb-ev:hover { width:4px; margin-left:-2px; }
.pb-ev-swatch.pb-ev-alert { background:#f87171; }
.pb-ev-swatch.pb-ev-warn  { background:#fbbf24; }
.pb-ev-swatch.pb-ev-info  { background:#38bdf8; }

.pb-cursor {
  position:absolute; top:-3px; bottom:-3px; width:2px; z-index:3;
  background:#f8fafc; box-shadow:0 0 0 1px rgba(15,23,42,.9);
}

/* ---- the time axis (screens/playback.js#axisTicks) -----------------------

   Hairlines across the bar plus labels beneath it, at round wall-clock times
   whose spacing rescales with the selected window. z-index 1: above the
   coverage fill so a gridline stays readable over recorded stretches, below
   the event marks (2) and the playhead (3), which are what an operator is
   actually hunting for.

   Nothing here takes pointer events. The bar is a click-to-seek control over
   its whole width, and a label that swallowed the click at exactly the moment
   it names would be an unusually cruel bug. */
.pb-grid {
  position:absolute; top:0; bottom:0; width:1px; z-index:1;
  background:rgba(148,163,184,.28); pointer-events:none;
}
.pb-axis { position:relative; height:14px; pointer-events:none; }
.pb-tick {
  position:absolute; top:0; transform:translateX(-50%);
  font:.66rem/1.2 var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
  color:var(--eye-muted); white-space:nowrap;
}
/* The outermost labels are pinned inside the track instead of centred, so
   neither hangs off the edge of the panel. */
.pb-tick-start { transform:none; }
.pb-tick-end { transform:translateX(-100%); }

/* The staleness banner. Sits above the bar, because "nothing was recorded for
   the last three hours" changes how every other mark on this timeline should
   be read. */
.pb-stale { margin:0; }

/* ------------------------------------------------------------ zone editor */
/* `.zone-editor*` are emitted by screens/zones.js and had NO rules at all —
   the canvas and the fields simply inherited whatever the modal gave them.
   `.modal-body` is a `flex-direction:column` container with `max-height:64vh`,
   so the unstyled editor was a shrinkable flex item wrapping an
   aspect-ratio-sized stage with no height floor: it could compress to nothing,
   taking the snapshot and the drawn zone with it. Hence both the explicit
   two-column layout AND the `min-height` on the stage. */
.zone-editor {
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  gap: 14px;
  align-items: start;
}
@media (max-width: 720px) {
  .zone-editor { grid-template-columns: minmax(0, 1fr); }
}
/* min-width:0 is load-bearing in a grid track: without it the canvas column
   refuses to shrink below its content's intrinsic width and pushes the fields
   off the dialog. */
.zone-editor-canvas { min-width: 0; }
.zone-editor-fields { min-width: 0; display: flex; flex-direction: column; gap: 10px; }

/* The floor. aspect-ratio alone gives height only if the width resolves; this
   guarantees the operator always has something to click on. */
.zone-editor .zc-stage { min-height: 220px; }
