/* ================================================================== MOVE·DETECT dashboard — light, calm, minimal. A safety board is calm most of the time and urgent rarely, so it stays near-monochrome with ONE interactive accent (indigo) and spends its only real color on the unacknowledged-critical alarm. Soft cards, generous whitespace, big tabular numbers. Air-gapped (폐쇄망): system fonts only, no external assets fetched at runtime. ================================================================== */ :root { color-scheme: light; --bg:#f5f6f8; --card:#ffffff; --line:#e7e9ee; --ink:#161a20; --ink-2:#5b636e; --ink-3:#949aa4; --accent:#4f46e5; /* indigo — only where you act (nav, links, focus) */ --ok:#16a34a; /* "감시 중" live-monitoring green */ /* Severity (reserved, semantic — never reused as a decorative color). Identity is always carried by a text label too (NF9: never color alone). */ --crit:#dc2626; --high:#ea580c; --warn:#ca8a04; --info:#64748b; --crit-bg:#fdf3f3; --crit-line:#f4c9c9; --border:var(--line); /* back-compat alias */ --text:var(--ink); --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:0 1px 2px rgba(20,24,31,.04), 0 8px 24px -18px rgba(20,24,31,.5); } * { box-sizing:border-box; } body { margin:0; font-family:var(--font); color:var(--ink); background:var(--bg); line-height:1.45; -webkit-font-smoothing:antialiased; } /* ---- Header ------------------------------------------------------ */ .app-header { display:flex; align-items:center; gap:1rem; padding:.85rem 1.6rem; background:var(--card); border-bottom:1px solid var(--line); } .app-brand { display:flex; align-items:center; gap:.55rem; font-weight:700; font-size:.98rem; letter-spacing:-.01em; } /* Live-monitoring dot — the board is watching. Calm green, not an alarm. */ .live-dot { width:8px; height:8px; border-radius:50%; background:var(--ok); box-shadow:0 0 0 3px rgba(22,163,74,.14); flex:none; } .brand-name { color:var(--ink); } .brand-sub { display:none; } /* the mono wordmark is gone; keep the hook harmless */ .app-nav { display:flex; gap:.2rem; margin-left:1.8rem; margin-right:auto; } .app-nav a { position:relative; color:var(--ink-2); text-decoration:none; font-size:.87rem; font-weight:500; padding:.45rem .75rem; border-radius:8px; } .app-nav a:hover, .app-nav a:focus-visible { background:#f1f2f5; color:var(--ink); text-decoration:none; } .app-nav a.is-active { color:var(--accent); } .app-nav a.is-active::after { content:""; position:absolute; left:.75rem; right:.75rem; bottom:-.86rem; height:2px; background:var(--accent); border-radius:2px; } .app-session { display:flex; align-items:center; gap:.7rem; font-size:.85rem; color:var(--ink-2); } .app-session .who { color:var(--ink); } .app-session a { color:var(--accent); text-decoration:none; font-weight:500; } .app-session a:hover { text-decoration:underline; text-underline-offset:3px; } /* ---- Buttons / fields -------------------------------------------- */ button, .btn { font:inherit; font-size:.83rem; font-weight:500; cursor:pointer; padding:.42rem .8rem; color:var(--ink); background:#fff; border:1px solid var(--line); border-radius:9px; transition:transform .1s, background .15s, border-color .15s; } button:hover, .btn:hover { background:#f7f8fa; border-color:#dcdfe4; } button:active, .btn:active { transform:scale(.98); } :focus-visible { outline:2px solid var(--accent); outline-offset:2px; } input, select { font:inherit; font-size:.9rem; color:var(--ink); padding:.5rem .65rem; background:#fff; border:1px solid var(--line); border-radius:9px; } input::placeholder { color:var(--ink-3); } input:focus, select:focus { outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent); } label { color:var(--ink-2); font-size:.85rem; } /* ---- Page shell -------------------------------------------------- */ .app-main { max-width:1000px; margin:0 auto; padding:2rem 1.6rem 4rem; } .page-head { display:flex; align-items:baseline; gap:.8rem; margin:0 0 .25rem; flex-wrap:wrap; } h1 { font-size:1.5rem; font-weight:750; letter-spacing:-.025em; margin:.1rem 0 0; } /* Live watch pill (set by app.js from poll health): green when polling, muted when stale. The dot is a ::before so app.js can freely overwrite the text via textContent. */ .watch { font-size:.82rem; font-weight:600; color:var(--ok); display:inline-flex; align-items:center; gap:.35rem; } .watch::before { content:""; width:6px; height:6px; border-radius:50%; background:currentColor; flex:none; } .watch.is-stale { color:var(--ink-3); } .page-sub { color:var(--ink-3); font-size:.88rem; margin:.15rem 0 1.8rem; } /* ---- Status strip: KPI tiles ------------------------------------- */ /* Unacknowledged counts per severity, computed client-side from the same /api/events array the list renders (no extra endpoint). Identity is a colored dot + a text label, never color alone (NF9). A zero tile dims but never hides, so the strip never reflows. */ .status-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:.85rem; margin:0 0 2rem; } .stat-tile { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:1.05rem 1.15rem; box-shadow:var(--shadow); } .stat-tile .stat-label { display:flex; align-items:center; gap:.45rem; font-size:.79rem; font-weight:500; 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; } /* the dot carries the color; the emoji is dropped in this theme */ .stat-tile .stat-count { display:block; margin-top:.5rem; font-size:2.3rem; font-weight:730; letter-spacing:-.04em; line-height:1; color:var(--ink); font-variant-numeric:tabular-nums; } .stat-tile.is-zero { opacity:.62; } .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); } /* The one moment of color: an unacknowledged critical tints its tile. */ .tile-critical:not(.is-zero) { background:var(--crit-bg); border-color:var(--crit-line); } .tile-critical:not(.is-zero) .stat-count { color:var(--crit); } /* ---- Device / camera health panel -------------------------------- */ /* Surfaces the heartbeat telemetry the edge already reports (online/offline, fps, temp, throttle, config drift). A dead camera is a safety blind spot; an offline card takes the same red the critical alarm does. State is never color alone — the "오프라인/응답 없음" text carries it too (NF9). */ .device-strip { display:flex; flex-wrap:wrap; gap:.6rem; margin:0 0 2rem; } .device-strip.is-empty { color:var(--ink-3); font-size:.85rem; } .device-card { flex:1 1 11rem; min-width:11rem; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:.7rem .85rem; box-shadow:var(--shadow); } .device-card .device-name { display:flex; align-items:center; gap:.45rem; font-weight:600; font-size:.9rem; } .device-card .device-name .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; } .device-card .device-meta { margin-top:.3rem; font-family:var(--mono); font-size:.76rem; color:var(--ink-3); } .device-card .device-warn { margin-top:.35rem; font-size:.72rem; font-weight:600; color:var(--warn); } .device-card.is-offline { background:var(--crit-bg); border-color:var(--crit-line); } .device-card.is-offline .device-name .dot { background:var(--crit); } /* Device poll went stale (500 / WAF): dim the last-known cards and say so, never freeze silently. */ .device-strip.is-stale { opacity:.6; } .device-stale-note { flex-basis:100%; font-size:.72rem; color:var(--ink-3); } /* "new events arrived while paging" reload affordance — honest about a possible gap in the list. */ .page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .8rem; padding:.5rem .75rem; font-size:.8rem; color:var(--ink-2); background:var(--card); border:1px solid var(--line); border-radius:8px; } .page-gap-hint[hidden] { display:none; } /* ---- Event list: soft cards -------------------------------------- */ .event-row { display:flex; align-items:center; gap:1rem; background:var(--card); border:1px solid var(--line); border-radius:14px; padding:.95rem 1.15rem; margin-bottom:.6rem; box-shadow:var(--shadow); } /* An unacknowledged critical is the only row that takes color; the tint + red badge + "확인 필요" chip make it unmissable without shouting. */ .event-row.is-unacknowledged.sev-row-critical { background:var(--crit-bg); border-color:var(--crit-line); } /* Evidence thumbnail (newest snapshot) — a small camera still on the left of the row. */ .event-thumb { width:64px; height:44px; object-fit:cover; border-radius:8px; border:1px solid var(--line); background:#0f1216; flex:none; } .severity-badge { display:inline-flex; align-items:center; gap:.45rem; flex:none; min-width:66px; font-size:.84rem; font-weight:600; white-space:nowrap; } /* app.js/detail.js build the badge as (emoji)text. In this theme the icon becomes a colored dot (emoji hidden) and the label carries identity. */ .severity-badge .icon { width:9px; height:9px; border-radius:50%; background:currentColor; font-size:0; line-height:0; } .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:1rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem; } .event-time { margin-top:.2rem; font-family:var(--mono); font-size:.79rem; 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, appended by app.js to an unacknowledged critical row. */ .chip { font-size:.72rem; font-weight:600; color:var(--crit); background:#fbe2e2; padding:.12rem .5rem; border-radius:999px; } .ack-btn { margin-left:auto; flex:none; } .ack-btn:disabled { cursor:default; color:var(--ink-3); background:none; border:none; box-shadow:none; font-family:var(--mono); font-size:.8rem; } /* ---- Section labels --------------------------------------------- */ .section-label { font-size:.79rem; font-weight:600; color:var(--ink-3); letter-spacing:.04em; margin:0 0 .7rem; } /* ---- List toolbar: filters + "더 보기" --------------------------- */ .list-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.7rem; } .list-head .section-label { margin:0; } .list-filters { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; } .list-filters select { font-size:.82rem; padding:.35rem .5rem; } .filter-check { display:inline-flex; align-items:center; gap:.35rem; font-size:.84rem; color:var(--ink-2); cursor:pointer; user-select:none; } .filter-check input { accent-color:var(--accent); } .list-more { display:flex; justify-content:center; margin-top:.5rem; } .list-more button[hidden] { display:none; } /* ---- Login ------------------------------------------------------- */ .login-form { display:flex; flex-direction:column; gap:.65rem; max-width:340px; margin:3.5rem auto 0; background:var(--card); border:1px solid var(--line); border-radius:16px; padding:1.8rem; box-shadow:var(--shadow); } .login-form h1 { margin:.1rem 0 .5rem; } .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:#0f1216 center / contain no-repeat; /* dark canvas: a camera frame reads best on dark */ 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:360px; 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:600; font-size:.75rem; 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 -------------------------------------------------- */ @media (max-width:720px) { .status-strip { grid-template-columns:repeat(2,1fr); } .app-nav { margin-left:0; } .app-main { padding:1.5rem 1.1rem 3rem; } } @media (max-width:420px) { .status-strip { grid-template-columns:1fr 1fr; } }