f34817d5b6
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.
700 lines
21 KiB
CSS
700 lines
21 KiB
CSS
/* 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); }
|
|
}
|