:root {
  color-scheme: dark;
  --bg: #0b0e13; --panel: #131820; --panel-2: #1a2029; --line: #262e3a; --line-2: #313b4a;
  --text: #e7ebf1; --muted: #8b97a8; --faint: #5c6878;
  --accent: #34d399; --accent-ink: #052e22; --accent-soft: rgba(52, 211, 153, .14);
  --warn: #fbbf24; --danger: #f87171; --danger-soft: rgba(248, 113, 113, .14); --info: #60a5fa;
  --tile: #05070a; --shadow: 0 10px 30px rgba(0, 0, 0, .45);
  --r: 10px; --bar: 52px;
  /* Timeline (timeline.js): event-kind and per-camera-lane colours. Qualitative/categorical, not semantic
   * state, so they intentionally don't get separate light-theme values (their job is telling lanes/kinds
   * apart, not matching light/dark mood) — defined once here, read via getComputedStyle at draw time so
   * they're real tokens instead of hex literals baked into the canvas code. line/intrusion/tamper already
   * equal --danger exactly, so they reuse it rather than duplicating the value under a new name. */
  --ev-motion: #eab308; --ev-videoloss: #6b7280; --ev-bookmark: #22d3ee;
  --cam-1: #60a5fa; --cam-2: #f472b6; --cam-3: #34d399; --cam-4: #fb923c;
  /* On-video overlay chrome: badges, gradients and buttons painted directly over live camera footage.
   * Fixed black/white regardless of theme, on purpose — the video underneath never re-themes, so overlay
   * chrome that did would read as broken, not adaptive. Consolidated here from what were five slightly-
   * different ad-hoc black opacities and two ad-hoc white-button-on-video opacities scattered through the
   * file (audited, not assumed — the previous values are unchanged, this only names and centralizes the
   * ones that were exact duplicates in three or more places). See DESIGN.md's Colors section. */
  --ov-scrim-soft: rgba(0, 0, 0, .4); --ov-scrim: rgba(0, 0, 0, .5); --ov-scrim-strong: rgba(0, 0, 0, .55);
  --ov-ink: #fff; --ov-roi: rgba(59, 130, 246, .12);
  --ov-btn-bg: rgba(255, 255, 255, .08); --ov-btn-bg-hover: rgba(255, 255, 255, .16);
  --ov-danger: #dc2626; /* on-video tamper/video-loss badges — fixed, not --danger: badges over the same
   * clip shouldn't change colour when the operator's app theme does, and this predates and no longer
   * matches either theme's --danger value */
  /* General modal backdrop (dialogs, popovers-as-scrim) — a different concept from the on-video family
   * above even though it happens to share .55's exact opacity today; kept as its own token rather than
   * folded into --ov-scrim-strong so the two can diverge later without one accidentally dragging the other. */
  --scrim: rgba(0, 0, 0, .55);
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
/* Light theme values below are the darkest each hue can go while keeping its identity (same hue/
   saturation, only lightness reduced), tuned to the minimum shift that clears WCAG AA — verified directly
   against every real usage (button-fill text, text-on-bg, text-on-panel), not eyeballed: accent #059669 ->
   #048059 (was 3.77:1 as white button text, 3.45:1 as text-on-bg; now 4.96/4.54), faint #8f9bab -> #808ea0
   (was 2.58:1, below even the 3:1 UI-component floor; now 3.05), danger #dc2626 -> #da2323 (was 4.42:1
   text-on-bg, just under AA; now 4.53). Dark theme (the default) already cleared AA on every pair checked. */
:root[data-theme="light"] { color-scheme: light; --bg: #f3f5f8; --panel: #fff; --panel-2: #f0f3f7; --line: #dfe4ec; --line-2: #c9d1dd;
  --text: #16202e; --muted: #5d6b7e; --faint: #808ea0; --accent: #048059; --accent-ink: #fff; --accent-soft: rgba(4, 128, 89, .12);
  --warn: #b45309; --danger: #da2323; --danger-soft: rgba(218, 35, 35, .1); --info: #2563eb; --shadow: 0 10px 30px rgba(16, 24, 40, .14); }
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) { color-scheme: light; --bg: #f3f5f8; --panel: #fff; --panel-2: #f0f3f7; --line: #dfe4ec; --line-2: #c9d1dd;
    --text: #16202e; --muted: #5d6b7e; --faint: #808ea0; --accent: #048059; --accent-ink: #fff; --accent-soft: rgba(4, 128, 89, .12);
    --warn: #b45309; --danger: #da2323; --danger-soft: rgba(218, 35, 35, .1); --info: #2563eb; --shadow: 0 10px 30px rgba(16, 24, 40, .14); }
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.45; -webkit-font-smoothing: antialiased; overflow: hidden; accent-color: var(--accent); } /* browser-default form controls (range sliders, radios) otherwise render in the browser's own blue, not Sentinel Green — checkboxes already set this per-element (kept, redundant but harmless), sliders never did */
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
svg.i { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; }
[hidden] { display: none !important; }

/* ---------- shell ---------- */
#app { height: 100%; display: flex; flex-direction: column; }
.topbar { height: var(--bar); flex: none; display: flex; align-items: center; gap: 14px; padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 650; letter-spacing: .01em; }
.brand-mark { width: 26px; height: 26px; border-radius: 8px; background: #111827; display: grid; place-items: center; }
.brand-mark::before { content: ""; width: 12px; height: 12px; border-radius: 50%; border: 2.5px solid var(--accent); box-shadow: inset 0 0 0 2px #111827, inset 0 0 0 5px var(--accent); }
.nav { display: flex; gap: 2px; margin-left: 6px; }
.nav a { display: flex; align-items: center; gap: 7px; padding: 7px 12px; border-radius: 8px; color: var(--muted); text-decoration: none; font-weight: 500; }
.nav a:hover { background: var(--panel-2); color: var(--text); }
.nav a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
.spacer { flex: 1; }
.tools { display: flex; align-items: center; gap: 8px; }
.clock { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; min-width: 190px; text-align: right; white-space: nowrap; }

/* ---------- buttons & controls ---------- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 34px; padding: 0 13px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--panel-2); font-weight: 500; white-space: nowrap; }
.btn:hover { border-color: var(--faint); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
.btn.primary:hover { filter: brightness(1.08); }
/* .spin's default ring/moving-segment colors (white-alpha + accent) are tuned for a dark tile/panel
   background — inside .btn.primary that background IS var(--accent), so the accent-colored moving segment
   nearly disappears into its own button. Swap to accent-ink (the button's own text color, already chosen
   to contrast with accent in both themes) whenever a spinner sits inside a primary button. */
.btn.primary .spin { border-color: color-mix(in srgb, var(--accent-ink) 22%, transparent); border-top-color: var(--accent-ink); }
.btn.danger { color: var(--danger); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn.ghost:hover { background: var(--panel-2); color: var(--text); }
.btn.icon { width: 34px; padding: 0; }
.btn.sm { height: 28px; padding: 0 9px; font-size: 13px; }
.btn.sm.icon { width: 28px; padding: 0; }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.seg { display: inline-flex; padding: 2px; border-radius: 9px; background: var(--panel-2); border: 1px solid var(--line); }
.seg button { border: 0; background: transparent; height: 28px; padding: 0 11px; border-radius: 7px; color: var(--muted); font-weight: 500; }
.seg button[aria-pressed="true"] { background: var(--panel); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .3); }
/* white-space: nowrap — a pill has a fixed 24px height and no min-width; without this, a pill squeezed by
   its flex siblings (found directly: Playback's "X/4 recorder sessions" pill under a crowded touch-device
   topline) wraps its text to two lines that overflow past the pill's own rounded background instead of the
   pill just staying narrow, which is what actually happens to everything else in a flex row by default. */
.pill { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--line); font-size: 12px; color: var(--muted); white-space: nowrap; flex: none; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
.dot.live { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.dot.wait { background: var(--warn); animation: pulse 1.2s infinite; }
.dot.off { background: var(--danger); }
@keyframes pulse { 50% { opacity: .35; } }
.menu-wrap { position: relative; }
.menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; min-width: 220px; padding: 6px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line-2); box-shadow: var(--shadow); }
/* openPopover() in ui.js: appended straight to <body> so an ancestor's overflow:hidden (grid tiles, video
   panes) can never clip it — position is set inline (viewport coordinates), not by this top/right pair. */
.popover-portal { position: fixed; top: auto; right: auto; z-index: 200; }
.enh-menu { min-width: 150px; display: flex; flex-direction: column; gap: 2px; }
.enh-menu button { text-align: left; height: 30px; padding: 0 9px; border-radius: 7px; color: var(--text); font-size: 12.5px; }
.enh-menu button:hover { background: var(--panel-2); }
.enh-menu button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
/* The "wand" L0 panel (enhancePanel.js) — wider than a plain preset list since it holds a slider per
   parameter, shared by the Live grid tile, Live focus view and every Playback pane's popover. */
.enh2-panel { min-width: 260px; max-width: 300px; max-height: min(80vh, 620px); overflow-y: auto; gap: 10px; }
.enh2-status { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--muted); padding: 2px 2px 4px; }
.enh2-status.active { color: var(--accent); }
.enh2-status.active::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: none; }
.enh2-presets { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 2px; }
.enh2-presets button { flex: none; height: 26px; padding: 0 10px; border-radius: 99px; background: var(--panel-2); color: var(--text); font-size: 12px; }
.enh2-presets button:hover { background: var(--accent-soft); color: var(--accent); }
.enh2-presets button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.enh2-group-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 4px 0 2px; }
.enh2-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 2px 8px; height: 26px; font-size: 12.5px; }
.enh2-row span:first-child { grid-column: 1; }
.enh2-row input[type=range] { grid-column: 1 / -1; grid-row: 2; width: 100%; margin: 0; }
.enh2-row { grid-template-rows: auto auto; height: auto; padding: 3px 0; }
.enh2-val { grid-column: 2; grid-row: 1; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 11.5px; }
.enh2-tools { display: flex; flex-direction: column; gap: 4px; }
.enh2-tools button { width: 100%; justify-content: flex-start; }
.enh2-tools button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
.menu .lay { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
.menu .lay button { display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 9px 4px 7px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted); font-size: 12px; }
.menu .lay button:hover { background: var(--panel-2); color: var(--text); }
.menu .lay button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }

/* ---------- live view ---------- */
.liveview { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
.subbar { flex: none; display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: var(--bg); flex-wrap: wrap; }
/* Unlike Playback's topline/Live's focus-bar, this stays in normal flow even in fullscreen (never floats) —
   but the .liveview it's the first child of still reaches the true top of a fullscreened screen, so it
   needs the same safe-area push the others get, for the same reason. */
.liveview:fullscreen .subbar { padding-top: max(8px, env(safe-area-inset-top)); }
.pager { display: flex; align-items: center; gap: 8px; color: var(--muted); font-variant-numeric: tabular-nums; }
.pager .dots { display: flex; gap: 5px; }
.pager .dots button { width: 8px; height: 8px; padding: 0; border-radius: 50%; border: 0; background: var(--line-2); }
.pager .dots button[aria-current="true"] { background: var(--accent); }
.wall { flex: 1; min-height: 0; display: grid; gap: 4px; padding: 4px; }
.tile { position: relative; min-width: 0; min-height: 0; background: var(--tile); border-radius: 8px; overflow: hidden; border: 1px solid var(--line); }
.tile.empty { display: grid; place-items: center; color: var(--faint); border-style: dashed; background: transparent; }
/* a grid tile on loan to the large view: its own chrome (click-to-focus hitzone, name bar, buttons) is
   replaced by the focus view's — the tile itself (and its live connection) keeps running underneath */
.tile.in-focus { border: 0; border-radius: 0; }
.tile.in-focus .hit, .tile.in-focus .ov, .tile.in-focus .tile-actions, .tile.in-focus > .zoomtag, .tile.in-focus .ev-badges { display: none; }
.stage { position: absolute; inset: 0; container-type: size; overflow: hidden; display: grid; place-items: center; }
.stage > * { grid-area: 1 / 1; }
/* The player is a box with the picture's real shape (--ar), sized to fit (or fill) the tile. The video is stretched
   into it, so SD (often a squeezed 2:1 frame) and HD show the same picture in the same place. */
.stage cam-player { transform: translate(var(--zx, 0px), var(--zy, 0px)) scale(var(--zs, 1)); transform-origin: center; position: relative; display: block; aspect-ratio: var(--ar, 1.7778); width: min(100cqw, calc(100cqh * var(--ar, 1.7778))); }
/* .focus is a separate overlay (not inside .wall — see live.js openFocus's own comment on why), so the
   temporary fit/fill toggle needs its own selector here too, not just the grid's .wall.fill. */
.wall.fill .stage cam-player, .focus.fill .stage cam-player { width: max(100cqw, calc(100cqh * var(--ar, 1.7778))); }
.stage cam-player video { width: 100%; height: 100%; object-fit: fill; background: #000; display: block; pointer-events: none; }
.stage cam-player.pending { visibility: hidden; }
/* Scoped to .stage (live grid tiles / focus view) specifically — this used to be a bare .enh-canvas rule,
   which also matched playback's .pb-pane .enh-canvas (a completely different layout: no .pb-pic ancestor
   transform to combine with) since class selectors aren't scoped to their intended context by default.
   There it added a SECOND transform on top of the one the pane's own zoom already applies to its .pb-pic
   wrapper, compounding to the zoom factor squared — confirmed directly (223% zoom measured as ~497%
   effective) before finding this. */
.stage .enh-canvas { transform: translate(var(--zx, 0px), var(--zy, 0px)) scale(var(--zs, 1)); transform-origin: center; position: relative; display: block; aspect-ratio: var(--ar, 1.7778); width: min(100cqw, calc(100cqh * var(--ar, 1.7778))); pointer-events: none; z-index: 2; }
.wall.fill .stage .enh-canvas, .focus.fill .stage .enh-canvas { width: max(100cqw, calc(100cqh * var(--ar, 1.7778))); }
.stage.zanim .enh-canvas { transition: transform .2s ease; }
.veil { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; text-align: center; padding: 16px; color: #cbd5e1; background: rgba(5, 7, 10, .78); pointer-events: none; z-index: 2; }
.veil .msg { max-width: 34ch; font-size: 13px; }
.spin { width: 22px; height: 22px; border-radius: 50%; border: 2.5px solid rgba(255, 255, 255, .18); border-top-color: var(--accent); animation: spin .8s linear infinite; flex: none; }
.spin.sm { width: 14px; height: 14px; border-width: 2px; }
@keyframes spin { to { transform: rotate(360deg); } }
.ov { position: absolute; left: 0; right: 0; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 7px 9px; color: #fff; pointer-events: none; }
.ov.top { top: 0; background: linear-gradient(#000a, transparent); }
.ov.bottom { bottom: 0; background: linear-gradient(transparent, #000a); font-size: 11.5px; color: #cdd5df; font-variant-numeric: tabular-nums; opacity: 0; transition: opacity .15s; }
.tile:hover .ov.bottom, .focus:hover .ov.bottom { opacity: 1; }
/* Hover-to-reveal has no touch equivalent — a device that can't hover just never showed this at all, not a
   graceful degradation. (hover: none) is what actually distinguishes "no real pointing device that can
   hover" from "a mouse user whose cursor happens to be elsewhere," which no amount of touch event handling
   on the element itself can substitute for (there's nothing to "start touching" until the tap that would
   already be the action). Applies to every hover-reveal control below, not just this one. */
@media (hover: none) { .ov.bottom { opacity: 1; } }
.ov .name { font-weight: 600; text-shadow: 0 1px 2px #000; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.ov .grow { flex: 1; }
.tag { padding: 1px 6px; border-radius: 5px; background: var(--ov-btn-bg-hover); font-size: 11px; font-weight: 600; letter-spacing: .03em; }
.tag.hd { background: var(--accent); color: var(--accent-ink); }
/* Small "a live filter is active here" indicator — same pill style as HD/SD so it reads as part of the
   same family of at-a-glance badges, icon-only + accent-colored so it's distinct at this size. */
.tag.fx { display: inline-flex; align-items: center; padding: 2px 5px; color: var(--accent); }
.tag.fx .i { width: 12px; height: 12px; }
/* left AND right (not just right) gives this an actual defined width to expand within — needed so the
   group below can size itself against a real number instead of an auto/shrink-to-fit container, which had
   no ceiling of its own and let the expanded group's content spill past the tile's own left edge on
   anything narrower than ~340px (found directly: it rendered over the *next* tile, not clipped by this
   one's own overflow:hidden, since getBoundingClientRect for an absolutely-positioned child reports its
   laid-out position regardless of who ends up visually clipping it — the fix is giving the layout itself a
   real ceiling, not just hoping overflow catches it). justify-content keeps the toggle pinned to the right
   edge whether collapsed or expanded, same as before. */
.tile-actions { position: absolute; top: 6px; left: 6px; right: 6px; z-index: 5; display: flex; align-items: center; justify-content: flex-end; gap: 4px; opacity: 0; pointer-events: none; transition: opacity .15s; }   /* hidden buttons must not swallow clicks */
.tile:hover .tile-actions, .tile:focus-within .tile-actions { opacity: 1; pointer-events: auto; }
@media (hover: none) { .tile-actions { opacity: 1; pointer-events: auto; } }
.tile-actions button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; border: 0; background: rgba(15, 20, 28, .82); color: #fff; backdrop-filter: blur(6px); flex: none; }
.tile-actions button:hover { background: var(--accent); color: var(--accent-ink); }
.tile-actions button.txt { width: auto; padding: 0 8px; font-size: 11.5px; font-weight: 700; }
/* Collapsed by default (see tile.js's own comment on why: a grid's worth of tiles each showing all 7
   action buttons at once, permanently on touch, reads as cluttered) — the toggle sits at the tile's fixed
   top-right corner (last in flex order, so its own position never moves), the group expands to its left.
   grid-template-columns 0fr->1fr (not width/max-width, both layout properties whose per-frame animation
   forces a full reflow every tick) is the standard technique for animating a "how much room does this
   take" reveal without that cost — the track-size interpolation is handled without the same thrash, and,
   unlike max-width, doesn't need a guessed-or-100% target that can jump non-linearly if the real content is
   narrower. .tile-actions-row (a plain flex row) is the actual button strip; this element only exists to
   own the animated track. */
.tile-actions-group { display: grid; grid-template-columns: 0fr; overflow: hidden; opacity: 0; transition: grid-template-columns .2s ease, opacity .15s ease; }
.tile-actions-row { display: flex; align-items: center; gap: 4px; min-width: 0; min-height: 0; overflow: hidden; }
/* 1fr, not a fixed pixel count — .tile-actions now spans the tile's real available width (left+right, not
   just right — see its own comment), so the row's own max-content size (clamped by that available width)
   is what the single 1fr track resolves against, not a guess sized for the widest tile in the grid. A
   narrow tile still can't show all 8 buttons at once, but they now clip within their own tile's own bounds
   instead of spilling across the tile boundary into whatever's next to it. */
.tile-actions.expanded .tile-actions-group { grid-template-columns: 1fr; opacity: 1; }
.tile-actions-toggle svg { transition: transform .2s ease; }
.tile-actions.expanded .tile-actions-toggle svg { transform: rotate(180deg); }
.tile-actions.expanded .tile-actions-toggle { background: var(--accent); color: var(--accent-ink); }
.tile .hit { position: absolute; inset: 0; z-index: 1; cursor: pointer; }
.editing .tile { cursor: grab; outline: 2px dashed var(--line-2); outline-offset: -2px; }
.editing .tile .hit { cursor: grab; }
.editing .tile.dragging { opacity: .35; }
.editing .tile.over { outline: 2px solid var(--accent); }
.editing .tile-actions { display: none; }
.editing .grip { display: inline-flex; }
.grip { display: none; align-items: center; justify-content: center; padding: 2px 6px; border-radius: 6px; background: var(--accent); color: var(--accent-ink); font-size: 11px; font-weight: 700; gap: 4px; }
.ev-badges { position: absolute; left: 8px; top: 34px; z-index: 5; display: flex; flex-direction: column; gap: 4px; align-items: flex-start; pointer-events: none; }
.ev-badge { padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 700; letter-spacing: .02em; color: #fff; background: rgba(15, 20, 28, .82); backdrop-filter: blur(6px); box-shadow: 0 2px 8px var(--ov-scrim-soft); }
.ev-badge.motion { background: var(--accent); color: var(--accent-ink); }
.ev-badge.tamper, .ev-badge.videoloss { background: var(--ov-danger); }
.ev-badge.line { background: #d97706; }

/* focus view */
.focus { position: absolute; inset: 0; z-index: 20; background: #000; display: flex; flex-direction: column; }
.focus-bar { flex: none; display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.focus-bar h2 { margin: 0; font-size: 15px; font-weight: 600; flex: 1; min-width: 60px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.focus .nav-arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 6; width: 42px; height: 68px; border: 0; border-radius: 10px; background: rgba(15, 20, 28, .6); color: #fff; display: grid; place-items: center; opacity: 0; transition: opacity .15s; }
.focus:hover .nav-arrow { opacity: 1; }
.focus .nav-arrow:hover { background: var(--accent); color: var(--accent-ink); }
.focus .nav-arrow.prev { left: 10px; } .focus .nav-arrow.next { right: 10px; }
@media (hover: none) { .focus .nav-arrow { opacity: 1; } }
.focus .hitzone { position: absolute; inset: 0; z-index: 1; }
/* JS-driven (LiveView._bindFocusAutoHide), same show-on-activity/hide-while-idle cycle Playback's topline
   and controls use — not a :hover reveal, which (like every other hover-only control above) never showed
   this at all on a device that can't hover, fullscreen or not. */
/* Same safe-area fix as .pb-pane-label above, same cause: floating instead of occupying its own row means
   this can now genuinely reach an iPad's status-bar/notch area. */
.focus:fullscreen .focus-bar { position: absolute; top: 0; left: 0; right: 0; z-index: 8; background: linear-gradient(#000c, transparent); border: 0; padding-top: max(8px, env(safe-area-inset-top)); opacity: 0; pointer-events: none; transition: opacity .22s ease; }
.focus:fullscreen .focus-bar.show { opacity: 1; pointer-events: auto; }
.focus:fullscreen .nav-arrow { opacity: 0; }
.focus:fullscreen .nav-arrow.show { opacity: 1; }
.focus:fullscreen { background: #000; }

/* instant replay overlay */
.replay-overlay { position: absolute; inset: 0; z-index: 25; background: #000; display: flex; flex-direction: column; }
.replay-bar { flex: none; display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--line); }
.replay-bar span { font-weight: 600; }
.replay-stage { position: relative; flex: 1; min-height: 0; display: grid; place-items: center; }
.replay-stage canvas { max-width: 100%; max-height: 100%; }

/* ---------- empty / onboarding ---------- */
.center-card { margin: auto; max-width: 440px; text-align: center; padding: 34px 28px; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; }
.center-card h2 { margin: 12px 0 6px; font-size: 19px; }
.center-card p { margin: 0 0 18px; color: var(--muted); }
.cc-icon { margin: 0 auto; color: var(--accent); }
.cc-icon.danger { color: var(--danger); }
.cc-icon.muted { color: var(--faint); }
.cc-icon .i { width: 34px; height: 34px; }

/* ---------- settings ---------- */
.settings { flex: 1; min-height: 0; display: grid; grid-template-columns: 210px 1fr; overflow: hidden; }
.side { padding: 14px 10px; border-right: 1px solid var(--line); background: var(--panel); display: flex; flex-direction: column; gap: 2px; overflow: auto; }
.side a { display: flex; align-items: center; gap: 9px; padding: 9px 12px; border-radius: 8px; color: var(--muted); text-decoration: none; font-weight: 500; }
.side a:hover { background: var(--panel-2); color: var(--text); }
.side a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
.pane { overflow: auto; padding: 22px 28px 90px; }
.pane-inner { max-width: 940px; margin: 0 auto; }
.pane h1 { margin: 0 0 4px; font-size: 20px; }
.pane .lead { margin: 0 0 20px; color: var(--muted); }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; }
.card h3 { margin: 0 0 3px; font-size: 14.5px; }
.card .sub { margin: 0 0 14px; color: var(--muted); font-size: 13px; }
.form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; }
.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.field.wide { grid-column: 1 / -1; }
.field > label, .field > .lbl { font-weight: 550; font-size: 13px; }
.field .hint { color: var(--muted); font-size: 12.5px; }
.field .err { color: var(--danger); font-size: 12.5px; }
.range-row { display: flex; align-items: center; gap: 10px; }
.range-row input[type=range] { flex: 1; }
.range-val { flex: none; min-width: 3.5em; text-align: right; font-variant-numeric: tabular-nums; font-size: 13px; color: var(--muted); }
input[type=text], input[type=password], input[type=number], select, textarea { width: 100%; padding: 0 11px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--bg); }
input[type=text], input[type=password], input[type=number], select { height: 36px; }
textarea { padding: 8px 11px; min-height: 74px; resize: vertical; }
input:disabled, select:disabled, textarea:disabled { opacity: .5; cursor: not-allowed; background: var(--panel-2); }
input:hover:not(:disabled), select:hover:not(:disabled), textarea:hover:not(:disabled) { border-color: var(--faint); }
input.invalid { border-color: var(--danger); }
.input-row { display: flex; gap: 6px; }
.switch { position: relative; display: inline-block; vertical-align: middle; width: 38px; height: 22px; flex: none; }   /* inline-block: width/height must apply inside table cells too */
.switch input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; z-index: 1; }
.switch span { position: absolute; inset: 0; border-radius: 99px; background: var(--line-2); transition: background .15s; }
.switch span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }
.switch input:checked + span { background: var(--accent); }
.switch input:checked + span::after { transform: translateX(16px); }
.switch input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
.toggle-row { display: flex; align-items: center; gap: 12px; }
.result { margin-top: 12px; padding: 11px 13px; border-radius: 9px; border: 1px solid var(--line); background: var(--panel-2); font-size: 13px; display: flex; gap: 10px; align-items: flex-start; }
.result.ok { border-color: var(--accent); background: var(--accent-soft); }
.result.bad { border-color: var(--danger); background: var(--danger-soft); }
.result b { font-weight: 600; }
.tbl-scroll { overflow-x: auto; margin: 0 -1px; } /* scrolls the table itself on narrow screens instead of crushing its columns unreadably — see the mobile section at the end of this file */
/* min-width, not width: 100% — width:100% forced every column (including Name) to fit inside whatever
   narrow space was available, which is exactly the "crushing its columns unreadably" the comment above says
   this table avoids: found directly, a camera's Name field was rendering only its first two characters on
   a phone. The fixed-width columns (num/fps/act) alone need ~470px; 640px gives Name a readable width too,
   with .tbl-scroll above providing the horizontal scroll for whatever doesn't fit rather than squeezing it. */
table.tbl { width: 100%; min-width: 640px; border-collapse: collapse; }
.tbl th { text-align: left; font-size: 12px; font-weight: 600; color: var(--muted); padding: 0 8px 8px; text-transform: uppercase; letter-spacing: .04em; }
.tbl td { padding: 7px 8px; border-top: 1px solid var(--line); vertical-align: middle; }
.tbl td.num { width: 74px; } .tbl td.fps { width: 96px; } .tbl td.act { width: 1%; white-space: nowrap; text-align: right; }
.tbl tr.off td:not(.act) { opacity: .5; }
.tbl tr.adv td { padding-top: 0; border-top: 0; }
.tbl input, .tbl select { height: 32px; }
.rowtest { color: var(--muted); font-size: 12.5px; padding: 2px 8px 8px; }
.savebar { position: fixed; left: 210px; right: 0; bottom: 0; z-index: 15; display: flex; justify-content: center; padding: 12px; pointer-events: none; }
.savebar > div { pointer-events: auto; display: flex; align-items: center; gap: 12px; padding: 9px 12px 9px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line-2); box-shadow: var(--shadow); }
.opts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; }
.opt { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--bg); text-align: left; display: flex; flex-direction: column; gap: 3px; }
.opt small { color: var(--muted); font-size: 12px; }
.opt[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.laygrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; }
.laygrid button { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 6px 9px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--bg); color: var(--muted); font-size: 12.5px; }
.laygrid button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; }
.kv dt { color: var(--muted); } .kv dd { margin: 0; }
.muted { color: var(--muted); }
kbd { padding: 1px 6px; border-radius: 5px; border: 1px solid var(--line-2); background: var(--panel-2); font: 12px ui-monospace, monospace; }

/* ---------- toasts & modal ---------- */
#toasts { position: fixed; right: 16px; bottom: 16px; z-index: 100; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
.toast { max-width: 380px; padding: 10px 14px; border-radius: 10px; background: var(--panel); border: 1px solid var(--line-2); box-shadow: var(--shadow); display: flex; gap: 10px; align-items: flex-start; animation: rise .18s ease-out; }
.toast.ok { border-left: 3px solid var(--accent); } .toast.bad { border-left: 3px solid var(--danger); }
@keyframes rise { from { transform: translateY(8px); opacity: 0; } }
/* overflow-y: auto matters whenever a dialog's own content (the export dialog's two full calendars, most
   notably) is taller than a phone's viewport — without it the excess just spilled past the fixed/inset:0
   edges with no way to scroll to it, permanently hiding whatever was below the fold, including the actual
   submit button. Confirmed directly: the Export dialog's own "Export" button sat ~450px past the viewport,
   unreachable, on a 375x667 screen. */
.scrim { position: fixed; inset: 0; z-index: 90; background: var(--scrim); display: grid; place-items: center; padding: 16px; overflow-y: auto; }
.dialog { width: min(440px, 100%); padding: 20px; border-radius: 14px; background: var(--panel); border: 1px solid var(--line-2); box-shadow: var(--shadow); }
.dialog h3 { margin: 0 0 6px; } .dialog p { margin: 0 0 16px; color: var(--muted); }
.dialog .row { display: flex; justify-content: flex-end; gap: 8px; }

/* AI frame enhancer (docs/enhance-ai-spec.md) — a large, near-fullscreen viewer, not the small dialog
   style above: the whole point is close visual inspection of the result. */
.enh-scrim { padding: 0; background: rgba(0, 0, 0, .78); }
/* min-width: 0 overrides the grid item default of min-width: auto — without it, .scrim's grid layout let
   this refuse to shrink below its own content's min-content width (the bottom toolbar's several buttons,
   found directly: 666px on a 375px phone), pushing "Download enhanced" and others off the right edge of
   the screen entirely with no way to reach them, rather than actually filling (and wrapping/scrolling
   within) the real 100% width this rule already asked for. */
.enh-viewer { width: 100%; min-width: 0; height: 100%; display: flex; flex-direction: column; background: var(--bg); }
.enh-viewer.is-fs { background: #000; }
.enh-top { flex: none; display: flex; align-items: center; gap: 14px; padding: 10px 14px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.enh-title { display: flex; align-items: center; gap: 8px; font-weight: 700; }
.enh-title .hint { font-weight: 400; }
.enh-fuse { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); white-space: nowrap; cursor: pointer; }
.enh-fuse input { margin: 0; }
.enh-topactions { display: flex; gap: 6px; margin-left: auto; flex: none; } /* pinned to the far right of .enh-top regardless of whether the fuse checkbox (only shown for a >1-frame burst) is present */
.enh-top2 { flex: none; display: flex; align-items: center; gap: 14px; padding: 8px 14px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.enh-weight { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); white-space: nowrap; }
.enh-weight input { width: 110px; }
.enh-weight-val { font-variant-numeric: tabular-nums; width: 2.8em; }
.enh-roi-chip { display: flex; align-items: center; gap: 2px; font-size: 12px; font-weight: 600; color: var(--accent); background: var(--accent-soft); padding: 3px 4px 3px 10px; border-radius: 99px; }
.enh-stage { position: relative; flex: 1; min-height: 0; background: #000; overflow: hidden; container-type: size; display: grid; place-items: center; }
.enh-pic { display: grid; place-items: center; transform: translate(var(--zx, 0px), var(--zy, 0px)) scale(var(--zs, 1)); transform-origin: center; }
.enh-pic > * { grid-area: 1 / 1; } /* the live-filter canvas overlays the <img> exactly, same trick as .pb-pic's raw+enhanced canvas pair */
/* Same technique as .pb-pane canvas (see its comment) and .stage cam-player: an intrinsic box shaped like
   the real picture, sized to fit in EITHER direction — a plain max-width/max-height (shrink-only) would
   under-scale a source-resolution "before" frame that's smaller than the viewer, same bug fixed there. */
.enh-img { aspect-ratio: var(--ar, 1.7778); width: min(100cqw, calc(100cqh * var(--ar, 1.7778))); display: block; }
.enh-img-result { opacity: 1; }   /* set per-frame from the blend slider (JS) — no transition, so dragging tracks the pointer exactly */
.enh-blend { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); white-space: nowrap; flex: none; }
.enh-blend input[type=range] { width: 130px; }
.enh-blend-val { font-variant-numeric: tabular-nums; width: 3.2em; }
/* Same phone-width fix as .enh-bottom's own comment above (the row scrolls, nothing's ever truly
   unreachable), but the blend control is the widest thing in that row — trimming it here means less
   scrolling stands between a phone and "Download enhanced", the primary action at the far end. */
@media (max-width: 480px) {
  .enh-blend .hint { display: none; }
  .enh-blend input[type=range] { width: 84px; }
}
.enh-livefilter-canvas { aspect-ratio: var(--ar, 1.7778); width: min(100cqw, calc(100cqh * var(--ar, 1.7778))); display: block; pointer-events: none; }
.enh-stage.flashlight-on { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'%3E%3Ccircle cx='14' cy='14' r='10' fill='rgba(255,214,130,0.22)' stroke='%23ffd682' stroke-width='2'/%3E%3Ccircle cx='14' cy='14' r='2.2' fill='%23ffd682'/%3E%3C/svg%3E") 14 14, crosshair; }
.enh-stage .hitzone { position: absolute; inset: 0; z-index: 3; touch-action: none; }
/* Sits above .hitzone (z-index 3) so a drag is captured as a selection instead of a pan while "Select
   region" is active — pointer-events stays none the rest of the time so normal pan/zoom is unaffected. */
.enh-roi-layer { position: absolute; inset: 0; z-index: 4; pointer-events: none; cursor: crosshair; }
.enh-roi-layer.active { pointer-events: auto; }
.enh-roi-box { position: absolute; border: 2px solid var(--accent); background: var(--ov-roi); box-shadow: 0 0 0 9999px var(--ov-scrim-soft); pointer-events: none; }
.enh-stage .zoomtag { position: absolute; right: 10px; top: 10px; z-index: 6; height: 26px; padding: 0 11px; border: 0; border-radius: 99px; background: var(--accent); color: var(--accent-ink); font-size: 12px; font-weight: 700; box-shadow: 0 2px 8px var(--ov-scrim); }
.enh-badge { position: absolute; left: 10px; bottom: 10px; z-index: 5; display: flex; align-items: center; gap: 7px; max-width: calc(100% - 20px); padding: 7px 12px; border-radius: 8px; background: rgba(120, 53, 15, .92); color: #fef3c7; font-size: 12px; font-weight: 600; box-shadow: 0 2px 8px var(--ov-scrim); }
.enh-badge svg { flex: none; width: 15px; height: 15px; }
.enh-loading { position: absolute; inset: 0; z-index: 7; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; background: rgba(5, 7, 10, .82); color: #cbd5e1; }
.enh-ocr-panel { position: absolute; right: 10px; top: 10px; z-index: 6; width: min(320px, calc(100% - 20px)); max-height: calc(100% - 20px); overflow-y: auto; padding: 10px 12px; border-radius: 10px; background: var(--panel-2); border: 1px solid var(--line-2); box-shadow: var(--shadow); }
.enh-ocr-head { display: flex; align-items: center; gap: 7px; font-weight: 700; font-size: 13px; margin-bottom: 8px; }
.enh-ocr-head .btn { margin-left: auto; }
.enh-ocr-body { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
.enh-ocr-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 5px 8px; border-radius: 6px; background: var(--panel); font-size: 13px; }
.enh-ocr-text { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; overflow-wrap: anywhere; }
.enh-ocr-conf { flex: none; color: var(--muted); font-size: 11.5px; }
/* Same fix as .pb-ctrl-center (playback's transport row): 4 buttons + a status hint is wider than a phone
   screen at any reasonable touch size — found directly, "Download enhanced" (the primary action) was
   rendering fully off-screen with no way to reach it at all. Horizontal scroll instead of hiding anything,
   for the same reason as that row: every action here stays reachable rather than silently disappearing. */
.enh-bottom { flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--line); overflow-x: auto; }
.enh-bottom .spacer { flex: 1; }
.enh-bottom .btn, .enh-bottom .enh-status { flex: none; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.chip { padding: 5px 11px; border-radius: 99px; border: 1px solid var(--line-2); background: var(--bg); }
.chip[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }

@media (max-width: 800px) {
  .settings { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
  /* padding-inline: 0, spacing moved to margin on the links themselves — Safari has a long-standing bug
     where a horizontally-scrolling flex container's own edge padding gets clipped at the scroll boundary
     (confirmed on-device: "Connection"'s leading C rendered cut off at rest, scrollLeft already 0 — not a
     scroll-position bug, a padding-rendering one). margin isn't affected by that Safari quirk. */
  .side { flex-direction: row; border-right: 0; border-bottom: 1px solid var(--line); overflow-x: auto; padding-inline: 0; }
  .side a { margin-inline: 4px; } .side a:first-child { margin-inline-start: 10px; } .side a:last-child { margin-inline-end: 10px; }
  .savebar { left: 0; } .form { grid-template-columns: 1fr; } .pane { padding: 16px 14px 90px; }
  .nav a span, .brand span { display: none; } .clock { display: none; }
}
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }

input[type=number] { -moz-appearance: textfield; appearance: textfield; }
input[type=number]::-webkit-outer-spin-button, input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* zoom */
.stage.zanim cam-player { transition: transform .2s ease; }
.tile .hit.zoomed { cursor: grab; } .tile .hit.zoomed.panning, .hitzone.zoomed.panning { cursor: grabbing; }
.hitzone { touch-action: none; } .hitzone.zoomed { cursor: grab; }
.tile > .zoomtag { position: absolute; right: 8px; bottom: 8px; z-index: 6; height: 26px; padding: 0 11px; border: 0; border-radius: 99px; background: var(--accent); color: var(--accent-ink); font-size: 12px; font-weight: 700; box-shadow: 0 2px 8px var(--ov-scrim); }
.tile > .zoomtag:hover { filter: brightness(1.1); }
.zoomctl { display: inline-flex; align-items: center; gap: 2px; }
.zoomctl .pct { min-width: 54px; font-variant-numeric: tabular-nums; }

/* ---------- playback ---------- */
.pb { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.pb-body { position: relative; flex: 1; min-height: 0; display: flex; }
/* Fullscreen: give the video the whole screen — the camera list and date/time panels become auto-hiding
   overlays instead of permanent flex columns, shown only while the pointer is near their edge (or over the
   panel itself once open). Windowed mode is untouched; this is all scoped under .pb:fullscreen. */
.pb:fullscreen .pb-side { position: absolute; top: 0; bottom: 0; z-index: 25; width: 260px; background: rgba(8, 11, 16, .97); backdrop-filter: blur(8px); box-shadow: 0 0 28px var(--ov-scrim-strong); transition: transform .22s ease; overflow-y: auto; }
.pb:fullscreen .pb-side-left { left: 0; transform: translateX(-100%); }
.pb:fullscreen .pb-side-right { right: 0; transform: translateX(100%); }
.pb:fullscreen .pb-side.show { transform: translateX(0); }
.pb-edge { display: none; }
.pb:fullscreen .pb-edge { display: block; position: absolute; top: 0; bottom: 0; width: 28px; z-index: 24; }
.pb:fullscreen .pb-edge-left { left: 0; }
.pb:fullscreen .pb-edge-right { right: 0; }
.pb-side { flex: none; width: 232px; display: flex; flex-direction: column; gap: 10px; padding: 14px 12px; background: var(--panel); overflow-y: auto; }
.pb-side-left { border-right: 1px solid var(--line); }
.pb-side-right { border-left: 1px solid var(--line); }
.pb-side h3 { margin: 0 0 2px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.cam-list { display: flex; flex-direction: column; gap: 3px; }
.cam-item { display: flex; align-items: center; gap: 9px; padding: 9px 10px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--text); text-align: left; font-weight: 500; cursor: pointer; }
.cam-item:has(input:disabled) { opacity: .4; cursor: not-allowed; }
/* Playback only ever disables an *unselected* item (past its 4-camera cap), where dimmed-and-blocked is the
   right read. Events disables the one *selected* item left when it's the last one (can't drop to zero) —
   still fully chosen, just locked, so it should read like every other selected item, not like a greyed-out
   unavailable one. */
.cam-item.on:has(input:disabled) { opacity: 1; cursor: default; }
.cam-item:hover { background: var(--panel-2); }
.cam-item.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.cam-item input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent); flex: none; }
.cam-item .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pb-pool { font-size: 11.5px; }
.pb-pool.warn { color: var(--warn); border-color: var(--warn); }

/* multi-pane video grid */
.pb-panes.multi { display: grid; gap: 3px; background: var(--line); padding: 0; place-items: initial; }
.pb-pane { position: relative; background: #000; overflow: hidden; display: grid; place-items: center; container-type: size; }
.pb-pane > * { grid-area: 1 / 1; }
.pb-pic { width: 100%; height: 100%; display: grid; place-items: center; transform: translate(var(--zx, 0px), var(--zy, 0px)) scale(var(--zs, 1)); transform-origin: center; }
.pb-pic > * { grid-area: 1 / 1; } /* without this, the two canvases (raw + enhanced) aren't guaranteed the same grid cell and can size independently — confirmed as the cause of a real mismatch under zoom */
.pb-pane.zanim .pb-pic { transition: transform .2s ease; }
/* Same technique live view's .stage cam-player uses (--ar set per pane from the decoded frame size in
   playback.js's _onFrame): the canvas is given an intrinsic box shaped like the real picture and sized to
   fit the pane, in BOTH directions. max-width/max-height alone (the previous rule) only ever shrinks —
   a canvas whose decoded resolution is smaller than the pane (e.g. a 720p camera in a pane sized for a
   1080p one) rendered at its native pixel size with huge unused margins on every side instead of scaling
   up to fill the available space, which is what produced far bigger black borders than the aspect-ratio
   mismatch alone would ever justify. width:min(cqw, cqh*ar) scales up or down to whichever dimension is
   the tighter fit, so the picture always uses all the room a straight letterbox allows. */
.pb-pane canvas { aspect-ratio: var(--ar, 1.7778); width: min(100cqw, calc(100cqh * var(--ar, 1.7778))); display: block; }
/* Settings > Display > Picture & behaviour "Fill tile" — the same setting live view's .wall.fill already
   uses (and the same width:max(...) technique), applied here too: scales up until BOTH dimensions satisfy
   their minimum, filling the pane edge-to-edge and cropping via the pane's own overflow:hidden. */
.pb-panes.fill .pb-pane canvas { width: max(100cqw, calc(100cqh * var(--ar, 1.7778))); }
.pb-pane .enh-canvas { pointer-events: none; z-index: 1; }
.pb-pane .hitzone { position: absolute; inset: 0; z-index: 3; }
/* Sits above .hitzone so a drag is captured as a region selection instead of a pan while armed — same
   pattern as the AI frame enhancer's own ROI layer (docs/enhance-ai-spec.md 2c), pointer-events stays none
   the rest of the time so normal pan/zoom is unaffected. */
.pb-pane .pb-roi-layer { position: absolute; inset: 0; z-index: 5; pointer-events: none; cursor: crosshair; }
.pb-pane .pb-roi-layer.active { pointer-events: auto; }
.pb-pane .pb-roi-box { position: absolute; border: 2px solid var(--accent); background: var(--ov-roi); pointer-events: none; }
/* Digital flashlight cursor: a soft ring + hotspot dot standing in for a torch beam, hotspot centred (14,14)
   so the glow the shader draws lines up with the actual cursor tip, not its corner. */
.pb-pane.flashlight-on { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'%3E%3Ccircle cx='14' cy='14' r='10' fill='rgba(255,214,130,0.22)' stroke='%23ffd682' stroke-width='2'/%3E%3Ccircle cx='14' cy='14' r='2.2' fill='%23ffd682'/%3E%3C/svg%3E") 14 14, crosshair; }
/* Top-right rather than bottom-right: the playback transport is a floating overlay at the bottom of the
   pane now, and a bottom-right pill would sit right on top of it. */
.pb-pane > .zoomtag { position: absolute; right: 8px; top: 8px; z-index: 6; height: 26px; padding: 0 11px; border: 0; border-radius: 99px; background: var(--accent); color: var(--accent-ink); font-size: 12px; font-weight: 700; box-shadow: 0 2px 8px var(--ov-scrim); }
.pb-pane > .zoomtag:hover { filter: brightness(1.1); }
.pb-pane-label { position: absolute; top: 0; left: 0; right: 0; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 5px 8px; font-size: 12px; font-weight: 600; color: #fff; text-shadow: 0 1px 2px #000; background: linear-gradient(#000a, transparent); pointer-events: none; transition: top .22s ease; }
/* Fullscreen: the topline now floats/auto-hides instead of permanently occupying its own row (see its own
   comment), so a top-row pane's video — and this label — genuinely reaches the true top of the screen,
   including an iPad's status-bar/notch safe area, which it never did before that change. Found directly
   (real fullscreen, not simulated): the camera name rendered right under the iPad's own status bar. */
.pb:fullscreen .pb-pane-label { top: env(safe-area-inset-top); }
/* Second, separate collision: right when fullscreen opens (or on any activity), the topline is *also*
   floating and shown at the same time — both it and this label anchor to the same top-left corner, so their
   text visibly overlapped (found directly). .pb-topline and .pb-stage are siblings under .pb-center, so the
   general sibling combinator can push every pane's label down by the topline's own height, no JS needed —
   only while the topline is actually visible; it settles back to the corner once the topline auto-hides. */
.pb:fullscreen .pb-topline.show ~ .pb-stage .pb-pane-label { top: calc(var(--bar) + env(safe-area-inset-top)); }
.pb-pane .pb-veil { padding: 10px; z-index: 4; }
.pb-pane .pb-veil .msg { font-size: 12px; }
.pb-center { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.pb-topline { flex: none; display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: var(--bg); }
/* True fullscreen means no chrome permanently on screen for a 16:9 stream, not just a transparent bar still
   taking up its own layout row — this now leaves the .pb-stage below it exactly as before (still flex:1),
   floats over the top of the video instead, and joins the same show-on-activity/hide-while-idle cycle
   .pb-controls already has (_bindAutoHideControls), rather than a separate hover-only reveal. */
.pb:fullscreen .pb-topline { position: absolute; top: 0; left: 0; right: 0; z-index: 8; border-bottom: 0;
  background: linear-gradient(#000c, transparent); padding-top: max(8px, env(safe-area-inset-top));
  opacity: 0; pointer-events: none; transition: opacity .22s ease; }
.pb:fullscreen .pb-topline.show { opacity: 1; pointer-events: auto; }
/* Same contrast treatment .pb-controls already needed for the same reason: this floats over live video of
   unpredictable brightness now, instead of sitting on the app's own solid panel background. */
.pb:fullscreen .pb-topline, .pb:fullscreen .pb-topline .pill { color: #f2f4f7; }
.pb:fullscreen .pb-topline .btn:not(.primary) { background: var(--ov-btn-bg); border-color: var(--ov-btn-bg-hover); color: #f2f4f7; }
.pb:fullscreen .pb-topline .btn:not(.primary):hover { background: var(--ov-btn-bg-hover); }
.pb:fullscreen .pb-topline .pill { background: var(--ov-btn-bg); border-color: var(--ov-btn-bg-hover); }
/* .pb-stage is just the video viewport now — the transport lives inside it as an overlay (below) so the
   video gets the full height instead of losing a fixed strip to a permanently-visible control bar. */
.pb-stage { position: relative; flex: 1; min-height: 0; background: #000; overflow: hidden; }
.pb-panes { position: absolute; inset: 0; display: grid; place-items: stretch; }
.pb-panes > .pb-pane { width: 100%; height: 100%; min-width: 0; min-height: 0; }
.pb-canvas { max-width: 100%; max-height: 100%; display: block; }
.pb-veil { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; background: rgba(5, 7, 10, .82); color: #cbd5e1; text-align: center; padding: 16px; }
/* Controls float over the bottom of the video (spec: "give maximum to the video") instead of taking a
   permanent strip below it — a gradient scrim, visible while paused or the pointer is near the bottom of
   the video, fading out otherwise so the picture gets the space back. Same idea as any video player's
   auto-hiding transport bar. */
.pb-controls { position: absolute; left: 0; right: 0; bottom: 0; z-index: 5; display: flex; flex-direction: column;
  background: linear-gradient(to top, rgba(6, 9, 14, .94) 0%, rgba(6, 9, 14, .8) 55%, transparent 100%);
  padding-top: 34px; opacity: 0; transform: translateY(6px); pointer-events: none; transition: opacity .22s ease, transform .22s ease; }
.pb-controls.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
/* Three zones so the transport (play/step/rewind) reads as the visual center of the bar regardless of how
   much is in the side groups — the 1fr/auto/1fr columns keep it centered rather than just "left-aligned
   after whatever came before it", which is what a single flex row gives you. */
.pb-ctrlrow { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; padding: 10px 14px 8px; }
/* Every button in this row reads as one control bar regardless of which size class it happens to use
   elsewhere in the app ("Jump to now"/"Select range"/"Export clip" were .btn.sm at 28px sitting next to
   34px icon buttons in the same row) — force the same height and icon-button width across the whole bar. */
.pb-ctrlrow .btn { height: 34px; }
.pb-ctrlrow .btn.sm { padding: 0 12px; font-size: 13px; }
.pb-ctrlrow .btn.icon, .pb-ctrlrow .btn.sm.icon { width: 34px; padding: 0; }
.pb-ctrl-left { display: flex; align-items: center; gap: 8px; }
.pb-ctrl-center { display: flex; align-items: center; justify-content: center; gap: 6px; }
.pb-ctrl-right { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.pb-macros { display: flex; gap: 3px; padding: 2px; border-radius: 9px; background: rgba(255, 255, 255, .06); border: 1px solid rgba(255, 255, 255, .12); }
.pb-macros .btn.icon { border: 0; background: transparent; color: #fff; }
.pb-controls .btn { color: #f2f4f7; }
.pb-controls .btn:not(.primary) { background: var(--ov-btn-bg); border-color: var(--ov-btn-bg-hover); }
.pb-controls .btn:not(.primary):hover { background: var(--ov-btn-bg-hover); }
.pb-ctrl-center .btn.icon { position: relative; width: auto; min-width: 34px; padding: 0 8px; }
.pb-ctrl-center .btn.icon .u { font-size: 9px; margin-left: 1px; opacity: .7; }
.pb-speed { width: 64px; height: 34px; padding: 0 4px; border-radius: 8px; border: 1px solid var(--ov-btn-bg-hover); background: var(--ov-btn-bg); color: #f2f4f7; font-size: 13px; text-align: center; text-align-last: center; }
.pb-time { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 500; }
.pb-hintrow { padding: 0 14px 10px; text-align: center; }
.pb-hintrow #pbtl-hint:not(:empty) { color: var(--accent); font-size: 12.5px; font-weight: 600; }
@media (max-width: 900px) {
  .pb-ctrlrow { grid-template-columns: 1fr; justify-items: center; }
  .pb-ctrl-left, .pb-ctrl-right { justify-content: center; }
}
.pb-timeline { position: relative; flex: none; height: 90px; border-top: 1px solid var(--line); background: var(--panel); }
.pb-timeline canvas { width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; }
.tl-tip { position: absolute; z-index: 10; display: flex; flex-direction: column; gap: 2px; padding: 6px 10px; border-radius: 8px; background: var(--panel-2); border: 1px solid var(--line-2); box-shadow: var(--shadow); pointer-events: none; white-space: nowrap; }
.tl-tip b { font-size: 12.5px; }
.tl-tip span { font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
.tl-tip .tl-tip-name { color: var(--text); font-weight: 600; font-variant-numeric: normal; }
/* Jump-to-playhead: only shown (timeline.js) when the playhead has scrolled out of view, docked to
   whichever side it went off — the whole point is to be noticeable without covering the scale label. */
.tl-jumpph { position: absolute; top: 4px; z-index: 11; height: 22px; padding: 0 10px; border-radius: 99px; border: 0; background: var(--accent); color: var(--accent-ink); font-size: 11.5px; font-weight: 700; box-shadow: 0 2px 8px var(--ov-scrim); cursor: pointer; }
.tl-jumpph:hover { filter: brightness(1.1); }
.tl-jumpph.left { left: 6px; }
.tl-jumpph.right { right: 6px; }
/* Fullscreen: playback.js moves .pb-timeline to be the first child of .pb-controls (and back out again on
   exit) instead of leaving it in its normal-flow spot below .pb-body, which fullscreen never renders (no
   room below the video once the view fills the screen). Nested there, it's already part of the same
   floating bottom overlay as the transport row and picks up its existing show/hide-on-activity behaviour
   for free — no separate auto-hide wiring needed. Cosmetic-only override: its own opaque panel background
   would show as a hard-edged box against .pb-controls' gradient, so blend it in instead. */
.pb-controls .pb-timeline { border-top: 1px solid rgba(255, 255, 255, .14); background: transparent; }
.pb-source { display: none; }
@media (min-width: 1100px) { .pb-source { display: inline-flex; } }

/* calendar + time jump panel */
.pb-cal { display: flex; flex-direction: column; gap: 6px; }
.dtp { display: flex; flex-direction: column; gap: 6px; }
.dtp-label { margin: 0; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.exp-range { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 4px 0 16px; }
.exp-range .dtp-host { min-width: 0; }
@media (max-width: 560px) { .exp-range { grid-template-columns: 1fr; } }
/* Multi-cut clipper (spec 10/15): the pending clip list dialog and its toolbar badge. */
.clip-count { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 99px; background: var(--accent); color: var(--accent-ink); font-size: 10.5px; font-weight: 700; }
.clip-rows { display: flex; flex-direction: column; gap: 4px; max-height: 240px; overflow-y: auto; margin: 4px 0 14px; }
.clip-row { display: grid; grid-template-columns: 20px 1fr auto auto auto; align-items: center; gap: 10px; padding: 7px 9px; border-radius: 8px; background: var(--panel-2); font-size: 13px; }
.clip-idx { color: var(--muted); font-variant-numeric: tabular-nums; }
.clip-range { font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.clip-dur { color: var(--muted); font-size: 12px; }
.clip-status { min-width: 90px; text-align: right; }
.clip-status a { color: var(--accent); font-weight: 600; }
.cal-head { display: flex; align-items: center; justify-content: space-between; }
.cal-label { font-weight: 600; font-size: 13px; }
.cal-week { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; color: var(--muted); font-size: 11px; padding: 2px 0; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.cal-day { position: relative; aspect-ratio: 1; border: 0; border-radius: 7px; background: transparent; color: var(--faint); font-size: 12.5px; font-variant-numeric: tabular-nums; }
.cal-day.empty { visibility: hidden; }
.cal-day.has { color: var(--text); cursor: pointer; }
.cal-day.has:hover { background: var(--panel-2); }
.cal-day.has::after { content: ""; position: absolute; left: 50%; bottom: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent); transform: translateX(-50%); }
.cal-day.today { box-shadow: inset 0 0 0 1px var(--line-2); }
.cal-day.sel { background: var(--accent); color: var(--accent-ink); font-weight: 700; }
.cal-day.sel::after { background: var(--accent-ink); }
.cal-day:disabled { cursor: default; }
.time-inputs { display: flex; align-items: flex-end; gap: 4px; }
.time-field { display: flex; flex-direction: column; align-items: center; gap: 3px; flex: 1; }
.time-field label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.time-field input { width: 100%; height: 36px; text-align: center; border-radius: 8px; border: 1px solid var(--line-2); background: var(--bg); font-variant-numeric: tabular-nums; font-size: 14px; }
.time-sep { padding-bottom: 16px; color: var(--muted); font-weight: 600; }
.pb-side-right .hint { margin: 2px 0 0; font-size: 12px; color: var(--muted); }

@media (max-width: 980px) {
  .pb-side { width: 180px; padding: 10px 8px; }
  .pb-side h3 { display: none; }
}
.pb-panel-toggle { display: none; }
/* Unconditional (not inside either view's media query): both drawer patterns below share this one scrim,
   and its own [hidden] attribute already keeps it invisible until JS un-hides it — which JS only does from
   a toggle button that's itself only visible/clickable at its view's own breakpoint, so this needs no width
   gating of its own. */
.pb-panel-scrim { position: absolute; inset: 0; z-index: 24; background: var(--scrim); }
/* Below 760px there's no longer room to show the camera list and the calendar in-flow alongside the video
   without crushing it, regardless of pointer type — found directly, not assumed: stacking all three in a
   column left the video a thin sliver with the transport controls overlapping it, and the calendar's own
   natural height (a full month grid) pushed the timeline off-screen entirely, invisible and unreachable, on
   a phone-height viewport. A *touch* device needs the same treatment at far more width than that: an iPad
   in landscape has plenty of width (1024-1366px) for two 260px rails beside the video, but they're still
   permanently-docked chrome competing with the picture on a device meant to be held, not a fixed desk
   monitor — every iPad size, both orientations, gets the same drawer treatment a phone needs for width
   alone. Hence the two conditions below, either sufficient on its own: (pointer: coarse) at a generous
   width is what actually distinguishes "touch device, however wide" from "someone shrunk their laptop's
   browser window" (which stays on the plain width check — found directly that dropping this half left a
   narrow *mouse* window back on the crushing bug this whole rule exists to fix, since the 980px rule below
   only ever shrinks the rails' width, it doesn't stack/hide them). Below the qualifying width, both side
   panels become the exact same off-canvas drawers .pb:fullscreen already uses above — opened by the
   topline's two icon buttons (no hover on touch, so no edge-hover reveal here) and dismissed via the scrim
   or the toggle again — so windowed mode gets the video, transport and timeline at full width by default,
   with cameras/date-jump one tap away instead of permanently competing for space. 1400px comfortably covers
   every current iPad in landscape (widest: 12.9" Pro at 1366px) while still excluding real desktop
   displays, which are essentially never touch-primary. */
@media (max-width: 1400px) and (pointer: coarse), (max-width: 760px) {
  /* Scoped to .pb (Playback) specifically — Events reuses the same .pb-side/.cam-list classes and gets the
     identical drawer treatment (see the events-view block below) via its own toolbar buttons rather than
     Playback's topline ones, so it needs its own copy of these rules rather than sharing this one block. */
  .pb .pb-panel-toggle { display: inline-flex; }
  /* var(--panel), not the fixed dark rgba() .pb:fullscreen's own drawer above uses — that one only ever
     floats over video (always black, regardless of theme, by design), this one is windowed-mode chrome and
     needs to actually follow the light/dark theme like every other panel in the app does (found directly:
     it didn't, a stray dark drawer over an otherwise-light UI in light theme). */
  .pb .pb-side { position: absolute; top: 0; bottom: 0; z-index: 25; width: 260px; max-width: 80vw; background: color-mix(in srgb, var(--panel) 97%, transparent);
    backdrop-filter: blur(8px); box-shadow: var(--shadow); transition: transform .22s ease; overflow-y: auto;
    flex-direction: column; flex-wrap: nowrap; max-height: none; border: 0 !important; }
  .pb .pb-side h3 { display: block; } /* the 980px rule above hides it at the cramped inline width — plenty of room for it in a full drawer */
  .pb .pb-side-left { left: 0; transform: translateX(-100%); }
  .pb .pb-side-right { right: 0; transform: translateX(100%); }
  .pb .pb-side.show { transform: translateX(0); }
  .pb .cam-list { flex-direction: column; flex-wrap: nowrap; }
  /* "recorder sessions" hidden here, "X/4" kept — with 2 extra topline buttons now shown at this breakpoint
     (touch drawer toggles) plus status/time/fit/fullscreen, there isn't room for the full label; the pill's
     own title attribute still carries it. Belt-and-braces alongside .pill's own white-space:nowrap fix. */
  .pb-pool-label { display: none; }
}

/* events (search) — cameras on the left, results in the middle, filters on the right: the same .pb-side/
   .cam-list Playback already uses, for one consistent picker pattern instead of a second, differently-
   shaped one. */
.events-view { flex: 1; min-height: 0; display: flex; overflow: hidden; }
.pb-side select { height: 34px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--bg); padding: 0 8px; width: 100%; }
.events-presets { display: flex; flex-direction: column; gap: 4px; }
.events-presets button { text-align: left; height: 32px; padding: 0 10px; border-radius: 7px; border: 1px solid transparent; background: transparent; color: var(--muted); }
.events-presets button:hover { background: var(--panel-2); }
.events-presets button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
.events-custom { display: flex; flex-direction: column; gap: 14px; padding-top: 4px; border-top: 1px solid var(--line); }
.events-main { flex: 1; min-width: 0; overflow: auto; padding: 18px 22px 40px; }
.events-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; max-width: 1400px; margin: 0 auto; }
.ev-card { display: flex; flex-direction: column; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); overflow: hidden; content-visibility: auto; contain-intrinsic-size: 260px 290px; } /* skips layout/paint for off-screen cards — a real cost on a long results list, especially on mobile */
.ev-thumb { position: relative; aspect-ratio: 16/9; background: var(--tile); overflow: hidden; cursor: pointer; }
.ev-thumb img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .15s; }
.ev-thumb.loaded img { opacity: 1; }
.ev-thumb-fallback { position: absolute; inset: 0; display: grid; place-items: center; color: var(--faint); }
.ev-thumb.loaded .ev-thumb-fallback { display: none; }
.ev-thumb-fallback .i { width: 28px; height: 28px; }
.ev-card-body { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 6px; }
.ev-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ev-card-top .ev-badge { position: static; }
.ev-card-date { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.ev-card-cam { font-weight: 600; }
.ev-card-title { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ev-card-actions { display: flex; align-items: center; gap: 6px; margin-top: 4px; flex-wrap: wrap; }
/* List view (thumbnails off) — one row per event, no image at all, so switching here also means zero
   /thumbnail requests and zero extra DVR playback-session pressure, not just a denser layout. */
.events-list { display: flex; flex-direction: column; gap: 2px; max-width: 1100px; margin: 0 auto; }
.ev-row { display: flex; align-items: center; gap: 12px; padding: 9px 12px; border-radius: 9px; background: var(--panel); border: 1px solid var(--line); content-visibility: auto; contain-intrinsic-size: 100% 46px; }
.ev-row .ev-badge { position: static; flex: none; }
.ev-row-cam { font-weight: 600; flex: none; min-width: 130px; }
.ev-row-date { font-size: 12.5px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; }
.ev-row-title { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Event preview popup — a small centered dialog (unlike the frame enhancer's near-fullscreen viewer),
   since this is "what happened here", not close inspection. */
.evp-scrim { padding: 16px; }
.evp-dialog { width: min(820px, 100%); max-height: 90vh; display: flex; flex-direction: column; border-radius: 14px; background: var(--panel); border: 1px solid var(--line-2); box-shadow: var(--shadow); overflow: hidden; }
.evp-top { flex: none; display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.evp-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 9px; font-weight: 600; }
.evp-title .ev-badge { position: static; flex: none; }
.evp-etitle { color: var(--muted); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.evp-stage { position: relative; flex: none; aspect-ratio: 16/9; background: #000; display: grid; place-items: center; overflow: hidden; }
.evp-stage canvas { max-width: 100%; max-height: 100%; display: block; }
.evp-veil { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; background: rgba(5, 7, 10, .82); color: #cbd5e1; text-align: center; padding: 16px; }
/* Same fix as .pb-ctrl-center/.enh-bottom: 4 buttons + a status hint don't fit a phone width at any
   reasonable size — found directly, "Open in playback" (the primary way out of this popup back to full
   review) was rendering partly off the dialog's own right edge with no way to reach the rest of it. */
.evp-bottom { flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--line); overflow-x: auto; }
.evp-bottom .btn, .evp-bottom .hint { flex: none; }
.events-toolbar { display: none; gap: 8px; margin-bottom: 14px; }
/* Same two-condition breakpoint as Playback's drawer rule, and the same reason: a narrow *mouse* window
   needs this purely for width (found directly — a plain width-only condition had regressed here too), a
   touch device (any iPad, either orientation) needs it regardless of width. */
@media (max-width: 1400px) and (pointer: coarse), (max-width: 820px) {
  /* Same off-canvas drawer pattern as Playback, and the same (max-width, pointer: coarse) condition and
     reasoning — every iPad, both orientations, not just a narrow window — rather than stacking the camera
     picker, results and filters in one column: an earlier version of this rule did that, but DOM order
     (picker, results, THEN the filters that produced them) put "Apply filters" below the fold with
     .events-view's own `overflow: hidden` giving no way to reach it at all (confirmed directly: its
     scrollHeight ran ~185px past clientHeight) — reordering fixed reachability but still gave the results
     list less width than it could have. Drawers (opened via .events-toolbar's two buttons, only shown at
     this width) keep results full-width by default, matching Playback's own reasoning exactly. */
  .events-view { position: relative; overflow: hidden; }
  .events-toolbar { display: flex; }
  /* var(--panel), theme-aware — this floats over the (normally-themed, not video-black) results list, so a
     fixed dark background reads as a stray dark panel in light theme (found directly, not assumed). */
  .events-view .pb-side { position: absolute; top: 0; bottom: 0; z-index: 25; width: 260px; max-width: 80vw; background: color-mix(in srgb, var(--panel) 97%, transparent);
    backdrop-filter: blur(8px); box-shadow: var(--shadow); transition: transform .22s ease; overflow-y: auto;
    flex-direction: column; flex-wrap: nowrap; max-height: none; border: 0 !important; }
  .events-view .pb-side h3 { display: block; }
  .events-view .pb-side-left { left: 0; transform: translateX(-100%); }
  .events-view .pb-side-right { right: 0; transform: translateX(100%); }
  .events-view .pb-side.show { transform: translateX(0); }
  .events-view .cam-list { flex-direction: column; flex-wrap: nowrap; }
  .events-view .events-main { flex: 1; min-width: 0; overflow-y: auto; }
  .events-custom { flex-direction: row; border-top: 0; }
  .ev-row { flex-wrap: wrap; }
}

/* ---------- mobile & touch ---------- */
/* Safe-area insets: matters once this is added to a phone's home screen (standalone display), or on a
   notched phone in landscape — harmless everywhere else since the env() vars are 0 on unsupported/normal
   browser-chrome contexts. Layered onto the existing fixed sizes, not replacing them. */
.topbar { padding-top: env(safe-area-inset-top); height: calc(var(--bar) + env(safe-area-inset-top)); padding-left: max(14px, env(safe-area-inset-left)); padding-right: max(14px, env(safe-area-inset-right)); }
.savebar { padding-bottom: env(safe-area-inset-bottom); }
.pb-controls { padding-bottom: env(safe-area-inset-bottom); }

/* Touch targets: driven by input capability, not viewport width (a touchscreen laptop is still a mouse
   context most of the time; a narrow phone browser is still touch) — per DESIGN.md's own Component
   philosophy this compact 28-34px scale is deliberate for the mouse/desktop case, so it stays completely
   untouched there. This block only fires under a coarse (touch) pointer, and only bumps the controls small
   enough to actually miss-tap: 28px icon/segment buttons go to a real 40-44px hit area; already-34px
   buttons and settings-tab links just get a couple more px of height. Visual size (icon, label, padding)
   is left alone — only the hit area grows, via padding/min-size, so density doesn't visibly change. */
@media (pointer: coarse) {
  .btn { min-height: 40px; }
  .btn.sm { min-height: 40px; }
  .btn.icon, .btn.sm.icon { min-width: 44px; }
  .btn:not(.icon):not(.sm) { min-height: 44px; }
  .seg button { min-height: 40px; min-width: 40px; }
  .tile-actions button { width: 38px; height: 38px; }
  .nav a { min-height: 44px; }
  .side a { min-height: 44px; }
  .pager .dots button { width: 12px; height: 12px; } /* page dots: 8px is a real miss-tap target on touch */
  .cal-day { min-height: 36px; }
  .enh-menu button, .events-presets button { min-height: 40px; }
  .enh2-presets button { min-height: 34px; padding: 0 12px; } /* stays a compact pill row (many presets, one line) — nudged, not pushed to 40+ */
}

/* Live grid: on a phone, the desktop layout's own column/row count (up to 4x4) is set inline per-tile by
   live.js (renderWall()) to build the exact grid the operator picked — CSS alone can't know how many
   cameras that page holds, but it can override how those same cells are ARRANGED without touching which
   camera is in which cell or any paging/rotation logic underneath. Below this width every tile reflows
   into a simple 2-up (1-up if the panel itself is narrower still) auto-flowing grid instead of cramming
   the desktop's column count into a phone screen — same cameras, same order, same click/zoom behaviour,
   just legible instead of illegibly tiny. Scrolls instead of clipping if a page has many tiles. */
@media (max-width: 640px) {
  .wall { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)) !important; grid-template-rows: none !important; overflow-y: auto; align-content: start; }
  .wall .tile, .wall .tile.empty { grid-column: auto !important; grid-row: auto !important; aspect-ratio: 16 / 9; }
  .wall.fill .tile, .wall.fill .tile.empty { aspect-ratio: 4 / 3; } /* "Fill tile" mode: a touch shorter, still stable so the crop isn't extreme on a 2-up phone grid */
}

/* Playback transport: the rewind-macro / step / play / fwd-macro row (9 controls) is wider than a phone
   screen at any reasonable button size. Hiding the macros would remove a real feature with no keyboard to
   fall back to on a phone — instead the row scrolls horizontally, centered content included, so every
   control stays one swipe away rather than disappearing. */
@media (max-width: 640px) {
  .pb-ctrl-center { max-width: 100%; overflow-x: auto; justify-content: flex-start; padding: 2px; -webkit-overflow-scrolling: touch; }
  .pb-ctrl-center > * { flex: none; }
}

/* ---------- TV mode (Settings > Display) ---------- */
/* Couch-distance legibility and a clear, high-contrast keyboard/remote focus ring for TV mode's own
   arrow-key grid navigation (live.js's _tvMove) — works alongside the normal mouse/touch/Tab paths
   everywhere else in the app, not instead of them, since a laptop driving a TV over HDMI (or just running
   TV mode at a desk for the bigger text) still has a real pointer.
   Scoped to the live view itself, not the shared app shell (topbar/nav) — the couch-distance case this
   solves is watching cameras, and every functional part of TV mode (arrow-key selection, forced SD, the
   tile focus ring) is Live-view-only. Enlarging the topbar too, tried first, bled into every other screen
   (Settings, Events, Playback) sharing that same header and left it looking larger than the page's own
   heading underneath it — a real hierarchy break, not a deliberate TV treatment of those screens. */
html.tv-mode { font-size: 19px; }   /* basis for the rem sizes below; nothing outside .liveview uses rem, so shared chrome elsewhere is untouched */
html.tv-mode .subbar { min-height: 60px; gap: 10px; padding: 10px 14px; }
/* .btn and .pill both come with their own fixed height (34px / 24px) from their base rules — padding alone
   doesn't grow a fixed-height box, it just eats into its content area, so every one of these needs its
   height overridden explicitly alongside the bigger padding, not just the padding. .btn/.pill already
   center their content via flex (display:inline-flex;align-items:center in their base rules), so once the
   height is actually bigger there's nothing else to fix for them. */
html.tv-mode .subbar .btn, html.tv-mode .subbar .pill { font-size: 1.05rem; height: 44px; padding: 0 18px; }
/* .seg button has NEITHER: no flex (default inline-block), and its centering relies entirely on
   line-height matching its fixed 28px height — the actual "not aligned, not properly rendered" bug
   reported live: forcing bigger padding without a matching height left content overflowing a box too
   short for it, line-height included, with nothing to re-center it once flex isn't there to do that job. */
html.tv-mode .subbar .seg { padding: 3px; }
html.tv-mode .subbar .seg button { font-size: 1.05rem; height: 40px; padding: 0 18px; display: flex; align-items: center; justify-content: center; }
/* .btn.icon (fullscreen, zoom, snapshot, etc.) is a fixed 34px square with zero padding by design (the SVG
   IS the content) — inheriting the text button's padding above put a 34px-border-box button through 16px
   of horizontal padding alone, squeezing its 18px icon into ~2px of content box. Same specificity as the
   base .btn.icon rule (two classes each), so being later in the file was enough to win; this restores its
   own sizing instead of the text-button treatment, just bigger. */
html.tv-mode .subbar .btn.icon, html.tv-mode .focus-bar .btn.icon { width: 44px; height: 44px; padding: 0; }
/* Per-tile quick actions stay reachable exactly the way they already are everywhere else in the app — a
   mouse hovering the tile (laptop/desktop, see .tile:hover) or the tile's hit-target having keyboard focus
   (.tile:focus-within, which now includes TV-mode's own arrow-key selection below) — nothing TV-specific
   needed here, it was already input-agnostic. */
html.tv-mode .tile .name { font-size: 1.15rem; }
html.tv-mode .tile .hit:focus-visible { outline: 4px solid var(--accent); outline-offset: -4px; z-index: 6; }
html.tv-mode .focus-bar { min-height: 64px; padding: 10px 14px; }
html.tv-mode .focus-bar h2 { font-size: 1.3rem; }
html.tv-mode .focus-bar .btn { font-size: 1.05rem; height: 44px; padding: 0 18px; }
html.tv-mode .focus-bar .btn:focus-visible,
html.tv-mode .nav a:focus-visible,
html.tv-mode .subbar .btn:focus-visible,
html.tv-mode .savebar .btn:focus-visible,
html.tv-mode button:focus-visible,
html.tv-mode a:focus-visible { outline: 4px solid var(--accent); outline-offset: 2px; }
html.tv-mode .nav-arrow { width: 56px; height: 56px; }

/* Full screen on the live grid, on any device (not TV mode specifically — see the .tv- prefixes' own note
   below): hide the subbar entirely — every stream tile is the point of this screen, not the toolbar above
   it — and leave a single floating cluster in its place (page prev/next, when there's more than one page,
   plus an indicator, plus exit), positioned over the video and hidden until the cursor/finger/remote moves
   (live.js's _bindWallFsAutoHide, same idle-hide cycle .focus-bar/.nav-arrow already use in the
   single-camera focus view). Escape (or the two-finger-pinch-out gesture on iPad/iPhone) always still works
   too — that's the browser's own Fullscreen API, not this cluster.
   Vertically centered on the right edge, not the top-right corner — every tile's own controls anchor to
   ONE of its corners (.tile-actions to its top, .zoomtag to its bottom when zoomed), and whichever tile
   ends up in the wall's own top-right or bottom-right corner sits exactly there too, so either corner could
   float this cluster directly over that tile's real controls and block them outright (found directly: the
   top-right tile's own expand toggle, unreachable underneath it). The vertical middle of the right edge has
   nothing else living there in the grid — same reasoning the single-camera focus view's own nav-arrows
   already use for exactly this edge.
   Named .tv-fs-* / built for TV mode first (page-switching + exit while full screen, on a screen with no
   normal way back to the subbar), but nothing about full-bleed full screen is actually TV-specific — a
   laptop, iPad, or iPhone wants exactly the same "maximum space for the cameras" once it's full screen too,
   so these rules apply generically rather than staying gated to html.tv-mode. The class names stayed as-is;
   renaming them touches nothing behavioral. */
.liveview:fullscreen .subbar { display: none; }
.tv-fs-controls { display: none; }
.liveview:fullscreen .tv-fs-controls {
  display: flex; align-items: center; gap: 4px; position: absolute; z-index: 8;
  top: 50%; transform: translateY(-50%); right: max(14px, env(safe-area-inset-right));
  padding: 6px; border-radius: 14px;
  background: rgba(15, 20, 28, .7); opacity: 0; pointer-events: none; transition: opacity .22s ease;
}
.liveview:fullscreen .tv-fs-controls.show { opacity: 1; pointer-events: auto; }
.tv-fs-btn { display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 10px; background: transparent; color: #fff; }
.tv-fs-btn[hidden] { display: none; }   /* single-page live: prev/next set [hidden] via JS — needs stating explicitly, since the display:grid above outranks the browser's own [hidden] default */
.tv-fs-btn:hover { background: var(--accent); color: var(--accent-ink); }
.tv-fs-page { padding: 0 4px; color: #fff; font-size: 1rem; font-variant-numeric: tabular-nums; white-space: nowrap; }

/* Same idle cycle, extended to every tile's own identifying chrome — name, live dot, SD/HD tag, the
   tile-actions menu, the zoom-reset pill — not just the subbar and the floating exit/page cluster above.
   Without this, a tile that's currently hovered, touched, or (TV mode) arrow-key-selected left its own
   .tile-actions visibly revealed forever (:hover/:focus-within, the same rules that reveal it everywhere
   else in the app — neither stops being true just because nothing moved), which defeated the point of a
   full screen with nothing but the cameras on it. live.js's _bindWallFsAutoHide toggles the same .show it
   already used for .tv-fs-controls, on .liveview itself so it survives renderWall()/renderBar() rebuilding
   .wall's and .subbar's own contents (unlike a class on .wall, which those calls reset on every
   layout/page/quality change). !important because this has to beat :hover/:focus-within's own reveal rules
   outright while idle, not merely tie with them on specificity — the same technique the mobile grid reflow
   already uses a bit further down for the same reason (an unconditional override, not one more rule in the
   normal cascade). .ev-badges (motion/tamper alerts) are deliberately exempt: real signal, not
   identification chrome, so idle is not a reason to hide it. */
.liveview:fullscreen:not(.show) .tile .ov,
.liveview:fullscreen:not(.show) .tile .tile-actions,
.liveview:fullscreen:not(.show) .tile > .zoomtag {
  opacity: 0 !important; pointer-events: none !important;
}
.liveview:fullscreen .tile .ov { transition: opacity .22s ease; }

/* ---------- sign-in (login.html, pair.html) ---------- */
/* body is overflow:hidden app-wide (the app scrolls inside its panes); these standalone pages scroll here. */
.auth-page { height: 100%; overflow-y: auto; display: grid; place-items: center; padding: 24px 16px; }
.auth-card { width: min(400px, 100%); padding: 28px 26px; border-radius: 16px; background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); }
.auth-card h1 { margin: 18px 0 6px; font-size: 20px; }
.auth-card .cc-icon { margin: 18px 0 0; }
.auth-brand { display: flex; align-items: center; gap: 9px; font-weight: 650; letter-spacing: .01em; }
.auth-lead { margin: 0 0 18px; color: var(--muted); }
.auth-form { display: flex; flex-direction: column; gap: 14px; margin-top: 16px; }
.auth-check { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 13.5px; }
.auth-check input { width: 16px; height: 16px; margin: 0; }
.auth-err { padding: 9px 12px; border-radius: 9px; border: 1px solid var(--danger); background: var(--danger-soft); font-size: 13px; }
.auth-submit { height: 40px; width: 100%; }
.auth-alt { display: flex; flex-direction: column; align-items: stretch; gap: 6px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
.auth-alt .hint { margin: 2px 0 0; color: var(--muted); font-size: 12.5px; text-align: center; }
.auth-pair { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 6px 0 4px; min-height: 60px; justify-content: center; }
.auth-countdown { text-align: center; color: var(--muted); font-size: 12.5px; margin: 8px 0 0; }
.qr { width: 184px; padding: 0; border-radius: 10px; background: #fff; line-height: 0; }
.qr svg { width: 100%; height: auto; display: block; }
.pair-code { font: 700 30px/1 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: .12em; color: var(--text); }
.pair-input { font-family: ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; }
/* A TV is read from across the room and driven with a remote: everything bigger, code most of all. */
html.tv-mode .auth-card { width: min(640px, 100%); padding: 40px; }
html.tv-mode .auth-card h1 { font-size: 30px; }
html.tv-mode .auth-lead { font-size: 20px; }
html.tv-mode .qr { width: 260px; }
html.tv-mode .pair-code { font-size: 56px; }
html.tv-mode .auth-card .btn { height: 48px; font-size: 18px; }
html.tv-mode .auth-card input[type=text], html.tv-mode .auth-card input[type=password] { height: 48px; font-size: 18px; }

/* ---------- account menu (topbar) ---------- */
.who { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 10px; border-radius: 99px; border: 1px solid var(--line-2); background: var(--panel-2); color: var(--text); font-size: 13px; max-width: 220px; }
.who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who .role { color: var(--muted); font-size: 12px; }
.who.lan { color: var(--muted); }
.acct-menu { min-width: 200px; display: flex; flex-direction: column; }
.acct-menu button, .acct-menu a { display: flex; align-items: center; gap: 9px; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text); text-decoration: none; text-align: left; }
.acct-menu button:hover, .acct-menu a:hover { background: var(--panel-2); }
.acct-menu .head { padding: 6px 10px 8px; border-bottom: 1px solid var(--line); margin-bottom: 4px; color: var(--muted); font-size: 12.5px; }
.acct-menu .head b { display: block; color: var(--text); font-size: 13.5px; }
.badge { display: inline-flex; align-items: center; height: 20px; padding: 0 7px; border-radius: 99px; font-size: 11.5px; font-weight: 600; background: var(--panel-2); border: 1px solid var(--line-2); color: var(--muted); white-space: nowrap; }
.badge.ok { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.badge.warn { color: var(--warn); border-color: var(--warn); }

/* ---------- account dialog + security tab ---------- */
.dialog.wide { width: min(560px, 100%); }
.dialog .form { grid-template-columns: 1fr; }
.dialog section + section { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
.dialog h4 { margin: 0 0 4px; font-size: 14px; }
.dialog .sub { margin: 0 0 12px; color: var(--muted); font-size: 13px; }
.codes { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px 14px; padding: 12px 14px; border-radius: 10px; background: var(--bg); border: 1px solid var(--line); font: 14px ui-monospace, monospace; }
.enrol { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.enrol .qr { width: 150px; flex: none; }
.secret { font: 13px ui-monospace, monospace; overflow-wrap: anywhere; color: var(--muted); }
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
.sess-list { display: flex; flex-direction: column; }
.sess { display: flex; align-items: center; gap: 12px; padding: 9px 0; border-top: 1px solid var(--line); }
.sess:first-child { border-top: 0; }
.sess .grow { flex: 1; min-width: 0; }
.sess .grow b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 550; }
.sess .grow small { color: var(--muted); font-size: 12.5px; }
table.tbl.sec-tbl { min-width: 560px; }
.warnbox { margin-top: 12px; padding: 10px 12px; border-radius: 9px; border: 1px solid var(--warn); font-size: 13px; }
@media (max-width: 800px) { .who .role { display: none; } }
/* A viewer (sign-in on, watch-only account) can't add bookmarks; the server refuses them anyway. */
html.role-viewer [data-a=bookmark] { display: none !important; }
a.btn { text-decoration: none; }
a.btn:not(.primary) { color: var(--text); }
a.btn.ghost { color: var(--muted); }
.tbl td.act .row-actions { flex-wrap: nowrap; }
.dialog h3 + section { margin-top: 14px; }
