/* ================================================================== AI SAFETY CCTV — operations console. Light, dense, professional. A safety board is calm most of the time and urgent rarely: near-monochrome chrome, indigo reserved for interaction, colour spent only on the unacknowledged-critical alarm. Density carries the "professional product" weight — a two-column console (device/camera rail + detection stream) with a compact KPI strip and snapshot-forward event rows. Accessibility (NF9): state is never colour alone — a dot/led always pairs with a text label. Air-gapped (폐쇄망): system fonts, zero external assets. ================================================================== */ :root { color-scheme: light; /* Surfaces — content is light; chrome (top bar + rail) is a second neutral layer, a hair cooler and elevated with hairline borders. */ --bg: #eef1f5; /* app canvas (cool light) */ --panel: #ffffff; /* top bar + rail surface */ --panel-2: #f7f9fc; /* rail inset / hover fill */ --card: #ffffff; /* content rows/cards */ --line: #e3e7ee; /* hairline border */ --line-2: #edf0f5; /* faint divider */ /* Ink ramp — contrast-checked on white. ink-2 ≈ 8:1, ink-3 ≈ 4.6:1. */ --ink: #131824; --ink-2: #48505e; --ink-3: #6b7382; /* Brand + interaction (indigo — only where you act: nav, focus, selection, primary). */ --accent: #4f46e5; --accent-ink: #ffffff; --accent-wash: #eceafe; --accent-line: #d6d2fb; /* Live monitoring (calm green, never an alarm). */ --ok: #15a34a; --ok-wash: #e7f6ec; /* Severity — reserved, semantic; identity always carries a text label too. */ --crit:#dc2626; --crit-ink:#a51d1d; --crit-bg:#fdf2f2; --crit-line:#f2cccc; --high:#e2560c; --high-bg:#fdf4ee; --high-line:#f5d9c4; --warn:#b7820a; --warn-bg:#fbf6e6; --info:#5b6473; --font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif; --mono:ui-monospace,"Cascadia Mono","Cascadia Code",Consolas,"D2Coding","Roboto Mono",monospace; --shadow-sm:0 1px 2px rgba(19,24,36,.05); --shadow: 0 1px 2px rgba(19,24,36,.04), 0 10px 26px -18px rgba(19,24,36,.42); --radius:12px; /* z-scale (semantic, not arbitrary) */ --z-sticky:100; --z-dropdown:200; --z-toast:400; } * { box-sizing:border-box; } html { -webkit-text-size-adjust:100%; } body { margin:0; font-family:var(--font); color:var(--ink); background:var(--bg); line-height:1.45; font-variant-numeric:tabular-nums; -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; } /* ---- Top bar (chrome, second neutral layer) ---------------------- */ .app-header { position:sticky; top:0; z-index:var(--z-sticky); display:flex; align-items:center; gap:1.1rem; padding:.6rem 1.25rem; background:var(--panel); border-bottom:1px solid var(--line); box-shadow:var(--shadow-sm); } .app-brand { display:flex; align-items:center; gap:.6rem; text-decoration:none; color:inherit; } .brand-mark { display:grid; place-items:center; width:34px; height:34px; flex:none; border-radius:9px; color:var(--accent); background:linear-gradient(180deg,#f3f2ff,#e9e7fe); border:1px solid var(--accent-line); } .brand-mark svg { width:20px; height:20px; display:block; } .brand-text { display:flex; flex-direction:column; line-height:1.12; } .brand-name { font-weight:750; font-size:.92rem; letter-spacing:.02em; color:var(--ink); } .brand-tag { font-size:.68rem; color:var(--ink-3); letter-spacing:.01em; } /* legacy hook from the old header — kept harmless */ .live-dot { display:none; } .app-nav { display:flex; gap:.15rem; margin-left:.6rem; margin-right:auto; } .app-nav a { position:relative; color:var(--ink-2); text-decoration:none; font-size:.86rem; font-weight:550; padding:.42rem .7rem; border-radius:8px; transition:background .15s, color .15s; } .app-nav a:hover, .app-nav a:focus-visible { background:var(--panel-2); color:var(--ink); } .app-nav a.is-active { color:var(--accent); background:var(--accent-wash); } .app-session { display:flex; align-items:center; gap:.7rem; font-size:.84rem; color:var(--ink-2); } .app-session .who { color:var(--ink); font-weight:550; display:inline-flex; align-items:center; gap:.4rem; } .app-session .who::before { content:""; width:22px; height:22px; border-radius:50%; flex:none; background:radial-gradient(circle at 50% 38%, #fff 0 34%, transparent 35%), var(--accent-wash); border:1px solid var(--accent-line); } .app-session a { color:var(--accent); text-decoration:none; font-weight:550; } .app-session a:hover { text-decoration:underline; text-underline-offset:3px; } /* ---- Controls: buttons, fields ----------------------------------- */ button, .btn { font:inherit; font-size:.82rem; font-weight:550; cursor:pointer; padding:.4rem .78rem; color:var(--ink); background:var(--card); border:1px solid var(--line); border-radius:9px; transition:transform .1s, background .15s, border-color .15s, box-shadow .15s; } button:hover, .btn:hover { background:var(--panel-2); border-color:#d3d9e2; } button:active, .btn:active { transform:translateY(.5px) scale(.99); } button:disabled { cursor:default; } :focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; } input, select { font:inherit; font-size:.86rem; color:var(--ink); padding:.44rem .6rem; background:var(--card); border:1px solid var(--line); border-radius:9px; transition:border-color .15s, box-shadow .15s; } input::placeholder { color:var(--ink-3); } input:focus, select:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-wash); } label { color:var(--ink-2); font-size:.84rem; } /* ---- Page shell -------------------------------------------------- */ .app-main { max-width:1380px; margin:0 auto; padding:1.5rem 1.25rem 3rem; } /* ---- Console: device/camera rail + detection stream -------------- */ .console { display:grid; grid-template-columns:264px minmax(0,1fr); gap:1.25rem; align-items:start; } .rail { position:sticky; top:calc(46px + 1.5rem); display:flex; flex-direction:column; gap:1.1rem; background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:1rem .9rem; box-shadow:var(--shadow-sm); } .rail-block { display:flex; flex-direction:column; gap:.6rem; } .rail-block + .rail-block { padding-top:1rem; border-top:1px solid var(--line-2); } .rail-title { margin:0; font-size:.7rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--ink-3); } .rail-filters { display:flex; flex-direction:column; gap:.6rem; } .rail-filters .field { display:flex; flex-direction:column; gap:.3rem; font-size:.74rem; font-weight:600; color:var(--ink-3); letter-spacing:.02em; text-transform:uppercase; } .rail-filters select { text-transform:none; font-size:.85rem; font-weight:400; letter-spacing:0; color:var(--ink); } .filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.85rem; color:var(--ink-2); cursor:pointer; user-select:none; } .filter-check input { width:15px; height:15px; accent-color:var(--accent); } /* ---- Stream (main) ----------------------------------------------- */ .stream { display:flex; flex-direction:column; min-width:0; } .stream-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.9rem; } .stream-title { display:flex; align-items:baseline; gap:.7rem; } h1 { font-size:1.35rem; font-weight:760; letter-spacing:-.02em; margin:0; } .page-head { display:flex; align-items:baseline; gap:.8rem; margin:0 0 .25rem; flex-wrap:wrap; } .page-sub { color:var(--ink-3); font-size:.86rem; margin:.15rem 0 1.4rem; } .section-label { font-size:.72rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; margin:0 0 .7rem; } /* Live watch pill — green when polling, muted when stale; dot breathes while live. */ .watch { font-size:.8rem; font-weight:650; color:var(--ok); display:inline-flex; align-items:center; gap:.4rem; } .watch::before { content:""; width:7px; height:7px; border-radius:50%; background:currentColor; flex:none; box-shadow:0 0 0 0 currentColor; animation:watch-pulse 2.4s ease-out infinite; } .watch.is-stale { color:var(--ink-3); } .watch.is-stale::before { animation:none; } @keyframes watch-pulse { 0%{ box-shadow:0 0 0 0 rgba(21,163,74,.4);} 70%,100%{ box-shadow:0 0 0 6px rgba(21,163,74,0);} } /* ---- KPI strip: compact unacknowledged counts -------------------- */ /* Computed client-side from the /api/events window (no extra endpoint). A tight inline strip of pills, not four hero cards — dense, glanceable. Zero dims, never hides. */ #events-status.kpi-strip { display:flex; gap:.5rem; flex-wrap:wrap; } .stat-tile { display:inline-flex; align-items:center; gap:.5rem; padding:.4rem .7rem; background:var(--card); border:1px solid var(--line); border-radius:999px; box-shadow:var(--shadow-sm); } .stat-tile .stat-label { display:inline-flex; align-items:center; gap:.4rem; font-size:.76rem; font-weight:600; color:var(--ink-2); } .stat-tile .stat-label .led { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dot,#c4c9d0); } .stat-tile .stat-label .icon { display:none; } .stat-tile .stat-count { font-size:1rem; font-weight:750; letter-spacing:-.02em; line-height:1; color:var(--ink); } .stat-tile.is-zero { opacity:.55; } .stat-tile.is-zero .stat-count { color:var(--ink-3); } .tile-critical{ --dot:var(--crit);} .tile-high{ --dot:var(--high);} .tile-warning{ --dot:var(--warn);} .tile-info{ --dot:var(--info);} .tile-critical:not(.is-zero) { background:var(--crit-bg); border-color:var(--crit-line); } .tile-critical:not(.is-zero) .stat-count, .tile-critical:not(.is-zero) .stat-label { color:var(--crit-ink); } /* ---- Device / camera tree (rail) --------------------------------- */ /* Heartbeat telemetry the edge reports (online/offline, fps, temp, throttle, drift). A dead camera is a safety blind spot → an offline row takes the alarm red, with the "오프라인/응답 없음" text carrying state alongside the dot (NF9). */ .device-strip { display:flex; flex-direction:column; gap:.4rem; } .device-strip.is-empty { color:var(--ink-3); font-size:.82rem; padding:.3rem 0; } .device-card { background:var(--panel-2); border:1px solid var(--line); border-radius:10px; padding:.55rem .65rem; transition:border-color .15s, background .15s; } .device-card .device-name { display:flex; align-items:center; gap:.45rem; font-weight:600; font-size:.85rem; color:var(--ink); } .device-card .device-name .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; box-shadow:0 0 0 3px var(--ok-wash); } .device-card .device-meta { margin-top:.28rem; padding-left:.02rem; font-family:var(--mono); font-size:.72rem; color:var(--ink-3); } .device-card .device-warn { margin-top:.32rem; font-size:.7rem; font-weight:650; color:var(--warn); } .device-card.is-offline { background:var(--crit-bg); border-color:var(--crit-line); } .device-card.is-offline .device-name { color:var(--crit-ink); } .device-card.is-offline .device-name .dot { background:var(--crit); box-shadow:0 0 0 3px var(--crit-bg); } .device-strip.is-stale { opacity:.6; } .device-stale-note { font-size:.7rem; color:var(--ink-3); padding-bottom:.2rem; } /* ---- Detection stream: event rows -------------------------------- */ .event-list { display:flex; flex-direction:column; gap:.5rem; } .event-list[aria-busy="true"] .stream-empty { color:var(--ink-3); } .stream-empty { padding:2.5rem 1rem; text-align:center; color:var(--ink-3); font-size:.9rem; } .event-row { display:flex; align-items:center; gap:.9rem; background:var(--card); border:1px solid var(--line); border-radius:11px; padding:.6rem .8rem; box-shadow:var(--shadow-sm); transition:border-color .15s, box-shadow .15s, transform .1s; } .event-row:hover { border-color:#d3d9e2; box-shadow:var(--shadow); } /* The one row that takes colour: an unacknowledged critical — full tint + border + a soft attention glow (state, not decoration). Never a bare colour: badge + "확인 필요" chip too. */ .event-row.is-unacknowledged.sev-row-critical { background:var(--crit-bg); border-color:var(--crit-line); animation:crit-attention 2.6s ease-out infinite; } .event-row.is-unacknowledged.sev-row-high { background:var(--high-bg); border-color:var(--high-line); } @keyframes crit-attention { 0%{ box-shadow:0 0 0 0 rgba(220,38,38,.18);} 70%,100%{ box-shadow:0 0 0 5px rgba(220,38,38,0);} } /* Evidence slot — a camera still on the left of every row (aligned, CCTV-grid feel). */ .event-thumb-slot { position:relative; width:74px; height:52px; flex:none; border-radius:8px; border:1px solid var(--line); background:#0e1116; overflow:hidden; } .event-thumb { width:100%; height:100%; object-fit:cover; display:block; } /* No snapshot (none captured, or purged by PIPA retention): an empty frame, not a black feed. */ .event-thumb-slot.is-empty { border-style:dashed; border-color:#cdd3dc; background:var(--panel-2); } .event-thumb-slot.is-empty::after { content:"무영상"; position:absolute; inset:0; display:grid; place-items:center; font-size:.62rem; color:var(--ink-3); } .severity-badge { display:inline-flex; align-items:center; gap:.42rem; flex:none; min-width:58px; font-size:.8rem; font-weight:650; white-space:nowrap; } .severity-badge .icon { width:9px; height:9px; border-radius:50%; background:currentColor; font-size:0; line-height:0; flex:none; } .sev-critical{ color:var(--crit);} .sev-high{ color:var(--high);} .sev-warning{ color:var(--warn);} .sev-info{ color:var(--info);} .event-meta { flex:1; min-width:0; } .event-type { font-weight:650; font-size:.95rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem; } .event-time { margin-top:.15rem; font-family:var(--mono); font-size:.75rem; color:var(--ink-3); } .event-detail-link { color:inherit; text-decoration:none; } .event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; } .chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; } .ack-btn { margin-left:auto; flex:none; } .ack-btn:disabled { color:var(--ink-3); background:none; border-color:transparent; box-shadow:none; font-family:var(--mono); font-size:.76rem; } /* "new events arrived while paging" reload affordance — honest about a possible gap. */ .page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .7rem; padding:.5rem .75rem; font-size:.8rem; color:var(--ink-2); background:var(--accent-wash); border:1px solid var(--accent-line); border-radius:9px; } .page-gap-hint[hidden] { display:none; } .page-gap-hint button { margin-left:auto; } .list-more { display:flex; justify-content:center; margin-top:.8rem; } .list-more button[hidden] { display:none; } /* legacy list toolbar hooks (filters moved into the rail) */ .list-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.7rem; } .list-filters { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; } /* ---- Login ------------------------------------------------------- */ .login-form { display:flex; flex-direction:column; gap:.7rem; max-width:360px; margin:4rem auto 0; background:var(--card); border:1px solid var(--line); border-radius:16px; padding:2rem 1.9rem; box-shadow:var(--shadow); } .login-form h1 { margin:.1rem 0 .5rem; font-size:1.3rem; } .login-form label { display:flex; flex-direction:column; gap:.35rem; font-size:.8rem; font-weight:600; color:var(--ink-2); } .login-form button { margin-top:.4rem; padding:.6rem; font-size:.9rem; background:var(--accent); color:var(--accent-ink); border-color:var(--accent); } .login-form button:hover { background:#4239d6; border-color:#4239d6; } .form-error { color:var(--crit); font-size:.85rem; } /* ---- Zone editor ------------------------------------------------- */ .zone-controls { display:flex; gap:.6rem; flex-wrap:wrap; align-items:end; margin-bottom:.9rem; } .zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; } #zone-canvas { border:1px solid var(--line); border-radius:12px; background:#0e1116 center / contain no-repeat; cursor:crosshair; max-width:100%; box-shadow:var(--shadow); } #zone-list { list-style:none; padding:0; margin:1rem 0; display:flex; flex-direction:column; gap:.4rem; } #zone-list li { display:flex; align-items:center; justify-content:space-between; gap:.75rem; max-width:380px; padding:.5rem .75rem; background:var(--card); border:1px solid var(--line); border-radius:10px; font-size:.85rem; color:var(--ink-2); } #zone-status { font-family:var(--mono); font-size:.82rem; color:var(--ink-3); } /* ---- Incident detail -------------------------------------------- */ .detail-row { margin:.35rem 0; } .detail-row .detail-label { color:var(--ink-3); font-weight:700; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; } #event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; margin-top:1.2rem; } .detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:12px; background:#fff; padding:4px; box-shadow:var(--shadow); } /* ---- Responsive: rail collapses above the stream ----------------- */ @media (max-width:860px) { .console { grid-template-columns:1fr; } .rail { position:static; } .rail-block { flex-direction:column; } .rail-filters { flex-direction:row; flex-wrap:wrap; align-items:end; } .device-strip { flex-direction:row; flex-wrap:wrap; } .device-card { flex:1 1 12rem; } } @media (max-width:560px) { .app-main { padding:1rem .9rem 2.5rem; } .brand-tag { display:none; } .event-thumb { width:60px; height:42px; } } /* ---- Reduced motion ---------------------------------------------- */ @media (prefers-reduced-motion:reduce) { *, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; } }