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>
Per the user's request — the rubric-derived scenes I added in one
sweep weren't tied closely enough to their actual project narrative
and ate up presentation time. Reverting to the pre-insertion deck:
removed
problem-statement / research-questions / solution-overview /
evaluation-setup / theoretical / practical / design-principles /
limitations / conclusion-future
kept (user-requested earlier in the session)
motivation (with the IEEE 9881803 citation)
live (A100 inference scene)
CSS rules and references/* sidecar files for the removed scenes
are left in place as harmless dead code; they can be cleaned up
later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mlp / cnn / knn_semi were rendering grey for at least one client even
though their .model-fill.<name> rules were identical specificity to
the working ones (lstm/gru/bert/knn/gbt). Probable cause: stale
browser cache or a theme-pass rule clobbering background:.
!important on every model gradient is heavy-handed but guarantees
the deck reads right during the live talk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bar widget had gradients for lstm / gru / rnn / bert / knn
only — any other model name (cnn, mlp, transformer, gbt, knn_semi,
transformer_ssl) rendered a track but no fill. Now:
- Added explicit gradients for cnn, mlp, transformer,
transformer_ssl, gbt, knn_semi (each visually distinct from the
existing five).
- Added a neutral grey-grey fallback on .model-fill itself, so any
unanticipated model name still produces a visible bar instead of
silently disappearing. The specific class rules override it.
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>
Stats line now reads "A100 inference · live · N models · X infer/sec
· last window: <host> · hit-rate: …" instead of "live detections ·
N hosts · model: …". Prose rewritten to describe lanes as side-by-
side model-agreement check rather than per-host activity.
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>
REORDER
- collect (big-number ingest counter) moved from #7 to #2 — sits
right after the title as the dataset-quantity hook
- training-code moved from #15 to #14 — "how we trained" now
appears before "what we got" (models accuracy bars)
EVAL FRAMING CORRECTION
The fleet hosts are uniform — every host runs every profile, just
at different rates — so the actual split is held-out-by-sample
(profile-stratified), NOT held-out-by-host. Both hosts contribute
to train, val, AND test. The generalization claim is "unseen
malware sample_name", not "unseen device".
Fixed across:
- evaluation-setup: split-recipe block, val↔test gap (was
"cross-host gap"), prose
- problem-statement: RQ wording, "generalize across hosts" →
"generalize to sample_names"
- research-questions: RQ2 ("from a host the training set never
saw" → "sample_names the training set never saw"); literature-gap
bullet flipped from "cross-host generalization" to "sample-
stratified evaluation"; prose
- solution-overview: pipeline diagram caption
- theoretical-contributions: "cross-host as the eval axis" →
"held-out-by-sample as the eval axis"
- limitations: two-host-fleet card now states "both hosts
contribute to train/val/test"; "KNN cross-host gap" → "KNN
val ↔ test gap"
- conclusion-future: bullet flipped to held-out-by-sample as
primary axis
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>
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 no longer rolls the last 5 minutes; it aggregates
time-weighted phase durations across a sampled slice of the
on-disk dataset. The prose now matches the bar.
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.
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).