/* Hallmark · pre-emit critique: P5 H4 E4 S4 R5 V4 * genre: modern-minimal · theme: custom (Tally-referenced) · redesign * scope: app (single-screen phone HUD over a live scan view) — no marketing * macrostructure/nav/footer archetypes apply * anchor hue: cool-cobalt 256 · outdoor-legibility constraint: status never colour-only */ html, body { overflow: hidden; height: 100%; margin: 0; } * { box-sizing: border-box; } body { background: var(--color-paper); color: var(--color-ink-2); font-family: var(--font-body); font-weight: 400; font-size: var(--text-base); line-height: 1.5; -webkit-font-smoothing: antialiased; } h1, h2, h3 { font-family: var(--font-display); font-weight: 650; font-style: normal; letter-spacing: -0.015em; color: var(--color-ink); margin: 0; } .hidden { display: none !important; } a { color: var(--color-accent); } /* ── Focus — instant, never animated, always visible ────────────────────── */ :focus { outline: none; } :focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } /* ── App shell — one fixed viewport, no page scroll ever. The scan view is the permanent base layer; the HUD floats fixed on top of it; Settings/ Recording/Log are overlays drawn above both, never a navigation away. ── */ #app { position: fixed; inset: 0; overflow: hidden; } .scan-view { position: absolute; inset: 0; overflow: hidden; background: var(--color-ink); } #pointcloud-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; touch-action: none; /* OrbitControls owns touch gestures here, not the browser (no pull-to-refresh/pinch-zoom-page fighting it) */ } .scan-placeholder { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--color-ink-4); font-family: var(--font-mono); font-size: var(--text-sm); text-align: center; padding: var(--space-2xl); } /* ── HUD — fixed, pointer-events pass through the gaps so the scan view underneath stays interactive (pan/zoom, once M3 adds that). Top/bottom scrims guarantee legible light text regardless of what's rendered in the scan view beneath (placeholder today, a point cloud later). ── */ .hud { position: absolute; inset: 0; z-index: var(--z-sticky); display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; } .hud-top { pointer-events: auto; display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2xs); padding: max(var(--space-xs), env(safe-area-inset-top)) max(var(--space-sm), env(safe-area-inset-right)) var(--space-lg) max(var(--space-sm), env(safe-area-inset-left)); background: linear-gradient(to bottom, oklch(20% 0.02 258 / 0.65), transparent); } .hud-bottom { pointer-events: auto; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm); padding: var(--space-lg) max(var(--space-sm), env(safe-area-inset-right)) max(var(--space-xs), env(safe-area-inset-bottom)) max(var(--space-sm), env(safe-area-inset-left)); background: linear-gradient(to top, oklch(20% 0.02 258 / 0.65), transparent); } .hud-startup, .hud-rec, .hud-actions, .hud-top-right { display: flex; align-items: center; gap: var(--space-xs); } .status-badges { display: flex; gap: var(--space-2xs); flex-wrap: wrap; } /* ── Status badges — icon + text, never colour alone (outdoor/colour-blind safe) ── */ .badge { display: inline-flex; align-items: center; gap: 0.3em; padding: 0.3em 0.7em; border-radius: var(--radius-pill); font-family: var(--font-mono); font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.02em; line-height: 1.4; white-space: nowrap; } /* Sensor badges (LiDAR/IMU/camN) get a hard fixed width, not shrink-to-fit — icon glyphs (✓/!/✕) can render at slightly different widths than monospace text depending on font fallback, so relying on content width alone still let the box size drift. A fixed width removes that variable entirely: label sits left, Hz sits right (space-between), and the box itself never resizes as health/Hz change. Sized generously for the longest realistic content ("✓ LiDAR STALE 199.9Hz"); flex-shrink:0 stops the row from squeezing it under width pressure. */ .badge-sensor { width: 15em; box-sizing: border-box; flex-shrink: 0; justify-content: space-between; } .badge-ok { background: var(--color-success); color: var(--color-success-ink); } .badge-stale { background: var(--color-warning); color: var(--color-warning-ink); } .badge-down { background: var(--color-danger); color: var(--color-danger-ink); } .badge-rec { background: var(--color-danger); color: var(--color-danger-ink); } .badge-ok::before { content: "✓"; } .badge-stale::before { content: "!"; } .badge-down::before { content: "✕"; } .badge-rec::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; animation: badge-rec-pulse 1.4s var(--ease-in-out) infinite; } @keyframes badge-rec-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } @media (prefers-reduced-motion: reduce) { .badge-rec::before { animation: none; } } /* Badge Hz suffix — a measured number, not just a traffic-light state. status-panel.js NBSP-pads the health word and Hz digits to a fixed character count so badge width never changes between updates (that's the real fix); min-width here is just a safety margin against font metric rounding. */ .badge .badge-hz { display: inline-block; min-width: 7ch; text-align: right; opacity: 0.85; font-weight: 500; } .hint { color: var(--color-ink-3); font-size: var(--text-xs); margin: var(--space-2xs) 0; word-break: break-all; font-family: var(--font-mono); } /* ── Buttons — pill, semantic fills, full state coverage ───────────────── default · hover · focus-visible · active · disabled all styled below. */ .btn-row { display: flex; gap: var(--space-xs); margin: var(--space-sm) 0; } .btn { flex: 1; min-height: 44px; /* touch target floor */ padding: 0 var(--space-md); border: 1px solid transparent; border-radius: var(--radius-pill); font-family: var(--font-body); font-weight: 600; font-size: var(--text-sm); white-space: nowrap; cursor: pointer; transition: background-color var(--dur-mid) var(--ease-out), border-color var(--dur-mid) var(--ease-out), transform var(--dur-fast) var(--ease-out); } .btn:active { transform: translateY(1px); } .btn:disabled { background: var(--color-paper-2) !important; color: var(--color-ink-4) !important; border-color: var(--color-rule-soft) !important; cursor: not-allowed; transform: none; } .btn-green { background: var(--color-success); color: var(--color-success-ink); } .btn-green:hover:not(:disabled) { background: oklch(from var(--color-success) calc(l - 0.05) c h); } .btn-red { background: var(--color-danger); color: var(--color-danger-ink); } .btn-red:hover:not(:disabled) { background: oklch(from var(--color-danger) calc(l - 0.05) c h); } .btn-blue { background: var(--color-accent); color: var(--color-accent-ink); } .btn-blue:hover:not(:disabled) { background: oklch(from var(--color-accent) calc(l - 0.05) c h); } .btn-gray { background: var(--color-paper-2); color: var(--color-ink-2); border-color: var(--color-rule); } .btn-gray:hover:not(:disabled) { background: var(--color-paper-3); } .btn-small { flex: 0 0 auto; width: 44px; min-height: 44px; border-radius: var(--radius-sm); background: var(--color-paper-2); color: var(--color-ink-2); border-color: var(--color-rule); } .btn-small:hover { background: var(--color-paper-3); } /* HUD buttons sit inline in a floating strip, not a stacked full-width .btn-row, so they must not stretch (flex:1) — size to content instead. */ .btn-hud { flex: none; min-width: 64px; box-shadow: var(--shadow-whisper); } .btn-icon { min-width: 44px; width: 44px; padding: 0; font-size: var(--text-md); } .btn-estop { min-height: 52px; background: var(--color-paper-1); color: var(--color-danger); border: 1.5px solid var(--color-danger); border-radius: var(--radius-md); font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; cursor: pointer; transition: background-color var(--dur-mid) var(--ease-out), color var(--dur-mid) var(--ease-out); } .btn-estop:hover { background: var(--color-danger); color: var(--color-danger-ink); } .btn-estop:active { transform: translateY(1px); } .btn-estop-hud { min-height: 44px; padding: 0 var(--space-md); font-size: var(--text-xs); background: oklch(20% 0.02 258 / 0.55); box-shadow: var(--shadow-whisper); } /* ── State lines (● + label — carried over from the field-proven PyQt5 idiom) ── Default colours assume a light panel; the HUD versions below override for legibility over the dark top/bottom scrims. */ .state-line { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 600; margin-top: var(--space-2xs); } .state-line.on { color: var(--color-success); } .state-line.off { color: var(--color-ink-4); } .state-line.rec { color: var(--color-danger); } .hud .state-line { font-size: var(--text-xs); text-shadow: 0 1px 3px oklch(0% 0 0 / 0.5); } .hud .state-line.off { color: var(--color-paper-3); } /* ── Disk free space — safety info, not decoration ───────────────────── */ .disk-free { display: flex; align-items: center; gap: 0.4em; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-ink-3); } .disk-free.warning { color: var(--color-warning); font-weight: 600; } .disk-free.danger { color: var(--color-danger); font-weight: 600; } /* Small always-on chip in the HUD top strip — same idea as the sensor badges, just neutral (free space isn't a health traffic light). */ .hud-disk-free { padding: 0.3em 0.7em; border-radius: var(--radius-pill); background: oklch(20% 0.02 258 / 0.55); color: var(--color-paper-1); font-weight: 600; } .hud-disk-free.warning { color: oklch(78% 0.16 68); } .hud-disk-free.danger { color: oklch(78% 0.18 25); } /* ── Live recording meter (elapsed + size) ───────────────────────────── */ .rec-meter { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-ink-3); font-variant-numeric: tabular-nums; } .hud .rec-meter { color: var(--color-paper-1); text-shadow: 0 1px 3px oklch(0% 0 0 / 0.5); } /* ── Bag list — richer entries (duration / size / topic count) ──────── */ .bag-item { padding: var(--space-xs) 0; border-bottom: 1px solid var(--color-rule-soft); } .bag-item:last-child { border-bottom: none; } .bag-item-name { color: var(--color-ink-2); font-weight: 600; display: flex; align-items: center; gap: 0.4em; } .bag-item-meta { color: var(--color-ink-4); font-size: var(--text-xs); margin-top: 2px; } .bag-item-incomplete { color: var(--color-warning); font-weight: 600; } /* ── Overlays — Settings / Recording / Log all float as a centred sheet above the scan view + HUD, never a page navigation. Later overlays in DOM order (log, opened from inside Settings) stack visually above earlier ones at the same z-index. ── */ .overlay { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; align-items: center; justify-content: center; padding: var(--space-md); } .overlay-backdrop { position: absolute; inset: 0; background: oklch(15% 0.02 258 / 0.55); } .overlay-sheet { position: relative; display: flex; flex-direction: column; width: 100%; max-width: 480px; max-height: min(85dvh, 720px); background: var(--color-paper-1); border: 1px solid var(--color-rule); border-radius: var(--radius-lg); box-shadow: 0 8px 32px oklch(15% 0.02 258 / 0.28); overflow: hidden; } .overlay-sheet-small { max-width: 380px; } .overlay-header { flex: none; display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); padding: var(--space-md) var(--space-lg); border-bottom: 1px solid var(--color-rule-soft); } .overlay-header h2 { font-size: var(--text-md); } .btn-close { flex: none; width: 36px; height: 36px; border: none; border-radius: var(--radius-sm); background: var(--color-paper-2); color: var(--color-ink-2); font-size: var(--text-sm); cursor: pointer; } .btn-close:hover { background: var(--color-paper-3); } .overlay-tabs { flex: none; display: flex; gap: var(--space-lg); padding: 0 var(--space-lg); border-bottom: 1px solid var(--color-rule); overflow-x: auto; } .overlay-body { flex: 1; overflow-y: auto; padding: var(--space-lg); } /* Outer overlay-level tabs (카메라 설정 / 미리보기 / 최근 녹화 / 로그). Deliberately a different class from .tab-btn/.tab-panel below — those are reset via a global querySelectorAll by camera-settings.js for the per-camera tabs, which would otherwise clobber these too. */ .pane-tab-btn { padding: var(--space-xs) 0; min-height: 40px; border: none; border-bottom: 2px solid transparent; background: transparent; color: var(--color-ink-3); font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 500; cursor: pointer; white-space: nowrap; transition: color var(--dur-mid) var(--ease-out), border-color var(--dur-mid) var(--ease-out); } .pane-tab-btn:hover { color: var(--color-ink); } .pane-tab-btn.active { color: var(--color-ink); border-bottom-color: var(--color-accent); font-weight: 600; } .pane-tab-btn-log { margin-left: auto; color: var(--color-accent); } .pane-tab { display: none; } .pane-tab.active { display: block; } /* ── Log overlay — the unified [tag] scrollback the PyQt tools had ──────── */ .log-header-actions { display: flex; align-items: center; gap: var(--space-md); } .log-autoscroll { display: flex; align-items: center; gap: var(--space-2xs); font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-ink-3); cursor: pointer; } .log-autoscroll input { accent-color: var(--color-accent); cursor: pointer; } .log-body { flex: 1; min-height: 0; background: var(--color-ink); padding: var(--space-sm) var(--space-md); overflow-y: auto; font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.6; white-space: pre-wrap; word-break: break-all; } .log-line { display: flex; gap: 0.6em; } .log-line .log-tag { flex: 0 0 auto; color: var(--color-ink-4); text-transform: uppercase; font-weight: 600; } .log-line.tag-lidar .log-tag { color: oklch(70% 0.14 256); } .log-line.tag-camera .log-tag { color: oklch(72% 0.14 149); } .log-line.tag-recording .log-tag { color: oklch(72% 0.16 25); } .log-line.tag-fastlio .log-tag { color: oklch(72% 0.15 320); } .log-line .log-text { color: var(--color-paper-1); } /* ── Form fields ──────────────────────────────────────────────────────── */ .field-row { display: flex; align-items: center; gap: var(--space-xs); margin: var(--space-sm) 0; } .field-row label { width: 72px; flex: 0 0 72px; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-ink-3); } .field-row input[type=text] { flex: 1; min-height: 40px; padding: 0 var(--space-sm); background: var(--color-paper-2); border: 1px solid var(--color-rule); border-radius: var(--radius-sm); color: var(--color-ink); font-family: var(--font-body); font-size: var(--text-sm); transition: border-color var(--dur-mid) var(--ease-out), background-color var(--dur-mid) var(--ease-out); } .field-row input[type=text]:hover { border-color: var(--color-ink-4); } .field-row input[type=text]:focus-visible { background: var(--color-paper-1); border-color: var(--color-accent); outline: 2px solid var(--color-focus); outline-offset: 1px; } .field-row input[type=text]::placeholder { color: var(--color-ink-4); } .browse-panel { border: 1px solid var(--color-rule); border-radius: var(--radius-sm); background: var(--color-paper-2); max-height: 180px; overflow-y: auto; padding: var(--space-2xs); margin-bottom: var(--space-sm); font-family: var(--font-mono); font-size: var(--text-xs); } .browse-panel .dir-entry { padding: var(--space-xs); border-radius: var(--radius-sm); cursor: pointer; transition: background-color var(--dur-fast) var(--ease-out); } .browse-panel .dir-entry:hover { background: var(--color-paper-3); } .bag-list { list-style: none; padding: 0; margin: 0; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-ink-2); } .bag-list li { padding: var(--space-xs) 0; border-bottom: 1px solid var(--color-rule-soft); } .bag-list li:last-child { border-bottom: none; } /* ── Tabs — underline indicator, crossfade content (never slide). Per-camera tabs inside the "카메라 설정" pane. ── */ .tabs { display: flex; gap: var(--space-lg); border-bottom: 1px solid var(--color-rule); margin-bottom: var(--space-md); } .tab-btn { padding: var(--space-xs) 0; min-height: 40px; border: none; border-bottom: 2px solid transparent; background: transparent; color: var(--color-ink-3); font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: color var(--dur-mid) var(--ease-out), border-color var(--dur-mid) var(--ease-out); } .tab-btn:hover { color: var(--color-ink); } .tab-btn.active { color: var(--color-ink); border-bottom-color: var(--color-accent); font-weight: 600; } .tab-panel { display: none; animation: tab-crossfade var(--dur-mid) var(--ease-out); } .tab-panel.active { display: block; } @keyframes tab-crossfade { from { opacity: 0; } to { opacity: 1; } } @media (prefers-reduced-motion: reduce) { .tab-panel { animation: none; } } .tab-panel > label:first-child { display: flex; align-items: center; gap: var(--space-xs); font-size: var(--text-sm); color: var(--color-ink-2); cursor: pointer; } .tab-panel input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--color-accent); cursor: pointer; } .cam-field-grid { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: var(--space-xs) var(--space-sm); align-items: center; margin: var(--space-sm) 0; } .cam-field-grid label { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-ink-3); } .cam-field-grid input[type=number] { min-height: 36px; padding: 0 var(--space-xs); border: 1px solid var(--color-rule); border-radius: var(--radius-sm); background: var(--color-paper-2); color: var(--color-ink); font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-sm); width: 100%; transition: border-color var(--dur-mid) var(--ease-out); } .cam-field-grid input[type=number]:hover { border-color: var(--color-ink-4); } .cam-field-grid input[type=number]:focus-visible { border-color: var(--color-accent); outline: 2px solid var(--color-focus); outline-offset: 1px; } .cam-field-grid input:disabled { color: var(--color-ink-4); background: var(--color-paper); border-color: var(--color-rule-soft); } .cam-result { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-ink-3); min-height: 1.4em; margin-top: var(--space-2xs); } /* ── Camera preview grid ─────────────────────────────────────────────── */ .camera-grid { display: flex; gap: var(--space-md); flex-wrap: wrap; } .camera-pane { flex: 1; min-width: 260px; } .camera-pane .cam-title { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-ink-3); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: var(--space-2xs); } .camera-pane img { width: 100%; aspect-ratio: 4 / 3; object-fit: contain; background: var(--color-ink); border: 1px solid var(--color-rule); border-radius: var(--radius-md); display: block; } /* ── Mobile / small overlay widths — verified at 320 / 375 / 414 / 768 px (Hallmark floor). No horizontal scroll, no two-line button labels, tap targets >= 44px. The app shell itself has no scroll at all (see #app). ── */ @media (max-width: 480px) { .field-row { flex-wrap: wrap; } .field-row label { width: 100%; flex-basis: 100%; } .cam-field-grid { grid-template-columns: 1fr; } .cam-field-grid label { margin-top: var(--space-2xs); } }