move_detect/server/public/assets/app.css
유창욱 d8b8e1f381 feat(analytics): weekday-hour pattern, precursor-spike alert, period trends
The accumulated data starts predicting instead of just recording:

- Time pattern (weekday x hour density grid): WHEN the site is dangerous, on
  one card - shift-change, post-lunch, Thursday-afternoon hotspots become
  visible. Bucketing runs in PHP on the server-local clock (site timezone)
  so the SQL stays portable (the sqlite harness has no FROM_UNIXTIME).
- Precursor-spike alert: last-7-days count of precursor signals (near-miss,
  zone entries, occupancy violations - signals, not injuries) vs the prior
  4-week weekly baseline. Ratio >= 1.5 with at least 5 samples raises a
  banner above the KPIs: an alarm on the PRECURSOR of an accident, with a
  concrete suggestion (staffing/route/rest review). Below the sample floor
  it never fires - a ratio over 2 events is noise, not statistics.
- Period-over-period trends on every KPI: reportSummary gained an optional
  upper bound so the identical aggregation runs over the preceding window.
  Worsening turns red, improving turns accent - except the near-miss count,
  which stays NEUTRAL: more near-misses may mean better detection, not more
  danger, and the report must not editorialize that.

Verified: PHPUnit 140 pass (2 new: window separation + pattern bucketing,
spike ratio + sample floor) + Chrome real-render (alert banner, KPI trend
lines, Thursday-afternoon hotspot readable in the grid).
2026-08-07 07:35:30 +09:00

532 lines
31 KiB
CSS

/* ==================================================================
AI SAFETY CCTV — operations console.
Light, dense, professional. A safety board is calm most of the time and
urgent rarely: near-monochrome chrome, one interaction colour, and colour
spent only on the unacknowledged-critical alarm. Density carries the
"professional product" weight — a two-column console (device→camera tree +
detection stream) with a compact KPI strip and evidence-forward event rows.
Accessibility (NF9): state is never colour alone — a dot/led always pairs
with a text label, and every ink is contrast-checked on its own surface.
Air-gapped (폐쇄망): system fonts, zero external assets.
================================================================== */
:root {
color-scheme: light;
/* Surfaces — content is white; chrome (top bar + rail) is a second neutral
layer, and the canvas sits a step deeper so white cards read as figure. */
--bg: #e9edf1; /* app canvas */
--panel: #ffffff; /* top bar + rail surface */
--panel-2: #f5f8fa; /* rail inset / hover fill */
--card: #ffffff; /* content rows/cards */
--line: #dee4ea; /* hairline border */
--line-2: #eaeff4; /* faint divider */
/* Ink ramp — contrast-checked on white AND on --panel-2 (the rail inset),
where the rail's small mono meta lives: ink-3 ≈ 5.0:1 there. */
--ink: #131824;
--ink-2: #464e5c;
--ink-3: #626a79;
/* Interaction — deep petrol. Placed deliberately far from the severity ramp
(red/orange/amber) on the wheel: "where you act" can never be misread as
"what is wrong". Used for nav, focus, selection and primary only. */
--accent: #0b6b7a;
--accent-deep: #095a67;
--accent-ink: #ffffff;
--accent-wash: #e2f0f3;
--accent-line: #b9d9df;
/* Live monitoring (calm green, never an alarm). */
--ok: #15803d;
--ok-wash: #e7f6ec;
/* Severity — reserved, semantic; identity always carries a text label too.
The pure hue drives graphics (dots, leds, tints); the *-ink variant drives
TEXT, because the pure hue alone misses 4.5:1 at label sizes. */
--crit:#dc2626; --crit-ink:#a51d1d; --crit-bg:#fdf2f2; --crit-line:#f2cccc;
--high:#e2560c; --high-ink:#9a3a06; --high-bg:#fdf4ee; --high-line:#f5d9c4;
--warn:#b7820a; --warn-ink:#7a5606; --warn-bg:#fbf6e6; --warn-line:#efe0b4;
--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:10px;
/* 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:.55rem 1.15rem;
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; }
/* The one dark object in a light console: an equipment-plate mark. It is the page's
focal anchor and reads as hardware, not as a generic app logo. */
.brand-mark {
display:grid; place-items:center; width:32px; height:32px; flex:none;
border-radius:8px; color:#7fd0dd; background:#0f2b31;
}
.brand-mark svg { width:19px; height:19px; display:block; }
.brand-text { display:flex; flex-direction:column; line-height:1.12; }
.brand-name { font-weight:750; font-size:.9rem; letter-spacing:.02em; color:var(--ink); }
.brand-tag { font-size:.67rem; 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:.85rem; font-weight:550; padding:.4rem .68rem; border-radius:7px;
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:.83rem; 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:21px; height:21px; 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:.38rem .74rem; color:var(--ink); background:var(--card);
border:1px solid var(--line); border-radius:8px;
transition:transform .1s, background .15s, border-color .15s, box-shadow .15s;
}
button:hover, .btn:hover { background:var(--panel-2); border-color:#ccd4dd; }
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:.85rem; color:var(--ink);
padding:.42rem .58rem; background:var(--card);
border:1px solid var(--line); border-radius:8px;
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 -------------------------------------------------- */
/* Measure, not screen width: past ~1240px the eye has to travel from the severity badge to the
확인 button across pure air, and the row stops reading as one unit. */
.app-main { max-width:1240px; margin:0 auto; padding:1.15rem 1.15rem 3rem; }
/* ---- Console: device/camera rail + detection stream -------------- */
.console { display:grid; grid-template-columns:272px minmax(0,1fr); gap:1rem; align-items:start; }
.rail {
position:sticky; top:calc(45px + 1.15rem);
display:flex; flex-direction:column; gap:.85rem;
background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
padding:.8rem .75rem; box-shadow:var(--shadow-sm);
}
.rail-block { display:flex; flex-direction:column; gap:.45rem; }
.rail-block + .rail-block { padding-top:.8rem; border-top:1px solid var(--line-2); }
.rail-head { display:flex; align-items:center; justify-content:space-between; gap:.5rem; min-height:1.15rem; }
.rail-title {
margin:0; font-size:.69rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
color:var(--ink-3);
}
.rail-reset {
font-size:.68rem; font-weight:600; padding:.1rem .42rem; border-radius:6px;
color:var(--accent); background:var(--accent-wash); border-color:var(--accent-line);
}
.rail-reset:hover { background:#d5e9ed; border-color:#a5ccd4; }
.rail-reset[hidden] { display:none; }
/* Rail's live region: one short summary line, so a screen reader hears "카메라 1대 오프라인"
instead of the whole tree re-read on every 5s poll. */
.rail-sum { margin:0; font-family:var(--mono); font-size:.7rem; color:var(--ink-3); letter-spacing:-.01em; }
.rail-sum .is-down { color:var(--crit-ink); font-weight:600; }
.rail-filters { display:flex; flex-direction:column; gap:.55rem; }
.rail-filters .field { display:flex; flex-direction:column; gap:.28rem; font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
.rail-filters select { text-transform:none; font-size:.84rem; font-weight:400; letter-spacing:0; color:var(--ink); }
.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.84rem; color:var(--ink-2); cursor:pointer; user-select:none; }
.filter-check input { width:15px; height:15px; accent-color:var(--accent); }
/* ---- Device → camera tree (rail) --------------------------------- */
/* Devices carry the heartbeat telemetry (online/offline, fps, temp, throttle, drift); the
cameras that have actually reported events nest underneath on hairline connectors, so the
rail answers "what is watching, and is it alive?" in one glance. A camera node is also the
stream filter. An offline device takes the alarm tint, with "오프라인" text beside the dot. */
.device-tree { display:flex; flex-direction:column; gap:.28rem; }
.device-tree.is-empty { color:var(--ink-3); font-size:.8rem; padding:.2rem 0; }
.device-tree.is-stale { opacity:.62; }
.device-stale-note { font-size:.69rem; color:var(--crit-ink); padding-bottom:.15rem; }
.device-node { border:1px solid var(--line); border-radius:9px; background:var(--panel-2); }
.device-head {
display:block; width:100%; text-align:left; font:inherit;
padding:.4rem .48rem; background:none; border:0; border-radius:9px;
}
.device-head:hover { background:#fff; }
.device-line { display:flex; align-items:center; gap:.4rem; }
.device-line .twisty {
width:5px; height:5px; flex:none; margin:-2px .05rem 0 .1rem;
border-right:1.5px solid var(--ink-3); border-bottom:1.5px solid var(--ink-3);
transform:rotate(45deg); transition:transform .15s;
}
.device-node.is-collapsed .twisty { transform:rotate(-45deg); }
.device-line .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; box-shadow:0 0 0 3px var(--ok-wash); }
.device-name { font-weight:600; font-size:.83rem; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.device-tally { margin-left:auto; font-family:var(--mono); font-size:.68rem; color:var(--ink-3); flex:none; }
.device-meta { display:block; margin-top:.2rem; padding-left:1.1rem; font-family:var(--mono); font-size:.69rem; color:var(--ink-3); }
.device-warn { display:block; margin-top:.18rem; padding-left:1.1rem; font-size:.69rem; font-weight:650; color:var(--warn-ink); }
.device-node.is-offline { background:var(--crit-bg); border-color:var(--crit-line); }
.device-node.is-offline .device-head:hover { background:#fef7f7; }
.device-node.is-offline .device-name { color:var(--crit-ink); }
.device-node.is-offline .device-meta { color:var(--crit-ink); }
.device-node.is-offline .device-line .dot { background:var(--crit); box-shadow:0 0 0 3px #f7d9d9; }
/* A camera reporting under a device_id that /api/devices does not know: no heartbeat verdict
exists, so the dot must not claim one. Neutral dot + "장치 목록에 없음" text. */
.device-node.is-unknown .device-line .dot { background:var(--ink-3); box-shadow:0 0 0 3px #e2e6ec; }
/* Cameras: hairline tree connectors (structural, 1px, neutral) — an equipment list, not
an indent. The last child's vertical segment stops at its own elbow. */
.cam-list { list-style:none; margin:0; padding:.05rem .48rem .4rem 1.5rem; }
.cam-item { position:relative; }
.cam-item + .cam-item { margin-top:.1rem; }
.cam-item::before { content:""; position:absolute; left:-.5rem; top:0; bottom:0; width:1px; background:#c8d1db; }
.cam-item:last-child::before { bottom:auto; height:50%; }
.cam-item::after { content:""; position:absolute; left:-.5rem; top:50%; width:.42rem; height:1px; background:#c8d1db; }
.cam-item:has(.cam-node[aria-pressed="true"])::after { background:var(--accent); }
.device-node.is-offline .cam-item::before, .device-node.is-offline .cam-item::after { background:var(--crit-line); }
.cam-node {
display:grid; grid-template-columns:minmax(0,1fr) auto auto; align-items:center; gap:.4rem;
width:100%; text-align:left; font:inherit;
padding:.2rem .35rem; border:1px solid transparent; border-radius:6px;
background:none; color:var(--ink-2);
}
.cam-node:hover { background:#fff; border-color:var(--line); }
.cam-node[aria-pressed="true"] { background:var(--accent-wash); border-color:var(--accent-line); color:var(--accent); }
.cam-id { font-family:var(--mono); font-size:.74rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cam-when { font-family:var(--mono); font-size:.66rem; color:var(--ink-3); }
.cam-node[aria-pressed="true"] .cam-when { color:var(--accent); }
.cam-count {
min-width:1.1rem; text-align:center; padding:.02rem .26rem; border-radius:999px;
font-size:.65rem; font-weight:700; line-height:1.5;
color:var(--ink-2); background:#e6eaef;
}
.cam-count.lv-critical { color:var(--crit-ink); background:var(--crit-bg); box-shadow:inset 0 0 0 1px var(--crit-line); }
.cam-count.lv-high { color:var(--high-ink); background:var(--high-bg); box-shadow:inset 0 0 0 1px var(--high-line); }
.cam-count.lv-warning { color:var(--warn-ink); background:var(--warn-bg); box-shadow:inset 0 0 0 1px var(--warn-line); }
/* Sits on the camera column, not the card edge — an empty tree branch, not a stray note. */
.cam-empty { padding:.05rem .48rem .42rem 1.85rem; font-size:.69rem; color:var(--ink-3); }
/* ---- 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:.75rem; }
.stream-title { display:flex; align-items:baseline; gap:.7rem; }
h1 { font-size:1.3rem; 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:.79rem; 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,128,61,.4);} 70%,100%{ box-shadow:0 0 0 6px rgba(21,128,61,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. */
.kpi-wrap { display:flex; align-items:center; gap:.55rem; flex-wrap:wrap; }
.kpi-cap { font-size:.68rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--ink-3); }
#events-status.kpi-strip { display:flex; gap:.4rem; flex-wrap:wrap; }
.stat-tile {
display:inline-flex; align-items:center; gap:.45rem;
padding:.32rem .62rem; 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:.38rem; font-size:.75rem; 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:.98rem; 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); }
/* ---- Detection stream: event rows -------------------------------- */
.event-list { display:flex; flex-direction:column; gap:.4rem; }
.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:.8rem;
background:var(--card); border:1px solid var(--line); border-radius:10px;
padding:.45rem .7rem .45rem .45rem; box-shadow:var(--shadow-sm);
transition:border-color .15s, box-shadow .15s;
}
.event-row:hover { border-color:#ccd4dd; 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 first — a 16:9 still large enough to judge the scene without opening the
incident, and itself a link into it. Rows stay aligned, so the stream reads as a
contact sheet of the last N detections. */
.event-thumb-slot {
position:relative; display:block; width:132px; height:76px; flex:none;
border-radius:7px; border:1px solid var(--line); background:#0e1116; overflow:hidden;
transition:border-color .15s, box-shadow .15s;
}
a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-wash); }
.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:#c9d1da; background:var(--panel-2); }
.event-thumb-slot.is-empty::after { content:"무영상"; position:absolute; inset:0; display:grid; place-items:center; font-size:.68rem; color:var(--ink-3); }
.severity-badge { display:inline-flex; align-items:center; gap:.4rem; flex:none; min-width:56px; font-size:.79rem; 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-ink);} .sev-high{ color:var(--high-ink);} .sev-warning{ color:var(--warn-ink);} .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; }
/* Provenance: device (prose) → camera (mono) → stamp (mono). Typeface does the separating,
so no "·" chain fights the "·" inside device names. */
.event-time { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.7rem; margin-top:.22rem; font-size:.74rem; }
.event-time .ev-src { font-weight:550; color:var(--ink-2); }
.event-time .ev-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); }
.event-time .ev-at { font-family:var(--mono); 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-ink); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; }
/* Actions column: [확인][오탐] while open; a single mono verdict line once resolved. */
.row-actions { margin-left:auto; flex:none; display:flex; align-items:center; gap:.4rem; }
.ack-btn { flex:none; min-width:4.5rem; text-align:center; }
/* 오탐 is the rarer, corrective action — visually secondary to 확인 so it can't be
fat-fingered as the default. */
.fp-btn { flex:none; padding:.32rem .55rem; font-size:.75rem; color:var(--ink-3);
background:none; border-color:transparent; box-shadow:none; }
.fp-btn:hover { color:var(--ink-2); border-color:var(--line); background:#fff; }
.ack-done, .fp-done { color:var(--ink-3); font-family:var(--mono); font-size:.75rem; white-space:nowrap; }
.fp-done { text-decoration:line-through; text-decoration-color:#c9d1da; }
/* 24h catch-up brief above the stream — one line of "what happened overnight". */
.day-brief {
margin:0 0 .65rem; padding:.5rem .75rem; font-size:.79rem; color:var(--ink-2);
background:var(--panel-2); border:1px solid var(--line); border-radius:8px;
}
.day-brief strong { color:var(--ink); margin-right:.1rem; }
.day-brief.has-severe { background:var(--high-bg); border-color:var(--high-line); }
.day-brief a { color:var(--accent); text-decoration:none; font-weight:600; white-space:nowrap; }
.day-brief a:hover { text-decoration:underline; }
/* Rail: FP-rate report rows. */
.fp-stats { display:flex; flex-direction:column; gap:.34rem; font-size:.74rem; }
.fp-stats.is-empty { color:var(--ink-3); }
.fp-row { display:flex; align-items:baseline; justify-content:space-between; gap:.6rem; min-width:0; }
.fp-what { color:var(--ink-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.fp-what .fp-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); margin-right:.15rem; }
.fp-rate { flex:none; font-family:var(--mono); color:var(--ink-3); }
.fp-rate.is-hot { color:var(--warn-ink); font-weight:700; }
/* "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 .6rem; padding:.45rem .7rem;
font-size:.79rem; color:var(--ink-2); background:var(--accent-wash); border:1px solid var(--accent-line); border-radius:8px; }
.page-gap-hint[hidden] { display:none; }
.page-gap-hint button { margin-left:auto; }
.list-more { display:flex; justify-content:center; margin-top:.7rem; }
.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:14px; 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:var(--accent-deep); border-color:var(--accent-deep); }
.form-error { color:var(--crit-ink); 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:10px;
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:9px;
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; }
/* Evidence timeline: each frame carries its moment label — the sequence reads left→right
into the event ("이전 → 직전 → 발생 시점"). */
.detail-snap { margin:0; display:flex; flex-direction:column; gap:.3rem; flex:1 1 240px; max-width:360px; }
.detail-snap figcaption { font-size:.72rem; font-weight:700; color:var(--ink-3); font-family:var(--mono); }
.detail-snap:last-child figcaption { color:var(--ink); }
.detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:10px; 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-filters { flex-direction:row; flex-wrap:wrap; align-items:end; }
/* The tree keeps its nesting but flows into columns instead of one tall list. */
.device-tree { display:grid; grid-template-columns:repeat(auto-fill,minmax(15rem,1fr)); gap:.35rem; align-items:start; }
.device-stale-note { grid-column:1/-1; }
}
@media (max-width:560px) {
.app-main { padding:1rem .9rem 2.5rem; }
.brand-tag { display:none; }
/* Below this width the four header groups cannot share one line without every label
wrapping mid-word. Give the nav its own row instead. */
.app-header { flex-wrap:wrap; gap:.5rem .7rem; padding:.5rem .9rem; }
.brand-name, .app-session .who, .app-session button, .app-session a { white-space:nowrap; }
.app-nav { order:3; width:100%; margin:0; }
.app-nav a { flex:1; text-align:center; }
.app-session { margin-left:auto; }
.event-row { gap:.6rem; }
.event-thumb-slot { width:104px; height:60px; }
/* The badge shrink-wraps, but its text label always stays: severity must never
ride on the dot's colour alone (NF9), least of all on a phone. */
.severity-badge { min-width:0; }
}
/* ---- Safety operations report (/report) --------------------------- */
.report { max-width:960px; margin:0 auto; }
.report-head { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; margin-bottom:1.1rem; }
.report-head h1 { margin:0; }
.report-range { margin:.3rem 0 0; font-size:.8rem; color:var(--ink-3); font-family:var(--mono); }
.report-controls { display:flex; align-items:flex-end; gap:.6rem; }
/* KPI band — the focal strip: four numbers that summarize the period at a glance. */
.report-kpis { display:grid; grid-template-columns:repeat(4, 1fr); gap:.7rem; margin-bottom:1rem; }
.kpi {
display:flex; flex-direction:column; gap:.18rem; padding:.75rem .85rem;
background:var(--card); border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
}
.kpi-label { font-size:.72rem; font-weight:700; color:var(--ink-3); letter-spacing:.05em; text-transform:uppercase; }
.kpi-value { font-size:1.55rem; font-weight:750; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
.kpi-note { font-size:.72rem; color:var(--ink-3); }
.kpi.is-severe { background:var(--crit-bg); border-color:var(--crit-line); }
.kpi.is-severe .kpi-value { color:var(--crit-ink); }
.kpi.is-warn .kpi-value { color:var(--warn-ink); }
.report-grid { display:grid; grid-template-columns:1fr 1fr; gap:.9rem; }
.report-card {
padding:.9rem 1rem 1rem; background:var(--card);
border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
}
.report-card h2 { margin:0 0 .75rem; font-size:.85rem; font-weight:750; letter-spacing:.02em; }
.report-empty { margin:.4rem 0 0; font-size:.76rem; color:var(--ink-3); }
.bar-list { display:flex; flex-direction:column; gap:.55rem; }
.bar-head { display:flex; justify-content:space-between; align-items:baseline; gap:.6rem; font-size:.78rem; }
.bar-label { color:var(--ink-2); font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.bar-value { flex:none; font-family:var(--mono); font-size:.73rem; color:var(--ink-3); }
.bar-value.is-hot { color:var(--crit-ink); font-weight:700; }
.bar-track { margin-top:.22rem; height:6px; border-radius:999px; background:#e8ecf1; overflow:hidden; }
.bar-fill { height:100%; border-radius:999px; background:var(--accent); }
.bar-fill.is-hot { background:var(--crit-ink); }
.rule-stats { display:grid; grid-template-columns:repeat(2, 1fr); gap:.6rem; }
.rule-stat {
display:flex; flex-direction:column; gap:.1rem; padding:.6rem .7rem;
border:1px solid var(--line); border-radius:8px; background:var(--panel-2);
}
.rule-stat strong { font-size:1.25rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-3); }
.rule-stat span { font-size:.72rem; color:var(--ink-3); }
.rule-stat.is-nonzero strong { color:var(--ink); }
.rule-stat.is-nonzero { background:var(--card); }
.report-foot { margin:1.1rem 0 0; font-size:.72rem; color:var(--ink-3); }
/* 전조 급증 경보 — 사고 전 신호에 거는 알람이므로 KPI보다 위. */
.report-spike {
margin:0 0 .8rem; padding:.6rem .8rem; font-size:.82rem; color:var(--warn-ink);
background:var(--warn-bg); border:1px solid var(--warn-line); border-radius:8px;
}
.report-spike strong { margin-right:.15rem; }
.kpi-trend { font-size:.7rem; color:var(--ink-3); font-family:var(--mono); }
.kpi-trend.is-worse { color:var(--crit-ink); }
.kpi-trend.is-better { color:var(--accent); }
.report-card-wide { grid-column:1 / -1; }
.pattern { display:flex; flex-direction:column; gap:2px; overflow-x:auto; }
.pattern-row { display:flex; align-items:center; gap:2px; }
.pattern-day { flex:none; width:1.4rem; font-size:.68rem; color:var(--ink-3); font-family:var(--mono); }
.pattern-hour { flex:1 0 14px; font-size:.62rem; color:var(--ink-3); font-family:var(--mono); text-align:left; }
.pattern-cell { flex:1 0 14px; height:16px; border-radius:3px; background:#edf0f4; }
.pattern-cell.is-filled { background:var(--crit-ink); }
.pattern-head { margin-bottom:2px; }
@media (max-width:860px) {
.report-kpis { grid-template-columns:repeat(2, 1fr); }
.report-grid { grid-template-columns:1fr; }
}
/* 인쇄: 앱 크롬을 걷어내고 리포트만 A4에 얹는다 — 주간 안전회의 배포물이 목적. */
@media print {
.app-header, .report-controls { display:none !important; }
body { background:#fff; }
.app-main { padding:0; }
.kpi, .report-card, .rule-stat { box-shadow:none; }
.report-grid, .report-kpis { break-inside:avoid; }
}
/* ---- Reduced motion ---------------------------------------------- */
@media (prefers-reduced-motion:reduce) {
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
}