Pulp in the browser — the WAM v2 and WebCLAP adapters, the wasm runtime, the Skia/WebGL2 browser window host, and the WebGPU (emdawnwebgpu) GPU-audio lane. Covers what does and does not compile to wasm, the worklet-thread constraints (no std::thread, no fetch, no navigator.gpu), the non-realtime tick, memory growth, how to PROVE a GPU lane actually ran rather than silently falling back to CPU, and the silent-failure traps that make a wasm build "work" while producing no audio or no pixels.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add danielraffel/pulp --skill web-plugins --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Web Plugins?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/danielraffel-web-plugins)More formats (shields.io, HTML) on the badges page.
---
name: web-plugins
description: Pulp in the browser — the WAM v2 and WebCLAP adapters, the wasm runtime, the Skia/WebGL2 browser window host, and the WebGPU (emdawnwebgpu) GPU-audio lane. Covers what does and does not compile to wasm, the worklet-thread constraints (no std::thread, no fetch, no navigator.gpu), the non-realtime tick, memory growth, how to PROVE a GPU lane actually ran rather than silently falling back to CPU, and the silent-failure traps that make a wasm build "work" while producing no audio or no pixels.
---
# Pulp on the web (WAM v2 / WebCLAP / browser UI)
Pulp targets the browser through two audio ABIs and one UI host:
| Piece | Where | What it is |
|-------|-------|------------|
| WAM v2 adapter | `core/format/src/wasm/wam_adapter.cpp`, `wam-runtime.mjs` | The whole module lives inside one `AudioWorklet` |
| WebCLAP adapter | `core/format/src/wasm/` + `examples/web-demos/wclap-build/` | Real CLAP, wasm-hosted; needs COOP/COEP for threaded shared memory |
| Browser window host | `core/view/platform/web/` | `core/view`'s widget tree painted by **Skia Ganesh on WebGL2** |
The audio side and the UI side are **independent**. The browser UI module is
DSP-free and talks to audio only through the web player's `HostAdapter` seam, so
the *same* wasm UI module mounts against both a WAM and a WebCLAP demo. Build it
once; if it looks different across the two ABIs, that is a shared-player bug, not
a per-demo tweak.
## What is NOT in the browser
Be precise about this; it is easy to overclaim and the claims get quoted.
- **The UI is not Graphite/Dawn.** The published Skia wasm slice is **Ganesh on
WebGL2** and ships zero `wgpu` symbols (see the `skia-gpu-build` skill's wasm
section). WebGL2 has **no compute shaders**, so the UI's render path cannot
carry GPU DSP — the two lanes are unrelated (see below).
- File-backed loaders and native editors are compiled out (`PULP_WASM` /
`PULP_HEADLESS`). A plugin that needs an asset on the web must carry it in the
binary or fetch it on the main thread — an `AudioWorkletGlobalScope` cannot
fetch, which is also why the WAM worklet build must be `SINGLE_FILE` (the wasm
embedded in the .js).
## GPU audio in the browser: it exists, and it is a THIRD lane
**CI capacity isolation.** The real-GPU macOS proof is advisory and must not
consume a required merge-gate runner. `.github/workflows/web-plugins.yml`
therefore reads `PULP_ADVISORY_GPU_MACOS_RUNS_ON_JSON`, not
`PULP_LOCAL_MACOS_RUNS_ON_JSON`, and passes it through
`tools/scripts/resolve_advisory_macos_runner.py`. The proof skips when the
advisory selector is unset; configured selectors must be self-hosted and cannot
contain `pulp-build*` or `pulp-preamble*`, and must carry a
`pulp-advisory-*` identity. Give the proof its own governed tartci supervisor
and `pulp-advisory-gpu` label. Do not add that advisory label to a required
runner, and do not use Orchard.
That isolation was being defeated by the workflow's trigger list, not by its
runner selector: `web-plugins.yml` also ran on `merge_group`, so
`GPU audio proof (macOS, real WebGPU)` claimed a macOS runner on **every merge-
queue entry** while gating nothing — GitHub validates one merge group at a time,
so that competed directly with the required `macos` gate for the same Mac pool.
The workflow now runs on `pull_request` only. Capacity isolation is a claim about
*when* a job runs as much as *where*: an advisory job must not appear on
`merge_group`, because nothing there is advisory in effect — it either gates the
merge or it just slows the queue down.
`examples/web-demos/gpu-audio/` runs SuperConvolver's convolution as a **WGSL
compute shader on the browser's real WebGPU device**. Do not repeat the old line
that this is impossible or unstarted. But be equally precise about its shape,
because three separate constraints force it and each one is a trap:
- **The DSP links `emdawnwebgpu`, not Skia.** Dawn's Emscripten port implements
`webgpu.h` over `navigator.gpu` and is **completely independent of Skia** — the
UI's Ganesh/WebGL2 slice is irrelevant here and must not be dragged in.
`tools/cmake/PulpGpuWasm.cmake` (`--use-port=emdawnwebgpu`) is deliberately
Skia-free.
- **An `AudioWorklet` cannot touch `navigator.gpu`. At all.** So the compute
CANNOT live where the audio lives. It runs in a **DedicatedWorker**, and hands
finished blocks to the worklet over **SharedArrayBuffer rings** — the same shape
as native `GpuAudioTransport`: fixed latency primed as N blocks, lock-free ring,
a `MissPolicy` the audio thread applies when a block is late. (WebCLAP already
needs COOP/COEP, so the SAB is free; a WAM-only page would have to earn it.)
- **The native blocking readback is FATAL in a browser.** `MapAsync` + a spin on
`ProcessEvents` works natively and **deadlocks on the web**: the spin starves the
very JS event loop that would resolve the map. The browser arm must be genuinely
async — submit block N+1 while N's map is still in flight, bound the in-flight
depth, and route a blown deadline to `MissPolicy` instead of hanging.
**Never claim it is faster than the CPU.** It is not, and the plugin does not
default to it. The 2026-06-29 spike measured a competent real-FFT CPU convolver
beating or tying the GPU at every musically plausible setting. This is a
**capability** result — convolution reverb running as a compute shader in a
browser tab — and the CPU remains the default and the always-available fallback.
### Proving a GPU lane ran is the whole problem
A GPU path that silently falls back to the CPU is **indistinguishable from one that
works**. "It sounds right", "the device and pipelines exist", and "no errors" all
pass falsely when nothing ever dispatched. Two assertions carry real weight, and
`browser-test/validate-gpu.mjs` is the worked example:
- **Kill the safety net.** Run with the CPU fallback disabled: a GPU that never
produced a block then yields **silence**, so the run cannot be quietly covered.
- **Tamper with the shader and watch the samples move.** Read the **actual WGSL
text back out of the shipped module**, rewrite it (scale the output store by
0.5), push it back before init, and require the audible samples to come out
exactly 0.5× — bit-for-bit. A JS or wasm impostor doing the arithmetic elsewhere
is *completely unaffected* by editing a shader it never runs. Reading the source
out of the module rather than pasting a copy into the test also makes it
drift-proof.
**Never assert on `timestamp-query`.** Chrome quantizes it: a 512-point FFT block
measures **0 ns**. A zero there is not evidence the GPU idled, and asserting either
way manufactures false evidence. Report it; never gate on it.
**Proving the ENGINE is not proving the PAGE.** The engine fixture is allowed to
cheat — `validate-gpu.mjs` measures the impulse response on the CPU engine in one
run and hands it to the worker in the next. A visitor gets no such favour, so
everything between the plugin's data and the worker's kernel is UNCOVERED by it,
and each link fails *silently*: the page loads, the CPU path plays, the GPU engine
simply never appears, and nothing anywhere errors. Drive the ASSEMBLED page in a
real browser too (`page-gpu.mjs`), and assert the worker's counters ADVANCE — not
that a control exists (it can be inert), not that there are no errors (a silent
fallback is errorless by design), and not a cumulative `produced > 0` (that latches
on the first block ever made and then reads "GPU" forever, including while a lost
device misses every deadline).
### Getting the plugin's data OUT to the page (and the trap in it)
A plugin whose DSP can also run OUTSIDE the worklet — a GPU worker, which an
`AudioWorkletGlobalScope` cannot even reach — has to tell that somewhere what to
work with. For a convolver that means the IR, and it must be the IR the plugin
ACTUALLY ENDED UP WITH, after its own normalize/window pass. Handing the page a raw
IR to give to both sides is **not** equivalent: the plugin transforms what it is
given, so the two engines would convolve with different kernels and the CPU
fallback would quietly stop being a substitute for a missed GPU block.
The seam that works: plain **optional wasm exports** (`pulp_ir_generation` /
`pulp_ir_snapshot` / `pulp_ir_data`) → the worklet polls → `postMessage` → the
adapter latches and re-emits (`onIrChanged`) → the page forwards it. Not a new CLAP
extension: an extension is permanent ABI invented for one demo, carried forever by
every host that is not this page. An export is opt-in and invisible — the shared
worklet checks whether the module has it and does nothing when it does not.
Two things that WILL bite:
- **Do not gate the poll on the non-realtime tick.** It looks obviously right — the
tick is what rebuilds the IR — and it silently never fires for the FIRST one. The
plugin builds its initial IR while ACTIVATING, so by the time audio is running it
has nothing pending, never asks the host for a callback, and a tick-gated poll
waits forever for a rebuild that already happened. Poll the generation counter
every quantum; it is one wasm call returning a `uint32`.
- **Latch it in the adapter.** The first value is published while the plugin
activates — before the page's handler exists. Without a latch (and a replay on
subscribe) that first publish is lost, and the consumer waits for a change that
already happened.
## A Worker and a worklet share no heap — a program crosses as bytes
A Worker's wasm and a worklet's wasm are **separate linear memories**, so a
pointer either one writes is meaningless in the other. That is why a compiled
`playback::PlaybackProgram` — a graph of `shared_ptr` and `std::vector` — can
never be published from a compiler Worker to a realtime worklet directly, and
why `pulp/playback/program_wire.hpp` exists: it is the flat, pointer-free,
versioned byte range that crossing form takes, with a validating decoder that
allocates nothing and borrows typed spans straight out of the buffer.
Consequences for anything on the web lane that publishes program state:
- Size the destination with `program_wire_encoded_size` and write into a
caller-owned span. Both the encoder and the decoder allocate nothing, which is
what lets a producer write straight into a `SharedArrayBuffer` ring and a
worklet adopt without touching the allocator.
- The buffer's base must be eight-byte aligned or the decoder rejects it — a
ring's slot stride has to be a multiple of eight, not merely large enough.
- Payloads crossing this boundary are **untrusted input**. Never hand a worklet
bytes it has not run through `decode_program_wire`; the decoder is where
truncation, a spliced length, an unknown section, and an out-of-range index
all become typed rejections instead of an out-of-bounds read in the render
callback.
- A rejected generation must leave the previously adopted program playing.
Adoption is `decode` then swap, never swap then validate.
- **Adopt on `(producer_epoch, generation)`, never on `generation` alone.** A
differing epoch means a different producer: reset carried cursor state and
adopt unconditionally, because generations from two producers are not
comparable. This is the case a page hits whenever it rebuilds its Worker while
the `AudioContext` survives — the new Worker republishes generation 1, and a
consumer comparing generations alone refuses every publish from then on and
renders a stale program with no diagnostic to distinguish it from silence.
- **Decide a lane `Unchanged` on its `instance_token` too, not on
`(lane_id, generation)`.** The epoch separates producers; it does not separate
two programs from one producer, and that is the common case — a worklet's
Worker recompiling. `generation` is supplied by the caller rather than minted
per compile, so everything else about the lane can be identical. Each
`ProgramWireAutomationLaneRecord` carries the producer's own token for exactly
this; compare it for equality only, and only against a token from the same
`producer_epoch`. Keeping cursor state on a lane whose token moved renders the
stale curve with nothing malformed for the decoder to reject.
## Capability tiers are shared with mobile — do not mint a browser-local enum
When a browser lane needs to say "this page can only do the degraded thing",
the tier vocabulary already exists and is **not** browser-specific:
`core/platform/include/pulp/platform/device_capability.hpp` declares one
`DeviceCapabilityTier` (`Constrained` / `Standard` / `Full`) that the browser
lane's Tier A/B/C and the mobile lane's M-A/M-B/M-C both name. Use it. A
second, browser-local `TierA | TierB | TierC` looks harmless in isolation and
then permanently forks the ladder, because mobile and playback quotas will be
reading the shared one.
The seam is `DeviceCapabilityInputs::realtime_render_available`. The header
deliberately never names `crossOriginIsolated`, `SharedArrayBuffer`, or worklet
module support: **the browser probe collapses those three observations into
that one boolean** and hands over neutral inputs, because a type consumed by
iOS and Android must not carry web-platform spellings. Keep the probe's
vocabulary in the probe.
Two consequences worth knowing before you tune anything:
- **No realtime render path caps the page at `Constrained`**, whatever its
memory and core count — the rungs above it are defined by rendering locally.
That is the controller-mode shape, and it is why a Tier-A page should refuse
to construct the worklet path with a typed diagnostic rather than build it
and catch a throw.
- **A lane with no thermal API tops out at `Standard`.** Browsers expose no
thermal signal, so a browser page cannot currently reach `Full`. This is a
deliberate policy in `project_device_capability_tier` — the top rung's quotas
assume the platform will report heat so the consumer can step down — not an
oversight. Revisit it when the browser lane actually needs the top rung, and
change it in the shared header rather than routing around it.
### The tier is in `platform`; the quota table is not
`pulp::format::device_quotas(tier, thermal)`
(`core/format/include/pulp/format/device_quotas.hpp`) is the matching table
(voices, nodes, simultaneous editors, preview quality). It deliberately does
**not** sit next to the tier enum, and reaching for
`pulp::platform::DeviceQuotas` will not compile.
`core/platform` is named in every row of the engine's declared dependency
floors (`tools/scripts/timeline_engine_dependency_floor_check.py`), so anything
placed there is reachable by every module and no floor row can object to it.
That is right for a shared vocabulary and wrong for a budget over render-graph
nodes and editing surfaces. `core/format` appears in no floor row, so an engine
module that reaches for the quota header is rejected by the gate.
Consumption runs one way: every enforcement point the table names already takes
its ceilings as injected configuration (`playback::AudioRendererLimits`,
`timeline::SessionLimits`, `graph::GraphRuntimeLimits`,
`format::PrepareResourceLimits`), so the table's consumer is the shell that
*constructs* those — which a browser lane already is. No engine module needs to
see it.
It is a declaration today: nothing enforces it yet, so reading it is safe and
*relying* on someone else having enforced it is not.
## The worklet has no second thread
A WAM module runs entirely inside the audio worklet: **there is no `std::thread`
and no control thread.** A processor whose control changes need work `process()`
must never do (decode, resample, FFT-plan, allocate) therefore has nowhere to do
it — which is what `Processor::on_non_realtime_tick()` /
`non_realtime_tick_pending()` exist for.
`pulp::audio::decode_wav(span, limits)` is linked into both WAM and WebCLAP so
code that already owns raw RIFF/WAVE bytes can share the native decoder. That is an
**off-render import API**: never call it from `process()` or the worklet render
turn. For browser file/drop imports, prefer the page's `AudioContext.decodeAudioData`
when session-rate resampling is wanted, then transfer the resulting PCM through
plugin state or another explicitly bounded handoff.
- The WAM adapter marks the processor dirty on a control write and services it
**once per render turn**, right after the render call (`WamStage::service_non_realtime`
/ `mark_non_realtime_dirty`). A knob drag delivers many control messages in one
turn; they collapse into a single pass over the **latest** values. Do not rely
on one tick per parameter write — rely on "eventually, with the latest values."
- It is **not** an audio-thread callback and never runs inside `process()` — but
in a worklet-only host it does run on the same OS thread, just outside the
render call. **A long tick still makes the next quantum late.** Keep the work
bounded and proportional to what actually changed.
- CLAP reaches the same hook a different way (`request_callback` →
`on_main_thread`), and **native** CLAP gets it too. See the `clap` skill.
### "Keep the work bounded" is a hard requirement, not advice
Coalescing an expensive rebuild to once-per-render-turn lowers how *often* it
fires; it does **not** make it safe. SuperConvolver's IR rebuild (synthesis +
a peak-response FFT over the whole IR + a partitioned FFT re-plan for both
channels) measured **15.0 ms in one render callback against a 2.667 ms quantum
budget** (128 frames @ 48 kHz) — a dropout on every Size change, even coalesced.
The fix is to **time-slice** the work, not to shrink it. The pattern
(`examples/super-convolver/sliced_ir_rebuild.hpp`) generalizes:
- Express each phase as a **stream of fixed-cost items**; the tick consumes at
most `budget` of them and returns. Budget is a **constant**, never a function
of the input size — that is the whole point.
- Keep the OLD state rendering audio until the new one is complete, then publish
it through the existing lock-free handoff (`signal::ConvolverIrSwapper` /
`runtime::Handoff`). The audio thread's only job stays `try_swap_ir()`.
- A new request mid-job must **supersede** (restart) it, not queue behind it — a
knob drag delivers a stream of values and only the last one is ever heard.
- **`non_realtime_tick_pending()` must stay `true` while a job is in flight**, not
just when one is *needed*. Both web hosts stop pumping the tick the moment it
says false — WAM skips `service_non_realtime`, and WebCLAP stops calling
`request_callback` — so a job that only advertises "work needed" strands itself
half-built.
- Pick the budget by **measuring the slowest phase on the scalar path the browser
actually runs** (no vDSP/Accelerate — that is Apple-native only, and it makes a
large FFT ~3× faster than what wasm will do). SuperConvolver uses 32768 items ⇒
worst render callback 0.78 ms across a full Size drag, vs 15.0 ms before.
- The cost you pay is **latency, not glitches**: a 4 s IR needs ~120 render turns
(~300 ms) to rebuild. Crossfade the swap (`PartitionedConvolver::set_crossfade`)
so the change is heard as continuous rather than as a switch being thrown, and
remember that any test which measures an IR right after a swap will otherwise
measure the *blend*.
- An FFT over the whole input is the one thing that will not chunk. Decompose it:
a truncated Gentleman–Sande (DIF) split down to 1024-point leaves turns one
indivisible N-point transform into a stream of butterflies plus a stream of leaf
FFTs, exact to float precision. See `superconvolver::PeakResponseScan`.
## Landmine: `emscripten_resize_heap` stubbed to `false` = a bare abort past 16 MB
The WAM runtime used to hard-stub `emscripten_resize_heap` to return `false`
while every module links `-sALLOW_MEMORY_GROWTH=1`. The two together mean: the
first allocation past the **16 MB initial heap** fails, and the module aborts
with **no diagnostic** — no OOM message, no exception, just a dead plugin. It
reproduces only on inputs large enough to grow the heap, so a small demo passes
and a real one dies.
`wam-runtime.mjs` now implements real geometric growth, capped at the wasm32
ceiling. If you write or vendor another JS runtime shim for a Pulp wasm module,
`emscripten_resize_heap` **must actually grow the memory** — a `false` stub is
only correct if the module is also linked with `ALLOW_MEMORY_GROWTH=0`, and none
of them are.
## Landmines that make a *rendering* build fail silently
Both live in the `skia-gpu-build` skill's wasm section; know they exist:
- **No `SK_TRIVIAL_ABI`** → `wasm-ld` links a *trapping stub* for cross-boundary
`sk_sp` calls and the first frame dies with a bare `RuntimeError: unreachable`.
- **Emscripten's `SkFontMgr_New_Custom_Empty`** returns a non-null, glyph-less
fontmgr reporting 1 family / 1 face, so null-checks *and* family-count guards
both pass while every string measures at **zero width**. Probe font usability
by drawing a glyph (`unicharToGlyph('A') != 0`), never by counting families.
### The bundled-font list exists TWICE, and the web copy is the one that breaks
There is no platform font manager in a browser, so embedded blobs ARE the font
stack. Two files declare which faces get embedded:
- `core/canvas/CMakeLists.txt` for desktop
- `tools/cmake/PulpWebUi.cmake` for web / WASM
`core/canvas/src/bundled_fonts.cpp` names every blob symbol directly and is
compiled into BOTH. So adding a face to the desktop list and not the web one is
not a missing glyph at runtime, it is a **compile error**:
```
error: no member named 'Jost_Regular_ttf' in namespace 'pulp_bundled_fonts'
```
in a lane nobody runs locally, discovered by CI on a PR about something else.
The desktop list carries a comment telling you to keep it aligned with
`bundled_fonts.cpp`; the web copy had no comment and no way to know it existed.
Guarded now by the `bundled-font-lists-agree` ctest
(`tools/scripts/check_bundled_font_lists.py`), which compares the two lists and
names the file to fix. If you add a font, add it in both places and the lint
will tell you when you have not.
## Browser-host rules
### Local demo HTTP servers are loopback-only, canonical, and non-reflecting
Browser fixtures need real HTTP headers, but they are still test harnesses rather
than general-purpose file servers. Use
`examples/web-demos/tools/local-http-security.mjs` for local demo servers:
- bind explicitly to `127.0.0.1`; an omitted host exposes Node's listener on all
interfaces and turns a private fixture into a LAN service;
- validate and decode the raw origin-form request path before URL normalization,
reject malformed encodings, separators, dot segments, and non-allowlisted path
characters, then resolve the real path and prove it remains under the canonical
root (including after following symlinks);
- return fixed plain-text error bodies. Never append `req.url`, a decoded path, or
another request-controlled value to a response body;
- keep route aliases as explicit canonical roots. Do not fall back to a lexical
`startsWith(root)` check: sibling-prefix paths and symlinks bypass it.
The helper's Node tests include traversal, encoded separators, malformed URLs,
symlink escapes, reflected markup, and the loopback bind contract. Add a route-
specific test when a fixture needs semantics beyond those shared invariants.
- **Probe for WebGL2; a browser without it is a shipping configuration.**
`pulp::view::web::browser_host_gpu_available()` is the web analogue of
`decide_gpu_host`. The mount path must fail loudly and asynchronously so the
host page can fall back (the demo pages restore the player's generated
parameter grid). A module that fails asynchronously and reports nothing leaves
an empty panel, which reads as a render bug.
- **WebGL context loss is a normal event.** Lost → the surface reports
unavailable and the rAF loop keeps pumping; restored → Ganesh is rebuilt and
repaints. Ganesh cacheable `LayerHandle`s are retired on loss because their
textures belong to the dead context; retained-layer callers must recheck
`layer_valid()` and record a replacement. Any GPU resource cached above the
surface must either survive that cycle independently or follow the same
explicit invalidation/rebuild rule.
- The render loop is `requestAnimationFrame`-driven
(`core/render/src/render_loop_emscripten.cpp`); DOM pointer/key events are
translated in `core/view/include/pulp/view/web/web_event_translate.hpp`.
### Web-player metadata is text, and links are web-only
`@danielraffel/web-player` is a library boundary: demo titles, subtitles,
parameter choice labels, source links, gallery links, and host labels can come
from downstream package manifests. Build those fields with `createElement`,
`textContent`, and `setAttribute`; `innerHTML` is reserved for package-owned
static markup. Property assignment alone is not enough for links because a
`javascript:` or `data:` URL remains executable when clicked, so resolve the URL
and accept only `http:` / `https:` (relative links resolve through the page's
base URL). Invalid optional links are omitted; an invalid gallery link falls
back to the package gallery default.
The dependency-free DOM shim in `packages/pulp-web-player/test/dom-shim.mjs`
also receives library strings. Keep its HTML/selector scanners linear and cover
long repeated whitespace/bracket inputs in `test/dom-security.test.mjs`; a test
helper must not turn an adversarial label into a regular-expression denial of
service.
## Landmine: CLAP has no parameter `unit` — only `value_to_text`
The WAM ABI reports a parameter's display unit directly (`wam_adapter.cpp`
emits `{"unit":"%"}`). CLAP deliberately does **not**: `clap_param_info` has
no unit field, and a host is expected to *display whatever
`clap_plugin_params.value_to_text()` renders* ("35.00 %"). A WebCLAP host that
marshals only the info struct therefore produces unitless parameters, and the
shared player — same page code, same plugin — renders "1.50" on the WebCLAP
demo and "1.50 s" on the WAM one. That was a real, shipped divergence.
Both WebCLAP hosts (`packages/…/vendor/pulp-wasm/wclap-processor.js` worklet
and `core/format/src/wasm/wclap-host.mjs` offline) therefore call
`value_to_text` at **two probe values** per parameter and report the raw
strings as `textProbes`; `deriveDisplayUnit()` in `wclap-abi.mjs` recovers the
suffix (and returns `""` rather than inventing one when a plugin uses a custom
`to_string`, e.g. enum labels). If you add a field the Pulp web UI needs and
CLAP has no struct slot for it, this is the pattern — go through the plugin's
own display call, don't hardcode a default in the adapter.
## Handing a plugin BINARY data from the page (samples, IRs, wavetables)
A browser has no filesystem, so a plugin whose native build loads a file
(`vw::FileChooser` → `set_ir_path`) needs a web equivalent. **Do not add a
per-ABI entry point for it.** Go through the plugin's own state:
- Pulp's bounded in-memory WAV decoder is portable core code, not a native
file-loader. `tools/cmake/PulpPortableWav.cmake` owns its shared sources and
include paths; both `PulpWam.cmake` and `PulpWclap.cmake` consume those lists.
Keep that shared manifest aligned with `core/audio/CMakeLists.txt` so native
and browser builds cannot silently diverge.
- The timebase, timeline, and playback engines are part of both production DSP
builds. `PulpWam.cmake` and `PulpWclap.cmake` own their curated source lists,
compile them with `PULP_COMPILE_EXECUTOR_DISABLE_THREADS=1`, and must stay in
lockstep with the native module lists. `web-timeline-source-closure` enforces
that source closure, and this workflow must trigger on changes to any engine
module or the bounded WAV decoder. Even an unattached authoring-only value
extraction that adds one engine `.cpp` must update both curated lists in the
same commit; passing the native dependency-floor check does not prove the web
closure. The playback automation program and cursor are portable engine
sources. `TrackAutomationProgram` is part of the same closure: when adding or
splitting a playback aggregate source, mirror it into both curated ABI lists
and keep `web-timeline-source-closure` green. Timeline schema-migration sources
are in the same closure even though they are document-persistence code rather
than playback: a Track schema migration TU counts as an engine module, so it
lands in `core/timeline/CMakeLists.txt` and both curated ABI lists together.
Keep these modules threadless and
independent of state, host, and format code; compiling them into wasm does not
by itself create a JavaScript-facing timeline API or Host delivery path.
Track-owned automation lanes, their commands, schema migrations, and identity
helpers follow this same portable-document rule: every new internal `.cpp`
belongs in the native timeline target, the no-exceptions target, and both web
ABI lists. This proves that snapshots containing automation can compile into
the browser runtimes; it does not prove scheduling or parameter delivery until
the playback/host binding consumes those lanes. Take-lane editing follows the
same rule: a dedicated reducer such as `transaction_take_internal.cpp` is not
pulled in transitively just because `transaction.cpp` is already listed, so it
must be mirrored into both web ABI source lists with the native and
no-exceptions targets. Track-freeze document support has the same closure:
decoder helpers and the `SetTrackFreeze` reducer are portable timeline units,
not native render jobs, so list them in the native timeline target,
no-exceptions target, WAM, and WebCLAP together. Browser replay consumes the
persisted artifact reference; it must never attempt to rerender a freeze.
Track mixer state (gain/pan plus its automation targets) is the counter-case
worth knowing: it added **no** translation unit at all. The document field and
the new variant alternative land in existing timeline units, and the render-side
`track_mixer_program.hpp` is deliberately header-only, so the WAM and WebCLAP
lists are untouched and `web-timeline-source-closure` stays green without an
edit. Check whether a change adds a `.cpp` rather than assuming a feature-sized
change must touch the web lists — and keep new small render-path helpers
header-only when they have no state to define, so the closure surface does not
grow for free.
Musical `TimeConform::Resample` follows the same existing portable playback
units: its stateless source-phase mapping and analytic tempo-ramp inverse add
no translation unit, allocation, thread, or web-specific adapter.
`TimeConform::Stretch` adds the finite artifact compiler and the prepared
realtime stream behind the audio-domain boundary; both audio translation
units belong in the WAM and WebCLAP portable dependency inventories. This
keeps persisted projects and playback compilation portable, but does not by
itself create a JavaScript authoring surface or a browser timeline host.
These builds also share Timeline's persistent indexes: initial Track/Project
construction and identity restoration bulk-build sorted balanced trees,
while ordinary edits path-copy only the changed search paths. Do not replace
bulk construction with repeated persistent insertion; wasm's tighter memory
ceiling makes the transient allocation growth especially costly.
- A new `core/timeline` translation unit belongs in exactly **one** place.
`core/timeline/CMakeLists.txt`, `PulpWam.cmake`, `PulpWclap.cmake`, and the
`pulp-test-timeline-no-exceptions` OBJECT library in
`test/cmake/timeline_tests.cmake` all resolve their sources through
`pulp_resolve_timeline_sources()` in `core/timeline/PulpTimelineSources.cmake`,
so one edit to that function covers the native target, both web lanes, and the
no-exceptions proof together. Hand-editing any of the four consumer lists for a
timeline unit is not just unnecessary, it is wrong.
- The shared source manifest does **not** propagate target usage requirements.
If an existing timeline source starts including a header from another module,
every raw-source consumer must receive that module's include surface too. For
the header-only `pulp::music` module, add `core/music/include` to both
`_PULP_WAM_INCLUDES` and `_PULP_WCLAP_INCLUDES`, add it to the standalone
fixture-runner WASM root, and link `pulp::music` into the no-exceptions OBJECT
target. Linking it only to native `pulp::timeline` leaves native builds green
while WAM, WebCLAP, the WASM fixture corpus, and the no-exceptions proof fail
on `<pulp/music/...>` includes.
- **This skill owns the engines' web-ABI source closure, not the engines.**
`skill_path_map.json` maps `web-plugins` to
`core/timeline/PulpTimelineSources.cmake`,
`core/playback/PulpPlaybackSources.cmake`, `PulpWam.cmake` (which carries the
literal `core/timebase/src` list) and `PulpWclap.cmake` — the surfaces that
decide what the browser lanes compile. It does **not** claim
`core/timebase/**`, `core/timeline/**`, or `core/playback/**` wholesale.
Adding or splitting an engine TU still lands on this skill, because it edits
one of those lists; editing an existing engine TU does not, because nothing
here goes stale. Whole-subsystem claims made the gate fire on every engine
edit, which trains reflexive `Skill-Update: skip` trailers — and that reflex
is how a genuinely missed skill update gets waved through. `web-timeline-
source-closure` (a ctest, not this gate) is what proves the closure itself.
`tools/scripts/test_skill_sync.py::RealSkillPathMapOwnershipTests` asserts
the boundary from both sides, so widening it back fails a test.
- Sequence-level document state (markers, regions) is portable engine data, so
its migration and reducer units — `sequence_schema_migrations.cpp`,
`transaction_marker_internal.cpp` — belong in the shared timeline source
function and the no-exceptions library like any other timeline TU, even though
they never run in a render path.
- Only translation units under `core/timeline/src` join those lists. A public
header added under `core/timeline/include` for a foreign-format interop
target — `dawproject_import.hpp`, `smf.hpp` — stays out of every web source
list, because its implementation lives in its own target
(`core/dawproject`, `core/smf`) that the browser lanes deliberately do not
build. The closure checker scans sources, so adding such a header is
correct even though it touches `core/timeline`.
- **The editor rung is not in the browser lanes at all.** `core/timeline_editor`
(`SequencerUiHost`, `EditIntent`, `lower_edit_intent`) is its own target with
its own source list; the WAM/WebCLAP lanes compile `core/timeline/src` directly
and never link it, so a browser plugin has commands and transactions but no
gesture verbs. If a browser editor needs to lower intents, link
`pulp::timeline-editor` — do **not** add `edit_intent.cpp` back to
`PulpTimelineSources.cmake`. That would put the editor's vocabulary into the
document model's source list, which is the exact placement
`timeline-engine-dependency-floor` exists to prevent, and the closure checker
would not object because it only asks whether every `core/timeline/src` TU is
listed, never whether a listed TU belongs there.
- Timeline is the exception in list shape: engine units from other modules are
still hand-listed per lane, so check which shape the module uses before
assuming the resolver covers it. `web-timeline-source-closure` only asks
whether every TU under `core/timeline/src` reaches the WAM and WebCLAP lanes —
which the resolver satisfies automatically — so it can neither catch a
hand-listed module's omission nor object to a TU that is listed but does not
belong in the document model's list at all.
- The browser lanes inherit transport behavior for free, including behavior that
did not exist when the ABI lists were written. Playhead scrubbing is the worked
example: `MasterTransport::begin_scrub()` emits repeated windows whose restarts
are ordinary range discontinuities, so the WAM/WebCLAP builds got it purely
because `transport.cpp` was already in both lists — no source-list edit, no
worklet change, no JS surface. Prefer that shape when adding an engine feature
in the browser: express it as ranges the existing portable units already
publish. It also means a browser-visible behavior change can land with an
unchanged web source closure, so a green `web-timeline-source-closure` is not
evidence that the wasm lanes are unaffected — check what the transport now
publishes per block, not just which files moved.
- A new portable engine TU is **one** edit, not three. `PulpWam.cmake` and
`PulpWclap.cmake` both resolve their timeline/playback sources through
`pulp_resolve_timeline_sources()` / `pulp_resolve_playback_sources()`, so
adding a file to `core/timeline/PulpTimelineSources.cmake` (or the playback
equivalent) puts it in both wasm lanes automatically. Do not hand-add it to
the WAM/WebCLAP lists — `web-timeline-source-closure` counts production TUs
per lane and a duplicate is drift, not belt-and-braces.
- **A new bounded document array needs a web ceiling of its own.** Every
unbounded array the decoder walks is quota-governed by a `DecodeLimits` field,
and `DecodeLimits::web_defaults()` deliberately tightens the ones that can grow
large (notes, automation points, takes, comp segments) because a browser tab
has far less headroom than a desktop host. Adding a field and forgetting
`web_defaults()` is silent: the desktop ceiling applies, nothing fails, and the
tab is one hostile document away from an OOM the native tests will never see.
The decoder is not the only place to wire it either — `schema_json_preflight.cpp`
governs the same array independently, before any model object exists, and that
is the check a hostile input hits first.
- Timeline schema references are also a generated web API contract. Nested
objects and arrays must carry `$ref` metadata through the canonical schema;
arrays need the reference under `items.$ref`, not only on the container. The
TypeScript facade projection consumes those references to emit concrete
nested types. If a timeline field such as `Sequence::groove()` or
`GrooveTemplate::steps` is added without the reference, native persistence can
remain green while the generated browser API silently degrades the value to
`unknown`. Regenerate the JSON Schema and TypeScript facade together and keep
the schema/codegen drift gates in the same change.
- Per-note probability, pass conditions, and ratchets are portable playback
behavior too. WAM and WebCLAP compile the same bounded note program and use the
authored modifier seed with note identity and loop-pass index, so decisions are
replayable without mutable RNG state in the worklet. Scrub windows deliberately
stay on pass zero; seeks, play starts, and loop-boundary changes re-anchor the
pass epoch, while ordinary program adoption and host recording-state changes do
not. Keep modifier expansion behind
`ProgramCompileRequest::maximum_note_events_per_track`: wasm's memory ceiling
makes an unbounded ratchet fan-out especially dangerous. This engine support
does not by itself add a JavaScript authoring surface.
- Sequence groove rendering and compile-context invalidation are equally
portable. Built-in MIDI subscribes to `CompileContextKind::Groove`; its owner
sequence timing displacement and velocity accent are compiled before the
bounded ratchet expansion. WAM and WebCLAP therefore inherit the same note
program without browser-only math. Keep `CommitResult` predecessor provenance,
registry snapshots, and MIDI compile-structure tokens in the shared timeline /
playback lane so sparse reuse cannot publish stale browser programs. This also
does not create a JavaScript authoring surface by itself.
- Registered-content compiler code is portable, but its declaration is
process-local. Adding a renderer to the shared playback source resolver makes
the implementation available to WAM and WebCLAP; it does **not** install the
schema, codec, or `ContentRendererRegistration` into a browser plugin's
registries. A plugin that authors or loads that content must declare the same
exact schema provenance and bounded renderer before compiling in each wasm
instance. Run schema/JSON validation and the registered compile hook in the
non-RT producer/Worker, then publish only validated `ProgramWire` bytes to the
AudioWorklet; none of that content compilation belongs on the render thread.
Keep unresolved content as a named compile failure rather than browser-only
silence, and do not carry a nondefault renderer production claim through
`ProgramWire`: the remote instance cannot inherit a reproducibility claim
without the process-local hook that justified it.
- A compile-time guard in a portable timeline header fires in the browser lanes
too. `core/timeline`'s `AutomationTarget` carries a `static_assert` on its
alternative count (and an overload set with no generic fallback) precisely so
that widening the variant cannot slip through silently. Because those sources
are in the WAM/WebCLAP closure, widening it breaks the wasm builds at the same
point as the native ones — which is what you want. It also means the guard
must be satisfied before the widening lands, not after, or both lanes go red
together.
- Extraction produces a new translation unit and carries the same obligation.
Moving a helper out of an already-listed engine `.cpp` into its own file reads
as a pure refactor, because the origin unit stays listed everywhere — but the
extracted file is new to every list its module maintains.
- `core/timeline` and `core/playback` are in the wasm closure, so their
compile-time model guards fire in the browser lanes too. `ClipContent` carries
an overload set with no generic fallback plus `static_assert`s on its
alternative count, precisely so a new clip content kind cannot slip through as
silence. Widening it therefore reds the WAM and WebCLAP builds at the same
points as the native ones, which is the intent — but it also means the guards
must be satisfied *before* the widening lands, not in a follow-up, or every
lane goes red at once with no partial-progress path.
- Both ABIs already expose the plugin's opaque state behind ONE `HostAdapter`
call — WAM through `wam_state_size`/`wam_read_state`/`wam_write_state`, WebCLAP
through the `clap.state` extension — and both produce the *same* `PLST` blob the
native VST3/AU/CLAP builds write.
- So: read the live state, swap its **plugin-owned blob** for a record carrying the
payload, write it back (`packages/pulp-web-player/src/state/plugin-state.js` —
`parseContainer` / `buildContainer`, exported from the package). The plugin's
`deserialize_plugin_state()` is the receiver. Preserving the `params` half of
the container is what stops a load from resetting the user's knobs.
- Three things fall out for free, which is why this is the seam: it is **identical
on WAM and WebCLAP** (nothing to keep in sync), the payload **survives a state
save/restore** because it *is* the state, and "revert to the built-in" is the
same call with a different tag.
- Decode with the demo's **own `AudioContext`** (`decodeAudioData` resamples to the
context rate), so the PCM arrives at the session rate and the plugin's resampler
— which is not chunkable, unlike everything else in the rebuild — is skipped.
- Worked example: `examples/web-demos/super-convolver-ui/ir-source.js` (the SCv2
record + the drop-zone) and the `onReady` seam in the shared shell, which is the
hook for **plugin-specific page chrome** that needs the live adapter and the
AudioContext. `customUi` is the wrong hook for this: it *replaces* the parameter
grid and falls back to it on failure.
## Landmine: iOS Safari ignores `.click()` on a `display:none` file input
The page's IR/sample picker is a `<input type=file>` the plugin's Source affordance
triggers (editor → `load_ir_path({})` → `Module.onRequestIr` → `filePicker.click()`).
Hiding that input with `hidden` / `display:none` works on **desktop** Safari and
Chrome but **iOS Safari silently drops the `.click()`** — the tap does nothing and it
reads as a broken control. Keep the input in the render tree and hide it visually
instead (`position:fixed; width:1px; height:1px; opacity:0; pointer-events:none`).
The click must also still be **synchronous inside the user gesture** — the whole
canvas-pointer → wasm → `EM_JS` → `click()` path is synchronous, so that holds; do not
defer it behind a promise/`setTimeout`.
## Landmine: a control-surface canvas eats page scroll — opt into `pan-y`, and it's TWO paths
`web_input.cpp` sets `canvas.style.touchAction = 'none'` and `preventDefault`s BOTH
pointerdown AND wheel so a knob drag / wheel-over-knob never pans the page — correct for a
standalone plugin. But an editor **embedded in a scrollable page** (only horizontal
sliders, no vertical drags) then swallows every gesture and the page cannot be scrolled
from on top of the plugin. Opt in by setting `canvas.style.touchAction = 'pan-y'` after
mount (see `pulp-ui.js`); the input layer checks the live `touch-action` and, on a
pannable canvas:
- **touch** (mobile): skips pointer capture + `preventDefault` on pointerdown, so a
VERTICAL drag scrolls the page while a HORIZONTAL drag or tap still drives a slider
(`pan-y` claims only the vertical axis).
- **wheel** (DESKTOP trackpad/mouse — a SEPARATE path, easy to forget): still forwards the
wheel to the view but does NOT `preventDefault`, so the page scrolls. **The touch fix
alone leaves desktop broken** — the "big screen, can't scroll over the plugin" bug is the
wheel handler, not touch-action.
Do NOT flip the default to `pan-y` — a knob-heavy plugin needs both axes for its own
drags. VERIFY IN WEBKIT, not just Chromium: `playwright-core`'s `webkit` IS Safari's
engine (`Version/…Safari/605`), and `page.mouse.wheel(0, 600)` over the canvas must move
`window.scrollY` (measured 0→209 on the SuperConvolver editor). Chromium-only "it scrolls"
is not proof for the browser the user is actually on.
## Landmine: scripted controls need the browser host's `on_drag` channel
A scripted gesture uses two distinct `View` callbacks:
- `on_pointer_event` carries the press/release/cancel edges. A drag tick must not
become another `pointerdown` merely because its `is_down` field is true.
- `on_drag` carries the JS `pointermove` stream. The legacy `on_mouse_drag`
virtual reaches stock C++ widgets, but it cannot reach a canvas-drawn control
whose handler was installed by `WidgetBridge`.
Every browser drag tick must therefore deliver, to the target captured for that
raw browser pointer ID, the modern event and legacy virtual, then `on_drag` for
mouse or identity-bearing `on_pointer_move` for touch/pen. Do not redispatch
that scripted move on native ancestors: the bridge event already bubbles
through the JS element tree, so doing both double-fires ancestor and document
listeners.
Revalidate the captured target between callbacks because a handler may rebuild
and destroy its own subtree synchronously. Missing the scripted channel produces
a deceptive failure: the control renders and receives press/release, but it
never moves. The router tests pin exact-target delivery, capture teardown, and
the rule that release/uncaptured hover emit no extra move.
## Landmine: a WebCLAP host must READ parameters back, not mirror them
A host that remembers what it last *sent* (`values.set(id, v)`) goes stale the
moment the plugin rewrites its own parameters — which it does on every state load
and preset change. `WebClapPlugin.paramValue()` calls
`clap_plugin_params.get_value()`; use it. The WAM lane has the same trap and
solves it with `wam_param_epoch` + `wam_read_param_values`.
## Testing — the assertions a native test cannot reach
The `WAMv2 + WebCLAP (Linux, headless Chrome)` lane
(`.github/workflows/web-plugins.yml`) is the gate. Two conventions:
- **Drive both ABIs from ONE runner.** `superconvolver_runner.mjs` asserts the
same audio / parameter / latency / state behavior against the WAM module *and*
the WebCLAP module, so a divergence between the two fails in CI rather than in
a demo page. Do this for any plugin that ships in both.
- **Real pixels + a real gesture.** The browser fixture asserts a WebGL2 context,
GPU and raster content floors, text ink, a synthesized drag producing a
bracketed `gestureBegin → setParameterValue → gestureEnd`, a host→UI repaint,
and the context loss/restore cycle. Nothing here is reachable from a native
unit test; don't substitute one.
The lane pins emsdk (never `latest`) and fetches the Skia wasm slice from
`tools/deps/manifest.json` — see the `ci` skill's `web-plugins.yml` section
before touching either.
### A native/WASM semantic oracle must share the production object closure
When an existing native executable already owns the fixture and semantic hash,
compile that same entry point against `$<TARGET_OBJECTS:pulp-wam-dsp>` and run
the emitted Emscripten JavaScript under Node. Do not translate the fixture into
JavaScript or create a second list of timeline/playback sources: either can stay
green while the production WAM closure drifts. The registered chord-renderer
proof in `examples/web-demos/wasm-build/CMakeLists.txt` is the reference shape.
Its negative control belongs in the production renderer, not in the consumer or
the expected hash: perturb one WASM-emitted value, force the `pulp-wam-dsp`
object to rebuild, and require the native executable to stay green while the
Node oracle goes red. Restore the renderer byte-for-byte, force both rebuilds,
then rerun both green. A control that edits the expected value only proves the
comparison can disagree; it does not prove the WASM executable reached the
production realization path.
`web-plugins.yml` also carries a second, unrelated job:
`Timeline fixture corpus (WASM)`, which builds `pulp-fixture-runner` under
emscripten and runs the timeline conformance corpus through it. It shares the
file only for the emsdk pin — it needs no Skia, no Chrome, no wasi-sdk, no npm,
so keep it a separate job rather than a step in the lane above, or a one-minute
check starts waiting on a fifteen-minute one. It is not a browser lane; if you
are here for WAM/WebCLAP behavior, it is not the job you want.
### Landmine: `pulp_add_wclap(Foo)` declares the target as `Foo-wclap`
Not `Foo`. `cmake --build … --target Foo` is a hard **"No rule to make target"**,
not a fallback — so a workflow step with the bare name fails before it reaches
whatever it was supposed to prove. Shipped that way once in `web-plugins.yml`.
### Landmine: a plugin is not time-invariant while its IR is still rebuilding
Any fixture that **measures an impulse response and then convolves with it** (the
standard way to check a convolver against an oracle) is assuming the plugin is
linear and **time-invariant** across the capture. SuperConvolver is only that once
its IR has stopped moving, and it does not start that way: a Size change is
**time-sliced** across many `process()` calls and crossfaded
(`superconvolver::SlicedIrRebuild`), exactly so the render callback stays in budget
instead of spiking (see "Keep the work bounded").
Set a parameter and capture immediately and you probe `h` **mid-crossfade** — a
blend of two IRs, a kernel that describes no instant of the run. Everything
downstream is then judged against it, and the failure **frames the ENGINE as broken
when the fixture is what is wrong** (measured: CPU vs oracle relative RMS **3.4** —
not a drift, a different signal).
Two things fix it, and you want both:
1. **Pre-roll of silence** before the marker impulse. The graph is live and the
plugin drains its sliced rebuild while nothing is being measured.
2. **A second impulse after the analysis window**, whose response must equal the
first's. A pre-roll alone is just an assumption that convergence fits inside it,
and it rots the moment the slice budget or the IR length changes. The second
impulse turns it into a **checked claim** about the exact property the oracle
depends on, and it fails loudly with a named reason *before* the bad kernel can
poison anything.
## Demo pages
Publishing or updating a browser demo (shared player, cache-busting, OG images,
COOP/COEP) is the `screenshot-sync` skill plus the personal `pulp-web-demo`
standard. The one rule worth repeating here because it silently kills audio:
**never cache-bust the worklet processor URL** — the registered processor name is
derived from it, so a `?v=` forks the name and the node never constructs.
## Landmine: the OG bake is a TWO-PASS assemble — the second pass needs the same args
`gen-og-images.mjs` shoots each page, then `assemble-gallery.mjs` runs **again** to bake the
`og:image` / twitter block into the HTML — a page's tags are emitted only when its `og.png`
already exists on disk, which is only true on that second pass.
Run the second pass **bare** (`node assemble-gallery.mjs` with no `--wam-build` / `--ui-build`
/ `--gpu-build`) and the assembler cannot find the build trees, so it **skips** every page that
needs one. It skips them quietly — a one-line note — and therefore never rewrites their HTML,
which is the only thing that pass exists to do. `/super-convolver-gpu/`'s og.png was shot
perfectly and then never referenced: the page shipped with **no `og:image` at all** and
unfurled bare. Pass the same trees to both passes.
## The page owns the engine readout, not the view tree
A status line *inside* the plugin canvas that the page refreshes on a timer is a layout event
on a timer: every time a counter gains a digit the label re-measures, and at phone width it
sheared straight through the knob labels above it. It was also duplicate chrome — the page
already renders an Engine `<select>` directly under the canvas, which both names the engine and
is the control that changes it.
Put live metrics in **DOM slots** beside that control, each with a fixed width and
`font-variant-numeric: tabular-nums`, so a changing number never moves the layout. The view
tree carries the controls; the page carries the readout.
## Landmine: an offered engine is not a running engine — gate the WORK, not the output
A GPU (or any offload) lane fed by a SharedArrayBuffer ring keeps receiving blocks in **both**
engines: the plugin pushes on every block regardless, because that is what advances the shared
block timeline and keeps the two paths sample-aligned. A worker that convolves whatever it is
handed therefore keeps the GPU **fully busy while the user is on CPU** — measured on the
shipped SuperConvolver page at ~100 queue submits per second, a whole wet stream produced and
discarded. The user sees it: *"in CPU mode I still see spikes on my GPU meter."* Selecting CPU
must make the GPU **idle**, not merely ignored.
Gate it with a ring FLAG the worker reads once per tick (not a message — the worker's job is to
never stall). While it is clear: drain the input ring into plain memory, submit nothing.
**But idling is only half of it.** A partitioned convolver's tail comes from a frequency-domain
delay line of recent input spectra, and **updating that line IS the GPU work** — the forward FFT
is a dispatch. So an idle GPU is one whose memory of the recent past goes stale, and resuming
with a stale line smears a **ghost of pre-flip audio** under the new material. Buffer the raw
input blocks while idle (a memcpy) and **replay them on the flip**, oldest first with outputs
discarded, to rebuild exactly the line the GPU would have had. One priming burst, then live.
Keep the history exactly as long as the convolver's memory (`ceil(irFrames / block) + 1`);
replaying more just overwrites the same partitions.
Assert BOTH halves in the browser fixture, from the worker's OWN counters in the SAB (never a
label the page prints): submits do **not** advance while on CPU, and the priming counter is
non-zero after the flip. Mutation-check the first one — an always-on worker must make it fail.
## Landmine: the adapter's `values` array is POSITIONAL, not keyed by parameter id
`onParamsChanged(values, infos)` hands you `values` built as `infos.map(p => value(p.id))` — so
`values[i]` belongs to `infos[i]`. Reading `values[param.id]` "works" only when ids happen to
equal indices; on SuperConvolver, Engine's id is 5, so `values[e.id]` silently read the SIXTH
parameter. Find the INDEX (`infos.findIndex(...)`) and read `values[index]`. This bug hid for a
long time because the page's `<select>` handler also set the same state directly — the broken
path only ran for preset/host writes.
## Landmine: the wasm UI build hand-lists its sources — a core refactor silently breaks it
`tools/cmake/PulpWebUi.cmake` builds Pulp's own view+canvas render stack to wasm by
**explicitly listing every TU** (it can't glob core/ the way the native build's CMakeLists do —
it deliberately excludes Dawn/Graphite/scripting/design-import TUs). So when a core refactor
**splits a file** — `skia_canvas.cpp` → `skia_canvas.cpp` + `skia_canvas_path.cpp`, or the text
editor into `text_edit_model` / `text_editor` / `_clipboard` / `_ime` / `_paint` — the native
build (which lists or globs them) keeps working, and this hand-list silently goes stale. The
failure is a wall of `undefined symbol:` at wasm-ld link time, and it lands far from the PR that
caused it.
The web build ("Build + prove + (owner-gated) deploy") is now a **required** status check.
That is the durable fix the older advice asked for — but it has a sharp consequence: when a core
change adds a TU this hand-list misses, the stale list no longer breaks *silently* on main, it
**reds the required gate for every open PR**, including ones that never touched view/canvas. A
new file is the same failure mode as a split file: `value_source_binding.cpp` (defining
`FrameClockBinding::~FrameClockBinding` / `refresh`) was added to `core/view/CMakeLists.txt` but
not to `PulpWebUi.cmake`, and `view.cpp` — already in the wasm list — referenced it, so every web
UI module link-failed and blocked the queue. Whoever adds a `core/view/**` or `core/canvas/**`
`.cpp` must mirror it into `PulpWebUi.cmake` in the same change, or the whole PR queue wedges.
This includes small internal policy/helper TUs, not only visible widget or renderer splits.
`yoga_layout.cpp` was refactored to call `yoga_measurement_internal.cpp`; native view-core and
all native tests stayed green, but the required WebCLAP build failed at `wasm-ld` with undefined
`sanitize_yoga_measurement` / `resolve_yoga_measure_dimension` until the helper was added to
`_PULP_WEBUI_VIEW_SOURCES`. Treat every new out-of-line dependency of an already-listed wasm UI
source as a paired `PulpWebUi.cmake` edit, even when the helper has no web-specific code.
When you hit it: don't chase symbols one build at a time. Read the symbol's namespace, find the
defining TU (`git grep -l 'Thing::method' -- core/.../src`), and **mirror what the native build
compiles** (`core/view/CMakeLists.txt`, `core/canvas/…`) rather than adding files piecemeal —
one added TU pulls in its own new references (a Label opening a TextEditor cascades into the
editor's model + clipboard + context-menu TUs, and finally into `pulp::platform::Clipboard`,
which had no web impl at all). Build locally against `origin/main` before trusting it — the
cascade is real, and your local linker matches CI (verify the first layer agrees before chasing
the next).
## Landmine: an animating web editor recurses paint→repaint unless the render loop is armed
A view that calls `request_repaint()` from inside `paint()` — any continuously animating editor
(SuperConvolver's living field) — routes through `WindowHost::mark_dirty()` →
`schedule_repaint()`. That method only requests an async rAF frame when `PULP_VIEW_HAS_RENDER_LOOP`
is defined; otherwise it falls back to a SYNCHRONOUS `repaint()`, which re-enters `paint()` from
within `paint()` and recurses until the JS stack overflows. Two traps compound it:
1. **The macro is native-only by default.** `PULP_VIEW_HAS_RENDER_LOOP=1` is set on the native
`pulp-view-core` target. The wasm UI build (`PulpWebUi.cmake`) hand-lists its sources and does
NOT inherit it — even though it DOES compile and arm the rAF `RenderLoop`. Define it in
`PulpWebUi.cmake` or every animating editor deadlocks on first paint.
2. **`run_event_loop()` marks dirty before the loop exists.** It calls `show()` → `mark_dirty()`
BEFORE creating the render loop, so the first paint is synchronous even with the macro. The
browser `WindowHost::render_frame()` carries a re-entrancy guard (`rendering_` flag): a repaint
requested during paint is deferred to the next frame, never nested.
The crash is a red herring generator: the stack overflows in whatever draw is executing when it
tips (a gradient FP, a texture upload), so the symbolized top frames point AWAY from the cause.
Count the repeating frame — it was 662× `View::paint_all` — to find the real cycle. The static
generated grid never animates, so this stays hidden until a real animating editor mounts. **A
new custom editor must be exercised by the browser fixture (it mounts and must not overflow).**
The demand-driven host checks whether the frame clock has active subscribers
before asking `needs_continuous_frames()` to walk the whole view tree. Preserve
that order when changing the browser host: a subscriber scan is the cheap
positive path for live meters and animations, while the tree walk is the
fallback for views that request continuous painting without a subscription.
`surface_lost` still takes precedence over both because recovery must schedule
another frame regardless of view activity.
## The full-canvas editor pattern (a plugin's REAL Skia UI on the web)
**Two modes, a per-plugin choice — full-canvas is ADDITIVE, not a replacement.** A demo
page runs in one of two modes, selected by the `customUi` seam:
- **Default (grid) mode** — the shared player renders the plugin's parameters as a knob
grid, generated declaratively. Zero bespoke UI code. This is the norm and the right
choice for a simple utility (a gain, a basic filter).
- **Full-canvas mode** — the page supplies a `customUi` (a compiled Skia UI module) that
paints the plugin's whole bespoke editor onto ONE canvas filling the panel. Opt-in, more
work, native-quality. Pick it when the plugin's identity is a custom visual (an EQ's
frequency-response curve + draggable bands, a convolver's IR/field, a synth's scope).
No `customUi` → grid; `customUi` present → full-canvas — **and the full-canvas mode FALLS
BACK to the grid if the module fails to mount**, so adding it never risks the baseline.
Everything below is the full-canvas path.
A **full-canvas editor** paints the plugin's whole bespoke UI onto ONE Skia canvas that
fills the panel edge to edge — the same native editor the DAW shows, running in the
browser. `super-convolver-gpu` is the reference implementation; **copy it, do not reinvent
it.** Where to look:
- `examples/super-convolver/super_convolver_ui.hpp` — the editor itself (a `vw::View`
drawn with the canvas API). This compiles into BOTH the native plugin and the wasm UI
module — **one source, two builds.**
- `examples/web-demos/super-convolver-ui/` — the web mount: `ui_entry.cpp` (the
`EMSCRIPTEN_KEEPALIVE` seam), `super_convolver_web_host.hpp` (a browser shim
implementing the editor's host interface), `pulp-ui.js` (`mountPulpUi`), `CMakeLists.txt`.
- `tools/cmake/PulpWebUi.cmake` — builds the editor + Pulp's view/canvas/Skia stack to wasm.
- The GPU page in `examples/web-demos/wclap-build/cloudflare/assemble-gallery.mjs`
(`customUi` + the CSS that widens `#panel.pulp` and pins the canvas height).
- Docs: `docs/guides/web-plugins.md` ("Native Editors on the Web"), and the size/tier
design in `planning/2026-07-15-web-editor-architecture-and-size.md`.
**The decoupling contract (what lets one editor serve native + web).** The editor talks
ONLY to a small host interface (`SuperConvolverUiHost`: gpu_status / ir_path /
load_ir_path / impulse_response_snapshot) + `StateStore` + a data bus — NEVER the DSP
header. Native host = the processor; web host = the browser shim. A new plugin defines
its own `<Plugin>UiHost` interface and two implementors.
**Params cross the DSP, not the editor.** The editor reads its parameter list from the
DSP descriptor (adapter → `pulp-ui.js` → `g_store`). So a param the editor draws must be
DECLARED BY THE DSP — declare the SAME set the native build declares (the web build once
dropped `Rooms` and the editor showed four sliders where native shows five). Rebuild BOTH
the UI module (`build-webui`) AND the DSP wasm when params change; the UI module alone is
not enough.
**Full-width layout.** Strip the page chrome the editor already draws (engine dropdown,
CPU/GPU blurbs, a separate load-file area) — it duplicates what the canvas paints. Widen
`#panel.pulp` (`max-width: min(1200px,94vw)`) and give the canvas a FIXED height
(`clamp(420px,60vh,680px)`), not an aspect ratio (aspect makes the box a function of width
and collapses on a phone).
### Responsive + iOS is NOT optional — the layout is DRAWN, so CSS won't save you
A full-canvas editor lays itself out in code, so a phone-width bug (overlapping header,
sheared slider labels) never surfaces in a unit test — only in a browser, at that width.
### A4 browser DPR evidence
The maintained `super-convolver-web` A4 cells use
`tools/scripts/gpu_dpr_web_adapter.py` with the exact executable
`tools/scripts/gpu_dpr_web_measurement.mjs`. Set
`PULP_DPR_WEB_MEASUREMENT_BIN`, `PULP_DPR_BROWSER_BIN`, and
`PULP_DPR_WEB_BUILD_DIR` to absolute paths before passing the adapter to
`gpu_dpr_runner.py`. The build directory must contain the exact
`PulpSuperConvolverUi.js`/wasm output for the planned Pulp SHA.
This lane requires hardware WebGL2, `EXT_disjoint_timer_query_webgl2`, 30
strictly positive timer-query samples, 20 unique Chrome process IDs, WebGL call
instrumentation for upload/resident-byte ledgers, real pointer delivery, PNG
fidelity, and nonce-scoped DevTools user-timing spans from one renderer PID.
SwiftShader/software renderers, zero timings, repeated browser PIDs, mixed
trace PIDs/nonces, native adapters, or handwritten category claims are invalid.
The checked-in Python tests prove those rejection paths; they do not replace a
real browser run.
The terminal v2 runner must invoke and snapshot the exact executable adapter,
bind its actual producer PID, and retain eight unique files for every original
and repeat web cell: JSON projections, both real PNGs, the real DevTools trace,
and the exact wasm/Mach-O/ELF/PE product bytes. A filename, data string, or
producer-authored `pass` claim cannot substitute for those bytes. Only
`finalize-v2 --run-dir ...` may derive the candidate, and only fixed-path Git
blobs plus `verify-live-v2` establish protected-main publication.
Install exactly `playwright-core@1.61.1` in the isolated measurement package;
the producer records and verifies that version and rejects drift. Each metric
declares measured/derived/unavailable provenance. WebGL timing includes five
baseline and five eight-times-known-work query trials and must detect the extra
work above its empirical resolution. The scenario manifest—not the page—owns
the expected logical point/target; the page reports the actual pointer event and
parameter hit. Compare a CPU reference PNG with a WebGL canvas PNG at identical
content/state, bind both hashes to one token, and retain numeric similarity,
small-text luminance variation, and thin-stroke coverage. Adaptive mode must
record real DPR overrides and measured scale transitions. Old incompatible
receipts remain `SUPERSEDED`/`NONCOUNTED` in the checked-in instrument state.
Every full-canvas editor MUST handle these, all learned the hard way on SuperConvolver:
- **Narrow breakpoint.** The UI `scale()` keys off HEIGHT, so a tall skinny phone canvas
has a LARGE scale and a SMALL width — a centered header (mode tabs) then collides with
the wordmark on the left and the status chip on the right, and a quarter-width slider
cell cannot fit "MIX" and "35 %" on one line. Add a `narrow_` mode (`W < ~680*scale`):
drop the tabs to their own row, give secondary chrome its own row, and STACK each slider
as label-over-value-over-track. Keep desktop untouched.
- **The info/help overlay must reflow too** — a fixed two-column card overflows a phone;
give it a full-width, stacked, clipped variant.
- **devicePixelRatio can change with NO CSS resize** (window dragged to a different-scale
monitor, OS display-scale change, some pinch-zoom) — none of those fire `resize` or the
`ResizeObserver`, so the backing store stays stale and the canvas goes blurry/mis-scaled.
The shared web layer (`web_input.cpp`) arms a re-arming `matchMedia("(resolution: Ndppx)")`
listener that re-runs the resize (which re-reads dpr) on each DPR change. Browser text-zoom
DOES change the CSS size, so it is already covered by the ResizeObserver. The Ganesh surface
also retires rasterized cacheable `LayerHandle`s when DPR changes; callers rebuild them after
`layer_valid()` turns false rather than scaling an old-density texture.
- **iOS file picker:** the page's `<input type=file>` must NOT be `display:none`/`hidden`
(iOS Safari drops `.click()` on it) — hide it visually instead. See its own landmine above.
- **Page scroll over the canvas:** opt into `touch-action: pan-y` after mount so a vertical
drag scrolls the page while a horizontal drag/tap still works the controls. See its landmine.
- **Live readouts:** reserve their height (two lines if they can wrap) and WRAP rather than
truncate, so flipping state never bumps the page; debounce fast-toggling values (~0.8s
hysteresis) so a single dropped poll doesn't strobe the text.
### A3/A4 GPU evidence on the maintained web canary
Do not treat a successful browser mount, responsive screenshot, or native Dawn
receipt as web GPU evidence. The maintained UI canary is Skia Ganesh/WebGL2,
while browser GPU-audio uses a separate emdawnwebgpu worker; record which lane
each observation actually exercised.
For the A4 DPR experiment, the web owner supplies all 12 browser cells for the
`web-canary` scenario: DPR `1`, `1.5`, `2`, and `3` across exact, configured-max,
and non-shipping adaptive simulations. Each independently produced receipt must
bind the browser/build/adapter identity and issued attempt nonce, and retain 30
steady samples plus 20 fresh browser-process first-frame trials, capture
similarity, small-text legibility, logical-input correctness, interaction
latency, and bounded artifacts. Use the real browser canvas and browser input
path; a native producer or synthetic receipt is not a substitute. A zero timing
sample is unavailable evidence, never a fast frame.
Ingest through `tools/scripts/gpu_dpr_runner.py ingest` and require its closed
checks before counting a cell. `finalize` remains forbidden until all 84 cells
and exact A2T/A3 dependency receipts exist. The experiment may select
`no-change`, `configured-max-candidate`, or `adaptive-candidate`; none of those
changes current web DPR policy in Horizon A.
The current runtime control GPU-health provider is Standalone-only. Do not claim
that `dev.pulp.gpu/health.read@1` works in WAM/WebCLAP until an actual browser
product endpoint, exact-instance receipt, and mapped control tests exist. A3's
web relevance today is the transferable trace/evidence vocabulary and the rule
that missing browser producers remain explicit, not a fabricated pass.
### Verifying the GPU-AUDIO path on macOS: use REAL Safari, not Playwright WebKit
This one wastes hours if you don't know it. To verify a WebGPU-audio demo (the GPU
engine actually producing blocks) on macOS:
- **Playwright's bundled "WebKit" has NO WebGPU** — `navigator.gpu` is `undefined`, so it
can never exercise the GPU lane. It is NOT real Safari. Do not conclude "Safari can't"
from it.
- **Drive REAL `Safari.app` via `safaridriver`** (W3C WebDriver) — it has WebGPU on the
system GPU. One-time: `sudo safaridriver --enable` + Safari → Develop → "Allow Remote
Automation". Client: `selenium-webdriver` (`forBrowser("safari")`). Reusable runner:
`examples/web-demos/tools/measure-safari-gpu.mjs --url <demo>`.
- **The GPU-wedge asymmetry:** repeated HEADLESS-Chrome (Dawn) WebGPU runs wedge that
context's GPU for the session — it silently produces 0 blocks and stays dark, and even
the baseline that worked earlier reads zero (that's the tell it's the environment, not
your change). REAL Safari runs in its own process on the system GPU and is NOT wedged by
that, so it keeps working when headless Chrome goes dark. (Memory:
verify-gpu-ui-via-skia-raster.)
- **A fast Mac may be too fast to reproduce a slow device's MISS RATE** (0% here vs 37% on
a user's phone). So real Safari verifies CORRECTNESS (does the lane produce? does a change
keep audio right? does a stat appear?); verify miss-rate LOGIC deterministically in the
native stub-GPU harness `test/test_super_convolver_web_gpu.cpp`, whose fake worker can be
made to fall behind and drop/expire blocks with no browser and no GPU.
- **Audio etiquette:** safaridriver opens a real window and the demo plays the synth loop
out the speakers — announce before, cap the run, `driver.quit()` after (CLAUDE.md).
- For Safari/WebGPU specifics (timestamp-query support, small-dispatch quantization, feature
gating) search the web or Apple docs via the **`sosumi`** CLI (`sosumi search "WebGPU"`,
`sosumi fetch <developer.apple.com URL>`) — Safari's WebGPU lags Dawn's, so never assume a
Chrome capability is present. BETTER: query the REAL device via safaridriver
(`requestAdapter().features`), because Safari advertises optional features on the ADAPTER but
only grants them on a DEVICE created with `requiredFeatures:[...]`. Measured 2026-07-16:
Safari's adapter lists `timestamp-query`, but `requestDevice()` without it returns a device
WITHOUT it — so a WebGPU-timing path must opt the feature in explicitly or it silently gets 0.
### Verifying it: a browser pass at three sizes is PART OF THE JOB, not a favor to ask for
Do NOT ship a full-canvas editor and let the user discover overlapping text. Before calling
it done, run the bundled audit and LOOK at every size — this is the workflow, not an extra:
```sh
node examples/web-demos/tools/responsive-audit.mjs \
--url https://<preview>/<plugin>/ --out /tmp/audit
# then READ /tmp/audit/{desktop,tablet,phone}-editor.png with your own eyes
```
It screenshots the page + editor at desktop (1440w), tablet (834w), and phone (390w),
prints the editor's CSS size and computed `touch-action`, and lists what to check
(header collisions, slider label/value overlap, the info card, even fill, and — on phone —
the two-line readout, the file picker, and scroll). Drive real interactions with CDP
`Input.dispatchTouchEvent` (a REAL touch), not synthetic `new TouchEvent` (which does not
drive scrolling and gives a false negative). Fix what the screenshots show, redeploy, re-run.
### The wasm DSP lanes compile a hand-picked source subset — not `pulp-runtime`
`PulpWam.cmake` / `PulpWclap.cmake` build `pulp-wam-dsp` / `pulp-wclap-dsp` from an
explicit `_PULP_WAM_CORE_SOURCES` / `_PULP_WCLAP_CORE_SOURCES` list, deliberately
NOT linking `pulp-runtime` (it drags in mbedTLS + http, too heavy for wasm). The
`web-timeline-source-closure` gate then requires that EVERY
`core/{timebase,timeline,playback}/src/*.cpp` appear in those lists — so adding a
new production TU under `core/timeline/src/` forces it into the wasm plugin. Keep
native-only or heavy-dependency code (e.g. the DAWproject importer, which needs
pugixml and audio/WAV inspection) OUT of `core/timeline/src/` — put it in a
sibling module such as `core/dawproject/` with its own target so the closure
does not sweep it into the wasm DSP binary. If a timeline source genuinely
belongs in the web plugin, add it to BOTH lane lists (and provide any dependency
the lanes don't already compile).
For `core/timeline` and `core/playback` specifically, both lanes consume the
shared manifests (`PulpTimelineSources.cmake`, `PulpPlaybackSources.cmake`) by
variable, so adding a TU to one manifest sweeps it into BOTH wasm binaries with
no lane edit and no review prompt. That is a one-line change with a wasm-sized
consequence: a source needing `std::thread`, exceptions, or a dependency the
lanes don't compile (only `rolling_audio_capture_buffer.cpp` and `sha256.cpp`
are carried from outside the three engine modules) must be header-only or live
outside `src/`.
The closure globs `src/*.cpp` only, so a header-only engine addition — a new
`core/timeline/include/**` type, or new inline logic on an existing one — needs
no lane-list edit and cannot break the gate. Skill-sync still flags such a
change here (it maps whole directories, not file kinds); confirm the diff adds
no TU under `core/{timebase,timeline,playback}/src/` before treating the flag
as a real source-closure obligation.
## A new CLAP adapter TU must be added to `PulpWclap.cmake` as well
`tools/cmake/PulpWclap.cmake` keeps its OWN list of `core/format/src/*.cpp`
sources rather than linking `pulp::format`, because the wasm module is built
standalone. So adding a translation unit to the CLAP adapter means editing two
build files, not one.
Miss the second and every WebCLAP target fails at link with an undefined symbol
— and **native builds stay green**, so it only shows up in CI. The native test
targets that compile `clap_adapter.cpp` directly (see
`super_convolver_dsp_tests.cmake`, `canvas_text_tests.cmake`) also link
`pulp::format`, so the missing symbol resolves out of the archive. The wasm
module has no archive to fall back on.
The same applies to any sibling file the adapter calls into: `PulpWclap.cmake`
already lists `clap_remote_controls.cpp` and `clap_note_name.cpp` next to
`clap_adapter.cpp` for exactly this reason.
## The source list covers `core/runtime/` too — not just the adapter's TUs
The rule above ("a new CLAP adapter TU must be added to `PulpWclap.cmake`")
generalises further than it reads: `PulpWclap.cmake`'s list is the wasm module's
**entire world**, because it does not link `pulp::runtime` either. So a symbol
the adapter merely *references* has to be there as well.
Concretely: `clap_adapter.cpp` gained a `runtime::ScopedTracingAttachment`, which
calls `Tracing::attach()` / `detach()`. Under the default `PULP_TRACING=OFF`
those compile to no-op stubs — but the symbols still have to exist, and
`core/runtime/src/trace.cpp` was not in the list. Native builds linked fine
(they get it out of the `pulp::runtime` archive); every WebCLAP target failed
with `wasm-ld: undefined symbol: pulp::runtime::Tracing::attach()`.
The failure mode is what makes this expensive: it is invisible locally and on
the required macOS gate, and surfaces only in the `Build + prove` lane. When you
add ANY dependency to a TU on this list — not just a new TU — check whether its
definition is in the list too.
## The Ganesh/WebGL surface reports a frame outcome now (WAH-2)
`SkiaSurface::end_frame()` returns `render::FrameOutcome` instead of
`void`. On the browser backend (`skia_surface_ganesh.cpp`):
- a normal flush reports `presented` — the browser composites the canvas
element itself, so a successful `flushAndSubmit()` IS the frame
reaching its output;
- a LOST WebGL context reports `recreate`, not `failed`: the caller must
rebuild against the restored context before the frame means anything;
- `has_presentable_target()` is always `true` here — there is no
offscreen-by-design mode on this backend.
Hosts gate damage retirement on `render::frame_reached_output(outcome)`,
so a backend that misreports makes the UI either repaint forever
(false failure) or go stale (false success). If you add a web render
path, return an honest outcome rather than defaulting to `presented`.
## `headless_defaults.cpp` must never share a link with `pulp-format-core`
`core/format/src/wasm/headless_defaults.cpp` supplies the WASM lane's own
definitions of `Processor::create_view()` and
`create_ara_document_controller()`, because a plugin that overrides neither
still carries both slots in its vtable and the wclap/wam links contain no view
layer and no ARA SDK.
The native definitions of those same two methods live in `format.cpp` and
`ara.cpp`, both compiled into **`pulp-format-core`**. So the ODR prohibition is
now about a *target*, not just two filenames: `PulpWclap.cmake` and
`PulpWam.cmake` compile format sources directly and must never grow a link to
`pulp-format-core`. Nothing enforces that mechanically — the comment at the top
of the file is the only guard, which is why it must keep naming the right
target.
**Completeness rule, measured rather than assumed.** *Defining* one of these as
`return nullptr;` does **not** need the returned type complete; `format.cpp`
compiles against nothing but `processor.hpp`'s forward declaration, which is
what keeps `pulp-format-core` view-free. *Calling* one does, because the caller
destroys the returned `unique_ptr` and `~unique_ptr` instantiates the deleter:
```
error: invalid application of 'sizeof' to an incomplete type 'pulp::view::View'
```
So "unique_ptr needs a complete type" is true at the destruction site, not at
the definition. The minimal `class View {}` / `class AraDocumentController {}`
completions in that file are kept because the TU stands in for the whole view
and ARA layers in a link that has neither, not because returning nullptr would
otherwise fail to compile.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!