/* ── 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 run ~25% longer than English and overflow the nowrap .scene-btn rows inside the fixed-width CCTV panel (e.g. ENFOCAR · COBERTURA DESACTIVADA · AUTO HOP DESACTIVADO). Let the rows wrap under es only; the English layout stays pixel-identical. */ html[lang='es'] .cctv-controls { flex-wrap: wrap; } /* A translated label longer than its button wraps inside the button instead of running past the panel edge. English labels fit on one line, so the English layout is unchanged. */ .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-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-cal-input { width: 58px; font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.7px; color: var(--text-primary); background: rgba(0, 212, 255, 0.1); border: 1px solid rgba(0, 212, 255, 0.5); border-radius: 4px; padding: 1px 3px; outline: none; -moz-appearance: textfield; } .cctv-cal-input::-webkit-outer-spin-button, .cctv-cal-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .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; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 7px; color: var(--text-primary); padding: 6px 8px; font-family: var(--font-sans); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; outline: none; } #cctv-camera-select:focus-visible { outline: 2px solid var(--text-primary); outline-offset: -3px; } #cctv-camera-select:disabled { opacity: 0.45; } #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 { display: inline-flex; align-items: center; justify-content: center; min-width: 90px; padding: 6px 8px; border: 1px solid rgba(0, 212, 255, 0.2); border-radius: 8px; font-family: var(--font-mono); font-size: 9px; letter-spacing: 1px; color: #8fe7ff; background: rgba(0, 212, 255, 0.08); text-transform: uppercase; } /* 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'] { color: #adffbf; border-color: rgba(141, 255, 135, 0.42); background: rgba(141, 255, 135, 0.12); } #cctv-quality-chip[data-cal-badge='curated'] { color: #ffe6a2; border-color: rgba(255, 217, 122, 0.35); background: rgba(255, 217, 122, 0.1); } /* Unsaved live edits (save-gated persistence, viewshed design §3e). */ #cctv-quality-chip[data-cal-badge='edited'] { color: #ffd08a; border-color: rgba(255, 176, 82, 0.5); 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 */ .cctv-frame-wrap.loading:not(.has-frame)::before { content: 'ACQUIRING FRAME'; position: absolute; inset: 0; display: flex; 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; } @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; border: 1px solid rgba(0, 212, 255, 0.32); border-radius: 999px; background: rgba(0, 212, 255, 0.09); color: #9defff; font-family: var(--font-mono); font-size: 8px; letter-spacing: 1.3px; padding: 4px 8px; } #cctv-source-badge[data-frame-state='loading'] { color: #ffe59b; border-color: rgba(255, 205, 92, 0.45); background: rgba(255, 205, 92, 0.1); } #cctv-source-badge[data-frame-state='error'] { color: #ffb0b0; border-color: rgba(255, 102, 102, 0.5); 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); } /* Longer translated labels and units: tighter calibration chips keep the seven values to two rows so SAVE / RESET stay in view, and the camera select uses the compact mono face. English keeps its original look. */ 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; }