CIS490/training/dashboard/static/dashboard.css
Max Gorog 3783fabe86 live scene: per-host swim lanes + latest-detection callout
New scene 13 (between perf and references) for fleet-wide live
predictions. Each host gets a row of recent prediction cells
(capped at 60), painted by predicted phase; mismatch with ground
truth shows a hatched overlay. A callout below the lanes holds
the most recent detection with model, profile, confidence, and
latency.

Producer contract is the new LiveDetection dataclass in events.py.
The dashboard side is producer-agnostic — the inference loop can
run locally or offload to A100 (or any GPU/host); just POST events
back. No rate-limiting needed; the swim-lane DOM does the capping.

Demo synthesizes 5 hosts walking through phases at ~92% accuracy
so the scene reads as live the moment the deck loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:03:32 -05:00

1245 lines
49 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

:root {
color-scheme: dark;
/* Theme machinery overrides --c1..c5, --accent, --accent-soft via
JS. The fallback values below match the previous hard-coded
palette so the page looks identical on a fresh load. */
--theme-l: 70%;
--theme-c: 0.15;
--theme-h: 250;
--c1: oklch(70% 0.15 250);
--c2: oklch(70% 0.15 220);
--c3: oklch(70% 0.15 280);
--c4: oklch(70% 0.15 340);
--c5: oklch(70% 0.15 100);
--accent: var(--c1);
--accent-soft: oklch(70% 0.15 250 / 0.15);
--bg: #07090d;
--bg-rgb: 7, 9, 13;
--bg-elev: #0d1117;
--bg-elev2: #161b22;
/* Text & line greys, theme-aware with a discontinuous jump.
A *linear* response of grey-L to theme-L is too easy to push
into unreadable territory at the slider extremes. So the L
dependence is a STEP at theme-L = 50%: clamp(0, (L - 50) ×
1000, 1) gives 0 below 50 and 1 above 50 with a vanishingly
small transition zone — effectively a step function expressed
in pure CSS. Both step values are inside their grey's safe
contrast band. Chroma tint stays linear (high C → more hue
in the greys) since chroma doesn't threaten contrast. Falls
back to mid-band values if --theme-l-num isn't set (briefly
during initial load before JS runs).
The step landing zones are calibrated so:
theme-L < 50 → cooler / dimmer greys (suits darker accents)
theme-L ≥ 50 → brighter / fuller greys (suits brighter accents)
and the difference is small enough not to wreck the design,
yet large enough that you SEE the click as you cross 50%. */
--l-step: clamp(0, calc((var(--theme-l-num, 70) - 50) * 1000), 1);
--c-tint: calc(var(--theme-c, 0.15) * 0.18);
--fg: oklch(
calc(89% + var(--l-step) * 5%)
var(--c-tint)
var(--theme-h, 250));
--fg-dim: oklch(
calc(56% + var(--l-step) * 9%)
calc(var(--c-tint) * 1.2)
var(--theme-h, 250));
--fg-mute: oklch(
calc(33% + var(--l-step) * 7%)
calc(var(--c-tint) * 0.9)
var(--theme-h, 250));
--line: oklch(
calc(16% + var(--l-step) * 4%)
calc(var(--c-tint) * 0.4)
var(--theme-h, 250));
--line-soft: oklch(
calc(20% + var(--l-step) * 4%)
calc(var(--c-tint) * 0.5)
var(--theme-h, 250));
--accent: #58a6ff;
--accent-soft: rgba(88, 166, 255, 0.15);
--warn: #f85149;
--ok: #3fb950;
--phase-clean: #3fb950;
--phase-armed: #d29922;
--phase-infecting: #db61a2;
--phase-running: #f85149;
--phase-dormant: #6e7681;
--topbar-h: 44px;
--prose-w: 36em;
--scene-fade-ms: 600ms;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased; overflow-x: hidden;
}
/* ─── Background canvas (theme-driven) ─────────────────────────────── */
/* Animation timings respect --anim-speed (multiplier; 1 = nominal,
2 = twice as fast). Whole canvas can also be blurred via
--bg-blur. Both are set by the theme panel sliders. */
/* Minimal positioning, no layer-promotion tricks. Every stacking-
context-creating property here (filter, transform, isolation,
contain) caused some downstream compositor artifact in earlier
iterations. The browser will GPU-promote on its own when needed.
`filter` is only applied when the bg-blur slider is non-zero —
the JS sets `--bg-filter` to `blur(Npx)` then, and removes the
custom property when blur is 0 so the rule falls through to
`none`. */
.bg-canvas {
position: fixed; inset: 0; z-index: 0;
pointer-events: none; overflow: hidden;
background: var(--bg);
filter: var(--bg-filter, none);
overflow-anchor: none;
}
/* Subtle palette-tinted vignette — applies on every theme so even
"black" picks up palette character. Tint strength controlled by
--tint-strength via the panel. */
.bg-tint {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(ellipse 90% 70% at 50% 65%,
oklch(var(--theme-l) calc(var(--theme-c) * 0.6) var(--theme-h) / var(--tint-strength, 0.10)),
transparent 65%);
}
.bg-drift, .bg-lava, .bg-vaporwave, .bg-laser {
position: absolute; inset: 0; display: none;
}
body[data-theme="drift"] .bg-drift { display: block; }
body[data-theme="lava"] .bg-lava { display: block; }
body[data-theme="vaporwave"] .bg-vaporwave { display: block; }
body[data-theme="laser"] .bg-laser { display: block; }
/* drift — soft, blurred radial blobs floating up and back down.
The blobs are JS-generated; styling here uses theme variables
(--drift-blur, --drift-opacity) so live slider changes don't
need a DOM rebuild. */
.drift-blob {
position: absolute; border-radius: 50%;
filter: blur(var(--drift-blur, 70px));
opacity: var(--drift-opacity, 0.55);
mix-blend-mode: screen;
}
@keyframes drift-rise {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-130vh) scale(1.4); }
}
/* lava — metaballs via SVG goo filter. The filter values
(stdDeviation, alpha-threshold matrix entries) are updated by
JS as the goo strength / blur sliders move. Bubbles are
JS-generated so their statistical-distribution sliders work. */
.goo-container { position: absolute; inset: 0; filter: url(#goo); }
.goo-bubble { position: absolute; border-radius: 50%; opacity: 0.95; }
@keyframes goo-rise {
0% { transform: translateY(0) scale(0.9); }
45% { transform: translateY(-65vh) scale(1.05); }
55% { transform: translateY(-75vh) scale(1.1); }
100% { transform: translateY(-130vh) scale(0.85); }
}
/* ─── Vaporwave (overhauled) ───────────────────────────────────────
Layered: gradient sky → palette-blended sun with venetian-blind
stripes → glowing horizon → perspective grid floor → CRT scanlines.
Knobs (CSS vars set by sliders): --vw-horizon, --vw-grid-size,
--vw-perspective, --vw-sun-size, --vw-blind. */
.bg-vaporwave {
position: absolute; inset: 0;
background: radial-gradient(
ellipse 100% 70% at 50% var(--vw-horizon, 55%),
transparent 0%, transparent 60%,
rgba(0, 0, 0, 0.6) 100%);
}
.vw-sky {
position: absolute; left: 0; right: 0; top: 0;
height: var(--vw-horizon, 55%);
background: linear-gradient(
to bottom,
oklch(15% 0.20 var(--theme-h, 250)) 0%,
var(--c1) 60%,
var(--c2) 100%);
}
.vw-sun {
position: absolute; left: 50%;
top: var(--vw-horizon, 55%);
width: var(--vw-sun-size, 50vmin);
height: var(--vw-sun-size, 50vmin);
transform: translate(-50%, -78%);
border-radius: 50%;
background: linear-gradient(
to bottom,
var(--c3) 0%,
var(--c4) 50%,
var(--c5) 100%);
overflow: hidden;
box-shadow: 0 0 80px var(--c4), 0 0 160px var(--c3);
}
/* Venetian-blind stripes on the lower 50% — black-bg occluders, so
the sun's gradient peeks through the gaps. */
.vw-sun-blinds {
position: absolute; left: 0; right: 0; bottom: 0;
height: 50%;
background: repeating-linear-gradient(
to bottom,
transparent 0 calc(var(--vw-blind, 11px) - 4px),
rgba(0, 0, 0, 0.92) calc(var(--vw-blind, 11px) - 4px) var(--vw-blind, 11px));
}
.vw-horizon {
position: absolute; left: 0; right: 0;
top: var(--vw-horizon, 55%);
height: 1px; background: var(--c1);
box-shadow:
0 0 20px var(--c1),
0 0 40px var(--c1),
0 0 80px var(--c2);
}
/* The vaporwave floor is a <canvas> drawn frame-by-frame in JS via
requestAnimationFrame. Two earlier CSS-only attempts (perspective +
rotateX with animated background-position; then split rotate +
translate3d on a repeating-linear-gradient) both flickered when
the user scrolled — the perspective math kept getting recomputed
in lockstep with document scroll, even with isolation: isolate
and contain: paint. The canvas approach side-steps that entirely:
no perspective transform, lines drawn at exact pixel positions
each frame, palette colors read from CSS custom properties. */
/* CSS perspective grid. Three layered elements so rotate (static)
and translate (animated) live on different layers — animating
transform on the rotated element directly was what re-rasterized
per frame in earlier attempts. Each element is also pushed into
its own compositor layer to keep the document scroll from
dirtying the bg. */
/* Collapsed structure: one perspective container, one animated grid
element. The rotation and translation live on the SAME transform
on the SAME element — no nested transform-style: preserve-3d, no
separately-promoted compositor layer for the translate. The
compositor has nothing to disagree about, so the phantom-orthogonal
3D-grid post artifact disappears. */
.vw-floor {
position: absolute; left: -50%; right: -50%;
top: var(--vw-horizon, 55%); bottom: -10%;
perspective: 800px;
overflow: hidden;
}
.vw-floor-grid {
position: absolute; left: 0; right: 0; top: 0; height: 200%;
background-image:
repeating-linear-gradient(
to bottom,
transparent 0 calc(var(--vw-grid-size, 80px) - 3px),
var(--c1) calc(var(--vw-grid-size, 80px) - 3px) var(--vw-grid-size, 80px)),
repeating-linear-gradient(
to right,
transparent 0 calc(var(--vw-grid-size, 80px) - 3px),
var(--c2) calc(var(--vw-grid-size, 80px) - 3px) var(--vw-grid-size, 80px));
transform-origin: top center;
animation: vw-floor-y calc(4s / var(--anim-speed, 1)) linear infinite;
}
/* Both keyframes carry the same rotateX, so it stays static; only
translateY interpolates. The grid never leaves its rotated plane
because its rotation is part of the same transform list. */
@keyframes vw-floor-y {
from { transform: rotateX(var(--vw-perspective, 62deg)) translateY(0); }
to { transform: rotateX(var(--vw-perspective, 62deg))
translateY(var(--vw-grid-size, 80px)); }
}
/* Scanlines: confined to the sky above the horizon and stacked
below the sun (DOM-ordered between .vw-sky and .vw-sun) so the
sun's solid disc occludes them in its area. The 5 px period is
off-resonance with the floor's perspective grid (which is below
the horizon and never overlaps with these anyway) and with the
sun's blinds (which the sun itself hides). Plain alpha
compositing — no mix-blend-mode multiply that previously
created an isolation group flattening 3D content beneath. */
.vw-scanlines {
position: absolute; left: 0; right: 0; top: 0;
height: var(--vw-horizon, 55%);
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
transparent 0 3px,
rgba(0, 0, 0, 0.14) 3px 5px);
}
/* laser show — long beams rotating from screen center, palette colors.
Beams JS-generated so count/thickness/blur sliders work. */
.laser-beam {
position: absolute; left: 50%; top: 50%;
height: var(--laser-thickness, 3px);
width: 200vmax; transform-origin: left center;
filter: blur(var(--laser-blur, 2px));
opacity: var(--laser-opacity, 0.55);
mix-blend-mode: screen;
}
@keyframes laser-sweep {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ─── References scene (PDF viewer + tab strip + description) ──────── */
.ref-stack { /* metric-stack-wide variant; let content area fill height */
height: 100%;
justify-content: flex-start;
}
.ref-tabs {
display: flex; flex-wrap: wrap; gap: 6px;
max-height: clamp(60px, 9vh, 110px);
overflow-y: auto;
}
.ref-tabs .awaiting {
color: var(--fg-mute); font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 4px 0;
}
.ref-tab {
background: transparent; color: var(--fg-dim);
border: 1px solid var(--line); border-radius: 16px;
padding: 4px 12px; font: inherit; font-size: 12px; cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
transition: color 120ms, border-color 120ms, background 120ms;
max-width: 28em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ref-tab:hover { color: var(--fg); border-color: var(--fg-mute); }
.ref-tab.active {
color: var(--accent); border-color: var(--accent);
background: var(--accent-soft);
}
/* Two-column layout: PDF viewer on the left taking the larger
share, description panel on the right. The viewer's column
uses minmax(0, …) so the iframe won't blow out the grid when
the PDF reports a wide intrinsic size. */
.ref-content {
flex: 1 1 auto; min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.55fr);
gap: 14px;
}
.ref-viewer-wrap {
background: var(--bg-elev);
border: 1px solid var(--line); border-radius: 4px;
overflow: hidden;
min-height: 0;
}
.ref-viewer {
width: 100%; height: 100%;
min-height: clamp(360px, 70vh, 900px);
border: 0; display: block; background: var(--bg-elev);
}
.ref-description {
background: var(--bg-elev);
border: 1px solid var(--line); border-radius: 4px;
overflow-y: auto;
padding: 18px 22px;
font-size: 14px; line-height: 1.6;
color: var(--fg);
min-height: 0;
}
.ref-description h1, .ref-description h2 {
font-size: 15px; font-weight: 600; margin: 0 0 10px;
color: var(--fg);
}
.ref-description h3 { font-size: 13px; font-weight: 600; margin: 12px 0 4px; }
.ref-description p { margin: 0 0 10px; }
.ref-description ul,
.ref-description ol { margin: 0 0 10px; padding-left: 20px; }
.ref-description li { margin: 0 0 4px; }
.ref-description code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em; color: var(--accent);
background: var(--accent-soft); padding: 1px 5px; border-radius: 3px;
}
.ref-description strong { color: var(--fg); font-weight: 600; }
.ref-description em { color: var(--fg-dim); font-style: italic; }
.ref-description .awaiting {
color: var(--fg-mute); font-style: italic;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
/* On narrow viewports stack vertically: PDF on top, description
below, capped to a sensible height so the PDF still gets room. */
@media (max-width: 1100px) {
.ref-content { grid-template-columns: 1fr; }
.ref-description { max-height: 240px; }
}
/* References scene wants more horizontal room than the default
metric scenes — the PDF is the point. Drop the right padding
that reserves space for the prose column. The prose for this
scene is hidden anyway (see below) so we can use the full width
for the PDF + description grid. */
.stage-view[data-view="references"] {
padding-right: clamp(8px, 2vw, 48px);
}
/* Hide the prose card on the references scene — the description
panel inside the metric-stack already explains each PDF in
context, and freeing the right-side viewport gives the
description panel proper room. */
.scene[data-stage="references"] .prose { display: none; }
/* ─── Per-theme settings section ───────────────────────────────────── */
.theme-bg-section { display: none; }
.theme-bg-section.is-active { display: block; }
/* Continuous-harmony hint line */
.theme-harmony-hint {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; color: var(--fg-mute); padding: 2px 0;
}
/* Theme-panel advanced section accordion */
.theme-advanced { border-top: 1px solid var(--line); padding-top: 6px; }
.theme-advanced > summary {
cursor: pointer; color: var(--fg-dim);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; letter-spacing: 0.04em; padding: 4px 0;
list-style: none;
}
.theme-advanced > summary::-webkit-details-marker { display: none; }
.theme-advanced > summary::before { content: '▸ '; display: inline-block; transition: transform 150ms; }
.theme-advanced[open] > summary::before { transform: rotate(90deg); }
.theme-advanced > .theme-sliders { padding-top: 6px; }
/* ─── Theme panel ──────────────────────────────────────────────────── */
/* Right-half sidebar that slides in from the right when open.
Always rendered (no hidden attribute) so the transform animation
works; visibility gated by the .is-open class. Pointer-events
are turned off when closed so the hidden panel doesn't intercept
clicks on the page beneath. */
.theme-panel {
position: fixed;
top: var(--topbar-h); right: 0; bottom: 0;
width: 50vw;
z-index: 60;
background: rgba(13, 17, 23, 0.95);
backdrop-filter: blur(12px);
border-left: 1px solid var(--line);
padding: 20px 28px 28px;
color: var(--fg); font-size: 12px;
display: flex; flex-direction: column; gap: 14px;
overflow-y: auto;
box-shadow: -20px 0 60px rgba(0, 0, 0, 0.5);
transform: translateX(100%);
transition: transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.theme-panel.is-open {
transform: translateX(0);
pointer-events: auto;
}
@media (max-width: 880px) {
.theme-panel { width: 100vw; }
}
.theme-panel-header {
position: sticky; top: 0;
display: flex; align-items: center; gap: 8px;
background: inherit;
padding-bottom: 10px;
margin: -20px -28px 0;
padding: 14px 28px;
border-bottom: 1px solid var(--line);
}
.theme-panel-header .theme-title { flex: 1; font-weight: 600; letter-spacing: 0.04em; }
.theme-panel select, .theme-panel input[type="range"] {
background: var(--bg-elev); color: var(--fg);
border: 1px solid var(--line); border-radius: 4px;
font: inherit; font-size: 12px;
}
.theme-panel select { padding: 4px 8px; }
.theme-row {
display: flex; align-items: center; gap: 10px;
}
.theme-row > span:first-child {
flex: 0 0 90px; color: var(--fg-dim);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; letter-spacing: 0.04em; text-transform: lowercase;
}
.theme-row > select { flex: 1; }
.theme-wheel-block {
display: grid; grid-template-columns: 200px 1fr; gap: 14px; align-items: center;
}
.theme-wheel {
position: relative; width: 200px; height: 200px;
user-select: none; touch-action: none;
}
.wheel-disc {
position: absolute; inset: 0; border-radius: 50%;
/* Conic starts from 0deg = 12 o'clock (top). Was previously
`from -90deg` which puts the gradient origin at 9 o'clock —
a 90° offset from where the JS marker code expects it
(positionMarker uses (H - 90)°, so H=0 lands at the top). */
background: conic-gradient(
from 0deg,
oklch(var(--theme-l) var(--theme-c) 0),
oklch(var(--theme-l) var(--theme-c) 60),
oklch(var(--theme-l) var(--theme-c) 120),
oklch(var(--theme-l) var(--theme-c) 180),
oklch(var(--theme-l) var(--theme-c) 240),
oklch(var(--theme-l) var(--theme-c) 300),
oklch(var(--theme-l) var(--theme-c) 360)
);
}
.wheel-rim {
position: absolute; inset: 25%; border-radius: 50%;
background: var(--bg-elev); border: 1px solid var(--line);
}
.wheel-markers { position: absolute; inset: 0; pointer-events: none; }
.wheel-marker {
position: absolute; width: 18px; height: 18px;
border-radius: 50%; border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
transform: translate(-50%, -50%); cursor: grab;
pointer-events: auto; touch-action: none;
}
.wheel-marker.primary {
width: 24px; height: 24px;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.6), 0 0 12px rgba(255, 255, 255, 0.3);
}
.wheel-marker:active, .wheel-marker.dragging { cursor: grabbing; }
.theme-sliders {
display: flex; flex-direction: column; gap: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; color: var(--fg-dim);
}
.theme-sliders label { display: flex; flex-direction: column; gap: 3px; }
.theme-sliders input[type="range"] { width: 100%; }
.theme-swatches {
flex: 1; display: flex; gap: 4px; height: 28px;
}
.theme-swatch {
flex: 1; border-radius: 3px; border: 1px solid var(--line);
}
.theme-meta-row {
display: flex; align-items: center; justify-content: space-between;
border-top: 1px solid var(--line); padding-top: 10px; margin-top: 2px;
}
.theme-meta-row code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; color: var(--accent);
background: var(--accent-soft); padding: 2px 6px; border-radius: 3px;
}
.theme-meta-row button.ghost {
background: transparent; color: var(--fg-dim); border: 1px solid var(--line);
font: inherit; font-size: 11px; padding: 3px 8px; border-radius: 3px; cursor: pointer;
}
.theme-meta-row button.ghost:hover { color: var(--fg); border-color: var(--fg-mute); }
/* ─── Topbar ───────────────────────────────────────────────────────── */
.topbar {
position: fixed; top: 0; left: 0; right: 0; height: var(--topbar-h); z-index: 50;
display: flex; align-items: center; gap: 10px; padding: 0 16px;
background: rgba(7, 9, 13, 0.85); backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line); font-size: 12px;
}
.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
.topbar .spacer { flex: 1; }
.topbar .status { color: var(--fg-dim); }
.topbar .status.ok { color: var(--ok); }
.topbar .status.bad { color: var(--warn); }
.topbar .counter {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg-dim); font-variant-numeric: tabular-nums; padding: 0 6px;
}
.topbar button.ghost {
background: transparent; color: var(--fg-dim); border: 1px solid var(--line);
font: inherit; padding: 4px 10px; border-radius: 4px; cursor: pointer;
transition: color 120ms, border-color 120ms;
}
.topbar button.ghost:hover { color: var(--fg); border-color: var(--fg-mute); }
.topbar button.ghost:disabled { opacity: 0.35; cursor: not-allowed; }
.topbar button.ghost.icon { padding: 4px 8px; min-width: 28px; }
.topbar button.ghost.active { color: var(--accent); border-color: var(--accent); }
/* ─── Layout ───────────────────────────────────────────────────────── */
.layout { position: relative; }
.canvas-wrapper {
position: fixed;
top: var(--topbar-h); left: 0; right: 0;
height: calc(100vh - var(--topbar-h));
z-index: 1; overflow: hidden;
/* Layer-isolation properties (transform: translateZ, will-change,
contain: paint) were tried here during the scroll-flicker
investigation but caused glitchy cutout-mask artifacts on the
foreground stage views — paint containment plus a
GPU-promoted layer breaks compositor ordering of children's
opacity transitions. Layer promotion stays only on .bg-canvas,
which is enough to isolate bg from scroll. */
}
html, body { overflow-anchor: none; }
.article { overflow-anchor: none; }
.article {
position: relative; z-index: 2;
padding-top: var(--topbar-h); pointer-events: none;
}
.article .prose { pointer-events: auto; }
/* ─── Stage views ──────────────────────────────────────────────────── */
.stage { position: absolute; inset: 0; cursor: pointer; }
.stage-view {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: flex-start;
/* Reserve full prose-w of right-side space so the metric stack
ends exactly where the prose column starts (was prose-w - 1.5em,
which let prose's feathered left edge sit over interactive
widgets and block clicks). */
padding-right: clamp(0px, var(--prose-w), 42em);
/* No opacity transition: snapping scenes in/out instantly. The
transition was the source of the grid-shape artifact that
appeared over metric content during scene changes. While
stage-view's opacity was animating between 0 and 1, the
compositor sampled the perspective floor (bg-canvas's animated
grid) into the stage-view's intermediate bitmap, and the
grid pattern leaked into the metric content area for the
duration of the transition. Removing the transition removes
that compositor work entirely. */
opacity: 0;
pointer-events: none;
}
.stage-view[data-active] { opacity: 1; pointer-events: auto; }
/* Intro stage */
.bg-grid {
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(88,166,255,0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(88,166,255,0.07) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse at center, #000 0%, transparent 75%);
animation: drift 18s linear infinite;
}
/* DIAGNOSTIC: hide .bg-grid unconditionally (was previously hidden
only on non-black themes). The grid-shape artifact reported as
"appearing over metric content during scene transitions" matches
the intro scene's .bg-grid being visible-through-transparency
when both intro and the next scene's stage-view are
simultaneously partially-opaque during the IntersectionObserver-
driven cross-fade. .bg-grid is in the intro stage-view; the next
scene's metric-stack has a transparent background, so the grid
shows through during the transition window. Hiding it
unconditionally for now to verify, then we'll reintroduce it on
the black theme if it's actually wanted there. */
.bg-grid { display: none; }
@keyframes drift {
from { background-position: 0 0, 0 0; }
to { background-position: 48px 48px, 48px 48px; }
}
.intro-block {
position: relative; z-index: 1; text-align: left;
padding: 0 clamp(32px, 5vw, 80px); width: 100%;
}
.intro-eyebrow {
font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase;
color: var(--fg-dim); margin-bottom: 18px;
}
.intro-title {
font-size: clamp(56px, 9vw, 168px); line-height: 0.95; font-weight: 700;
letter-spacing: -0.04em;
background: linear-gradient(180deg, var(--fg) 0%, var(--fg-dim) 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
/* ─── Metric stack — calculated, viewport-relative sizing ──────────── */
/* Wide-by-default. Inside a stage-view the right padding already
reserves room for prose, so width:100% means "use everything left
of the prose column." Backdrop is a horizontal gradient that
fades at both edges so the card dissolves into the bg instead
of meeting it with a hard rectangle. The right fade is wider
(78%→100%) than the left (0%→8%) because the right edge meets
the prose column's feathered left edge — letting the two
feathers overlap produces a continuous transition from
metric-card → bg → prose-card. */
.metric-stack {
text-align: left;
/* Asymmetric horizontal padding: less on the left (so the
interactive widgets sit further left, closer to the viewport
edge) and the existing larger value on the right (which holds
the gradient fade and the prose column behind it).
Vertical padding unchanged. */
padding:
clamp(20px, 2.5vh, 36px)
clamp(40px, 5vw, 88px)
clamp(20px, 2.5vh, 36px)
clamp(20px, 2.5vw, 48px);
width: 100%; max-width: none;
display: flex; flex-direction: column;
gap: clamp(10px, 1.4vh, 22px);
background: linear-gradient(
to right,
rgba(var(--bg-rgb), 0) 0%,
rgba(var(--bg-rgb), var(--content-backdrop, 0.30)) 8%,
rgba(var(--bg-rgb), var(--content-backdrop, 0.30)) 78%,
rgba(var(--bg-rgb), 0) 100%
);
}
.metric-stack-wide {
/* Slightly less right padding than other stages so the table can
stretch into the gradient zone of the prose column. */
padding-right: clamp(24px, 3vw, 56px);
}
.metric-eyebrow {
font-size: clamp(11px, 1vw, 14px);
letter-spacing: 0.18em; text-transform: uppercase; color: var(--fg-dim);
}
.metric-big {
font-size: clamp(72px, 13vw, 240px);
line-height: 0.95; font-weight: 700;
letter-spacing: -0.04em; font-variant-numeric: tabular-nums;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.metric-sub { color: var(--fg-dim); font-size: clamp(13px, 1vw, 16px);
line-height: 1.55; font-variant-numeric: tabular-nums; }
.metric-sub code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em; color: var(--accent);
background: var(--accent-soft); padding: 1px 5px; border-radius: 3px;
}
.awaiting {
color: var(--fg-mute); font-size: clamp(12px, 0.95vw, 14px);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 16px 0; font-style: italic;
}
/* Sparkline */
.sparkline {
width: 100%; height: clamp(140px, 28vh, 360px); margin-top: 6px;
}
.sparkline path { fill: none; stroke: var(--accent); stroke-width: 1.5;
vector-effect: non-scaling-stroke; }
.sparkline #ingest-spark-fill { fill: var(--accent-soft); stroke: none; }
/* Per-host bars */
.bars { display: flex; flex-direction: column; gap: clamp(8px, 1.1vh, 14px); }
.bar-row {
display: grid;
grid-template-columns: minmax(140px, 18ch) 1fr minmax(72px, 10ch);
gap: clamp(10px, 1vw, 18px); align-items: center;
font-variant-numeric: tabular-nums;
}
.bar-host { color: var(--fg); font-size: clamp(13px, 1vw, 15px);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.bar-track { height: clamp(24px, 3vh, 40px); background: var(--bg-elev);
border: 1px solid var(--line); border-radius: 3px; overflow: hidden; }
.bar-fill { height: 100%; background: var(--accent);
transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.bar-count { color: var(--fg-dim); font-size: clamp(13px, 1vw, 15px); text-align: right;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* Phase mix */
.phase-stack {
display: flex; height: clamp(48px, 7vh, 96px);
border-radius: 4px; overflow: hidden;
background: var(--bg-elev); border: 1px solid var(--line);
}
.phase-seg { transition: flex-grow 600ms ease; flex-grow: 0; min-width: 0; }
.phase-seg.clean { background: var(--phase-clean); }
.phase-seg.armed { background: var(--phase-armed); }
.phase-seg.infecting { background: var(--phase-infecting); }
.phase-seg.infected_running { background: var(--phase-running); }
.phase-seg.dormant { background: var(--phase-dormant); }
.phase-legend { display: flex; flex-wrap: wrap; gap: 14px;
font-size: 12px; color: var(--fg-dim);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.phase-legend > span { display: inline-flex; align-items: center; }
.phase-legend .swatch { display: inline-block; width: 10px; height: 10px;
border-radius: 2px; margin-right: 6px; }
/* ─── Code cards (stack scene) ─────────────────────────────────────── */
.code-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: clamp(12px, 1.4vw, 22px);
align-items: start;
}
.code-card {
background: var(--bg-elev2);
border: 1px solid var(--line);
border-radius: 4px;
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: clamp(11px, 0.92vw, 14px);
line-height: 1.6;
}
.code-card-header {
padding: 7px 14px;
background: var(--bg-elev);
border-bottom: 1px solid var(--line);
color: var(--fg-dim);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: lowercase;
}
.code-card pre.code {
margin: 0;
padding: 14px 18px;
white-space: pre;
overflow-x: auto;
color: var(--fg);
max-height: clamp(260px, 50vh, 560px);
}
/* Syntax-highlighting colors derive from the theme. Each token
type sits at a fixed hue offset from --theme-h so the relative
distinguishability between kw / str / fn / ty / num is preserved
regardless of where the user moves the H slider — they all
rotate together. Lightness fixed at 75% (readable on dark bg
without straining), chroma fixed at 0.18 (saturated enough that
the offsets read as distinct colors). Comments go through
--fg-mute so they pick up the L-step grey transitions. */
.code-card .kw { color: oklch(75% 0.18 calc(var(--theme-h, 250) + 0)); }
.code-card .str { color: oklch(75% 0.18 calc(var(--theme-h, 250) + 220)); }
.code-card .com { color: var(--fg-mute); font-style: italic; }
.code-card .fn { color: oklch(75% 0.18 calc(var(--theme-h, 250) + 280)); }
.code-card .ty { color: oklch(75% 0.18 calc(var(--theme-h, 250) + 40)); }
.code-card .num { color: oklch(75% 0.18 calc(var(--theme-h, 250) + 200)); }
/* ─── Database explorer ────────────────────────────────────────────── */
.db-header {
display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap;
}
.db-count { color: var(--fg-mute); font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-variant-numeric: tabular-nums; }
.db-controls {
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
}
.db-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
.db-tab {
background: transparent; color: var(--fg-dim);
border: 1px solid var(--line); border-radius: 16px;
padding: 4px 12px; font: inherit; font-size: 12px; cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
transition: color 120ms, border-color 120ms, background 120ms;
}
.db-tab:hover { color: var(--fg); border-color: var(--fg-mute); }
.db-tab.active { color: var(--accent); border-color: var(--accent);
background: var(--accent-soft); }
.db-search {
flex: 1; min-width: 200px;
background: var(--bg-elev); color: var(--fg);
border: 1px solid var(--line); border-radius: 4px;
padding: 6px 10px; font: inherit; font-size: 13px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.db-search:focus { outline: none; border-color: var(--accent); }
.db-table-wrap {
flex: 1 1 auto; min-height: 0;
max-height: clamp(280px, 56vh, 720px);
overflow: auto;
border: 1px solid var(--line); border-radius: 4px;
background: var(--bg-elev);
}
.db-table {
width: 100%; border-collapse: collapse;
font-size: clamp(12px, 0.92vw, 14px);
font-variant-numeric: tabular-nums;
}
.db-table thead th {
position: sticky; top: 0; z-index: 1;
background: var(--bg-elev2); color: var(--fg-dim);
text-align: left; padding: 8px 12px;
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
font-weight: 500; border-bottom: 1px solid var(--line);
}
.db-table tbody tr {
border-bottom: 1px solid var(--line-soft); cursor: pointer;
transition: background 80ms;
}
.db-table tbody tr:hover { background: rgba(88, 166, 255, 0.06); }
.db-table tbody tr.selected { background: var(--accent-soft); }
.db-table td {
padding: 7px 12px; color: var(--fg);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.db-table td.db-size { color: var(--fg-dim); text-align: right; }
.db-host { color: var(--fg); }
.db-id { color: var(--fg-dim); }
.db-detail {
border: 1px solid var(--line); border-radius: 4px;
background: var(--bg-elev);
overflow: hidden;
display: flex; flex-direction: column;
}
.db-detail[hidden] { display: none; }
.db-detail-meta {
padding: 8px 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; color: var(--fg-dim);
border-bottom: 1px solid var(--line-soft);
display: flex; gap: 14px; flex-wrap: wrap;
}
.db-detail-meta .db-id { color: var(--fg); }
.db-detail-chart-wrap {
background: var(--bg-elev2);
width: 100%;
position: relative;
}
.db-detail-chart {
display: block;
width: 100%;
height: clamp(220px, 32vh, 420px);
}
.db-detail-chart .axis { stroke: var(--line); stroke-width: 1; }
.db-detail-chart .tick {
fill: var(--fg-mute); font-size: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.db-detail-chart .metric-line {
fill: none; stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.db-detail-chart .phase-band { opacity: 0.18; }
.db-detail-chart .phase-band.clean { fill: var(--phase-clean); }
.db-detail-chart .phase-band.armed { fill: var(--phase-armed); }
.db-detail-chart .phase-band.infecting { fill: var(--phase-infecting); }
.db-detail-chart .phase-band.infected_running { fill: var(--phase-running); }
.db-detail-chart .phase-band.dormant { fill: var(--phase-dormant); }
.db-detail-chart .placeholder {
fill: var(--fg-mute); font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.db-detail-legend {
display: flex; flex-wrap: wrap; gap: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; color: var(--fg-dim);
padding: 8px 14px;
border-top: 1px solid var(--line-soft);
}
.db-detail-legend > span { display: inline-flex; align-items: center; }
.db-detail-legend .swatch {
display: inline-block; width: 10px; height: 10px;
border-radius: 2px; margin-right: 6px; vertical-align: middle;
}
/* ─── Attack envelope thumbnails ───────────────────────────────────── */
.profile-grid {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(12px, 1.4vw, 22px);
}
.profile-card {
border: 1px solid var(--line); border-radius: 4px;
padding: clamp(12px, 1.2vw, 18px);
background: rgba(13, 17, 23, 0.6);
}
.profile-name {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: clamp(13px, 1vw, 15px); color: var(--fg); margin-bottom: 4px;
}
.profile-shape {
font-size: clamp(11px, 0.85vw, 13px); color: var(--fg-dim);
margin-bottom: 8px; line-height: 1.4;
}
.profile-card svg {
display: block; width: 100%;
height: clamp(56px, 9vh, 120px);
}
.profile-card svg path { fill: none; stroke: var(--accent); stroke-width: 1.4;
vector-effect: non-scaling-stroke; }
/* ─── Chunking timeline ────────────────────────────────────────────── */
.chunk-rule, .chunk-row, .chunk-axis { display: flex; gap: 4px; }
.chunk-row { height: clamp(56px, 9vh, 120px); }
.chunk-cell {
flex: 1; border-radius: 3px;
display: flex; align-items: center; justify-content: center;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: clamp(11px, 0.95vw, 14px); color: var(--fg);
}
.chunk-cell.clean { background: var(--phase-clean); }
.chunk-cell.armed { background: var(--phase-armed); }
.chunk-cell.infecting { background: var(--phase-infecting); }
.chunk-cell.infected_running { background: var(--phase-running); }
.chunk-cell.dormant { background: var(--phase-dormant); }
.chunk-rule { height: 8px; background: var(--bg-elev);
border: 1px solid var(--line); border-radius: 2px; padding: 1px; }
.chunk-rule .tick { flex: 1; border-right: 1px solid var(--line); }
.chunk-rule .tick:last-child { border-right: none; }
.chunk-axis { height: 16px; font-size: 10px; color: var(--fg-mute);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.chunk-axis span { flex: 1; text-align: center; }
/* ─── Model bars ───────────────────────────────────────────────────── */
.model-bars { display: flex; flex-direction: column; gap: clamp(10px, 1.5vh, 18px); }
.model-row {
display: grid; grid-template-columns: minmax(80px, 12ch) 1fr minmax(64px, 9ch);
gap: clamp(10px, 1vw, 18px); align-items: center; font-variant-numeric: tabular-nums;
}
.model-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: clamp(13px, 1vw, 15px); color: var(--fg); }
.model-track { height: clamp(28px, 4vh, 48px); background: var(--bg-elev);
border: 1px solid var(--line); border-radius: 3px; overflow: hidden; }
.model-fill { height: 100%; transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.model-fill.lstm { background: linear-gradient(90deg, #58a6ff, #1f6feb); }
.model-fill.gru { background: linear-gradient(90deg, #db61a2, #a8327f); }
.model-fill.rnn { background: linear-gradient(90deg, #d29922, #8a6a17); }
.model-fill.bert { background: linear-gradient(90deg, #f85149, #b22e2a); }
.model-fill.knn { background: linear-gradient(90deg, #3fb950, #1a7f37); }
.model-acc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: clamp(13px, 1vw, 15px); color: var(--fg-dim); text-align: right; }
/* ─── KNN 3-D scatter (canvas) ─────────────────────────────────────── */
.scatter3d-controls {
display: flex; flex-wrap: wrap; gap: 12px;
align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.scatter3d-modes { display: flex; flex-wrap: wrap; gap: 6px; }
.scatter3d-mode, .scatter3d-reset {
background: transparent; color: var(--fg-dim);
border: 1px solid var(--line); border-radius: 16px;
padding: 4px 12px; font-size: 12px; cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
transition: color 120ms, border-color 120ms, background 120ms;
}
.scatter3d-mode:hover, .scatter3d-reset:hover {
color: var(--fg); border-color: var(--fg-mute);
}
.scatter3d-mode.active {
color: var(--accent); border-color: var(--accent);
background: var(--accent-soft, rgba(80, 140, 220, 0.12));
}
.scatter3d-wrap {
position: relative;
background: var(--bg-elev, rgba(255, 255, 255, 0.03));
border: 1px solid var(--line); border-radius: 4px;
width: 100%;
height: clamp(320px, 56vh, 640px);
overflow: hidden;
cursor: grab;
touch-action: none;
}
.scatter3d-wrap:active { cursor: grabbing; }
.scatter3d {
display: block;
width: 100%; height: 100%;
}
/* ─── Live detections (scene: live) ────────────────────────────────── */
.live-stack { gap: clamp(10px, 1.6vh, 20px); }
.live-stats {
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
padding: 8px 14px;
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg-dim);
background: var(--bg-elev, rgba(255, 255, 255, 0.03));
border: 1px solid var(--line);
border-radius: 4px;
}
.live-stats-eye { color: var(--accent); font-weight: 600; }
.live-stats-dot::before { content: '·'; margin-right: 12px; opacity: 0.5; }
.live-lanes {
display: flex; flex-direction: column;
gap: clamp(4px, 0.8vh, 8px);
padding: 12px;
background: var(--bg-elev, rgba(255, 255, 255, 0.03));
border: 1px solid var(--line);
border-radius: 4px;
min-height: 220px;
}
.live-lanes:empty::before {
content: 'no hosts reporting yet';
align-self: center;
margin: auto;
color: var(--fg-mute);
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
}
.live-lane {
display: grid;
grid-template-columns: minmax(120px, 16ch) 1fr;
gap: 12px; align-items: center;
}
.live-lane-host {
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.live-lane-cells {
display: flex; gap: 1px;
height: clamp(24px, 4vh, 36px);
overflow: hidden;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 2px;
}
.live-cell {
flex: 1 1 0;
position: relative;
min-width: 4px;
}
.live-cell.clean { background: var(--phase-clean); }
.live-cell.armed { background: var(--phase-armed); }
.live-cell.infecting { background: var(--phase-infecting); }
.live-cell.infected_running { background: var(--phase-running); }
.live-cell.dormant { background: var(--phase-dormant); }
.live-cell.miss::after {
content: ''; position: absolute; inset: 0;
background: repeating-linear-gradient(
-45deg, transparent 0 3px, rgba(0, 0, 0, 0.55) 3px 5px
);
}
.live-latest {
padding: 14px 16px;
background: var(--bg-elev, rgba(255, 255, 255, 0.03));
border: 1px solid var(--line);
border-radius: 4px;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 16px; align-items: center;
}
.live-latest-empty {
grid-column: 1 / -1;
text-align: center;
color: var(--fg-mute);
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 12px 0;
}
.live-phase-block {
width: clamp(80px, 9vw, 110px);
aspect-ratio: 1;
border-radius: 4px;
display: grid; place-items: center;
font: clamp(12px, 1vw, 14px) ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
text-align: center; padding: 8px;
line-height: 1.15;
}
.live-phase-block.clean { background: var(--phase-clean); }
.live-phase-block.armed { background: var(--phase-armed); }
.live-phase-block.infecting { background: var(--phase-infecting); }
.live-phase-block.infected_running { background: var(--phase-running); }
.live-phase-block.dormant { background: var(--phase-dormant); }
.live-meta {
display: flex; flex-direction: column; gap: 4px;
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg-dim);
min-width: 0;
}
.live-meta-host { color: var(--fg); font-weight: 600; }
.live-meta-line code { color: var(--fg); }
.live-conf {
text-align: right;
font: clamp(20px, 2vw, 28px) ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg);
white-space: nowrap;
}
.live-conf-label {
display: block;
font-size: 11px; color: var(--fg-mute);
font-weight: normal;
margin-bottom: 2px;
}
/* ─── Scatter plots (KNN, perf) ────────────────────────────────────── */
.scatter {
width: 100%;
height: clamp(320px, 60vh, 640px);
}
.scatter .axis { stroke: var(--line); stroke-width: 1; }
.scatter .axis-label { fill: var(--fg-mute); font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.scatter .point { transition: r 200ms; }
.scatter .point.clean { fill: var(--phase-clean); }
.scatter .point.armed { fill: var(--phase-armed); }
.scatter .point.infecting { fill: var(--phase-infecting); }
.scatter .point.infected_running { fill: var(--phase-running); }
.scatter .point.dormant { fill: var(--phase-dormant); }
.scatter .perf-point { fill: var(--accent); stroke: #1f6feb; stroke-width: 1.5; }
.scatter .perf-label { fill: var(--fg); font-size: 13px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* ─── Floating advance button ──────────────────────────────────────── */
.fab {
position: absolute; right: 24px; bottom: 24px; z-index: 5;
width: 44px; height: 44px; border-radius: 50%;
background: rgba(13, 17, 23, 0.85); color: var(--fg-dim);
border: 1px solid var(--line); font-size: 16px; cursor: pointer;
backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
transition: color 150ms, border-color 150ms, transform 150ms;
}
.fab:hover { color: var(--accent); border-color: var(--accent); transform: translateY(-1px); }
.fab:disabled { opacity: 0.25; cursor: not-allowed; transform: none; }
/* ─── Article (prose, overlaid right) ──────────────────────────────── */
.scene {
display: flex; align-items: center; justify-content: flex-end;
min-height: 100vh;
/* 0.5rem right padding instead of 2rem — shifts the prose card
~1.5rem further right so it sits cleanly past the interactive
widgets in the metric stack. Bottom/top still 4rem; left 2rem
stays as scroll-margin for the prose column. */
padding: 4rem 0.5rem 4rem 2rem;
}
.scene .prose {
width: var(--prose-w); max-width: calc(100vw - 4rem);
padding: 2.25rem 2.5rem 2.25rem 5rem;
font-size: 17px; line-height: 1.65; color: var(--fg);
/* Two background layers: the existing left-feathering gradient on
top, plus a uniform backdrop underneath driven by
--content-backdrop. When the backdrop is 0, the gradient's
transparent left edge reveals bg behind (matrix-explorable
look). When the backdrop is non-zero, the transparent edge
reveals a partly-opaque dark layer instead, so the prose stays
legible over busy themes. */
background:
linear-gradient(
to left,
rgba(var(--bg-rgb), 0.96) 0%,
rgba(var(--bg-rgb), 0.95) 70%,
rgba(var(--bg-rgb), 0.0) 100%
),
rgba(var(--bg-rgb), var(--content-backdrop, 0));
opacity: 0; transform: translateY(20px);
transition: opacity var(--scene-fade-ms) ease,
transform var(--scene-fade-ms) ease;
}
.scene[data-active] .prose { opacity: 1; transform: translateY(0); }
.scene .prose h2 { margin: 0 0 14px; font-size: 24px; font-weight: 600;
letter-spacing: -0.01em; }
.scene .prose .lede { font-size: 24px; line-height: 1.4; font-weight: 500;
margin: 0 0 20px; }
.scene .prose p { margin: 0 0 14px; }
.scene .prose strong { color: var(--fg); font-weight: 600; }
.scene .prose code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em; color: var(--accent);
background: var(--accent-soft); padding: 1px 5px; border-radius: 3px;
}
.scene .prose .hint {
color: var(--fg-mute); font-size: 12px;
letter-spacing: 0.16em; text-transform: uppercase; margin-top: 28px;
}
.scene-end-spacer { height: 30vh; }
@media (max-width: 880px) {
:root { --prose-w: 92vw; }
.stage-view { padding-right: 0; padding-bottom: 50vh; }
.intro-block, .metric-stack { padding: 0 24px; }
.bar-row { grid-template-columns: 110px 1fr 60px; }
.model-row { grid-template-columns: 80px 1fr 56px; }
.profile-grid { grid-template-columns: 1fr; }
.scene { padding: 2rem 1rem; min-height: 80vh; justify-content: center; }
.scene .prose { padding: 1.5rem; }
.topbar .counter { display: none; }
.db-table-wrap { max-height: 40vh; }
}