/* ── CCTV Panel ─────────────────────────────── */ #cctv-panel { --panel-expanded-width: 360px; position: fixed; top: var(--left-stack-top); left: var(--left-stack-x); right: auto; bottom: auto; z-index: 100; width: var(--panel-expanded-width); max-height: calc(100vh - 24px); } #cctv-panel .panel-glow { position: absolute; inset: -18px; background: radial-gradient( ellipse at center, rgba(0, 212, 255, 0.16) 0%, transparent 65% ); filter: blur(16px); opacity: 0.25; pointer-events: none; } .cctv-panel-inner { position: relative; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--panel-radius); padding: 14px; display: flex; flex-direction: column; gap: 8px; max-height: calc(100vh - 24px); overflow-y: auto; scrollbar-width: thin; scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06); backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.03) inset, 0 1px 0 rgba(255, 255, 255, 0.06) inset; } .cctv-panel-inner::-webkit-scrollbar { width: 8px; } .cctv-panel-inner::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.04); border-radius: 999px; } .cctv-panel-inner::-webkit-scrollbar-thumb { background: linear-gradient( 180deg, rgba(0, 212, 255, 0.6), rgba(0, 212, 255, 0.28) ); border: 1px solid rgba(0, 212, 255, 0.4); border-radius: 999px; } .cctv-controls { display: flex; gap: 8px; margin-bottom: 0; } /* Spanish labels overflow the nowrap .scene-btn row of the fixed-width panel; wrap the row under es only, English stays as is. */ html[lang='es'] .cctv-controls { flex-wrap: wrap; } /* A label longer than its button wraps inside it, not past the panel edge. */ html:not([lang='en']) .cctv-controls .scene-btn { white-space: normal; overflow-wrap: break-word; } .cctv-calibration-block { border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 9px; padding: 8px 9px; background: rgba(255, 255, 255, 0.02); } .cctv-calibration-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; } .cctv-calibration-header .cctv-summary-label { margin: 0; } /* Click-to-edit effective-pose readout (viewshed/gizmo design §3d): compact value chips replace the seven offset sliders. */ .cctv-cal-readout { display: flex; flex-wrap: wrap; gap: 5px; } .cctv-cal-input { width: 58px; } .cctv-cal-value { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.7px; color: #91edff; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 7px; padding: 4px 7px; cursor: text; transition: border-color 0.15s ease, background 0.15s ease; } .cctv-cal-value:hover:not(:disabled) { border-color: rgba(0, 212, 255, 0.45); background: rgba(0, 212, 255, 0.08); } .cctv-calibration-actions { margin-top: 8px; } #cctv-panel input:disabled, #cctv-panel button:disabled, #cctv-panel select:disabled { opacity: 0.45; } #cctv-camera-select { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #cctv-enable-btn.active, #cctv-coverage-btn.active, #cctv-auto-hop-btn.active, #cctv-projection-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(0, 212, 255, 0.14); } #cctv-quality-chip { min-width: 90px; --status-badge-border: rgba(0, 212, 255, 0.2); --status-badge-color: #8fe7ff; --status-badge-background: rgba(0, 212, 255, 0.08); } /* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no in-world tint). Three visually distinct states: calibrated / curated / raw-prior. */ #cctv-quality-chip[data-cal-badge='calibrated'] { --status-badge-color: #adffbf; --status-badge-border: rgba(141, 255, 135, 0.42); --status-badge-background: rgba(141, 255, 135, 0.12); } #cctv-quality-chip[data-cal-badge='curated'] { --status-badge-color: #ffe6a2; --status-badge-border: rgba(255, 217, 122, 0.35); --status-badge-background: rgba(255, 217, 122, 0.1); } /* Unsaved live edits (save-gated persistence, viewshed design §3e). */ #cctv-quality-chip[data-cal-badge='edited'] { --status-badge-color: #ffd08a; --status-badge-border: rgba(255, 176, 82, 0.5); --status-badge-background: rgba(255, 176, 82, 0.14); } .cctv-frame-wrap { position: relative; border-radius: 10px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.12); background: radial-gradient( circle at 20% 20%, rgba(0, 244, 255, 0.09), transparent 44% ), linear-gradient(180deg, rgba(8, 14, 19, 0.88), rgba(4, 7, 11, 0.96)); min-height: 150px; /* .cctv-panel-inner is a scrolling column flex container, so the default flex-shrink:1 squeezed this wrap down to its 150px floor while the 16:9 image inside is taller — and the wrap is overflow:hidden, which clipped the bottom of the frame right back off. Never shrink the preview. */ flex-shrink: 0; } /* Lightweight loading shimmer while the active frame is fetching */ .cctv-frame-wrap::after { content: ''; position: absolute; inset: 0; background: linear-gradient( 100deg, transparent 24%, rgba(0, 212, 255, 0.08) 44%, rgba(170, 244, 255, 0.14) 50%, rgba(0, 212, 255, 0.08) 56%, transparent 76% ); transform: translateX(-100%); opacity: 0; pointer-events: none; transition: opacity 0.2s ease; } .cctv-frame-wrap.loading::after { opacity: 1; animation: cctv-frame-shimmer 1.4s linear infinite; } /* "Acquiring" caption only when no previous frame is showing underneath. The text lives in the markup (data-i18n) so it is translated. */ .cctv-frame-caption { display: none; position: absolute; inset: 0; align-items: center; justify-content: center; font-family: var(--font-mono); font-size: 8px; letter-spacing: 2.2px; color: rgba(145, 237, 255, 0.62); pointer-events: none; } .cctv-frame-wrap.loading:not(.has-frame) .cctv-frame-caption { display: flex; } @keyframes cctv-frame-shimmer { to { transform: translateX(100%); } } #cctv-frame { width: 100%; /* A fixed 16:9 box with the frame CONTAINED inside it. Providers burn their timestamp into a corner (TfL top-left, Austin bottom-left) and the old 150px-tall box was ~2.2:1, so object-fit: cover ate ~18px off the top and bottom — exactly where those stamps live (owner field test 2026-07-31). Sources are not all 16:9 either (a TfL frame measures 352x288), so `contain` is what actually guarantees nothing is cut; the ratio just keeps the panel height stable as you switch cameras. */ aspect-ratio: 16 / 9; height: auto; object-fit: contain; display: block; opacity: 0; transition: opacity 180ms ease; } #cctv-video { width: 100%; height: 100%; display: block; object-fit: contain; } #cctv-frame.active { opacity: 1; } #cctv-video:not([hidden]) { opacity: 1; } #cctv-frame[hidden], #cctv-video[hidden] { display: none; } #cctv-meta { font-family: var(--font-mono); font-size: 9px; letter-spacing: 1px; color: var(--text-secondary); } #cctv-source-badge { align-self: flex-start; } #cctv-source-badge[data-frame-state='loading'] { --status-badge-color: #ffe59b; --status-badge-border: rgba(255, 205, 92, 0.45); --status-badge-background: rgba(255, 205, 92, 0.1); } #cctv-source-badge[data-frame-state='error'] { --status-badge-color: #ffb0b0; --status-badge-border: rgba(255, 102, 102, 0.5); --status-badge-background: rgba(255, 72, 72, 0.11); } .cctv-summary-label { font-family: var(--font-mono); font-size: 8px; letter-spacing: 1.7px; color: var(--text-dim); } #cctv-summary { min-height: 38px; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.8px; line-height: 1.45; color: #a8f7ff; text-shadow: 0 0 10px rgba(0, 212, 255, 0.18); } /* Tighter calibration chips keep the seven values to two rows, so SAVE / RESET stay in view. */ html:not([lang='en']) .cctv-cal-readout { gap: 4px; } html:not([lang='en']) .cctv-cal-value { flex: 1 1 auto; white-space: nowrap; letter-spacing: 0.3px; padding: 4px; } html:not([lang='en']) #cctv-camera-select { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em; } /* The camera name gets its own row; PREV / NEXT share the row below it. */ html:not([lang='en']) .cctv-controls:has(#cctv-camera-select) { flex-wrap: wrap; } html:not([lang='en']) #cctv-camera-select { flex: 1 1 100%; order: -1; }