Initial commit: scan_web integrated recording UI

FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera
settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/
scan_gui_triple desktop tools into one web app.
This commit is contained in:
gardentech
2026-08-07 14:26:00 +09:00
commit f34817d5b6
38 changed files with 5430 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
/* Self-hosted (vendored, not CDN) — the field PC/phone may have no internet.
Both files are variable fonts; one @font-face per family covers every
weight the design uses via the weight axis. */
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/vendor/fonts/Geist-Variable.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Geist Mono";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/vendor/fonts/GeistMono-Variable.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
+699
View File
@@ -0,0 +1,699 @@
/* 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); }
}
+96
View File
@@ -0,0 +1,96 @@
/* Hallmark · genre: modern-minimal · theme: custom (Tally-referenced cool-indigo,
* instrument-panel discipline borrowed from Cobalt) · redesign · designed-as-app
* paper-band: light · display-style: grotesk-sans (Geist) · accent-hue: cool-cobalt (256)
*/
:root {
color-scheme: light;
/* ── Paper / ink — cool engineered near-white, never pure #fff/#000 ── */
--color-paper: oklch(97.8% 0.005 255); /* page ground */
--color-paper-1: oklch(99.3% 0.003 255); /* panel / card surface */
--color-paper-2: oklch(95.0% 0.008 255); /* recessed surface (inputs, browse tree) */
--color-paper-3: oklch(91.5% 0.010 255); /* hover/active recessed surface */
--color-ink: oklch(22.0% 0.020 258); /* headings, primary text */
--color-ink-2: oklch(38.0% 0.016 257); /* body text */
--color-ink-3: oklch(48.0% 0.013 257); /* secondary / hint text */
--color-ink-4: oklch(66.0% 0.010 257); /* placeholder / disabled text */
--color-rule: oklch(85.0% 0.010 255); /* visible hairline border */
--color-rule-soft: oklch(90.5% 0.008 255); /* quiet hairline */
/* ── Accent — one cobalt signal, used for focus / links / active state only ── */
--color-accent: oklch(52.0% 0.185 256);
--color-accent-ink: oklch(98.5% 0.004 255);
--color-accent-tint: oklch(93.5% 0.030 256);
--color-focus: oklch(48.0% 0.200 256);
/* ── Functional state colours — status/health semantics, not brand accent ── */
--color-success: oklch(52.0% 0.145 149);
--color-success-ink: oklch(98.5% 0.004 149);
--color-warning: oklch(64.0% 0.165 68);
--color-warning-ink: oklch(20.0% 0.030 68);
--color-danger: oklch(53.0% 0.195 25);
--color-danger-ink: oklch(98.5% 0.004 25);
/* ── Typography — Geist family throughout (single-family discipline is the
modern-minimal signature), Geist Mono as the one outlier for status/data.
Geist covers Latin only; "Noto Sans KR" carries the Korean UI copy —
the browser falls back to it per-glyph automatically. */
--font-display: "Geist", "Noto Sans CJK KR", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
--font-body: "Geist", "Noto Sans CJK KR", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Noto Sans CJK KR", "Noto Sans KR", ui-monospace, "SF Mono", Menlo, monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 0.9375rem; /* 15px */
--text-md: 1.0625rem; /* 17px */
--text-lg: 1.3125rem; /* 21px */
--text-xl: 1.75rem; /* 28px */
--text-2xl: 2.25rem; /* 36px */
/* ── Spacing — 4pt scale ── */
--space-3xs: 0.25rem; /* 4px */
--space-2xs: 0.375rem; /* 6px */
--space-xs: 0.5rem; /* 8px */
--space-sm: 0.75rem; /* 12px */
--space-md: 1rem; /* 16px */
--space-lg: 1.5rem; /* 24px */
--space-xl: 2rem; /* 32px */
--space-2xl: 3rem; /* 48px */
/* ── Radius — tight/technical for surfaces, pill for actions & chips ── */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-pill: 999px;
/* ── Elevation — hairline borders do the work; shadow is a whisper, one only ── */
--shadow-whisper: 0 1px 2px oklch(20% 0.02 258 / 0.06);
/* ── Motion — three named eases, no bounce/overshoot ── */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--dur-fast: 100ms;
--dur-mid: 180ms;
--dur-slow: 280ms;
/* ── Z-index — named scale ── */
--z-base: 1;
--z-raised: 10;
--z-dropdown: 100;
--z-sticky: 200;
--z-modal: 400;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: var(--dur-fast) !important;
scroll-behavior: auto !important;
}
}