UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업. 두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다. ━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 동선 — 확인(ack) 경로가 위험했다 - 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를 뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이 밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서 '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리 (전환해도 행끼리 상대 순서는 절대 안 바뀐다). - 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라 무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로. - 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다. - 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고, 플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다. - 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다. - 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이 통째로 꺼지는 경로다. 전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다 - KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다. KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의. - 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장 (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다). - 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리 시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를 넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다. - 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로. 시각 — 화면마다 완성도가 달랐다 - 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고, 납품 심사에서 "덜 만든 티"는 정확히 거기서 났다. - 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다. - 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선. 캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다. ━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━ 통보 — 이 제품의 정의에 해당하는 구멍 지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에 넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다. - 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라 어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가 없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다. - 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가 아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다. - 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등). '아무도 보지 않았다'는 사실 자체를 사건으로 만든다. - 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간 인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다. 조용한 실패 제거 - 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을 전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속 초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다. - 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다. 끊겨 있던 배선 2건 - thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고 /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도 장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py). - 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수 없었다. 지표는 있는데 그것을 켜는 사건이 없었다. 관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다) - 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은 엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다. 목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다. - 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고 통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만 의도적으로 withheld됐다'를 볼 수 있어야 한다. - 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반). 증거·규정 - 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수 없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시 exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이 매번 빨간불을 켜면 아무도 이 도구를 안 본다. - 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 + 조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다. - 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만). 운영 - 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter), 모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php), 백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php). - tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다. 미착수 4건 (모델·하드웨어가 선행 조건) - 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다. 감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다 (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록 명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다. - PLC 접점 협착 게이팅도 같은 이유로 인터페이스만. 검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59). Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→ 행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개) 재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0, 상태 변경 핸들러 전부 CSRF+RBAC.
1048 lines
64 KiB
CSS
1048 lines
64 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); }
|
|
/* Rail: 날짜 조회 (사고 조사 진입로). */
|
|
.rail-date { display:flex; align-items:flex-end; gap:.4rem; }
|
|
.rail-date .field { flex:1; min-width:0; display:flex; flex-direction:column; gap:.28rem; font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
|
|
.rail-date input { width:100%; font-size:.8rem; }
|
|
.rail-date button { flex:none; }
|
|
|
|
/* ---- 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);} }
|
|
|
|
/* Alarm mute — only present while something is actually alarming, and it says so while on.
|
|
A silenced console that LOOKS identical to a live one is the failure this must not create. */
|
|
.mute-btn { font-size:.72rem; padding:.22rem .5rem; color:var(--ink-2); }
|
|
.mute-btn[hidden] { display:none; }
|
|
.mute-btn[aria-pressed="true"] { color:var(--crit-ink); background:var(--crit-bg); border-color:var(--crit-line); font-weight:650; }
|
|
.mute-state { font-size:.72rem; font-weight:600; color:var(--crit-ink); }
|
|
.mute-state[hidden] { display:none; }
|
|
|
|
/* View mode — a segmented pair, deliberately NOT a checkbox: the two modes are exclusive
|
|
readings of the same list, and a checkbox implied "and also filter by this". */
|
|
/* align-self, because .stream is a stretching flex column and an inline-flex child would
|
|
otherwise span the full stream width — a segmented control the width of the page reads as
|
|
a toolbar, not as a two-way switch. */
|
|
.view-modes { display:inline-flex; align-self:flex-start; gap:2px; padding:2px; margin:0 0 .7rem;
|
|
background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
|
|
.mode-btn { font-size:.78rem; font-weight:600; padding:.3rem .7rem; border:0; border-radius:7px;
|
|
background:transparent; color:var(--ink-3); box-shadow:none; }
|
|
.mode-btn:hover { background:#fff; color:var(--ink-2); }
|
|
.mode-btn.is-on { background:var(--card); color:var(--accent); box-shadow:var(--shadow-sm); }
|
|
|
|
/* Day-query banner: the stream is showing a past day, not the live feed. Says so, and the
|
|
way back is the same control every time. */
|
|
.query-banner { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem;
|
|
font-size:.79rem; color:var(--accent-deep); background:var(--accent-wash);
|
|
border:1px solid var(--accent-line); border-radius:8px; }
|
|
.query-banner[hidden] { display:none; }
|
|
.query-banner .query-text { font-weight:600; }
|
|
.query-banner button { margin-left:auto; flex:none; }
|
|
.query-banner.is-truncated { color:var(--warn-ink); background:var(--warn-bg); border-color:var(--warn-line); }
|
|
|
|
/* Day divider — a strictly chronological list still has to make midnight visible. */
|
|
.day-sep { display:flex; align-items:center; gap:.7rem; margin:.75rem 0 .3rem; }
|
|
.day-sep:first-child { margin-top:0; }
|
|
.day-sep span {
|
|
flex:none; font-size:.7rem; font-weight:700; letter-spacing:.05em; color:var(--ink-3);
|
|
font-variant-numeric:tabular-nums;
|
|
}
|
|
.day-sep::after { content:""; flex:1; height:1px; background:var(--line); }
|
|
|
|
/* Resolution feedback. 확인/오탐 are audit-backed and not reversible here, so this is a
|
|
confirmation of what was recorded — never an "undo" that the server could not honour. */
|
|
.action-toast {
|
|
position:fixed; left:50%; bottom:1.5rem; transform:translate(-50%, .5rem);
|
|
z-index:var(--z-toast); max-width:min(90vw, 30rem);
|
|
padding:.55rem .9rem; font-size:.82rem; font-weight:600;
|
|
color:#fff; background:#1b2330; border-radius:9px;
|
|
box-shadow:0 10px 30px -12px rgba(19,24,36,.6);
|
|
opacity:0; pointer-events:none; transition:opacity .18s, transform .18s;
|
|
}
|
|
.action-toast.is-on { opacity:1; transform:translate(-50%, 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 — on EVERY unacknowledged row, toned by severity. A critical still shouts
|
|
(solid fill); lower tiers state the same fact quietly (tinted, outlined). What must never
|
|
happen again is a row whose handled/unhandled state is only inferable from whether a button
|
|
exists in a distant column. */
|
|
.chip { font-size:.68rem; font-weight:700; padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; flex:none; }
|
|
.chip-critical { color:#fff; background:var(--crit-ink); }
|
|
.chip-high { color:var(--high-ink); background:var(--high-bg); box-shadow:inset 0 0 0 1px var(--high-line); }
|
|
.chip-warning { color:var(--warn-ink); background:var(--warn-bg); box-shadow:inset 0 0 0 1px var(--warn-line); }
|
|
.chip-info { color:var(--ink-2); background:#eef1f5; box-shadow:inset 0 0 0 1px #dde3ea; }
|
|
|
|
/* 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; }
|
|
/* Armed state: the first click asks, the second commits. 오탐 asserts the DETECTOR was wrong,
|
|
moves a per-camera statistic a threshold is later retuned from, and has no undo. */
|
|
.fp-btn.is-armed { color:var(--high-ink); background:var(--high-bg); border-color:var(--high-line); font-weight:650; }
|
|
.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; }
|
|
/* Under the minimum sample there is no rate to print — only counts, and they must not
|
|
compete visually with a real one ("100% (1/1)" used to outrank "33% (5/15)"). */
|
|
.fp-rate.is-thin { color:#8b93a1; font-size:.7rem; }
|
|
|
|
/* "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; }
|
|
|
|
/* ---- Primary action ---------------------------------------------- */
|
|
/* The one filled button in the app. It exists so a page's committing action cannot sit at the
|
|
same weight as the destructive one beside it — which is exactly how 저장 and 폴리곤 지우기
|
|
used to read on the zone editor. */
|
|
.btn-primary {
|
|
color:var(--accent-ink); background:var(--accent); border-color:var(--accent); font-weight:650;
|
|
}
|
|
.btn-primary:hover { background:var(--accent-deep); border-color:var(--accent-deep); }
|
|
|
|
/* ---- Zone editor ------------------------------------------------- */
|
|
/* Same console grammar as the events page (panel rail + stage), so the two screens read as one
|
|
product. The panel walks the actual task order: ① pick a camera ② draw ③ rules + save.
|
|
Before this the page was a bare <h1> over three unlabelled rows of loose controls. */
|
|
.zone-page { display:flex; flex-direction:column; }
|
|
.zone-console { display:grid; grid-template-columns:300px minmax(0,1fr); gap:1rem; align-items:start; }
|
|
|
|
.zone-panel {
|
|
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:.85rem .8rem; box-shadow:var(--shadow-sm);
|
|
}
|
|
.zone-step { display:flex; flex-direction:column; gap:.5rem; }
|
|
.zone-step + .zone-step { padding-top:.85rem; border-top:1px solid var(--line-2); }
|
|
.zone-step .rail-title { display:flex; align-items:center; gap:.45rem; }
|
|
.step-no {
|
|
display:grid; place-items:center; width:16px; height:16px; flex:none;
|
|
border-radius:50%; font-size:.62rem; font-weight:700; letter-spacing:0;
|
|
color:var(--accent); background:var(--accent-wash); border:1px solid var(--accent-line);
|
|
}
|
|
.step-hint { margin:0; font-size:.73rem; color:var(--ink-3); }
|
|
.zone-step .field {
|
|
display:flex; flex-direction:column; gap:.28rem;
|
|
font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase;
|
|
}
|
|
.zone-step .field input, .zone-step .field select {
|
|
text-transform:none; font-size:.83rem; font-weight:400; letter-spacing:0; color:var(--ink); width:100%;
|
|
}
|
|
/* Label and its unit/why note share ONE line — as separate flex children of the column they
|
|
stacked, and a hint on its own row reads as a second label. */
|
|
.field-label { display:inline-flex; align-items:baseline; gap:.35rem; flex-wrap:wrap; }
|
|
.field-hint { font-weight:600; font-size:.64rem; color:#8b93a1; letter-spacing:0; text-transform:none; }
|
|
.step-actions { display:flex; flex-wrap:wrap; gap:.4rem; }
|
|
.step-actions button { flex:1 1 auto; }
|
|
.zone-overlay-tools { display:flex; align-items:flex-end; gap:.4rem; }
|
|
.zone-overlay-tools .field { flex:0 0 5.5rem; }
|
|
/* aria-pressed had no visual pair: the heatmap could be on with nothing on screen saying so —
|
|
the one place this stylesheet's own "state is never colour alone" rule was broken by there
|
|
being no state indication at all. */
|
|
#heatmap-btn { flex:1; }
|
|
#heatmap-btn[aria-pressed="true"] {
|
|
color:var(--accent); background:var(--accent-wash); border-color:var(--accent-line); font-weight:650;
|
|
}
|
|
.zone-manual { font-size:.75rem; color:var(--ink-3); }
|
|
.zone-manual > summary { cursor:pointer; padding:.15rem 0; color:var(--accent); font-weight:600; }
|
|
.zone-manual[open] { display:flex; flex-direction:column; gap:.45rem; padding-bottom:.2rem; }
|
|
.zone-manual-why { margin:.25rem 0 0; font-size:.72rem; color:var(--ink-3); }
|
|
|
|
.zone-stage { display:flex; flex-direction:column; gap:.6rem; min-width:0; }
|
|
#zone-canvas {
|
|
width:100%; height:auto; display:block;
|
|
border:1px solid var(--line); border-radius:10px;
|
|
background:#0e1116 center / contain no-repeat; cursor:crosshair;
|
|
box-shadow:var(--shadow);
|
|
}
|
|
.zone-status {
|
|
margin:0; padding:.42rem .65rem; font-size:.79rem; color:var(--ink-2);
|
|
background:var(--panel-2); border:1px solid var(--line); border-radius:8px;
|
|
}
|
|
.zone-saved { display:flex; flex-direction:column; gap:.4rem; }
|
|
.zone-list { list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:.35rem; }
|
|
.zone-item {
|
|
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
|
|
padding:.45rem .6rem;
|
|
background:var(--card); border:1px solid var(--line); border-radius:9px;
|
|
font-size:.83rem; color:var(--ink-2);
|
|
}
|
|
.zone-item-label { display:inline-flex; align-items:center; gap:.5rem; min-width:0; }
|
|
.zone-swatch { width:13px; height:13px; flex:none; border-radius:3px; border:2px solid; }
|
|
.zone-empty { font-size:.79rem; color:var(--ink-3); padding:.3rem 0; }
|
|
.zone-del { flex:none; font-size:.75rem; padding:.26rem .55rem; color:var(--ink-3); }
|
|
.zone-del:hover { color:var(--crit-ink); border-color:var(--crit-line); background:var(--crit-bg); }
|
|
/* Armed: the second click deletes, and there is no restore on the server. */
|
|
.zone-del.is-armed { color:#fff; background:var(--crit-ink); border-color:var(--crit-ink); font-weight:650; }
|
|
|
|
@media (max-width:860px) {
|
|
.zone-console { grid-template-columns:1fr; }
|
|
.zone-panel { position:static; }
|
|
}
|
|
|
|
/* ---- Incident detail -------------------------------------------- */
|
|
/* The screen an accident is judged on. It was a bare label:value list with a default blue link,
|
|
in a 200px column inside a 1240px shell — the least finished surface in the product and the
|
|
one an investigator spends the most time on. */
|
|
.detail { display:flex; flex-direction:column; gap:.9rem; }
|
|
.detail-crumb { display:flex; align-items:center; gap:.4rem; font-size:.76rem; color:var(--ink-3); }
|
|
.detail-crumb a { color:var(--accent); text-decoration:none; font-weight:600; }
|
|
.detail-crumb a:hover { text-decoration:underline; text-underline-offset:3px; }
|
|
.detail-head { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
|
.detail-headline h1 { margin:0; }
|
|
.detail-when { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.7rem; margin:.3rem 0 0; font-size:.79rem; }
|
|
.detail-when .ev-src { font-weight:550; color:var(--ink-2); }
|
|
.detail-when .ev-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); }
|
|
.detail-when .ev-at { font-family:var(--mono); color:var(--ink-3); }
|
|
.detail-actions { display:flex; align-items:center; gap:.5rem; flex:none; }
|
|
.detail-actions[hidden] { display:none; }
|
|
.detail-action-note { margin:0; font-size:.82rem; font-weight:600; color:var(--ink-2); }
|
|
.detail-action-note.is-error { color:var(--crit-ink); }
|
|
|
|
.detail-grid { display:grid; grid-template-columns:minmax(0,1fr) 336px; gap:.9rem; align-items:start; }
|
|
.detail-card {
|
|
padding:.9rem 1rem 1rem; background:var(--card);
|
|
border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
|
|
}
|
|
.detail-card-title { margin:0 0 .75rem; font-size:.72rem; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-3); }
|
|
.detail-empty { margin:0; font-size:.82rem; color:var(--ink-3); }
|
|
.detail-foot { margin:.8rem 0 0; font-size:.72rem; color:var(--ink-3); }
|
|
|
|
/* Meta as a definition list: the label column is a real column, so values line up. */
|
|
#event-detail-meta { margin:0; display:flex; flex-direction:column; }
|
|
.detail-row { display:grid; grid-template-columns:4.9rem minmax(0,1fr); gap:.55rem; align-items:baseline; padding:.32rem 0; }
|
|
.detail-row + .detail-row { border-top:1px solid var(--line-2); }
|
|
.detail-row dt { font-size:.73rem; font-weight:600; color:var(--ink-3); }
|
|
.detail-row dd { margin:0; font-size:.85rem; color:var(--ink); min-width:0; overflow-wrap:anywhere; }
|
|
.detail-row dd .mono { font-family:var(--mono); font-size:.8rem; }
|
|
|
|
#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; }
|
|
/* Evidence timeline: each frame carries its moment label — the sequence reads left→right
|
|
into the event ("이전 → 직전 → 발생 시점"). No fabricated second offsets: the frame
|
|
interval is edge configuration this server does not know. */
|
|
.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-sm); }
|
|
|
|
@media (max-width:860px) {
|
|
.detail-grid { grid-template-columns:1fr; }
|
|
}
|
|
|
|
/* ---- 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); }
|
|
|
|
/* Three zone-rule counters. "미확인 잔여" used to sit here as a fourth tile of equal weight,
|
|
which read as a fourth rule; it is a processing state, so it moved to a line below. */
|
|
/* minmax(0,1fr), not 1fr: a grid item's automatic minimum is its CONTENT, so "체류 상한 초과"
|
|
refused to shrink, blew the card out and gave the whole report a horizontal scrollbar. */
|
|
.rule-stats { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:.6rem; }
|
|
.rule-stat {
|
|
display:flex; flex-direction:column; gap:.1rem; padding:.6rem .7rem; min-width:0;
|
|
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; }
|
|
.report-card[hidden] { display:none; }
|
|
|
|
/* 현장별 롤업 (본사 관점). */
|
|
.site-rows { display:flex; flex-direction:column; gap:.4rem; }
|
|
.site-row { display:flex; align-items:baseline; justify-content:space-between; gap:.8rem;
|
|
padding:.5rem .65rem; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
|
|
.site-row.is-worst { background:var(--crit-bg); border-color:var(--crit-line); }
|
|
.site-name { font-weight:650; font-size:.9rem; color:var(--ink); }
|
|
.site-nums { display:flex; align-items:baseline; gap:.35rem; font-size:.73rem; color:var(--ink-3); }
|
|
.site-nums strong { font-size:1rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-2); margin-left:.55rem; }
|
|
.site-nums strong:first-child { margin-left:0; }
|
|
.site-severe.is-hot { color:var(--crit-ink); }
|
|
.site-unacked.is-open { color:var(--high-ink); }
|
|
.site-cams { margin-left:.8rem; font-family:var(--mono); }
|
|
|
|
/* 개선 조치: 기록 폼 + 전후 비교 행. */
|
|
.action-form { display:flex; gap:.5rem; margin-bottom:.8rem; }
|
|
.action-form input { flex:1; min-width:0; }
|
|
.action-form #action-camera { flex:0 0 10rem; }
|
|
.action-form button { flex:none; }
|
|
.action-row { display:flex; align-items:baseline; justify-content:space-between; gap:.8rem;
|
|
padding:.45rem 0; border-top:1px solid var(--line); font-size:.79rem; }
|
|
.action-row:first-of-type { border-top:none; }
|
|
.action-what { display:flex; align-items:baseline; gap:.6rem; min-width:0; }
|
|
.action-date { flex:none; font-family:var(--mono); font-size:.72rem; color:var(--ink-3); }
|
|
.action-cam { flex:none; font-family:var(--mono); font-weight:600; color:var(--ink-3); }
|
|
.action-note { color:var(--ink-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
.action-effect { flex:none; font-family:var(--mono); font-size:.73rem; color:var(--ink-3); }
|
|
.action-effect.is-better { color:var(--accent); font-weight:700; }
|
|
.action-effect.is-worse { color:var(--crit-ink); font-weight:700; }
|
|
.action-effect.is-pending { font-style:normal; }
|
|
/* Below the minimum sample the row states the counts and withholds the verdict — a coloured
|
|
▲33% off six events is the kind of number a safety meeting rejects, and takes the rest of
|
|
the card's credibility with it. */
|
|
.action-effect.is-thin { color:#8b93a1; font-weight:400; }
|
|
.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, .action-form, #export-labels { 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; }
|
|
}
|
|
|
|
/* ==================================================================
|
|
확장 화면: 시스템 설정 · 안전 관리 · 사고조사 패키지, 그리고 콘솔의
|
|
일괄 처리 · 키보드 트리아지 · 저장공간 경고.
|
|
기존 콘솔과 같은 문법(카드 + 소문자 캡션 + 단일 accent)을 유지한다 —
|
|
새 화면이 다른 제품처럼 보이는 것이 이 프로젝트의 원래 문제였다.
|
|
================================================================== */
|
|
|
|
/* ---- Stream tools: 뷰모드 + 일괄 확인 + 단축키 안내 --------------- */
|
|
.stream-tools { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-bottom:.7rem; }
|
|
.stream-tools .view-modes { margin:0; }
|
|
.bulk-btn { font-size:.78rem; font-weight:600; color:var(--ink-2); }
|
|
.bulk-btn[hidden] { display:none; }
|
|
/* 무장 상태는 붉게 — 되돌릴 수 없는 일괄 처리라는 사실을 색으로도 말한다. */
|
|
.bulk-btn.is-armed { color:#fff; background:var(--crit-ink); border-color:var(--crit-ink); }
|
|
.kbd-hint { margin-left:auto; font-size:.7rem; color:var(--ink-3); display:inline-flex; align-items:center; gap:.25rem; }
|
|
.kbd-hint kbd {
|
|
font-family:var(--mono); font-size:.65rem; padding:.05rem .3rem; border-radius:4px;
|
|
background:var(--panel-2); border:1px solid var(--line); color:var(--ink-2);
|
|
}
|
|
/* 키보드 커서: 포커스 링과 구분되는 은은한 레일. 마우스 사용자에게는 보이지 않는다. */
|
|
.event-row.is-cursor { box-shadow:inset 3px 0 0 0 var(--accent), var(--shadow-sm); }
|
|
|
|
.storage-warn {
|
|
margin:0 0 .6rem; padding:.5rem .75rem; font-size:.8rem; color:var(--crit-ink);
|
|
background:var(--crit-bg); border:1px solid var(--crit-line); border-radius:8px;
|
|
}
|
|
.storage-warn[hidden] { display:none; }
|
|
.storage-warn .warn-lead { font-weight:700; margin-right:.15rem; }
|
|
|
|
/* ---- 공용 관리 화면 셸 -------------------------------------------- */
|
|
.admin-page, .compliance-page { display:flex; flex-direction:column; gap:1rem; }
|
|
.comp-grid { display:grid; grid-template-columns:1fr 1fr; gap:1rem; align-items:start; }
|
|
.comp-wide { grid-column:1 / -1; }
|
|
.admin-card {
|
|
padding:1rem 1.1rem 1.1rem; background:var(--card);
|
|
border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
|
|
}
|
|
.admin-card-head { display:flex; align-items:center; justify-content:space-between; gap:.8rem; flex-wrap:wrap; margin-bottom:.6rem; }
|
|
.admin-card-head h2 { margin:0; font-size:.95rem; font-weight:750; letter-spacing:-.01em; }
|
|
.admin-device-pick { min-width:16rem; }
|
|
.admin-note {
|
|
margin:0 0 .8rem; padding:.5rem .7rem; font-size:.78rem; line-height:1.5; color:var(--ink-2);
|
|
background:var(--panel-2); border:1px solid var(--line-2); border-radius:8px;
|
|
}
|
|
.admin-note code { font-family:var(--mono); font-size:.74rem; }
|
|
.admin-empty { margin:.4rem 0; font-size:.8rem; color:var(--ink-3); }
|
|
.admin-status { margin:.55rem 0 0; font-size:.78rem; color:var(--ink-2); min-height:1em; }
|
|
.comp-subhead { margin:1rem 0 .5rem; font-size:.7rem; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-3); }
|
|
|
|
.form-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(11rem, 1fr)); gap:.6rem; align-items:end; }
|
|
/* `display:grid` beats the UA's [hidden]{display:none}, so a collapsed form would render
|
|
open — say it explicitly for every layout class that sets display. */
|
|
.form-grid[hidden], .detail-links[hidden] { display:none; }
|
|
.form-grid .field { display:flex; flex-direction:column; gap:.28rem;
|
|
font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
|
|
.form-grid .field input, .form-grid .field select {
|
|
text-transform:none; font-size:.83rem; font-weight:400; letter-spacing:0; color:var(--ink); width:100%;
|
|
}
|
|
.form-grid .filter-check { align-self:center; }
|
|
.form-grid > button { align-self:end; }
|
|
.comp-span, .sup-reason { grid-column:1 / -1; }
|
|
.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.8rem; color:var(--ink-2); cursor:pointer; user-select:none; text-transform:none; letter-spacing:0; font-weight:400; }
|
|
.filter-check input { width:15px; height:15px; accent-color:var(--accent); }
|
|
|
|
/* ---- 통보 채널 ---------------------------------------------------- */
|
|
.channel-list { display:flex; flex-direction:column; gap:.4rem; margin-bottom:.8rem; }
|
|
.channel-row {
|
|
display:flex; align-items:center; gap:.7rem; padding:.5rem .7rem;
|
|
background:var(--panel-2); border:1px solid var(--line); border-radius:9px;
|
|
}
|
|
.channel-row.is-off { opacity:.6; }
|
|
.channel-main { display:flex; flex-direction:column; gap:.1rem; min-width:0; flex:1; }
|
|
.channel-kind { font-size:.66rem; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:var(--accent); }
|
|
.channel-name { font-size:.86rem; font-weight:650; color:var(--ink); }
|
|
.channel-meta { font-size:.73rem; color:var(--ink-3); overflow-wrap:anywhere; }
|
|
.channel-form { display:flex; flex-direction:column; gap:.7rem; padding-top:.8rem; border-top:1px solid var(--line-2); }
|
|
.channel-form[hidden] { display:none; }
|
|
|
|
/* ---- 임계값 ------------------------------------------------------- */
|
|
.thr-list { display:grid; grid-template-columns:repeat(auto-fit, minmax(19rem, 1fr)); gap:.7rem; }
|
|
.thr-row { padding:.65rem .75rem; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
|
|
.thr-head { display:flex; align-items:baseline; justify-content:space-between; gap:.5rem; }
|
|
.thr-label { font-size:.85rem; font-weight:650; color:var(--ink); }
|
|
.thr-value { font-size:.7rem; color:var(--ink-3); font-family:var(--mono); }
|
|
.thr-controls { display:flex; gap:.4rem; margin-top:.4rem; }
|
|
.thr-controls input { flex:1; min-width:0; }
|
|
.thr-help { margin:.45rem 0 0; font-size:.72rem; line-height:1.5; color:var(--ink-3); }
|
|
|
|
/* ---- 교대 인수인계 ------------------------------------------------ */
|
|
.handover-pending { padding:.7rem .8rem; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
|
|
.handover-head { display:flex; align-items:baseline; justify-content:space-between; gap:.6rem; flex-wrap:wrap; }
|
|
.handover-range { font-family:var(--mono); font-size:.73rem; color:var(--ink-3); }
|
|
.handover-stats { display:flex; gap:.6rem; margin-top:.6rem; }
|
|
.handover-stat { flex:1; display:flex; flex-direction:column; gap:.1rem; padding:.5rem .6rem;
|
|
background:var(--card); border:1px solid var(--line); border-radius:8px; }
|
|
.handover-stat strong { font-size:1.3rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-3); }
|
|
.handover-stat span { font-size:.72rem; color:var(--ink-3); }
|
|
.handover-stat.is-open strong { color:var(--ink); }
|
|
.handover-stat.is-severe { background:var(--crit-bg); border-color:var(--crit-line); }
|
|
.handover-stat.is-severe strong { color:var(--crit-ink); }
|
|
.handover-from { margin:.5rem 0 0; font-size:.75rem; color:var(--ink-3); }
|
|
.handover-form { display:flex; align-items:flex-end; gap:.5rem; margin-top:.7rem; }
|
|
.handover-form .field { flex:1; display:flex; flex-direction:column; gap:.28rem;
|
|
font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
|
|
.handover-form input { text-transform:none; font-weight:400; letter-spacing:0; color:var(--ink); width:100%; }
|
|
.handover-list { display:flex; flex-direction:column; gap:.3rem; }
|
|
.handover-row { display:flex; flex-wrap:wrap; gap:.5rem; align-items:baseline; padding:.4rem 0;
|
|
border-top:1px solid var(--line-2); font-size:.78rem; }
|
|
.handover-when { font-family:var(--mono); font-size:.72rem; color:var(--ink-3); }
|
|
.handover-who { font-weight:650; color:var(--ink); }
|
|
.handover-count { font-family:var(--mono); font-size:.73rem; color:var(--ink-3); }
|
|
.handover-note { color:var(--ink-2); flex-basis:100%; }
|
|
|
|
/* ---- 억제 --------------------------------------------------------- */
|
|
.suppress-list { display:flex; flex-direction:column; gap:.4rem; margin-top:.7rem; }
|
|
.suppress-row { display:flex; align-items:center; gap:.7rem; padding:.5rem .7rem;
|
|
background:var(--warn-bg); border:1px solid var(--warn-line); border-radius:9px; }
|
|
.suppress-main { display:flex; flex-direction:column; gap:.1rem; flex:1; min-width:0; }
|
|
.suppress-scope { font-family:var(--mono); font-size:.76rem; font-weight:650; color:var(--warn-ink); }
|
|
.suppress-reason { font-size:.82rem; color:var(--ink); }
|
|
.suppress-meta { font-size:.72rem; color:var(--ink-3); }
|
|
|
|
/* ---- 위험성평가 --------------------------------------------------- */
|
|
.risk-list { display:flex; flex-direction:column; gap:.4rem; }
|
|
.risk-row { display:flex; align-items:flex-start; gap:.8rem; padding:.6rem .7rem;
|
|
background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
|
|
.risk-score { flex:none; width:3.2rem; display:flex; flex-direction:column; align-items:center;
|
|
gap:.05rem; padding:.35rem 0; border-radius:8px; background:var(--card); border:1px solid var(--line); }
|
|
.risk-score strong { font-size:1.2rem; font-weight:750; font-variant-numeric:tabular-nums; }
|
|
.risk-score span { font-size:.63rem; font-weight:700; letter-spacing:.04em; }
|
|
.risk-score.is-high { background:var(--crit-bg); border-color:var(--crit-line); color:var(--crit-ink); }
|
|
.risk-score.is-mid { background:var(--warn-bg); border-color:var(--warn-line); color:var(--warn-ink); }
|
|
.risk-score.is-low { color:var(--ink-3); }
|
|
.risk-body { display:flex; flex-direction:column; gap:.12rem; flex:1; min-width:0; }
|
|
.risk-type { font-size:.66rem; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:var(--accent); }
|
|
.risk-hazard { font-size:.86rem; font-weight:600; color:var(--ink); }
|
|
.risk-control { font-size:.78rem; color:var(--ink-2); }
|
|
.risk-meta { font-size:.72rem; color:var(--ink-3); font-family:var(--mono); }
|
|
|
|
/* ---- 성능 추이 ---------------------------------------------------- */
|
|
.metric-chart { min-height:6rem; }
|
|
.metric-svg { width:100%; height:auto; display:block; }
|
|
.metric-svg .axis { stroke:var(--line); stroke-width:1; }
|
|
.metric-svg .series-fps { fill:none; stroke:var(--accent); stroke-width:2; stroke-linejoin:round; }
|
|
.metric-svg .series-throttle { fill:var(--high); opacity:.35; }
|
|
.metric-legend { display:flex; gap:1rem; flex-wrap:wrap; margin-top:.4rem; font-size:.72rem; color:var(--ink-3); }
|
|
.legend-fps::before { content:""; display:inline-block; width:14px; height:2px; background:var(--accent); margin-right:.3rem; vertical-align:middle; }
|
|
.legend-throttle::before { content:""; display:inline-block; width:8px; height:10px; background:var(--high); opacity:.35; margin-right:.3rem; vertical-align:middle; }
|
|
.legend-range { margin-left:auto; font-family:var(--mono); }
|
|
.metric-summary { margin:.35rem 0 0; font-size:.78rem; color:var(--ink-2); font-family:var(--mono); }
|
|
|
|
/* ---- 이벤트 상세: 링크 · 조치 카드 --------------------------------- */
|
|
.detail-side { display:flex; flex-direction:column; gap:.9rem; min-width:0; }
|
|
.detail-links { display:flex; gap:.5rem; flex-wrap:wrap; }
|
|
.detail-link-btn {
|
|
font-size:.78rem; font-weight:600; text-decoration:none; padding:.32rem .65rem; border-radius:8px;
|
|
color:var(--accent); background:var(--accent-wash); border:1px solid var(--accent-line);
|
|
}
|
|
.detail-link-btn:hover { background:#d5e9ed; }
|
|
.detail-card-hint { margin:0 0 .6rem; font-size:.74rem; line-height:1.5; color:var(--ink-3); }
|
|
.detail-action-card .field { display:flex; flex-direction:column; gap:.28rem; margin-bottom:.5rem;
|
|
font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
|
|
.detail-action-card input { text-transform:none; font-weight:400; letter-spacing:0; color:var(--ink); width:100%; }
|
|
|
|
/* ---- 설치 점검 (온보딩) -------------------------------------------- */
|
|
.setup-page { max-width:820px; margin:0 auto; }
|
|
.setup-steps { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.6rem; }
|
|
.setup-step {
|
|
display:flex; gap:.85rem; align-items:flex-start; padding:.85rem 1rem;
|
|
background:var(--card); border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
|
|
}
|
|
.setup-mark {
|
|
display:grid; place-items:center; width:26px; height:26px; flex:none; border-radius:50%;
|
|
font-size:.8rem; font-weight:750;
|
|
color:var(--ink-3); background:var(--panel-2); border:1px solid var(--line);
|
|
}
|
|
.setup-step.is-ok { border-color:var(--accent-line); }
|
|
.setup-step.is-ok .setup-mark { color:#fff; background:var(--ok); border-color:var(--ok); }
|
|
.setup-step.is-todo { background:var(--warn-bg); border-color:var(--warn-line); }
|
|
.setup-step.is-todo .setup-mark { color:var(--warn-ink); background:#fff; border-color:var(--warn-line); }
|
|
/* blocked = 앞 단계가 끝나야 판정할 수 있는 단계. 실패가 아니므로 경고색을 쓰지 않는다. */
|
|
.setup-step.is-blocked { opacity:.62; }
|
|
.setup-step.is-unknown .setup-mark { color:var(--ink-3); }
|
|
.setup-body { display:flex; flex-direction:column; gap:.2rem; min-width:0; }
|
|
.setup-title { margin:0; font-size:.92rem; font-weight:700; }
|
|
.setup-status { margin:0; font-size:.82rem; color:var(--ink-2); }
|
|
.setup-why { margin:.25rem 0 0; font-size:.78rem; line-height:1.55; color:var(--ink-3); }
|
|
.setup-link { align-self:flex-start; margin-top:.4rem; font-size:.78rem; font-weight:600;
|
|
color:var(--accent); text-decoration:none; }
|
|
.setup-link:hover { text-decoration:underline; text-underline-offset:3px; }
|
|
.setup-foot { margin:1rem 0 0; font-size:.76rem; color:var(--ink-3); }
|
|
.setup-foot a { color:var(--accent); }
|
|
|
|
/* ---- 사고조사 패키지 ---------------------------------------------- */
|
|
/* 인쇄물로 제출되는 문서다. 화면과 종이에서 같은 위계로 읽혀야 한다. */
|
|
.package-page { display:flex; flex-direction:column; gap:.9rem; }
|
|
.package-toolbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; }
|
|
.package {
|
|
max-width:860px; margin:0 auto; padding:2rem 2.2rem 2.4rem; background:var(--card);
|
|
border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow);
|
|
}
|
|
.pkg-head { padding-bottom:1rem; margin-bottom:1.2rem; border-bottom:2px solid var(--ink); }
|
|
.pkg-doc-kind { margin:0; font-size:.7rem; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--ink-3); }
|
|
.pkg-title { margin:.3rem 0 .2rem; font-size:1.6rem; }
|
|
.pkg-sub { margin:0; font-size:.85rem; color:var(--ink-2); font-family:var(--mono); }
|
|
.pkg-section { margin-bottom:1.5rem; break-inside:avoid; }
|
|
.pkg-h2 { margin:0 0 .6rem; font-size:.72rem; font-weight:700; letter-spacing:.08em; text-transform:uppercase;
|
|
color:var(--ink-3); padding-bottom:.3rem; border-bottom:1px solid var(--line); }
|
|
.pkg-facts { margin:0; display:grid; grid-template-columns:auto 1fr; gap:.3rem 1rem; }
|
|
.pkg-kv { display:contents; }
|
|
.pkg-kv dt { font-size:.76rem; color:var(--ink-3); }
|
|
.pkg-kv dd { margin:0; font-size:.84rem; color:var(--ink); overflow-wrap:anywhere; }
|
|
.pkg-empty { margin:.3rem 0; font-size:.8rem; color:var(--ink-3); }
|
|
.pkg-note { margin:.6rem 0 0; font-size:.72rem; color:var(--ink-3); }
|
|
.pkg-frames { display:flex; gap:.7rem; flex-wrap:wrap; }
|
|
.pkg-frame { margin:0; flex:1 1 220px; max-width:280px; }
|
|
.pkg-frame img { width:100%; height:auto; display:block; border:1px solid var(--line); border-radius:8px; }
|
|
.pkg-frame figcaption { margin-top:.25rem; font-size:.7rem; font-family:var(--mono); color:var(--ink-3); }
|
|
.pkg-timeline { list-style:none; margin:0; padding:0; }
|
|
.pkg-tl-row { display:flex; align-items:baseline; gap:.7rem; padding:.3rem 0; border-bottom:1px solid var(--line-2); font-size:.8rem; }
|
|
/* 조사 대상 사건은 목록 안에서 즉시 찾을 수 있어야 한다. */
|
|
.pkg-tl-row.is-subject { font-weight:700; background:var(--accent-wash); margin:0 -.5rem; padding:.3rem .5rem; border-radius:6px; }
|
|
.pkg-tl-time { font-family:var(--mono); font-size:.75rem; color:var(--ink-3); flex:none; }
|
|
.pkg-tl-sev { flex:none; font-size:.72rem; font-weight:700; min-width:2.4rem; }
|
|
.pkg-tl-sev.sev-critical { color:var(--crit-ink); }
|
|
.pkg-tl-sev.sev-high { color:var(--high-ink); }
|
|
.pkg-tl-sev.sev-warning { color:var(--warn-ink); }
|
|
.pkg-tl-sev.sev-info { color:var(--info); }
|
|
.pkg-tl-type { flex:1; }
|
|
.pkg-tl-state { flex:none; font-size:.73rem; color:var(--ink-3); }
|
|
.pkg-alerts, .pkg-actions { list-style:none; margin:0; padding:0; }
|
|
.pkg-alert, .pkg-actions li { display:flex; gap:.7rem; align-items:baseline; flex-wrap:wrap;
|
|
padding:.3rem 0; border-bottom:1px solid var(--line-2); font-size:.8rem; }
|
|
.pkg-alert-ch { font-weight:650; }
|
|
.pkg-alert-status { font-size:.75rem; }
|
|
.pkg-alert.is-sent .pkg-alert-status { color:var(--ok); }
|
|
.pkg-alert.is-failed .pkg-alert-status { color:var(--crit-ink); font-weight:700; }
|
|
.pkg-alert.is-suppressed .pkg-alert-status { color:var(--warn-ink); }
|
|
.pkg-alert-when { margin-left:auto; font-family:var(--mono); font-size:.73rem; color:var(--ink-3); }
|
|
.pkg-alert-err { flex-basis:100%; font-size:.72rem; color:var(--crit-ink); font-family:var(--mono); }
|
|
.pkg-act-date { font-family:var(--mono); font-size:.75rem; color:var(--ink-3); }
|
|
.pkg-act-note { flex:1; }
|
|
.pkg-act-by { font-size:.73rem; color:var(--ink-3); }
|
|
.pkg-sign { display:grid; grid-template-columns:repeat(3, 1fr); gap:1.2rem; margin-top:.5rem; }
|
|
.pkg-sign-box { display:flex; flex-direction:column; gap:1.8rem; }
|
|
.pkg-sign-role { font-size:.74rem; color:var(--ink-3); }
|
|
.pkg-sign-line { border-bottom:1px solid var(--ink-3); }
|
|
.pkg-foot { margin-top:1.5rem; padding-top:.7rem; border-top:1px solid var(--line);
|
|
font-size:.7rem; color:var(--ink-3); line-height:1.6; }
|
|
.package-loading { color:var(--ink-3); font-size:.85rem; }
|
|
|
|
@media (max-width:860px) {
|
|
.comp-grid { grid-template-columns:1fr; }
|
|
.package { padding:1.2rem 1rem 1.6rem; }
|
|
.pkg-sign { grid-template-columns:1fr; }
|
|
}
|
|
|
|
/* 패키지 인쇄: 앱 크롬과 조작 버튼을 걷어내고 문서만 남긴다. */
|
|
@media print {
|
|
.package-toolbar, .detail-links, .detail-action-card { display:none !important; }
|
|
.package { max-width:none; margin:0; padding:0; border:0; box-shadow:none; }
|
|
.pkg-section { page-break-inside:avoid; }
|
|
}
|
|
|
|
/* ---- 모바일: 현장 관리자는 폰으로 본다 ---------------------------- */
|
|
/* 데스크 콘솔의 축소판이 아니라, 한 손으로 확인만 하는 화면으로 재구성한다.
|
|
터치 타깃 44px, 썸네일 우선, 부수 정보는 접는다. */
|
|
@media (max-width:560px) {
|
|
.stream-tools { gap:.4rem; }
|
|
.kbd-hint { display:none; } /* 폰에는 키보드가 없다 */
|
|
.view-modes { flex:1; }
|
|
.mode-btn { flex:1; min-height:38px; }
|
|
.bulk-btn { width:100%; min-height:40px; }
|
|
|
|
.event-row { flex-wrap:wrap; padding:.55rem .6rem; gap:.55rem; }
|
|
.event-thumb-slot { width:88px; height:52px; }
|
|
.event-meta { flex:1 1 0; }
|
|
.event-type { font-size:.92rem; }
|
|
/* 액션은 행 아래 전폭으로 내린다 — 44px 타깃을 확보하고, 좁은 화면에서
|
|
썸네일과 버튼이 서로를 밀어내지 않게 한다. */
|
|
.row-actions { margin-left:0; flex-basis:100%; gap:.5rem; }
|
|
.ack-btn { flex:1; min-height:44px; font-size:.88rem; }
|
|
.fp-btn { flex:0 0 5.5rem; min-height:44px; border-color:var(--line); }
|
|
.day-sep span { font-size:.72rem; }
|
|
|
|
.rail { padding:.7rem .65rem; }
|
|
.form-grid { grid-template-columns:1fr; }
|
|
.handover-form { flex-direction:column; align-items:stretch; }
|
|
.handover-form button { min-height:44px; }
|
|
.thr-list { grid-template-columns:1fr; }
|
|
.thr-controls input, .thr-controls button { min-height:44px; }
|
|
.channel-row, .suppress-row, .risk-row { flex-wrap:wrap; }
|
|
.channel-row button, .suppress-row button, .risk-row button { min-height:40px; }
|
|
.detail-grid { gap:.7rem; }
|
|
.detail-actions { width:100%; }
|
|
.detail-actions button { flex:1; min-height:44px; }
|
|
}
|
|
|
|
/* ---- Reduced motion ---------------------------------------------- */
|
|
@media (prefers-reduced-motion:reduce) {
|
|
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
|
|
}
|