Skip to content

Propose ADR-0087: a visualizer is a document, not a component - #191

Open
jeffcrouse wants to merge 7 commits into
mainfrom
adr/visualizers-are-documents
Open

Propose ADR-0087: a visualizer is a document, not a component#191
jeffcrouse wants to merge 7 commits into
mainfrom
adr/visualizers-are-documents

Conversation

@jeffcrouse

Copy link
Copy Markdown
Member

One ADR, proposed. No code.

The plugin contract today is "a visualizer is a React component, and here are our four library versions"React, THREE, @react-three/fiber and Drei on window.Familiar, with the bundle evaluated by new Function inside the host page. ADR-0063 is about to publish that outward, after which it is very hard to retract. That timing is why this is worth deciding now.

The shape was inherited, not chosen

ADR-0034 point 1 says so itself:

"The format is adopted, not designed — but adopted because it costs nothing and two working samples already build to it, not because there is an ecosystem that a different choice would strand. There is not."

What prompted it

ADR-0065's seeded example lyric-pulse crashed the first time it ever ran, on a freshly built app. Its bundle ends })(window.Familiar.React) and renders with jsxRuntime.jsxs(...) — but React does not export jsxs; the automatic runtime is a different module. Verified against the installed React 19.2.4:

React.jsxs          undefined
React.createElement function
jsx-runtime.jsxs    function

It draws divs with a glow. Not 3D, no THREE, no renderer needed. It was broken by plumbing the contract obliged it to use. That is the shape of the problem, not an unlucky bug.

The decision

A visualizer is a folder with an index.html, loaded in its own browsing context, sent events. The whole API is four names: familiar:track, familiar:audio, familiar:state inbound, familiar:ready outbound. No shared libraries, no framework, no window.Familiar.

The objection that would sink an event model does not hold. Audio already crosses a process boundary at 10 Hz and the page reconstructs 60 Hz from it (nativeAnalysisBuffers.ts:20). A postMessage hop into a child document is noise against that.

What this costs, stated rather than buried

  • The current visualizers are not preserved, and that was chosen. Asked directly whether a solid, simple contract mattered more than saving any particular visualizer, the answer was yes. So there is no sharing hatch, and the three r3f scene graphs are rewritten, demoted to drop-ins, or dropped. Recorded in Consequences so nobody later reads their loss as a regression to fix by reintroducing shared libraries.
  • ADR-0067 and ADR-0068 were aimed at the wrong contract. Both proposed, so nothing shipped unwinds — but the effort was spent, and 0068's conversion work is redirected rather than executed.
  • Point 6 names the one thing this makes worse: serving a folder is a path-traversal surface that serving one known filename was not.

If this is rejected

The two-line fix — add a JSX runtime to the globals — should be applied anyway. A seeded example that crashes on first run is worse than shipping no examples.

🤖 Generated with Claude Code

jeffcrouse and others added 7 commits August 20, 2026 07:31
The plugin contract today is "a React component, and here are our four library versions" —
React, THREE, @react-three/fiber and Drei on window.Familiar, with the bundle evaluated by
new Function inside the host page. ADR-0063 is about to publish that contract to outside
authors, after which it is very hard to retract.

ADR-0034 point 1 already records that the shape was inherited rather than chosen: "adopted, not
designed ... because two working samples already build to it, not because there is an ecosystem
that a different choice would strand. There is not."

The prompt was ADR-0065's seeded example crashing the first time it ever ran. lyric-pulse ends
`})(window.Familiar.React)` and renders with jsxRuntime.jsxs, which React does not export — the
automatic runtime is a different module. It draws divs with a glow. It is not 3D and needs no
renderer, and it was broken by plumbing the contract obliged it to use.

The objection that would sink an event model does not hold: audio already crosses a process
boundary at 10 Hz and the page reconstructs 60 Hz from it (nativeAnalysisBuffers.ts:20). A
postMessage hop into a child document is noise against that.

So: a folder with an index.html, loaded in its own context, sent events. No shared libraries, no
framework contract, crash isolation by construction rather than by error boundary. Three of five
built-ins genuinely earn r3f, so point 7 lets a plugin opt into a vendored copy by URL — the
plugin's choice, visible in its source, rather than a global the host injects.

Recorded honestly: ADR-0067 and ADR-0068 were aimed at the wrong contract. Both are proposed, so
nothing shipped unwinds, but the effort was spent. Point 6 also names the one thing this makes
worse — serving a folder is a traversal surface that serving one known filename was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two changes, both from Jeff's steer that a solid, simple contract matters more than saving any
particular visualizer.

Point 7 had let a plugin load a host-vendored THREE by URL. That hatch existed for exactly one
reason — sparing the three r3f built-ins from each bundling 2.4 MB — and with those not treated
as a constraint it has no other justification. A sharing mechanism is a version contract wearing
a different hat. It moves to Alternatives as rejected, and point 7 now says there is no sharing
of any kind. New point 8 answers the bundle-size worry the honest way: ship fewer visualizers.

Point 2 was vague where it most needed not to be — "events on the plugin's own window" is not a
contract anyone can build against. It now names all four: familiar:track, familiar:audio,
familiar:state inbound, familiar:ready outbound. And states the property that makes it simple —
a plugin is obliged to receive, never to implement, so a document that draws nothing is still
valid.

Recorded in Consequences that losing the current visualizers was chosen rather than overlooked,
so nobody later reads it as a regression to be fixed by reintroducing shared libraries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Approved by Jeff. The plugin contract becomes a folder with an index.html, loaded in its own
browsing context and sent four events. No React, no THREE, no @react-three/fiber, no Drei, and
no sharing mechanism of any kind.

ADR-0034's Status records the partial supersession the way ADR-0013 does for ADR-0050: points 1
and 3 only. Its point 2 — interpreted code evaluated in WebKit and never in the app's process —
is untouched and better served by a document than by new Function. Points 4, 5, 7 and 8 stand.

Two proposed ADRs are now aimed at the wrong contract and need revisiting before any more work
goes into them: ADR-0067 shrinks to almost nothing once there are no libraries to expose, and
ADR-0068's conversion of the built-ins to IIFE bundles is redirected. Neither has shipped.

The immediate consequence is that ADR-0063 can publish a contract worth publishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt 1

An accepted ADR is a living record, so this adds the Implementation block: what landed for point
6, and the measurement that decided how point 1's "own browsing context" is built.

The open question was whether an iframe could be sandbox="allow-scripts" — opaque origin, no
ambient authority — while still loading its own CSS and JS over a custom scheme. A custom scheme
plus an opaque origin is exactly where a surprise hides, and building the event bridge on an
assumption would have been finding out late. A standalone WKWebView harness answered it: all
subresources fetched, script ran, stylesheet applied, origin null, host DOM unreachable,
postMessage working both ways.

Two findings worth keeping. The control run could not reach the host DOM either, because
`//host` and `//plugin` are already different origins under one scheme — so origin separation
and the sandbox attribute stack rather than one carrying the load. And the residual: sandbox
denies cookies and storage but not network, so a plugin can still fetch anywhere. The handler
serves the document and can attach a CSP; that is named as the next decision rather than
quietly assumed solved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-0087 point 8 said the shipped set should be deliberately small — "one or two cheap ones" —
because each self-contained document carries its own libraries. Asked which ones should ship,
Jeff's answer was all five. That is the opposite of point 8, so it supersedes rather than
quietly diverging; the rule against editing an accepted Decision exists for exactly this.

Point 8 optimised the wrong thing. It traded what a fresh install can do against megabytes, in
an app that already ships a 3.2 MB visualizer document and whose library is measured in tens of
gigabytes of audio. It also left two categories — shipped, and "demoted to a drop-in" — which is
the distinction ADR-0068 was written to remove and which point 7's "one shape, not two" is
otherwise careful about.

So: all five convert, all five ship, and the duplicated THREE is paid deliberately rather than
engineered around. Paying it in disk is the point of having refused a sharing mechanism — a
shared library is a version contract, and this is only bytes.

Point 4 notes that per-plugin builds may tree-shake better than one bundle sized for all of
them, with a follow-up to strike that optimism if measuring shows it does not hold.

This also closes the question point 7 deliberately left open, so the conversion work is now
fully specified: five folders, then the registry can go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"All five existing visualizers" reads as including music video, which is wrong and is a
mistake worth making impossible rather than correcting again.

ADR-0066 point 2 already removes music-video from the registry — "Four built-ins remain:
reactive-terrain, beat-tiles, lyrics and lyric-storm" — and ADR-0085 makes it a native Mac
player mode. So the conversion set is those four plus spectrum.

The reason it is spelled out rather than left to inference: the registry in the code still
lists music-video, because the ADRs removing it have not landed. Anything counting the current
state gets five and includes the one that is leaving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were proposed against the component contract ADR-0087 deletes, so neither can be accepted
as written and leaving them open invites work against a surface that no longer exists.

ADR-0067 decided what to expose on window.Familiar. There is no window.Familiar: a visualizer
brings its own libraries, so there is nothing left to choose the contents of.

ADR-0068 would have built the four built-ins as IIFE bundles against those globals. ADR-0088
reaches its destination — one shape for shipped and drop-in alike — by making both a folder with
an index.html.

Rejected rather than superseded-as-accepted, since neither was ever accepted, and each records
what survived it. ADR-0067's observation that Music Video was the only built-in needing
playerStore and an API client was evidence the component contract was wrong, and ADR-0087 cites
it. ADR-0068's point 3 — two builds of one visualizer drift, and nothing in CI can see it — is
the argument ADR-0088 used to refuse a small shipped set with the rest demoted, because two
categories drift the same way. Its point 2, letting a local plugin shadow a built-in, is
inherited for free: with one shape there is nothing special about a shipped id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant