Status: spec, rev 1, 2026-08-04. Nothing here is built yet. Every claim about existing code is a path and a line number, so it can be checked.
RAVIO turns the amp harness's real work into a drivable, watchable, broadcastable world. The car drives a sky road. The signs it passes are the changes that actually happened. The exits lead to lanes. The dashboard is the lane's real scores. The drive-in plays what the lane built.
The one-line thesis:
The road may never move for a reason the viewer cannot read off a sign.
That sentence is not new. It is already enforced in the existing RAVIO renderer, and recovering why is the reason this spec exists rather than a rewrite.
Code-as-world is a graveyard. code_swarm died in 2014. GitHub Skyline was shut down by GitHub — the site now reads "The lights are out for now." Primitive.io burned real funding on walk-through-your-codebase VR and is abandonware. CodeCity has produced papers for eighteen years — SecCityVR at EASE 2025 found *higher usability, lower frustration, and longer task completion times* — with zero sustained industrial adoption. Gource is the one healthy survivor (v0.56, March 2026) and it teaches the real lesson: it is beautiful for ninety seconds and then it is the same beautiful thing forever.
AI-narrated 24/7 streams fail differently. Nothing, Forever peaked near 20,000 concurrent viewers in 2023. It is still running in 2026, averaging 8–9 concurrent viewers. A thousandfold collapse while the technology kept working perfectly. The Oxford Adaptation paper's finding: season 1 worked because viewers read incoherent output against Seinfeld. Season 2 dropped the frame and viewership "bottomed out."
Human meaning-making remains essential; AI output alone cannot sustain engagement.
The asset none of the prior art had: amp emits real events with stakes. A rung claimed and then retracted. A contradictions gate that refuses to clear. A merge blocked because statusCheckRollup said no. A billboard reading graphonomous — contradictions gate held 3 findings is more watchable than any procedural highway, because it might be wrong and someone can go check.
The two genres fail in complementary ways. Code-as-world has nothing to say; a narrator gives it something. AI narration has nothing true to talk about; a real harness doing real work gives it a frame that renews itself for free — which is exactly what Nothing, Forever lost and never got back.
Every watchable version of this genre has a legible win condition. TPP had a game everyone played as a child. Claude/Gemini Plays Pokémon inherit nostalgia plus a finish line.
RAVIO's frame is the evidence ladder, and it already exists:
spec → in_tree → live_local → live_deployed → external
LADDER_RUNGS, `code/amp.py:11467`. Five rungs. Every lane is trying to climb. Rungs can be retracted (retract_rung exists). That is a scoreboard with stakes, a win condition, and the possibility of visible failure — and failure is content in a way that silence is not.
This project's most likely failure is not technical and not a platform ban. It is that it becomes wallpaper. Guard rails, in the spec because they are cheap now and expensive later:
The stream runs when the harness runs. Not 24/7. See §13.
Uptime is never the product. Lofi Girl ran ~20,000 hours and was killed by a
bogus DMCA claim from a label that didn't own the music — for the third time. If your stream's value is "it has been running for N days," anyone can zero N.
Surface the machinery. Token counters, diffs, the gate firing, the filter
firing. Audiences for this genre want the seams.
This is an integration, not a greenfield build. Roughly 60% of the renderer is written.
~/Projects/Travelmonstr/tools/road/road-radio.html (586 lines)Note the tree boundary: RAVIO's renderer lives in ~/Projects/Travelmonstr/, which is a separate git tree from ~/ProjectAmp2/. This spec lives in ProjectAmp2 because the data source does. See §14 for the resolution.
three.js r160 ES module, WebGLRenderer({alpha:true}) at fixed 1920×1080, composited onto a 2D canvas. No custom GLSL anywhere — stock MeshBasicMaterial + CanvasTexture plus heavy 2D canvas painting.
Already built and working:
| Piece | Where | Notes |
|---|---|---|
| Curved ribbon road | makeRibbon/updateRibbons L49–73 | 3 ribbons × 96 segments, displaced per-frame in X |
| 20 billboards | mkBoard L92 | pole + arm + 512×340 CanvasTexture panel |
| Recycling centreline | L75–81 | 40 dashes, yawed to road tangent |
| Cosmic sky | drawSky L472 | 2D canvas under the WebGL output |
| Sky off-ramps | drawRamps L438 | 6 bezier strokes — painted, not drivable |
| Checkered horizon sphere | checkerSphere L447 | at the vanishing point |
| Cockpit dashboard | drawDash L303 | BASS/DRIVE gauges, KM/H, 30-bar EQ, banking wheel |
| Monitor hole | L555–570 | destination-out punch — the drive-in screen already exists |
| DVD-bounce logo | drawLogo L504 | recolours on wall hit |
| Live loop | L574–583 | requestAnimationFrame, ~15 u/s, screen-recordable |
| Headless render | render-radio.mjs | CDP → ffmpeg → muxed hour video |
Constants that govern everything (L39, L175):
const S = 30, PER = 10, L = S * PER, SIDE_X = 8.2; // 30 units/milepost, 10 boards/side
const SPEED_U = 15; // world units/sec
⇒ one sign every 2 seconds. Remember this number; §8 depends on it.
~/Projects/Travelmonstr/site/ravio/index.html (455 lines) — the **live interactive
browser port**. Click-to-open-modal, mouse-tracking saucer, prefers-reduced-motion static fallback. Runs as plain static HTML, no build.
~/Projects/Travelmonstr/tools/road/road.html (256 lines) — older flat-road
music-video variant with poster billboards.
steer()// steer in [-1,1]: DIRECTION + magnitude come STRICTLY from how fast the years
// are moving (gaining -> right/+, losing -> left/-), tanh-saturated ... NO idle
// sway: the curve is a pure readout of the year rate ... (An earlier idle-sway
// term made the road bend right during the flat record holds while the signs
// stayed put -> "curves right but stuck on the same number".)
function steer(d) { const r = yearRate(tOf(d)); return 0.66 * Math.tanh(r / 3); }
That comment is the doctrine of this project, discovered the hard way. It is why site/ravio/index.html — which did re-add an idle sway (L165) because it has no data to read — is the less correct implementation despite being the interactive one.
// year read on a sign = a pure function of that sign's OWN milepost (m) ... so
// quantizing to its milepost m makes the number STATIC: it never changes while
// the sign is on screen -- no per-frame flicker.
function yearAt(drive, depthAhead) {
const m = Math.round((drive + (depthAhead || 0)) / S);
return Math.round(dialYear(tOf(m * S)));
}
This is the load-bearing idea of the whole system. A sign's content is a pure function of its own integer index, not of wall-clock time. It is why the signs don't flicker. §3 shows that amp's change feed has exactly this shape already.
~/ProjectAmp2/code/| File | Lines | What |
|---|---|---|
amp.py | 20,042 | core library — state, workers, transcripts, ladder |
server.py | 3,927 | the HTTP console (not amp.py) |
store.py | 726 | SQLite mirror + the change feed |
preview.py | 379 | child preview servers |
index.html / app.js / app.css | — | vanilla ES, no framework, no build step |
State root .amp/, files dot-prefixed (.board.json, not board.json).
This is the spec's core. Each row is a claim that two independently built systems have the same shape.
| RAVIO | amp | Why it is the same thing |
|---|---|---|
| milepost `m` | `revisions.seq` | Both are a never-reset monotonic integer. A sign's content must be a pure function of its own milepost; a revision is immutable once written. |
| `drive` accumulator | the bridge's journal cursor | Never resets. Odometer. |
| road curvature | d(evidence)/dt | Right = evidence accruing, left = retraction, straight = nothing being proved |
| altitude | LADDER_RUNGS index | It is a sky road. Spec-only flies low; live_deployed flies high. |
| billboards | change-feed rows | The change that happened, at the milepost it happened |
| exits / off-ramps | lanes | An exit opens when a lane goes active |
| the district you're in | workspace | core, substrate, products, compose, academy, research, showcase |
| cockpit gauges | lane_ratings() | Seven ratings per lane, each {value, n, why, bar} — `bar` is the redline — already computed every poll, no model calls |
| speedometer | event backlog | Speed = how fast work is actually landing |
| what the car is doing | _PHASES | running/editing/reading/searching/fetching/delegating/planning/waiting |
| drive-in screen | preview.py child server | Already a real origin on 127.0.0.1:88xx |
| advertisement signs | lane direction | The 8 authored fields — what the lane is for |
Mileposts. Every state write in amp funnels through one function — save_text → store.record, `amp.py:611` — into:
CREATE TABLE revisions(
seq INTEGER PRIMARY KEY AUTOINCREMENT, -- store.py:178
path, workspace, body BLOB, sha, bytes, written_at, device, zip)
Identical writes are deduped by sha (store.py:367), so a row is a genuine change. GET /api/db/changes?since=<seq>&limit=<n> (server.py:3412, store.py:649) returns a bodyless cursor feed. Nothing in the dashboard consumes it — it was built as a sync feed and left unused. It is exactly the right hook and it is free.
Altitude. LADDER_RUNGS (amp.py:11467), derived per lane by lane_rungs() (amp.py:11581) from review verdicts. Proposed altitudes:
| Rung | y | Reading |
|---|---|---|
| (unrecorded) | 0 | ground — 22 of 27 lanes were here at rev 5 |
spec | 40 | |
in_tree | 90 | |
live_local | 150 | |
live_deployed | 220 | |
external | 300 | someone outside used it |
Altitude is the most legible thing in the frame and it costs nothing to render. It also makes retraction visible as a descent.
The dial. The year dial is replaced by the current lane's evidence rating from lane_ratings() (amp.py:11638) — a 0–1 float that carries rung, recomputed on every poll, entirely off disk with no model calls. So:
function steer(d) { return 0.66 * Math.tanh(evidenceRate(d) / K); }
Same shape, same saturation, same doctrine. Signs show the score and the rung, so the curve is readable off the signs. Do not add an idle sway. If nothing is being proved, the correct render is a straight road, and that is information.
Violating any of these produces a thing that looks like RAVIO and lies.
Readability. The road may never move for a reason the viewer cannot read off a
sign. No idle sway, no decorative curvature, no ambient drift.
Milepost purity. A sign's content is a pure function of its milepost. Never of
wall-clock time, never of camera position, never of a value that can change while the sign is on screen.
No invented events. A sign renders a change that happened. Quiet is rendered as
quiet (§8), never as fabricated activity.
Monotonicity. drive and the journal cursor never decrease and never reset. A
process restart resumes the odometer; it does not rewind it.
The renderer never writes to amp — except through the explicit dashboard
control path (§10), which is a user action, gated, and logged.
Nothing is claimed at a rung it did not earn. The world renders lane_rungs()
verbatim. If a lane is unrecorded, it flies at ground level and the sign says unrecorded. This is the same discipline WORKSPACE_DIRECTION.md rule 2 already applies to written directions.
Narration is gated before synthesis. Text → moderation → TTS. Never the other
order. §11.
Five planes. Each is separately testable and separately killable.
amp console (127.0.0.1:8787, X-Amp-Token, no CORS)
│ poll: /api/db/changes?since= · /api/state · tail *.jsonl
▼
┌───────────────────────────────────────────────────────────┐
│ 1. BRIDGE ravio/bridge.py │
│ owns the journal (its own append-only copy) │
│ assigns mileposts · derives world state · serves :8890 │
└───────────────────────────────────────────────────────────┘
│ GET /world.json (same-origin with the renderer)
▼
┌───────────────────────────────────────────────────────────┐
│ 2. RENDERER three.js — road-radio.html descendant │
│ road · signs · exits · altitude · drive-in · dash │
└───────────────────────────────────────────────────────────┘
│ headless Chrome under Xvfb (real GPU)
▼
┌────────────────────┐ ┌──────────────────────────────────┐
│ 4. AUDIO │ │ 3. NARRATOR │
│ music bed │◄──┤ local LLM → moderation → Kokoro │
│ PipeWire sinks │ │ 60s prebuffer · gen-id queue │
└────────────────────┘ └──────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ 5. BROADCAST x11grab + NVENC → MediaMTX → tee → YouTube │
└───────────────────────────────────────────────────────────┘
Why a bridge process and not fetch-from-the-page: amp refuses cross-origin (server.py:3283) and requires X-Amp-Token on every /api/ path (server.py:3292). A browser page on another port cannot talk to it. The bridge holds the token, and serves the renderer from its own origin so the renderer needs no auth at all.
Three more reasons the bridge must exist, all of which are gaps in amp rather than preferences:
amp has no event bus, no SSE, no WebSocket, no long-poll, no webhooks. The
console learns everything by re-fetching a full snapshot every 3s (app.js:206).
amp's change feed is pruned to 20 revisions per document (store.py:227). It is
a live tail, not an archive. Mileposts you drive past are gone unless the bridge keeps its own copy. Invariant 4 requires that copy.
Worker transcripts live outside the state root (~/.claude/projects/*/), so
they are not in the feed at all, and tailing them is the only sub-second signal.
ravio/bridge.pyPure stdlib, no dependencies, matching the house style of preview.py and store.py.
An append-only SQLite table the bridge owns:
CREATE TABLE milepost(
m INTEGER PRIMARY KEY AUTOINCREMENT, -- THE milepost
kind TEXT, -- change | worker | rung | gate | quiet | exit
amp_seq INTEGER, -- source revisions.seq, NULL for derived rows
lane TEXT, workspace TEXT,
headline TEXT, -- what the sign says. <= 40 chars. see §9
detail TEXT, -- JSON. the narrator's fact source
evidence REAL, -- the dial at this milepost
rung TEXT, -- altitude at this milepost
at TEXT);
`m` is assigned by the bridge, not copied from `amp_seq`. Necessary because the bridge also emits rows amp has no seq for (worker phase transitions from transcript tails, quiet markers), and because amp's seq is sparse and bursty while mileposts must be dense and ordered. amp_seq is retained for provenance so any sign can be traced back to the exact revision that caused it.
| Loop | Period | Source | Emits |
|---|---|---|---|
changes | 2 s | GET /api/db/changes?since=<cursor> | one change row per revision |
state | 3 s | GET /api/state | lane ratings, rungs, stages, live workers block |
transcripts | 500 ms | tail ~/.claude/projects/*/<sid>.jsonl | worker rows: tool calls, edits, bash |
quiet | 4 s | — | a quiet row iff no real row in the interval (§8) |
Session ids resolve from .board.json task rows (session_id → resume_of → task_id, the same order do_log uses at server.py:2843).
Transcript parsing must be its own implementation, not `/api/log`. record_events (amp.py:4783) is the shape to mirror, but _tool_headline (amp.py:4767) throws away the full tool input and clips to 200 chars. A sign that wants the actual file edited is fine; one that wants the diff is not. Parse the jsonl directly. Files reach hundreds of megabytes — seek to end, never re-read from the start.
GET /world.json{
"cursor": 48211,
"car": { "drive": 91824.5, "lane": "graphonomous", "workspace": "core",
"phase": "editing", "speed_u": 18.2, "altitude": 90 },
"dial": { "evidence": 0.42, "rate": 0.11, "rung": "in_tree" },
"gauges": { "direction": 0.8, "spec": 0.5, "goals": 0.3, "workers": 0.9,
"evidence": 0.42, "standing": 0.7 },
"mileposts": [ { "m": 48200, "headline": "graphonomous · edit engine.ex",
"kind": "change", "amp_seq": 121889, "rung": "in_tree" } ],
"exits": [ { "m": 48260, "lane": "PULSE", "rung": "live_local",
"direction": { "for": "…", "thesis": "…" } } ],
"screen": { "kind": "preview", "url": "http://127.0.0.1:8804/" },
"backlog": 3
}
Served with the renderer from one origin on :8890. The renderer polls at 1 Hz and interpolates; it never blocks on the network — a failed poll reuses the last world and the car keeps driving. A stall in the bridge must never stall the road.
Float32 has 24 mantissa bits. Past ~10⁵–10⁶ metres, world positions snap to representable values and the snapping changes between frames → visible jitter. At 15 u/s you reach 10⁶ units in about 18 hours.
The current code already computes positions from drive per frame, so the fix is small and must be made explicit: the camera stays at the origin forever, chunks move toward it, and `drive` is a pure float64 JS `Number` odometer with no rendering consequence. This eliminates the entire bug class rather than mitigating it.
Two related traps, both silent:
Never feed accumulated seconds into a float32 shader uniform. After 24h,
t = 86400 quantizes to ~0.008 s steps and every sin(t*f) visibly stutters. Wrap: uTime = t % 3600. Presents as "the shaders started stuttering overnight."
Clamp delta and use a monotonic clock. dt = Math.min(rawDt, 0.1); a GC pause
otherwise flings the car 300 m in one frame. performance.now(), never Date.now() — NTP will step the clock over days and a negative dt NaNs everything.
Replace dialYear/yearAt with a journal lookup, preserving milepost purity exactly:
function signAt(drive, depthAhead) {
const m = Math.round((drive + (depthAhead || 0)) / S);
return journal.get(m); // immutable once written — invariant 2 holds
}
paintYear becomes paintSign — same "only repaint when it changes" guard (if (b.m === m) return;), which matters more now because the content is text rather than a number.
The existing signs use 512×340 CanvasTexture with 900 150px Arial Black. That works for a 4-digit year and will not work for graphonomous · contradictions gate held 3.
Two independent problems:
Distance. A 1024×512 canvas billboard is fine close up, shimmers at 100 m, goes soft under 10 m. troika-three-text 0.52.5 (actively maintained, peerDep three >=0.125.0) gives resolution-independent SDF text from one atlas. Use it for sign body text; keep CanvasTexture for the fixed-layout chrome.
H.264 4:2:0 chroma subsampling stores colour at quarter resolution. Any edge whose contrast lives in colour rather than brightness is smeared, and ringing appears at saturated high-contrast edges. Worse here than in most scenes because billboards approach continuously — every frame is a new scale, so the encoder can't reuse blocks and spends its bits on motion instead of on the sign.
| Rule | Why |
|---|---|
| Contrast in luma, not chroma — white on dark navy | luma is full-res in 4:2:0 |
| Thick strokes, ≥ ~4 device px stem | thin stems fall below post-compression resolution |
| Cap-height ≥ ~24 px when it must be readable | below ~16 px at 1080p is a coin flip |
| Flat plates. No gradient, dither or grain behind text | noise eats the bitrate edges need |
| No thin glyph outlines — solid plate instead | a 1px stroke is a chroma-boundary generator |
| Slab-serif or grotesque, never didone | high stroke-contrast faces are hairlines by definition |
Test against decoded frames, not the canvas. Render a sign, encode at the real target bitrate, look at the output, iterate.
tools/legibility.sh, tools/legibility_motion.sh, tools/motion_report.py. The sign comes from world/sign.js, the same module the road uses — a legibility test that draws its own sign is testing the copy, and keeps passing after the road's type changes.
Static frame, luma SSIM per sign region: 0.9975–0.9984 across every apparent width from 640 px down to 80 px, at 10000 / 4500 / 2000 kbps alike.
But a static frame is the easy case — after the first GOP a still is nearly free. The real workload is a billboard approaching: every frame is a new scale, the encoder cannot reuse blocks, and the rate controller spends bits on motion instead of type. Synthesised approach, 90→900 px over 6 s at 30 fps, CBR with a 2 s GOP, compared against a qp 0 encode of the identical animation, cropped to the sign's computed rectangle at each sampled frame:
| frame | sign px | 10000k | 4500k | 2000k |
|---|---|---|---|---|
| 6 | 116×68 | 0.9990 | 0.9976 | 0.9927 |
| 24 | 198×116 | 0.9997 | 0.9992 | 0.9979 |
| 54 | 332×194 | 1.0000 | 1.0000 | 0.9998 |
| 90+ | ≥494×290 | 1.0000 | 1.0000 | 1.0000 |
Damage concentrates exactly where theory says it should — the far end, where the sign is smallest and its spatial frequency highest relative to the macroblock grid — and worsens as bitrate falls. Worst case 0.9927 at 2000 kbps, comfortably above the 0.95 readable threshold. The luma-contrast rules are doing their job; §6.3 is not a blocker at any rate under consideration.
The measured scene is a sign on a flat background. The real scene has backdrop.jpg behind it — a photographic starfield, high-frequency noise, and the single worst thing to put behind text in a bitrate-limited encode. It will steal bits from the signs, and that has not been measured. Nor has the full scene with road, moving centreline dashes and HUD in frame. The honest claim is the typography survives H.264 in isolation, not the stream will be legible.
Answered later the same day, by removal rather than by measurement. world/space.js replaced backdrop.jpg with a procedural star field: small bright points on near-black, seeded by a fixed mulberry PRNG so it is the same sky every run. Most of the frame is now flat, which costs the rate controller almost nothing, and the points are stable in world space so the encoder can track them. This does not mean the hazard above was measured — it means the specific thing that would have caused it is gone. The full-scene test is still unrun, and the deterministic seed exists for §6.5's soft restart: a sky that reshuffles on rebuild makes the restart visible in the output.
Two false results were produced and discarded on the way here, both worth recording because both looked authoritative:
The first static run reported LOST on all six sizes. -loglevel error suppresses
ffmpeg's own SSIM line (it logs at info), the parse returned None, and the caller treated None as 0.0. A metric that cannot be read must never read as a bad score; it now reports metric unavailable.
The first motion run reported **avg luma SSIM 0.999999 — better than the static
test**, which is backwards. It measured whole 1920×1080 frames, mostly flat background that encodes perfectly and drowns the region under test. That is the exact error already identified for the static case and then reintroduced one script later. Fixed by cropping to the sign's computed rect per frame.
Today drawRamps (L438) paints 6 beziers into the 2D sky canvas. They are background art. And the road model —
function curveX(depthAhead, drive) { return steer(drive) * depthAhead * depthAhead * 0.010; }
— evaluates steer at the camera's drive, so the entire visible road has exactly one curvature at a time. A diverging path is structurally inexpressible. This is the largest piece of genuinely new engine work.
Generalize to a path-indexed centreline:
function pathX(depthAhead, drive, path) {
const base = steer(drive) * depthAhead * depthAhead * 0.010;
if (path.kind === 'main') return base;
const t = clamp((depthAhead - path.d0) / path.len, 0, 1);
return base + path.offset * smoothstep(t);
}
function pathY(depthAhead, drive, path) {
if (path.kind === 'main') return 0;
const t = clamp((depthAhead - path.d0) / path.len, 0, 1);
return (path.altitude - path.altitude0) * smoothstep(t);
}
makeRibbon/updateRibbons take a path; the ramp is a fourth ribbon set. The camera transitions by blending its path parameter over ~2 s. Because the mainline formula is untouched when kind === 'main', nothing that works today changes.
Pool geometry, never allocate. Pre-allocate N segments and rewrite attribute
buffers in place. Every discarded BufferGeometry is a GC event plus a GPU buffer needing .dispose(). Chunk churn is the #1 leak vector.
Log `renderer.info` every 5 minutes — memory.geometries, memory.textures,
render.calls. Flat is healthy; a slow ramp on geometries is the pool leaking and you see it at hour 2 instead of hour 40.
Soft restart every 6–24 h regardless — fade out, rebuild, fade in. Persist the
odometer so a restart is invisible in the output.
WebGL context loss is the most likely thing to end a long run, and three.js's
recovery story is weak (webglcontextrestored frequently never fires; forceContextRestore often unsupported). Do not call `preventDefault()` on context-lost — that promises a restore you may not be able to deliver, trading auto recovery for a permanent black screen. Log it, watchdog 5 s, tear down, rebuild.
This was specced and unimplemented until the page stopped loading in a live browser:
THREE.WebGLRenderer: A WebGL context could not be created.
Reason: Web page caused context loss and was blocked
Uncaught Error: Error creating WebGL context. at (index):303
Not a GPU fault. Chrome caps live WebGL contexts (~16 per process) and blocks new ones from an origin that keeps losing them. Reloading a WebGL page in a loop, or leaving several tabs of it open — which is exactly what verifying this project looks like — reaches that on a perfectly healthy machine.
The damage was in the failure mode, not the failure. new WebGLRenderer throws, a throw at the top level of a module aborts the whole module, and every listener, poll and control defined below line 303 silently never existed. The page then looked half-loaded rather than broken: HTML and CSS arrive, the cockpit paints, nothing in it does anything, and the obvious first suspicion is the server. Renderer construction is now wrapped and failure paints a full-screen explanation naming the real cause.
Loss and restore are handled per the rule above — no preventDefault(), the frame loop stops drawing into a dead context rather than throwing once per frame, and sign textures are invalidated on restore because they died with the context. Verified by forcing it through WEBGL_lose_context: frames froze at 74 with "the GPU context was lost" on screen, resumed 74 → 84 on restore, and __read/__voice were still alive throughout.
renderer.dispose() + forceContextLoss() now run on pagehide. Without that a reload leaks its context until the GC gets to it, which is how a page talks itself into the block above: reload often enough and the contexts outlive the pages that made them.
POST /soak + tools/soak_report.py"Flat over 24 hours" was an assertion with nothing behind it. It now has a measurement path, which is not the same as having the result.
The page samples renderer.info (geometries, textures, programs, calls, triangles), frame-time percentiles for the interval, and performance.memory when Chrome offers it, once a minute, and posts them. The bridge stamps arrival time and appends a line to .ravio/soak.jsonl — the page's own uptime_s is recorded but never trusted for order, so a tab that suspends and resumes cannot reorder the log by lying about its clock. A KEEP allowlist bounds what a sample can write.
tools/soak_report.py is written to refuse rather than to flatter:
A span shorter than --hours is reported short and never extrapolated. Eight good
hours are eight hours, not "on track for 24".
Growth is measured within one page load. uptime_s going backwards is a reload,
and a reload that resets the heap is not evidence the heap is stable.
Frame timing comes from foreground intervals only (hidden_frac < 0.05).
Samples are grouped by `sid`, minted per page load, before anything else.
A per-hour slope fitted over minutes is withheld rather than printed.
Two findings from the first fifteen samples, both about the instrument.
The log had two writers. A second viewer had the page open, and the two uptime_s clocks interleaving in one file read as a single page reloading every minute — the report duly averaged a 7 MB heap against a 33 MB one and called the difference growth. Hence sid. Two viewers is not a bug; a measurement that cannot tell them apart is.
Then it cried wolf. With that fixed, three samples over two minutes produced heap_mb GROWING +31.22/h — arithmetic, not evidence, from a metric that sawtooths with GC. Refusing to extrapolate a short span has to work in both directions, so slopes are now withheld below 10 samples / 0.5 h and the row reads moved, span too short.
That first rule earned itself immediately too. The opening sample came back visibility: hidden, hidden_frac: 1, ms_p50: 999.9 — a pane that is not displayed runs at 1 fps, because rAF is suspended and even the 100 ms watchdog is throttled to a second. Averaging those intervals in would have flattered the result in exactly the direction the claim wants. It also means the frame-time half of this section cannot be earned headless; the memory half can, at 1 fps, but a leak driven by per-frame allocation will surface ~60× slower than it would on a displayed pane.
An exit opens when a lane goes active — a worker dispatched, a gate fired, a rung moved. Event-driven, not a fixed carousel, so the road offers you a turn toward whatever just started mattering.
The advance-warning sequence is what makes an exit legible, and it is free given the milepost model — signs at m − 24, m − 12, m − 4:
EXIT 3 · PULSE ¾ MILE
live_local ↑ 150
Spacing. At SPEED_U = 15 and S = 30 you pass a sign every 2 s. With 8 lanes, exits at sign cadence would run the whole portfolio in 16 seconds. Exits use a coarser scale: minimum 64 mileposts (~2 min) between them, which also makes the warning sequence meaningful rather than decorative.
Taking an exit is auto-navigated (§13 — nobody is at the wheel). Policy, in order:
A lane whose rung just changed always wins — including a retraction.
Otherwise the lane with the largest evidence rate magnitude.
Otherwise the lane with a live worker and the oldest last-visit.
Otherwise stay on the mainline.
Rule 1 exists so that descents are never skipped. A world that only shows lanes climbing is a lie by omission, and invariant 6 forbids it.
Workspaces (core, substrate, products, compose, academy, research, showcase) are stretches of highway with their own palette and sky treatment. The existing palette() (L245) already switches on SEG for travelmonstr/telefoldal — seven-way instead of two-way, same mechanism.
But a lane row carries no workspace of its own. Verified against the live console on 2026-08-04: state_payload() returns a lane's name, repo, path, branch, backend, env_id, bound, running, stage, mode, dispatch_count, last_dispatch, ratings, tasks — and no district. The console shows exactly one workspace at a time and names it at the top level (state["workspace"], "substrate" when M1 ran, exposing 4 lanes: TRAAVIIS, TRVM, WRL, WRLM).
⇒ The road is in one district at a time, and it is whichever district the console is in. Crossing a district border would mean POST /api/workspace/use — a write, which invariant 5 forbids the renderer from doing on its own initiative.
This is a better design than the one it replaces, and it should stay: the world shows you where you actually are. Driving into core is an act, taken at the dashboard (§10), not a scenic transition the renderer decides to perform. The seven-district highway is a §16 question, not a v1 feature.
The direction field is 8 authored fields (DIRECTION_FIELDS, amp.py:2655): for · thesis · claim · bar · unknown · not_for · after_bar · represents.
These are the perfect billboard ad copy — they are literally the lane's pitch for its own existence, written by hand. thesis on the approach, bar on the exit ramp (the test it must pass), unknown on the way out (what it still doesn't know).
There is a sharp irony worth preserving in the design: GET/POST /api/lane/directions existed with zero frontend callers — the one record shown to every proposer and scorer was the one nobody could see. Putting it on a 40-foot billboard is the strongest possible correction.
A scrollable panel owns its own wheel. The wheel handler scrubs the journal and called preventDefault() on every event, so a wheel over a list you had deliberately opened scrubbed the road and the list under the pointer did not move. Measured: eight notches over the history panel moved the odometer 0 mileposts; the same eight over the sky moved it 585 → 602. Reported against the history panel, but #picker is overflow-y:auto with a max-height too and had carried the defect for longer — which is why the test is "can this element actually scroll right now" rather than a list of the two panels known about today.
The sky is the stick. The keyboard flies the ship and the keyboard only works while the document holds focus — which the embedded console takes for good the moment you click into it, a trap already recorded against the flight controls. The largest surface on screen did nothing at all. Press on empty sky and pull, and the offset becomes the same tx/ty the keys produce, through the same acceleration, damping and soft walls.
It is a stick, not a handle, and that is the same judgement the scrub already made: dragging the ship's position would put it through the corridor wall in one frame. With flight, the value was never the problem — the derivative was. Full deflection is 140 px of pull; the drag sums with the keys and the sum is clamped, so two inputs agreeing cannot exceed one at full deflection.
Three things it must refuse, each of which was built deliberately rather than discovered:
It arms on empty sky only. An instrument is a control and a sign is a thing you
open; starting a drag on either makes the click that follows ambiguous, and the ambiguity would resolve differently depending on how steady your hand was.
A drag does not open a page. A drag ends in a click on the canvas, and opening a
page because you let go of the stick is the most annoying outcome available.
The suppression is armed fresh on every press. A drag released over another window
ends at blur and no click ever follows, so a flag that only cleared when consumed would sit there and eat the next real click on a sign — a dead cockpit with nothing on screen to explain it.
Signs lean out on hover — scale 1.07, eased at dt * 9. Small on purpose: these are 84 px Arial Black plates sized to survive an encoder at 90 px wide (§6.3), so a big pop would be a legibility change as much as a hover state. Scaling the group takes the glow with it, and the raycast reads the world matrix, so the target grows along with the sign.
The top centre belongs to whatever you have chosen to look at. It took three passes to say that in one sentence, and the first two fixed real collisions that were not the reported one.
Pass one — the flash. The voice plate and the flash message were each absolutely positioned at a fixed percentage, top: 2% and top: 12%. Fine while the plate was an empty 40 px rectangle; not fine once it had contents. Measured at 1280×577 the plate runs 12 → 336 px and the flash landed at 69, through the trail line and the buttons. A percentage is a guess about a height. They are one flex column now (#topstack) and the flash sits under whatever the plate currently is, open panels and all.
Pass two — the reading panel, which is what was actually reported. #readbox is placed on the road's projected rectangle, which is the middle of the frame, and the plate runs down the same frame from 12 px. With the history open they overlap outright; with it closed there were 12 px of clearance that the panel's own geometry moves through as the road does — a collision waiting on where the road happened to be, which is why it looked intermittent.
Pass three — leaning in. At look = 1 the monitor and its action rows rise into the top centre, and pressing V laid the caption straight across the console. The threshold is 0.35, not a new number: it is the same one hitTest uses to decide the screen is aimable, so the plate clears the monitor on exactly the lean where the monitor becomes the thing you are working with.
Pass four — stop enumerating and start measuring. The three passes above are three guesses at a list of states, and the list was wrong every time, each time in a state or at a window size that had not been tried. The fourth report was the plate against the dashboard monitor with the history panel open: the plate is top: 2% with its height set by its contents, so opening HISTORY takes it from ~140 px to 400–730 px, and the dashboard is scaled by width — so on a wide window the monitor rises to meet it. At 1280×577 the plate reached 403 and the monitor started at 340. Neither look > 0.35 nor any other condition on the list had anything to say about that, because the collision is between the plate's own content height and a canvas rect, and no list of modes describes it.
placeTopStack() now asks the geometry instead:
A page or the TV owns the whole screen, so the plate hides. That is the "you are
reading, not flying" case and hiding is the right answer.
The monitor is a ceiling, not a trigger. The first version of the measured pass
hid the plate whenever the monitor touched it — which meant pressing HISTORY made the history vanish at 2560×900. Worse than the overlap it avoided. The scrollable panel is now given the room that actually exists (avail − rest, solved against the stack's current height so it stays right as the caption rewraps) and scrolls inside it.
Only when there is no room at all — leaned in, monitor at the top of the frame,
avail < 96 px — does the plate go.
The monitor's rect comes from the same tvRect() projection placeTv() pins the iframe to, so the drawing and the test cannot disagree. It is worth naming why this was hard to find: the monitor is painted on the canvas and has no DOM box, so every scan of the document for overlapping elements came back clean at every size.
Verified with both panels open at 1280×577, 1600×700, 2560×900, 2560×1080, 1920×1000, 2560×1400 and 3440×1440 — plate shown and clear of the monitor at every one, all 16 history rows present and scrolling — plus hidden for the lean, for a page, and for the TV, and visible again after each.
Pass six — the actual answer, and it was in `dash.js` the whole time. The box under the caption is the top-centre pill — TRAAVIIS · <LANE> IS ON, the milepost in 52px Arial Black, HOLDING STRAIGHT · quiet — the milepost readout that sits where the year used to be. It is painted on the dash canvas at design-space (700, 40, 520×92), dead centre at the top, which is exactly where the voice plate was put.
Two bordered boxes in the same place, one of them with no element to find. That is why six rounds of scanning the document came back clean at every window size: the pill has no getBoundingClientRect, no computed style, nothing to enumerate. It is pixels.
It is also aspect-dependent, which is why it never appeared in testing. The dash is drawn in a 1920×1080 design space mapped by s = W / DW, so at 16:9 the whole design fits and the pill lands at y 40–132. At the 2.22 aspect of the test window the dash overflows the top and the pill is at y −55 — off-screen entirely. Every measurement taken here was in a window where the thing being reported did not exist.
pillRect() is exported now, on the same principle as monRect/tvRect: the code that draws it and the code that avoids it read one definition. placeTopStack() puts the plate 10 px under the pill's measured bottom, so they are a column — which is what was asked for six messages ago — and the plate rides up with the pill as the lean lifts the dash, falling back to the CSS top: 2% at aspects where the pill is off-screen.
Verified at 1920×1080 (pill 40–132, plate 142–253), 2560×1440 (pill 53–176, plate 186–297) and 1280×577 (pill off-screen, plate at 12), plus a full lean sweep in both directions: gap holds at 10 px throughout, no overlap at any point.
Both were built on misreadings of the same screenshots, and both are behaviour changes that were never asked for. They are defensible on their own merits and are kept for now, but they are not what fixed the reported bug and should be reverted on request.
The box overlapping the plate was read as a billboard. It was not — but the investigation did turn up something real: boards run all the way onto the camera plane and clip through it, so in the last stretch a sign is larger than the frame and is no longer a sign at all.
Boards run all the way onto the camera plane and clip through it. In the last stretch a sign is larger than the frame, so it is not a sign any more; it is a bordered rectangle filling the top of the screen. No amount of DOM scanning could ever have found this, which is exactly why four passes missed it: a sign has no element, no rect, no getComputedStyle — it is geometry, and the document knows nothing about it.
The fix is a pass-by fade, and it is stated in how much of the frame the sign covers rather than in raw distance — that is the quantity that decides whether it is a sign or an obstruction, and unlike a distance it does not change meaning when the window is reshaped. Full strength up to a third of the frame, gone by three fifths, off the same signFillDistance() solve the zoom stops at, so the two cannot disagree.
Measured over 400 samples of flight, peak opacity per fill bucket:
| frame fill | 0–30% | 30–40% | 40–50% | 50–60% | 60%+ |
|---|---|---|---|---|---|
| max opacity | 1.00 | 0.69 | 0.63 | 0.22 | 0.00 |
Nothing is lost by it: closer than that a sign cannot be read anyway, and the clutter it adds is what §6.3 spends its length arguing against. The board you flew to is exempt — the zoom deliberately parks inside this window (verified at 81% fill, opacity 1), and dissolving the sign you closed on would be the bait and switch openAd() already refuses. Faded boards also drop out of the raycast: three.js hit-tests a mesh handed to it directly whether or not it is visible, so without that you could click a sign that was no longer there.
Two numbers to turn if the fade is wrong in either direction: the 0.60 and 0.35 passed to signFillDistance() — gone-by and full-until, as fractions of the frame.
Verification honesty: the fade law was measured — opacity against projected size, which needs only distance and panel geometry. Where a sign lands on screen was not: project() reads matrixWorldInverse, which three.js refreshes while rendering, and on a box with no GPU nothing renders. The probe forces the matrices current so it answers the same headless, but the position half of it is unverified against a real frame.
The corner panels stay up throughout: they do not reach the middle, and knowing which lane you are stopped in is worth having. It is the top centre that is contested, because that is where a page goes.
It hides #topstack and not #say: visibility: hidden still reserves the plate's 300-odd px inside the column, so hiding the plate alone would put the flash a third of the way down an otherwise empty screen — and it is visibility rather than display so the box stays measurable while hidden, which the geometry test depends on.
The monitor glows under the pointer — as an instrument, not as a page. It is deliberately not on the open frame: a page you are already reading does not need to be told it is under the pointer. Drawn in dash.js as a shadowed stroke outside the screen clip, plus a brighter top stop on the glass gradient, so the whole panel responds rather than just its outline.
The glow needed `hitTest` changed, and the first version of this entry was measured in the wrong place. monitor was gated on look > 0.35, so the screen was only aimable while leaning in — and the first measurement was taken there, on one of the few pixels the action rows do not cover. Measured in the state you are actually in most of the time, flying, at look = 0: monitor appeared 0 times across the whole frame, and the glass returned lookToggle like the rest of the dash face. Pointing at the screen did nothing and hovering it could not light anything up, which is what was reported.
The lean gate is gone: the screen is aimable at any lean, 0 → 2304 hit points while flying, and the glow measures 18 → 209 peak blue on the edge. The rows stay gated, because the rows are only drawn while leaning in. This does change what a click does — clicking the screen while flying now opens the lane page instead of toggling the lean. The rest of the dash face still toggles it, and a glow on something a click does not open would be an affordance that lies.
#tvbar — the address bar, and why it is not on the canvasThe framed page had no chrome and nothing on screen said what it was. That is worse here than in a browser, because these pages are proxied: they navigate like local pages, so cockpit.tv.url is the address that was dialled and stops being the address you are looking at after one click.
The obvious home was the dashboard's existing header — the traaviis.com + ON AIR strip above the screen rect. Measured, that position is unusable while a page is up: at full lean and 1280×577 the screen rect starts at y = -106, so the header line lands at -122 and is never drawn; and anywhere lower is behind the iframe, which is DOM at z-index 9 against the dash canvas at 6. So the bar is DOM, positioned by placeTv() off the same rect the frame is pinned to, clamped to top ≥ 0 — an address bar you cannot see is not an address bar. Clamped, it lies over the page's own top edge, which is why it takes no pointer events.
It shows where the page really lives, not where the bridge keeps it: /ext/<host>/… is un-proxied back to https://<host>/…, because putting our own plumbing in the address bar would be showing the proxy as if it were the site. The hash is included — on a one-page anchor site, which code.traaviis.com is, it is the only part that ever changes, and a bar that never moved on the site it was built for would read as broken rather than static. Verified following the frame through an anchor click and a path navigation.
The console gets the same treatment, and needed the bridge's help to get it. /console first read amp console · / — a label where an address was asked for, and you cannot copy a label into another window. server.py binds 8787 and bumps to the next free port, so only the bridge knows which one answered; it now publishes console_base in world.json and the bar reads http://127.0.0.1:8787/.
It is an `<input>`, and it goes where you type. Editable, select-all on focus, Enter to navigate — the frame, not the tab, and to the proxy path, because leaving the bridge's origin would end the same-origin access the bar depends on to read the address back. proxyForUrl() is the inverse of the un-proxy: a bare host gets https://, a console_base address becomes /console/…, anything else becomes /ext/<host>/….
Two things it must do that are not obvious:
Stop writing to the field while you are typing in it. placeTv() runs every frame;
writing the frame's address in unconditionally overwrote each keystroke as it landed and the bar could not be edited at all.
Refuse what is not an address. new URL() is far more permissive than a browser bar
— it accepted the sentence "what should we do about 3 and 7 west" and percent-encoded it into a hostname, giving https://what%20should%20we%20…/: a plausible-looking address for a navigation that could only fail, with nothing said about why. The hostname now has to be a dotted name, localhost, or a bracketed IPv6 literal. Verified across eight inputs; the four malformed ones flash a refusal and navigate nowhere.
No border, no radius, no focus ring. An address bar is chrome, and an accent frame around the top of every page is a second thing competing with the page for attention.
The proxy only rewrote root-absolute links. href="/x" became /ext/<host>/x, but a
fully-qualified https://<host>/x — which pages commonly use to link to themselves, and which code.traaviis.com does — passed through untouched and sent the frame out of the bridge's origin. That loses same-origin, which is the entire reason for proxying: the frame's own fetches start failing and the cockpit can no longer read its location, so the bar freezes on whatever was last dialled. Now rewritten for every allowlisted host; a link to the rest of the web stays a link to the rest of the web.
Off-origin fell back to the dialled URL. Silently showing the address you *asked
for* while the frame is somewhere else is worse than showing nothing: it is wrong with complete confidence. The bar now empties and says a page outside the bridge — its address cannot be read.
Re-picking the same screen could not recover. The stray navigation happens inside
the frame, so tv.src is never touched and dataset.url still matched the screen being re-picked — nothing was re-set and the screen stayed stuck on the external page. A one-shot tvForce on an explicit pick fixes it. It is deliberately not a per-frame "is it off-origin" test: placeTv runs every frame, and re-assigning src mid-load would restart that load forever.
The amp console is a genuine exception and not a bug. It is a single-page app that navigates by calling select() and showTab() — verified: clicking its tabs leaves location.href completely unchanged. There is no URL for the bar to follow, and inventing ?lane=&tab= would be fabricating an address that does nothing if pasted.
Defect introduced and removed in the same pass, recorded because the class is nasty: the off-origin sentinel was first written as a string containing a literal NUL byte, which made index.html read as binary to grep and every other line-based tool — searches silently returned nothing. It is an object compared by identity now.
Clicking a billboard flew the road first, then turned for the sign. holdT eases the forward travel to a stop at rate 3.5 — about 300 ms — and zoomT eases the dolly in slower still at 2.2, deliberately, because a fast dolly onto a sign reads as a jump cut. For that first third of a second the ship was therefore still running down the road while the dolly had barely started: two movements, where the click promised one.
A sign read now stops the road travel at once (holdT = 1). Measured over the 600 ms after the click, forward drive advance: 0.0 at every sample, against 0.3 → 1.3 for a road read. Road and dash reads keep the eased stop, and should: there the deceleration is the transition — the road folds up as you slow onto it — and nothing else is moving for it to disagree with.
Then the approach itself was in the wrong order: across, then in. The distance close and the lateral alignment both ran on zoomT, so the ship began sliding sideways while it was still far out — and far out is exactly where the sign field is densest, because every other billboard is still between you and the one you picked. You crossed the field diagonally and flew through the signs on the way.
The sidestep is now held back to the last 45% of the zoom (LAT_FROM = 0.55, smoothstepped so the alignment eases in rather than announcing itself as a second movement). Closing the distance first keeps the ship in the corridor, where there is nothing to hit, and leaves a short step across at the end once the neighbours are behind you. Measured on a real click: zoomT 0.23 → 0.56 with ship.x at 144.0 → 144.3 — no lateral motion at all — then the crossover to 330 (the sign's own x) over the back half.
Not established: that nothing is clipped on the way in. This was measured as ship position against zoom progress, on a machine with no GPU — the geometric argument is that the corridor is empty and the signs are beside it, not that a render was inspected.
Then the sidestep itself was janky at the end, and the form was the reason. ship.x += (target - ship.x) * rate is a per-frame lerp, and the rate was itself ramping to 1 — a rate of 1 means "cover the whole remaining gap in this frame." So the last stretch was not an ease at all: the ship crawled, jumped the remainder in a frame or two, and stopped dead. The bank is derived from the lateral rate, so the roll spiked with it. It was also frame-rate dependent: a per-frame factor with no dt in it covers more ground per second the faster the machine draws.
It now interpolates absolutely between two fixed endpoints — the ship's position when the sidestep began, and the sign — on the same smoothstep. The board holds station while zoomed, so both ends are fixed and the path cannot wobble. Measured lateral speed through the step: 6 → 155 → 300 → 354 → 370 peak → 356 → 296 → 188 → 76 → 12 → 2, landing exactly on the sign's x. A bell, with no spike at the end.
The billboard's amber survived into the next panel. openSign() put an ad class on #readbox for the advert dressing, and openReading() cleared inspect and not ad. So opening an advert, closing it, and clicking the road gave you the road's contents wearing the billboard's amber background and header — the sign's border, still up over the road.
The fix is not "also remove ad there". Every opener was cleaning up after the openers it happened to know about, and that arrangement produces exactly this bug once per new opener. One dressBox('ad' | 'inspect' | 'plain') now sets every class on every open. A toggle per class cannot leak; a remove-the-one-I-know-about cannot help it.
lookToggle outranks the sceneHover and click both require hitTest() to return nothing, and at 1280×577 the lookToggle band covers y ≥ 334 — 1566 of ~4900 sampled points, including every single point where a sign was pickable. At that window height a sign cannot be hovered or clicked; the click toggles the look instead. Flying the ship to the corridor floor lifted the signs clear and 4117 sign points became reachable, which is how the hover was measured at all.
This is pre-existing and not a consequence of the hover work — but it means "click the sign to open it" (§6.4, and the commit that named it "the thing you click is the thing that becomes the page") is height-dependent in a way nothing states. Either lookToggle should yield to a scene hit, or it should stop claiming a third of the frame. Not changed here, because it is a change to what a click does and that deserves its own decision.
The odometer was a number, and a number is the one readout that cannot be wrong and cannot be useful. 1,043 says how far without saying where the work was, how much of the road behind you is empty, how far back the journal even reaches, or how to get to any of it. And every other control on the ship is relative — a held key, a wheel notch, an impulse — so "put me at 812" was not expressible at all.
Clicking MILEPOST (or pressing O) opens a strip under it: the overview and the absolute control in one instrument. That pairing is what every scrubbed timeline converges on — an overview readable at a glance, a preview under the pointer so you can aim without committing, and a pick that moves you there.
One column per CSS pixel over [oldest held … head], coloured by the loudest kind in the column — act > direction > note > unknown > board > state > quiet. Max, never mean: with §8's measured 79% quiet an averaged column erases exactly the act you are hunting for. A column holding more than one recorded row gets a white cap, so a busy stretch cannot masquerade as a single event.
Three states, and they are three, not two:
| Mark | Means |
|---|---|
| coloured tick, 2px, height by kind | a row the harness recorded |
| dim band, 1px | quiet — held, and nothing happened. §8: a real answer |
| faint hatch | not held — world.json returns a window, and before it there is road we have no record of |
Collapsing the last two would be invariant 6 in a costume. The hover card keeps the same distinction in words: "quiet — the harness recorded nothing here" versus "outside the journal window — no record held".
Coverage was first decided by bucketing rows into columns — a column with no row in it was drawn as not-held. That is a different question than the one being asked, and it breaks the moment the strip is wider than the range is long: at 300 mileposts across 430 pixels roughly a third of the columns receive no row at all, purely from rounding, and a journal with no gaps in it rendered as a dotted line. A hole in the data that was really a hole in the arithmetic — the worst available defect for an instrument whose whole job is to say what is and is not known.
Coverage is now decided per milepost (tot[]/hav[] over lo…hi), and a column covering no whole milepost inherits its neighbour rather than claiming a gap: between two held mileposts there is no road missing to report.
| click a tick | fly there — snapped to the nearest recorded column within 4px, because a 1px tick is not a 1px target for a hand |
| drag | scrub |
| double-click | open the same reading page the road opens. One page, three ways in |
№ field | go to an exact milepost, or be told why not: "№ 99999 is outside the journal · 780–1080" |
| `,` / `.` | previous / next recorded milepost |
| O / Esc / click away | open, close |
,/. are the reason the tape earns its space. Measured on the live journal, 854 of 1080 rows are quiet, so "next milepost" is four times out of five a walk to another blank sign. Stepping between things that happened is what makes the road navigable rather than merely scrollable.
seekTo is eased through flyScrub(), at dt*7 — or dt*24 while the pointer is still down, because then the tape is a scrubber and your hand is the derivative. This is the same lesson §7.1 already records against the held keys: the value was never the problem, the derivative was. Two consequences that are not optional:
The glide returns its delta through flyScrub() so `drive` moves with it. Writing
shipM directly slides the ship along the journal while the scenery stays put, and the road you come back out to belongs to the milepost you left.
Any hand on the scrub controls cancels the glide. Otherwise picking a milepost and
then reaching for the wheel is two controls pulling the ship in opposite directions and neither of them wins.
The pending pick is drawn as a hollow pink bug on the same scale as the live cyan ship marker — the altitude-preselect idiom — so the gap between asked for and at is a distance you can see. The head is a solid amber wall labelled NOW: the newest milepost amp has written, and the one the ship is never allowed past (invariant 3).
The legend lists only the kinds this journal actually contains. A fixed legend advertising six categories when the road has three reads as "we looked and found none", which is a measurement nobody made.
The behaviour was right and the panel still read as a fault. All four are the same shape: a rule written for a corner readout, applied to a panel four times wider.
`.pick` was the wrong selector. The picker was bound to querySelectorAll('.pick'),
but .pick is the dressing — dashed underline, pointer, hover tint — and the odometer wears it now too. The milepost readout therefore called openPicker(undefined), which fetches the district list and hangs it under a panel with no room for it. Bound to .pick[data-open]: a control is identified by what it names, never by how it is painted.
Right-aligned rows left ~300px of empty panel. MILEPOST, the value, ALT and the
speed were all written to hug the right edge of a corner box. Opened, they hung on the right of a 430px panel with a void beside them — the single biggest reason it looked broken. #odohd / #odosub span the width while .tapeon is set, label one side and value the other; closed, the corner readout is untouched, because it was never wrong.
The strip needed a well. 97% quiet means an unbacked strip is one hairline and four
ticks floating in panel background — it reads as an instrument that failed to load, not as a quiet road. A backed, rounded well says "this is the extent, and it is mostly empty." The outline is an inset box-shadow, not a border: clientWidth/ clientHeight exclude a border, so a 1px one silently shrinks the drawing surface to 428×56 while the paint code still lays out against 430×58.
A CSS rule cannot beat a per-frame inline style, at any specificity.
#cockpit.tapeon #topstack { top:238px } is (2,1,0) against #topstack's (1,0,0) and it lost every frame, because placeTopStack() writes style.top off the pill's measured rect on every tick. Nothing in the DOM looked wrong; the rule was simply never in the running. The clearance belongs in that function, beside the decision it is part of — which is also where §7.2's own lesson already points: a percentage is a guess about a height; a column does not have to guess.
#topstack is centred at min(66ch,74vw) and the opened odometer is right-anchored, so whether they meet depends on the viewport: at 1280×577 they overlap by 126px (two bordered rectangles, the exact defect §7.2 records), and at 1920×1080 they clear each other with 190px to spare. Narrowing the card cannot fix the first case — at 1000px there is no clearance to be had at any width.
So placeTopStack() tests the rects and drops the card to odo.bottom + 10 only when they overlap horizontally. Measured: card top 12 → 213 at 1280, unchanged at 142 at
A function that dropped it unconditionally would be inventing a collision to solve.
tools/dial_check.py measured the real tempo off disk. The numbers are the most consequential in this spec:
| Measurement | Value |
|---|---|
| Change-feed rows, busiest day (2026-07-30) | 593 → 24.7 mileposts/hour |
| Change-feed rows, median day | 372 → 15.5 mileposts/hour |
Reviews carrying a ladder entry, all time | 40 |
| Rung movements per day | ~7, and most reviews hold the rung rather than move it |
| Days idle at time of measurement (2026-07-31 → 08-04) | 4 |
Road capacity at S=30, SPEED_U=15 | 1800 mileposts/hour |
The road was specced to run at about 1% of its capacity. At the original constants a sign passes every 2 seconds while a real change arrives every 2.4–4 minutes — so roughly 98 of every 100 signs would be a quiet marker.
It does not license adding curvature to compensate. That is precisely the deleted idle sway, re-introduced with better manners. A mostly-straight road is the truth about this repository's tempo, and invariant 1 exists to make that visible rather than hidden.
It does not license slowing the car down. Crawling at 1.5 u/s to make signs feel frequent would trade the one thing the existing renderer does beautifully — the sense of motion — for nothing.
S, not the speedSign spacing is a free parameter; sign cadence is what the content rate constrains.
const S = 300; // was 30. 10x, derived from measurement, not taste.
At SPEED_U = 15 this gives a sign every 20 seconds — a watchable cadence that matches the measured content rate, while the car still moves at full speed. The car feels fast. The signs are rare because the work is rare. Both statements are true and the viewer can see both.
Consequences to carry through: L = S * PER grows with it, so the visible road is 3000 units deep instead of 300 — curveX's depthAhead² term and the fog/far-plane need re-tuning together, and billboard panel scale must grow or distant signs become unreadable (§6.3). RIB_SEG = 96 over 10× the length is a coarser ribbon; expect to raise it.
Most of the time nothing is happening — including, at time of writing, four consecutive days. This is the case that kills ambient streams, and invariant 3 forbids inventing activity to fill it.
It also sharpens §13's cadence decision, which is now ratified: the stream runs when the harness runs. A 24/7 stream of this repository would have been four days of quiet markers. That is not a stream, and no amount of rendering fixes it.
Speed is the backlog readout. Not constant:
speed_u = 15 * (0.35 + 0.65 * Math.tanh(backlog / 8));
Idle ≈ 5 u/s — a slow cruise. Busy saturates at 15. The speedometer becomes an honest instrument: how fast the work is actually landing. And it satisfies invariant 1, because backlog is on the dash.
What signs show during quiet, in preference order — all true, none fabricated:
Standing state. PULSE · live_local · 42 goals closed — a fact about now.
Open unknowns. The unknown direction field. A lane that doesn't know something
is interesting, and this is where the eight-field change earns itself.
Blockers. observations() (amp.py:17078) and per-lane lane_flow blockers.
Mileposts. — 48,200 —, plain. Distance travelled is a fact.
Never: fake commits, fake progress, decorative "activity."
What is deliverable, and what is not. This needs stating plainly because the obvious version does not work.
| Source | Verdict |
|---|---|
Local video file (Travelmonstr .film.mp4, RAVIO hour cuts) | ✅ THREE.VideoTexture. Solved, cheap, correct. |
amp preview child server (127.0.0.1:88xx) | ✅ via Electron offscreen rendering (§9.2) |
| Our own DOM (dashboards, diffs, logs) | ✅ HTMLTexture if available, else Electron OSR, else 2D overlay |
| YouTube, or any third-party site | ❌ Not deliverable as an in-world texture. See below. |
Why YouTube can't go on the screen. Every path fails for a different reason: HTMLTexture (three.js r184+, Google I/O 2026) explicitly excludes cross-origin iframe content for security — it draws your own DOM. CSS3DRenderer can show a real cross-origin iframe, but it renders real DOM in a separate layer with no depth compositing against WebGL — your road, sign posts and car cannot occlude it, and fog and post-processing are out. That is disqualifying for a drive-in screen that the road passes in front of. The entire html2canvas/foreignObject family cannot render an iframe, external CSS, external fonts, video frames, or execute JS, and taints the canvas on any cross-origin image, which breaks the WebGL upload outright.
So: v1 plays your own films. You have 28 music videos and a 1.09 GB finished RAVIO hour sitting in ~/Projects/Travelmonstr/site/assets/. That is the right content anyway — it is on-brand, it is yours, and it carries no licensing question.
texture.colorSpace = THREE.SRGBColorSpace or video renders washed out.
A `VideoTexture`'s dimensions/format cannot change once used. An HLS bitrate
switch at 3 a.m. breaks the screen and leaks a texture per switch. Watch videoWidth/videoHeight, dispose and rebuild on change.
Poll currentTime. The silent failure is readyState staying healthy while time
stops advancing.
Always await video.play() and handle rejection — a silently rejected play()
is a black screen for three days. Launch with --autoplay-policy=no-user-gesture-required.
Don't feed 1080p to a screen occupying 200 px. 480p is plenty and the output stream
smears it anyway.
preview.py already gives a lane's build a real origin — a child server on its own port, framed by origin rather than proxied under a path (the docstring explains why: absolute asset paths only resolve at an origin root, and dev-server live-reload sockets connect back to the origin they were served from).
GET /api/preview?lane= returns {url}; GET /api/preview/stamp?lane= returns a cheap "<filecount>:<newest_mtime>" fingerprint designed to be polled — reload the texture when it changes.
Render it with Electron `offscreen: true` at 15–30 fps via the paint event. Do not enable useSharedTexture initially: it requires a native node module, and CVE-2026-34764 is a use-after-free in its `release()` callback that dereferences freed memory in the main process. A drive-in screen does not need 60 fps.
The request is for the dashboard to expand to fill the screen, be lookable-around, and have controls that are really the editor's controls.
The honest v1: expanding leaves 3D. The cockpit dash stays a 2D canvas painting in world (as it is today, drawDash L303, already reading real gauges). "Expand" fades the 3D scene back and brings up real DOM on top of the canvas — actual amp console panels, in a browser, with working buttons. No texture, no raycast, no hit-test translation, no origin trial. It works today, in every browser, forever.
HTMLTexture with the InteractionManager addon (which computes a CSS matrix3d each frame so the browser does hit-testing, hover, focus and input natively) is the seductive version — DOM on a curved dash surface you can look around. It is genuinely first-party in three.js (PR #31233). But it is origin-trial-gated (M148–M150 or M151; Chrome stable is already 151, so the trial may have expired) and it is unavailable in OBS Browser Source entirely, which still ships Chromium 127. Treat it as an upgrade to attempt after v1 works, never as the foundation.
Writes back to amp go through the bridge (invariant 5), which holds the token.
Do not hand-pick the control list — amp already enumerates it. Verified in M1: GET /api/flow?lane= returns, per stage, a state (clear / blocked), a blockers list, and an actions array where each entry is:
{ "id": "explore", "label": "Look for somewhere to go",
"post": "/api/direction/explore", "body": { "lane": "TRAAVIIS" },
"why": "one architect call across this lane, proposing what is worth doing",
"primary": true }
A route, a body, a human label and a justification — everything a button needs, written by amp, kept in sync by amp. The dashboard renders flow[].actions[] and posts the post/body verbatim. A hand-maintained list in RAVIO would drift the first time a stage gained an action; this cannot.
The why string is also the narration line for that control, free.
Every write emits its own milepost row, so an adjustment made from the dashboard appears on the next sign. The world records that you touched it. That is the correct behaviour and it is also the best demo of the whole concept.
Every write emits its own milepost row, so an adjustment made from the dashboard appears on the next sign. The world records that you touched it. That is the correct behaviour and it is also the best demo of the whole concept.
The instrument panel that opens from a vacuum tube (1–7, or a click) had the right contents and the wrong shape. Every fault was width.
| before | after | |
|---|---|---|
| panel | 2.2% inset — 1836×1032 at 1920 | capped 1180×1000, centred |
| prose measure | ~250 characters to the line | ~55 in a card, 76ch for the why |
| 23 records | 23 identical full-width rows | cards in a minmax(330px,1fr) grid |
| record body | 11px ui-monospace | 12.5px proportional |
| short view | 280px of dead panel | body sizes to content, briefing takes the slack |
The panel cap is in panelTarget(); it is a cap on the panel, not on the type. Chips, cards and controls all want the width — only running text has to be held to a column, and that belongs in CSS beside the type sizes. panelTarget() is reached only through morphRect(), i.e. only for readSource === 'dash', so this changes the tube inspector and nothing else: the road read uses readRect() and the billboard uses signRect().
Five findings worth keeping:
A cap is right for the long view and wrong for the short one. #readbody was
flex:1 1 auto, so it claimed the slack unconditionally — and evidence on a lane never judged past spec (the common case) printed four chips and a button row, then held 280px of empty panel open above the conversation. max-height:74% + flex:0 1 auto on the body, flex:1 1 auto on the briefing, is right for both.
Monospace was costing more than it bought. sub carries prose at least as often as
a path; "The [&] stack is one bet, stated three ways" was being set in 11px ui-monospace. Nothing in a card is column-aligned, so the fixed advance bought nothing.
Hover is not an affordance. A drillable record announced itself only by tinting
under the pointer — findable one record at a time, never at a glance. It gets a ›.
Dashed at 1px is not a distinction. Thirteen controls read as thirteen of the same
kind of button, five of which do something irreversible. Navigating ones now carry ↗, which says leaves rather than acts without touching amp's authored label.
The lane was printed twice, 900px apart — once in the subtitle, once in .at —
which reads as two facts that happen to agree rather than as one fact repeated.
Everything is scoped to #readbox.inspect. The same .sec/.rec/.rt/.rs/.rm classes dress the advert panel, which is deliberately styled as the billboard it flew you to; card chrome there would be exactly the bait-and-switch #readbox.ad exists to prevent. Verified by opening a billboard after the change: still amber, still centred Arial Black, no cards.
PANEL_W/PANEL_H 132×77 → 165×96.25, aspect held exactly so sign.js's 1024×600 texture still maps square. It is the only statement of the size: signFillDistance() solves both the zoom stop and the pass-by fade from it, and signRect() projects the panel from it.
Two consequences that fall out of that, rather than needing tuning:
The reading panel does not change size. The zoom stops at signFillDistance(0.84)
— a distance solved so the sign fills 84% of the frame whatever PANEL_W/H are. So the billboard is 25% bigger as you fly past it, and identical once you are standing in front of it. Same for the pass-by fade at 0.60/0.35.
`SIDE_X` is deliberately not scaled. The corridor is the work's shape, not the
signage's. Half-width goes 66 → 82.5, so the inner edge sits at 247 units — still ~100 clear of the ship's own soft wall at ±144.
Measured on the live world rather than asserted: at 2435 units the projected width is 59px, against 58.6 predicted for 165 and 46.8 for 132. The scene geometry reads PlaneGeometry width: 165, height: 96.25.
Reported as "clicking on the dash opens the controls panel, which is now broken because of the mirrors." The mirrors inherited the fault; they did not cause it.
monRect() took nActions and sized the panel 130 + rows*78. But drawMonitor() renders screens.concat(actions) and hitTest() indexes into the same list — so the panel was sized by actions and drawn with screens plus actions. On a lane with screens and no controls, which is exactly what a lane whose console is unreachable looks like, it sized for one row and drew two.
Measured on core/TRAAVIIS: 2 screens, 0 actions. One wrong number, three faults:
| reader | got |
|---|---|
drawMonitor | rows drawn past the bottom of its own panel |
hitTest | a click region that did not match what was drawn |
mirrorClear | the panel believed to stop 78px above where it did |
That third one is the report. The left housing's fitting starts at design y 517; the mis-sized panel ended at 508, so the clearance test passed and the mirror stayed up over a panel that actually reached 586. Proven both ways:
monRows(2 screens, 0 actions) = 2
left clear at look=1: false <- correct: the mirror hides
left clear at look=1, nRows=0: true <- the old number: it stayed
Fixed at the source, monRows(st), so all four readers get one answer.
A second defect was hiding underneath it: `no controls available — the console is not reachable` was drawn at a fixed `m.y + 110`, before the rows, which is inside row 0 — so the sentence printed straight through "the code editor · amp console". It is a statement about the ACTIONS, so it now draws after the rows, in the slot the first action row would have taken, and only when it fits.
traaviis.com is a control — BUILT 2026-08-06The station label on the bezel was a caption. It sits above the monitor rect, so it was outside every hit region — hitTest fell through to "the click was meant for the world behind it" and picked a billboard. It now always opens the controls.
Two things make "always" the hard part, and neither is the hit region:
A destination, not a toggle. lookToggle is the toggle; pressing this twice must not
close what you pressed it to see. A destination that closes on the second press is a toggle wearing a label.
Clearing `cockpit.tv` is the load-bearing half. A page survives closing on purpose —
that is exactly what makes V bring the same one back — so leaning in with one remembered shows the page, not the menu. Without this, the control you pressed to see the controls would hide them behind a website. The menu lists the screens, so putting it back is one click from there.
Measured: load a page, come back out (tv.hidden = true, page still remembered), click the label → pageUp: false and both screen rows on screen.
Offered only when no page is up, matching where drawMonitor draws it — the one state in which the menu it opens is the thing that would appear. And it says it is a control on approach: underlined, brightened, CONTROLS fading in beside it, pointer cursor. A label that acts on click and never says so is the same defect the dials had.
Reported as "it says no controls available, the console is not reachable." The console was reachable. It was answering on 127.0.0.1:8787 the whole time, and the sentence was invented.
Pulling the thread found three faults stacked on each other.
1. The roster was never pruned. World.lanes was written into on every /api/state and never cleared, so every lane the console had ever shown stayed in it wearing whichever workspace was current when it was last seen. Measured: amp's core has 8 lanes and the cockpit offered 12 — the extra four (TRAAVIIS, TRVM, WRL, WRLM) live in ws/substrate/config.json. It is replaced now, not merged. The rest of the portfolio is unaffected: roster() still unions the live set with every config.json on disk, which is where the other 23 lanes and their real districts come from.
This breaks the picker's own stated rule — offering a lane the console cannot act on is offering a control that will refuse.
2. The refusal was thrown away. With TRAAVIIS selected in core, amp answered {"ok": false, "error": "unknown lane 'TRAAVIIS'"}. /controls.json dropped that and returned actions: [] with console: true — the reason was already known and discarded one layer before the thing that needed it. It now carries why.
3. The dash guessed a cause. no controls available — the console is not reachable was one hard-coded string printed whenever the action list was empty, for any reason. It now says which nothing it is:
| condition | line |
|---|---|
console === false | no controls — the console is not reachable |
| amp gave a reason | no controls — unknown lane 'TRAAVIIS' |
| reachable, no reason | no controls published for this lane at this stage |
A cause nobody measured is invariant 6 in the one place a pilot goes to find out what is wrong, and a wrong reason is worse than none — it sends you to look at the console instead of at the lane.
Reported as "the evidence vacuum tube modal doesn't have items that are clickable, it is just a list of file names." Three faults in one list, all measured live:
Not this lane. /api/rulings?lane= accepts the parameter and ignores it —
server.py:3310 calls rulings_payload() with no argument. Asking for KILN returned nine rulings belonging to wrlm, wrl, pulse, trvm, traaviis and docs, and the tube presented them as KILN's evidence. Filtered in the bridge, since the console will not.
A file name is not a title. A row is {name, lane, size, mtime}, so the generic
_first_text titled every record wrlm-cc5f2d669f2.md. Each ruling's own first line says what it is: # GPT-5.6 consult: traaviis, with - model: and - opened: under it.
Nothing to open. A record without a rating was inert, so the most loaded rating on
the dash was a wall of .md you could not read. Each now carries open, /ruling.json serves the text, and the panel renders it as a document — pre, its own line breaks, an 82ch measure. It is markdown amp wrote; reflowing it would rearrange a record.
Fetching each ruling's text is only affordable because the lane filter runs first — a handful of localhost reads, not ninety.
Measured after: TRAAVIIS → 1, WRL → 2, PULSE → 2, KILN → 0. Zero is now the honest answer where nine wrong ones used to be, and the panel already says "no records behind this yet" rather than inventing a list.
Two housings on stalks, mounted at the outer top corners of the hood and extending well above it. Each centres on one of the two big dials (evidence at 176, drive at 1744), because a stalk has to come from somewhere and the dial bosses are the only structure out there. Design space, like everything else, so the pair holds its place through every lean and every window shape.
| left | right | |
|---|---|---|
| shows | this machine's camera | the c u l8er reel |
| source | getUserMedia, video only | real mp4s off c-u-l8er.link |
| default | off | playing, muted |
| controls | click the glass, the bar, or K | click / ⏭ / 🔇, on hover |
Canvas paints the fitting; DOM plays the picture. dash.js draws the housing, the stalk and the label; mirrorRect() hands back the glass in canvas pixels from the same constants it drew with, and placeMirrors() pins a <video> into it. Exactly the arrangement the monitor already uses for its iframe — one layout, not two. They are placed from inside placeTv(), which already decides whether a page has taken the frame, so the mirrors go when the HUD goes from one decision rather than a second copy of it.
Track 1 is 01.film.mp4 and track 2 is 02.film.mp4, which makes "position N is NN.film.mp4" look like a rule. It is a guess checked against two of twenty-eight, and a guess that silently plays the wrong video is worse than a mirror that says it could not find the reel. So both halves come from the site: the homepage publishes a schema.org ItemList of every watch page, and each watch page names its own `<video src>`. GET /culater.json does the 29 requests once and caches to .ravio/culater.json.
c-u-l8er.link is deliberately not added to EXT_HOSTS. The proxy exists so the TV's frame keeps the bridge's origin — the console fetches same-origin and the address bar has to read the location. A mirror does neither; it plays an mp4, which is a plain media load. Fewer things routed through the bridge, not more.
The first discovery returned 27 of 28 and reported ok: true. The missing page (telefoldal/on-and-on) was fine — it fetched perfectly on the next attempt, so it was a transient blip — but except: continue had swallowed it, and the reel would have been short forever with nothing saying so. Three changes, all of them the same principle:
one retry, which is what the failure actually needed;
every skip is named in a missed[] with its reason, and the renderer flashes
reel · N of M videos when the catalogue came back short;
a short catalogue gets a 600s TTL instead of 43200, so a network blip cannot make a
video disappear for the rest of the day.
Re-run: listed=28 got=28 missed=0, 14 per album.
Clicking the track name opens a selector:
| channel | is |
|---|---|
TRAVELMONSTR | 14 videos, album order |
TELEFOLDAL | 14 videos, album order |
SHUFFLE | all 28, dealt at random across both |
W.W.W. RAVIO | Wicked Wide Window Radio Waves — the full hour, from /ravio/ |
W.W.W. RAVIO is its own channel, not a row in `videos`. It is not an album track, and shuffling songs should not deal an hour. It is also discovered differently: its <video> tag carries no `src` at all — the page sets it from script — so the bridge reads the URLs the page holds rather than the tag, and takes both of them. /ravio/ ships a CDN copy and a local one and falls back between them on error; a mirror that only knew the first would go dark exactly where the site would have kept playing, so the renderer does the same fallback before it gives up on a track.
reel (the catalogue) and queue (what is being played out of it) are now two lists. They were one while there was one channel, and keeping them one would mean SHUFFLE had to reorder the catalogue itself — which destroys the album order the album channels are entirely made of. Fisher-Yates on a copy, for the same reason.
Three rules that fall out of having channels at all:
Wrapping past the end of SHUFFLE deals again. Otherwise "random" is one permutation
you watch forever.
A restored channel has to be one the catalogue can fill. A saved SHUFFLE against an
empty reel, or RAVIO with the broadcast down, would restore you onto a dead channel and read as the mirror having failed. The saved position is likewise never restored for SHUFFLE — that queue was dealt fresh this load, so an index from the last one means nothing.
A channel with nothing behind it is still listed, greyed, saying unreachable or
none. Hiding it would claim the site has three channels when it has four and one is down.
The list is placed by placeMirrors() off the same rect everything else in the housing is placed with, so it rides the lean instead of being left at the coordinates it opened at; it flips below the glass when there is no sky above it; and it closes when the housing loses its air, because a menu at z-index 11 for a mirror that is gone would outrank the panel that displaced it.
§11.5 elects the lowest live sid, over a BroadcastChannel. Two things that rule cannot see, both observed on this bridge:
It is blind to whether anyone is looking. A 13.5-hour-old viewer at hidden_frac: 1
with info: null (no GL context, running at the 1fps watchdog rate) held sid 50a7c7ac against the visible window's fb4359bc — so within one browser it would have taken the voice from the window actually being watched.
`BroadcastChannel` does not cross browser profiles. In a different browser it saw no
peers at all, self-elected, and popped /narration.json in parallel — draining lines the visible page never got. That is §11's "N tabs spoke N different sentences and drained each other's lines", in the one configuration the bus was never able to fix.
So visibility is announced on the alive beat, narratorSet() is the live set minus everything hidden, and isNarrator() requires this page to be visible. The bus can only fix the first case; refusing to narrate while hidden fixes both, because it needs no agreement with anyone. visibilitychange announces immediately rather than waiting up to two seconds, and stops a clip already playing — a page that has just disqualified itself should not finish its sentence into a window nobody is looking at.
If nothing is visible, nothing narrates. That is the right answer rather than a gap: narration exists to be heard, and a page nobody is looking at should not be draining a queue that pops.
The reel is muted by default, because a browser will not autoplay audio without a gesture. The first version went further: unmuting the reel muted the narrator, on the reading that §11's ONE VOICE AT A TIME covers any second source.
That reading was wrong. §11 is about two narrators saying different sentences over each other, where the loss is comprehension of both. Music under a voice is the case every broadcast desk already solved — turn one down, not off — and silencing the briefing to hear a song trades away the thing the cockpit is for. Corrected 2026-08-06 on report.
So the reel ducks to 0.18 while any TTS is speaking and comes back after. Fast attack (120ms), slow release (420ms): the voice must not be stepped on, and the music must not lurch back the instant a sentence ends. Ramped in steps rather than assigned, because a hard cut on music reads as a dropout.
The hooks are the two places sound actually starts — audioEl.play() for Voicebox and speechSynthesis.speak() for the browser voice — and end(), which every path exits through, including abort, error, the 90s guard and stopSpeaking(). That is why muting mid-sentence releases the duck instead of stranding the music at 0.18. It also means the duck fires when there is sound, not when there is an intention: a Voicebox line spends ~1s in synthesis first, and ducking for a synthesis that then fails would be ducking for nothing.
Every tab ducks, not just the one making the sound. Only the elected narrator speaks (§11.5), but every tab is playing its own reel through the same speakers — so duck/ unduck go over the same BroadcastChannel the captions use, with a fromBus flag so a relayed duck does not re-announce itself. A tab that dies mid-sentence cannot strand the others: the same 90s the utterance guard uses releases it.
The first version refused to save the sound setting, on the grounds that a browser will not autoplay audio without a gesture, so restoring it would reject play() and leave the mirror not playing at all. That reasoning was about the naive restore, not about the preference — and unmuting on every refresh is a worse answer than either. Saved on report, 2026-08-06.
The preference is trivial; the restore is where the risk actually lives:
the element always starts muted, so playback begins no matter what;
it then tries to unmute and checks whether it survived — Chrome pauses the element in
some paths rather than rejecting the promise, so a resolved play() is not proof;
if the browser refused, it goes back to muted, keeps playing, and waits for the first
pointerdown/keydown to try again — on capture, so a click aimed at the console iframe or a HUD button counts too. Any gesture satisfies the browser; it does not have to be aimed at us.
sndWant is what you asked for, sndArmed is the browser not having allowed it yet, and they are shown differently because they are different states. The glyph stays honest: muted reads 🔇 whatever was asked for, and the armed state is an amber pulsing border. Drawing 🔊 over a silent video would be the button reporting the preference instead of the sound. What is saved is sndWant, never mirvid.muted — otherwise an autoplay refusal would quietly un-choose the setting.
Reported as "I have to click the sound button twice" and "music still isn't auto playing".
The toggle read `!sndWant`, which is what was ASKED for, not what you can hear. With the preference restored and the browser refusing, sndWant is already true — so the first press on a button showing 🔇 turned the setting off, and the second turned it back on. Two presses to reach the state the button was already offering. It reads live = sndWant && !mirvid.muted now, so a silent mirror always turns on.
The gesture hook was the other half of the same press. It listens on capture, so a press on the sound button reached it first, unmuted, and then the click handler toggled the setting off again — one press doing both halves and cancelling itself. The button is excluded from the hook; it runs its own retry inside its own gesture.
And "not auto playing" was the video playing silently, not the video stopped: paused: false on every measurement. The armed state is a pulsing border on a control bar that only appears on hover, so the one thing that explained it was invisible. It flashes once on restore now — the browser is refusing, not the mirror, and one click anywhere fixes it. window.__snd() reports {want, armed, hooked, muted, paused, volume, ducked, savedSnd}, because this state is three flags and an element property and inferring it from a glyph is how both rounds of this went.
Measured after the fix, on a browser that permits unmuted autoplay: one click turns it on (want:true, armed:false, muted:false), and a refresh with zero clicks comes back muted:false, paused:false.
Both branches measured:
| browser | click | after refresh |
|---|---|---|
| permits unmuted autoplay | muted:false, 🔊 | `muted:false`, playing, 🔊 — no click needed |
| blocks it (cold profile) | muted:true, `paused:false`, armed, snd:1 saved | armed again, still playing, preference intact |
The row that matters is the second one: the refusal costs the sound, never the playback and never the setting. Ducking verified against genuinely audible sound afterwards — 1 → 0.18 → 1, still unmuted.
A refresh mid-song restarting from zero is the whole reel resetting every time the page reloads, which on a stream is most of the time. reelT is saved beside reel, and pendingSeek is applied on `loadedmetadata` — setting currentTime on a <video> that does not know its duration yet is discarded silently.
Four bounds on it, each one a way the naive version misbehaves:
Not below 2s — a resume to 0.4s is a seek for nothing that costs a re-buffer.
Not within 6s of the end — resuming into the outro fires ended, so the video you
came back to is gone before you see it.
Saved every 5s, not every tick. timeupdate fires ~4×/s and the save is debounced;
writing on each one is a localStorage write every 400ms for a whole hour.
Kept across the `/ravio/` fallback swap. The two urls are the same hour, so falling
back mid-broadcast should change where the bytes come from and nothing else.
SHUFFLE resumes too, by moving the saved video to the front of the freshly dealt queue rather than restoring an index. The index is meaningless — this deal is not the last one — but "put me back where I was" still is, and this is both at once: a new shuffle, and the song you were on, at the second you left it.
Video only. A microphone is not part of "a camera view of me programming", and asking for one would put a second permission and a live input into a page that is already talking. Nothing is recorded and nothing leaves the page — the only consumer of the stream is the element on screen. Tracks are stopped explicitly on toggle-off and on pagehide, because a camera that keeps running after you close the tab is the thing that makes people tape over the lens.
The preference is saved, and it is off-biased: camWant is only ever set by a click, so a viewer who turned it off yesterday gets it off today and one who never touched it never had it on. The restore is still a real getUserMedia call and the browser's own permission is still the gate — the pref only remembers whether you asked.
Refusals are named rather than left dark: the browser refused (NotAllowedError) and no camera on this machine (NotFoundError) are different faults with different fixes.
Shipped, the DOM <video> floated over the control rows the moment you leaned in. Reported as "the camera view is z-indexed higher than the tv screen", and that is exactly what it looked like — but the z-order was never wrong. #tv is 9 and a mirror is 7; the thing underneath was the monitor, which is painted on the dash canvas at 6 and grows from 352..928 at rest to 150..1770 leaned in. Canvas handled it by itself, because drawMonitor() paints after drawMirror() and simply covers it. The pinned video does not.
So the rule is not a z-index. It is mirrorClear(side, look, nActions, tvOn) — a measured overlap of the housing against monRect() — and both sides read it: the canvas decides whether to paint the fitting with it, the renderer decides whether to place the video with it. Two copies of that rule is how a video ends up over a panel that has already taken its place.
A threshold on look would have been the easy fix and the wrong one: the monitor is not centred at rest, so it reaches the left housing at t≈0.12 and the right one later, and a constant picked by eye needs re-picking the first time MON or MIRROR moves.
Two related corrections fell out of chasing it:
`placeMirrors()` now reads `tvShowing()`, not `placeTv()`'s `want`. The two differ by
reading === null, and drawDash is handed tv: tvShowing() — so with a page remembered and a panel open, the canvas drew no housing while the DOM went on placing a video into where one would have been, at a lift computed from the other branch.
The label plate is part of the fitting, so the clearance test covers the 19px above the
housing too. Otherwise the housing goes and its caption survives, naming something that is no longer there.
Verified with elementFromPoint rather than by eye: flying, each mirror is the topmost element at its own centre; leaned in, both are gone and the control rows are unobstructed; with a page on the screen, both are gone.
An empty `<video>` is an opaque black rectangle, and it sat exactly on the caption
the canvas had painted to say which nothing you were looking at. So a dark mirror read as a rendering fault with the words explaining it underneath. Fixed with background: transparent, not hidden — the element is also the click target that turns the thing on, and hiding it would leave the canvas inviting a click nothing was listening for.
`box-sizing` on a pinned bar. The control strip is positioned to the glass's width;
content-box added its 12px of padding on top, so it overhung the bezel by 6px a side.
A label in open sky lands on a billboard. The name plate sits above the housing,
which is exactly where the signs pass. It gets its own dark ground first.
The dial shows speed_u * (1 + boost * BOOST_X), and both halves have been asked about by the number they print, so both are named now rather than being coefficients you have to do arithmetic on to understand.
`SPEED_IDLE` (bridge.py), 5.25 → 6.7. speed() was written as
15.0 * (0.35 + 0.65 * tanh(backlog / 8)), so finding out what the road does when nothing is happening took arithmetic — and per §8 that is the number on the dial almost all the time (the repo was idle four straight days during the measurement that set the cadence). It is a floor and a ceiling, so it is written as a floor and a ceiling. The ceiling is untouched: a busy harness still tops out at 15.0 and only the quiet end of the range moved. At S=300 that is a milepost every ~45s idle instead of ~57s.
`BOOST_X` (index.html), 4.5 → 9. Full boost now reads exactly 67
against the 6.7 floor instead of 36.9. It stays a multiplier of the harness's own speed rather than a number of its own — invariant 1, the road may never move for a reason the viewer cannot read off the instruments. A busy harness boosts from a higher floor and the dial says so; hard-coding 67 would have made the top of the range say the same thing whether anything was happening or not.
The dial was unreadable from outside, which is why __ravio() now publishes speed, boost and baseSpeed. It is painted on a canvas, so checking it meant re-deriving speed_u * (1 + boost * 4.5) in the test — testing the arithmetic against itself. Measured through the real key path: idle 6.7, Shift held to full boost 67.0, released back to 6.7.
A test-harness note worth keeping: the first attempt measured boost: 0 through three samples and looked like a broken feature. The page was in gear R — out of D the keydown handler returns before touching keys and flyShip skips the input path entirely, both on purpose (§10.4, "a scene owns the keyboard"). The instrument was right and the test was in the wrong gear.
A four-position gate on the console, right of the wheel. It selects one of four things this cockpit can be doing:
| is | ||
|---|---|---|
| P | PARK | pulled over on the shoulder; the drive-in is showing |
| R | REEL | the c u l8er films, full frame |
| C | CAMERA | the room, and a mic that records into the app |
| D | DRIVE | the road. Everything the cockpit did before this existed. |
P-R-C-D is P-R-N-D with the neutral position given a job — not a pun for its own sake: N is the gear where the engine runs and the car does not move, which is exactly what the camera scene is. G walks the gate (shift+G backwards), Esc drops back into D, and the stick can be clicked directly. 1–7 are the tubes, [ ] walk the lane roster and { } the districts; every other obvious key was already spoken for.
Why a shifter and not a tab bar. The cockpit's whole argument is that an instrument should be the thing it represents — a rating is a vacuum tube because a tube is either lit or dark, the road is the page because a page is glued to a surface that is actually there. A mode selector in a car is a gearstick; it has detents, and it is always in exactly one of them. A tab bar was three pixels cheaper and one metaphor poorer.
The gate lives at design 1452..1604 × 828..1068, which is the one empty column on this dash wide enough for a control: the wheel's rim ends at 1438 and the DRIVE dial's begins at 1622. shiftAt() gives each gear a band of the plate rather than the 21px notch — a detent is something you knock the stick into, not a target you have to hit — and shiftClear() is the same measured-overlap test mirrorClear() uses, so the painter and the hit test cannot disagree about whether the stick is there.
The car stops out of D. holdT already models a stop — it is how a
reading phase holds station — so a gear feeds that term rather than inventing a second kind of not-moving the two would have to keep in agreement. P earns a pose on top of it: pulled over is a place, so the ship crosses to CORRIDOR_HALF * 1.28, banks on the way and levels off. R and C hold the lane, because moving for them would be motion with nothing to read it off — invariant 1.
The dashboard is in front of the content. A scene is pinned to
stageRect() and runs all the way down to the hood, and the dashboard occludes it: the picture disappears behind the crown of the hood in the middle and reaches further down at the outer corners, because that is where the hood is. The stick that opened the scene stays under your hand, so nothing has to grow its own way out and no scene can strand you.
And it leaves a gap on the other three sides. Flush to the frame, the reel and the camera were screens you could not see past; the road, the stars and the signs behind them are the reason this is a cockpit and not a media player. Applied on report, and it is the argument the park screen had already settled — so edgeGap() is one exported definition both read, because two margins that are nearly the same is worse than either being wrong: it reads as a mistake rather than as a choice. Not on the bottom, which runs behind the hood; a margin there would lift the picture off the dash and leave a stripe of sky between the two. The stage's background went opaque at the same time — with real sky showing around the edges, a panel that is 92% dark over a lit 3D scene reads as a smudge on the glass instead of as a screen.
One element per source. The camera and the reel move onto the stage and
back; they are not duplicated. Two <video>s on one getUserMedia is two permission prompts with one winner, and two on one reel is the song restarting every time you look at it properly.
rehome() takes currentTime and paused before re-parenting and puts them back after, because some browsers pause a media element on re-insertion — which for the camera is a black frame and for the reel is the whole reason the mirror bothers to remember reelT.
Shipped, the district panel, the odometer, the voice plate and the voice bar all landed on top of the tab strip and the address bar — the two controls the drive-in is made of. They are pinned to the windshield, and the windshield is exactly the rectangle stageRect() takes. The state was legible from window.__gear() the whole time and looked perfect; the collision was only ever visible as pixels.
So a scene hides the chrome the same way a page does (§7.2), through the same placeTv() branch rather than a second copy of the rule. Narration itself is untouched — only the plate that displays it — because §11's rule is that narration exists to be heard.
Two more readouts were lying while parked, and both are invariant 1 in its plainest form: the monitor card said NOW FLYING for a car stopped on the shoulder, and the bottom line offered V · look down into the cockpit for a key a scene has taken away. They now name the gear and say G · next gear · Esc · back to the road.
The naive way to put the dash over the content is a z-index: drop #stage below #dashcv (6) and let the hood paint on top. That kills the scene. #dashcv is inset:0 with pointer-events:auto across the whole frame — it is the cockpit's hit surface — so anything beneath it is unclickable, the same trap §7.3 already recorded for #gl.
The first build dodged it by leaving the stage on top and cutting it to the hood's silhouette. That looked identical but it was not the same thing, and on report it is now done properly: the stage goes to z-index 5, under the canvas, and `dashClip()` clips the CANVAS to the hood instead. The canvas then only takes the pointer where the dashboard actually is, so the scene keeps every click above the curve while being genuinely painted over below it. Verified by clicking a track in the reel's list with the dash on top: track 5 selected, reelnow followed.
The clip is applied only out of D. In D the canvas also draws the mirror housings (design y 536), the top-centre pill (40) and the morph plate, every one of which is above the hood and would be clipped away; getComputedStyle reads none in D and the path out of it.
The hood curve alone was not the dashboard's shape. Reported as the TV screen not being in front of the reel and camera scenes, and that is exactly what it was: the monitor's bezel starts at design y 680 (drawMonitor's m.y - 40) and the tube rack's label at 702, both above the hood's 752 — so the top of the monitor panel, the traaviis.com label and the LANE RATINGS cap were sliced flat by the clip while the scene showed through where they should have been. The clip is three subpaths now — the hood, the monitor's box and the rack's box, each derived from the constants those are drawn with — which union under clip-path's nonzero rule. RACK got named in the process: x0/base/span were being recomputed inline at three sites and the clip would have been a fourth copy of the arithmetic.
An instrument overlapping the bottom of a windshield is correct; an instrument hiding a CONTROL is not. With the dash on top the monitor reached up over the reel's transport bar and the track name became unreadable. Paying for that as extra padding on the whole stage would cost every scene height to solve one bar's problem, so dashReach() publishes how far the instruments reach past the crown and only the bar spends it. Measured at 1280×577: 22.0px, putting the bar's bottom edge at 307.6 against a bezel top of 307.7.
Two things fell out of the geometry, and the second is a bug this codebase has now hit three times:
Occlusion is not layout. The scenes laid themselves out to the hood's
deepest point and the dashboard then covered whatever landed in the middle — the reel's transport bar and the bottom row of the track list, observed. The renderer spends hoodDepth() (42.5 design px, ~28.7 client px at 1280) as padding-bottom on the stage.
`box-sizing`. Under content-box that padding was added, so the element
grew by 28.7px instead of the content area shrinking by it. Measured: stage bottom 387 against a hood edge of 358.3. With border-box the transport bar's bottom lands at 329.6, which is the crown exactly. §10.3 hit the same class of fault on the mirror's control strip.
The top-centre pill stopped being drawn out of D at the same time: it sits at design y 40..132, which is inside the stage, so it was being painted underneath a scene and contributing nothing but a rectangle ghosting through.
Flat against the windshield, the reel and the camera read as a web page in a box. A picture in a cockpit is mounted, and canted it reads as hardware in the cabin — the same argument the tube rack and the mirror housings are made of.
It is the right edge that recedes: rotateY(11deg), origin at the near left. Built the other way first, on the reading that a screen on your left should turn to face the middle of the car; the pane is not on your left, it is the whole windshield, and the near corner belongs beside the rail you are reaching for. Corrected on report.
The perspective lives on the wrapper, not on the pane. Put it on the element being rotated and each one gets its own vanishing point at its own centre; on the parent, both scenes share one, so the reel and the camera look like they are mounted in the same room. #reelbar is a sibling of the pane rather than a child — a transport you have to read at an angle is a transport that is harder to hit, and those are controls, not part of the picture.
`drop-shadow`, not `box-shadow` — reported as the shadow at the video's bottom-left going solid and then just ending rather than continuing downward, and the cause was that the box and the picture are not the same rectangle. The video is object-fit:contain, so measured at 1280×577 the glass box is 850×255 while the image inside it is narrower: .mirror's own background:#05070e (there for the mirror housings, where an empty video has to be a dark pane) filled the rest as bars, and a box-shadow hugged that whole rectangle. Its lower edge sat 7px above the wrapper's floor, where the stage's overflow:hidden cut it — a solid dark region terminating in a hard horizontal line against #reelwrap's black, two nearly-identical blacks meeting at a visible seam.
drop-shadow follows the alpha of what is actually painted, so with the letterbox made transparent on the stage it hugs the picture itself and falls off on every side including downward.
That was not the whole of it, and the second half was the reported one. The transport bar's scrim was the bar's own background, and the bar is lifted by --dashreach (22px) to clear the monitor — so the gradient ramped to 90% opaque and then stopped 22px above the pane's floor, and the picture underneath popped back to full brightness. Measured: bar at y 266..308 inside a wrap running to 330. That is "gets solid but then just ends and doesn't continue downward" exactly, and no amount of work on the shadow could have touched it.
The scrim is its own layer now (#reelwrap:after), anchored to the pane's floor rather than to a control that moves, so the darkening reaches the bottom of the picture whatever the dashboard is doing above it — z-index 0 against the bar's 1, so the scrim goes over the video and the controls go over the scrim. The lesson generalises past this pane: a scrim belongs to the surface it darkens, not to the widget that happens to sit on it. Filtering a playing video every frame is worth a number rather than a shrug, and R-versus-D is not that number — R also composites a video and a track list. Interleaved A/B on the same scene, filter on and off three times each: p50 27.6 vs 26.5 ms, ranges overlapping (the worst p95 of the six runs, 43.6 ms, was a filter-*off* run). Inside the noise on this machine at this size.
§10.3 refused a microphone on the left mirror, on the grounds that a camera view is not an interview. That refusal still holds where it was made: the camera stream is video-only, and the mic is a separate stream asked for separately, at the moment you press REC and not before. This scene is where the interview happens.
NOTHING IS SAVED — changed on report, and the panel is better for it. The first build had a take list, a SAVE that wrote to .ravio/takes/, and bridge routes behind it. What the desk is actually for is the level: a microphone you cannot see is a microphone you cannot trust, and "did it hear me" is the question a saved take otherwise gets used to answer after the fact. So there is one recording, it lives in this page, and closing the tab is the whole of the delete story. The /take, /takes.json and /take/delete routes are gone from bridge.py — a store nothing writes to is a claim the code no longer makes.
The desk is a real signal path, not a button with a bar beside it:
stream → source → gain ─┬→ analyser (the meter)
├→ recDest (what MediaRecorder gets)
└→ monitor → out (only when you ask)
The gain is before the split, so the slider moves what is recorded and what the meter shows together. A trim that only moved the needle would be a meter that lies about the file, which is the one thing a meter must not do. Verified: a −20 dBFS source at 2.0× reads a −14 peak, exactly +6 dB.
The VU is logarithmic. A meter mapping amplitude straight to width spends
almost all its travel in the top 6 dB and reads as pinned or nothing at every level a voice actually sits at. −60..0 dBFS across the bar is the range the ear works in and every desk in the world draws. Measured against a −20 dBFS sine: bar −23.0 dB (peak/√2 = −23.01), peak mark −20.0, width 61.6%.
Fast attack, slow release. A bar that follows the samples exactly is
unreadable flicker; one that eases both ways lags the thing it reports. Rising is instant, falling is 48 dB/s.
Peak hold is a separate mark and it falls rather than resetting. The bar
answers am I being heard now, the peak answers did I clip a moment ago, and one indicator cannot do both.
The track's bands were the bug. Painted at opacity:.22 the scale read as
a full bar, so an idle meter looked pinned to 0 dB — the one reading a level meter must never give by accident. .09.
Monitoring is off until asked for, and says headphones only beside
itself, because feedback is the default outcome on a laptop. It is a gain node rather than a reconnect, so the graph is identical either way.
The device picker is filled after permission, and always. Labels are blank
until the browser allows the mic, so an unlabelled list is not "no inputs", it is "not allowed to say" — two different sentences. deskOpen() returns early on refusal and the picker was left as an empty <select>: a control with nothing in it and nothing said about why.
The mic is released when you leave the scene. A page holding an open
microphone while you look at something else is the audio version of the camera that keeps running after you close the tab, and §10.3 already decided how that goes. applyGear() calls stopDesk() on every shift, not only on the C branch — the state to undo is "the desk is open", not "the gear used to be C".
Playback still ducks the reel (1 → 0.18 → 1) rather than muting it.
MEASURED LIMITS, both headless-only. MediaRecorder produces no bytes in a headless Chrome — onstop never fires at all — so the capture step cannot be exercised there; it is the same call the reading pane's MIC has been making since §11.5. And await ac.resume() never settles without an audio device, which hung an evaluate outright: send a real gesture first and construct the context without awaiting the resume. window.__feed(stream) builds the graph around a synthetic source so the ballistics and the dB mapping can be driven and read back, which is how every number above was obtained.
Every control here drives the same machinery the mirror does — setChannel, reelShow, setSnd, one queue and one reel. A second player with its own idea of which track is on is two now-playing states to keep in agreement, and the first disagreement is the mirror and the scene playing different songs through the same speakers. paintSnd() now paints both sound buttons for the same reason.
The four channels, the greyed-but-listed dead one, and the "toggle on what you can hear, not on what was asked for" rule are all §10.3's, unchanged.
A CSS finding worth generalising: #reelnow had overflow:hidden and text-overflow:ellipsis and rendered as `TRAVELMONST` anyway. A flex item defaults to min-width:auto, which refuses to shrink below its own content — so the ellipsis machinery sat there doing nothing while the text was cut mid-word. min-width:0 on both, and the url yields first because it is the footnote.
§9 recorded that "play any website on the drive-in screen is NOT deliverable": HTMLTexture excludes cross-origin iframes, CSS3DRenderer cannot be occluded by the road, and the html2canvas family cannot render iframes at all. That finding was about texturing a page onto 3D geometry, and it still stands. This screen is DOM pinned to a rectangle — the arrangement #tv has used since §7.2 — so the constraint does not reach it.
What did have to change is the bridge. /ext/<host> was allowlisted with the sentence "this is a screen for two named sites, not an open proxy", and a browser that can only reach two addresses is not one. EXT_OPEN = True lifts it for GET, deliberately, with the trade written at the top of bridge.py:
What it costs — the bridge will fetch any https page a local pointer asks
for and hand it back same-origin. It binds 127.0.0.1, so the reachable set is "software already running as this user", and a tab on another origin still cannot use it: nothing sends CORS headers and Private Network Access blocks the preflight.
What it does not do — no credentials forwarded, no cookies kept between
requests, and POST is not proxied. It is a reader. Log in to something and it will still look logged out, which is the honest behaviour rather than a bug. That sentence is on the new-tab screen, not left to be discovered.
What will break — rewriting is textual. Anything that builds its own
addresses in script, registers a service worker or streams over a websocket leaves the proxy or fails, and says so instead of showing a blank frame.
Set EXT_OPEN = False and the two pinned screens work exactly as before.
P IS NOT A SCENE ON THE STAGE — it is the dashboard's own TV, grown. Rebuilt on report, and it is the version the geometry was always asking for: the drive-in was a panel on the windshield next to a monitor that already exists for showing pages, which is two screens for one job.
It GROWS out of the bezel — the same gesture the traaviis.com label makes when it opens the controls, eased at the same dt*4 the lean uses, rather than appearing at its final size. parkRect(W, H, t, from) is a smoothstepped lerp from the monitor to the target, so the screen swells out of its own housing instead of a new surface arriving over the top of it. Sampled inside one frame loop (separate evals cannot see the first two frames, and a screenshot at 350 ms already catches it three-quarters open): 389×218 → 844×386 at 145 ms → 1115×485 at 307 ms → settled 1228×527 by ~976 ms.
Reported as "it doesn't even match the size of the tv screen when it's expanding and contracting", and there were three causes:
`from` is the LIVE monitor glass, not a copy of `MON`. Hard-coding the
resting constants is only right at look = 0. Shifting into P from a leaned-in cockpit eases the lean down while this eases up, so the monitor was being drawn lifted and swollen (monRect grows with the lean) while the box started from the resting rect — the grow began somewhere the screen was not. It now reads the same tvRect() that #tv is pinned with, so the two cannot disagree.
The rail's 150px floor is scaled by the travel. Held constant it applied at
t = 0 too, where the whole box is only ~384px wide — so the grow began as a 234px screen with a 150px rail bolted to it, a shape the monitor never has, and then re-proportioned itself on the way out.
The box fades through the monitor's own position. No lerp makes a bordered,
rounded div line up pixel-for-pixel with a bezel painted on a canvas, and the mismatch is at its most visible precisely where the two shapes are closest. So over the first half of the travel it dissolves in; at rest it is not drawn at all and the monitor you are looking at is the monitor. It is a pure function of the eased t, so leaving folds back into the bezel the same way — measured 934px @1.00 → 575 @0.57 → 478 @0.21 → 363 @0.00.
The fade window was 0.32 of the travel first and that was too fast: measured every frame, it completed by 72 ms — three frames at the ~30fps this page runs while a scene is opening — which reads as a pop rather than a dissolve. At 0.5 it gets five or six (0 → 0.14 → 0.46 → 0.82 → 1 over 133 ms), and the box is still inside the monitor's footprint for most of them, which is the stretch the fade exists to cover. The content has its own later ramp (--parkshow, over the last third) because the two hide different things: this one hides a shape that cannot line up, that one hides a tab rail reflowing through six widths.
And it stops short of every edge. Flush to the frame it was a screen you could not see past, and the road, the stars and the signs behind it are the reason this is a cockpit and not a browser. Measured at 1280×577: 26px of gap on the left, top and right, bottom at the bezel line (553), with an amber rim and a drop shadow so the gap reads as a screen standing in front of the world rather than as a page that failed to fill the window.
The gap is measured off the VIEWPORT, not the design canvas, and the first version got that wrong in a way only a short window shows. The dash is anchored to the bottom of the frame, so at 1280×577 design y=0 is already 143px above the top of the screen — a 52-design-px top margin computed there put the screen's top edge at client y −108. A margin has to be a margin against the thing it is a margin from.
The screen half ends three quarters of the way across the frame; the last quarter is the browser's controls. The split is expressed against the box actually being drawn rather than held as a constant fraction, or it would slide across the picture while the box was still growing. The rail is likewise a fraction with a 150px floor — a constant would be a third of a small window and a sliver of a large one, and a rail narrower than its own buttons is not a rail.
The screen is in front of the dashboard and the rail is behind it — two layers, not one box, and the split is the whole point. #park (the screen) is at z-index 10 because a page you are reading is not scenery behind an instrument panel. But the rail occupies the right quarter, which is exactly where the shifter is bolted, so in front of the dash it buried the one control that gets you out of P. #pchrome is its own fixed layer at z-index 4, under the canvas: the tab list runs down to where the hood begins and the dashboard takes over from there, with the stick still under your hand. Verified with elementFromPoint(1010, 480) → dashcv, and a click there shifts P → R.
Three things that split forced:
The canvas is clipped in P too. It was exempt while the whole scene was in
front of it; left unclipped with the rail underneath, #dashcv is inset:0 and takes every pointer event over the rail's full height — the tab list would be visible and dead.
The rail's content ends where the hood begins at the RAIL's x. The edge is
a quadratic, so the dashboard rises about 20px higher under the rail's left edge (337) than under its right (356); hoodYAt() is asked at the left, which is the earliest the hood can arrive across the span and the only value that hides nothing. The box keeps its full height — only the content stops short, because being behind the dashboard below that line is the point.
Two elements mean two things to hide. The early return in placePark()
went on hiding only the screen, so dropping back into D left a browser rail sitting on the windshield with nothing behind it. The branch that forgets one is always the one that runs least often.
The rail still carries its own D · DRIVE button beside Esc, because the screen half covers the road and a way back should not depend on finding the stick.
The curtains and the theatre floor went with the move. They framed a small panel sitting on a windshield; this is the dashboard's own glass at three quarters of the frame, and the bezel around it is the dashboard.
Two details the vertical rail forced: #tabnew keeps its place inside #tabbar (paintTabs inserts each tab before it) and is pulled to the top with order:-1, so the list still grows against the button; and .tab .t needed the same min-width:0 the reel's title did, or a long page title pushes the close button out of the rail instead of ellipsing.
Reported as the buttons not working, and they were doing exactly what they had been told: the tab's history only ever recorded addresses TYPED into the bar. Clicking a link inside a page moved the frame and left hist untouched. Measured — the frame sitting on /wiki/Wikipedia:Community_portal while the tab still said /wiki/Drive-in_theater, hist 3 and cursor 2. Back then either jumped past everything you had actually browsed, or was disabled outright on a tab you had never typed into twice.
A link click is a navigation and belongs in the history. tabLand() reads the frame's own location (same-origin, because everything on this screen is proxied) and records anything the page did for itself. Three things it needs to get right, each one a way the naive version misbehaves:
`pending` tells our navigations from the page's. Without it every tabGo
and every tabStep would land there and push its own destination a second time — two presses of back per page.
It adopts the LANDING address, not the dialled one. A redirect makes those
differ, and recording where the frame was sent puts an entry in the history that going back to would only redirect forward again.
`location.replace`, not `src =`. Assigning src a value it already holds
is a no-op in some browsers, which is a back button that does nothing on exactly the round trip you are most likely to make; it also stops each step stacking an entry on the top window's history, where it does not belong.
Only a load event may resolve a pending navigation, and that was a real race rather than a precaution. The 1.2s backstop (which exists because load does not fire for a hash-only move, and on a one-page anchor-navigated site the hash is the only part of the address that ever changes) can run in the window between pointing the frame somewhere and that page arriving — at which moment the frame still reads the old address. It matched the pending entry by cursor, adopted the page being navigated away from as that entry's address, and the real load then arrived to find a mismatch and pushed. Measured: two presses of back moved one page and shrank the history from 8 entries to 7. A pendingAt stamp is the other half — a load event that never arrives would otherwise wedge the history for the life of the tab, so the backstop clears a stale pending after 8s.
Verified end to end, bookkeeping and frame: typed → link click → back → forward → reload lands on Drive-in_theater → Community_portal → Drive-in_theater → Community_portal → Community_portal, with hist 2 → 4 → cursor 1 (forward enabled) → and a new address after two backs truncating 4 entries to 3.
Real tabs, not a page picker: every tab keeps its own live iframe behind the active one, its own truncated-at-the-cursor history, and its own title read off the page when the page is same-origin enough to ask. Addresses go through the existing proxyForUrl(), and tabLocation() reuses tvCurrentUrl()'s TV_OFF_ORIGIN sentinel — a frame that has left the proxy is a different answer from "we do not know yet", and falling back to the dialled address would name the wrong page with complete confidence.
Four rewriter findings, each one measured rather than anticipated:
`xmlns="http://www.w3.org/2000/svg"` is an identifier, not an address.
Rewriting it renames the SVG namespace and every <svg> on the page stops rendering — which looks like "images are broken on this site", a long way from the line that caused it. NOREWRITE holds the schema hosts.
The host must be dotted. Without that clause the protocol-relative //
branch matches the start of a line comment in any script: // note is not an address and /ext/note/ is a page that never existed.
Rewrite against where it LANDED, not where it was asked for. urlopen
follows redirects silently, so a page fetched from example.com that lands on www.example.com would have its own root-absolute paths rewritten under the host that redirected, and every asset would 404 one hop later.
Drop `<base>` and drop `integrity`. A <base href> re-resolves every
relative url against the original site, walking the document out of the proxy in one attribute; subresource integrity is a hash of bytes we have just changed, and left in place the browser refuses the asset silently — a rewritten stylesheet becomes an unstyled page for no visible reason.
ravio-screen/1.0 is honest and gets 403'd by a good share of the web. The UA is a browser string now: both are true statements about a program fetching a page for a person to look at, and the one that fails does not make the request more honest, only less useful.
One more, caught in a screenshot: `rgba(...,.94)` over the stage's own `.75–.92` leaves about 1.5% of the road showing through, which on a dark panel over a lit 3D scene reads as ghost geometry drifting behind the text. The stage is translucent because a windshield is; a panel you read words off is not a windshield.
Verified live at 1280×577 against the running bridge: all four gears, the ship at the shoulder in P, camera and reel re-homing without duplication, Wikipedia rendering complete through the proxy in one tab while example.com stays alive in another, Esc/G walking the gate, and zero console errors.
The saucer on the steering wheel hub is the ship you are sitting in.
This is not a metaphor added for this section; it is already true in the code. world/dash.js:32 exports saucer(c, s) and wheel() calls it at rad * 0.0041 (dash.js:107) to paint the wheel's hub. The same function, character for character, draws the flying saucer on c-u-l8er.link/ravio/. The emblem under your hands and the craft you are inside have always been the same drawing at two scales.
So going outside is not a new scene invented for authentication. It is the one gesture that lets you look at the hub from the other side.
A keys slot on the dash, right of the wheel and left of the monitor. Empty when signed out; a key hangs in it when a walltube session is live, and the key turns — a short rotation, not a loop — when a broadcast is actually on air. It is the only control on the dashboard that reports a fact about a machine that is not this one.
The rect must be measured, not assumed. §10.4 found the shifter's column by measuring what was empty (wheel rim ends 1438, DRIVE dial starts 1622) and that column is now spent on the P/R/C/D gate at 1452–1604. The keys need their own measured gap; do not hand-place them next to the gate and discover the collision in a screenshot. Cf. §10.4's rule that the HUD collision was only ever visible as pixels.
§10.4 rule (2) says a scene is pinned to stageRect() — the windshield above the dash lip — so that no scene grows its own way out of the frame. The outside view is deliberately exempt. It is full-bleed: the dashboard is gone, because you are not behind it.
This is a real override and it is recorded as one. The justification is that rule (2) protects the cockpit's composition from scenes that forget they are inside a car, and this view's entire content is that you are not in the car. R and C remain pinned; P already grew out of the bezel to z-index 10; outside is the third and last exception, and the spec should refuse a fourth without an argument this specific.
Consequences that follow immediately, each of which is a thing that has bitten before:
Two elements, two things to hide (§10.4 Round 5c). Entering outside hides the dash
canvases, the dial layer, #topstack, the voice bar and the stage. The branch that forgets one is always the one that runs least often — so hiding is one function, called from one place, listing every element by name.
`#dashcv` is `inset:0` + `pointer-events:auto`. The same trap as #gl and the
stage. Outside must either clip the canvas to nothing or hide it outright, or the beam will track a cursor whose clicks all land on a dead dashboard.
`holdT` still owns the stopping. Rule (1) is not overridden. Outside feeds the
existing stop term like every gear does; it does not introduce a second kind of not-moving. It is a mode layered over whatever gear you were in, and leaving it returns you to that gear.
Ported from c-u-l8er.link/ravio/, which is canvas 2D and about ninety lines. Three pieces:
| element | what it is |
|---|---|
#saucer | the emblem, drawn once into a 520×360 canvas, then moved by CSS transform |
#beam | a full-frame canvas at mix-blend-mode:screen, redrawn every frame |
| the loop | eases the ship toward the pointer, banks it, and aims the beam |
The numbers that make it feel alive, taken from the source rather than re-invented:
Position eases at x += (tx - x) * 0.08; target is W*0.5 + mX*W*0.34 horizontally
and near the top vertically, dipping toward the cursor.
Bank is rot = -mX*11 - vel*0.7 — it leans into the turn and into its own
velocity, which is why it reads as a craft rather than a sprite.
Idle sway is stronger when untouched (2.4 vs 1.0). An untouched ship drifts;
a handled one settles. Do not normalise these to one constant.
The beam is a quad from a narrow span*0.16 at the hull to a wide span*0.62 where
it lands, plus a brighter inner core at 0.32 width and a radial landing glow, all breathing on 0.72 + 0.18*sin(t*0.002).
Port it, do not re-derive it. §10.3's mirror work established that checking a formula against your own prediction is testing the arithmetic against itself. This geometry is already tuned and already shipped on a live page.
Where the beam lands is where the sign-in field sits. The saucer tracks your hand; the field is under the light. Sign-in is a magic link (§13.4), so the field takes an email address and nothing else — there is no password to type into a beam.
Three states, and the ship's behaviour distinguishes them without any text:
| state | ship |
|---|---|
| signed out | hunting — tracks the cursor, beam wide, looking for you |
| link sent | holding — beam locked on the field, sway damped |
| signed in | rising — beam narrows and lifts, ship recedes, mode exits to the cockpit |
Do not put a spinner in the beam. The whole point of the abduction gesture is that the wait is the animation.
A pane that is not displayed runs at 1 fps (§6.5). Every timing above is a
wall-clock ease, so a hidden pane will make all of them look broken. Measure with ≥2.4 s samples per §10.4, or drive the state directly through a window.__* hook.
mix-blend-mode:screen over a full-bleed dark field is correct; over the *lit 3D
scene* it is not, and §10.4 Round 7 already found that a translucent surface over a lit scene reads as a smudge. Outside draws its own background.
The beam canvas is sized by DPR with setTransform(DPR,0,0,DPR,0,0) and re-sized on
resize. Skipping the re-size gives a beam that is correct until the first window change and subtly wrong forever after.
facts → LLM → moderation → TTS → prebuffer → play
Never synthesize unreviewed text. Every AI stream ban in the 2023 cohort was text that reached audio: Neuro-sama (2 weeks), Nothing Forever (14 days), Unlimited Steam (permanent), AI Family Guy (banned 7 days after channel creation — Peter Griffin gave detailed bomb-making instructions and named a venue).
The proximate cause was an OpenAI Davinci outage → failover to Curie → a transphobic standup bit. But the real finding is in Hartle's own statement: "We mistakenly believed that we were leveraging OpenAI's content moderation system for their text generation models." There was no filter in the loop at all. They were relying on model-level safety behaviour — an implicit, undocumented property of one specific model — and it silently evaporated on failover.
A fallback path is a different system and it inherits none of your assumptions. For an unattended stream, the fallback is the one that will be running while you're asleep.
So: gate the fallback path identically, and test it by forcing a failover.
OpenAI's Moderation API is still free in 2026 (omni-moderation-latest). There
is no cost argument against using it.
Add a second independent gate — a local deny-list at minimum. Mismatch Media's own
conclusion was that they needed "secondary content moderation systems as redundancies."
Show the filter firing. Neuro-sama displays the word filtered on screen rather
than silently suppressing. Silent suppression is indistinguishable from a broken pipeline, it denies the audience the information that the gate works, and it turns safety into a bit. Copy this exactly.
The 60 s prebuffer is your moderation window (§11.3) — free, since you want it
for TTS anyway.
The single most transferable finding from Gemini Plays Pokémon: the model "struggled to utilize the raw pixels", and the fix was structured text plus ground truth injected from RAM.
Do not ask a model to read the visualization. The narrator's input is the bridge's detail JSON — lane, rung, diff stat, gate name, verdict. The visualization is for humans. This also means narration keeps working when the renderer is broken, which is a good property for the thing that talks.
Also from that harness, and it is counter-intuitive enough to be worth stating as a rule: past ~100k tokens the model "would fall into repetitive loops of behavior instead of coming up with new strategies." The fix was not a bigger window — 1M was available and deliberately unused. The fix was aggressively throwing context away and re-seeding from a compact summary plus fixed goals.
For a stream that runs for days this is the central architectural constraint. Reset every N beats; re-inject persistent framing (the ladder, the lane roster, the current district) and a rolling summary. Never let the context just grow.
Local model. Qwen3 8B/14B via Ollama. Commentary is a low-difficulty generation
task. Marginal cost is electricity, and it removes the API-outage failure mode that is precisely what killed Nothing, Forever. It is also what Neuro-sama — the only breakout success in the category — actually does.
TTS: Kokoro-82M via Kokoro-FastAPI. Apache-2.0, unambiguous. 82M params,
<2 GB VRAM, 35–100× realtime on a 4060 Ti, and 1.5–2× realtime on CPU alone via ONNX — so it stays off the GPU that is rendering, and there is a zero-GPU fallback. UTMOS 4.44, specifically credited with holding up under sustained listening. OpenAI-compatible /v1/audio/speech, chunked streaming, per-word timestamps (→ free captions). Only real loss: no voice cloning.
Prebuffer 30–120 s. This is the unfair advantage: **narration is not
interactive, so the synthesizer can run ahead of the playhead. At 35× realtime, two minutes of lookahead costs ~3.4 s of GPU. Latency is removed from the problem list entirely** — every voice-agent latency article is solving a problem this project does not have. The buffer is simultaneously the moderation window.
Priority queue with TTL. (priority, deadline, text, ttl). Drop items past TTL
rather than speaking them late. Stale narration is worse than none.
Generation counter, or it will talk over itself. Single-writer playback thread;
every request carries a monotonic id; on preempt, bump the id, flush the queue, cancel the server, and drop arriving chunks whose id is stale, including ones already in flight. Without that last step: you cancel, three chunks are already on the wire, they play over the new utterance.
Never let the queue empty. Music is the always-on base layer, narration is added
on top, so "nothing to say" is silence over music, not dead air.
Library: RealtimeTTS 0.7.3 (MIT) — feed() takes an iterator so text arrives while audio plays, plus engine fallback chaining and word-timing callbacks.
YouTube's inauthentic content policy (renamed from "repetitious" 2025-07-15, clarified 2026-07-16) is channel-level, not per-video, and one bucket is a flat disqualification: AI personas delivering information on sensitive topics — health, legal, finance, politics. The narrator has a hard deny-list on all four. It talks about this repository and nothing else.
Do not build a plan that assumes ad revenue. The policy also targets content "easily replicable at scale," which an auto-generated stream self-evidently is.
narrator.py was written against jamiepine/voicebox's HTTP surface on 2026-08-04. The server was never installed. Nothing had ever listened on 17493 and /voice.json had reported available: false since the route was added — the client was talking to a port with nothing behind it, fail-soft, exactly as designed, and therefore silently.
tools/voicebox_server.py + tools/voicebox.sh are the missing half, deliberately on the same contract so a real Voicebox install can replace it by listening on the same port with nothing in RAVIO changing.
The voice is Kokoro, as this section always wanted. For one day it was not: Kokoro needs a phonemiser, the phonemiser is espeak-ng, and espeak-ng needs a package manager. sudo pacman -S espeak-ng closed that, and the compromise engine stayed on as a fallback rather than being deleted.
| speak | hexgrad Kokoro-82M via kokoro-onnx — 54 voices, 24 kHz, Apache-2.0, ~2.8 s of audio in well under a second of CPU once loaded |
| speak · fallback | facebook/mms-tts-eng — kept so a missing 325 MB model file or a broken espeak-ng degrades to a worse voice, never to no voice |
| hear | openai/whisper-base.en — ~145 MB, the smallest model that reliably takes a spoken sentence |
The pip package is `kokoro-onnx`, not `kokoro`. The torch build caps at Python <3.13 and both interpreters on this box are past it — system 3.14.2 and asdf 3.13.14. The ONNX build has no such cap, needs no second torch, and reuses the onnxruntime already installed.
Installed into a venv created with --system-site-packages, so it sees the system torch rather than downloading a second multi-gigabyte copy. That venv, not /usr/bin/python, is what tools/voicebox.sh launches — and neither is the asdf 3.13 that runs bridge.py, where none of this is importable at all.
`kokoro_possible()` is checked, never assumed, and /healthz reports engine and fallback so a silent downgrade to the worse voice is impossible to miss.
| engine | "Ask the orchestrator what is blocking the substrate lane." → STT |
|---|---|
mms-tts-eng | *"At the Orchistrader what is blocking the substrate lane?"* |
| Kokoro-82M | "Ask the orchestrator what is blocking the substrate lane." — verbatim |
Same whisper model on both sides, so this is the synthesiser's articulation and nothing else. It does not mean transcription is now reliable — this is synthetic speech into the recogniser, which is the easy case, and the read-back below stays mandatory.
POST /listen is an addition to the Voicebox contract, not part of it. A real Voicebox will 404 there and the cockpit treats that as no ear, not as an error — the same fail-soft rule as no voice.
The mic speaks the transcript back and stops; sending stays a separate deliberate click. A transcription that becomes an instruction without a human hearing what it became is a way to dispatch work nobody asked for.
This does not relax now that Kokoro round-trips verbatim. That measurement is synthetic speech into the recogniser — clean, level, no room, no accent, no crosstalk — which is the easy case and not the one a microphone presents. whisper-base.en is still the smallest useful model, and the failure mode being guarded against is not a garbled sentence but a plausible one: the read-back exists for the mishearing that reads as a perfectly sensible different instruction.
Reported from a live run: several voices talking over each other. Two independent causes, either sufficient on its own.
Across tabs. Every open page ran its own narrate() timer, and /narration.json pops a line rather than peeking. Three pages therefore spoke three different sentences simultaneously, and each was draining lines the others would now never say. Three live pages were confirmed in the soak log at the moment it was reported — §6.5's sid is what made that countable, which is the second time that field has paid for itself.
Within a tab. speak() started an <audio> per call with no queue, the speechSynthesis fallback queues rather than replaces and could add a third, and the old code resolved when playback started rather than when it finished.
The rule now:
The lowest live `sid` narrates. Deterministic, no negotiation to get wrong, and a
page that closes falls out of the peer set within six seconds.
Only the narrator may FETCH, not merely may speak. Muting the extra tabs without
this would have hidden the overlap and kept the data loss.
Captions go everywhere, relayed over the same channel. The caption is the
narration; only the voice is exclusive.
One serial queue per page, resolving on ended, with a 90 s guard — a promise that
never settles would wedge the stream into permanent silence.
Ambient lines are dropped, not queued. Reading five stale lines back to back is
worse than silence, which is the judgement §8 already made.
Operator-facing speech outranks narration, takes the floor, and hushes other pages.
The cockpit's reply field pointed at /api/chat first and that was the wrong end of amp, for two reasons.
It is the wrong content: /api/chat is everything the board did — every dispatch, ruling, note and question sorted by time. That is a feed, and reading a feed is not a conversation. The briefing is the conversation; the briefer has already read that feed and says what it means.
It is also the wrong consequence: do_chat_send with no lane starts the orchestrator, which runs git and dispatches to lanes. Combined with a microphone, that is a spoken sentence one mishearing away from starting a worker. /api/brief is the route amp itself describes as the one where "nothing here reaches the board, no lane hears it, and no work starts. The briefer reads and talks, and that is the whole of what this can do." That is the correct correspondent for a voice interface.
The caption was never on screen, and had never been. showCaption() wrote the narrator's line into #saytxt, which shipped style="display:none" inline in the very first commit and was never turned back on. So the top-centre plate rendered as an empty bordered rectangle at the top of every frame for the whole life of the project, while the voice said something else entirely. #saylog had the same defect.
This is worth stating as a class rather than a typo. §11 is built on showing what the voice is doing — show the filter firing, show the queue, never let silent suppression be indistinguishable from a broken pipeline. A hidden caption is that same failure one layer down: a muted page could not tell you what it was not saying, and the mic read-back (§11.5), which exists precisely so a human sees what the transcription became, was spoken into a blank box.
The plate now carries the line, its source and time, a one-line ellipsized trail, and three controls — because there are exactly three things you want to do about a sentence you just heard:
REPEAT (R) — say the last line again. When the voice is muted it says so in text
rather than doing nothing, for the §11 reason: a control that is silently inert is indistinguishable from a broken one.
HISTORY (H) — every line said this session, newest first, each row clickable to
hear that one again. Repeat is just the top row of the same list.
REPLY (T) — answer, without stopping. Same correspondent as the reading panel's
field, which is to say the briefer and only the briefer (§11.6). The reply is the same act whether you pulled over to type it or said it in flight, and reaching it more easily must not quietly make it a larger one. The briefer's answer comes back as a caption on the plate, not just as audio.
The ledger is client-side, and that is deliberate. The bridge's spoken deque only knows about narration — a status line, a mic read-back and the briefer's answer are all spoken by the page and never enter the narrator's queue, so a panel built from /narration.json alone would be missing three of the five things you actually heard. The server's deque seeds it at startup so a reload does not begin deaf.
`GET /narration.json?peek=1` was added for that seed. The plain route pops a line, which is why only the elected narrator was ever allowed to call it (§11.5) — any other page polling it ate lines the speaking page would then never say. Every page wants the ledger, and reading a history must not consume the future. ?limit=N (≤200) is how far back it goes; the default 12 was sized for the two-line trail, not for a panel you have opened to scroll.
`display:flex` beats `[hidden]`. The reply composer shipped open on every page load
because an author display rule outranks the UA's [hidden]{display:none}. Any element given a display must be told [hidden]{display:none} explicitly.
The plate ate a third of the frame. The trail carried two full sentences and wrapped
to three lines — 36 px of a 200 px plate, the tallest thing on it. What came before is what HISTORY is for; the trail is the glance, so it is clamped to one line.
Both fields stop their own keys (e.stopPropagation()), or typing a reply flies the ship and 1-7 swap the tube on the monitor behind you — the same trap the reading panel already documents.
Use the Travelmonstr catalogue. Two albums, 28 tracks, plus 22 RAVIO segue/bumper MP3s, all in ~/Projects/Travelmonstr/. You own them outright.
This is not a compromise, it is strictly the best option available:
$0, no caps, no attribution, no third-party claim surface.
You are the human author — which matters, because under the US Copyright Office's
Part 2 report (29 Jan 2025) "copyright does not extend to purely AI-generated material", so AI music gives you no Content ID shield and can't be registered.
It is on-brand. RAVIO is a c u l8er radio broadcast. The music is the point.
On Suno specifically, since it was the starting assumption: Suno is a music generator, not a TTS engine — its output unit is a song; there is no narration mode, no voice list, no SSML. (The likely source of the confusion is Bark, the open text-to-audio model Suno released in April 2023 — obsolete, don't build on it.) There is also no official public Suno API as of August 2026; every "Suno API" for sale today is an unofficial wrapper driving accounts, i.e. a ToS violation with ban risk. And its free tier is explicitly non-commercial while paid-tier download caps are unpublished — an undisclosed hard ceiling on exactly the thing a long stream needs.
Optional later: procedural ambient via Tone.js in the same page, which gets you sample-accurate sync with the visuals for nearly free, and is also unambiguously yours.
Twitch caveat if you ever stream there: the Music Guidelines prohibit "radio station-style broadcasts of recorded music without a live visual performance" — and note the conjunction, owning the rights is not sufficient on its own. RAVIO is safe because the music is background to a visual. Keep it that way, and keep VOD storage off (Audible Magic scans VODs and clips, not live audio).
Run the stream when the harness runs. Not 24/7.
This is a design decision, not a limitation. The stream's content is real work; when no work is happening there is nothing true to show, and §8's quiet mode is a graceful degradation, not a business model. AI Village — the closest existing thing to "watch agents work as entertainment" — is deliberately not 24/7: weekdays 11am–3pm PT with archived replays. Appointment viewing. It works.
It also sidesteps most of §13's operational pain: no 48-hour Twitch cycling, far less memory-leak exposure, no undisclosed-daily-quota risk.
RAVIO cockpit, in a REAL displayed window on the laptop
↓ getDisplayMedia({preferCurrentTab:true}) ← composited tab: GL + both dash
↓ canvases + #tv iframe + stage
+ deskRecDest.stream ← §10.3's audio bus, already built
↓
one MediaStream, H.264 forced ← ~2 Mbps, ONE upload from the house
↓ WHIP (HTTP POST of an SDP offer)
↓
walltube.traaviis.com — Phoenix + ex_webrtc, one Fly.io machine
├── WHEP ──────────────→ paying subscribers (sub-second, RTP forward)
└── Membrane.RTMP.Sink ─→ YouTube · Twitch · X (H.264 passthrough + AAC)
The single most important consequence: twelve of the thirteen landmines below stop applying. No Xvfb, no x11grab, no NVENC-vs-VAAPI decision, no OBS, no MediaMTX, no hand-tuned itsoffset, no YouTube daily-quota exposure, no enableAutoStop. The browser's own WebRTC stack does the encoding and the congestion control, and the fan-out to the public platforms happens on a machine in a datacentre instead of on a home uplink.
Three things this resolves that were blocked from other directions:
The iframe TV becomes broadcastable. §9 ruled out "play any website" because
HTMLTexture excludes cross-origin iframes. That constraint is about texturing a page onto geometry. Window capture never touches it — the same escape hatch that let #tv be a DOM iframe pinned to a rect rather than a texture.
§6.5's frame-time proof becomes earnable. It "cannot be earned headless at all"
and needed Xvfb plus a real window. Under this design the broadcast is the real displayed window, so the soak measures the thing it claims. The instrument built on 2026-08-04 finally has the conditions it was written for.
Landmine 11 — the home uplink — is defused by the restream, not despite it.
The laptop uploads exactly one ~2 Mbps stream no matter how many destinations there are. Four platforms from the house would have been ~8 Mbps against a measured 12 Mbps ceiling; four platforms from Fly is still one stream out the door.
Audio does not come from tab capture, even though Chrome supports it on Linux. §10.3 already built deskAC → gain → {analyser, recDest, monitor} with a createMediaStreamDestination at world/index.html:6059. Take that track and combine it with the display video track into one MediaStream. Two reasons: the VU meter calibrated to −60..0 dBFS then meters the actual broadcast rather than a parallel path, and the gain slider that already moves the file and the meter together moves the stream too.
2 Mbps is a measured floor, not a guess. §6.3's encoder test put worst-case luma SSIM at 0.9927 at 2000 kbps for RAVIO's own text. Do not drop below it to save bandwidth without re-running that test — the signs are the content, and an unreadable sign is not a cheaper stream, it is no stream.
Kept deliberately. This list is the record of a day-per-item that would otherwise be re-spent if the browser-encoder route fails and the ffmpeg pipeline has to come back. Only items 11 and 13 bear on the current design; the rest are the ffmpeg/Xvfb/OBS path.
**This is a HYBRID-graphics laptop, and which GPU you land on decides everything
below. Corrected 2026-08-04** — an earlier revision of this section said "this box is AMD, not NVIDIA", which was wrong about the machine and right only about what the browser happened to pick.
lspci: NVIDIA AD107M [GeForce RTX 4060 Max-Q / Mobile]
Chrome, unprompted, reported `ANGLE (AMD, AMD Radeon 890M Graphics (radeonsi
gfx1150 LLVM 21.1.6), OpenGL ES 3.2)` — the Strix Point integrated GPU.
So both are present, and by default the renderer runs on the iGPU while the RTX 4060 sits idle. Three consequences:
NVENC is available — on the 4060, not the iGPU. This reverses the earlier
"encoding must be VAAPI" conclusion. NVENC is a separate fixed-function block with its own memory path, which is strictly better than asking one integrated GPU to render and encode out of shared system RAM.
The `obs-browser` NVIDIA blacklist CAN fire here — if the browser is offloaded
onto the 4060, glVersion contains NVIDIA and OBS silently drops to software WebGL. Earlier text said the blacklist "cannot fire"; that was only true of the default iGPU path. Landmine 1 is live again, and it is a reason to keep Chrome + x11grab rather than OBS.
The good configuration has to be chosen, not inherited: render on the 4060 via
__NV_PRIME_RENDER_OFFLOAD=1 __GLX_VENDOR_LIBRARY_NAME=nvidia (or DRI_PRIME), encode with NVENC, and confirm with chrome://gpu that WebGL is hardware and on the device you meant. Unverified — nothing has been run on the 4060 yet.
Also measured: 30 GiB RAM with only ~8.6 GiB available at the time of checking. That is a real constraint on running a local model alongside a browser, an encoder and the harness, and it argues for the small end of §11.3's model range.
OBS Browser Source renders WebGL in software on Linux + NVIDIA, unconditionally.
obs-browser has a hardcoded driver blacklist: if glVersion contains NVIDIA, hwaccel = false. No setting overrides it. Check your OBS log for "Blacklisted driver detected" before blaming a shader. Not applicable on this machine (see 0), but it is the main reason the pipeline uses Chrome + x11grab.
OBS Browser Source is Chromium 127 (~2 years stale) — no HTMLTexture, no
WebGPU. The CEF bump is still awaiting review, targeted at OBS 33.
`-reconnect_at_eof 1` on an RTMP output does nothing. Those flags are documented
only for HTTP. RTMP has no reconnect options at all.
OBS's own reconnect has an undocumented 15-minute cliff (1.5× backoff, capped at
15*60*1000). A rough network patch walks you into 15-minute dead-air windows and stays there. The local relay is the fix — the encoder pushes to localhost, a separate process pushes relay → YouTube, and upstream can die and reconnect without touching the encoder or the browser.
`enableAutoStop: false`. The "YouTube ends your broadcast 60 seconds after video
stops" rule everyone quotes is an opt-in flag, not a platform cutoff. Left true, any one-minute blip silently kills the broadcast. This is the single most important knob on the whole list.
Use PipeWire, not PulseAudio. Not a preference — under this workload PulseAudio's
timestamps are not monotonically increasing, giving audio drift and speech distortion. (Mux hit this in production; PipeWire fixed it.) One null sink per source (tts, browser, music), mixed in the filtergraph so you get per-source gain.
ffmpeg cannot capture Wayland. kmsgrab needs root and breaks on 10-bit/HDR.
Run under Xvfb/X11.
A/V sync is a hand-tuned constant. The reference implementation hardcodes
1250 ms of itsoffset to match x11grab latency. Yours will differ, and it will drift.
YouTube has an undisclosed daily stream quota. Restart the encoder, never the
broadcast — a crash-loop can burn the allowance and lock you out.
NVENC is a separate fixed-function block, so encoding does not steal shader time
from the render; it shares VRAM bandwidth. Requires NVIDIA driver ≥ 570.
Check your upload link. 12 Mbps sustained is the real gate on a home connection.
Target 1080p30 as the safe default.
CDP screencast is a screenshot API wearing a video costume — ~25–31 fps ceiling,
out-of-order frames, and no frames emitted when the page doesn't change. Never use it for this.
SwiftShader is being deleted from Chromium and already needs
--enable-unsafe-swiftshader. Any plan whose fallback is software WebGL has no fallback.
--disable-background-timer-throttling --disable-backgrounding-occluded-windows
--disable-renderer-backgrounding --autoplay-policy=no-user-gesture-required
--disable-features=CalculateNativeWinOcclusion,BackForwardCache,PaintHolding
Leave GPU process isolation on (skip --in-process-gpu) so a GPU fault is a recoverable context loss rather than a total crash. Belt and braces: render from a setInterval fallback if rAF hasn't fired in > N ms, to catch a Chrome update silently changing throttling at 4 a.m. systemd Restart=always plus a heartbeat.
`walltube.traaviis.com` is the ON AIR lamp made true. Until it exists, the lamp on the monitor bezel is a decoration; the cockpit says the words and nothing leaves the room. walltube is the machine that makes the lamp a claim about the world.
It is a separate codebase and a separate deploy: an Elixir/Phoenix application on one Fly.io machine with one SQLite volume. It does four things and no others.
Accepts one broadcast over WHIP, from one authenticated operator.
Serves it to subscribers over WHEP, sub-second.
Restreams it to YouTube Live, Twitch and X over RTMPS.
Carries a chat that reaches back into the cockpit.
It does not render, does not know what a milepost is, and does not talk to amp. The cockpit is the only thing that knows what the picture means; walltube moves pixels and sentences. Keeping that line clean is what lets walltube be restarted, redeployed or lost without touching the harness.
| layer | choice | why |
|---|---|---|
| app | Phoenix 1.8 + LiveView | the chat and the viewer count are the app; LiveView is the shortest path to both |
| media | ex_webrtc | WHIP and WHEP are first-class; the elixir-webrtc/apps Broadcaster example is this exact product |
| restream | Membrane.RTMP.Sink | RTMP and RTMPS; wants H.264 video + AAC audio, which is exactly what we will hand it |
| store | ecto_sqlite3 on a Fly volume | one broadcast has one origin, so the single-host volume limit costs nothing |
| auth | phx.gen.auth, magic link | Phoenix 1.8's default is passwordless — no password to store, which matches the repo's posture on secrets |
A single SFU node covers well under 100 viewers, and §0's own audience research (Nothing, Forever fell from ~20k concurrent to 8–9 while the tech kept working) says that ceiling is not the binding constraint. Do not build for a scale the genre has never delivered; build the cap (§13.7) instead.
Boombox is not the answer here. It is the obvious first reach — one line for rtmp → hls — but its RTMP is input-only; there is no RTMP output. The restream leg has to be a Membrane pipeline with Membrane.RTMP.Sink, or a sidecar ffmpeg. Prefer the former: it keeps one supervision tree, and a sink that dies is a child that restarts.
The cockpit publishes H.264, not VP8, by filtering RTCRtpSender.getCapabilities('video') down to video/H264 and passing it to setCodecPreferences() before createOffer().
This one line decides whether the Fly machine is cheap or impossible. With H.264 in, the server remuxes — depayload RTP to access units, hand them to the RTMP sink, never decode a frame. With VP8 in, every RTMP destination needs a full 1080p30 transcode, which a shared-CPU Fly machine will not sustain, and the failure mode is not an error but a stream that falls progressively further behind.
Audio is transcoded regardless: WebRTC speaks Opus, RTMP wants AAC. That is cheap and unavoidable. Video passthrough is the thing worth protecting.
MEASURED 2026-08-07 — H.264 is offered, and it is capped at 720p30. RTCRtpSender.getCapabilities('video') on this machine's Chrome (147, X11 Linux) returns six H.264 variants alongside VP8/VP9/AV1:
profile-level-id | profile | level |
|---|---|---|
42001f | constrained baseline | 3.1 |
42e01f | baseline | 3.1 |
4d001f | main | 3.1 |
each in both packetization-mode=0 and =1. Main profile with packetization-mode 1 is exactly what Membrane.RTMP.Sink wants, so the passthrough design holds and no video transcode is required. getDisplayMedia is present; Opus is present.
But every variant offered is level 3.1, which is 1280×720@30 — not the 1080p30 §13 assumed. All six carry level-asymmetry-allowed=1, so the answer may specify a higher level and walltube can raise it. This is a server obligation that no amount of WHEP testing will surface, because a browser viewer will happily accept 720p and report success. Two consequences:
§13's "target 1080p30 as the safe default" is a claim about the ffmpeg path, not
this one. Until walltube's answer is written and the resulting resolution measured, the honest planning number is 720p30.
720p30 at the measured 2 Mbps floor is not obviously worse — §6.3's legibility test is
about whether text survives the encoder, and fewer pixels at the same bitrate means more bits per pixel. It also halves §13.7's egress. Do not treat the cap purely as a loss; re-run §6.3 at 720p before deciding to fight it.
Still log the negotiated codec from the SDP answer and put it on the dash next to the keys. Capability is a runtime fact and this measurement was taken in one browser build on one day; the broadcast will run in whatever Chrome is installed then.
Keyframes are the non-obvious one. WebRTC encoders emit an IDR when they are asked to — on PLI/FIR — not on a schedule. RTMP platforms want a keyframe roughly every 2 s and will produce ugly, slow-to-join players without one. So walltube sends a periodic PLI to the publisher to force the cadence its RTMP sinks need. This is a server obligation, not a browser setting, and it will not be discovered by testing WHEP alone: WHEP viewers get their keyframe on join and everything looks fine.
phx.gen.auth passwordless. Email in, link out, session back. One users table, one boolean is_broadcaster that exactly one account has.
Chat requires an account. Watching does not require one; speaking does. That is
the smallest gate that makes moderation possible at all.
Broadcast requires `is_broadcaster`. The WHIP endpoint checks it. There is no
second credential, no stream key to leak into a screenshot of the dashboard — which matters, because the dashboard is on camera by construction.
The cockpit's keys slot (§10.5) holds the session, not a key file.
Chat is Phoenix PubSub + Presence, rendered in LiveView on the walltube page and pushed back down to the cockpit over the same socket the bridge already polls.
In the cockpit it renders to the RIGHT of the angled pane, in R and C. Three constraints that are already settled and must not be re-litigated:
The pane is rotateY(11deg) with perspective on the wrapper, so both share one
vanishing point (§10.4 Round 2). A chat column that is a sibling of the pane inside that wrapper inherits the same room for free. Give it its own perspective and it gets its own vanishing point and comes unglued — the exact defect that put the mirror control bar back on its own origin in §10.3.
The transport bar is already a sibling and already spends --dashreach. §10.4 Round 4
ruled that only the bar spends it; the chat column does not, or every scene pays the height.
edgeGap() is ONE exported definition (§10.4 Round 7). The column uses it. Two
margins that are nearly the same reads as a mistake rather than a choice.
The inbound gate is not the outbound gate. §11's moderation runs server-side on POST /speak for text on its way to the voice. Chat is text on its way in, from people who are not the operator, and it needs its own filter with its own rules. Building chat without it and retrofitting later means shipping the unfiltered version first, which is the one version that cannot be taken back.
The picture goes out to four places; the talking should come back from all four. Researched 2026-08-07. Two of the three external platforms are readable, one is not.
| platform | read path | auth | cost |
|---|---|---|---|
| YouTube | liveChatMessages.streamList | OAuth, own broadcast | quota units, see below |
| Twitch | EventSub channel.chat.message (WebSocket) | OAuth user:read:chat, own channel | free |
| X | — none — | — | — |
streamList, never liststreamList establishes a server-streaming connection and pushes messages as they arrive; list is a polling loop. The docs say outright that streamList exists to reduce polling and avoid exceeding quota, and it returns chat history on connect before going live. maxResults 200–2000, default 500.
Why this matters arithmetically: the default allocation is 10,000 units/day, and a polling loop at the interval YouTube itself hands back is on the order of hundreds of calls an hour. At any per-call cost above ~1 unit, a four-hour broadcast does not fit in a day's quota. streamList sidesteps the whole question by not being a loop.
But the cost is genuinely undocumented, and that is the finding. liveChatMessages appears in neither the quota-cost table nor the streamList reference page. So: measure the burn on the Cloud console during the first real broadcast; do not compute it from the docs, because the docs do not carry the number. Budget the first broadcast as if it might exhaust quota, and have the failure be "YouTube chat stops arriving", never "the broadcast stops".
RESOURCE_EXHAUSTED on too-frequent requests is a documented behaviour — treat it as back-pressure to obey, not an error to retry through.
justinfanAnonymous IRC read (justinfanNNN, no token) is real and is what most third-party clients use. Do not use it here. Twitch's own migration guide recommends EventSub for reading chat and the Helix API for sending, and IRC's replacement path is the one that will still exist in two years. This is your own channel, so the OAuth cost is a one-time authorisation plus a stored refresh token — the argument for anonymous access (no account to bind) does not apply.
channel.chat.message over the EventSub WebSocket, scope user:read:chat. Note the payload shape is not IRC's PRIVMSG tags — some of the same information, a different format. Anything written against an IRC parser will need rewriting, so do not start with one "just to test".
No end-of-life date has been announced for IRC, and none was found. That is not the same as a commitment that it will stay.
There is no live-broadcast chat read API. As of 2026-02-06 X moved to pay-per-usage — $0.005 per post read, no free tier, 2M reads/month cap, and the legacy $200 Basic tier is closed to new signups and was retired for existing ones after 2026-06-01. Above the cap the only option is Enterprise at ~$42,000/month.
So X is a write-only leg: it needs a paid Premium subscription to receive the broadcast, needs a manual Go Live click each time (§13.6), and gives nothing back. That is three costs and zero returns. X should be the last thing built and the first thing cut.
Tag every message with its origin, and never re-order the timeline. External
viewers are seconds behind the WHEP feed, so their messages arrive referring to something already past. A merged column sorted by arrival is incoherent unless each row says where it came from. Platform badges are not decoration here — they are the thing that makes a mixed timeline readable. Do not attempt to compensate by timestamp-shifting; you would be guessing at each viewer's buffer.
A message that came from a platform is never emitted back to a platform. walltube
can relay its own subscribers outward and can read the others inward, but relaying an inbound message onward creates a loop the moment two platforms are both readable. One origin field, one hard rule, enforced at the boundary rather than in the UI.
Honour deletions, and state the window you cannot close. A message a YouTube
moderator removes must be removed from walltube's store and from the cockpit. But the dashboard is captured by getDisplayMedia and already restreamed — pixels cannot be recalled. There is an irreducible few-second window in which a deleted message was on the broadcast. Say so in the spec rather than discovering it in an incident.
Namespace identities. The same handle on YouTube and on walltube is two different
people. Render the origin next to the name, and key on origin|id, never on the display name — the same class of bug as §7.4's "identify a control by what it NAMES, never by how it is painted", and as keying briefing turns on role|at.
It resolves decision 5 (§16) in favour of reading (1), and it makes decision 6 harder. Relaying Twitch chat into the cockpit means the Twitch experience is no longer worse than walltube's — which is exactly what Twitch's terms ask for, and Twitch stopped penalising combined chat in March 2026. The compliant path and the wanted path turn out to be the same path.
But it removes the paid tier's strongest feature. If every platform's chat reaches the dashboard, a walltube subscriber is buying latency alone — sub-second picture, and a message that lands while the thing it is about is still on screen. That may well be enough; it is thinner than "your chat reaches the driver and theirs does not", and it should be priced and pitched honestly as what it is.
One H.264 elementary stream, one AAC encode, three independent Membrane.RTMP.Sink children. Independent is the point: a platform that rejects, throttles or drops must not take the other two — or the WHEP feed — with it. §13 landmine 4 said the local relay is the fix because the encoder should never see the internet; that reasoning survives intact, it has simply moved from the laptop to the Fly machine.
Per-platform facts, verified 2026-08-07:
YouTube Live — RTMPS, simulcasting explicitly permitted, no additional cost.
The most straightforward of the three.
Twitch — simulcasting has been allowed for both Affiliates and Partners since
October 2023, with no exclusivity clause, and as of March 2026 Twitch no longer penalises displaying a combined chat. But two written terms bear directly on this design (see §13.7): the Twitch experience must be at least as good as other destinations including chat engagement, and you may not direct Twitch viewers to leave for a concurrent stream elsewhere.
X — RTMP via Media Studio Producer, and it is the awkward one. It requires a
verified account with a paid X Premium or Premium+ subscription to obtain a stream key at all, and sending the RTMP feed is not sufficient to go live — someone must click Go Live in Media Studio for each broadcast. So X is a recurring cost plus a manual step that cannot be automated from walltube. Treat X as optional and last. The sink is the same code; the account is the blocker.
Running cost, at the design bitrate:
| line | cost |
|---|---|
| one Fly machine | ~$3/mo, and more if the AAC encode + 3 sinks need a bigger size |
| dedicated IPv4 | $2/mo — mandatory. Fly will not route UDP over a shared v4 |
| egress, WHEP viewers | $0.02/GB NA+EU; ~0.9 GB/hr/viewer at 2 Mbps |
| egress, 3 RTMP sinks | ~2.7 GB/hr total, ≈$0.05/hr, regardless of audience |
| X Premium | a monthly subscription, if X is in scope at all |
Ten concurrent WHEP subscribers across 48 broadcast hours a month is roughly $14/month all in. The shape to notice is that it is linear and unforgiving: a hundred concurrent subscribers is ~$105/month in egress alone, arriving as a surprise invoice after the fact.
So the cap and the budget ship in v1, not as a follow-up. A hard concurrent-viewer limit and a monthly bandwidth ceiling, both enforced server-side, with a house full sign when they bind. A refusal is a feature; an invoice is not. This is the same discipline as §6.5's soak report withholding a slope below 10 samples — the instrument must be willing to say no.
The open question this design creates: what does a subscriber actually buy? The same picture is free on YouTube, Twitch and X. The honest answer is latency and reach — the paid feed is sub-second and its chat lands on the dashboard in the cockpit, where it can change what happens next; the free feeds are three to six seconds behind and talk to a room the driver is not in. The free platforms are the trailer; walltube is the arcade.
And that answer is in direct tension with Twitch's written terms, which require the Twitch experience to be at least as good as other destinations including how you engage with Twitch chat, and forbid pointing Twitch viewers at a concurrent stream elsewhere. A paid tier whose entire value proposition is a better chat is, read plainly, the thing that clause is about. This is not a reason to abandon either — it is a reason to decide deliberately, in writing, before building the paywall. Three defensible readings:
Relay Twitch chat into the cockpit too, so Twitch engagement is not worse, and
sell subscribers on latency alone. Compliant, and it costs the paid tier its strongest feature.
Do not carry Twitch at all, and restream to YouTube and X only.
Carry Twitch, sell latency, never link out, and accept that clause 1 is a
judgement call about "at least as good".
Unresolved. It is decision 5 in §16.
Separately and non-technically: taking subscription income is a change in circumstances that can interact with benefit eligibility. That is worth checking with someone who knows those rules before the first payment lands, not after.
Found in research 2026-08-07, none yet met in practice — this whole section is unverified until walltube runs.
`walltube.traaviis.com` must be DNS-only — grey cloud. traaviis.com resolves to
104.21.32.80 / 172.67.184.107, which is Cloudflare. Cloudflare's proxy does not carry WebRTC/UDP. Orange-clouding this record breaks WHIP and WHEP in a way that presents as a broken ICE configuration and will cost a day to trace to DNS.
The dedicated IPv4 is not optional and is not the default. UDP requires it; a
shared v4 silently will not work. The app must also bind the UDP side to fly-global-services, on the same port it is reached on — Fly rewrites the address but not the port.
`ExWebRTC.ICE.FlyIpFilter` exists for exactly this and must be wired behind a
FLY_APP_NAME check, so local development is not filtered against a network it is not on.
`getDisplayMedia` needs a user gesture, every session. The keys slot is that
gesture. It cannot be restored from a preference, and a broadcast therefore cannot auto-resume after a page reload — which argues for treating a reload during a broadcast as an incident, not a hiccup.
Do not persist broadcast state before rehydrating it. §7's Round 10 lesson,
already paid for once: a save that runs before the restore erases the record. The keys slot has exactly the same shape.
A hidden pane runs at 1 fps and `innerWidth` reads 0. Every claim about the
broadcast's frame rate is a claim about a displayed window. Do not measure this headless and do not believe a number that came from a hidden pane.
The renderer is in ~/Projects/Travelmonstr/; the data is in ~/ProjectAmp2/. These are separate git trees and the repo tracks none of its sub-repos — commits go inside the sub-repo, never as a parent gitlink.
Resolution: RAVIO/ is a new lane in ProjectAmp2, in the `showcase` workspace, whose stated job in WORKSPACE_DIRECTION.md is:
Turn earned evidence into inspectable form without changing the claim.
That is this project's mission sentence, already written, before this project existed. Invariant 6 is the "without changing the claim" clause made executable.
RAVIO/
docs/spec/README.md ← this file
bridge.py ← §5. stdlib only, house style of preview.py
world/ ← renderer, forked from road-radio.html
index.html
journal.js road.js signs.js exits.js dash.js
narrator/ ← §11. facts → LLM → moderation → Kokoro
broadcast/ ← §13. WHIP publisher: getDisplayMedia + deskRecDest
(was: xvfb + ffmpeg + mediamtx units — path not taken)
walltube is NOT in this tree. It is its own repo and its own deploy:
walltube/ ← §13.1. Elixir/Phoenix, own git repo, own Fly app
lib/walltube/ ← accounts (magic link), chat, broadcast supervisor
lib/walltube_web/ ← LiveView: the watch page, the chat, the admin
lib/walltube/media/ ← ex_webrtc WHIP+WHEP, Membrane.RTMP.Sink ×3
priv/repo/migrations/ ← ecto_sqlite3
fly.toml ← one machine, one volume, DEDICATED IPv4
The boundary is load-bearing (§13.1): walltube moves pixels and sentences and never learns what a milepost is. The cockpit is the only thing that knows what the picture means. That is what makes walltube restartable, redeployable and losable without touching the harness — and it is why the restream leg lives there rather than here.
Art assets (backdrop.jpg, ravio_logo_*.svg, three.module.js) are copied, not symlinked, across the tree boundary, with their source path recorded — a symlink across trees breaks the static-serve model and confuses both git repos.
Upstream fixes that belong to RAVIO-the-visual rather than RAVIO-the-game (a drawDash bug, a nicer planet) go back to Travelmonstr. New work stays here.
Each milestone is independently demoable. The point of M0 is to fail cheaply.
Pipe real board events into a local Qwen3-8B for commentary over a Gource render of these repos, moderated and buffered, unlisted, for a week.
If you don't want to leave it on, no one else will. Everything after this costs real weeks; this costs a weekend and it tests the only assumption that matters.
/tmp was freed, so this became reachable. Gource, ollama, xvfb and every TTS backend are still not installed and need sudo — but M0 no longer needs any of them:
The visual is RAVIO's own road, which M2 already built. Gource was only ever a
way to avoid investing in a renderer first; that argument expired when M2 shipped.
The commentary needs no model. narrator.py's default backend calls nothing.
amp already writes the prose — every rating carries a why, every review an assessment — and quoting what the harness wrote is more truthful than paraphrasing it, cannot hallucinate, and costs no GPU. A local LLM becomes an enhancement for connective tissue, never the source of fact.
Voice is deferred, not faked. speechSynthesis exists in the browser but reports
zero voices (no speech-dispatcher/espeak on this box), so the on-screen caption is the narration for now. That ordering is also the safe one: nothing can be spoken that was not first moderated bridge-side and displayed.
Run live against the substrate workspace, this is everything the narrator had to say before going permanently quiet — 201 lines offered, 8 spoken, 193 suppressed as verbatim repeats:
1. Still on TRAAVIIS, with no evidence position on record.
2. On TRAAVIIS, direction is unrecorded: nothing has been proposed here, so there
is nothing waiting to start.
3. On TRAAVIIS, spec is unrecorded: 1 document(s), none of them rated against the
text that is there now.
4. On TRAAVIIS, workers is unrecorded: no worker has settled in this lane yet.
5. On TRAAVIIS, evidence is unrecorded: no claim from this lane has ever been
judged past `spec`.
6. On TRAAVIIS, standing is unrecorded: nothing has been registered here as having
to keep being true.
7. On TRAAVIIS, settled is unrecorded: the work has reported nothing about the
doctrine from this lane.
8. On TRAAVIIS, goals reads 1.00 against a bar of 0.60 — 8 of 8 stopped goal(s)
finished; 0 stopped short.
Every sentence is true and none was invented. The content of this stream is bounded by the work, and when the work stops there is genuinely nothing to say. That is the strongest possible confirmation of §13's cadence ruling, and it is a result no amount of rendering effort could have changed.
It also shows the narrator only ever talks about current_lane. With four lanes in the district it would have ~32 sentences rather than 8 — rotating the subject lane during quiet is the obvious next move, and it is still bounded, just less tightly.
**Quiet mileposts were emitted every 4 seconds — ~900/hour against a measured real
rate of 15–25/hour. `last_real_at()` filtered to non-quiet rows; with no real change ever recorded it returned `None`, the caller read that as "nothing recent", and appended on every tick. The road burned mileposts faster than the work could ever fill them, defeating the entire §8 calibration. Now paced against the last row of any kind: measured 1 milepost/minute**, which at idle speed matches the sign cadence. An unparseable timestamp now also stays quiet rather than falling through to the same bug by another route.
The moderation log leaked secrets. The filter records why it fired, because a
filter you cannot explain is one nobody trusts — but it stored the matched fragment, which for the credential gate meant writing the first 32 characters of a live token into a field displayed on screen. The secret gate now reports only shape: matched a credential pattern (35 chars, not recorded). Other gates still quote.
The first run produced "Still on TRAAVIIS, with no evidence position on record." six times consecutively. Truthful, and unwatchable — the exact sameness that took Nothing, Forever from ~20k concurrent viewers to single digits.
Invariant 3 forbids inventing variety to fill quiet, so the only honest remedy is silence. Verbatim repeats are suppressed and the queue is allowed to reach empty. gauge_lines() was also changed from returning one "best" gauge to returning all seven — picking a favourite meant one sentence forever, which under repeat suppression degrades to permanent silence instead. Neither is right; there are seven genuinely different true statements per lane and the narrator works through them.
The actual M0 test — leave it running for a week and see whether you open it — has not happened, and it cannot be run against an idle harness. The eight-sentence result is a floor, not the experiment. M0 needs a week with the harness working.
M2 existing still does not discharge M0. M2 proves the road reads the work; M0 tests whether anyone wants to watch it. Run M0 before M3.
RAVIO/bridge.py, 480 lines, pure stdlib. Reads the change feed directly from `.amp/amp.db` in read-only mode rather than through /api/db/changes, so it works whether or not a console is running; /api/state supplies the derived ratings and rungs that are computed per-poll and never stored, and its absence degrades the world without stopping the road.
Proof, run live against the `substrate` workspace: across three separate process invocations the journal went m = 2 → 3 → 4 and never rewound — invariant 4 holds across restart, which was the point of the milestone. amp_cursor resumed from stored meta at 4281 (the mirror's head) rather than replaying history. The unique index on amp_seq is what makes the changes loop idempotent: a re-read is refused by the database rather than by a check the caller might forget.
Three things it corrected in this spec on first contact with real data:
Lane rows carry no workspace → §7 districts, rewritten above.
A rating is `{value, n, why, bar}`, not a bare float. bar is the threshold the
rating must clear — so every gauge has a redline, and why is prose amp already wrote ("no claim from this lane has ever been judged past `spec`"). That is the best billboard and narration copy in the system and it costs nothing to carry.
`/api/flow` enumerates the dashboard's controls → §10, rewritten above.
It also confirmed invariant 6 works in the honest direction: all four substrate lanes report rung: null, so they fly at ground level and the sign reads unrecorded. The world does not round that up.
Not yet done in M1: the transcript-tailing loop (§5) is specced but unimplemented — it is the only sub-second signal and it lands with M2, where there is something to render it on.
RAVIO/world/index.html, self-contained ES module, no build step, importing the vendored three.module.js (r160). tools/vendor.py copies engine + sky across the tree boundary and records source path, size and sha256 in world/assets/PROVENANCE.json, so a drift between the two trees is detectable rather than silent.
Built: conveyor origin (camera pinned at 0 forever), 3-ribbon curved road at RIB_SEG=160, recycling centreline, 16 milepost-quantised billboards, S=300, fog matched to the far plane, altitude easing, live HUD off world.json.
Proof — the framebuffer, read back through `gl.readPixels` on a 40×22 grid, since screenshots need a displayed pane and this one is headless:
..........# :: ##........
..........######## : ..#############
............... :::::::: ..............:
.......: :::::::::::::::::::: :......:
: :::::::::::::::::::::::::::::::::: :
A vanishing-point road widening to the bottom of frame, billboards flanking it, and transparent regions where the sky image shows through behind the alpha canvas.
Verified live against the substrate workspace: signs: [30,30,31,31,…37,37] — each left/right pair on the same milepost and static while on screen, which is invariant 2 holding; steer: 0 with dialRate: 0 — dead straight, because nothing is being proved, which is invariant 1 holding; 6 of 7 gauges rendering the word unrecorded rather than 0.00, which is invariant 6 holding. Geometries 105, textures 16, draw calls 105.
Two defects found by running it, both predicted by this spec:
Canvas 0×0 → `camera.aspect = w/0` → Infinity → a NaN projection matrix, which
renders nothing forever, including after the pane returns. A hidden or not-yet-laid-out pane reports innerHeight === 0. Now clamped to ≥1, and polled, because a resize event does not fire when a pane is merely revealed.
`requestAnimationFrame` stopped firing entirely on a non-compositing page — the
exact §13 hazard. rAF now only schedules; a 100 ms watchdog drives the frame when rAF has been quiet for >250 ms. Without it a stream freezes on a frame nobody notices. This was observed, not anticipated defensively.
Deferred from M2, deliberately: troika SDF text needs npm and there is no build step, so signs use CanvasTexture at 1024×600 with §6.3's luma-contrast rules applied (white on near-black, 84px Arial Black, flat plates, no thin outlines).
Superseded within the hour. This paragraph originally ended "distance legibility is therefore unverified against a real encoder — that test is a §6.3 requirement and it has not been run." It was run: see §6.3's MEASURED 2026-08-04, worst case 0.9927 luma SSIM at 2000 kbps against a
qp 0reference.CanvasTextureat 1024×600 is not a compromise the encoder can detect, and troika is no longer on the critical path. Left visible rather than deleted because the reasoning that predicted the risk was sound; only the conclusion expired.
road-radio.html's 2D cockpit and painted sky are also not ported: they are ~250 lines of canvas art whose behaviour I would be guessing at. The HUD is plain DOM in the meantime, which is also what §10 wants long-term.
Not a planned milestone. It is what got built between M2 and this line, and it is recorded here because the build order otherwise reads as if nothing happened after the road first drew. Everything below is in world/ and verified against a live bridge.
The world stopped being a rail. flyShip gives the ship lateral and vertical velocity with soft walls — the corridor is a lane and leaving it is resisted rather than forbidden. flyScrub (PageUp/PageDown, held) scrubs the journal through the same acceleration model, because a per-press jump read as a cut: the value was right, the derivative was not. The ship still cannot overtake baseM; flying past the newest recorded milepost would render a future nobody has written, which invariant 3 forbids. Caught up, the speed is the arrival rate of new work.
Districts became reachable. { / } and a pointer picker switch workspace through amp's own /api/workspaces; [ / ] cycle lanes. Per §7 a lane row carries no workspace, so this is a write at the dashboard, not a scenic transition — and amp refuses it outright while a worker is running (do_workspace_use rebinds every path the harness writes to). That refusal is now surfaced verbatim instead of being swallowed.
The dash became instruments. dash.js draws a rack of seven vacuum tubes, one per rating. A tube either glows or it doesn't, so an unrecorded rating is a dark tube rather than a zeroed bar — invariant 6 rendered as hardware. 1–7 or a click puts one tube's reading on the monitor with amp's own why prose beside it; bar is drawn as the redline.
Quiet mileposts carry the lane's advertisement. A milepost with nothing to report does not render blank: it renders the lane's authored direction as billboard copy (§7's Advertisement signs). All 31 lanes have one, so this is real text, not filler.
The narration lies on the road. crawl.js textures spoken lines onto a plane along the corridor, riding the same drive odometer as the tarmac, so the crawl and the road move as one surface instead of sliding against each other. Perspective does the legibility work.
The sky became procedural. space.js — see §6.3's addendum. Driven by an encoder result, not by taste.
And the cockpit can act. V tips the eye down to the console; arrows select; Enter fires. The action list is fetched from /controls.json, which reads amp's own /api/flow — never hand-maintained — and POST /act refuses any route that is not in the set amp last published. The act then comes back down the road as a sign, weight
The TV plays the amp console itself, proxied same-origin so its API works inside the
frame; the honest cost of that proxy is stated at bridge.py:849.
Per-viewer state (lane, district, tube, TV) lives in localStorage, not on the bridge: two viewers of one stream must not fight over each other's selection.
Three UI defects found by using it, all hiding one honest refusal. flash() never cancelled its prior timer, so an older message's clear wiped a newer message; poll() blanked the same #err element every second, giving every message a ~1 s life; and openPicker had an async race where a slow fetch resolving after a newer open rewrote innerHTML and orphaned the rows already on screen — the buttons stayed visible and clicking them did nothing at all. Every open now carries a token. The lesson worth keeping: capture what the POST actually returns rather than inferring from the UI. Three layers of interface bug looked exactly like "amp is broken", and amp was correct throughout.
The road is built to be read at 90 px through an encoder at speed, which is why a sign carries a headline and nothing else. Everything under that headline — the path, the digest, the assessment prose — has nowhere to go on a passing plate. So the road is clickable, and clicking it stops the world and stands it up.
The reading phase. Raycast picks the sign you clicked, or the milepost you are
standing on if you clicked the tarmac. The panel rises and un-tilts, the ship holds station, the eye levels off, and the same row is re-rendered as a page. Nothing new is fetched — the row was already in journal, unread. It prints every field the row has and no field it does not: a pane printing evidence: — for a row with no evidence is inventing a measurement to fill a layout, which is invariant 6 in a different costume.
The briefing under it, with a field to answer in and a mic beside it. See §11.6 for
why that field talks to the briefer and not to the board.
The wheel scrubs, feeding the same velocity the held PageUp/PageDown feed, as an
impulse rather than a position jump, so the damping already there carries it to a stop. Calibrated against the road rather than against feel: measured 0.6 mileposts per notch at the first constant and tuned to 1.2, because one milepost is one sign and "scroll to the next sign" is what a hand is trying to do.
Proof, live: two adjacent signs picked as mileposts 367 and 368, road centre picked the ship's own 366, sky picked nothing; drive advanced 7.35 flying, 0.113 reading, 6.71 on resume; a line sent from the cockpit reached the briefer and its answer came back into the pane 15 s later.
The first version was a modal box over the scene, which is not what a world should do with its own surface. Two nested groups fix it: roadWorld cancels the hinge's offset so every vertex goes on being written in the world coordinates the conveyor already computes — updateRibbons() and crawl.update() do not know the hinge exists — and roadHinge rotates ribbons, dashes and the narration crawl about a line just ahead of the ship. The crawl comes with it, so narration lying on the tarmac becomes readable in place.
Standing it up was only half. Folded, it was still a road: kilometres long, curved, vanishing to a point, with a panel pinned over the visible part. So the same readPitch also collapses the span into the reading band and flattens the curvature out of it, one lerp per vertex inside the conveyor that was rewriting them anyway — a morph that cannot desynchronise from the road it is morphing.
Measured off the vertex buffer:
readPitch | deck z-extent | deck y |
|---|---|---|
| 0 | −3300 → 60 | −21.8 → 0 |
| 0.578 | −1683 → −148 | −8.3 → 0 |
| 0.998 | −506.7 → −299.1 | 0 → 0 |
The band is −300 … −500 and the corridor is ±90, so at rest the deck is the rectangle placeRead() measures — both are computed from READ_Z0/READ_Z1/CORRIDOR_HALF. Signs fade rather than fold (they are beside the road, not on it); dashes and crawl fade because they bunch into the band as it closes.
The four constants were swept, not chosen. __tuneRead() walks angle, band length and dolly distance while __read().rect reports what each combination projects to — the only way to tune a surface in a pane that cannot be screenshotted. Three results worth keeping:
The first band (z −90…−430) put its near edge at the camera's feet once folded:
1472×934 in a 1280×720 view, near corners projecting to y=1138. The band must be mid-road.
The ship has to close on the surface as it folds. Standing the road up 300 units out
is honest and gives a panel less than half the frame across, because a road is a strip 180 units wide: 582×343 before the dolly, 803×657 after. The geometry did not change, the distance to it did.
It will never be full-bleed, and no tuning changes that — the shape of the panel is the
shape of the road. The viewport clamp is therefore a constraint, not a guard.
Watch it, do not infer it. window.__grab(cols, rows) reads the framebuffer back as ASCII immediately after renderer.render() -- the drawing buffer is not preserved, so anything asked for later returns a cleared one, and "I took a screenshot and it was black" is the wrong conclusion drawn from the wrong hook. Sampling the fold at several values of readPitch is the only way to see the transition in a pane that cannot be screenshotted, and the first time it was actually watched it showed two things every endpoint measurement had missed: the plate ran off the bottom of the frame, and the mid-fold was thick with sign, dash and crawl texture. Both were fixed by looking. Note the readback contains the WebGL scene ONLY -- the panel is DOM and never appears in it.
A measurement that lied, and why. An early sweep reported the dolly having no effect at all. It sampled every 90 ms in a backgrounded tab rendering at 1 fps, so most samples read a frame that had never been posed. Measurements have to outlast a frame, and this one looked exactly like a broken feature.
Metadata is the smallest thing in the panel, not the biggest. As a two-column definition list it took 34% of the panel height on a six-field row and letterboxed the conversation. As a wrapped strip of key/value chips it takes 15%, and the briefing went from 187 px to 223 px — 43% of the panel.
Three defects found by building it, all in code that had been working:
**ribbons held {g, x0, x1, yLift} — the mesh was built inline and dropped on the
floor.** Enough to rewrite the conveyor every frame, and not enough to raycast: clicking the road handed three.js a plain object and threw inside intersectObject.
A conveyor's cached bounding sphere is stale by construction. Positions are
rewritten in place every frame while three.js caches the sphere from where the road was when first drawn, so the raycast misses the road entirely once it has scrolled. Null it before picking.
`#dashcv` is `inset:0` with `pointer-events:auto` at z-index 6, so it covers the
whole viewport and `#gl` never receives a click at all. Scene picking has to live in the dashboard handler's no-hit fall-through. The first version attached to #gl and did nothing.
window.__pick(x, y) and window.__voice() are the debug surfaces for all of this, in the same spirit as __ravio() — a headless pane cannot be screenshotted, so the world has to be able to answer questions about itself.
Path-indexed centreline (§6.4). Ramps become geometry. Altitude = rung. Advance-warning signs. Auto-navigation policy. Proof: force a rung retraction and watch the road descend to the lane.
Status after M2.5: altitude already tracks the rung and eases toward it, and the corridor already bends with corridorY. What is not built is the part D2 asks about — there is still no path-indexed centreline, so the entire visible road has exactly one curvature at a time and a diverging ramp remains structurally inexpressible. Note that "tube" in the code means the gauge instrument, not an off-ramp; nothing in world/ implements §6.4 yet.
VideoTexture drive-in playing Travelmonstr films. Preview screen via Electron OSR. Direction fields as billboards.
Status after M2.5: direction fields are billboards, and the screen exists as a DOM iframe glued to the monitor rect — the amp console proxied same-origin, code.traaviis.com, and a lane's own preview.py instance when one is running. What remains is genuinely M4: no VideoTexture, so no Travelmonstr film has been played, and an iframe is not a texture — it cannot be occluded by the road, which §9 already predicted and is the same constraint that killed "any website on the drive-in".
Kokoro + local LLM + moderation + prebuffer + generation-id queue. On-screen filtered. Proof: force a moderation hit and a model failover, and confirm the gate holds on the fallback path.
Status after M2.5: the gate is built and is on the server side of POST /speak — text is moderated there even when the caller took it from /narration.json, because a second client must not be able to reach the voice by a route that skips the filter. A Voicebox client at 127.0.0.1:17493 is wired, and /voice.json currently reports available: false with the road driving on unaffected: voice absent is not an error condition for the stream. The proof above is still unrun — no moderation hit and no failover has been forced against a live synthesiser.
~~Xvfb + Chrome + PipeWire + NVENC + MediaMTX + tee.~~ Replaced by getDisplayMedia + deskRecDest → WHIP, per §13's pipeline. enableAutoStop:false and the rest of the landmine list belong to the path not taken. Proof: a 24-hour soak with renderer.info and RSS logged every 5 minutes, before anything is public.
Status: the soak instrument is built (§6.5) at a 1-minute cadence, and tools/soak_report.py renders the verdict. The soak itself has not been run. The constraint that instrument surfaced — a pane that is not displayed runs at 1 fps — is now satisfied by construction rather than by Xvfb: the broadcast is the real displayed window on the laptop, so the soak measures the thing it claims.
Phoenix + ex_webrtc + SQLite on one Fly machine. WHIP in, WHEP out, magic-link auth, chat, and the three RTMP sinks. Dedicated IPv4, grey-cloud DNS. Proof, in this order, each earned before the next:
WHIP from the cockpit → WHEP in a second browser, and the **negotiated video codec
logged as H.264** (§13.3). Without that line the rest is a transcode, not a remux.
A viewer chats; the message lands right of the angled pane in R and C (§13.5).
One RTMP sink up, with a measured keyframe interval ≈2 s — the periodic-PLI
obligation, which WHEP-only testing cannot detect.
Cap and bandwidth budget bind under a forced-low limit and produce a house full
sign (§13.7). Prove the refusal, not just the service.
Aggregation (§13.55): a YouTube message and a Twitch message land in the same column
as the walltube one, each badged with its origin — and a deletion on YouTube removes the row here. Plus the YouTube quota burn read off the Cloud console for a full broadcast, since the docs do not carry the number.
Keys slot on a measured rect, full-bleed outside mode, the saucer + beam ported from c-u-l8er.link/ravio/, magic-link field under the beam. Proof: sign in from outside the ship, come back inside, and broadcast — one unbroken gesture, with the dash chrome fully hidden throughout and restored after.
Do not reorder M0.
M7 before M8. The keys slot is an affordance for a session that must already exist; building the door before the room means the outside view's three states (§10.5) can only be tested by faking them, and a faked state is the one that ships wrong.
~~Cadence.~~ RATIFIED 2026-08-04: the stream runs when the harness runs.
§8's measurement made this one-sided — the repository was idle for four straight days during measurement.
~~Is the dial `evidence`?~~ **RESOLVED 2026-08-04 — measured, and the answer is
no, not on its own.** evidence moves on the order of a few times a day (40 ladder-carrying reviews all time, external never once reached). As the sole dial it is a dead-straight road ~99.9% of the time.
What replaces it is a three-scale split, each honest at its own tempo:
| Channel | Source | Tempo | Reads as |
|---|---|---|---|
| curvature | evidence rate | a few / day | rare, and means something when it happens |
| altitude | rung | a few / week | the big move; a retraction is a visible descent |
| speed | backlog | continuous | how fast work is landing right now |
| signs | change feed | ~20 / hour | the actual content |
Curvature stays on evidence because it is rare. A curve you see three times a day is an event; a curve you see constantly is wallpaper. The mistake was expecting one variable to carry every timescale.
Two corrections to §2 that fall out of the same measurement:
The rung lives on a review under the key `ladder`, not rung. My first parser
guessed rung/claim_rung/verdict_rung, found nothing, and would have reported "no ladder entries have ever been recorded" — which is false. Absence of evidence is not evidence of absence; bridge.py must read ladder.
A review also carries `retractions` (on 12 of 40) and a prose `assessment`.
The assessment is several sentences of specific, already-written justification — "WRL remains at `live_local`: the finished goal made the verifier genuinely repository-hermetic … but it did not exercise a qualifying deployed machine." That is the narration script, written by the harness, for free. §11.1 says feed the narrator facts rather than pixels; this is the best fact source in the system.
~~Does the viewer ever steer?~~ **PARTIALLY RESOLVED 2026-08-07 — the viewer
speaks, and is not yet allowed to steer.** §13.5 puts chat in the cockpit, which pulls in exactly the subsystem this decision deferred: "a moderation surface and a whole subsystem." That is now bought deliberately rather than avoided, and §13.5's inbound gate is the price.
Steering itself is still out. Chat-as-input ("chat votes the next exit") remains the single strongest engagement mechanic in this genre's history — Twitch Plays Pokémon's anarchy/democracy vote was invented mid-stream — and it still changes §7. The distinction to hold: a message that appears on the dashboard is content; a message that moves the road is a write, and invariant 1 says the road may never move for a reason the viewer cannot read off a sign. Steering-by-chat has to answer invariant 1 before it can be built.
Public or unlisted. M0 should be unlisted. When it goes public is a separate call.
~~The Twitch clause vs. the paid tier.~~ **RESOLVED 2026-08-07, same day it was
opened — by aggregation (§13.55).** Relaying Twitch chat into the cockpit makes the Twitch experience no better and no worse than walltube's, which is what the clause asks for, and Twitch stopped penalising combined chat in March 2026. Reading (1) below is therefore adopted: carry Twitch, relay its chat inward, sell latency, and never link out. The cost is that decision 6 gets harder — see there.
Original text, kept because the reasoning still governs what may not be built: Twitch's written terms require the Twitch experience to be at least as good as other destinations including chat engagement, and forbid directing Twitch viewers to a concurrent stream elsewhere. walltube's paid tier sells sub-second latency and a chat that reaches the cockpit — which is, read plainly, a better experience sold elsewhere. Three defensible readings are recorded in §13.7: relay Twitch chat into the cockpit too and sell latency alone; drop Twitch; or carry Twitch, sell latency, and never link out. Decide before building the paywall, not after.
**What a subscriber buys, given the same picture is free on three platforms —
SHARPENED 2026-08-07 and now the hardest open question in this spec.** The working answer was the free feeds are the trailer, walltube is the arcade: the paid feed is sub-second and its chat reaches the dashboard, the free feeds are behind and talk to a room the driver is not in.
Aggregation (§13.55) takes half of that answer away. Once Twitch and YouTube chat are relayed into the cockpit, everyone's message reaches the driver. What remains is latency alone — a message that lands while the thing it is about is still on screen, versus one that arrives seconds after the moment has passed. That is a real difference and it may be enough. It is also much thinner than the original pitch, and the whole revenue model rests on it.
Untested against a single real viewer. It should be the first thing checked once M7 runs, and it is cheap to check — ask the people in the free chats whether they would pay for the fast one. Do not build the paywall before that answer exists.
Does X stay in scope at all? (Opened 2026-08-07.) X costs a Premium
subscription to receive the stream, requires a manual Go Live click per broadcast that cannot be automated, and has no readable chat at any price a solo project would pay. Three costs, zero returns. §13.55 recommends it be the last thing built and the first thing cut; this decision is whether to build it at all.
Existing-code claims: read directly from ~/Projects/Travelmonstr/tools/road/road-radio.html, ~/Projects/Travelmonstr/site/ravio/index.html, and ~/ProjectAmp2/code/{amp,server,store,preview}.py on 2026-08-04. Line numbers are as-on-disk that day.
Platform, licensing, TTS, rendering and prior-art claims come from web research conducted 2026-08-04. Findings carrying explicit uncertainty in that research and not independently verified here:
HTMLTexture's origin trial end (M148–M150 vs M151; Chrome stable is 151) — **verify
before relying on §10's upgrade path.**
troika's WebGPU incompatibility is inferred from its GLSL-injection architecture,
not confirmed by a maintainer. Irrelevant while on WebGLRenderer.
CVE-2026-34764's patched-version range was not confirmed. §9.2 avoids the affected
code path entirely.
GEMA v. Suno (Munich) had a verdict scheduled 2026-07-31; outcome not retrieved.
§12 does not depend on it.
OBS PipeWire audio capture is still a third-party plugin per its own repo, though
several 2026 blogs claim it shipped natively. §13 does not use OBS.
Read directly, not from research: the saucer/beam implementation was fetched from https://c-u-l8er.link/ravio/ and read as source; saucer() at world/dash.js:32 and its call at dash.js:107 were read on disk and confirmed identical. The audio bus at world/index.html:6059 was read on disk. traaviis.com's Cloudflare addresses were resolved live. The Fly org's existing apps and the fact that no dedicated IPv4 is currently allocated were read from flyctl.
From web research, and NOT independently verified — every one of these is a claim this spec depends on and has not yet met in practice:
Fly.io egress at $0.02/GB (NA/EU) and the dedicated-IPv4 requirement for UDP. The
cost table in §13.7 is arithmetic on top of a quoted rate; nothing has been billed.
Membrane.RTMP.Sink accepting H.264 + AAC over RTMPS, and Boombox having no RTMP
output. If Boombox gains one, §13.2's reasoning should be revisited.
Twitch's simulcast terms, including the March 2026 combined-chat change. §13.7 and
§16 decision 5 rest on a reading of terms retrieved second-hand from summaries — read the actual written Terms before building the paywall. The research itself noted that a broadcast comment and the written Terms had appeared to differ, and that the written Terms govern.
X requiring verified Premium/Premium+ for an RTMP key, and requiring a manual *Go
Live* click per broadcast. This makes X a recurring cost plus an un-automatable step and is the reason §13.6 ranks it last.
Chrome tab-audio capture on Linux via getDisplayMedia. §13's pipeline deliberately
does not depend on it — audio comes from deskRecDest — so this claim is noted but not load-bearing.
That setCodecPreferences will actually yield H.264 on this machine's Chrome.
§13.3 requires logging the negotiated codec precisely because this is unverified.
On §13.55 specifically, one claim is known to be missing rather than merely unverified: the quota cost of liveChatMessages.streamList and .list is absent from Google's own quota-cost table and from the streamList reference page. Both were fetched on 2026-08-07 and neither carries the number. Any figure quoted for it elsewhere is community folklore. This is why §13.55 requires measuring the burn on the console rather than budgeting from documentation. The rest of §13.55 — streamList being server-push, the 10,000-unit default, RESOURCE_EXHAUSTED as back-pressure, EventSub's user:read:chat scope, justinfan anonymous IRC being real but not recommended, the absence of an IRC end-of-life date, and X's 2026-02-06 move to pay-per-usage with the retired Basic tier — comes from documentation and summaries read that day and has not been exercised against a live broadcast.