The renderer, the shaders and the debug harness
Read this before changing the WebGPU renderer, a shader, or anything the debug harness draws. The recurring lesson is that a renderer defect looks plausible — a graph with no labels still renders — so the harness and the fixtures need their own controls.
WGSL ships minified
- The bundles ship minified WGSL, not the shader text as written (round 52). Multi-line WGSL literals carry the
wgsltemplate tag (src/render/wgsl.mts; identity at runtime) andscripts/wgsl-minify.mjsstrips comments/whitespace at build time with${...}kept byte-for-byte opaque — so when adding a shader, tag the literal, and never put an interpolation inside a WGSL comment (a build error; spell the name in prose). The transform runs in dev builds too, deliberately: Playwright exercises the same transform that ships.
debug/ — the manual harness
debug/: The manual dev harness (npm run watch→ http://localhost:3333/), rebuilt in round 43. Offers fifteen networks: six from real exports (four fixtures shared with v3's WebGL harness underv3/debug/webgl/, the 465k-edgendex-x-largelocal todebug/, and a clustered variant derived from em-web in-page) and nine built in-page (round 124.6 added?network=greek-gods, the taxi-track reference picture), each with a hand-authored v4 stylesheet indebug/styles.js— including the real enrichmentmap.org style, a port of v3's own default debug graph (?network=v3-default, round 46.6) and, since round 57.5, v3's four documentation demos (?network=node-types/edge-types/edge-arrows/labels). Those five are the ones to open when the question is drawing rather than scale: between them they put every shape keyword, every curve style, every arrowhead in both fills and the whole label surface on one screen. Plus plus view/layout/core-toggle/selection/event/add-remove sections and a stats overlay. Use it for renderer, interaction and gesture changes that are hard to verify in unit tests alone.- Round 114.7 gave the layout section four things a person judging a layout needs: Preset restores the positions the graph loaded with (a snapshot
init.jstakes synchronously aftercytoscape()returns — the factory runs the load layout to completion first, flow included), a force-only Live box (animateLive; Animate now tweens every layout to its finished positions), a layout-appropriate edge types box that re-applies the sheet with the curve style the layout reads best with (taxi for flow and breadthfirst, bezier for the rings, haystack for force — the table isdebug/layout-config.js), and a Hover select that dims or hides everything outside the hovered neighbourhood (debug/hover.js, timed in the console — round 102's "app spelling today"). The spiral extension example lives indebug/spiral-layout.js, written over the contract the way a built-in is (nodeDimensions,packComponentsover its own array,finish), so it animates and avoids overlap. All three files export for the module suite, which runs the spiral headless against the real library. - Round 125.10 made the page the layout audit's instrument: a Live spacing slider that rescales the current positions about their centre as it is dragged (
layoutConfig.scaledPositionsover a snapshot taken at eachlayoutstop; applied throughpositions(), not a preset run, so the layout's own stop does not re-base it), an Airiness readout (debug/airiness.js— nearest-box and edge gaps through a quadtree, shared with the quality suite;test/modules/airiness.mjspins it against a brute-force twin), and edges coloured as their source node on the workflow, npm-deps and reactome scenes (thesource-bandderivation indebug/fixtures.js, which the generated networks now run through as the fetched ones do). - Round 125.11 put every layout option on the page: an Options panel generated from
debug/layout-options.jsfor the selected layout (its own options, then the shared plumbing; the boxes' options — animate, seed, avoidOverlap, labels, pack, tidy, spacing — stay where they are and areOWNED; function-typed options areEXCLUDEDwith a reason), merged over the boxes' spelling on Apply with only the fields that differ from the library's default sent, and acy.layout({...})readout of the exact call.test/modules/layout-options-panel.mjsholds the table to the declaration: every member of every layout option interface insrc/public-types.mtsis in the table, owned or excluded, and nothing in the table is unknown to the interface. - Round 118.4 added the force-only Infinite box (
infinite: true— the run keeps going at no cost at rest, a dragged node reflows its neighbourhood; Stop ends it and Reheat wakes it) and a Force overlap by select (avoidOverlap: 'settle' | 'sim' | 'both'— the settle's exact pass, a separation sweep every tick, or both; pinned tosimunder Infinite, where there is no settle). Both spellings are pure functions indebug/layout-config.js(forceAnimation,forceOverlap) with the module suite's specs. Driving the page by script under a live GPU run:node.position()andrenderedPosition()read the CPU column, which is stale under the lease, so a scripted pointer aimed by them lands on the background (a pan) rather than the node — find the node by hovering (cy.on('mouseover')fires from the GPU pick) or stop the run first, as 118's page drive and browser spec do. debug/fixtures.jsholds the fixture conversion and the generators, split out sotest/modules/debug-harness.mjscan exercise the same code: that spec asserts every fixture exists at the path the page will fetch, and that every sheet compiles against that fixture's real data. It is the only automated coveragedebug/has, and both halves exist because both failed silently before round 43. The 2026-08-05 review pass added three more, each pinning a defect a person had to open the page to find: the compound fixture lays out into disjoint parent boxes (grid places leaves in declaration order and parents derive from where their children land, so the node order andcolstogether decide readability), the event log reads layout once per frame rather than once per event, andwatch:syncbinds livereload on every interface (itslocalhostdefault resolves to::1here whilehttp-server -oopens 127.0.0.1, so the client never connected).- When the page does not come up, read the message on it: it names the phase.
debug/load-error.js(round 65.13) writes it, and the phase —network,http,decode,init— is passed in by the caller rather than guessed, because guessing is what the previous version did:init.jscalledloadNetworkinside the fixture's promise chain, so every library error (sheet compile,cytoscape(), layout) was reported as a broken fixture, and for a wire-loaded network as "rebuild the site". The generated networks build outside any promise and showed the real error, so a WebGPU failure read as "the binary networks are broken, the built-in ones are fine" — which is how it was reported. A fatal message is also sticky now:startStatsrewrites#statstwice a second and had been erasing the adapter error within half a second of it appearing. debug/slim-ndex.mjsrecords how the 34 MBndex-x-largefixture was derived from its 250 MB original, so the slim is re-runnable rather than a mystery blob.- The status build ships the fixtures as v4's own binary wire format (round 46.5). Measured over the five fetched fixtures: 102.5 MiB of JSON becomes 37.5 MiB, and the largest goes 34.1 -> 9.5 MiB — which is what puts every one of them under Cloudflare Pages' 25 MiB per-file cap, so the deploy carries every network itself with no off-site bucket and no CORS rule. Note what it is not: gzipped, binary and minified JSON are within 1% of each other, so this is a file-at-rest win (which is what the cap measures) and a parse win, not a transfer one. The build writes a manifest (
status-config.js, generated into the output tree) naming each encoded fixture;debug/init.jsprefers it and falls back to the JSON when it is absent, which is whatnpm run watchdoes — local development is unchanged. The encoder runsdebug/fixtures.js's owntoGpuElementsand calls the built UMD bundle —build/cytoscape.umd.js, the one filedebug/index.htmlloads — notsrc/, so the page decodes with exactly the code that encoded it. It read the CJS bundle until 2026-08-11, which only looked equivalent:npm run watchrebuilds the UMD alone andnpm run statusbuilds nothing, so a tree can hold a fresh UMD beside a CJS from an earlier commit. A spec pins the encoder's bundle against the page's script tag.
- Round 114.7 gave the layout section four things a person judging a layout needs: Preset restores the positions the graph loaded with (a snapshot
Something has to open the page
- A per-frame GPU readback that only ever returns zero looks exactly like a converged sim. The force runtime's convergence poll (round 18.3) mapped a staging buffer at frame start and the frame's encode skipped the copy while the buffer was mapped — so the copy was skipped on every frame, every readback was the staging buffer's initial zero, three zeros counted as settled, and every GPU force run with the default threshold stopped after nine iterations for the whole life of the integrator until round 116.1's spec needed a converged run. The spectral seed made the result plausible, the settle's separation hid the pile, the lease spec ran with
threshold: 0and the executor-invariants spec's bounds were loose enough to pass a nine-iteration run. Two rules from it: a readback path needs a spec that asserts the value moves (finite, non-zero, changing across polls), not only that the run ends; and when a poll and a copy share a buffer, the poll must wait for a copy to have been encoded since its last map (dispCopied) rather than mapping whenever it is free — call order between the renderer's frame phases is not something a runtime should assume. - Something has to open the page.
debug/now has specs, and they are worth having, but round 43 shipped with its own risk note saying they prove sheets compile and not that anything still looks right — and one day later a maintainer opening the page found three defects, one of which (a conservativefit()inflating compound graphs ~2×) was insrc/and visible in every app with a compound graph. When a change touches the harness, the renderer or bounds, drive the page:npm run watch, or a scripted browser that loadsdebug/index.html, screenshots it, and compares againstv3/debug/'s equivalent.
The adapter you got is not the GPU the box has
- Run
npm run gpubefore writing "this environment has no GPU" or deferring hardware work. It launches Chromium with the harness's own WebGPU/Vulkan flags and prints a HARDWARE / SOFTWARE-ONLY / UNKNOWN verdict. Twice a session has read a SwiftShader adapter label in an ad-hoc scripted browser and concluded the box had no GPU — on a machine with a discrete RX 580 — because its launch flags fell back to software (round 93.2 was the second time). Two facts the script keeps separate: the card being present (lspci inventory) and the browser reaching it (adapter identity); only the second licenses a GPU benchmark number or a deferral. Two footguns it encodes:navigator.gpuis unavailable onabout:blank(the probe serves a loopback http page), and a failed probe reports UNKNOWN (exit 1) rather than "no GPU", so a broken environment cannot manufacture the very conclusion the script exists to prevent. Goldens stay pinned to SwiftShader regardless — the verdict governs benchmarks and hardware-only work, not the visual suite.
A columnar payload can lose a whole column silently
- A columnar payload can lose a whole column silently, and the page still looks plausible. Round 46.5 re-encoded the harness fixtures into the binary wire format, and the first reader treated a dictionary column (
{ dict, indices }, 1-based, 0 = absent) as a plain array — so every string column in every fixture came backundefined. The graph still rendered: right node count, right edges, right positions, no labels and no categorical colours. Nothing throws on that. When a format has more than one column encoding, the spec has to assert each column still carries values after the round trip, not that the payload parsed; the control (read the dict as an array) must fail on every fixture.