- 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>
Five required + four optional slides, slotted into the existing flow
without renumbering the visible deck UI:
REQUIRED
- problem-statement (after motivation): single-sentence problem,
three numeric stat cards, explicit task-type justification
(multi-class classification, why not regression/ranking)
- research-questions (after problem-statement): two-column literature
gap layout + RQ1/RQ2/RQ3
- solution-overview (after research-questions): inline-SVG block
diagram of the pipeline (fleet hosts → receiver → episodes →
windowing → model zoo → per-window phase → trust score →
containment + reset)
- evaluation-setup (between chunking and models): four blocks
covering split recipe, primary metric, baselines compared, and
what's reported alongside accuracy. Each block leads with the
*why*, matching the assignment's "explain not only what will be
measured but why" requirement.
- conclusion-future (before references): two-column "what we showed"
+ unsupervised next steps (clustering / anomaly / SSL pretrain /
embedding viz). Addresses Section 8 of the assignment guide.
OPTIONAL
- theoretical-contributions: window-centre labelling,
schema-hashed checkpoints, cross-host as eval axis
- practical-contributions: /proc-only deployment,
producer-agnostic dashboard, labelled dataset on disk
- design-principles: one-loop-many-models, typed events as
contract, two-agent path ownership
- limitations: two-host fleet, synthetic profiles, 10 Hz floor,
KNN cross-host gap
Plus references/links.md gains four real online references (PyTorch,
XGBoost, scikit-learn, proc(5)) bringing the citation count from 8
to 12 — over the assignment's 10-source minimum.
CSS additions cover the new layouts (.problem-claim, .problem-stats,
.research-grid, .pipeline-svg + .pipeline-stage / .pipeline-arrow,
.eval-blocks, .conclusion-grid). Limitations cards reuse the
motivation-card pattern with an armed-phase amber marker for the
"warning" feel.
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>
New scene 2 (between intro and stack) framing the operational case
for a per-host detector. Three consequence cards on the stage —
network-level trust scoring, containment before pivot, fast
post-attack reset — backed by a prose section that cites IEEE
document 9881803 for the trust-aggregation argument.
Sidecar md for the paper lands in references/ as a citation note;
when the PDF is dropped in with a matching stem it'll show up in
the references viewer automatically. Link added to links.md too.
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>
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>
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.
Syntax-highlighting tokens (kw/str/com/fn/ty/num) were hardcoded
GitHub-dark hex values that ignored the theme. Each token now uses
oklch(75% 0.18 H+offset) where the offset is fixed per token type
and H is var(--theme-h). Result: turning the H slider rotates all
six syntax colors together while preserving their relative angular
separation, so they stay distinguishable from each other regardless
of theme. Comment color goes through --fg-mute to pick up the
L-step grey transitions like other body text.
Conic gradient was 'from -90deg' which puts the gradient origin at
9 o'clock; the JS positionMarker uses (H - 90)° so H=0 lands at 12
o'clock. The two were offset by exactly 90°, which is why the color
under the marker on the wheel didn't match the marker's own swatch.
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.
The static fg/fg-dim/fg-mute/line/line-soft hex values are now
oklch() expressions that read the theme's L, C, and H. Each keeps
its prior base lightness (93/63/38/18/22%, matching the previous
greys at default settings) plus a small bias from how far the L
slider has moved from 70% — so the text greys track the theme's
brightness without ever leaving the readable contrast range — and
a fractional chroma so they pick up a hint of the theme hue when
the C slider is cranked up.
Falls back to the same numerics in static form when --theme-l
etc. aren't yet set (briefly during initial page load before JS
runs).
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/.
Prose's feathered left edge was overlapping with interactive
widgets in the metric stack and blocking clicks (the prose has
pointer-events: auto so its left half — even when visually
mostly transparent — still captured input).
Two changes:
- .scene right padding: 2rem → 0.5rem. Pushes the prose card
~1.5rem further right.
- .stage-view padding-right: was calc(prose-w - 1.5em), now just
prose-w. The metric-stack now ends exactly where the prose
column starts instead of being pushed 1.5em into prose
territory.
Result: roughly 3em of overlap reduction. The prose's left
feather and the metric-stack's right feather now meet over bg
rather than over each other's content.
Backdrop card was a sharp rectangle whose right edge butted hard
against the prose column's left feather, producing a visible
seam where the two layers met. Replaced the solid background
with a horizontal linear-gradient that fades at both edges:
0% → transparent (left edge dissolves into bg)
8% → full backdrop (card body begins)
78% → full backdrop (card body ends)
100% → transparent (right edge dissolves into bg)
The right fade is wider than the left because the right edge
overlaps the prose column's feathered start; double-feathering
that interface gives a continuous metric-card → bg → prose-card
transition with no rectangles meeting.
Border-radius removed — was hidden by the feather anyway.
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.
Confirmed by user that snapping scenes in/out instead of opacity-
transitioning fixed the grid-shape artifact that had been appearing
over metric content during scene changes.
Root cause: while stage-view's opacity animated between 0 and 1
(over 600ms), the compositor was rendering stage-view to its own
intermediate bitmap and sampling whatever was painted underneath
— including the bg-canvas's animated perspective grid. That
sampled grid leaked into the metric content area for the duration
of the transition. Removing the transition removes the compositor
work entirely; scenes change with a snap, no resampling.
Trade-off accepted: no fade between scenes. If a smoother
transition is wanted later, options that DON'T trigger the same
sampling are clip-path wipes, transform-based slides, or animating
opacity at <100ms (short enough that the sampled bitmap doesn't
have time to register visually).
The grid-shaped artifact that appeared over metric content
during scene transitions was Chromium promoting the stage-view
to its own compositor layer mid-transition (when opacity left
exactly 0). At that promotion moment the new layer samples
whatever's painted underneath as its initial bitmap — which is
the moving perspective grid in the bg — and that snapshot stays
visible for the duration of the 600ms opacity transition,
reading as a phantom grid pattern over the metric content.
will-change: opacity tells the browser to promote the layer
before the transition starts. The transition is then a pure
compositor opacity interpolation: no resampling of bg, no stale
snapshots. The hint is on the actual transitioning element
(stage-view), not on canvas-wrapper, which avoids the
cutout-mask issue from the previous over-aggressive layer
isolation attempts.
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.
The 'cutout mask' artifacts on foreground stage views came from
piling contain:paint + will-change + transform:translateZ all
together on .canvas-wrapper. Paint containment plus a
GPU-promoted layer on the foreground container breaks compositor
ordering when children's opacity transitions fire (the
IntersectionObserver fades stage-views in/out as scenes activate),
producing rectangular cutout-shaped artifacts where the
transitioning element's layer hadn't fully composited yet.
Strip canvas-wrapper back to the bare minimum (just position,
overflow, z-index). Also drop isolation/contain from bg-canvas,
keeping only transform: translateZ(0) for layer promotion — that
alone is enough to give bg-canvas its own compositor layer
without fighting the foreground's painting.
The scanlines were genuinely the source of the orthogonal-in-3D
artifact, in two compounding ways:
1. mix-blend-mode: multiply on a fullscreen overlay forces an
isolation group: the browser composites the 3D-rotated floor
into a flat 2D bitmap underneath the blend, and that
flattening interacts badly with how Chromium rasterizes
perspective transforms.
2. The 4px stripe period beats against the perspective floor's
per-row line spacing (which is dense near the horizon and
sparse near the viewer). At the screen y where the floor's
row-spacing crosses 4px, the patterns interfere — producing
moiré bands that look like a phantom grid orthogonal to the
floor plane.
Fix: confine scanlines to the sky region above the horizon
(they never touch the perspective grid), drop the multiply
blend (regular alpha compositing), and use a 5px period that
avoids resonance with anything else in the scene.
The 'lines orthogonal in 3D space' artifact was the intro scene's
.bg-grid — a flat 2D grid pattern in canvas-wrapper (z=1), drawn
on screen with horizontal + vertical 1px lines. While the user
was on the intro scene with vaporwave active, this 2D grid
overlaid the rotated perspective floor, looking exactly like a
phantom wall of grid lines orthogonal to the floor — same kind of
pattern, similar palette, but on a plane perpendicular to it.
.bg-grid was added for visual texture under the black theme; the
animated themes (drift/lava/vaporwave/laser) all have their own
backgrounds and don't need it. Hide it on any non-black theme.
The audit-trail of changes I made trying to find this in .vw-floor
have all been valid (the perspective-origin at horizon, the
collapse to a single transform, dropping will-change) and all
should stay — they removed real layer-promotion concerns. But the
artifact the user was seeing the whole time was this overlay.
Audit revealed the orthogonal-in-3D phantom was actually correct
rendering of an INCORRECT perspective setup. Default
perspective-origin is 50% 50% of the perspective container — but
.vw-floor extends from the horizon (top 55%) to bottom -10%, so its
midpoint sits ~82% from the viewport top, well below the horizon
line. Receding vertical grid lines were converging at that midpoint
instead of at the horizon, which visually reads as the grid lines
standing UP off the floor at varying angles — exactly the "lines
literally orthogonal in 3D space" artifact.
Setting perspective-origin: 50% 0 puts the vanishing point at the
top of the floor box, which is exactly the horizon line, so
verticals converge where they should and the floor stops looking
3D-broken.
Removing the .vw-floor-tilt wrapper. The previous nested structure
(perspective container → rotated tilt with preserve-3d → grid with
animated translate3d) had three transform layers that the
compositor had to keep in sync, and Chromium's optimizer kept
breaking the agreement — placing the grid's compositor layer flat
in 3D space, producing the phantom orthogonal post.
New structure: one perspective container, one animated grid. The
rotateX and translateY live on the SAME transform property on the
SAME element. Both keyframes carry the same rotateX so it doesn't
animate (only translateY interpolates), and the rotation is part
of the same transform list as the translate so the grid never
leaves its rotated plane.
No will-change, no transform-style: preserve-3d, no
separately-promoted compositor layer. Nothing for the compositor
to disagree about.
Audit traced the 'orthogonal in 3D space' artifact to
will-change: transform on .vw-floor-grid. Chromium's compositor
was rasterizing the grid's gradient into its own layer and
placing the resulting bitmap in 3D using only the element's
own translate3d, ignoring the parent's rotateX even though
the parent had transform-style: preserve-3d. The result was
a flat 2D billboard of the grid pattern appearing as an
upright post alongside the correctly-rotated floor grid.
Removing will-change lets the simpler layer-promotion path
fire (animation alone), which respects preserve-3d. Also
add transform-style: preserve-3d on the grid itself for
belt-and-suspenders.
The 'orthogonal in 3D space' artifact is the animated grid layer being
rendered flat (its own 2D bitmap) and then composited back into the
rotated parent — visually appearing as a phantom grid orthogonal to
the actual floor plane. transform-style: preserve-3d on .vw-floor-tilt
tells the compositor to keep the child transforms in the parent's 3D
context so the grid stays glued to the floor instead of popping out.
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>
Even though bg-canvas is position:fixed, the browser was
re-rasterizing the perspective-rotated grid as the document
scrolled, producing visible jumps in the line positions.
Force bg-canvas into its own compositor layer (isolation: isolate
+ will-change: transform), confine the floor's paint to its box
(contain: paint), and drop transform-style: preserve-3d from the
floor-tilt (it's not needed without nested 3D transforms and
seems to encourage extra rasterization on Chromium).
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>
Animating background-position on a perspective-rotated element
re-rasterizes the gradient every frame; with 1px lines under
3D transform, subpixel sampling jitter caused them to flip
on/off between frames.
Fix: split rotate (static) and translate (animated) onto separate
elements, animate transform: translate3d instead, GPU-promote the
grid layer (will-change + translateZ + backface-visibility), and
bump line width to 2px. The repeating-linear-gradient is drawn
once and the layer offset moves it; translating by exactly one
cell-period loops seamlessly.
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>