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>
Non-parametric baseline alongside GBT/MLP/CNN/GRU/LSTM/Transformer.
Same BaseModel + schema-hashed checkpoint contract; sidecar is a
pickled sklearn KNeighborsClassifier (.knn.pkl) handled by the
existing checkpoint machinery alongside .xgb.json / .pt.
KNN's storage cost = n_train_rows × n_kept_features × 4 bytes.
At 660k windows × 145 kept (realistic mode) features = ~380 MB
sidecar; at 230 features (oracle) = ~600 MB. Heavy but ships through
the same artifact-upload path.
trainer/run.py learns a third fit branch:
- GBT — XGBoost early stopping on val mlogloss
- KNN — fit() memorizes; "training time" is val/test predict cost
- NN — train_nn loop (the rest)
Manifest gains knn-realistic + knn-oracle at priority 95 (just
below GBT). KNN's k=10 default lives in the model class — overriding
via hyper.k requires adding --k to run.py first to avoid the
unknown-arg exit-2 issue.
Smoke verified on the 567-episode subset:
knn oracle val=0.7365 test=0.1333 (held-out k-gamingcom)
That val/test gap (0.74 → 0.13) is the cross-device generalization
story: KNN memorizes elliott-thinkpad's local feature space and
falls apart on the other host. Honest baseline for the comparison
report.
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>
KNN-driven embedding events for the dashboard's KNN scatter scene
(scene 11). One forward pass populates all three of the scatter's
mode-toggle fields:
x, y, z — PCA-3 projection of the standardized window features
phase — ground-truth phase from labels.jsonl
predicted — KNN classifier's prediction (k=10, distance-weighted)
cluster — MiniBatchKMeans cluster id (k=8 default)
Two subcommands:
python -m training.producers.knn produce ... emit Embedding events
python -m training.producers.knn metric ... publish ModelMetric{knn}
on a tick (re-publish
for reconnect-warmth)
KNN classifier uses the held-out-by-host split aligned with the
supervised pipeline (train ∪ val on elliott-thinkpad, predict on
k-gamingcom) so the predictions reflect cross-device generalization,
not in-distribution self-prediction.
Smoke-verified end-to-end against the live dashboard (3 clients):
800 embedding events delivered in 12 s; ModelMetric{knn} with
test_macro_f1 = 0.4297 on the 567-episode smoke subset, sitting
between the trained GBT (0.557) and the under-trained NN models
(0.09–0.18) — sensible for a non-parametric baseline.
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.
At our model sizes (max ~250 K params, max batch 512), each training
process uses ~1 GiB VRAM. A 40 GiB A100 is far from contention with
two concurrent jobs. Bounded-concurrency rolling launcher cuts
sequential ~3.5 h → parallel ~1.7 h for the full 14-job manifest.
PARALLEL=2 (default) — override via env var if running on a smaller GPU
or testing the queue logic.
Per-job logs still land at logs/<model>_<mode>.log; failure reporting
is the same. Idempotent: skipping already-present checkpoints unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
External-GPU path for the time-pressured first round, before the
Windows desktop joins the WG fleet. Lambda is treated as an "external
worker" whose output lands in the same /var/lib/cis490/models/ tree
the receiver-coordinated fleet uses, so cis490-jobs status reflects
Lambda runs identically to fleet runs.
Three scripts + one ingest tool:
scripts/build-lambda-bundle.sh
Tarball at /tmp/cis490-lambda/lambda-bundle-<short>.tar.zst with:
- the repo (sans .git, sans data/, sans artifacts*)
- data/processed/{validation_v1,features_window_v1}.parquet
- data/processed/feature_schema_v1.json
- data/processed/tensor_window_v1/ (npz shards)
- bootstrap.sh (entrypoint)
- training_manifest.toml (the canonical job list)
- BUNDLE_MANIFEST.json (commit hash + counts + build stamp)
Verifies all four data inputs exist BEFORE compressing 5+ GB.
scripts/run-on-lambda.sh ubuntu@<ip>
rsync bundle up → ssh + run bootstrap → rsync artifacts +
reports/eval back to artifacts-lambda/ + reports/lambda/.
Resumable rsync; sha256-verified.
scripts/lambda-bootstrap.sh (runs ON the Lambda instance)
Creates .venv with cu121 torch + xgboost + the [training] deps,
iterates the manifest's job list in priority order (highest first),
runs trainer/run.py (or run_ssl.py for transformer_ssl) per job,
skips jobs whose .ckpt.json already exists (idempotent on re-run),
writes per-job logs/<model>_<mode>.log, runs eval suite at the end,
stamps artifacts/RUN_SUMMARY.json with counts + failed-job list.
tools/ingest_lambda_artifacts.py
Bundles each (ckpt.json + sidecar + train.json) trio into a
.tar.zst, sha256, PUTs to the local trainer-receiver's
/v1/model/{job_id}, marks the job complete. Maps (model, mode) →
job_id by re-reading the canonical manifest. Handles the queue
state churn (requeue if completed, claim if pending, fail-back
on race losses).
End-to-end smoke verified on the A100 instance just provisioned:
- SSH from Pi via ed25519 keypair (cis490-trainer-pi)
- GPU: A100-SXM4-40GB, driver 580.105.08
- venv warmed: torch 2.5.1+cu121, xgboost 3.2.0
- 464 GB ephemeral disk available
Pi-side feature build (build_features.py + build_tensors.py against
all 72,952 accepted+degraded episodes) is in progress; bundle build
gates on its completion. Estimated wall-clock for the full Lambda
training run on A100: ~2.5 hours for 12 supervised + 2 SSL models +
eval suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Producers are event *sources* — the renderer is everything inside
training/dashboard/. Sibling layout makes the dependency direction
one-way (producers import from training.dashboard.events; dashboard
never reaches into producers).
training/dashboard/producers/ → training/producers/
Internal imports rewritten via sed; eval_/run.py and training/README.md
cross-references updated. CLI entry stays via `python -m training.producers.<sub>`
(replay / metrics / perf / profiles).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single import point for the model session to wire interactive
scenes. One @dataclass per event type, with docstrings naming the
scene each one drives and the shape of every field:
PhaseEvent — scene 6 (baseline phase mix)
AttackProfile — scene 7 (per-profile envelope thumbnails)
Prediction — scene 8 (10-second window timeline)
ModelMetric — scene 9 (model accuracy bars)
Embedding — scene 11 (KNN scatter)
ModelPerf — scene 12 (accuracy-vs-latency scatter)
Phase + Model Literal types narrow the inputs so static checkers
+ IDEs autocomplete the canonical strings.
Publisher.publish now accepts either a dataclass instance from
events.py or a plain dict, so the existing
``pub.publish({"type": "...", ...})`` pattern keeps working
untouched.
Module-level publish() / try_publish() helpers wrap a default
Publisher for one-liner usage. The PRODUCERS.md guide now leads
with a pointer to events.py so the typed interface is the first
thing producers read.
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).
Symmetric companion to the collection fleet (orchestrator/fleet.py)
but for *training*. Collection is embarrassingly parallel; training
is not (a model is trained at most once across the fleet), so the
receiver coordinates which worker gets which job.
Operator-control surface is etc/training_manifest.toml.example —
single canonical file declaring (a) per-host capability + per-model
allow/deny policy, (b) one [[jobs]] entry per (model, mode, hyper)
with capability constraints (require_cuda, prefer_cuda, min_vram_gib,
min_ram_gib, allowed_hosts).
Components:
capability.py — self-detection: hostname, cores, RAM, CUDA presence,
VRAM, torch version, git commit. Used by workers to filter
eligible jobs before claiming.
manifest.py — TOML loader + JobSpec/HostSpec. Job IDs are stable
sha256 of (model, mode, hyper, split_recipe, train_hosts, seed)
so manifest reload is idempotent: existing rows keep their status,
new jobs become claimable, removed jobs stay until cancelled.
queue.py — SQLite job queue (training_jobs.db) with statuses
pending|claimed|running|completed|failed|cancelled. Atomic
claim_next via single UPDATE WHERE status='pending'. Heartbeat,
complete, fail. Stale-claim sweep (stale_after_s=600s) with
max_attempts cutoff to failed.
store.py — model artifact store mirroring receiver/store.py.
Artifact ID is the sha256 of the uploaded tarball; bit-identical
re-runs deduplicate.
receiver.py — Starlette app exposing 11 endpoints:
POST /v1/job/claim (worker)
POST /v1/job/{id}/heartbeat (worker)
POST /v1/job/{id}/complete (worker)
POST /v1/job/{id}/fail (worker)
PUT /v1/model/{id} (worker — uploads tarball)
GET /v1/jobs (anyone)
GET /v1/workers (anyone)
POST /v1/job/{id}/cancel (operator: X-Operator-Token)
POST /v1/job/{id}/requeue (operator)
POST /v1/manifest/reload (operator)
GET /v1/health (anyone)
Runs as cis490-trainer-receiver.service on the Pi alongside the
existing receiver, on a separate port.
client.py — stdlib HTTP client (urllib only, no new deps).
worker.py — long-running daemon. Loop: detect capability → claim →
spawn training/trainer/run.py subprocess → heartbeat every 30s →
tar artifact, sha256, PUT /v1/model → complete. SIGTERM-safe.
Operator CLI (tools/cis490_jobs.py): status / list / show / cancel /
requeue / reload / workers. Cancel and requeue require
$CIS490_OPERATOR_TOKEN matching the receiver's configured value.
Bootstrap: scripts/install-training-worker.sh (Linux systemd) and
scripts/install-training-worker-windows.ps1 (Windows Scheduled Task)
let the operator enroll a new host with one command after cloning
the repo and setting up the venv. Worker self-tests capability
before registering.
End-to-end smoke verified on the Pi: receiver up, manifest synced,
14 jobs queued, worker registered, claimed 4 CPU-eligible jobs
(allow_jobs=["gbt","mlp"]), completed 3 (gbt-realistic, gbt-oracle,
mlp-oracle), 1 failed with the actual error visible via
cis490-jobs status, 3 artifacts uploaded to
/var/lib/cis490/models/<model>_<mode>/<sha256>/bundle.tar.zst with
proper index.jsonl row.
21 unit tests (manifest validation: 8; queue lifecycle + eligibility:
13). All pass alongside the prior 17 training tests = 38 green.
Open limitations surfaced inline:
- Hyper-key drift between manifest and run.py fails at training
time, not at manifest reload (worth tightening to argparse
introspection later).
- mTLS not yet wired through Caddy for the trainer-receiver port —
listens loopback-only until that lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LogBERT-style self-supervised Transformer pretrain on `clean`-only
windows, plus Integrated Gradients attribution for any tensor model.
Both directly answer the assignment's §8 'next steps in unsupervised
learning' requirement and Natsos & Symeonidis 2025's RQ3 on
explainability.
Pretrain (training/models/transformer_ssl.py +
trainer/run_ssl.py):
- Masked Timestep Reconstruction (MTR) — random 15% of timesteps
zeroed, encoder + per-channel head reconstructs from the rest.
Loss: MSE over masked positions.
- Volume of Hypersphere Minimization (VHM, Deep SVDD-style) — pull
learnable [DIST] token embedding toward a frozen center vector
initialized as the mean over clean train. Loss: ||h_dist - c||^2.
- Calibrated anomaly threshold at user-configurable target FPR
(default 5%) on clean-val distance distribution.
- Trained ONLY on `clean`-phase windows; the model never sees a
labeled malware sample yet flags any window that doesn't look
clean — including novel malware the supervised classifier never
saw. Uses the same schema-hashed checkpoint format as the
supervised models so loaders refuse mismatched feature schemas.
XAI (training/xai/integrated_gradients.py):
- Per-(channel, timestep) attribution via path-integrated gradients
over Riemann-mid-point steps. Works for cnn/gru/lstm/transformer/
transformer_ssl.
- Per-phase mean |IG| heatmaps under reports/xai/<model>/<phase>.png,
top-k channel importance per phase as JSON. Smoke-verified on the
trained CNN: top channel for `clean` is guest.cpu_iowait (sensible
— clean = idle = high iowait).
Project brief and slide planner:
- docs/project_brief.md — full draft of the assignment's required
sections 1–9 (problem, research question, ML task type with
justification, six supervised algorithms with assumptions, dataset
description with full validation breakdown, evaluation metrics with
rationale, current progress, lit review with 11 APA citations,
next steps for unsupervised, references).
- docs/slide_planner.md — all 16 slides filled with content tied to
specific files and metrics from this codebase, not generic
placeholders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The model layer of the project, built honestly:
- tools/dataset_validate.py — full-sweep validator over the receiver
store (sha256, schema, monotonic labels, telemetry-row gate). On the
current corpus: 64,798 accepted + 8,154 degraded + 3,701 rejected +
7 errored across 76,660 shipped episodes. data/processed/validation_v1.parquet
is committed as the per-episode acceptance index.
- training/_features.py — channel registry (46 channels across
proc/guest/qmp/netflow), summary-stat windowing AND channel×time
tensor extraction at 10s/5s windowing. Time alignment uses t_wall_ns
(Unix ns) — tested fix for a real netflow-vs-host clock-base
inconsistency that was silently dropping every netflow channel.
- training/_split.py — three held-out recipes (host / sample / time)
with profile-stratification assertions. held_out_host carries
untested_profiles for cases like scan-and-dial absent from the test
host (5 of 6 profiles tested cross-device, never silently averaged).
- training/models/ — 6 architectures behind a common BaseModel
interface: gbt (XGBoost), mlp, cnn, gru, lstm, transformer. Each
trained twice (realistic / oracle) per the deployment threat model.
Schema-hashed checkpoints refuse to load if _features.py changed
since training (silent-input-drift protection, tested).
- training/trainer/ — unified training loop: class-weighted CE, LR
warmup + cosine, gradient clipping, mixed precision when CUDA,
early stopping on val macro F1, best-on-val checkpoint. Same loop
runs MLP/CNN/GRU/LSTM/Transformer; GBT uses XGBoost
early_stopping_rounds on val mlogloss.
- training/eval_/ — bootstrap 95% CIs on macro F1, per-class F1,
per-profile and per-host breakdown, paired-bootstrap significance
for model-vs-model gap. Confusion matrix uses union of seen labels.
- training/dashboard/producers/ — replay/metrics/perf/profiles
emitting the six event types the dashboard's awaiting scenes
consume; on-demand tensor extraction so the Pi can run live
inference without 65 GB of shards.
- 17 unit tests (split coverage, features round-trip, schema mismatch,
determinism, time-base alignment regression).
End-to-end smoke-trained all six on a 567-episode subset; held-out
test macro F1 reported with paired-bootstrap significance. The
methodology now reports honest cross-device generalization, not
in-distribution validation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
The earlier canvas→divs revert edit silently dropped due to a
file-modified race; HTML still had <canvas class=vw-floor-canvas>
while the CSS had been rewritten to target .vw-floor/.vw-floor-tilt/
.vw-floor-grid. Result: rules matched nothing and the grid was
invisible. This commit restores the div tree the CSS expects.
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>