The CSS-rule-per-canonical-name approach was wrong: any name the
producer publishes that wasn't in the hardcoded list (mlp_realistic,
cnn_oracle, knn_semi, anything new tomorrow) rendered grey because
no .model-fill.<name> rule matched.
Replace with a deterministic FNV-1a hash of the model string → hue,
applied inline as an OKLCH gradient when the row is created. Every
model string gets a stable, distinct color regardless of suffix or
case. Inline style beats any CSS rule, so this works whatever's in
dashboard.css.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- multi_model_metrics: publish gbt / mlp / cnn / knn_semi /
gru / lstm / bert (knn handled by knn streamer); read both
*_train.json and *_eval.json with macro_f1.point fallback
- dashboard.css: add palette gradients for the four
non-canonical names so the bars render with a fill colour
- dashboard.js: open the bar's visible scale to the full 0–1
range so honest-low cross-host F1s show as a bar instead of
clamping to 0%
- ship lambda-live-detection-loop.py + dashboard request docs
(scenes 7/8/12, sticky cache, lambda-inference-demo)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Saves a click during live demos. Topbar tooltip updated to mention
the binding. Hotkey is gated by the same input-focus check as 'c' /
arrow keys, so typing 'd' in a search box won't fire it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
It contributed no training data, so the A100 wouldn't be running
inference on its windows. Only hosts that actually produced data
(elliott-thinkpad, k-gamingcom) should appear as the source of
synthetic predictions in the live scene.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2500ms read too slow. 1000ms is the sweet spot — under the real
ceiling of ~1.5/sec but still lively enough to feel like a working
inference loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was 280ms (~3.5 events/sec) — way too fast for real fleet
inference. The bottleneck is window arrival (one 10-second window
per host per 10 s), not A100 forward-pass speed. With ~3 hosts × 5
models that's ~1.5 events/sec real ceiling, so demo at 2500ms
(~0.4/sec) reads honest without claiming impossible throughput.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The scene's framing was wrong. It's about the A100 doing live
model predictions, not about per-host telemetry collection. Lanes
now key on `model` instead of `host_id`; the callout leads with
model name + A100 latency, demoting host/profile to secondary
metadata. Stats line reads "N models · X infer/sec · last window
from <host>" instead of "N hosts · model: X".
Demo synthesis updated to match: 5 trained models cycle through
predictions on rotating fleet windows, each model with its own
accuracy + latency profile (KNN fast/loose, BERT slow/precise) so
the lanes visually differ. Article prose reframes the scene as
side-by-side model agreement, the natural read of per-model lanes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The KNN producer works; KNN does not need a demo-mode fallback.
Remove demo_start / demo_stop / cachedReal / demoActive scaffolding
that I'd added speculatively. Embedding events render directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same pattern as models / perf / live: cachedReal accumulates real
embedding events at all times; demoActive flag gates which source
renders.
- demo on → only synthetic clusters
- demo off → only real embeddings (replayed from cachedReal)
Cache cap 5000 points to bound memory across long sessions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was: demo seeded on demo_start, then real producer events rendered
on top of the synthetic bars/points/cells. Both sources visible
simultaneously — visually confusing.
Now: each widget tracks demoActive + a cachedReal store.
- demo_start: set demoActive=true, clear, repaint from synthetic
- demo_stop: set demoActive=false, clear, repaint from cachedReal
- on real event: always cache; only render when demo is off
Toggling demo flips between two clean pictures with no overlap.
cachedReal grows as real producer events arrive even while demo is
on, so demo_stop restores immediately without waiting for the
producer to re-publish.
Applied to: models bars, perf scatter, live detections.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same hasReal* gating I already used for phase_mix, applied to:
- models bars (model_metric)
- perf scatter (model_perf)
- live detections (live_detection)
Each widget tracks whether a real producer event has arrived; demo
only seeds when nothing real has been seen yet, and demo_stop
preserves real state instead of wiping it.
demoTick is now a no-op — periodic model_metric jitter was
overwriting real values mid-stream. Per-widget one-shot seeding
on demo_start (gated by hasReal*) is enough.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues with the accuracy-vs-latency scatter:
1. Linear x crammed RNN/GRU/LSTM into ~25 px of axis (380/520/700 μs)
while BERT alone took the right 80 % (3200 μs).
2. Labels placed at fixed +12 right of each point overlapped both
neighbouring points and other labels in the recurrent cluster.
Fixes:
- X-axis switched to log10 with bounds 10μs–10ms; tick labels and
marks added at 10μs / 100μs / 1ms / 10ms so the audience can
read the scale.
- Y-axis bounds tightened to [0.5, 1.0] (was [0.7, 1.0]) so KNN's
~0.43 cross-host F1 falls within the visible plot area instead
of off-bottom; ticks added at 0.6 / 0.8 / 1.0.
- Anti-overlap label placement: sort points by x, alternate
above (-12) / below (+18) the circle. Adjacent labels can no
longer share both x and y bands. repaintLabels() re-runs on
each model_perf event so late arrivals slot into the staircase.
Y-axis title also updated: "held-out accuracy" → "held-out macro-F1"
to match the actual metric the producer reports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same scope-narrowing as collect / hosts / db / knn — attack profiles
are real data from the orchestrator's catalog, so the deck should
display whatever the producer publishes via attack_profile events
and not overwrite that with synthetic curves on demo_start.
Removed both demo_start (synthesize) and demo_stop (clearAll)
handlers; the syntheticProfiles helper is left in place for
reference but is no longer wired to anything.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the data-ownership scope: collect (episodes-ingested counter),
hosts (per-host bars), and db (database explorer) all work fine in
or out of demo mode — they read real values from the server's
snapshot. Demo mode shouldn't be injecting fake `episode` records
into them.
Removed both dispatches from demoTick:
- `episode` (was 70% per tick) — no longer clobbers collect/hosts/db
- `phase` (was 50% per tick) — dead code anyway; baseline now
consumes the dataset-derived `phase_mix` event, not raw `phase`
demoTick is now just the model_metric jitter (5% per tick) so the
sequence-model bars don't sit frozen during a long demo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two targeted fixes for the demo-toggle path; intentionally narrow so
we don't override widgets that already work in both modes (KNN
scatter, DB explorer).
Phase-mix bar
- Tracks `hasRealMix` and only injects a synthetic fallback on
demo_start if no real snapshot/phase_mix event has been seen.
If real data later arrives, applyMix overwrites the synthetic
value automatically.
- Synthetic numbers mirror a real production run (500/78705
episodes, ~4.5 hours of weighted seconds) so the bar reads
correctly during a deck-only demo.
KNN model_metric
- Periodic demoTick tweaks now include `knn` alongside rnn/gru/lstm/
bert. Initial demo_start already populated all five bars; the
periodic tweak just keeps the knn bar from sitting frozen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously every scene rendered at all times — paint, layout, and
the per-scene widgets all ran in parallel. Now only the active
scene and its immediate neighbours carry [data-mounted]; far ones
get content-visibility: hidden on the prose side (paint skipped,
layout placeholder sized via contain-intrinsic-size so scroll
position stays accurate) and display: none on the absolutely-
positioned stage views.
The window is recomputed every time the active scene changes and
pre-computed before programmatic scrolls (Home/End/scrollToScene)
so the destination is rendered before it scrolls into view.
JS state in widgets is preserved — DOM nodes stick around, just
without paint cost — so the KNN scatter, live-detection lanes, and
sparkline state survive scrolling between scenes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four code snippets shown on stack and training-code scenes get
inline comments explaining the *why* of each line, not just *what*.
Aimed at the live audience: a presenter reads the comment as the
narration; a reader scans them top-to-bottom for the design story.
Covers: pyproject's three install profiles and what each library
contributes; receiver's bearer auth and why constant-time compare
matters; LSTM model's registry pattern, batch_first transpose,
last-step classification head; trainer loop's class weights vs the
imbalanced dataset, AMP scaler vs fp16 underflow, cosine + warmup
schedule, macro-F1 vs accuracy on imbalanced classes, best-state
restore vs last-epoch weights.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The stack scene's pyproject snippet was missing the `training`
group (torch, sklearn, xgboost, zstandard) — the libraries that
do the actual model work. Updated to match the real pyproject.toml.
The receiver snippet now ends at _bearer_check(...) instead of the
import block alone — gives the slide a non-trivial line of code to
read.
The training-code scene replaces the toy "PhaseLSTM" hand-rolled
loop with the real LSTM model class (registry-decorated _SeqBase
subclass + _LSTMClassifier wrapping nn.LSTM with last-step
classification head) and adds a second card showing the actual
train_nn loop: AMP autocast/scaler, cosine LR with linear warmup,
inverse-frequency class weights, gradient clipping, macro-F1
on val, early stop with best-state restore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Project around mean ± k·σ instead of the raw [0,1]³ producer-unit
cube. PCA-3 outputs are Gaussian-ish so even after the producer's
min/max rescale, the bulk of points clusters near the centroid;
without auto-fit the scatter looks dead-centre and tiny.
Implementation: incremental Welford-ish stats (running sum / sum²)
per axis, recomputed lazily on the first frame after new data
arrives. project() centers and σ-scales each point to ~[-0.5, 0.5];
outliers clamp to ±0.7 so they're visible just outside the cube.
The bounding cube now traces mean ± k·σ instead of [0,1]³, which is
also the natural visual unit for the "data spread" the user reads
off the screen.
resetStats() runs on demo toggle and is implicit when points are
cleared. SPREAD_K=2.5 puts ~99% of normally-distributed data inside
the cube; MIN_STD=0.02 keeps degenerate (all-equal) data from
exploding the divisor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The model-bar widget rendered .model-fill.knn with no gradient when
a model_metric{model:"knn"} arrived, leaving an empty track. Add a
green gradient and include knn in the demo-mode set so the row is
visible without waiting on the producer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The widget was waiting on live `phase` events that don't flow when no
orchestrator is running, so it sat empty. Replace the rolling
5-minute window with a periodic feeder that samples 500 random
episode tarballs from /var/lib/cis490/episodes, extracts each
labels.jsonl, and aggregates phase durations using consecutive
t_mono_ns deltas. Result lands in broadcaster.state["phase_mix"]
(survives snapshot cycles via dict.update) and re-broadcasts every
~10 min.
Frontend reads phase_mix from snapshot on connect and from live
phase_mix events on refresh; the bar uses time-weighted proportions
when available (falls back to label counts), and only sums canonical
phases for the denominator so non-displayed `failed` records don't
shrink the visible bars. Eyebrow and sub-line update with live
sample/population/label counts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the SVG 2-D scatter with a canvas-based 3-D one. Three color
modes (phase / predicted / cluster) with a toggle; drag the surface
to rotate; reset button. Bounding cube draws faintly so the rotation
reads as 3-D rather than re-shuffled 2-D.
Embedding event gains optional z / predicted / cluster fields. 2-D
producers still work (z defaults to 0.5, no other behavior changes).
CSS adds .scatter3d-* rules; --theme-h-num exposed for cluster-color
hue arithmetic. Synthetic demo data is now 3-D Gaussian clusters with
~7% mislabeled "predictions" so the predicted-mode view differs from
ground truth at a glance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes per the user's feedback that the slide had unused
horizontal space and needed per-PDF context.
Layout
- The reference scene is now a 2-column grid inside the
metric-stack: PDF iframe at ~1.7fr on the left, description
panel at ~0.55fr on the right (min 280px). On narrow viewports
(<1100px) it falls back to a vertical stack with the
description capped to 240px.
- Added #zoom=page-width to the iframe URL so the PDF's page
fits its column width instead of leaving margins beside an
8.5x11 page rendered in a wider iframe.
- Hide the prose card on the references scene — the description
panel inside the stack covers what the prose was saying, and
freeing the right edge gives the description proper room.
Description content
- Backend reads <stem>.md sidecar files alongside each PDF and
returns the contents in the /api/references payload.
- Frontend renders them with a tiny built-in markdown subset
(headings, bold/italic, lists, inline code, paragraphs) — no
third-party renderer dependency.
- Initial draft sidecar .md files committed for the four PDFs
currently in references/. Each describes how the paper informs
a specific scene of the deck (which model row, which eval
protocol, which channel selection). Edit them in place and the
panel updates on the next reload.
New scene 13 (after perf, the last in the deck) renders a tabbed
PDF viewer. Each tab is one .pdf in /opt/cis490/references/; the
active tab swaps the iframe's src to /refs/<encoded-filename>.
Backend
- /api/references — lists pdfs in REFS_DIR, returning
{"name": stem (newlines stripped), "path": "/refs/<urlencoded>"}.
- /refs static mount — serves the PDFs directly. check_dir=False
so the dashboard still boots if the directory is missing.
- REFS_DIR resolves relative to the install root so it works on
/opt/cis490 in production and any dev tree.
Frontend
- Stage view uses metric-stack-wide for the broader card; the
references scene also overrides .stage-view padding-right down
to a small gutter so the iframe takes most of the screen
horizontally — the prose card still sits on the right but the
PDF area is roughly 70% wide on standard viewports.
- Tabs are styled like .db-tab (palette-aware pills) and stop
propagation so they don't trigger the click-to-advance gesture.
- Iframe is lazy-loaded: src isn't set until the user actually
scrolls into the references scene OR clicks a tab, so the
browser doesn't fetch a big PDF the user may never view.
Two changes:
1. Replaced the linear L→grey-L mapping with a STEP function 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 in pure CSS. Both landing values are inside
each grey's safe-contrast band, so text stays readable at every
slider position, with a clear visible "click" as the slider
crosses 50%. The chroma tint stays linear (it doesn't threaten
contrast).
2. Fixed text that wasn't responding to the theme because it had
hardcoded color values:
- .intro-title gradient (#fff → #8b949e) → var(--fg) → var(--fg-dim)
- .chunk-cell text (rgba(255,255,255,0.85)) → var(--fg)
- .scene .prose strong (#fff) → var(--fg)
JS now publishes --theme-l-num (unitless) alongside --theme-l (with
%), since calc() can't multiply a percentage by a unitless number
to produce a unitless step value.
New endpoint GET /api/episode/<host_id>/<episode_id> in app.py.
Stream-decompresses the tarball (zstd -dc piped into tarfile),
extracts telemetry-proc.jsonl, labels.jsonl, and meta.json,
returns the parsed contents. Synchronous extract runs in
asyncio.to_thread so the event loop isn't blocked.
Frontend: clicking a row in the database explorer now fetches
the episode and draws an SVG chart matching the README's Real
Alpine VM envelope shape:
- per-interval CPU jiffies delta (user + sys)
- per-interval IO bytes delta (read + write)
- colored phase bands (clean/armed/infecting/infected_running/
dormant) overlaid by labels.jsonl
- axis ticks for 0-peak on Y, 0-totalDuration in seconds on X
- legend below the chart with palette-driven swatches
The detail panel that previously showed the row JSON now shows
metadata + the chart + the legend. Validated end-to-end against
a real episode (863 samples, 8 labels) extracted from
/var/lib/cis490/episodes/elliott-thinkpad/.
New 'content backdrop' slider (0..1, default 0.30) in the
animation section of the theme panel. Drives a single CSS
variable --content-backdrop that controls a uniform dark layer
behind both:
- .metric-stack — solid background with that opacity, plus a
rounded corner so the metric content reads as a card sitting
over the bg.
- .scene .prose — added as a SECOND background layer underneath
the existing left-feathering gradient. The gradient stays;
where it's transparent (left edge), the new uniform layer
shows through. At backdrop=0 the prose looks identical to
before; at backdrop>0 the feathered edge reveals a partly-
opaque dark instead of fully transparent bg.
So when the bg is busy (vaporwave, drift, lava) the user can
crank backdrop up for legibility; when it's the still black
theme they can drop it to 0 for the cleanest look.
The 'rendering over presentation elements' artifacts were from
piling stacking-context-creating properties on bg-canvas:
- filter: blur(0px) — even at 0px creates a stacking context
AND a 3D-flattening grouping property
- transform: translateZ(0) — stacking context + 3D context
- earlier: isolation, contain — stacking context + flattening
Each of these on its own can cause neighboring element artifacts
in Chromium (cutout-shaped opacity transition leaks, or in extreme
cases content rendering at the wrong z-order).
Strip everything. The bg-canvas is now just position:fixed with
overflow:hidden — that's enough for the bg layers it contains,
and the browser will GPU-promote it automatically when there's
real animation inside. The filter is now conditional: JS sets
--bg-filter to blur(Npx) only when the slider is non-zero, and
removes the custom property at zero so the rule falls through to
filter: none and no stacking context is created.
User prefers the CSS perspective look — restore the rotateX +
translate-only-animation grid. To finally kill the scroll-flicker,
push much harder on layer isolation:
- bg-canvas: isolation, contain: layout style paint, transform:
translateZ(0), will-change: transform, overflow-anchor: none
- canvas-wrapper: same compositor-layer treatment so its
data-active scene transitions can't dirty bg-canvas
- vw-floor: contain: strict + transform: translateZ(0)
- vw-floor-tilt: rotateX combined with translateZ(0) (hint
to compositor that this is a 3D layer)
- vw-floor-grid: backface-visibility: hidden, will-change
- html/body/article: overflow-anchor: none (disables Chrome's
scroll anchoring, which can nudge layout during the IO-driven
scene toggle and trigger re-paints)
- Lines bumped 2px → 3px (less subpixel sensitivity)
The canvas-based floor is removed; vw-floor-canvas + the rAF
drawing loop are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
<canvas> is a replaced element — top/bottom positioning alone
doesn't size it; it keeps its intrinsic 300x150 dimensions until
you give it explicit CSS width AND height. The previous CSS only
set width:100% so the canvas rendered at default 300x150, ignoring
bottom:0 and producing a tiny mis-positioned grid.
Set both explicitly: width: 100%, height: calc(100% - --vw-horizon).
Add a ResizeObserver so the drawing buffer follows the box when
the user moves the horizon slider or the window resizes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two prior CSS attempts both flickered when the user scrolled the
page. The first animated background-position on a perspective-rotated
element; the second split rotate from translate onto separate
elements with isolation: isolate, will-change, contain: paint, etc.
The browser kept recomputing the perspective math in lockstep with
document scroll regardless.
Replace the floor with a <canvas> drawn frame-by-frame via rAF:
- No CSS perspective transform; lines drawn at explicit pixel
positions by JS, so subpixel sampling can't shift them between
frames.
- Half-pixel y offsets keep 2px lines crisp.
- Perspective via 1/(1 + z*k) falloff formula; the slider
(perspective 30..80) drives k.
- Palette colors read from `var(--c1)` / `var(--c2)` via a hidden
probe div (lets the browser do the OKLCH→rgb conversion).
Cache refreshed every time the palette changes.
- Rendering loop early-returns when the active theme isn't
vaporwave, so it costs nothing on other themes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Theme panel
- Was a small floating tooltip-style card near the topbar.
- Now a full-height sidebar pinned to the right half of the screen
(50vw, 100vw on narrow viewports). Slides in/out from the right
via a transform transition. Toggle via the `is-open` class
instead of the hidden attribute so the animation works on close
too. Sticky header keeps the title and close button in view as
the panel scrolls.
No-despawn-while-on-screen
- Settings sliders that previously rebuilt the bg elements via
innerHTML='' caused all visible bubbles/blobs/beams to vanish
instantly. Now existing elements are retired gracefully:
- drift / lava: retireOnIteration — wait for the element's next
animationiteration event (which fires at the end of one rise
cycle, when the element is offscreen) and remove then. The
user sees old bubbles complete their journey while new ones
spawn alongside.
- laser: fadeOutAndRemove — opacity transitions to 0 over 800ms
then the element is removed. Lasers have no offscreen moment
in their continuous rotation so a fade is the only graceful
option.
- 60s safety timeout on retireOnIteration so spamming a slider
doesn't leak old nodes if animationiteration somehow doesn't
fire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-theme settings panel
- New section in the theme panel that auto-shows the active theme's
knobs (and only the active theme). Built dynamically from a
THEMES spec; new params drop in by adding one entry.
- drift: blob count (3-12), size, blur, opacity
- lava: bubble count (4-16), mean size, σ spread, goo merge
strength, goo blur σ. Bubbles are now JS-generated with sizes
drawn from N(mean, σ) so the spread slider produces real
variance, not just one-size-fits-all.
- vaporwave: grid cell, horizon angle, sun size, horizon position,
blind width.
- laser: beam count, thickness, blur, opacity. Beams JS-generated.
Vaporwave overhaul (was: a single perspective grid + radial sun)
- Layered scene: gradient sky → palette-blended sun with venetian-
blind stripes on the lower half → glowing horizon line → palette
perspective floor → CRT-style scanline overlay
- Sun box-shadow gives a halo; horizon has multi-stop glow
Live SVG goo filter
- The lava merge strength / blur sliders now mutate the inline
<feGaussianBlur stdDeviation> and <feColorMatrix values>
attributes via JS (CSS vars don't work inside SVG filter primitives).
Settings persist in localStorage; new params are merged in over
older snapshots so prior reloads don't break.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the discrete harmony dropdown with two continuous sliders
that together cover every named scheme:
- count (1..6) — number of palette colors
- spread (0..300°) — total angular range across the palette
Offsets fan symmetrically from the primary, so n=3 spread=60 →
[0, +30, -30] and n=4 spread=270 → [0, +90, -90, +180]. Common
named harmonies fall out as specific (count, spread) values:
mono count=1, any spread
complementary count=2, spread=180
analogous count=3-5, spread<=60
split-complementary count=3, spread~180-210
triadic count=3, spread=240
tetradic / square count=4, spread=270
A small hint line under the sliders shows the matched harmony name
when (count, spread) lands in a recognized neighborhood, or "custom"
otherwise. Per-marker drags continue to work on top of the
continuous setting; touching the count or spread slider regenerates
to the symmetric distribution (i.e. drags are reset on slider use,
which is the natural "clean me up" action).
Migration: any prior localStorage state with the old discrete
`harmony` field or multiplicative `spread` is silently dropped at
boot and replaced with the new defaults (count=3, spread=60).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes
- Wheel markers are now actually draggable. The previous version
rebuilt the marker DOM during drag, which destroyed the captured
element. apply() now only rebuilds when harmony changes; otherwise
it updates marker positions in place.
- Background swap is verified end-to-end. body[data-theme] is set
by JS and the CSS selectors gate per-theme bg layers correctly.
New theme controls (advanced details panes)
- spread (0.2-2x): scales harmony angular offsets uniformly
- L variance (0-40): per-color alternating lightness ladder
- C variance (0-0.15): per-color alternating chroma ladder
- animation speed (0.1-4x): drives every animation-duration via
calc(N / var(--anim-speed))
- background blur (0-40px): filter: blur on the entire bg-canvas
- tint strength (0-0.6): palette-tinted vignette opacity, applied
on EVERY theme so even "black" picks up palette character
Marker drag semantics
- Primary marker drag → rotates the entire palette (changes H)
- Non-primary marker drag → moves just that marker's offset
(allows individual proportion adjustment on top of harmony preset)
Backgrounds
- Old "lava" is renamed "drift" (soft blurred-blob version)
- New "lava" is a proper lava lamp via SVG goo filter
(Gaussian blur + alpha threshold) so bubbles merge as they
approach. Eight palette-colored bubbles rising at staggered
speeds.
State persists in localStorage; all sliders + offsets survive
reload. Panel resets all knobs back to defaults.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Press `t` (or click the topbar button) to open a floating theme
panel. Choices persist in localStorage.
Color machinery
- All palette colors derived from one base OKLCH (L, C, H) plus a
harmony rule that fixes angular offsets between siblings. Six
rules: mono, analogous, complementary, triadic, tetradic,
split-complement. Browsers parse CSS oklch() natively so no JS
conversion math is needed.
- Sliders for L (20-95%), C (0-0.4), H (0-360°), plus an interactive
color wheel where each marker is draggable. Dragging any marker
rotates the entire palette around the wheel — proportions are
locked by the harmony rule, so siblings move together.
Background themes (--c1..c5 from the palette drive every color)
- black: solid bg, no animation. Default.
- lava: 6 blurred radial blobs floating up the screen at different
speeds, screen-blended.
- vaporwave: perspective grid crawling toward the viewer + a soft
radial sun in palette colors.
- laser: 5 long beams sweeping from screen center, palette-colored.
Caveats
- oklch() needs Chrome 111+ / Firefox 113+ / Safari 15.4+. Older
browsers will fall back to CSS init values (which match the
previous palette), so the page still renders.
- Phase colors (clean/armed/etc) stay hardcoded — they're semantic.
- The keydown listener for `t` is registered alongside the other
hotkeys and skips form fields like the rest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New scene 10 between Sequence models and KNN: shows
training/models/lstm.py shape (PyTorch LSTM trainer, ~25 lines).
Placeholder content; the model session can swap in the real
trainer string in dashboard.js when it lands.
- Sharpens the prose gradient (95% solid until 70% across instead
of 92% solid until 55%) and bumps stage-view right-padding by
2.5em, so the prose column has a clearer edge against the canvas
content instead of fading smoothly into it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slots in between intro and collect: a side-by-side display of
pyproject.toml's annotated dependency list and the import header of
receiver/app.py. The point is to surface the project's
stdlib-first / annotated-deps stance early in the deck without
making the audience open a terminal.
Lightweight syntax highlighting via a small char-by-char Python
tokenizer (no third-party highlight.js). The TOML case uses regex.
GitHub-dark color palette.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Starlette + WebSocket dashboard run on the Pi as cis490-dashboard.service
(127.0.0.1:8447, Caddy-fronted at dashboard.wg). Tails
/var/lib/cis490/index.jsonl for episode events, snapshots host counts
every 30s, broadcasts to every connected browser. New connections get a
warm snapshot (recent_episodes, total_bytes, host_counts) so reloads
don't see a cold dashboard.
Frontend is a 10-scene scrollytelling deck following the project
outline: intro, collect, hosts, db explorer, baseline, attacks,
chunking, models, knn, perf. Sticky full-bleed canvas with a
right-aligned prose column (matrix-explorable layout). Hotkeys (arrows,
space, j/k, c, Home/End), prev/next chevrons, FAB, and an opt-in
click-to-advance toggle. Demo toggle drives synthetic data for the
five scenes that have no real producer yet (attack envelopes,
chunking, model bars, knn scatter, perf scatter); when off, those
scenes show "awaiting <event_type> events" rather than fake data.
Producers wire in by POSTing typed JSON to 127.0.0.1:8447/publish
(loopback only; Caddy 404s it externally). Event types the widgets
subscribe to: model_metric {model, accuracy}, embedding {x, y, phase},
model_perf {model, latency_us, accuracy}, prediction {episode_id,
window_idx, predicted, actual}, attack_profile {name, shape, curve},
phase {phase}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>