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:
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user