Skip to content
Back to skills

View Bridge

ASecurity

Plugin editors — load before writing or changing a JS/scripted plugin UI that animates, shows meters, analyzers or modulation, or handles pointer drawing, drag or zoom, and for editor lifecycle and multi-view attach. The realtime performance checklist covers React commits per pointer move or frame, pushing native data with dispatch_native_message instead of load_script, readout relayout, VisualizationBridge backlog policy and frame-time modulation display. Lifecycle covers when to override Pr...

  • 22 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added June 11, 2026
toolsjavascriptgojavaphpc++shellbashreactnodeaws

Works with

  • cursor
  • terminal
  • cli
  • api

Security analysis

A93/100
  • highPerforms destructive filesystem operations

Pro shows the line behind each finding and how to fix it

Scanned September 30, 2026

npx -y skills add danielraffel/pulp --skill view-bridge --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of View Bridge?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for View Bridge
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/danielraffel-view-bridge/badge)](https://www.skillsdirectory.com/skills/danielraffel-view-bridge)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: view-bridge
description: Plugin editors — load before writing or changing a JS/scripted plugin UI that animates, shows meters, analyzers or modulation, or handles pointer drawing, drag or zoom, and for editor lifecycle and multi-view attach. The realtime performance checklist covers React commits per pointer move or frame, pushing native data with dispatch_native_message instead of load_script, readout relayout, VisualizationBridge backlog policy and frame-time modulation display. Lifecycle covers when to override Processor::create_view(), the open → notify_attached → resize → close protocol, release_view() ownership rules, and secondary-view roles.
---

# ViewBridge skill

**TL;DR.** Every Pulp plugin format adapter (VST3, AU v2, AU v3, CLAP,
AAX, Standalone) opens its editor through
`pulp::format::ViewBridge`. You only touch the bridge when you override
`Processor::create_view()` or write a new adapter. This skill captures
the invariants the API enforces and the pitfalls that bit us enough
times to be worth remembering.

## When to use ViewBridge

| Task | Touch ViewBridge? |
|---|---|
| Add a knob to the auto-generated editor | No — just `define_parameters` |
| Return a hand-built `view::View` tree from a processor | Yes — override `Processor::create_view()` |
| React when the host actually shows / resizes / closes the editor | Yes — override `on_view_opened / on_view_closed / on_view_resized` |
| Add a new format adapter | Yes — construct a `ViewBridge` and follow the lifecycle protocol |
| Hand the built view to an external container (TabPanel, WindowHost) | Yes — call `ViewBridge::release_view()` |
| Ship a paint-only overlay | No — the legacy standalone inspector runtime was removed; compose canonical control separately |

### Native scripted UI is not a WebView

When a materialized canvas is retained as the native paint and hit-test
surface, its authored behavior wrapper may be a sibling view. The bridge must
relay every DOM input channel that the wrapper can subscribe to, including
`on_context_menu`; pointer, wheel, click, and context-menu relays must remain
liveness-checked and preserve callbacks already installed on the retained
surface. A right-click that lands on the retained canvas must still reach the
wrapper's context-menu handler after React replaces that wrapper.

`ScriptedUiSession`, `WidgetBridge`, and `@pulp/react` execute through Pulp's JS
engine and native Skia/Dawn view tree; they do not require `WebViewPanel`. In an
SDK configured with `PULP_BUILD_WEBVIEW=ON`, use `pulp::view-native` (or
`pulp_add_plugin(... NATIVE_UI)`, which also selects
`pulp::standalone-native`) when the shipped artifact must not link WebKit,
WebView2, or WebKitGTK. The legacy `pulp::view` and `pulp::standalone` targets
remain the compatibility composition for products that instantiate
`WebViewPanel`. Do not put WebView sources or framework links back onto
`pulp-view-core`, `pulp-view-script`, or `pulp-format`; static-library private
links propagate to the final consumer and silently contaminate native plugins.

`Processor`'s editor hooks (`create_view`, `view_size`, `on_view_*`) are
part of the node ABI surface. When adding a new view lifecycle hook, append
the virtual at the tail of `Processor`; never insert, remove, reorder, or
re-signature existing virtuals. `tools/scripts/node_abi_gate.py --mode=report`
is the fast check before pushing. The same rule applies to non-view Processor
virtuals: additive callbacks such as `process_f64(...)` belong after the
existing f32 process surface, and descriptor fields added for new capabilities
should be appended when positional aggregate initializer compatibility matters.

## Lifecycle protocol — adapter author side

```
bridge.open(&err)                 // builds the view; does NOT fire on_view_opened
  |
  +-- host.attach_to_parent(window)  // platform-specific
  |
bridge.notify_attached()          // fires Processor::on_view_opened
  |
bridge.resize(w, h)               // on each host resize; fires on_view_resized
  |
bridge.close()                    // fires Processor::on_view_closed iff attached
```

**Do not** fire `on_view_opened` before the host has actually attached
the view to a native parent. If attach fails after `open()`, a naive
implementation would fire
`on_view_opened` then destroy without a matching `on_view_closed` and
the plugin would leak host-window-dependent resources. `ViewBridge`
guarantees balance **only when the adapter honors the two-step
protocol** (`open()` then `notify_attached()`).

### Release open parameter gestures on editor teardown

An editor closed **mid-drag** (the user closes the plugin window without
lifting the mouse off a knob) leaves a `begin_gesture` with no matching
`end_gesture`, so the host's automation record stays latched open forever
(VST3 `beginEdit` with no `endEdit`, and the equivalent for AU/CLAP). The
adapter's editor-teardown path (VST3 `IPlugView::removed()`, an AU view
controller's teardown) must call `StateStore::release_open_gestures()`
**before** it tears the view down, so every host begin-edit is balanced.
The store tracks open gestures itself and keeps begin/end 1:1 (a duplicate
begin is suppressed, a stray end is a no-op); it also resets that tracking
whenever `set_gesture_callbacks()` is reinstalled, so an editor that clears
its callbacks on close and re-installs them on reopen starts each session
clean. When you add a new format's editor lifecycle, wire this release call
in its teardown or the same latch bug reappears per-format.

For CLAP, the gesture callbacks do not call the host directly. They enqueue
`CLAP_EVENT_PARAM_GESTURE_BEGIN` / value / `..._END` records on the adapter's
bounded main-thread-to-host SPSC queue. The adapter drains it from both
`process()` and `params.flush()` and requests a host flush for every editor
record, which is what preserves automation while transport is stopped. Keep
the producer main-thread-confined, suppress host-originated writes, and retry a
record rejected by `out_events->try_push()` before advancing; otherwise a
multi-threaded writer or a dropped boundary can corrupt host automation. See
the `clap` skill's Parameters section for the complete transport contract.

### Per-format attach point

| Format | `open()` is called here | `notify_attached()` is called here |
|---|---|---|
| VST3 | `CPluginView::attached()` before `attach_to_parent` | After `CPluginView::attached` returns `kResultTrue` |
| CLAP | `gui_create` | Inside `gui_set_parent` on the matched window API |
| AU v2 | `uiViewForAudioUnit` — after fetching context via `kPulpEditorContextProperty` | After `PluginViewHost::create` succeeds |
| AU v3 | `viewDidLoad` | After `PluginViewHost::attach_to_parent` in `viewDidLoad` |
| AAX | `AAX_CEffectGUI::CreateViewContainer()` — not `CreateViewContents()`, which runs before any window exists | After `try_attach_to_parent` succeeds |
| Standalone | Before `WindowHost::create` | After `WindowHost::create` succeeds |

AAX is the one adapter whose editor runs on its **own** `Processor`. AAX keeps
the host-side data model and the real-time algorithm apart, and the algorithm's
`Processor` lives in a private data block the model cannot reach, so
`EffectParameters` builds a second one for `create_view()` and mirrors its store
against the AAX parameter manager (the value authority). Do not "fix" that into
a shared instance by analogy with AU v2 — there, a second `Processor` was a real
bug; here it is the format's structure. See the `aax` skill.

### Host keyboard routing can bypass the NSView key path entirely — the DAW-spacebar trap

**Searchable keywords**: spacebar dropped in plugin, space not typed in text field,
REAPER eats spacebar, transport key stolen, letters type but spaces don't,
IPlugView::onKeyDown, performKeyEquivalent, chat box drops spaces.

A focused `TextEditor` in a plugin editor can silently drop the **spacebar** (and
only the spacebar) in some hosts. Symptom: letters type, spaces don't. Root cause
is the format-level key pipeline, NOT the NSView layer:

- **REAPER** with *"send all keyboard input to plug-in"* OFF (its default) offers
  keys to the VST3 view through `IPlugView::onKeyDown` **before** its own transport
  accelerator. If the view returns `kResultFalse`, REAPER claims Space for
  play/stop and the event **never reaches the NSView** (`keyDown:` /
  `performKeyEquivalent:`) — so no view-host-side routing can save the field.
  Letters aren't host-bound, so they fall through to the NSView path and type
  normally, which is exactly the "letters work, spaces don't" signature.
  `PulpPlugView::onKeyDown` (core/format/src/vst3_plug_view.cpp) handles this:
  consume Space **only** when a `TextEditor` in this editor's tree holds focus,
  excluding Cmd/Ctrl chords; every other key returns `kResultFalse` so transport,
  Musical Typing, and host shortcuts stay with the host. Scoped to Space because
  it is the only key REAPER delivers well-formed here (`host_quirks:
  reaper_keyboard_only_space`).
- The **NSView `performKeyEquivalent:`** fix (plugin_view_host_mac.mm) is a
  *different* layer, for hosts that DO route plain keys through the responder
  chain (plausibly Logic's AU hosting). Both layers exist because different hosts
  deliver keys differently — a fix at one layer does not cover the other.

Debugging rule: if a plugin's text field drops a key, first determine **whether
the key even reaches the NSView** (log in `keyDown:`). If it never arrives, the
fix belongs at the format layer (`onKeyDown`), not the view host.

### One policy answers "did the editor consume this key?" for every format

`pulp::view::route_plugin_key` (`core/view/include/pulp/view/plugin_key_routing.hpp`)
is the shared answer, and every format seam asks it rather than deciding for
itself: the macOS `-keyDown:` path in `plugin_view_host_mac.mm` (AU v2/v3, CLAP,
and VST3's NSView) and `PulpPlugView::onKeyDown` (VST3's own pipeline) both route
through it. It takes no platform type, because the three seams share no code —
only the policy.

It answers in this order: an open overlay's Escape, then the focused view under
**this** root, then `root.on_global_key`. Anything none of them claimed is
`forward_to_host`. Forwarding is the DEFAULT and consumption is what has to be
earned; there is no allowlist of "keys the host wants" anywhere in the policy,
because such a list is always incomplete and fails silently when it is.

Three things are easy to get wrong here, and each one is invisible until a
musician hits it:

- **A focused text field does not consume everything.** A Command/Control chord
  or a function key it declined is not text, so there is nothing left for the
  FIELD to do with it: the key falls through to `on_global_key` and, unclaimed
  there, to the host. The old macOS path returned
  "handled" unconditionally once a field held focus, which killed host chords
  and F-key transport for exactly as long as a type-in happened to be open.
  `PluginKeyOffer::is_function_key` is how the platform tells the policy that a
  key carries no character (AppKit's 0xF700-0xF8FF private-use range).
- **A merely focusable widget must not become a keyboard sink.** A view that
  accepts navigation but not text is offered only `is_plugin_navigation_key`
  (arrows, Home/End, Enter, Escape, and never with a chord modifier) and its own
  `on_key_event` decides from there. That floor is not a claim about what the
  host wants — it is what stops a focused knob from swallowing Space.
- **AppKit delivers one press twice.** `-performKeyEquivalent:` runs before
  `-keyDown:`, and on macOS that override owns `root.on_global_key`. So
  `PluginKeyOffer::offer_global_hook` is OFF by default and the NSView seam
  leaves it off: consulting the hook from both passes fires an editor-wide
  shortcut — and the script `keydown` listener behind it — twice for one press.
  A seam with a single delivery point (VST3 `onKeyDown`) sets it, and is then
  the only place the hook is consulted for that press.

One documented place the macOS seam does NOT forward what the policy calls
unclaimed: a view holding the focus slot that claims neither text nor
navigation. `acceptsFirstResponder` is already false for it, so the DAW owns
the keyboard and there is nothing to hand back — but forwarding would still
move first responder to the host view, and `resignFirstResponder` ends that
widget's focus out from under it. A custom control that uses Escape for its own
purpose would lose focus on the first press. That is a first-responder fact, so
it lives in the platform file rather than in the policy.

`plugin_key_focus(root)` is the scoped focus read every seam must use:
`View::focused_input_` is process-global, so with two editors open it may name
the *other* editor's field, and answering from it reports the key handled — so
the host never sees it either and the spacebar dies with no visible cause.

Pinned headlessly by `test/test_plugin_key_routing.cpp`, where every case that
asserts consumption has a sibling asserting the key it must hand back.

### Plain-key global shortcuts: standalone only by default

A script `keydown` listener that calls `preventDefault()` has CONSUMED the key:
the plug-in view reports it handled and the DAW never sees it. Keys it leaves
alone go back to the host (pinned by `test_plugin_view_host_script_keys.mm`).
The framework therefore cannot rescue a host whose keys an editor claims — the
policy has to be the editor's, and it needs one fact to decide: where it lives.

- **Ask `hostKind()`.** Scripts get `"plugin"`, `"standalone"`, or `"unknown"`
  (a preview or a bridge nothing configured). `ViewBridge` sets it from
  `Processor::editor_host_kind()`, which `StandaloneApp` marks `standalone` and
  every plug-in adapter leaves `plugin`. The session keeps it across hot
  reloads (`ScriptedUiSession::set_host_kind`). ViewBridge declares it when the
  editor opens, which is after a processor-owned session's first render, so an
  editor that builds its own `ScriptedUiSession` inside `create_view()` should
  call `session->set_host_kind(...)` before `load()`, or read `hostKind()` at
  keydown/render time rather than caching it at load.
- **Plain-key global shortcuts (a bare letter or digit that changes editor
  state) are standalone-only by default.** A DAW owns its plain keys — Logic's
  Musical Typing plays notes on A S D F G H J K L ; ' and W E T Y U O P, digits
  pick octave/velocity — and no host lets a plug-in ask whether that is open.
  In `"plugin"`, return from the listener WITHOUT `preventDefault()`; offer a
  user setting to opt back in.
- **Keyboard inside something the user explicitly opened stays the editor's**:
  arrows/Enter/Escape in an open menu or dialog, typing in a focused field.
- **Modifier chords are different.** Cmd/Ctrl chords reach the editor through
  `-performKeyEquivalent:` / `on_global_key` before the host's menus; claiming
  one (Cmd+Z) takes it from the host for as long as the editor window is key.
  Claim only chords the editor genuinely owns.
- **Every hint that names a key shows only while that key is live** — a badge
  or "A to cycle" in a DAW where A plays a note is a lie.

### Wheel, pinch and rotate resolve through open overlays

`deliver_mouse_wheel` and the macOS magnify/rotate handlers ask
`route_passive_pointer(root, pt)` before the tree hit test, the way presses
(`route_press_to_active_overlay`) and hovers (`hover_target_at`) already do. An
overlay that escapes its ancestors' bounds — a full-editor scrim mounted inside
a toolbar button — paints over the content, but the tree hit test cannot descend
into it there and lands on whatever it covers, so a scroll over a dialog's
backdrop used to zoom the plot behind it. Inside a shown overlay the input goes
to the overlay's own subtree; outside every overlay while a MODAL one is open
(`ModalOverlay`, or an overlay with `AccessRole::dialog`) it is dropped;
otherwise the tree answers as before. Hidden overlays count for nothing — and
`root_overlay_owns_keyboard` likewise ignores a claimed popover that is not on
screen, so a dialog that stays mounted while closed cannot hold the DAW's
keyboard.

## `release_view()` — for containers that own the view

`TabPanel::add_tab` and similar widgets take `std::unique_ptr<view::View>`.
Call `bridge.release_view()` to hand ownership over. The bridge keeps a
raw pointer so `notify_attached`, `resize`, and `close` continue to
dispatch `Processor::on_view_*` on the same view instance.

**Contract:** the caller must keep the released view alive until
`bridge.close()` runs (or the bridge is destroyed). The standalone
adapter handles this by calling `bridge.close()` explicitly after
`run_event_loop()` returns, before the `TabPanel` falls out of scope.

Standalone also has an editor-only path now: set
`StandaloneConfig::show_settings_tab = false` and `run_with_editor()`
will host the released editor root directly in `WindowHost` instead of
wrapping it in the outer `Editor/Settings` `TabPanel`. The same
ownership rule still applies: close the bridge before the released
root view is destroyed.

Standalone no longer constructs or close-wraps the legacy
`StandaloneInspectorRuntime`. Canonical runtime control is a separate host
composition concern: do not reintroduce it through `ViewBridge` or disturb the
standalone `open` / `notify_attached` / `close` balance. The audio-inspector tool
window is a distinct, local observability surface and remains supported.

`run_with_editor()` also opts the host into the platform's native
file-dialog backend by calling `platform::FileDialog::install_native_backend()`
once the processor's editor is confirmed. This is a no-op on macOS (a
native impl is compiled in) and on platforms with no built-in backend
(iOS/Windows/Android), but on Linux it installs the xdg-desktop-portal
FileChooser bridge (`core/platform/.../file_dialog_portal_linux.cpp`,
talking to the portal over the runtime-dlopen `pulp::platform::DBus`
client) so editor file pickers work natively. We install here — not via a
static initializer — because raising a portal dialog blocks, so the
default "no backend → no selection" contract must hold for headless/test
callers until a host explicitly opts in.

Standalone now also keeps the bridge's size in sync with the real host
content area. `run_with_editor()` reads `WindowHost::get_content_size()`
immediately after `notify_attached()`, subtracts any standalone chrome
height, dispatches `ViewBridge::resize(...)`, and re-dispatches on each
`WindowHost::set_resize_callback(...)` event. If you embed a native child
or rely on `Processor::on_view_resized(...)`, do not assume
`editor_size()` is the last word after attach.

Standalone resizes the host window per active settings-chrome tab. The
Audio/MIDI `SettingsPanel` needs far more height than a fixed-size editor
(query `SettingsPanel::preferred_height()` — header + inner Audio/MIDI tab
bar + every Audio-tab row at full size), so a 372px-tall editor window
squishes the device dropdowns to slivers. `run_with_editor()` wires the
outer card-stack `TabPanel::on_tab_change` to drive the SAME design-viewport
path the keyboard resize uses — `set_design_viewport` /
`set_fixed_aspect_ratio` / `request_content_size` to `(editor_w, editor_h)`
on the Editor tab and `(editor_w, SettingsPanel::preferred_height())` on the
Settings tab. The editor tab keeps its exact declared size (no letterbox);
the Settings tab reflows to fill its taller window. Guarded on
`chrome.tab_panel()` so the editor-only chrome (no settings tab) is untouched.

Standalone headless/test launches must not show or activate a native
window. `StandaloneConfig::headless`, `PULP_HEADLESS`, `PULP_TEST_MODE`,
`CI`, or `PULP_SCREENSHOT` route `run_with_editor()` through
`WindowOptions::initially_hidden`; screenshot mode captures
`WindowHost::capture_back_buffer_png()`, not compositor-visible
`capture_png()`. If a CI/test run is headless but has no screenshot path,
`run_with_editor()` fails before creating the host so tests cannot park a
hidden live window forever.

`WindowHost` also answers whether the frame it just rendered reached its output:
`supports_gpu_submission_evidence()` / `last_frame_gpu_submission_observed()`,
appended at the public vtable tail like every other host query. Only
`MacGpuWindowHost` implements them, mirroring the
`render::frame_reached_output()` verdict that already gates damage retirement,
so `presented` and `offscreen` report true and `recreate` reports false even
though the recording was submitted. Two consequences worth knowing before
reading the flag:

- **It describes the LAST rendered frame, not "now."** The inspector's
  frame-evidence producer is correct only because
  `capture_back_buffer_png()` runs immediately before it, on the main thread —
  the capture renders the frame the query then answers for. Reorder those two
  and the flag silently describes a different frame.
- **Every other host keeps the `false` default**, so an absent producer can
  never be read as evidence. Do not "fix" that by inferring submission from a
  valid screenshot or an available adapter: a capture that succeeded proves the
  host had pixels, not that they reached an output.

The audio dumps (`audio_probe_json_path`, `audio_scope_json_path`,
`audio_capture_wav_path`, `audio_capture_rolling_path`; gated by
`PULP_ENABLE_AUDIO_PROBES`) are a SECOND headless one-shot family that reuses
the same `screenshot_frame_delay` counter (`detail::DelayedAction`): after the
delay the mode's writer runs — probe-json writes `output_probe().latest()` via
the pure `audio::audio_probe_snapshot_to_json()` helper — then the host closes.
No fake PNG bytes or cleared screenshot path are involved; a dump has no image
to validate, so the plain `DelayedAction` drives it with the write as its side
effect. A headless run without a screenshot path is valid as long as ANY of the
four is set (`standalone_headless_requires_screenshot` accepts all four) — don't
tighten the "headless requires screenshot" guard to reject them.

**The two arming paths do not compose alike, and only one of them is plural.**
With a screenshot, every dump writer is called from the screenshot `capture_fn`
(same frame, before the window closes) and each no-ops on an empty path — so any
combination of dumps works. Without a screenshot, the modes are a table in
`run_with_editor()` and the loop `break`s after arming the FIRST non-empty entry;
the rest are dropped silently, no error. Table order (probe-json, scope-json,
capture-wav, capture-rolling) IS the precedence. Two consequences:

- A headless run asking for two dumps at once yields one file. Pass a screenshot
  path too if you need both.
- **A new dump mode is a new row in that table, not a branch beside it.** Each
  arming wraps `pre_screenshot_idle` — not whatever callback is currently
  installed — and hands the result to `WindowHost::set_idle_callback`, which
  holds ONE callback. A second arming therefore overwrites the first one's
  one-shot rather than chaining after it, and the earlier dump never writes.
  The `break` is what keeps that unreachable.

## AU v3 controller lifecycle — runs on XPC queue, NOT main (Phase 3.5)

The host invokes `-[PulpAUMacViewController createAudioUnitWithComponentDescription:error:]`
(macOS) and `-[PulpAUViewController ...]` (iOS) on
`com.apple.NSXPCConnection.user.endpoint` — the XPC connection's
serial queue, not the main thread. Any AppKit / UIKit call from there
(`setPreferredContentSize:`, `self.view`, the `PluginViewHost`
attach) throws `NSInternalInconsistencyException` and kills the
.appex process. Host reports "Failed to load Audio Unit"; auval
silently passes because it doesn't exercise the controller path.

`au_view_controller_mac.mm` + `au_view_controller_ios.mm` apply a
HARD GUARD at the top of `rebuildEditorIfReady` that bounces the body
to `dispatch_get_main_queue()` if invoked off-main. **Don't guard
only at `setAudioUnit:`** — the compiler can inline the property
setter when `createAudioUnit` assigns to `self.audioUnit`, bypassing
the check. The guard must live in `rebuildEditorIfReady`.

Full recipe (macOS framework + stub .appex + container .app, signing,
notarization, PlugInKit diagnostics) is in
`.agents/skills/auv3/SKILL.md → "macOS AU v3 packaging"`.

### AU v3 teardown must ALSO run on the main thread

The same off-main hazard applies to `-dealloc`. A GPU-backed
`PluginViewHost` owns a `CVDisplayLink` whose idle pump
(`make_editor_idle_pump`) is `dispatch_async`'d to the **main queue**
and dereferences the `ViewBridge`. If the last controller release lands
on the XPC connection queue (it can — `createAudioUnit…` and the rebuild
bounce arrive off-main), destroying the host + bridge off-main races a
main-queue idle block already past its `alive` liveness check → the pump
touches a freed bridge (SIGSEGV in `display_link_callback`). This is the
same use-after-free the AU v2 Cocoa view fixed by serializing teardown.
Both AU v3 `dealloc`s (`au_view_controller_mac.mm`,
`au_view_controller_ios.mm`) reset `_viewHost` on the main thread first
(`[NSThread isMainThread] ? reset : dispatch_sync(main, reset)`) — that
flips the host's liveness token and stops the display link before any
freeing — then let the remaining ivars destroy in the documented
reverse-declaration order. **Don't** remove the main-thread reset, and
**don't** instead try to clear `idle_callback_` from the off-main
`dealloc`: writing the `std::function` while the main-queue block is
calling it is just a different data race. Only main-thread serialization
closes it. The GPU host's `display_link_callback` block additionally
copies the idle callback locally and **re-checks `alive` after** running
idle (a scripted `poll()` can reentrantly close the editor and free
`self`) — parity with the CPU host's `render_link_callback`.

### macOS AU v3: editor attach is now DEFERRED until a settled host size

`rebuildEditorIfReady` no longer creates the `PluginViewHost` (or calls
`ViewBridge::notify_attached()`) inline. Logic hosts AU v3 out-of-process and
does **not** deliver its restored window size to the extension's view on initial
open, so a `PluginViewHost` built then would paint its first frame at the design
size inside Logic's smaller window (clipped). Instead `rebuildEditorIfReady`
opens the bridge, sets `preferredContentSize`, wires a `setFrameSize:` hook on a
custom `PulpAUMacRootView` (which fills its superview the **frame-based** way —
`autoresizingMask`, NOT Auto Layout: constraint pinning crashed Ableton Live, see
the auv3 SKILL), and marks the host
**pending**; `-createViewHostIfReady` then builds the host **at the view's real
bounds** — and only there fires `notify_attached()`. **Consequence for adapter
authors:** on macOS AU v3, `Processor::on_view_opened` (via `notify_attached`)
fires slightly later — after the host's first real layout, not at controller
build — and `_pendingRoot`/`_viewHostPending` gate the one-shot creation. Don't
move `notify_attached()` back inline; it reintroduces the first-paint clip.
Rationale + the view-config/first-paint root cause are in
`.agents/skills/auv3/SKILL.md → "Logic OOP first-paint clip"`. The same controller also calls `set_design_viewport_top_align(true)` so the AU design anchors to the TOP of a taller host pane (REAPER FX-chain) like CLAP/VST3 instead of centering between bands — see the auv3 SKILL.

**iOS editor sizing differs from macOS — it FILLS the pane.**
`au_view_controller_ios.mm` deliberately does NOT pin a design viewport: it
lays the root out at the actual pane bounds (via `resizeEditorToViewBounds` →
`set_size` + `ViewBridge::resize`) so a responsive flex scene fills edge-to-edge.
Aspect-locked design-viewport scaling letterboxed the pane (dark bars on the
sides) and pushed header text to the edge. The `notify_attached` → `resize`
protocol is unchanged; only the viewport policy differs. A genuinely
fixed-aspect iOS editor that wants letterboxing can call `set_design_viewport`
itself. See the `ios` skill for the responsive-scene + `syncCanvasSize` contract.

## AU v2 dual-Processor gotcha (fixed)

Pre-ViewBridge, the AU v2 Cocoa view factory called
`format::registered_factory()` and built a **second** `Processor` +
`StateStore` for the UI, syncing parameters one-shot at construction.
Any parameter change on the audio thread drifted from the UI.

Post-fix: `au_v2_adapter.{hpp,cpp}` exposes the host `Processor*` +
`StateStore*` via a private AU property `kPulpEditorContextProperty`
(`'PuEd'`). The view factory fetches it with `AudioUnitGetProperty`
and drives a `ViewBridge` against the host's single Processor.

AU v3 does the same thing via the ObjC accessors
`-[PulpAudioUnit pulpProcessor]` and `-[PulpAudioUnit pulpStore]`.

**If you add another AU-like adapter, expose the same pair.** Never
spin up a second Processor for the UI.

AU v3 render automation also attaches its block-local
`ParameterEventQueue` to the existing audio `Processor` immediately
before `process()` via `set_param_events(...)`. Treat that pointer like
the MIDI/MPE sidecars: it is valid only during the current process call
and must not be captured by editor/view code.

**Trigger / momentary params and the shared StateStore.** An editor that
raises a *trigger* parameter (`ParamInfo::is_trigger`, or a
`ParamDesignation::Reset` "reset/panic" control) on the shared store is
writing a one-shot: the adapter's render path calls
`StateStore::reset_triggers_rt()` after each `Processor::process` block,
which returns the parameter to its default. Editor/view code therefore
must not assume a trigger it set stays raised across frames — it is
observed for one block and then auto-settles. Read it back from the
store if you need to reflect the resting state in the UI.

## Standalone editor: a `--screenshot` launch has no audio behind it

`StandaloneApp::start()` skips the audio backend entirely when the launch is a
screenshot-only capture — no `AudioSystem`, no device, no render callback, no
hardware MIDI. The editor is built, opened and photographed exactly as usual;
only the audio underneath it is absent, so a capture can run on a shared or
unattended machine without opening a device.

What this means for editor work:

- The `ViewBridge` lifecycle is unchanged — `open` / `notify_attached` /
  `resize` / `close` all still run. Do not "fix" a capture by re-adding a
  device.
- Anything the editor drives from live audio (meters, scopes, the Settings
  tab's device lists) is empty in such a capture. Set
  `StandaloneConfig::screenshot_keeps_audio` (or `PULP_SCREENSHOT_KEEP_AUDIO=1`)
  when the shot is *about* live signal; requesting a probe/scope/WAV readout in
  the same run keeps audio on by itself.
- `StandaloneApp::audio_skipped_for_capture()` reports which mode a run took.

## Opening a document has two halves, and each is inert alone

An app that opens its own file type on macOS needs BOTH:

1. **Routing** — `pulp_declare_standalone_document_type()` in CMake, which writes
   `CFBundleDocumentTypes` + `UTExportedTypeDeclarations` into the app's
   Info.plist. This is what makes Finder give the file the app's icon and send a
   double-click to it.
2. **Delivery** — `StandaloneApp::set_open_files_handler()` (or, below it,
   `WindowHost::set_open_files_handler()`). macOS reports the file through the
   `NSApplicationDelegate` message `application:openURLs:`; with no handler the
   app launches and the file is dropped.

Declaring the type without installing a handler is the failure that reads as
"the declaration didn't work": the icon is right, the double-click launches the
app, and nothing opens.

```cpp
app.set_open_files_handler([&](const std::vector<std::string>& paths) {
    for (const auto& path : paths) open_project(path);
});
app.run_with_editor();     // handler MUST be installed first — see below
```

**Install before the event loop starts.** The file that launched the app is
delivered around `applicationDidFinishLaunching`, which is *inside* `[NSApp
run]`. A handler wired up after the loop starts would miss exactly the file that
caused the launch — the common case. Paths that arrive before a handler exists
are held and flushed the moment one is installed, so an app that installs late
still gets them, but the ordering above is the contract to write against.
Passing `{}` removes the handler without discarding what is queued.

**The delegate never displaces one that is already there.** A Pulp plug-in
loaded into a DAW shares that host's `NSApplication`. Taking over its delegate
would break the host's own document, reopen and termination messages, so the
install is guarded on `NSApp.delegate == nil`. A hosted Pulp binary therefore
never receives open events — correct, because the host owns its documents. This
is pinned by a test, not left to the guard's comment
(`test/test_mac_open_document.mm`, target `pulp-test-mac-open-document`), which
drives the real installed delegate directly: nothing in a unit test can produce
the Apple Event the OS sends.

`PulpAppDelegate` is registered in `core/view/platform/mac/pulp_mac_objc_names.h`
like every other Pulp ObjC class. ObjC class names are process-global, and an
`NSApplication` delegate is the most dangerous kind to have shadowed across two
co-loaded Pulp binaries.

## Editor-only state changes must tell the host, or the user loses them

A parameter edit from the editor reaches the host through the parameter system,
so the host knows the project changed and will offer to save. Anything the
editor changes *outside* that system leaves no such trace — a loaded sample or
impulse response, an imported wavetable, a UI-only setting that rides in the
plugin's own `getState` payload. The editor looks like it worked, the state
round-trips correctly through save/load, and the user still loses the work by
closing a project the host believed was clean.

`Processor::flag_state_dirty()` raises that edge. It is an atomic with the same
shape as `flag_latency_changed()` / `flag_note_names_changed()` — safe to call
from `process()`, though an editor or a file load on the main thread is the
usual caller. The adapter republishes it by whatever route the format
sanctions; the VST3 adapter is wired (`IComponentHandler2::setDirty(true)`,
delivered on its main-thread drain), CLAP's equivalent is
`clap_host_state::mark_dirty`, and the other adapters do not consume the flag
today — raising it there is harmless but inert.

The habit worth forming: whenever an editor writes something that only exists
in the plugin's own state payload, raise the flag in the same code path. It is
cheap, and the failure it prevents is silent and unrecoverable.

## Secondary views

```cpp
view::View* ViewBridge::attach_secondary_view(std::unique_ptr<view::View>, ViewRole);
bool        ViewBridge::detach_secondary_view(view::View*);
size_t      ViewBridge::view_count()                const;
view::View* ViewBridge::view_at(size_t index);
ViewRole    ViewBridge::role_at(size_t index)       const;  // Editor, Inspector
```

Roles are opaque — the bridge only uses them for introspection.
Parameter bindings propagate automatically because every attached view
polls the same `StateStore`; there is no explicit broadcast step.

## Parameter type-in round-trips through `ParamInfo` (shared with the editor)

The same `StateStore` every attached view polls (see *Secondary views*) is
also the source of truth for the host's parameter **display strings** and its
**generic type-in field**. As of G5 all four format adapters route the
host-facing text↔value conversion through the *same* `ParamInfo::to_string` /
`from_string` your editor draws from:

| Format | Host callback that now honors `ParamInfo` |
|---|---|
| VST3 | `getParamStringByValue` / `getParamValueByString` |
| AU v2 | `kAudioUnitProperty_ParameterStringFromValue` / `...ValueFromString` (+ `kAudioUnitParameterFlag_ValuesHaveStrings` when a `to_string` exists) |
| CLAP | `params_value_to_text` / `params_text_to_value` |

The CLAP `params_text_to_value` change is the one that lands on the shared
`clap_entry.hpp` (hence this skill is dual-mapped with `clap`): it now tries
`ParamInfo::from_string` **before** the generic locale-independent `strtod`, so
a fully custom rendering ("quality=0.75", an enum label, "12 o'clock")
round-trips instead of being rejected by a bare numeric parse. CLAP values are
plain (`min..max`) — the exact domain `from_string` returns — so no
normalization step is needed; VST3 converts to/from the host's normalized
domain around the same call.

**Editor consequence.** A parameter's on-screen value string (drawn by your
view) and the host's generic type-in field now agree, because both derive from
the same `ParamInfo` converters on the shared store. Two rules for editor
authors:

- **Don't add a parallel text parser in the editor.** Type-in a user enters in
  your own field should call the SAME `ParamInfo::from_string` (or bind through
  the store) so your field and the host's stay consistent.
- **Decline by returning a non-finite value.** A `from_string` that yields
  NaN/±inf is rejected: CLAP falls through to the numeric parse, VST3/AU decline
  to the base class. So a parameter with no meaningful string form should return
  NaN rather than a garbage value.

Tests: `test/test_clap_entry.cpp`, `test/test_vst3_param_display.cpp`,
`test/test_au_v2_param_display.mm`.

## Trackpad / Scroll-Wheel Zoom Event Path

**Searchable keywords**: trackpad zoom, scroll-wheel zoom, mouse wheel, "1.00x zoom" stuck, deltaY missing, deltaY=0, wheel event not firing, FilterBank zoom, Spectr zoom, onWheel, addEventListener('wheel'), registerWheel never called, wheel bubble, ancestor not receiving wheel, canvas-child captures wheel, wheel handler short-circuits.

**Symptom**: in Spectr (and likely any @pulp/react consumer that uses a
canvas child inside a wheel-handling wrap-div), scroll-wheel or
trackpad scroll over the canvas does NOT trigger the wrap-div's zoom
handler. The zoom indicator stays "1.00x" no matter how many wheel
events fire.

**Three independent bugs stacked**, all needed fixing to make zoom work:

### Bug A: `on(id, 'wheel', fn)` never invoked `registerWheel(id)`
The `on()` JS function in `kJSPreamble` mapped event names to native
registrars (click → `registerClick`, pointer events → `registerPointer`,
gesture events → `registerGesture`), but had **no case for `'wheel'`**.
Spectr's editor.js bound a wheel handler via `addEventListener('wheel',
fn)` which routes through `on(id, 'wheel', fn)`. The callback was
stored in `__callbacks__[id + ':wheel']` but the native side was never
told this view wanted wheel events. Result: `registerWheel` ran for 0
views, wheel events had no JS receivers.

**Fix**: add a `wheel` case to `on()` + a `wheel` group to
`__ensureNativeRegistered__()` so `on(id, 'wheel', fn)` calls
`registerWheel(id)` to wire the native dispatch. (`core/view/src/widget_bridge.cpp` `kJSPreamble`.)

### Bug B: bubble loop short-circuited on the wrong handler
`window_host_mac.mm::scrollWheel:` walked from the hit-tested deepest
view up to find the first ancestor with `on_pointer_event` set, then
delivered the wheel event there and returned. But the deepest hit
(typically a Canvas2D child) had `on_pointer_event` registered via
`registerPointer` (for pointerdown/up/move/cancel) — that lambda
short-circuits on `is_wheel` (it's the pointer-only handler). The
bubble therefore delivered the wheel event to a no-op handler and
returned, never reaching the wrap-div ancestor that had registered the
ZOOM handler via `registerWheel`.

**Fix**: change `scrollWheel:` to deliver to EVERY ancestor with
`on_pointer_event` set (not stop at the first). Each handler self-
filters on `me.is_wheel`: `registerPointer`'s lambda short-circuits
when `is_wheel == true`, `registerWheel`'s short-circuits when `false`.
So a view that registered both gets both halves; a view that registered
only one ignores the other. ScrollView ancestor still takes precedence
and stops the walk. (`core/view/platform/mac/window_host_mac.mm`.)

### Bug C: wheel-event payload must include `clientX/clientY/deltaY`
The bridge emits
`{deltaX, deltaY, clientX, clientY}` as an object (not positional args)
so the `@pulp/react` synthetic-event shim's `isPlainObject(rawArgs[0])`
branch can lift the fields. Without this, even after Bugs A+B were
fixed, `e.deltaY` would be undefined and `e.clientX - rect.left` would
read 0.

### How to diagnose if zoom is broken again

1. `fprintf(stderr, ...)` in `scrollWheel:` to confirm the NSView even
   receives the event — if not, accessibility / focus issue.
2. Confirm `registerWheel('<view_id>')` is being called after the
   view's editor.js mounts. If never called, Bug A is back.
3. Confirm the bubble walk reaches the wrap-div, not stopping at the
   canvas child. Print the chain `target → parent → … → root` and
   note which have `on_pointer_event` set.
4. Confirm `__dispatch__(view_id, 'wheel', {deltaX, deltaY, clientX,
   clientY})` is called with non-zero deltas. If `clientX/clientY` are
   0, `me.window_position` was not set in `scrollWheel:`.
5. In Spectr's `native-react/dist/editor.js` bundle, `grep deltaY` —
   expect ≥3 mentions. If 1 or 0, the bundle was built against an old
   `@pulp/react` without the synthetic-event delta fields and the
   bundle needs `npm run build:port`.

### Tooling: drive wheel events programmatically

`cliclick` has no wheel command (`w:N` is WAIT, not WHEEL). Compile a
tiny tool:

```c
// /tmp/scroll-event.c
#include <ApplicationServices/ApplicationServices.h>
int main(int argc, char** argv) {
    int count = argc > 1 ? atoi(argv[1]) : 10;
    int delta = argc > 2 ? atoi(argv[2]) : 5;
    for (int i = 0; i < count; i++) {
        CGEventRef ev = CGEventCreateScrollWheelEvent(NULL, kCGScrollEventUnitLine, 1, delta);
        CGEventPost(kCGHIDEventTap, ev);
        CFRelease(ev);
        usleep(50000);
    }
}
```

Build: `clang -framework ApplicationServices -o /tmp/scroll-event /tmp/scroll-event.c`. Posts real CGEvent scroll-wheel events that reach `NSView::scrollWheel:`. Use `/tmp/scroll-event 30 5` to inject 30 scroll-up events.

## Stale-Header ABI Mismatch

**Searchable keywords**: silent crash, "Standalone: editor window open"
then exit, segfault inside `WidgetBridge::register_api()::$_NNN`,
`byte read Translation fault` at `x9=0` during Promise reaction, JS
`__dispatch__` from `pump_message_loop`, Spectr exits after CoreAudio
init, `pointer_registered_`, `wheel_registered_`, link-swap, install
prefix, `/tmp/pulp-sdk-gpu-latest`, `pulp upgrade --install`.

**The trap**: any PR that adds/removes/reorders **member variables**
on `WidgetBridge` (or any other class whose layout consumers compile
against) changes the C++ struct layout. If the **installed SDK
header** under the consumer's `Pulp_DIR` is OLDER than the
**installed SDK static library**, the consumer's translation units
compute member offsets from the old (smaller) layout while the lib's
own translation units (lambdas registered in `register_api()`) use the
new (larger) layout. Member access from a lambda points into the
wrong byte — typically NULL or garbage — and the first access
SIGSEGVs.

This presents as a "silent crash" because:
- macOS shows the editor window briefly
- The first paint frame logs `[gpu-host] first frame: …`
- Then a Promise reaction (React's commit phase) fires a registered
  C++ callback that does a member load → segfault → process dies
- The standalone parent (zsh / shell) reports nothing useful

The macOS crash report tells you everything: open
`~/Library/Logs/DiagnosticReports/<App>-<date>.ips`, look at thread 0's
faulting frame. If it's `WidgetBridge::register_api()::$_NNN + <offset>`
with `Exception Subtype: KERN_INVALID_ADDRESS at 0x…` and `x9=0` (or
some other register pointing into the bridge struct), you have an ABI
mismatch.

**Fix**:
```bash
# Re-install the header to the SDK consumer's install prefix
cp core/view/include/pulp/view/widget_bridge.hpp \
   "$PULP_SDK_INSTALL/include/pulp/view/widget_bridge.hpp"

# Wipe the consumer's stale .o files (they were compiled with old offsets)
rm -rf "$CONSUMER/build/CMakeFiles/<your-target>.dir"

# Rebuild the consumer from clean
tools/ci/governed-build.sh cmake --build "$CONSUMER/build" --target <your-target>
```

When you `pulp upgrade --install` this is automatic because the SDK
release lays down headers + libs together from one build. The trap
only fires when you **link-swap a fresh `libpulp-view.a` into an SDK
install whose header is stale**, which is the failure mode for local
SDK-side iteration outside the `pulp upgrade` flow.

**Belt-and-suspenders mitigation** (TODO followup): consider adding a
build-time assertion in widget_bridge.hpp using `_Static_assert(sizeof(WidgetBridge) == EXPECTED, …)`,
where `EXPECTED` is generated at SDK release time from the actual
library build. A stale header would then fail to compile against the
fresh lib instead of segfaulting at first paint.

## Value channels reach the editor through the ViewBridge, and must be re-attached on reload

A processor publishes non-parameter values — gain reduction, an envelope, a
spectrum — via `Processor::value_channels()`, and a scripted UI binds them with
`bindMeter(id, "value:<name>")`. The set travels:

```
Processor::value_channels()
  -> ViewBridge (it owns the Processor reference)
  -> build_editor_ui(..., value_channels)
  -> ScriptedUiOptions::value_channels
  -> ScriptedUiSession (stored, like gpu_surface_)
  -> WidgetBridge::set_value_channels()
```

**The re-attach is the part that bites.** `ScriptedUiSession` rebuilds the
`WidgetBridge` on every hot reload, so the attach sits in the rebuild path next
to `set_asset_roots()` — exactly like `attach_gpu_surface()`. Miss it and
`value:` binds resolve on first load and then silently stop resolving after the
first edit, which looks like the channel died rather than like a lifecycle bug.

The set is **non-owning everywhere in that chain**: it belongs to the processor
(usually a subclass member) and must outlive every attached view. The virtual
returns `nullptr` by default, so a processor that declares nothing costs nothing
— no set, no bindings, no framework work.

**`value:` is a separate namespace from parameter names, with no fallback.** A
`value:` source that names no declared channel fails with
`BindingOutcome::unknown_value_channel`; it does NOT fall back to a parameter of
the same name. Resolving across the two would bind a meter to the wrong signal
and look like it worked.

**Reference implementation: `pulp create --template gain`.** Its
`processor.hpp.template` declares an `output` meter channel and publishes one
peak/RMS `MeterFrame` per block; its `ui/main.js` binds it with
`bindMeter("out-meter", "value:output")`, so no script runs per audio tick, and
throttles its numeric readout to ~10 Hz with a pinned width. Copy that shape for
any meter or level before reaching for a per-tick push. Data that is not a
meter (analyzer frames, modulation) goes through
`WidgetBridge::dispatch_native_message`, never `load_script` — the processor
template's custom-editor comment block says so where an author starts a custom
editor. Three gates keep scaffolds and imports on this path:
`tools/scripts/verify_create_templates.py` (no template calls `load_script`, and
every template UI script passes the realtime contract; `create-templates-verify`
ctest), `test/test_template_ui_scripts.cpp` (the gain script's meter really
follows the channel through the bridge, and the readout width is pinned), and
`tools/import-design/check_contracts.py`, whose `realtime` gate flags a React
state setter on a pointer-move / wheel / rAF / repeating-timer path, a
fresh-object setter in a sync effect, and one handler bound to both phases.

## Drag input is held per presented frame — do not dispatch raw AppKit events

Both macOS plug-in editor hosts (`MacPluginViewHost` CPU, `MacGpuPluginViewHost`
GPU, in `core/view/platform/mac/plugin_view_host_mac.mm`) run a CVDisplayLink,
so `-mouseDragged:` HOLDS its sample in a `pulp::view::HostDragCoalescer` and
the link callback releases at most one per presented frame. A pointer device
emits samples at its own rate; the editor presents at the display's, or slower
when a frame is expensive, and every extra sample between two frames costs a
full delivery — hit test, handler invocation (entering the JS engine for a
scripted UI), and an invalidation whose only effect is to re-dirty an
already-dirty surface. None of it reaches the screen.

Four rules ride with this. They live in `host_drag_coalescer.hpp` so a host
supplies only delivery, and a new host gets them by construction:

- **One dirty signal per HELD RUN, not per event.** `pulp_plugin_mouse_drag`
  returns whether to arm a repaint; it is true exactly on the idle→pending
  edge. That one request both marks the surface dirty and keeps the display
  link's dispatch gate open, which is all a flush needs. Calling
  `request_repaint()` / `-setNeedsDisplay:` per sample is precisely the
  O(events) cost coalescing removes.
- **Flush before any handoff.** A gesture-recognizer claim and `-mouseUp:` both
  END the captured target's bracket, so motion held from before them belongs
  inside it and must be delivered while the capture still resolves. Flushing
  after the reset drops it; not flushing strands it until a later frame hands it
  to a target whose gesture already ended.
- **A relative movement delta must be SUMMED across merged samples.** The
  surviving sample's absolute position is already the whole displacement, but
  `pointer.movement_x/y` is per-event. Keeping only the survivor's delta
  shortens every relative drag in proportion to how many samples merged — it
  reads as "the knob feels sluggish", never as dropped input.
- **A lost opt-in is invisible.** `HostDragCoalescer` fails safe: with no frame
  driver running it dispatches immediately, so a host that starts a driver
  without calling `-setCoalescePointerInput:YES` simply runs at the old rate —
  no crash, no log, no red test. Both link callbacks therefore assert
  `-coalescingPointerInput` out loud once per process. If you add a third frame
  driver, opt it in there, not in whichever start function you happened to edit.

What you can measure: `HostDragCoalescer::stats()` carries `raw_samples`,
`delivered_samples`, `merged_samples` and `flushes`, and the same quantities go
out as the `state` trace counters `raw_drag_samples`, `delivered_drag_samples`,
`pointer_samples_merged` and `pointer_coalescer_flushes` — the same names the
standalone window host emits, so one query covers both. The plug-in hosts still
emit no `frame` / `paint` / `gpu_acquire` spans, so a trace cannot profile their
frame loop; these counters are pointer-path evidence only.

## Common pitfalls

1. **Forgetting `notify_attached()` after a successful attach.** The
   host will show the view but `Processor::on_view_opened` never fires,
   so any lifecycle-tied setup (listener registration, meter startup)
   silently skips.
2. **Calling `close()` after destroying the released view.** Lifecycle
   dispatch then runs on a dangling pointer. Always close the bridge
   first, then let the caller-owned view destruct.
3. **Creating a second Processor in the editor path.** Fetch the host
   Processor via the format-specific accessor (AU v2 property, AU v3
   ObjC selector, CLAP / VST3 constructor param).
4. **Hard-coding `editor_size()` when you actually want resize
   bounds.** Prefer `pulp_add_plugin(... DESIGN_WIDTH N DESIGN_HEIGHT N)`
   over a per-plugin `view_size()` override for imported-design plugins;
   the macros flow into the default
   `Processor::view_size()` via `format::view_size_from_design(...)`,
   which derives `min = preferred * 2/3` and `max = 2 * preferred`. The
   derived `min > 0` is what makes CLAP's `gui_can_resize` engage.
   Override `view_size()` directly only when the dimensions are computed
   at runtime (rare).
5. **Forgetting that adapters must read `bridge.size_hints().min_*`
   when building the host's `WindowOptions`.** The bridge caches
   `Processor::view_size()` in `size_hints_`, but each adapter is
   responsible for forwarding `min_width`/`min_height` (and
   `preferred_*`) into its window/host options. The standalone
   adapter centralizes this in `detail::make_standalone_window_options`
   so the chrome-height shift is applied consistently. Other
   adapters wiring an OS window (e.g. host apps registering a
   `WindowHost::Factory`) need the same propagation; reading only
   `preferred_*` leaves the OS host with a zero minimum and lets
   plugins shrink below their declared floor.
6. **Idle-pump must drain timers + frames + async results — not just
   frames.** The platform host idle entry point (Mac CVDisplayLink,
   iOS CADisplayLink, Android AChoreographer) is the only thing that
   drives `WidgetBridge` per vsync when no input event fires. There
   are TWO bridge methods that drain different queues:
   - `poll_async_results()`: async-shell results (`execAsync` callbacks)
     + rAF callbacks (`__flushFrames__`). Does NOT pump message loop
     or drain timers.
   - `service_frame_callbacks()`: pumps engine message loop + drains
     native-tracked `setTimeout` / `setInterval` (`__flushTimers__`)
     + rAF callbacks. Does NOT drain async-shell results.

   Host idle paths must call BOTH (`poll_async_results()` first, then
   `service_frame_callbacks()`). Calling only the first drops timer
   callbacks on the floor — `setTimeout(fn, 100)` queues forever and
   only fires when an unrelated event happens to pump the message loop.
   `ScriptedUiSession::poll()` does this pair on Mac/iOS standalone;
   `core/render/platform/android/gpu_surface_android.cpp::android_render_frame()`
   does the same pair on Android. Tests live under `[issue-1412]` in
   `test/test_widget_bridge.cpp`.
7. **Design-viewport pointTransform block holds raw `this` — clear it
   in the host dtor.** Both `MacPluginViewHost::~` and
   `MacGpuPluginViewHost::~` MUST run `view_.pointTransform = nil` /
   `metal_view_.pointTransform = nil` inside an `@autoreleasepool`
   BEFORE the C++ host frees itself. DAWs routinely retain the
   returned NSView after disposal (attach_to_parent hands it to the
   DAW's view hierarchy); a later `mouseDown:` would otherwise invoke
   the block on freed memory. This is the same ownership shape as the
   deferred-click teardown token. Test: `pulp-test-plugin-view-host-design-viewport`
   has the dtor-clears-pointTransform regression case.
8. **`paint_overlays` MUST run inside the design-viewport transform.**
   ComboBox dropdowns, claimed overlays, and the inspector layer all
   draw in root-view coordinates, and mouse input inverse-maps
   window→root through `pointTransform` before `hit_test`. Painting
   overlays outside the transform puts them at root coords in window
   space → visually misaligned + non-clickable at any host size that
   isn't exactly the design size. Mac plugin host paint paths (CPU
   `drawRect:` and GPU `paint_scene`) call `View::paint_overlays`
   INSIDE the save/translate/scale block, matching the standalone
   `MacGpuWindowHost::paint_scene` overlay-inside-transform rule.
9. **A GPU display-link idle pump can outlive its `ViewBridge` — guard on
   `ViewBridge::alive_token()`, not the host's own `alive_`.** The GPU
   editor idle pump (`gpu_host_select.hpp::make_editor_idle_pump`) is
   dispatched to the main queue by `CVDisplayLink` and captures the bridge by
   raw pointer. Two ways it runs after the bridge is gone: (a) a host reloading
   the embedded view REPLACES the bridge (`_bridge = make_unique<…>`) while the
   host + its idle callback survive, and (b) `CVDisplayLinkStop` does NOT join
   an in-flight callback during teardown. The host's `alive_` token guards the
   HOST, not the bridge, so it does not cover case (a). Fix: `ViewBridge` owns
   its own `std::shared_ptr<std::atomic<bool>> alive_` (`alive_token()`), flipped
   false FIRST in `~ViewBridge` before `close()`; the pump captures a COPY of
   that token and no-ops when it reads false. This crashed the PulpTempoSampler
   AU embedded in Ableton Live 12 (EXC_BAD_ACCESS in `store()`/`scripted_ui()`
   on a freed bridge, v1.6.1). The token makes the no-op DECISION safe — it does
   NOT make a cross-thread deref of the raw bridge pointer safe, so teardown must
   stay on the main thread. General rule: any display-link/cross-thread callback
   capturing a raw pointer to a REPLACEABLE owned object needs a liveness token
   on THAT object, not just its host. Test: `[idle-pump][crash]` in
   `test_view_bridge.cpp` builds the pump, destroys the bridge, calls the pump →
   no-op instead of use-after-free.
9b. **A retained bridge needs the FORMAT OWNER's lifetime token, covering BOTH
   `Processor&` and `StateStore&`.** A second Live crash (EXC_BAD_ACCESS in
   `ViewBridge::poll_editor_reload`, AU embedded in Ableton Live 12, 2026-07-17)
   got past the bridge's own `alive_` check: AU audio-unit and view-controller
   lifetimes are independently ordered by the host, so Live can destroy the
   adapter's Processor and StateStore while retaining an editor whose display-link
   pump still fires. Guarding only `processor_` is incomplete: the same tick first
   drains `store_.pump_listeners()`, and the installed host-parameter surface also
   retains the store.

   The contract is therefore adapter-owner scoped:

   - The AUv2/AUv3/VST3/CLAP owner holds a `runtime::AliveToken` and passes a
     captured handle into every `ViewBridge` constructed from its Processor and
     StateStore. Retire it at the START of explicit teardown (`destroy`,
     `terminate`, `dealloc`) before either referent can die. As a fallback for
     implicit C++ teardown, declare the token after Processor/StateStore members
     so reverse member destruction retires it first.
   - Every bridge path that can reach `processor_` OR `store_` fails closed when
     the owner handle is retired: idle store pumping, scripted-session lookup,
     editor reload, attach/resize/close callbacks, rebuild, and the
     `StateStoreHostParamSurface`. `supports_editor_reload_` remains cached so the
     common non-reload tick does not need a virtual call, but caching is not the
     lifetime fix.
   - `AliveToken` only makes the check/no-op DECISION safe. It does not own either
     referent and does not establish cross-thread quiescence. Adapter teardown,
     editor teardown, and the idle pump remain serialized on the main thread;
     use a real join/quiescence protocol before extending this pattern across
     concurrently executing threads.

   Lifecycle coverage must exercise the full teardown-order matrix, not merely
   bridge-first destruction: retained editor after owner teardown, idle pump and
   host-param calls after owner retirement, and format-specific churn. Canonical
   cases carry `[owner-lifetime][lifecycle]`; run them under ASan with
   `ctest --test-dir build-asan -L lifecycle --output-on-failure`. The separate
   IPC self-destroy regression applies the same post-callback liveness principle:
   re-check after user code before touching connection state again.
10. **In a multi-plugin bundle, editor/metadata callbacks must resolve
    PER-INSTANCE state — never a process global.** A CLAP/AU/VST3 bundle exposes
    N plugins from one binary, so a single shared descriptor global would hand
    every plugin's editor the *first* plugin's metadata. The CLAP adapter caches
    a per-instance `PulpClapPlugin::descriptor_snapshot` at `create_plugin()`
    time; the audio/note-port and descriptor callbacks read
    `self->processor ? processor->descriptor() : descriptor_snapshot`, never a
    file-scope global. When wiring a new editor-facing callback (bus layout,
    channel names, editor size, feature flags) in a bundle-capable adapter, pull
    the answer from the instance's own processor/descriptor snapshot, or from the
    keyed registry entry looked up by the instance's id — a global read silently
    returns another plugin's data only once a second plugin ships, so it passes
    every single-plugin test. Registry lookup is `find_plugin(id)`; the id is the
    plugin's `bundle_id`.

## Tests

`test/test_view_bridge.cpp` is the canonical reference:
- `"ViewBridge falls back to AutoUi when create_view returns nullptr"`
- `"ViewBridge honors custom create_view()"`
- `"ViewBridge supports secondary views"`
- `"ViewBridge defers on_view_opened until notify_attached"`
- `"ViewBridge close without attach does not fire on_view_closed"`
- `"ViewBridge destructor closes view"`
- `"ViewBridge cross-format lifecycle invariants"` (VST3 / CLAP / AU v2 /
  AU v3 / Standalone / failed-attach replay)
- `"editor idle pump no-ops after the ViewBridge is destroyed"`
  (`[idle-pump][crash]`) — the GPU display-link pump liveness-token guard
  (pitfall 9); build the pump, `reset()` the bridge, call the pump → no UAF.

Run with `ctest --test-dir build -R ViewBridge --output-on-failure`.

### AU editor `dealloc` ordering — never call `bridge->close()` explicitly

When a `ViewBridge` and a `PluginViewHost` are owned by the same C++
scope (a struct, an Obj-C class's ivars), C++ destroys members in
REVERSE declaration order. AU's editor wrappers
(`PulpAUEditorOwnership` for AU v2 Cocoa view, `PulpAUViewController`
for AUv3 iOS) declare the bridge first and the host second so the
host (which holds `View& root_`) is torn down BEFORE the bridge that
owns the View. The host's destructor can then safely call
`root_.set_plugin_view_host(nullptr)` to clear the back-pointer; then
`~ViewBridge` fires `Processor::on_view_closed` and resets the View.

Calling `bridge->close()` explicitly inside dealloc reverses that
order — the View dies first, the host's `~PluginViewHost` then
dereferences a dangling `root_` reference, and AU editor close
crashes the host process. Do not reintroduce an explicit close in the
AU dealloc path. The full rationale lives in the `auv2`, `auv3`, and
`ios` skills since those files are dual-/triple-mapped.

### Standalone editor lane DOES need explicit `bridge->close()` before `stop()`

The "never call close() explicitly" rule above applies to AU dealloc
because the bridge and host are RAII-owned and destroyed in reverse
declaration order. The standalone app lane in
`core/format/src/standalone.cpp` is different: `run_with_editor()`
exits when `window->run_event_loop()` returns, and that can happen via
two paths:

1. **Window-close callback path** — the close handler already called
   `bridge->close()` while processor and view were both alive.
2. **Application-quit path** — the event loop returns without ever
   firing the window-close callback, so the bridge never saw `close()`.

If path 2 falls through to `stop()` without an explicit close, the
processor is torn down with a still-attached bridge and the view's
`on_view_closed` either never fires or fires against a freed
processor. The fix is to call `bridge->close()` unconditionally after
the event loop returns and before `stop()` — `close()` is idempotent,
so path 1 stays correct.

When you add a new host lane (CLI, embedded runner, foreign host), do
the same audit: is the bridge owned by an RAII path that guarantees
ordering (AU pattern, no explicit close), or does the lane have a
return path that bypasses the window-close callback (standalone
pattern, explicit close before processor teardown)?

Standalone also owns the native audio-workgroup lifetime for an opt-in parallel
Processor. After `AudioDevice::open()` and before `Processor::prepare()`, it
discovers `format::AudioWorkgroupClient` and forwards
`callback_workgroup()`. During stop it stops the callback first, publishes null
to the client, waits off the render thread until every worker acknowledges the
removal, then closes the device. Keep that order: the handle is borrowed from
the open device, and close (or a live default-device retarget) can invalidate it
before an asynchronously waking worker has left unless the acknowledgment
barrier completes first. The device's close preparation must also disable future
retarget notifications and serialize with an in-flight switch before the final
null+ack; draining only in standalone leaves a rebind window before close.

## EditorBridge — JSON message dispatch over the editor lifecycle

`pulp::format::ViewBridge` (this skill) handles **when** the editor
exists. `pulp::view::EditorBridge` handles **what messages flow
between it and the processor** while it does. Use both:

```cpp
class MyEditor : public pulp::view::View {
    void wire(pulp::view::WebViewPanel& panel) {
        bridge_.add_handler("set_value", [this](const auto& payload) {
            const auto v = pulp::view::EditorBridge::get_float(payload, "value", 0.0f);
            // ... apply to processor ...
            return pulp::view::EditorBridge::ok_response();
        });
        bridge_.attach_webview(panel);          // routes WebView postMessage → handlers
    }
    void unwire(pulp::view::WebViewPanel& panel) {
        bridge_.detach_webview(panel);          // clears the installed callback before teardown
    }
private:
    pulp::view::EditorBridge bridge_;
};
```

Key invariants:

- **Renderer-agnostic.** `attach_webview(WebViewPanel&)` today;
  `attach_native_runtime(JsRuntime&, "<handler_name>")` for the low-level
  native runtime, or `attach_native_runtime(ScriptEngine&, "<handler_name>")`
  when a product owns Pulp's public engine wrapper. A product built on
  `ScriptedUiSession` must use
  `attach_native_runtime(session, "<handler_name>")`: the session owns the
  attachment, installs it before the live script runs, and reinstalls it after
  every realm replacement without exposing or borrowing the replaceable
  `ScriptEngine`. Native overloads register one global function that accepts a
  JSON envelope string and returns the JSON response string; they share the
  same handler registrations and error vocabulary.
- **Session attachment has explicit teardown.** An `EditorBridge` attached to
  a `ScriptedUiSession` must outlive the attachment. Call
  `detach_native_runtime(session, "<handler_name>")` before either the bridge
  or a handler capture expires. The live realm's symbol then fails closed, and
  future realm replacements omit it.
- **Reload validation does not replay product effects.** A scripted reload
  first executes code in a probe realm. That realm receives fail-closed native
  message stubs so script validation can resolve the globals without calling
  processor handlers twice; only the committed live realm dispatches messages.
- **Explicit WebView teardown.** `detach_webview(WebViewPanel&)`
  clears the callback installed by `attach_webview`. Use it when the
  bridge and `WebViewPanel` are side-by-side members and you want to
  sever queued WebView messages before native detach or panel destruction.
- **noexcept dispatch.** `dispatch_json(...)` and `dispatch(...)` are
  `noexcept` and always return a well-formed JSON response envelope —
  handler exceptions become `{"ok":false,"error":"internal error"}`.
- **Standard envelope error vocabulary** (substring-compatible with
  Spectr's existing test suite — that compatibility is load-bearing
  for the Spectr cutover acceptance criterion):
  - `malformed_json` — JSON parse failed / root not object
  - `unknown_type` — no handler registered
  - `missing_field` — envelope missing/empty/non-string `type`
  - `wrong_type` — handler-emitted via `err_response("...")`
  - `internal_error` — handler threw
- **Plugin-specific drag/edit state stays in the handler closure** —
  the framework explicitly does NOT carry `EditorBridgeState`-style
  per-session state. Capture it on `[this]` instead.

When you change `core/view/src/editor_bridge.cpp` or its header, the
skill-sync gate requires updates to either *this* skill or the
`import-design` skill (or both). The path map maps both to the file.

## Event-bridge dispatch payload contract — the @pulp/react surface

WidgetBridge talks to JS by calling `__dispatch__(id, eventName, ...rawArgs)`.
The `@pulp/react` synthetic-event shim (`packages/pulp-react/src/synthetic-event.ts`)
inflates those raw args into a React-DOM-compatible event. **Both sides
of this contract must move together or JSX handlers silently break.**

The shim's `makeSyntheticEvent` only lifts fields off the first arg when
`isPlainObject(rawArgs[0])` is true. Positional args (e.g. `wheel(dx, dy)`)
fall through to the empty default object and `e.deltaY` is `undefined`.
That class of regression is what cost us multiple PRs on the Spectr
band-drawing / trackpad-zoom / Escape-modal fixes.

### Required payload shapes

| Event family | Raw shape (object literal) | Why each field |
|---|---|---|
| `pointerdown` / `pointerup` / `pointercancel` | `{clientX, clientY, offsetX, offsetY, pointerId, pointerType, isPrimary, pressure, altitudeAngle, azimuthAngle, button (W3C: 0=left, 1=middle, 2=right), ctrlKey, shiftKey, altKey, metaKey}` | JSX reads `e.clientX - rect.left`, hit-tests by `e.button`, and gates UI on modifier booleans |
| `pointermove` | `{clientX, clientY, offsetX, offsetY, pointerId, pointerType, isPrimary}` | dragged from `on_drag(local pos)`; `clientX/Y` MUST be window-relative — walk parent-chain `bounds()` to compute |
| `wheel` | `{deltaX, deltaY, clientX, clientY}` **as an object, not positional args** | The synthetic-event shim's plain-object branch is the only one that lifts wheel deltas |
| `keydown` / `keyup` | `{key (W3C UIEvent.key string: 'Escape', 'ArrowLeft', 'F1', 'a', ' ', etc.), keyCode (raw int), ctrlKey, shiftKey, altKey, metaKey, mods}` | JSX compares `e.key === 'Escape'`; the raw int alone is unusable |
| `gesturestart` / `gesturechange` / `gestureend` | `{scale, rotation, clientX, clientY}` | matches Safari GestureEvent |
| `change` / `input` (text) | `rawArgs[0]` is the string value, not an object | the synthetic-event shim treats `typeof === 'string'` as a change event |

### Required JS preamble shape

In `widget_bridge.cpp` the `kJSPreamble` and `kWindowListenerShim`
strings MUST guarantee:

- `__dispatch__(id, eventName, ...args)` wraps every callback in
  `try/catch` and surfaces failures via `__dispatchError__` if defined.
  Otherwise a single throwing handler kills the rAF self-rescheduling
  loop and the whole animation pipeline dies until the next event
  restarts it.
- When `id === '__global__'`, fan the dispatch out to
  `window._listeners[eventName]` so `window.addEventListener('keydown',
  fn)` works without the full web-compat bundle.
- `window` exposes `addEventListener` / `removeEventListener` /
  `dispatchEvent` and `_listeners` via the minimal shim — install AFTER
  all preludes so `var window = {...}` in `web-compat-document.js` does
  not clobber the shim.

### Native-side registrars MUST be idempotent

`registerPointer(id)` / `registerWheel(id)` (and any future
`registerX(id)`) install View callbacks. If a React re-render re-issues the
registration, replacing or stacking those callbacks can multiply delivery or
silently change callback ownership. Always gate registration per id/channel and
early-return on duplicates.

Wheel delivery has two distinct JS channels and must preserve both without
double-dispatching either one. The deepest registered native view is the sole
`__dispatch__` origin; its Element event bubbles `addEventListener('wheel', …)`
through the synthetic DOM. Registered ancestors above that origin still need
their low-level `on(id, 'wheel', fn)` callback, so native routing invokes
`__dispatchCallbackOnly__` for those ancestors. Never call full `__dispatch__`
again on an ancestor: that would re-enter Element bubbling and fire DOM
listeners twice. A native `wants_wheel_scroll()` view is the inclusive upper
boundary for both searches; a registration on that scroller receives the tick,
matching an `overflow:auto` DOM element, while registrations above it do not
receive a tick the scroller consumes.

Pointer delivery follows the same single-origin rule. Native dispatch walks the
hit path, selects the deepest View with `on_dom_pointer_event` (or
`on_dom_pointer_move_event`) as the sole full `__dispatch__` origin, and calls
`__dispatchCallbackOnly__` for registered native ancestors. The origin's
Element event performs the JS capture/bubble walk; full-dispatching an ancestor
would enter that walk twice. This callback-only ancestor lane preserves direct
`@pulp/react` `on(id, event, fn)` behavior without duplicating web-compat DOM
listeners. Native `registerPointer` emits the corresponding mouse event; the JS
DOM dispatcher must not synthesize a second one.

Propagation cancellation is part of this boundary: `stopPropagation()` allows
remaining listeners on the current target but prevents ancestor and document
delivery; `stopImmediatePropagation()` also stops remaining listeners on the
current target. Pointer document fanout occurs only when the Element dispatch
did not stop propagation.

### macOS host-side bubbling

`core/view/platform/mac/window_host_mac.mm` is the dispatch source for
mouse / pointer / wheel on macOS. Every dispatcher MUST:

- Set `me.window_position = pt` for wheel events (clientX/Y derives from
  this). Without it JSX `e.clientX - rect.left` for anchor-frequency
  zoom gives the wrong anchor.
- Route pointer events through the shared dispatcher. It performs one full DOM
  dispatch at the deepest registered origin, then callback-only native ancestor
  delivery. Do not full-dispatch every registered ancestor: JavaScript already
  performs W3C capture/bubbling from the origin.
- Leave `on_mouse_down` / `on_mouse_drag` / `on_mouse_up` deepest-wins
  (those are the JUCE-style click channel, not the W3C bubbling channel).

### registerShortcut focus-guard

`WidgetBridge::forward_key_event` checks `registerShortcut` entries
before falling through to the W3C `__global__` keydown dispatch. To
prevent global bare-key shortcuts (`?` for cheatsheet, etc.) from
firing while the user types into a text input, the loop suppresses any
registered shortcut whose modifier mask has no
`kModCtrl|kModAlt|kModMeta|kModCmd` bit set, IF a text-accepting
widget currently has focus. Shift alone counts as bare (Shift only
picks the upper-case glyph). Modifier chords (`Cmd+S`, `Cmd+,`) always
fire — they are always-global by design and must work even when an
editor has focus.

The focus signal is `View::focused_input_` (the same static slot the
macOS PulpView already maintains for text-input dispatch)
**narrowed** by `View::accepts_text_input()`. The slot is populated
for ANY focusable widget — Knob, Button, ListBox, TextEditor — via the
window-host focus path, so checking just `focused_input_ != nullptr`
would wrongly kill bare-key shortcuts after clicking a knob. The virtual `accepts_text_input()` returns false by
default; only `TextEditor` (and any future text-input widget) overrides
to true.

Prereq for the default-shortcuts pass (`planning/2026-05-16-default-
keyboard-shortcuts.md`), which adds a bare-`?` cheatsheet binding to
imported designs.

### Tests that pin the contract

`test/test_widget_bridge.cpp` — `[contract]` tag:

- "Event contract: W3C MouseEvent.button maps left=0, middle=1, right=2"
- "Event contract: forward_key_event emits W3C UIEvent.key strings + modifier booleans"
- "Event contract: window.addEventListener('keydown', fn) receives __global__ keydown"
- "WidgetBridge focus-guard: bare-key shortcuts suppressed while text input focused"
- "Event contract: __dispatch__ try/catch keeps listeners alive after a handler throws"
- "Event contract: wheel dispatch is an object {deltaX,deltaY,clientX,clientY}"
- "Event contract: registerPointer/registerWheel are idempotent (no lambda-stack growth)"
- `pulp-test-widget-bridge-dispatch-document-event` `[pointer-semantics]`:
  exact one-delivery, document bubbling, and stop/stopImmediate behavior

Run with: `./build/test/pulp-test-widget-bridge "[contract]"`

When adding any new event family or fields, add a corresponding
`[contract]` case AND a row to the payload-shape table above.

### Test and validator runs must not open native editor hosts

Adapter editor creation is guarded by `PULP_DISABLE_PLUGIN_EDITOR`,
`PULP_HEADLESS`, `PULP_TEST_MODE`, and `CI`. Validator and agent-driven
test paths must set the explicit `PULP_*` variables so VST3 returns no
editor view, CLAP hides `CLAP_EXT_GUI`, AU v2 returns no Cocoa view, and
AUv3 avoids constructing `PluginViewHost`. Do not replace this with
post-hoc window cleanup; the contract is that no native editor host is
created in the first place.

## Several editors in one process — scope every routing read to the event's root

A DAW puts many Audio Units in ONE `AUHostingServiceXPC` process, so a product
family (MIDI FX + instrument + audio FX) is several editors of the SAME binary
sharing an address space — and, because they are the same binary, sharing
IDENTICAL editor geometry. That last part is what turns a process-global slot
into a routing bug rather than a theoretical one: a pointer at (x, y) in editor
B lands at the same (x, y) in editor A's tree, so any rect test against a
globally-named widget passes for the wrong editor.

`View::focused_input_`, `View::active_overlay_`, and `ComboBox::active_popup_`
are process-global SHIM MIRRORS naming the most-recently-acted slot
process-wide. The per-root source of truth is `View::interaction()`. When you
add or edit a host/routing path:

- Read focus through `focused_input_under_root(root)` — never the raw mirror.
- Read the open dropdown through `ComboBox::active_popup_in(scope)` and close it
  through `close_active_popup(scope)` — never `active_popup_` /
  `close_active_popup()`, which act on whichever editor moved last. Reading the
  mirror sent editor B's wheel, click, and hover into editor A's open menu, and
  made the mac plugin host capture a drag target owned by a tree it does not own
  (`plugin_view_host_mac.mm` re-validates with `view_is_in_tree` on the next
  event, so the release is safe — but the event was already delivered wrong).
- The three-editor proof is `test/test_multi_plugin_coexistence.cpp`; the
  two-tree state proof is `test/test_interaction_multiinstance.cpp`.

Still process-wide and unscoped, so do not reach for them from a plugin host:
`WidgetBridge::dispatch_global_key` / `dispatch_document_event` fan out to EVERY
registered bridge in the process. The standalone window host may use that
fan-out. A plugin host must use `WidgetBridge::dispatch_key_for_root`, which
returns whether the owning bridge consumed the event and cannot reach another
editor's JS runtime.

## Document shortcuts in macOS plugin editors

Offer document shortcuts through the root-scoped script dispatcher after native
text editing and framework commands decline the key. Its boolean consumption
result comes from a registered shortcut or JavaScript `preventDefault()`; merely
having a listener is not permission to swallow a DAW key. Unconsumed keyDown
continues through the existing host-forwarding path. Do not acquire persistent
first responder just to make document shortcuts work.

AppKit can offer the same event to `performKeyEquivalent:` and then `keyDown:`.
The per-editor `PluginScriptKeys` remembers that event's identity and verdict,
so a listener sees one press while an unclaimed Space still reaches transport.
Keep the CPU and GPU hosts on the same helper. Test the actual JavaScript effect
and host receipt, including two live editors and text-input priority; counting
calls to the dispatcher does not prove this contract.

This path serves macOS AU, CLAP, and VST3 NSView editors. VST3's separate
`IPlugView::onKeyDown` accommodation remains limited to Space in a focused text
field. Windows currently routes script keys only for bounded navigation focus;
Linux's X11 plugin host has no general keyboard event pump. Neither platform
inherits the macOS shortcut behavior from this change.

## Keyboard-focus host etiquette — never hold the host's first responder when idle

A plugin editor embeds an `NSView` in the DAW's window. If that view returns
`acceptsFirstResponder = YES` unconditionally (or the plugin sets a *focusable
root* via `set_focusable(true)`), then **clicking any control makes the plugin
view the host window's first responder, and every keystroke routes into the
plugin** — stealing the host's keyboard. In Logic this silences **Musical
Typing** on software-instrument tracks the instant you touch a plugin control,
which masquerades perfectly as an audio failure ("adjusting the plugin kills the
instrument until I reopen it" — reopening just refocuses a host view). No crash,
no log; unaffected by any audio/parameter fix; happens only on instrument
tracks (audio tracks don't need keystrokes) and only via the plugin's own GUI
(the host's generic control view never moves focus out of the host). Cost ~2
days to find — see the `au-xpc-shared-process-crash` debug note.

Contract (`core/view/platform/mac/plugin_view_host_mac.mm`, both the CPU
`PulpPluginView` and GPU `PulpGpuPluginView` classes — shared by AU/VST3/CLAP):

- `acceptsFirstResponder` returns true only while the root-scoped focused view
  accepts text input or explicitly reports `accepts_navigation_input()`. The
  latter is a temporary capability, not general focusability: `ComboBox`
  reports it only while open. Plugin navigation is restricted to arrows,
  Home/End, Enter, and Escape; every other key remains with the DAW.
- After every `mouseDown:`/`mouseUp:`/`keyDown:`, call `syncKeyFocus`:
  `makeFirstResponder:self` when a widget wants keys; the instant it doesn't,
  **restore the host's PRIOR first responder** (saved at claim time), not
  nil — handing nil leaves Logic's key routing dead (Musical Typing stays
  silent after a type-in commit until the user resets the track). The saved
  pointer is never dereferenced: it is re-found *by identity* in the window's
  live view tree (`pulp_plugin_live_prior_responder`), so a freed responder
  degrades to nil instead of a dangling send (the file builds without ARC).
- Run that same `syncKeyFocus` reconciliation on every dispatched host frame,
  after the idle callback, in both the CPU and GPU display-link blocks. Focus
  can clear without a following `NSEvent` (programmatic blur or a scripted
  generation that fails after releasing its prompt field); event-only sync
  leaves the editor first responder and swallows the DAW keyboard indefinitely.
  The per-frame call is transition-guarded and idempotent. The
  `[frame-tick]` case in `test_plugin_view_host_key_focus.mm` holds the real
  display-link gate open, pumps the main run loop, and fails if the tick call
  is removed; it soft-skips only when the host proves that no display-link tick
  fired (supported headless macOS).
- **The host's grab wins**: `resignFirstResponder` must end the focused
  widget's bounded key interaction (`pulp_plugin_end_key_input`: clear the
  slot, then `on_focus_changed(false)` so a text field commits or a navigation
  control closes).
  Otherwise a type-in left open when the user clicks a host control keeps
  `acceptsFirstResponder` true and re-steals the keyboard on the next event.
  Widgets with type-in UIs should override `on_focus_changed(false)` to
  commit, exactly like a click-away inside the plugin.
- **Scope every focus decision to THIS editor's root.** `View::focused_input_`
  is a process-GLOBAL static, so with two plugin editors open in one host
  (two instances of the same plug-in is the common case) it can point into a
  *different* editor's tree. `acceptsFirstResponder`, `syncKeyFocus`, the
  keyDown dispatch, and the resignFirstResponder text-input teardown must all
  gate on `pulp_focus_under_root(self.rootView)` (focused view is `root` or a
  descendant — walk `View::parent()`), never on the bare global. Otherwise
  editor B accepts/steals the keyboard, or routes keys into editor A, while A
  holds a focused field. All four contracts (claim/restore, host-grab ends
  input, freed-prior-responder safety, per-editor scoping) are pinned by
  `test_plugin_view_host_key_focus.mm` (real `NSWindow` + responder dance,
  single- and two-editor, CPU host).
- Editors must NOT set a focusable ROOT. Claim focus per-field:
  `claim_input_focus()` in `enter_typein()`, `release_input_focus()` in
  `commit_typein()`/`cancel_typein()`, or for the lifetime of a bounded open
  navigation control. Text input has priority. Clear the root slot when the
  owning subtree detaches or is destroyed. This is the JUCE default
  (`wantsKeyboardFocus = false`; grab only for an active interaction).
- A control that opens navigation programmatically must enter through
  `transfer_input_focus(root, control)`, not overwrite the slot with
  `claim_input_focus()`. The transfer blurs and commits the previous text
  editor exactly once, is scoped to the owning root, and tolerates that blur
  synchronously unmounting the requested control.

## GPU view host auto-selection — never hardcode `use_gpu=false`

The format adapters (AU v2 / VST3 / CLAP / iOS AUv3) must NOT hand-set
`PluginViewHost::Options::use_gpu`. They call the shared decision helper
`pulp::format::decide_gpu_host(bridge)` (`core/format/include/pulp/format/gpu_host_select.hpp`),
which returns `use_gpu` = `bridge.uses_script_ui() || bridge.view()->requires_gpu_host()`,
honoring the `PULP_DISABLE_PLUGIN_GPU` runtime opt-out. Hardcoding
`use_gpu=false` is the exact trap that made a Skia/Dawn editor silently fall
back to the AutoUi CPU path (the GPU-plugin-view-host work, 2026-05).

Rules when touching an adapter's editor-attach path:
- A custom `Processor::create_view()` that owns a `ScriptedUiSession` MUST
  override `Processor::active_scripted_ui()` so `ViewBridge` reports
  `uses_script_ui()`, adapters log `mode=scripted`, select the GPU host, and
  `make_editor_idle_pump` can poll that session. Chainer-style generated
  processors use this path.
- A custom non-scripted GPU view (WebGPU/Three.js canvas, hand-built Skia view)
  MUST call `view->set_requires_gpu_host(true)` on its root, or
  `decide_gpu_host` returns `mode=autoui` and it gets CPU. The framework
  scripted-UI root (`editor_ui.hpp`) already sets it.
- After `PluginViewHost::create(...)`, call
  `format::warn_if_unexpected_cpu_fallback(decision, host.get())` — it screams
  (`runtime::log_error`) if GPU was requested but the host fell back to CPU.
- Wire the per-vsync editor pump: `host->set_idle_callback(make_editor_idle_pump(bridge))`.
  Without it a JS UI paints its first frame but `requestAnimationFrame` /
  timers / async results never fire.
- `make_editor_idle_pump` captures the `ViewBridge` by raw pointer, so the
  host MUST be destroyed before the bridge. In AU/VST3/CLAP the bridge is
  declared before the host (reverse-destruction → host first); keep that order.
- AU v2 has no host resize callback — use `host->set_resize_callback(...)` to
  forward native-NSView frame changes to `bridge->resize()`. VST3/CLAP drive
  resize through their own host size callbacks and don't need it.
- **Embedded plugin views route their own mouse input.** The mac plugin views
  (`PulpGpuPluginView` GPU + `PulpPluginView` CPU, in `plugin_view_host_mac.mm`)
  implement `mouseDown/Dragged/Up/scrollWheel` → `hit_test` → `on_mouse_event` +
  `on_mouse_down/drag/up` with W3C pointer/drag bubbling to ancestors and
  drag-target liveness, reusing the standalone window host's `mac_geometry`
  helpers. Without these the editor paints but swallows every click. They also
  set flexible `autoresizingMask` so they follow host editor-container resizes.
- **The Windows HWND host honors the same full input contract.**
  `plugin_view_host_win.cpp` routes left/right/middle press-drag-release with
  capture, vertical/horizontal wheel, hover/leave, focus loss, key down/up, and
  UTF-16 `WM_CHAR` text into the shared `View` handlers. Keep Win32 packing and
  key/button/wheel decisions in the HWND-free
  `platform/win_pointer_input.hpp`; `pulp-test-win-pointer-input` deliberately
  runs them on macOS CI. Auxiliary buttons use the modern `MouseEvent` channel
  only because the legacy callbacks have no button identity. Scope
  `View::focused_input_` to the current root before routing keys, or one open
  editor can steal another editor's typing.
- **Windows must not discard `Options::use_gpu`.** Requested GPU editors create
  Dawn/Skia after the HWND is attached; CPU editors (and failed GPU creation)
  raster through Skia and present the resulting top-down DIB in `WM_PAINT`.
  Always pass the adapter's `decide_gpu_host` result through the factory. If
  `use_gpu=false` still initializes Dawn, the runtime opt-out is fictional; if
  the CPU path only supports capture and not live presentation, ordinary
  AutoUi editors paint black.
- **AU specifically also needs the Cocoa view advertised** (`kAudioUnitProperty_CocoaUI`)
  or the host shows its own generic param view regardless of the host wiring —
  see the `auv2` skill's "AU v2 MUST advertise its Cocoa view" gotcha.

See `planning/2026-05-22-gpu-view-host-in-plugins.md` and its `qa/` doc.

### The standalone lane has its own CPU-fallback signal, and it is a different one

`warn_if_unexpected_cpu_fallback` is **plugin-only**: it takes a
`view::PluginViewHost*` and reads `gpu_surface_state()`. Standalone builds a
`view::WindowHost` instead, so it cannot call that helper at all — the two lanes
do not share a host base class. Anything that has to hold on both lanes must be
implemented twice; check the sibling lane before assuming a GPU-fallback guard
is global.

Standalone's own signal is the window-open log line. It reports the **resolved**
class via `WindowHost::is_gpu_backed()` and appends `(skia-unavailable)` when GPU
was requested and the resolved host is CPU. It used to print the `use_gpu`
*request*, so a Skia-less fallback still read `gpu=true` — the one line a
developer greps to decide whether the GPU path is live. When you add a
GPU-conditional standalone behavior, report what the window **is**, never what
was asked for; `is_gpu_backed()` defaults to `gpu_surface() != nullptr`, so a
stub or CPU host answers correctly without extra wiring.

**Asserting a log line needs a seam — `runtime::log_*` has no sink.**
`pulp::runtime::detail::log_impl` writes straight to `os_log` + `stderr`; there
is no callback, sink, or redirect to install. So a "does it log the right
thing?" test has exactly two options, and a helper returning `void` supports
neither:

- Split the message out as a pure `format_*_message(...)` returning
  `std::string` and assert that (the "Expose the Pipeline" rule), and/or
- capture `stderr` over the call with `freopen` + `dup`/`dup2` — the
  `StderrCapture` pattern in `test/test_design_import_native_common.cpp` and
  `test/test_standalone_editor_chrome.cpp`.

Assert the pure formatter for message *content* and the stderr capture for the
*wiring*, because the wiring is where a request-vs-resolved bug actually lives.
The formatter alone cannot catch a caller that passes the wrong argument. Watch
for `SUCCEED()`-style log tests: the previous coverage here called the helper
and asserted nothing, so it passed for the entire life of the bug.

## The browser host — a fourth host, and the one with no plugin ABI

`core/view/platform/web/` adds `pulp::view::web::BrowserWindowHost`: the same
`core/view` widget tree, flex layout, text shaping, and Ink & Signal theme,
compiled to wasm and painted into a `<canvas>` by **Skia Ganesh on WebGL2**
(*not* Graphite/Dawn — see the `skia-gpu-build` skill's wasm section), driven by
a `requestAnimationFrame` render loop (`render_loop_emscripten.cpp`). DOM
pointer/key events are translated into View events by
`core/view/include/pulp/view/web/web_event_translate.hpp`.

What is different about it, and what to keep true:

- **There is no `ViewBridge` and no format adapter here.** The browser UI module
  is DSP-free and talks to the audio side only through the web player's
  `HostAdapter` seam, which is why the *same* wasm UI module mounts against both
  the WAM and the WebCLAP demo. Don't reach for the plugin editor lifecycle in
  web code; there is no `create_view()` → `open` → `notify_attached` protocol to
  participate in.
- **Probe, don't assume, GPU.** `pulp::view::web::browser_host_gpu_available()`
  is the web analogue of `decide_gpu_host` — a browser with no WebGL2 context is
  a real, shipping configuration. The mount path must **fail loudly and
  asynchronously** so the host page can fall back (the demo pages fall back to
  the player's generated parameter grid). A UI module that only fails
  asynchronously and reports nothing leaves an empty panel, which reads as a
  render bug.
- **Context loss is a normal event, not a crash.** WebGL contexts are lost and
  restored by the browser at will (tab backgrounding, GPU reset). The surface
  reports unavailable on loss, the rAF loop keeps pumping, and Ganesh is rebuilt
  and repaints on restore. Any new GPU resource cached above the surface must
  survive that cycle.

## `Processor` vtable growth is append-only

`Processor` is an ABI surface (`node_abi_gate`). New virtuals — most recently
`on_non_realtime_tick()` / `non_realtime_tick_pending()` — go at the **end** of
the class with a default implementation, never inserted between existing
virtuals, or every prebuilt SDK consumer's vtable offsets shift and you get the
silent-garbage failure mode the "Stale-Header ABI Mismatch" section above
describes. Both hooks default to no-ops precisely so that adding them changes
nothing for processors that don't opt in. (What the hooks *do*, and which formats
actually call them — CLAP does, including native CLAP; VST3/AU/standalone do
not — is documented in the `clap` skill.)

### Rich f64 fallback scratch follows the active bus layout

Format adapters that negotiate bus widths away from descriptor defaults must
call `prepare_f64_fallback_scratch(context, active_layout)` with the complete
accepted `Processor::BusesLayout`. The context carries only main-bus widths;
using the one-argument descriptor-default overload after widening a sidechain or
aux bus leaves the default rich-f64 fallback undersized. Its RT capacity guard
then correctly emits silence instead of allocating, but the accepted layout
never reaches the processor. Unsupported-layout silence accommodation is the
exception: pass descriptor-safe widths so surplus host channels remain clamped.

## Proportional resize with aspect lock — design viewport (2026-05)

`PluginViewHost` now mirrors `WindowHost`'s design-viewport contract
so DAW-embedded editors can corner-drag
proportionally without re-laying out. Three new virtuals on the host
(no-op defaults; full impl on the mac CPU + GPU hosts today):

- `set_design_viewport(design_w, design_h)` — pin root at design size;
  paint applies an aspect-correct scale + letterbox translate to fit
  the current host bounds.
- `set_fixed_aspect_ratio(ratio)` — API parity only; the host doesn't
  own the OS window, so DAWs enforce the aspect via per-format hints.
- `window_to_root_point(pt)` — inverse-map a host-space point into
  root coords; called by native event handlers (mouse hit-test on
  resized windows) AND tests.

Per-format wiring:

- **CLAP** — `gui_can_resize=true`; `gui_get_resize_hints` sets
  `preserve_aspect_ratio=true` and `aspect_ratio_{w,h}=design_{w,h}`;
  `gui_adjust_size` snaps to the design aspect, then clamps to
  plugin min/max; `gui_create` calls `host->set_design_viewport(...)`
  + `host->set_fixed_aspect_ratio(...)`.
- **VST3** — `canResize=kResultTrue`; `checkSizeConstraint` snaps to
  the design aspect; `onSize`'s existing `host->set_size(...)` path
  resizes surfaces; `attached()` (or first `onSize`) calls
  `host->set_design_viewport(...)`.
- **macOS AUv3** — no CLAP/VST3-style drag constraint callback.
  `PulpAUMacViewController` must create its initial root view at the
  compile-time design size when `PULP_PLUGIN_DESIGN_W/H` are available,
  then call `host->set_design_viewport(...)` after `ViewBridge` opens.
  `supportedViewConfigurations:` should return aspect-correct host
  configs first, but only if they are large enough for the design;
  wrong-aspect "large enough" configs are fallbacks. Undersized fixed-
  design configs should be rejected so CoreAudioKit can choose the
  largest available configuration. REAPER's in-process AUv3 path can
  still shrink the first live layout after attach, so the macOS
  controller has a one-shot initial size sync that expands the host
  window by the view delta and reapplies the design size before normal
  `viewDidLayout` resize takes over.
- **AU v2** — cannot offer this; the DAW resizes the returned NSView
  directly with no host-side resize-hint analogue.

Pitfall to remember: when wiring `gui_set_size` / `onSize`, do NOT
re-layout the view at the host window size when a design viewport is
active — the host's paint already applies the design transform, and a
window-sized layout would briefly flash before the next paint reset.

See `test_plugin_view_host_design_viewport.mm` for the wiring proof.

### AutoUi default editors now ride this path automatically (2026-07)

The auto-generated editor (`AutoUi`, for any plugin with no custom
`create_view()` and no scripted UI) used to open at the bare
`editor_size()` default of 400x300 — too small for even a 7-knob synth,
so the top row clipped and the ScrollView couldn't reach it. Now
`ViewBridge::open()` derives a fitting size from the store:

- `pulp::view::AutoUi::preferred_size(store)` returns a design size that
  fits the generated knob grid (tiles wrapped to a roughly-square column
  count + the "Parameters" title + padding; grouped stores stack their
  boxes). It shares the tile/padding constants with `AutoUi::build()`.
- The bridge adopts it **only when the processor left the size unset** —
  `uses_auto_ui_` is true AND `size_hints_` is the untouched default
  (`min==0 && aspect==0 && preferred=={400,300}`). It then runs the fit
  through `format::view_size_from_design(w, h)`, so min/max/aspect get
  derived and `should_pin_design_viewport()` engages the pin + aspect
  lock. The whole grid scales uniformly (min-clamped, nothing truncates).
- Any explicit size still wins: a `view_size()` override, a
  `DESIGN_WIDTH/HEIGHT` import, or even an `editor_size()` override (which
  surfaces as a non-default preferred) all bypass the fit.

One seam to know: `uses_auto_ui_` is set in the `build_editor_ui`
fallback branch (custom `create_view()` keeps it false, so a native
editor still reports its own laid-out bounds instead of a store-derived
size); and the fit is applied in `open()`, not the constructor, because
"is this AutoUi?" isn't known until `create_view()` returns null.

Separately, the AutoUi scroll body top-aligns its grid's wrapped rows
once they overflow the viewport (`align_content: start`) instead of
centering them into negative, unreachable scroll offsets — that was the
"can't scroll to the top row" half of the same bug.

## GpuSurface Plumbing Into WidgetBridge

Adapter editor-attach paths that wire a scripted UI (`ScriptedUiSession`)
MUST hand the host's live `render::GpuSurface*` to the bridge so the
JS-side `navigator.gpu` / `canvas.getContext('webgpu')` shim routes
through Pulp's real Dawn instance. Without it, those shims fall through
to mocks and any JS-rendered WebGPU output (Three.js, raw WebGPU) is
black.

### Subscribe, do not sample (the Windows trap)

**Never read `host->gpu_surface()` once and hand the result to the
session.** That read is only correct on hosts that build their surface in
the constructor (Apple, Linux). The Windows host CANNOT: Dawn configures
presentation for the HWND's native-window shape, and the editor HWND is a
hidden `WS_POPUP` until `attach_to_parent()` reparents it into the DAW. A
read taken at editor-open time returns `nullptr` and never becomes
non-null on its own — so `navigator.gpu` fell through to mocks on every
Windows editor while the CPU-fallback diagnostic screamed about a host
that was about to run on the GPU. The same one-shot read also had nowhere
to learn about DETACH, so a consumer kept a raw pointer to a destroyed
surface.

Use the shared helper instead — one call, every format:

```cpp
// core/format/include/pulp/format/gpu_host_select.hpp
gpu_surface_binding_ =
    bind_gpu_surface(*editor_host_, bridge_.scripted_ui(), gpu, "VST3");
```

It subscribes to `PluginViewHost::observe_gpu_surface()`, forwards each
transition into `ScriptedUiSession::attach_gpu_surface()` (including the
`nullptr` on teardown), and owns the CPU-fallback diagnostic so it can
only fire once the state is genuinely decided.

**Surface availability is THREE states**, not two — collapsing them is
what produced the false warning:

| State | Meaning | Correct reaction |
|---|---|---|
| `pending` | will be created, not yet | wait; say nothing |
| `ready` | live; `gpu_surface()` non-null | attach it |
| `unavailable` | CPU host, or GPU init failed | detach; NOW a CPU-fallback warning is true |

**Lifetime rule:** the returned `GpuSurfaceSubscription` must be
`reset()` before the `ScriptedUiSession` it writes into is destroyed.
Declare it AFTER the host member (reverse-destruction order drops it
first) AND reset it explicitly in the adapter's close/removed path — the
bridge that owns the session usually outlives both. Dropping the
subscription is sufficient: no callback can fire afterwards, even from
the host's destructor.

Contract tests: `pulp-test-gpu-surface-lifecycle` (delayed creation,
detach, destruction, reattach, mid-session recreation,
no-callback-after-unsubscribe, self-unsubscribe from inside a callback).

### The underlying attach points

The order is fixed by the adapter editor lifecycle: `ViewBridge::open()`
constructs the `ScriptedUiSession` (and its `WidgetBridge`) BEFORE
`PluginViewHost::create()` allocates the `GpuSurface`. Two API points
close that gap (both now driven BY the subscription above rather than
called directly from adapters):

- `pulp::view::WidgetBridge::attach_gpu_surface(GpuSurface*)` — idempotent
  late-attach; nullable. Stores the pointer + lazily allocates the
  native GPU bridge state. Pair with `gpu_surface()` /
  `has_native_gpu_bridge()` for introspection / tests.
- `pulp::view::ScriptedUiSession::attach_gpu_surface(GpuSurface*)` —
  forwards to the active bridge AND stashes the pointer so any later
  hot-reload rebuild constructs the next bridge with the same surface.

Adapters call this after the host is built. Reference wiring in:

- `core/format/src/au_view_controller_ios.mm` (iOS AUv3)
- `core/format/src/au_view_controller_mac.mm` (macOS AUv3)
- `core/format/src/vst3_plug_view.cpp` (VST3)
- `core/format/src/au_v2_cocoa_view.mm` (AU v2)
- `core/format/include/pulp/format/clap_entry.hpp` (CLAP)

The expected log line on success:

```
[plugin-gpu-host] GpuSurface attached to WidgetBridge via ScriptedUiSession (<format>)
```

`PluginViewHost::gpu_surface()` mirrors `WindowHost::gpu_surface()` (CPU
hosts inherit the nullptr default; GPU hosts override). A host that
overrides it MUST also publish its transitions:

- `mark_gpu_surface_pending()` in the constructor when it intends to
  create a surface later;
- `publish_gpu_surface(surface, GpuSurfaceState::ready)` once the pair is
  live;
- `publish_gpu_surface(nullptr, GpuSurfaceState::unavailable)` BEFORE the
  surface objects are destroyed — publishing after the reset leaves a
  window in which consumers hold a dangling pointer they still believe is
  current.

A host that does neither reports `unavailable` forever, which is the
correct answer for a genuinely CPU-only host.

Coverage: `pulp-test-widget-bridge "[gpu-surface-plumbing]"` (ctor +
late-attach + idempotence + detach) and `pulp-test-scripted-ui
"[gpu-surface-plumbing]"` (session-level forwarding). A live-Dawn
counterpart should use the `[jsc][navigator-gpu][live]` test path when it lands.

See `planning/2026-05-29-ios-d3b-threejs-webgpu-program.md`
for the full rationale.

**Why this matters:** Without this plumbing, format adapters silently route
Three.js + WebGPU canvas calls to mock adapters in production plug-ins. Do not
bypass it — if you're writing a new format adapter or plugin host, route your
live `GpuSurface*` through `ScriptedUiSession::attach_gpu_surface()` (or
`WidgetBridge::attach_gpu_surface()` if you don't have a session yet).

**`presentable` flag**: `WidgetBridge`'s `__gpuCanvasConfigureImpl` and
`__gpuCanvasDescribeCurrentTextureImpl` both expose a `presentable` boolean to
JS. `true` iff `gpu_surface_->has_surface()` is true (i.e., the surface has a
real swapchain, not just an offscreen texture). Three.js draws to a
`presentable=false` canvas land in a silent offscreen render that's not
composited to the visible AUv3 editor. Always check this flag in any new GPU
bridge code path.

## References

### Author callback exception containment

`ViewBridge` is the shared containment boundary for author editor callbacks.
Creation, size and scripted-session lookup, open, close, and resize return safe
defaults or no-op when author code throws. Format ABI callbacks must not bypass
this boundary. Parameter text and custom state have matching containment in
`parameter_text.hpp` and `plugin_state_io.cpp`.

- `core/format/include/pulp/format/view_bridge.hpp` — public API
- `core/format/include/pulp/format/gpu_host_select.hpp` — GPU host auto-select helper
- `core/format/src/view_bridge.cpp` — implementation
- `core/format/include/pulp/format/processor.hpp` — `create_view`,
  `view_size`, `on_view_*`
- `core/format/include/pulp/format/plugin_descriptor.hpp` — the `ViewSize`
  struct and `view_size_from_design(...)`. The editor *hooks* stay on
  `Processor`; only their value type lives here. `processor.hpp` includes this
  header, so plugins and adapters that include `processor.hpp` reach `ViewSize`
  unchanged — that include is the compatibility contract, not an incidental
  one. The same split moved `ProcessContext` to `process_context.hpp` and
  `PrepareContext` to `prepare_resources.hpp`.
- `core/view/include/pulp/view/editor_bridge.hpp` — EditorBridge API
- `core/view/include/pulp/view/scripted_ui.hpp` — session-owned native message
  attachment and realm-replacement persistence
- `core/view/src/editor_bridge.cpp` — EditorBridge implementation
- `docs/guides/view-bridge.md` — user-facing guide
- `docs/reference/editor-bridge.md` — EditorBridge reference
- `examples/view-bridge-demo/main.cpp` — runnable headless demo
- `test/test_editor_bridge.cpp` — EditorBridge unit tests
- `planning/next-features-plan.md` § Feature 1 — historical planning context

## Plugin-contributed settings sections

`Processor::settings_sections()` lets a plugin surface its own Settings tabs (e.g. a model
picker) that the **host composes** alongside its host-owned Audio/MIDI tabs — keep device
selection a host concern (a plugin can't pick the audio device in a DAW; the host owns it),
while still giving one unified Settings panel. The standalone chrome
(`make_standalone_editor_chrome`) calls `processor.settings_sections()` and appends each via
`SettingsPanel::add_section(title, view)` after the Audio/MIDI tabs. Gotchas:

- The `Settings` tab (with Audio/MIDI) only exists when `StandaloneConfig::show_settings_tab`
  is true; that's also the gate for composing plugin sections. A plugin that wants its
  settings visible in the standalone must leave it on.
- Do NOT pull host audio settings down into the plugin editor — invert it: contribute the
  plugin's sections up. In a DAW the same `settings_sections()` show with no Audio/MIDI tab,
  automatically correct.
- `make_standalone_editor_chrome` accesses `StandaloneEditorChrome`'s private members via a
  `friend` declaration — if you change its signature, update the friend decl to match or it
  silently loses friendship and fails to compile on the private-member access.

## Headless screenshot captures native overlays

`StandaloneApp::run_with_editor`'s `--screenshot` path normally reads the host's
Skia back buffer (`capture_back_buffer_png`), which can't see an OS-composited
**native overlay** (a WebView child view). When the editor view hosts a native
overlay (`View::contains_native_overlay()`), the standalone now routes through
`pulp::view::capture_view()`, which calls the overlay's
`capture_native_overlay_png()` (e.g. `WebViewPanel::snapshot_png()` → WKWebView
takeSnapshot) so WebView editors are self-verifiable headlessly. A plain Skia UI
falls through to the back-buffer capture unchanged. See the `screenshot` skill.

## Standalone audio callback: size for the device's MAX block, NOT the nominal buffer

`StandaloneApp::start()` reads `audio_device_->buffer_size()` into
`config_.buffer_size` and it is tempting to size the processor + every scratch
buffer to that. **Don't.** The device's nominal buffer is not the largest block
the render callback can deliver: when the hardware sample rate differs from the
app's configured rate, CoreAudio (and other backends) splice in a resampler
(`AudioConverter…fillComplexBuffer` in the crash stack) that pulls the callback
in *variable, larger-than-nominal* blocks — up to the host's
`MaximumFramesPerSlice` (4096 by default on macOS). A user simply selecting an
output device at a different rate is enough to trigger it.

The 2026-06 symptom: a standalone SIGABRT on `com.apple.audio.IOThread.client`,
~85 min in, in `RealtimePitchTimeProcessor::process` →
`assert(num_samples <= config_.max_block)`. The processor had been
`prepare()`d for the nominal buffer; an oversized resampler pull blew the
assert (and, in NDEBUG, would have overflowed the pre-allocated scratch).

The contract (`core/format/src/standalone.cpp`, `start()` + the audio lambda):

- Compute `max_callback_block_ = std::max(config_.buffer_size, 4096)` once, and
  use it for `PrepareContext::max_buffer_size`, `test_buffer_`,
  `silence_buffer_`, and `output_probe_.prepare(...)` — every buffer the
  callback can write, sized to the MAX, not the nominal.
- Guard the callback head: if `ctx.buffer_size > max_callback_block_`, fill the
  output with silence, `log_error` once, and `return` — never let an
  over-max block reach `process()`. A backend that ignores
  `MaximumFramesPerSlice` degrades to a one-time warning, not a crash on a real
  user's machine.
- The silence early-return must STILL advance the transport clock
  (`transport_position_samples_.fetch_add(ctx.buffer_size, ...)` when
  `transport_playing`) before returning. The normal path advances after
  `process()`; an early `return` that skips it lags the transport position —
  and the MIDI timeline derived from it — by exactly the dropped frames.
- The member lives in `standalone.hpp` (`int max_callback_block_`). The audio
  device's `CallbackContext::buffer_size` is the *actual* frames this block
  (it can exceed the nominal), so the guard compares against it, not
  `config_.buffer_size`.

Rule of thumb for any RT host you write: prepare for the worst-case block the
backend may hand you, then assert/guard against anything larger. Nominal buffer
size is a hint, not a ceiling.

## Standalone audio-callback probe tap (Phase 5 observability harness)

`StandaloneApp`'s audio callback carries an optional realtime output-boundary
probe, gated behind the `PULP_ENABLE_AUDIO_PROBES` CMake option. The default is
ON for dev/example builds; release and SDK build paths pass
`-DPULP_ENABLE_AUDIO_PROBES=OFF` so shipped standalone artifacts do not export
the dev probe surface unless a developer opts in. When ON, `start()`
`prepare()`s `output_probe_` (the only place it allocates) and the callback
calls `output_probe_.analyze_output(...)` immediately after
`processor_->process(...)` — the "standalone processor-output boundary." This
is the first wired probe stage for "UI works, no sound" reports. Gotchas:

- It is NOT the input meter bridge. `input_meter_bridge_` is input-oriented and
  lacks the snapshot's stage/sequence/NaN/clip/silence fields. Don't conflate.
- The tap is fully `#if PULP_ENABLE_AUDIO_PROBES`-guarded; an OFF build links no
  `AudioProbe` symbols into `standalone.cpp.o` (verified by `nm`). If you touch
  the callback, keep the probe strictly inside that guard so OFF builds pay $0.
- The probe is RT-safe (scalar-only, no FFT/alloc/locks) — `pulp::audio::AudioProbe`.
  Do NOT model audio-thread work on `pulp::view::VisualizationBridge`, which runs
  STFT and returns `std::vector` in its callback (explicitly quarantined).

### Phase 6 — Audio Inspector tool window wired into the host

`run_with_editor()` opens a `pulp::view::AudioInspectorWindow` (a SEPARATE
floating window, sibling of the layout inspector — not a tab) that observes
`output_probe_`. It is created only when a real `WindowHost` exists, behind the
same `#if PULP_ENABLE_AUDIO_PROBES` guard. Wiring gotchas:

- **Key routing must not clobber the layout inspector.** The audio inspector's
  toggle (Cmd/Ctrl+Shift+A) dispatches through a shell-owned `CommandRegistry`
  via `route_global_keys(window_root, registry)`, which writes
  `window_root.on_global_key`. The layout inspector (Cmd+I) uses
  `on_global_click` (`install_inspector_hooks`). Distinct hooks → they coexist;
  do not move either onto the other's hook.
- **Compose the idle callback, don't replace it.** `poll()` must run each tick
  to refresh meters; capture the existing `pre_screenshot_idle` and call it
  first, then `audio_inspector_->poll()` — clobbering `set_idle_callback` drops
  the scripted-ui / settings / overlay paint.
- **Open + live waveform are env-gated.** `PULP_AUDIO_INSPECTOR` shows the
  window AND makes `start()` size `output_probe_`'s capture ring (to
  `AudioWaveformView::kCapacity`); without it the probe stays summary-only (no
  waveform allocation). Headless `--screenshot` also writes the inspector's own
  surface to `<stem>.audio-inspector.png` when it is open.
- **Teardown order:** destroy the window before the `CommandRegistry` (its RAII
  handler removal targets a live registry) and before `output_probe_`.
- **Panel colors:** `canvas::Color` channels are floats in `[0,1]` — build panel
  colors with `Color::rgba8(r,g,b,a)`, NOT `Color{26,26,32,255}` brace-init,
  which Skia clamps to opaque white (the white-on-white panel bug).
- **The editor idle pump drains the param store (automation → widgets).**
  `make_editor_idle_pump(bridge)` (in `editor_idle_pump.hpp`, set as every
  format's `set_idle_callback`) calls `bridge.store().pump_listeners()` each
  vsync. This is what makes `bind_parameter`-bound widgets follow host
  automation playback / host-side edits: `ListenerThread::Main` store changes
  are queued (the adapter writes the store from the audio thread) and ONLY
  fire on `pump_listeners()` on the UI thread. Without this drain, bound
  widgets never move during playback. The idle callback also keeps the GPU
  host's frame loop alive (`has_idle`), so the pump runs even when nothing
  else is animating. A custom view that reads the store directly each frame
  (not via `bind_parameter`) instead needs `View::set_continuous_repaint(true)`.
- **Listener-silent state restore schedules one native reconciliation frame.**
  `StateStore::deserialize()` publishes `state_restore_revision()` only after a
  successful restore. The editor idle pump consumes that edge on the main
  thread, reconciles only listeners that opted into
  `ListenerRestoreBehavior::Reconcile`, and requests one root repaint. Ordinary
  Main and Audio listeners remain silent, and a store-wide consumed revision
  prevents multiple ViewBridges from replaying the same reconciliation.
  Compare revisions for inequality: a failed plugin-envelope restore can
  advance once for the candidate parameters and again for rollback, and the
  pump intentionally coalesces both into one refresh of the final state.
- **A native `create_view()` sizes the host window from its own layout bounds.**
  In `ViewBridge::open`, when the editor is native (not a scripted UI) and the
  view reports non-zero `bounds()`, those bounds override the processor's
  `view_size()` hints (`preferred_width`/`preferred_height`). A native editor
  that lays itself out — but doesn't declare `DESIGN_WIDTH`/`DESIGN_HEIGHT` —
  would otherwise get the default window size, which can be narrower than the
  laid-out content, so the right column + edge padding fall off the window's
  right/bottom edge. Scripted UIs keep the processor-declared `view_size()`
  (their layout is driven from JS/Yoga, not C++ `bounds()`). This is SDK-level:
  every native editor is sized correctly without per-plugin hardcoding — do not
  reintroduce per-plugin window-size constants to work around clipped editors.

### Standalone tool-window commands share one shell registry

Standalone-owned floating tools (the Audio Inspector and the opt-in Musical
Typing Keyboard) register with one shell-owned `CommandRegistry`. Register every
handler first, then call `route_global_keys(window_root, registry)` once; each
call replaces `window_root.on_global_key`, so independently routing each tool
silently disconnects the previous shortcut. `WindowOptions::menu_commands` is
only the native menu's discoverability/action surface. It does not install the
root key route, and non-macOS hosts may ignore it, so Cmd/Ctrl shortcuts still
dispatch through the registry.

The Musical Typing helper is created before standalone audio starts because the
audio callback drains its lock-free failed-note-off recovery. Keep that helper
object stable until after the callback has stopped; `shutdown()` may release UI
state while audio is live because the recovery handoff is atomic, but destroying
the helper would leave the callback with a dangling pointer. Its application key
monitor must consult each owned root's `interaction().focused_input`, never the
process-global `View::focused_input_` shim: a text field in another editor/window
must not suppress notes in this standalone. Key-up remains routable after focus
changes so every accepted note-on can still receive its note-off.

## In-DAW scripted-UI hot reload is opt-in (dev only)

**Every host-embedded editor builds its `ViewBridge` from
`ViewBridge::Options::hosted_editor()`. Never assemble the struct by hand.**
That factory sets `enable_hot_reload` from `dev_editor_hot_reload_enabled()` — a
header-inline helper in `view_bridge.hpp` that returns true only when the host's
environment sets `PULP_DEV_HOT_RELOAD=1` (or `t`/`T`/`y`/`Y`) — and sets the role
to `Editor`. Default is OFF: a shipping plugin must never watch and reload
scripted UI / `theme.json` from disk inside a host. To use the in-DAW
edit→see-it loop, export `PULP_DEV_HOT_RELOAD=1` in the DAW's environment before
launching it.

The standalone app (`standalone.cpp`) is the one deliberate exception — it
hand-assembles `.enable_hot_reload = true` unconditionally because it *is* the
dev tool.

**Why the factory, and why a test guards it.** VST3 and CLAP shipped without
scripted-UI hot reload because they constructed their bridge from the
no-Options constructor. A sweep over call sites that named `enable_hot_reload`
therefore could not reach them — grepping for a field cannot find the call sites
that omit it. `test_view_bridge.cpp` now asserts the positive invariant instead:
every hosted adapter contains `Options::hosted_editor()` and none contains
`enable_hot_reload`, with a negative control on `standalone.cpp` (which *does*
contain it) proving the scan reads files at all. **Adding a new format adapter
with an editor means adding it to that test's `hosted[]` list.**

Editor polling runs every tick regardless; the flag only decides whether the
watcher acts. Two reload mechanisms exist and only one is gated by it — the
DSP-swap-driven editor rebuild (`ViewBridge::poll_editor_reload()`) runs in every
adapter through the shared idle pump, independent of `enable_hot_reload`.

On platforms shipping the no-op watcher stub (iOS — `HotReloader::kWatchesFiles`
is false, because choc's watcher needs macOS `FSEventStream*`), the flag is
accepted and file-watch reload is inert. `ScriptedUiSession` logs once in that
case so the degradation is visible rather than mysterious.

## Scripted UI joins a unified live-swap transaction (SwapUnit)

`ScriptedUiSwapUnit` (`core/format/include/pulp/format/reload/scripted_ui_swap_unit.hpp`)
adapts a `ScriptedUiSession` to the `SwapUnit` contract so a UI swap composes with
a DSP swap (`DspReloadSwapUnit`) under `apply_live_swap` — a content pack that
carries both UI and DSP applies as ONE transaction (item 1.8b/2.5b). Notes:
- The adapter is **path-based**: `to_stage()` captures `session.script_path()`,
  `apply` = `reload_from(new_script)`, `rollback` = `reload_from(prev_script)`.
  It reuses the public `reload_from` (last-good, state-preserving) — no new view
  API. The UX unit is ordered FIRST (cheap to re-apply); a later DSP failure rolls
  it back to the previous bundle.
- It lives in the FORMAT layer (format→view is the allowed direction; view never
  links format), in its own header so DSP-only users don't pull in view.
- Value-perfect widget-state restore across a rollback is a refinement (rollback
  re-runs the previous script; `reload_from`'s own snapshot/restore preserves
  matching widget values across the rebuild).

## Custom widgets carry their own reload state (item 1.4b)

`WidgetBridge` snapshots/restores BUILT-IN widget state by type across a scripted-
UI reload (knob/fader/range/toggle/checkbox/togglebutton scalar; combo/segmented
selection index; xy pad — `widget_bridge.cpp` `snapshot_values`/`restore_values`).
A CUSTOM widget (a `View` subclass) opts into carrying its OWN state by overriding
the `View` virtuals `save_reload_state(std::string&)` / `restore_reload_state(std::string_view)`
(default return false → existing widgets unaffected). The bridge calls them for
every widget in `widgets_` and stores the opaque blob in
`WidgetReloadSnapshot::custom_state` keyed by script id; restore hands the blob
back to the widget still living under that id (id/type change → no match, fail-
safe). Note: `widgets_` is populated by the JS `createX` registrars (built-ins
only today), so end-to-end coverage through a JS-created custom widget awaits a
custom-widget registration path — the `View` hook + bridge wiring are in place for
when one exists.

## Live editor reload (in-place rebuild on hot-swap) — 1.9

A hot-reload plugin whose logic hot-swaps its `create_view()` needs the OPEN
editor to rebuild in place — otherwise the DSP swaps live but the panel only
updates when the DAW re-instantiates the plugin (the symptom that surfaced this).
The mechanism is format-agnostic and driven by the shared idle pump:

- **Signal, don't marshal.** `Processor::supports_editor_reload()` +
  `editor_reload_generation()` (additive virtuals, defaults false/0).
  `ReloadableShell` overrides them; an atomic counter bumps on each successful
  swap (both `reload_now()` and the watcher). The editor **polls** the generation
  on its UI-thread tick — the reload fires on the control/watcher thread, so
  polling avoids cross-thread UI mutation. Don't wire `set_on_reloaded` straight
  into UI work.
- **Preserve the root object identity.** `PluginViewHost` captures `View& root`
  at `create()` — there is no replace-root API. So `ViewBridge::rebuild_primary_view()`
  TRANSPLANTS the fresh `create_view()` output (children + background) INTO the
  same root `View` the host still references, rather than swapping `view_`. A
  logic whose `create_view()` returns a custom `View` **subclass** with root-level
  paint gets children+bg refreshed but not the subclass identity (fine for the
  common container-root editor).
- **Processor-owned scripted sessions reload themselves.** When `create_view()`
  returns a custom root while `active_scripted_ui()` also exposes the processor's
  live `ScriptedUiSession`, it may explicitly opt in with
  `supports_in_place_scripted_ui_reload()`. `ViewBridge` caches that mode at
  `open()` and calls `reload_active_scripted_ui_in_place()` on a generation
  change. The opt-in is separate from `active_scripted_ui()` so existing custom
  scripted processors keep their ordinary `create_view()` rebuild behavior. In
  the opted-in mode, do not call `create_view()` again: it would replace the
  session and strand the root's raw host subscriptions on the destroyed
  instance. The override owns its locking protocol and must retain both the
  original session and root; returning false leaves the generation pending so
  the next UI tick retries the same in-place path.
- **Repaint after rebuild.** The CPU (CoreGraphics) mac host only repaints on
  `setNeedsDisplay`, so `make_editor_idle_pump` calls `View::request_repaint()`
  after a rebuild. Mutating the tree alone does NOT repaint on CPU.
- **One wiring point.** `make_editor_idle_pump` (gpu_host_select.hpp) covers AU
  v2/v3, VST3, CLAP, and both the CPU CVDisplayLink tick and GPU display link.
  `set_idle_callback` reads "GPU only" in the base header, but the mac CPU host
  (plugin_view_host_mac.mm) runs it via its own CVDisplayLink — so the pump does
  tick on CPU editors.

Test: `test_view_bridge.cpp` `[reload]` cases — a reloadable stub rebuilds into
the same root object with new content/bg, a processor-owned scripted session
keeps both its root and session identity across a failed retry and successful
reload, and a normal processor remains inert. `examples/hot-reload-morph`
exercises the ordinary transplant path end-to-end.

## Standalone is a transport — it must derive the same playhead change flags

`StandaloneApp` synthesizes transport (tempo, time signature, `position_beats`
from the rolling sample clock, `is_playing` from the user's play/stop toggle), so
it *is* a host for the Processor running inside it. It must therefore populate the
same derived `ProcessContext` fields the VST3 / AU / CLAP adapters do:

```cpp
detail::derive_bar_from_beats(proc_ctx);
detail::compute_playhead_changes(proc_ctx, playhead_prev_);   // member snapshot
```

It previously hand-rolled the `bar` derivation and never computed the change
flags at all, so `tempo_changed` / `time_sig_changed` / `transport_changed` /
`transport_started` / `transport_jump` were permanently `false` in the standalone
build. A tempo-synced generator therefore behaved differently in standalone than
in the plugin builds — and standalone is exactly where such generators get
developed. `playhead_prev_` is touched only from the audio callback.

## `transport_started` fires on the play edge; `should_reset_dsp_state()` does not

`ProcessContext::should_reset_dsp_state()` is `reset_requested || transport_jump`.
Pressing play at a **parked** position does not move the playhead, so
`compute_playhead_changes()` correctly reports no jump — and therefore
`should_reset_dsp_state()` returns `false` on the block where the transport starts.

That is correct for delay/reverb tails (a start is not a discontinuity, and tails
must survive it) and catastrophic for anything with run-relative phase. A
tempo-synced clock, LFO, or step sequencer that keys its phase reset off
`should_reset_dsp_state()` resumes a stale free-running phase and emits every
backlogged event on the first block of playback — a **pulse burst on play**.

Use `ProcessContext::transport_started` instead. It is deliberately *not* folded
into `should_reset_dsp_state()`, because the two answer different questions:
"did the timeline jump?" versus "did a run begin?"

Two subtleties:
- A processor instantiated **while the transport is already rolling** has no
  previous block to diff against (`!snapshot.has_previous`), so
  `transport_started` is set from `is_playing` on that first block. Without this a
  clock sits dead until the user stops and restarts the transport.
- Better still, derive event positions from `position_beats` outright rather than
  from an accumulator. Then a start, a seek, and a loop wrap are the same case,
  and none of them can produce a burst.

## The StateStore must outlive the Processor

`Processor::state()` dereferences a pointer the host installs. A Processor may
follow it for its whole lifetime — from `process()`, from its destructor, and from
any worker thread that destructor is about to `join()`. So the host has to keep the
store alive until the Processor is gone.

In practice that is one rule about member order: **declare the `state::StateStore`
before the `std::unique_ptr<Processor>`.** Members are destroyed in reverse
declaration order, so the store then dies last. Every host in `core/format` had it
backwards until 2026-07; the effect is nothing at all for a Processor with no
threads, and a use-after-free on plug-in close for one with a background thread that
reads `state().get_value()` while the destructor walks to its `join()`.

It crashes only on close, only sometimes, and the DAW gets the blame. The regression
test is `test/test_store_lifetime.cpp`; it observes the store's destruction through a
sentinel owned by a parameter's `to_string` closure rather than reading freed memory
and hoping the result looks wrong.

A Processor should not *rely* on this either: a worker thread that reads the store on
every tick is one host away from the same crash. Publish what the thread needs to
atomics from `process()` instead.

## Native children under a design viewport, and the resize contract

Two coupled seams a native-editor (WebView) plugin depends on:

**1. Native-child geometry honors the viewport transform.** When a host paints under a
design viewport it scales+letterboxes Pulp widgets, but `NativeViewHost::compute_geometry`
produces frames in ROOT/design space. If those raw coords are pushed to the OS view, a
tree mixing Pulp widgets and a native child drifts by scale+letterbox on any off-size DAW
pane. The fix is a **forward** transform companion to `window_to_root_point`'s inverse:
`bool design_viewport_transform(float& sx,float& sy,float& tx,float& ty)` on both
`PluginViewHost` and `WindowHost` (default false = identity; overridden by the mac CPU/GPU
plugin hosts and the GPU window host — the CPU window host has no viewport, so false is
correct). **Do the transform in the WIDGET (`native_view_host.cpp`), never in the host
`attach_native_child_view`/`set_native_child_view_bounds` primitives** — other callers
(`hosted_editor_attachment.hpp`, `examples/webview-{palette,monaco}`, `test_web_view.cpp`)
already pass HOST-space coords and would double-transform. Introspection getters stay in
design space; `computed_child_frame_host()` exposes the transformed frame for tests. The
pushed-geometry cache stores HOST-space, so a host resize (which changes the scale)
invalidates it and re-pushes even when the design-space layout is unchanged. WKWebView
caveat: scaling the frame **reflows** web content (CSS px track the frame), it does not
zoom — a `pageZoom = s` parity hook is deferred, not wired.

**2. The resize contract.** `ViewSize::aspect_ratio==0` means "free drag within [min,max]".
VST3 (`vst3_plug_view.cpp`) + CLAP (`clap_entry.hpp`) must honor it, but the migration
hazard is that the default `view_size()` returns `{w,h,0,0,0,0}` → `aspect_ratio==0`, so a
naive read flips every hand-authored plugin to free-reflow. Rule: `resizable = min>0 on
both axes` (CLAP's shipped convention); `free = resizable && aspect_ratio==0`. min==0 keeps
today's viewport pin (and VST3 `canResize()` now returns false there, aligning with CLAP);
resizable+aspect>0 is unchanged (viewport+lock+snap); free ⇒ no viewport/lock, clamp
min/max only, no aspect snap. Tests live in `test/test_vst3_plugin_state.cpp` and
`test/test_clap_entry.cpp` (`[resize]`), plus fake-host viewport-transform cases in
`test/test_native_view_host.cpp` (`[viewport]`). The pin decision itself is the shared
`should_pin_design_viewport(ViewSize)` predicate in `plugin_descriptor.hpp` — used by
VST3 (`vst3_plug_view.cpp`) AND the AU v2 Cocoa view (`au_v2_cocoa_view.mm`), so the
formats cannot drift. AU v2 historically skipped the pin entirely and a resized editor
CLIPPED in Logic; any new editor host must route through the predicate, not re-derive
the three-way rule inline (contract test:
`test/test_au_v2_cocoa_ui.mm` `[resize]`).

## Two ways to reach the host's parameters — wiring both DOUBLE-WRITES

A `DesignFrameView` can carry a user gesture to the host's parameter store by two
different routes, and they are **not** alternatives you can safely have both of:

- **The binder** (the original route): you wire `on_element_changed` and forward
  it into the parameter store yourself. This is what the existing embed path does.
- **The host-param surface** (`View::host_params()`): call `set_host_params(&s)`
  and `route_changes_to_host_params(true)`, and the view drives
  `begin_gesture` / `set_param` / `end_gesture` on the surface itself. The surface
  hides *which* parameter system is underneath, so one view runs unchanged against
  an embedding framework's parameter tree or Pulp's own StateStore.

**`on_element_changed` keeps firing when routing is on.** That is free for a
consumer that merely *observes* it — and a **double write** for one that *writes*
from it, which the binder does. Turn routing on without deleting the write side of
your handler and the host receives every value **and every gesture bracket**
twice: a doubled automation write and an unbalanced begin/end pair.

Routing is **OFF by default**, and that default is load-bearing — it is what keeps
every existing embed correct. Pick exactly one route per view:

- staying on the binder → leave routing off, change nothing;
- moving to the surface → turn routing on **and** drop the write side of
  `on_element_changed`, keeping it only for observation.

`test_host_param_surface.cpp` pins this deliberately: with both wired, one user
movement produces one surface write *and* one binder write. If that test ever
starts reporting a single write, the funnel has grown a dedupe and this advice is
stale.

Related: an element re-keyed at runtime with `set_element_param_key()` now
re-binds. It previously kept driving the parameter it was first bound to, so a
view that re-keyed on a preset change was quietly writing to the **wrong
parameter**.

### A DesignFrameView must be PULLED — `pump_listeners()` cannot reach it

The two host→UI channels are **not** the same mechanism, and the difference is
easy to miss:

- a `bind_parameter` widget **registers a store listener**, so
  `StateStore::pump_listeners()` pushes host automation into it;
- a `DesignFrameView` binds through the abstract `HostParamSurface` (so one view
  runs against JUCE APVTS / iPlug2 / StateStore) and therefore registers **no
  listener at all**. Nothing pushes to it. It has to be pulled with
  `sync_from_host_params()`.

The editor idle pump (`make_editor_idle_pump`) drives both — `pump_listeners()`
and the bridge's private `sync_design_frames_from_host()`, which it reaches as a
`friend` — so every plugin editor gets both channels. The pull is deliberately
NOT public: the pump is its only production caller, and a view-side enrolment
registry could retire the tree walk without an API deprecation. Reach it from a
test via `ViewBridgeTestAccess`, not by widening the class. Embedding Pulp views
in your own host? Wiring only the pump's listener drain gives you a design whose
knobs drive the host but never follow it — call the view's public
`sync_from_host_params()` from your own tick.

The pull is **silent**: `set_element_value` writes the element directly and never
re-emits `on_element_changed`, so it cannot echo back into the surface. That is
what makes it safe to pull unconditionally on a tick even though routing is
auto-enabled for every bound imported design — do not "optimize" it into a
re-emit.

**The walk is not free once the view tree is an application shell.** The walk is
deliberately over the LIVE tree — a cached `DesignFrameView*` list would dangle
whenever a view is removed (an editor reload transplants children), and a dangling
pointer here is a use-after-free inside a DAW's UI tick. But the original comment
justified doing that identification with a `dynamic_cast` per node on the grounds
that "a design tree is tens of views, not thousands." A plugin editor is; an
application shell built on the same view tree is not. Sampled on an *idle* Forge
Modular window, the `dynamic_cast` RTTI lookup alone — `libc++abi`'s
`__dynamic_cast`, one question per node per frame — was **7% of the process**.

Keep the live walk; drop the RTTI. `View::is_design_frame()` is set by
`DesignFrameView`'s own constructor, so the `static_cast` that follows is asking a
type that has already answered. If you add another "is it this kind of view?" test
to a per-frame walk, add a flag the constructor sets rather than a `dynamic_cast` —
and note that this cost is invisible in a plugin editor and only shows up when the
same tree carries an app.

**Testing that property: assert zero writes, not "it converges."** A
settle/no-drift check over repeated pulls looks like the natural convergence
proof and is nearly worthless here — a discrete parameter quantizes a small echo
straight back onto the same option, so an echoing pull can touch the host every
single frame while the value never moves and the check stays green. (Verified by
injecting exactly that echo: the settle/drift sweep passed; only
`FakeHostParamSurface::set_calls == 0` caught it, 180 == 0.) The pull is a read
— assert it that way, across every element kind, since routing is on for all of
them. Equally, do not add a naive `last_norm` guard: display text is not pure
in `(key, norm)` (`ParamInfo::to_string` is a closure and `do_param_display_text`
is virtual), so a tempo-synced delay reformats `"500 ms"` → `"1/4"` with its value
unmoved, and a value-only guard would freeze that readout.

## Widget-bridge JS dispatch and CSS color parsing have one home each

**Dispatch:** all `__dispatch__` event emission goes through
`core/view/src/widget_bridge/bridge_dispatch.{hpp,cpp}` — one `safe_dispatch_eval` (the
alive-flag overload) plus `dispatch_event(alive, engine, id, event_name, payload_expr)`.
It ALWAYS routes the target id through `js_string_literal`. Do not hand-build
`"__dispatch__('" + id + "', ...)"` by string concatenation: that pattern was copy-pasted
across ~40 call sites with *inconsistent* escaping, so an id containing a quote or
backslash broke out of the JS string literal and the exception was silently swallowed by
the surrounding catch. New events call `dispatch_event`.

**CSS color:** the bridge's full CSS-Color-4 parser is
`parse_bridge_css_color(std::string_view)` in `widget_bridge/css_color.hpp` — deliberately
NOT named `parse_css_color`, because `css_gradient.hpp` already declares a *weaker*
`pulp::view::parse_css_color(const std::string&)` (hex/rgb/rgba/transparent only — no named
colors, no hsl). Naming them alike makes `std::string` call sites silently bind to the weaker
overload and regress named/hsl handling. Keep the names distinct until the two parsers are
deliberately converged.

**Per-root interaction state (multi-editor isolation):** focus, active-overlay,
ComboBox popup, and the overlay paint queue live in a `RootInteractionState`
owned by the tree root, reached with `View::interaction()` (walks `tree_root()`
and lazily allocates). That is the source of truth — two editors in one host
process (the shared-AUHostingService case) each get their own block and no
longer share focus/popup state. The historical statics `View::focused_input_`
and `View::active_overlay_` remain as **shim mirrors** reflecting the
last-acted slot process-wide; read them only for back-compat, never to reason
about which editor owns focus — use `view->interaction()`. Detached widgets
(no parent, no children) intentionally share one `fallback_interaction()`
block. When enqueuing overlays from host code, push onto the *painting root's*
`interaction().overlay_queue`, not a process-global queue, or the overlay
paints on the wrong editor (see `standalone.cpp`).

## Realtime scripted editors: the performance checklist

Read this **while writing or changing** a scripted/JS editor that animates:
meters, an analyzer, a modulation overlay, pointer drawing, drag or zoom. An
editor that shows live audio does three things at display rate at once —
receives data from the audio side, reacts to the pointer, paints — and the
failures come from one of them quietly doing more work per frame than it looks
like. None of them show in a screenshot, a pixel diff, a browser fixture or a
unit test; they show up as a feel. One survivor keeps the stall, so apply every
item. Proving the result — traced capture, test signal, in-window drag,
per-phase frame gaps, validity gates, environment traps — is the `trace-analysis`
skill: its "Measurement mistakes that produce confident wrong answers" table
first, then the copy-pasteable workflow in
`.agents/skills/trace-analysis/references/ui_jank_playbook.md`, whose
worst-frame table maps each trace signature back to the item below that fixes
it. The long-form rationale is `docs/guides/interaction-cost.md`.

### 1. No framework commit on a per-move, per-frame or per-update path

Never commit React — or anything else that re-applies the whole document —
per `pointermove`, hover, animation tick, meter or analyzer update, **or at
`pointerdown`/`pointerup` of a gesture**. A commit at release lands exactly
when the display should resume; one at press delays the first drag frame.
Keep pointer, hover, gesture and animation state in refs; draw from the refs on
the canvas and request a repaint; write small DOM text (a readout, a tooltip, a
status pill) imperatively through `textContent` and its position. Commit only
for structural changes (a panel opens, a mode switches).

In a materialized/captured import every commit re-applies the captured import
metadata (the `import-design` skill explains that mechanism). Scoping cuts its
bridge traffic but not its per-commit document walk, and an app vendoring an
older `runtime.js` pays the full pass. Measured on one captured-import editor:
an LFO over 64 bands held 60 fps with the mouse still and stalled 100 ms–2.5 s
per frame while it moved; each `pointermove` cost ~42 ms against ~0.2 ms for a
`mousemove` on the same element, with ~150 `getLayoutBoxMetrics`, ~160
`setFlex`, ~195 `setFontFamily` and ~12 layout passes per event, while paint
(~2.5 ms) and `gpu_acquire` (~1–4 ms) stayed cheap. With the rule applied
`pointermove` fell to ~0.6 ms and no >100 ms stall remained.

Audit every commit source:

1. hover/pointer state in React state — including "only when the target
   changes", which on dense targets still commits nearly every move;
2. a status/readout effect publishing through the root's (or an ancestor's)
   state;
3. same-value setter calls — a same-value `setCursor(...)` still committed in
   this runtime, so compare before calling;
4. one handler registered as both `onPointerMoveCapture` and `onPointerMove`
   (runs twice per move);
5. a transient overlay hidden by a timer (`setVisible(false)`) and re-shown
   through state — keep it mounted and restart the timer imperatively;
6. a projection or sync path (host automation, native state pushes) calling a
   React setter with a value equal to the one it holds. Compare before
   `setState`, or keep derived state in refs. In one spectrum editor an
   unconditional `setMacroState(newArray)` / `setValue({...})` re-rendered the
   plot on every host automation change: a viewport change fell from ~33 ms to
   ~0.06 ms and an LFO-shape change from ~20 ms to ~0.05 ms once guarded.

Quick check before a trace: wrap `__dispatch__(id, type, payload)` with a timer
and compare `pointermove` against `mousemove` on the same element; orders of
magnitude apart means the handler commits.

### 2. High-rate native→JS data goes through `dispatch_native_message`

Meters, analyzer frames and modulation frames arrive at audio-hop rate. Push
them with `WidgetBridge::dispatch_native_message(receiver, type, payload)`:
typed arguments cross the engine binding directly, no source is generated or
parsed, microtasks are pumped, and `requestAnimationFrame` callbacks wait for
the host's frame tick.

Never push per-tick data with `load_script`. It parses JavaScript per push,
and it must not make rAF callbacks run outside the frame: each extra drain of a
self-rearming `draw()` is a full scene draw. Before SDK v0.878 every
`load_script` flushed rAF — measured on a materialized React import, one to two
extra full redraws per push (~44 extra drains a second at ~6.5 ms each,
roughly 290 ms of redraw per second with audio playing), halving the frame
rate. An app pinned to an older SDK still pays that.

- `WidgetBridge::load_script` flushes pending rAF only until the host's frame
  pump is live (the first `service_frame_callbacks()`). After that it leaves
  rAF for the next tick and requests a repaint. Headless tests and a first
  script load with no pump still materialize synchronously; a host that drives
  only `poll_async_results()` never goes live and keeps the eager flush.
  `frame_pump_live()` reports which regime a bridge is in.

### 3. Numeric readouts: throttle to ~10 Hz and pin the width

Digits faster than about 10 Hz are unreadable, and each write is a text shape.
Give the readout an explicit width sized for its widest string (sign, digits,
unit): `Label::set_text` skips layout invalidation only when the horizontal
axis is pinned and the line box is unchanged, so an intrinsic-width readout
dirties layout on every digit change — a Yoga pass over every node. Do not rely
on CSS `font-variant-numeric: tabular-nums` in the scripted runtime; the bridge
does not map it.

### 4. Analyzer: never let a slow UI overflow the capture

A spectrum display that polls `VisualizationBridge` from its frame tick should
set `VisualizationConfig::backlog_policy =
VisualizationBacklogPolicy::latest_window`. With the default `in_order` policy
and a `max_frames_per_poll` budget, a consumer polling slower than the hop rate
falls behind, overflows the capture tap, and the resulting discontinuity blanks
the spectrum until a full `fft_size` refills — about 0.2 s blank per overflow in
a measured spectrum editor, a freeze/jump cycle users report as the analyzer
"disappearing".

### 5. Modulation display: evaluate at frame time, crossfade in the audio owner

Repainting the last sample an audio block produced judders, because blocks and
frames run on unrelated clocks. Publish the modulator's inputs instead — phase
at a known sample time, rate, clock/sync state, current fade — and evaluate the
shape at the frame's own timestamp. When the user changes a shape, crossfade
(about 150 ms) in the audio-side owner and publish the fade position with the
inputs, so the display draws the blend the audio is actually playing; a
display-only crossfade drifts from what is heard.

### 6. Budget effects whose cost scales with the signal

Bloom, glow and per-band gradients cost per lit element, so an effect that is
free on a silent editor can dominate a frame with loud, dense audio. Cap the
number of glowing elements or reuse one cached gradient, and judge the cost
with real audio running, never on an idle editor.

## Present pacing on macOS: Mailbox is Fifo, and acquire waits on drawables

`PluginViewHost::PresentPolicy::nonblocking` prefers Mailbox, then Immediate.
On macOS that choice is a no-op: Dawn's Metal swapchain can only toggle
`CAMetalLayer.displaySyncEnabled`, which Mailbox and Fifo both leave on, so an
embedded editor is still vsync-paced and `gpu_acquire` (`nextDrawable`) still
blocks when all three drawables are held. Treat that as a known issue, not
evidence the policy works. Before changing present modes or adding a
frame-in-flight gate, capture a trace and read the standalone GPU window's
`gpu_acquire` args (`frames_in_flight`, `gpu_render_ms`, `late_ms`,
`refresh_period_ms`) to tell a GPU-bound frame from CPU bunching — see the
trace-analysis skill. `PULP_GPU_TIMING=1` turns on GPU render timing for a
standalone window (it relaxes Dawn validation, so it is never on by default),
and `PULP_AUDIO_DEVICE=null` lets that session run without an audio device.

## Scripted Canvas2D editors: the frame cost is the bridge-call count

A Canvas2D draw in a scripted editor costs roughly a fixed amount per JS→native
`canvas*` call (measured on a 64-band analyzer editor: ~2.7 µs per call, and
1,700–2,400 calls a frame). Cut calls, not pixels. Checklist:

- **Measure by counting crossings from JS**, not from the recorded command
  stream: wrap every `globalThis.canvas*` function with a counter (see
  `test/test_canvas2d_call_budget.cpp`). `canvasPathPolyline` expands back into
  `move_to`/`line_to` natively, so the stream cannot tell one batched call from
  hundreds of per-point ones. `PULP_LOG_CANVAS_PAINT=1` gives the per-paint
  command mix.
- **`save()`/`restore()` are cheap now; do not avoid them.** The shim keeps its
  record of what the native canvas holds (`_sent*`) across them — save()
  snapshots it with the JS state and restore() puts both back — so unchanged
  state is not re-sent after every restore. This is sound only because
  `CanvasWidget`'s replay reverts the Canvas2D drawing state on restore() itself
  (`core/view/src/canvas_replay_state.hpp`): SkiaCanvas's restore() reverts only
  matrix and clip, and CoreGraphicsCanvas's reverts the gstate but keeps fill
  and stroke colours in members, so no backend's own restore() gives Canvas2D
  semantics. If you add a sticky setter command, give it a
  `CanvasReplayState::slot_for` slot and a `_sent*` entry in
  `_SENT_FIELDS`, or restore() will leak it on Skia.
- **A draw command that sets state implicitly must update the record.**
  `fill_text` sets the fill colour it carries and `stroke_rect` sets the line
  width it carries (1 when the call carries none; the shim passes `lineWidth`); the shim writes those values into
  `_sentFillColor` / `_sentLineWidth`, and the replay notes them. A new such
  command that skips either side draws with a stale colour after the next
  cache hit.
- **Many disjoint segments are one call.** Tick marks and grid lines drawn as
  `moveTo`/`lineTo` pairs inside one `beginPath()` batch into a single
  `canvasPathPolyline(id, coords, starts)`; the batch only flushes when another
  method emits (every emitting method calls `_fp()` first — enforced by
  `check_canvas_path_flush.py`) or at the 65536-coordinate cap. Stroking each
  segment separately defeats this.
- **Static content: use a cached group, not a second canvas.** Grids, scales
  and labels that do not change per frame go in
  `ctx.pulpCachedGroup(key, drawFn)`: the first call records drawFn, and every
  later one is a single `canvasReplayGroup` crossing that does not run drawFn.
  A second stacked canvas for the static layer still re-sends every call
  whenever it redraws and adds a widget to paint. The replay runs the stored
  commands in place, under the current transform, inside an implicit
  save/restore, so pixels and blend order match calling drawFn directly
  (Skia and CoreGraphics, `test/test_canvas2d_cached_group.cpp`), and a
  `restore()` inside the group cannot pop state saved outside it. The content
  is a function of the key: set every style the group uses inside drawFn and
  call `ctx.pulpInvalidateGroup(key)` when its inputs change; a canvas resize
  or a new context drops every group. Groups live beside the frame's command
  stream, so a retained-frame full clear keeps them, and `canvasReplayGroup`
  returning false is how the shim learns it must record again. A native
  consumer of a canvas's commands must walk `CanvasWidget::replay_sequence()`,
  not `commands()`, or it misses what groups draw. The group replays its
  commands, not a cached texture, on purpose: `begin_layer(cacheable)` handles
  do persist across frames (the live Skia surfaces share one
  `RetainedLayerStore`), but a texture replay matches direct drawing only when
  the device translation is integer-aligned, and 8-bit source-over is not
  associative, so drawing into a transparent layer and compositing it can
  differ by 1 LSB. Caching pixels would trade the byte-exact guarantee for
  native replay cost; measure that cost in a trace before reaching for it.
- **A full-frame `clearRect` on a retained-frame canvas replaces the native
  stream**, so it clears the `_sent*` record — including the copies held by
  open save() snapshots.

## Hover and colour commits in a materialized React editor are paint-only

A materialized (captured-import) React editor re-applies Chromium-captured
metadata after any commit that could move a captured box, and each such pass
reads layout metrics that force a root layout. From @pulp/react runtime
revision 2 (`packages/pulp-react/runtime-fingerprint.json`) a commit that only
changes paint skips that pass and does not bump the mutation epoch:
`PAINT_ONLY_KEYS` (background, border colours, shadows, cursor, ...), `onX`
handlers, a `data-*` attribute no captured-state or runtime selector names, and
`color`/`textColor`/`opacity`/`fill`/`stroke`. For those last five the importer
runtime puts the captured value back on just that node and property where the
capture owns the channel, so the end state matches a full pass. A typical
button hover is then its own few React setters.

What still costs a pass: any size/position/typography/text change, className
or id changes, structural mutations, and an attribute or colour a selector
names (`[data-open]`, `path[fill]`). Hover styling written as a `data-*`
marker plus colour props is cheap; hover styling that swaps a className or
nudges a padding is not. An editor built from a vendored runtime older than
revision 2 gets none of this until its bundle is regenerated
(`tools/import-validation/check_vendored_runtime.py` names what it lacks).

## Editor-INITIATED host resize (`Processor::request_editor_resize`)

`on_view_resized` is the host→plugin direction (the DAW dragged the window,
tell the plugin). The plugin→host direction — the editor asking the host to
resize its own window — goes through `Processor::request_editor_resize(w, h)`.
Use it when the editor's natural size changes at runtime (e.g. a chrome-hiding
"player" mode that wants a smaller, differently-shaped window than its full
authoring layout).

How the seam is wired:

- The **adapter** installs the actual host call via
  `Processor::set_editor_resize_handler(editor_owner, cb)` when the editor
  opens, and clears that same owner's entry on close — BEFORE the bridge /
  editor host the handler captures is destroyed, or a late call dereferences
  freed state. Owner-scoped removal matters when a host opens multiple views
  for one processor: closing one must not erase another live view's handler.
  Each adapter's handler does three things: (1)
  `ViewBridge::set_preferred_size(w, h)` so the reported hints track the new
  shape, (2) `editor_host->set_design_viewport(w, h)` +
  `set_fixed_aspect_ratio(w/h)` so content fills the new window with no
  letterbox / squish, (3) the format's host resize call — CLAP
  `clap_host_gui->request_resize`, VST3 `IPlugFrame::resizeView`, AU v3
  `preferredContentSize`, standalone `WindowHost::request_content_size`.
  The standalone handler is currently installed only for the macOS window
  host, the implementation that can synchronously honor
  `request_content_size`; unsupported window hosts must report refusal rather
  than mutate hints and claim success. Standalone also clears the owner after
  `run_event_loop()` returns because application-quit can bypass the normal
  close callback.
  Its Settings-tab callback must read the bridge's CURRENT preferred size,
  not capture the initial dimensions; otherwise returning from Settings after
  an editor mode switch silently restores the old window shape.
- `request_editor_resize` returns **false** when no active handler is installed
  (no editor open, or a host with no resize path), when multiple simultaneous
  active editor windows make the processor-level target ambiguous, or when the
  host refused — the editor must keep its current size then. A host such as
  Logic may retain a detached AUv2 Cocoa editor object after close, so each
  registration may provide an `is_active` predicate; stale retained handlers
  are ignored rather than making a reopened editor permanently ambiguous. Run
  those predicates outside the side-table mutex because host queries may
  re-enter teardown. The API remains main-thread only.
- `ViewBridge::set_preferred_size(w, h)` recomputes `size_hints_` preferred +
  aspect from (w, h) but PRESERVES the min/max drag bounds, so a mode switch
  changes the window's aspect without snapping the resize grips.

Gotcha: the adapter installs the handler AFTER `create_view()` returns (it needs
the built editor host). An editor that wants a non-default size at OPEN (e.g. a
reopen straight into a compact mode) must re-request on its first idle/poll tick,
when the handler is live — the size it chose during `create_view()` predated the
handler and was dropped.

Gotcha (Logic AUv2): resize the returned editor view exactly once and let Logic
propagate that geometry to its immediate container and outer plug-in window.
Resizing Logic's container first applies the delta again through its flexible
autoresizing mask, producing alternating tiny/huge frames and visible backing
bars. Never mutate Logic's enclosing `NSWindow`; the host owns its chrome and
mouse capture. Treat the operation as a transaction: retain the returned view
and its associated editor owner, suppress the ordinary native resize callback
while the request is in flight, accept only when both editor and immediate
container reach the exact requested size without moving their origins, and
otherwise restore parent first and editor last. Publish `ViewBridge::resize`
once only after acceptance. The registration's activity predicate must require
the returned view to still have both a superview and a window, which is what
keeps close/reopen working when Logic retains the old Cocoa object.

Gotcha (ABI): the handler is stored in a SIDE TABLE
(`detail::editor_resize_handlers()`, a processor-keyed map of owner-keyed
handlers behind inline accessors), NOT a `Processor` data member —
deliberately. `Processor` is a
widely-inherited public base; adding a `std::function` member grows
`sizeof(Processor)`, and a header-only SDK overlay that mixed the new
`processor.hpp` with old prebuilt libs then crashed in `~Processor()` because
`libpulp-host`'s `BakedGraphProcessor` was allocated at the old (smaller) size.
The side table keeps the class layout frozen, so the capability is ABI-additive:
a fully rebuilt SDK gets working resize; an old lib linked against the new header
is harmless (no adapter sets a handler, so `request_editor_resize` returns
false). Reaching a downstream SDK (e.g. Forge on M5) with WORKING resize still
needs a full SDK rebuild + reinstall (the adapters that install the handler live
in the libs), but it will not crash in the meantime.

## An editor-driven state load is gated against the audio thread

`Processor::deserialize_plugin_state()` is documented as running "on a
host/main thread with the audio thread stopped". Format adapters now enforce
that with `format::StateRestoreGate`: the restoring thread takes a unique lock,
the audio thread takes a non-blocking shared lock around its call into the
Processor, and a contended block passes through instead of rendering.

What this means for editor code: a preset load or state restore driven from the
UI briefly makes the audio thread pass through. Keep the deserialize bounded —
the audio thread degrades for as long as the gate is held, so a multi-second
sample reload belongs on a worker with the heavy payload published afterwards,
not inside `deserialize_plugin_state()`.

`Processor::suspend()` / `resume()` remain opt-in and are still not called
automatically by the adapters; the gate is what actually protects the restore.

## A CanvasWidget's offscreen layer is paid only by backdrop-reading streams

Canvas2D gives every canvas its own backing store. `CanvasWidget::paint`
provides it with a full-bounds `save_layer` — at 2x a full-window canvas is a
multi-megabyte offscreen allocated, cleared and composited every paint, and a
materialized editor stacks two. That is only observable for commands whose
result depends on the pixels under them: `clearRect`, `putImageData`, and any
composite operation other than source-over. `add_command` flags those
(`CanvasWidget::reads_backdrop`), and a stream without one paints straight onto
the parent under a plain `save()` + bounds clip — source-over is associative, so
the pixels are the same (pinned on Skia and CoreGraphics by
`test_canvas_widget_backdrop.cpp`).

- **A single `globalCompositeOperation = 'lighter'` anywhere in the retained
  stream puts the layer back** for every paint of that stream. The flag resets
  only when the stream is replaced (`clear_commands`, i.e. a retained-frame
  full clear), so on a canvas that is cleared with `clearRect` every frame
  *without* the retained-frame opt-in the `clear_rect` itself keeps the layer.
- **Measure GPU time, not guesses**: the `[bench]` case of
  `pulp-test-canvas-widget-layer-gpu` (ctest label `bench`) renders two full-window 2x canvases on an offscreen
  Dawn/Graphite surface with timestamp queries and prints the median GPU ms per
  frame with and without the layers.
- A new command that composites against existing pixels must be added to
  `reads_backdrop`, or it will read the parent's pixels instead of the
  canvas's own.

## Note names are a Processor hook, not a view concern

`Processor::note_names()` lets a plug-in label individual keys — a drum kit's
"Kick", a sampler's articulation switches — and the format adapters publish that
list to the host (CLAP `note-name`, VST3 `IKeyswitchController`; AU has no
equivalent). It is a HOST-facing surface, so it does not travel through the view
bridge and an editor never needs to render it.

What matters here is the notification: a processor whose names change — a
sampler loading a new kit from its editor — must call
`flag_note_names_changed()`. The adapter drains that flag on the host/main
thread and tells the host to relabel. An editor that swaps the kit without
raising the flag leaves the DAW's piano roll showing the previous kit's names.

## Adapter tracing attachment does not touch the editor lifecycle (WAH-4)

`clap_adapter.hpp` and `au_adapter.mm` each gained a
`runtime::ScopedTracingAttachment` member so Perfetto captures work for every
format, not just VST3. It is deliberately independent of the editor lifecycle
documented above: it is owned by the PLUGIN INSTANCE, not by the ViewBridge or
the editor host, and its lifetime brackets audio as well as UI work.

If you are reordering members in either adapter, the tracing attachment must
destroy LAST (after the bridge and the editor host), because the final detach
flushes the trace and joins the auto-flush timer — it has to outlive every span
those objects can still emit.

## GPU first-visible-frame health

A control-enabled Standalone that declares `gpu.health.read` now requires an
attached ViewBridge/window. Its Pulp-owned health adapter polls only on the UI
thread, captures the existing back buffer, and publishes an immutable snapshot;
the control worker only reads that snapshot. Do not move capture or provider
writes to the audio thread.

Set the endpoint honestly. Standalone, DAW, and Forge roles use
`native-compositor-presentation` and require an independently sourced native
presentation timestamp. Only the constrained headless role may configure
`headless-capture-complete`; it uses capture completion and must leave compositor
present timing null. Missing compile/upload/hidden/present/source/shader
instrumentation is coverage, not event loss. Preserve exact nullable fields and
named categories so the closed A3 verifier can select only a passing
`no-change` or failing `queue-B4-investigation`; never invent Vellum events or
identities in the ViewBridge. Produce the blank-frame and audio-thread control
receipts with the focused harness commands in
`docs/validation/gpu-first-visible-a3-acceptance.md`; the raw seed environment
variable by itself is not a receipt.

The provider now keeps a distinct GPU evidence ID and trace evidence ID and can
retain multiple lifecycle trials, but a product adapter must call
`begin_editor_open` only for an editor lifecycle it actually observed. Do not
rearm it on an arbitrary frame or label repeated captures as same-process
editor reopens. `gpu_first_visible_a3_campaign.py` validates the external
10-cold/10-warm ledger; it does not create those lifecycle boundaries. The
standalone host's ordinary first observation is therefore a truthful preflight
and remains nonterminal until a real role adapter supplies all opens and the
role-appropriate endpoint.

`gpu_first_visible_a3_external_adapter.py` gives product teams one pinned
producer boundary for that missing work. A producer receives the fixed 10+10
request and must preserve the actual `begin_editor_open` lifecycle, cache
boundary, endpoint, and evidence IDs in its artifacts. The envelope validates
and retains those artifacts but never calls `begin_editor_open`, captures a
frame, or invents a presentation timestamp itself.
The four checked-in role entry points turn this into an executable handoff:
each invokes one exact external lifecycle driver, requires a predecessor for
every warm same-process reopen, and retains the closed driver protocol in the
host evidence tar. Each row answers the producer's nonce while the exact host
executable is alive and retains its observed start identity; the trace binds to
one challenged PID. A separate reviewed source-build driver reproduces the
measured product/bundle without receiving its runtime path. The lifecycle
driver must use the real product/host bridge; a loop of
capture calls without editor lifecycle evidence is rejected.

The separate four-state overhead collector similarly requires a
candidate-relative `state_build_driver`. It exports the exact source row and
default-deny rebuilds it without access to the measured executable, ambient
build output, or network, then requires matching executable bytes and tracing
sentinel state. Preserve its source archive, closed request/receipt, rebuilt
product, logs, and toolchain snapshots; no ViewBridge observation can replace
that product provenance.

The health-transition trace macros compile to no work when `PULP_TRACING=OFF`,
but compiled-in idle and active product cost still need evidence. Terminal A3
requires the acceptance guide's exact pre-change, compile-out, compiled-in idle,
and active 128 MiB control with 5 warmups, 30 measured, and 20 fresh-process
observations per state. All must report zero xruns and audio-thread trace events.
The active role campaigns include the spans but do not replace this differential
control. Keep all producer work and Perfetto session management off the audio
thread.

The terminal overhead workflow enters through
`gpu_first_visible_a3_trace_producer_overhead.py collect-state`. That collector,
not ViewBridge or the product driver, owns the 55 per-state liveness challenges
and binds process start plus executable identity. Active binary traces must
contain the health-first-visible package and the complete b4ba exact
20-signature `state`/`render`/`js` package on the challenged host process.
Acquire/submit/present are mandatory; every other signature remains counted
and an unobserved one is not-covered, not zero-cost. Require zero producer
events on declared audio TIDs. Do not add evidence IDs, session control, or
synthesized compositor timestamps to ViewBridge to satisfy the receipt.

A3 v2 terminal acceptance preserves that boundary with an external audio-thread exclusion receipt and an independently digest-bound blank-frame negative. Per-sample zero counters do not replace either control.

## `ViewBridge` is a view-side symbol despite living in `namespace pulp::format`

`ViewBridge` is declared in `pulp::format` but **defined in
`core/format/src/view_bridge.cpp`**, which is compiled into
`pulp-format-view`, not `pulp-format-core`. The namespace and the owning
target disagree, and that has bitten a symbol sweep already.

Concretely: `clap_adapter.cpp` carries an undefined
`pulp::format::ViewBridge::~ViewBridge()` (reached through the implicit
`~PulpClapPlugin`, which destroys a `std::unique_ptr<ViewBridge>` member). An
`nm` sweep filtering on `pulp::view::` does not see it and reports the object
as view-free. It is not.

**A namespace is not a proxy for target ownership.** When auditing what pulls
the view layer in, filter on the symbols a target *defines*, or skip the filter
entirely and let a link decide — `pulp-test-format-core-only-consumer` exists
for exactly that, and a linker applies no predicate.

`vst3_plug_view.cpp` also defines `make_plug_view()`, the view-free factory
`vst3_adapter.cpp` calls instead of naming `PulpPlugView`. Keep the factory's
definition in that file: it is compiled per-plugin, so the symbol resolves the
same way `PulpPlugView`'s constructor always did.

## The standalone harness can press keys, not just move a pointer

`core/format/src/standalone.cpp` arms three harness hooks off `StandaloneConfig`
+ env: `screenshot_path` (`PULP_SCREENSHOT`), the pointer drag
(`PULP_TEST_POINTER_DRAG`, owned by the mac window host), and the key sequence
(`test_key_sequence` / `PULP_TEST_KEY_SEQUENCE`). Reach for the key sequence
whenever a keyboard behaviour needs proving against the shipping build rather
than against a bridge unit test.

Shape, if you are extending it:

- The spec parser (`detail/standalone_key_sequence.hpp`) and the press/capture
  frame schedule (`detail/standalone_key_schedule.hpp`) are pure headers with
  no window and no AppKit, so both are unit-tested directly
  (`test/test_standalone_key_sequence.cpp`). Only the delivery is
  platform-specific (`standalone_key_driver_mac.mm`, stub elsewhere).
- Delivery goes through `WindowHost::native_content_view_handle()` — the public
  `void*` NSView seam — so the driver lives entirely in `core/format` and needs
  no edit to `core/view/platform/mac/window_host_mac.mm`. It also needs no
  `PULP_VIEW_OBJC_SUFFIX` wiring, because `performKeyEquivalent:` / `keyDown:` /
  `keyUp:` are NSResponder methods available on any `NSView*`, not methods of
  the per-binary mangled `PulpView_*` class.
- The driver calls `performKeyEquivalent:` before `keyDown:`, in that order,
  because that is what `NSWindow.sendEvent:` does. Do not "simplify" it to a
  bare `keyDown:`: a key fanned out to the script layer from BOTH entry points
  reads as two presses, and calling only one of them makes that defect
  invisible to the very harness meant to catch it.
- The schedule reports `finish` a full slot AFTER the last capture, never in the
  same frame, so a caller that closes on `finish` cannot close the window while
  a capture is still reading pixels out of the surface.
- The key run only closes the window when no `--screenshot` one-shot is armed;
  otherwise the screenshot owns the exit and closing here would race it.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…