| name | add-webview |
| description | Add a sandboxed webview UI control to an existing nanoTracker plugin (instrument OR pedal). Covers the single-file HTML constraint, the host↔iframe message bridge, optional iframe→host write channel, theme cascade, and the v3.5 audio route-back. Composable — assumes the rest of the plugin manifest already exists. |
| version | 1.0.0 |
| metadata | {"hermes":{"tags":["nanotracker","plugin","webview","html","javascript","ui"],"category":"audio-plugin-authoring","requires_toolsets":["terminal","files"]}} |
Add a webview control to an existing plugin
When to use
Use this when a plugin's audio side already exists (instrument or
pedal manifest is shaping up) and you want to add a custom HTML/JS
UI surface — a custom knob layout, an oscilloscope, a step
sequencer, a DOOM screen, anything you can draw with HTML / canvas
/ WebGL / WASM.
If you're starting a brand-new pedal from scratch with a webview UI,
use scaffold-webview-pedal instead — it bundles the pedal scaffold
with the webview steps in one go.
Procedure
1. Declare the capability + UI control
In the existing manifest:
"requires": [ "webview-ui"],
"ui": {
"layout": "flex",
"controls": [
{
"type": "webview",
"source": "web/ui.html",
"aspectRatio": "4/3"
}
]
}
The webview-ui capability is mandatory whenever any ui.controls[]
entry has type: "webview". The validator catches the omission.
2. Decide which host events the iframe receives
The bridge defaults to forwarding notes + params. Tighten or relax:
{
"type": "webview",
"source": "web/ui.html",
"forwardNotes": true,
"forwardParams": true,
"forwardEffects": false
}
For a parameter-only visualiser, set forwardNotes: false to save
postMessage bandwidth.
3. Decide whether the iframe writes back (v4 bidirectional bridge)
If your iframe needs to drive the host (paramWrite for fader UIs,
noteOn for in-iframe keyboards, presetLoad for preset browsers):
"requires": [ "webview-ui", "webview-writes"],
"ui": {
"controls": [
{
"type": "webview",
"source": "web/ui.html",
"acceptsParamWrites": true,
"acceptsPresetWrites": false,
"acceptsNotes": false,
"acceptsHostCommands": false
}
]
}
webview-writes is REQUIRED whenever any accepts* flag is true.
4. Author the iframe HTML (single file)
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
margin: 0;
background: var(--color-bg, #111);
color: var(--color-text, #ddd);
font: 11px monospace;
}
</style>
</head>
<body>
<script>
(function(){
window.parent.postMessage({ type: "__nt_ready" }, "*");
window.addEventListener("message", (e) => {
const ev = e.data;
if (!ev || typeof ev !== "object") return;
switch (ev.type) {
case "noteOn": break;
case "noteOff": break;
case "param": break;
case "pitch": break;
case "gain": break;
case "allNotesOff": break;
case "trackerEffect": break;
case "themeChange": {
for (const [k, v] of Object.entries(ev.theme)) {
const cssVar = "--color-" + k.replace(/[A-Z]/g, m => "-" + m.toLowerCase());
document.documentElement.style.setProperty(cssVar, v);
}
break;
}
case "presetList": break;
case "__nt_error": console.warn("[plugin]", ev.where, ev.message); break;
}
});
function setParam(key, value) {
window.parent.postMessage({ type: "paramWrite", key, value }, "*");
}
})();
</script>
</body>
</html>
5. Single-file constraint — no exceptions
The loader rejects HTML referencing sibling assets. Inline
EVERYTHING:
| Allowed | Rejected |
|---|
Inline <script> / <style> | <script src="./bundle.js"> |
data: URIs | <link href="style.css"> |
blob: URLs created at runtime | import "./util.js" |
https:// URLs (with caveats) | new Worker("worker.js") |
# fragment anchors | fetch("./data.bin") |
Bundling toolchains:
- React/Svelte/Vue:
vite-plugin-singlefile
- Pure JS:
esbuild --bundle --minify then manually inline
- Binary assets: base64 +
Uint8Array.from(atob(STR), c => c.charCodeAt(0))
ntvalidate runs the single-file scan at pack time so you find
violations before shipping.
6. (Optional) Audio route-back
If the iframe generates audio that should ride the tracker's master
bus (channel volume / pan / FX apply):
{
"type": "webview",
"source": "web/ui.html",
"acceptsAudioFrames": true
}
The host posts __nt_audioInit with the sample rate; the iframe
posts __nt_audio frames upstream. See
docs/12-webview-audio.md.
7. Re-validate
node tools/ntvalidate.mjs <plugin-dir>
Pitfalls
- ❌ Capability omission —
webview-ui MUST be in requires[]
if ANY control is type: "webview". webview-writes MUST be
declared if ANY accepts* write flag is true.
- ❌ External script tag — single-file constraint, see step 5.
- ❌ Forgetting the ready handshake — events queue and flush on
iframe
load instead, but late. Always send
{type:"__nt_ready"} early.
- ❌ Hardcoded colours — the iframe loses the host theme
cascade. Use
var(--color-...) so themeChange events take
effect.
- ❌ Trying to read parent DOM / cookies — sandboxed; impossible
by design.
- ❌ Polling
requestAnimationFrame for game-logic ticks — rAF
is throttled when the iframe isn't visible. Use setInterval for
fixed-rate logic; rAF only for visual updates.
Verification
ntvalidate passes (single-file scan included)
ntpack produces an archive
- Load + add-to-workspace per the host flow
- Iframe renders at the declared aspect ratio
- Test events: play a note → confirm
noteOn arrived; turn a host
knob → confirm param arrived
- (If
acceptsParamWrites) drag a UI control in the iframe →
confirm a host parameter changed
- Switch the host theme → confirm
themeChange re-styled the
iframe
Reference