Skip to content

Every visualizer is a document in the folder (ADR-0087, ADR-0088, ADR-0089) - #192

Merged
jeffcrouse merged 24 commits into
mainfrom
feature/visualizer-documents
Aug 23, 2026
Merged

Every visualizer is a document in the folder (ADR-0087, ADR-0088, ADR-0089)#192
jeffcrouse merged 24 commits into
mainfrom
feature/visualizer-documents

Conversation

@jeffcrouse

Copy link
Copy Markdown
Member

Implements ADR-0087, ADR-0088 and the new ADR-0089 (proposed in #191).

A visualizer was a React component registered onto window.Familiar and evaluated inside the host
page. It is now a folder with an index.html, loaded in a sandboxed iframe and sent three
events. The host lends it nothing — no React, no three.js, no globals.

What changed

  • DocumentVisualizer mounts the document in sandbox="allow-scripts" (no allow-same-origin,
    so the page gets an opaque origin) and posts familiar:track, familiar:state and
    familiar:audio, waiting for familiar:ready first. Isolation was spiked against a real
    WKWebView before anything was built on it.
  • visualizerCatalog replaces visualizerPluginHost and evaluates nothing. 367 lines of loader
    become 53 lines of types.
  • All five visualizers converted, each carrying its own bundle and its own Tailwind build.
  • ADR-0089: one source. The app bundle is seed material copied into the user's folder on first
    launch, not a second place plugins are served from. Every visualizer Familiar ships is now an
    ordinary, editable, deletable folder — which is what ADR-0063 publishes the API for.
  • A debug panel (⌥⌘P) reporting four rates, because "choppy" has four causes that look identical.
  • Timeouts on the API and audio sessions: 61s → 10s to fail with a reason.

The defects this turned up, all silent

The Xcode sync flattened the plugin folders — five index.html files colliding wrapper extension
The catalog read the native listing at the wrong level, so every visualizer was refused normaliseEntry, both shapes tested
getAudioData() was read with no subscriber, so the analysis loop never ran subscribe, with a test
Plugins laid out with Tailwind classes in documents that had no Tailwind per-plugin CSS build
familiar:audio never carried beat/onset, which Beat Tiles is named after added to the payload
pnpm build deleted car.glb the model is part of the build

Each rendered correctly and did nothing. None produced an error anywhere, which is why the
isInspectable work landed alongside.

Verification

497 frontend tests. Every plugin measured in a real browser: all five fill the viewport, no 404s, no
page errors. Beat reactivity proved by A/B rather than by "it animates" — the same 60fps stream with
and without beat pulses differs in 58,803 of 59,191 canvas bytes.

Pairs with seethroughlab/familiar-apple#131, which carries the native half.

🤖 Generated with Claude Code

jeffcrouse and others added 18 commits August 18, 2026 08:44
The countdown is empty, so ADR-0058 point 4's condition is met. This records what the removal
actually is, before anything is deleted.

It is not a delete of player/. ADR-0050 flagged that /embed and /visualizer pin much of it,
and that flag understates it: embed.tsx registers a NullAudioEngine through
player/audio/createEngine, and the Discovery components the embedded page renders —
DiscoveryList, DiscoverTrackList, DiscoveryGrid — all import usePlayerStore, which
re-exports player/playerStore and pulls in playbackStore and queueStore behind it.

So deleting player/ breaks the Mac and iPhone Discover tab, which is the opposite of the
point. The document says what goes, what stays and why, and names the question nobody has
answered: how much of player/'s 9,439 lines is actually reachable from the two embedded
entry points. That is measurable rather than a judgement call — build them with the app entry
removed and see what the bundler demands.

Left as a document rather than started as a cut, deliberately. A half-finished removal of the
most heavily tested code in the app is worse than none, and the failure mode here is
specifically invisible to every check we have: the routes are guarded by
navigationIntegrity.test.ts, and nothing guards the embed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scope said the honest sequence starts by building /embed and /visualizer with the app entry
point removed and seeing what the bundler demands, rather than deleting player/ and fixing type
errors. This is that measurement.

Method: build embed.html + visualizer.html with index.html dropped from rollupOptions.input,
dump every module id rollup emitted, then walk the source graph separately, since type-only
imports never reach the bundle and still have to compile.

Of player/'s 28 non-test files, 11 are bundled, 3 are type-only and 14 — 2,534 lines — are
unreachable from either entry point. useAudioEngine.ts, the file the subsystem is named for, is
on the removable side, along with all of ambient/, radioController, useAudioControls,
audioSettingsStore and the index.ts barrel. Deleting the barrel is what keeps the other
thirteen unreachable rather than dragged back by a re-export.

The list of what goes was wrong about three files, and this records it: db/index.ts,
offlineService.ts and playlistCache.ts are all in the embed bundle, and so is syncService.ts,
which the list never mentioned. Three independent chains pin Dexie — offline status on the
Discover header, favourites in the track context menu, and queue persistence.

Also records the reproduction, including the trap that cost a pass: strip comments before
regexing for imports, or stores/playerStore.ts's "// DEPRECATED: import from '../player'"
fabricates an edge to the barrel and makes all fourteen dead files look pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The feature is browser-only permanently — ADR-0036 built the server half and ADR-0037, which
would have had the Apple clients host and join, was rejected the same day. With the fallback
player going, /listen/:code would be the only route in the application that plays music.

Goes: routes/sessions.py (520), services/sessions.py (319), tests/test_api_sessions.py (221),
the router registration, and settings.turn_server_{url,username,credential} — configuration
whose only consumer was GET /sessions/ice-servers, deleted rather than defaulted for the reason
ADR-0036 point 3 gave when it deleted VITE_SESSIONS_RELAY_URL.

ADR-0036 point 8 said sessions stay in process and that held: SessionManager kept them in a
dict, so there is no model, no table and no migration to unwind.

The `sessions` tag leaves the generated surface. Its two REST operations had no caller in
either repository — the tag was added in anticipation of ADR-0037 and outlived its rejection by
twelve days — so no Swift call site changes. The companion commit in familiar-apple drops it
from filter.tags; without both, the lint's cross-check fails, which is how this was caught.

Schema: 261 operations to 259. Re-vendored into the Apple repo, which also closes the four-path
drift ADR-0078 found — the two copies are now byte-identical for the first time since
ADR-0064's ranking endpoint shipped.

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

ADR-0058 point 4's trigger is met, so the browser's player goes. 132 files deleted, ~31,900
lines, and what is left is an administration tool that constructs no audio engine at all.

**The shell.** AppShell mounted a player bar, a queue/session right panel, a full-player
overlay, an ambient overlay and two mobile overlays on every admin page, and useAppBootstrap
built an audio engine, a scrobbler and a play tracker on every page load — in a tool for
scanning a library and reading server health. useKeyboardShortcuts bound Space, arrows and
volume globally. All gone. packages/web/src/main.tsx now registers no engine at all, so
createEngine() throwing is the correct outcome for a call that should never happen there.

**The offline stack (ADR-0071).** db/, offlineService, playlistCache, syncService,
offlineManifestService, downloadStore and dexie itself. useFavorites is online-only: a toggle
with no server is an error the caller sees rather than an action queued for a sync nothing would
run. connectivityStore keeps its reachability half, which OfflineIndicator legitimately reports,
and loses offlineTrackIds.

Three things needed somewhere to live rather than deletion. The selected profile moved to
localStorage — it was the one thing in the Dexie store with nothing to do with caching tracks,
and getSelectedProfileId no longer needs to be async to answer. remoteLogService submits from
its in-memory buffer instead of spooling through IndexedDB, putting entries back on failure.
Queue persistence went entirely: the embedded surfaces register a null engine and never play,
so there was nothing for a restored queue to be restored into.

**Two files were over-deleted and restored.** useOfflineStatus reads the reachability half that
survives, and six callers use it including OfflineIndicator; ADR-0071 point 4 lists it for
deletion but point 3 keeps what it accesses, and the tension resolves in favour of keeping the
accessor. FavoriteButton is imported by PlaylistTrackList, which DiscoverTrackList renders on
the embed path — deleting it would have broken Discover on the Mac and phone. It moved to
components/shared/ beside its only consumer.

**The guardrail script was replaced, not repaired.** check-audio-guardrails.mjs read
player/useAudioEngine.ts and player/queueStore.ts by path and asserted things about the text in
them, so it broke outright when they went. check-embed-guardrails.mjs walks the import graph
from embed.tsx and visualizer.tsx and fails if either reaches IndexedDB, a real audio engine or
the effects chain — a property rather than a phrasing, and one a comment cannot satisfy.

Verification: tsc 7 errors, all pre-existing (the other 7 of the original 14 were in
useKeyboardShortcuts.test.ts, now deleted). 41 test files, 532 tests, all passing. The web build
still emits index, embed and visualizer; bundle budgets pass; lint has no errors; the new embed
guardrail walks 132 modules and finds nothing forbidden.

**Not verified, and it is the part that matters: Discover and the visualizer have not been
opened on a Mac or a phone.** Nothing automated covers those surfaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment explaining why `familiar-surface` exists said the fallback would serve "the full web
app, `WebAudioEngine` and all, which would then hold an audio session". That stopped being true
when ADR-0071 deleted the web player: `index.html` now registers no engine at all.

The marker is not less necessary for that. A probe that cannot tell which document it received
cannot tell a working surface from a silent one, which is the defect ADR-0017's record keeps
finding here. The note now says that instead of an outdated worst case.

Found by grepping the re-vendored bundle for `WebAudioEngine` and expecting zero hits.

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

ADR-0057 point 5 says a capability and its affordances leave together, and ADR-0070 point 5
applies that to the parity record: the row goes with the feature, in the same change. The
removal commits landed without it, so this closes that.

Listen-together leaves the Listening table and joins "Retired, and deliberately not coming
back", beside the Capacitor app. The fallback player gets its own entry there — the trigger was
met, and WebAudioEngine, the effects chain, the queue and the Dexie store went with it.

One consequence worth stating rather than leaving to be noticed: ADR-0060 point 1's first
exclusion rule, "excluded by decision", now points at an empty list. Listen-together was its
only member. The rule stays — a future row may need it, and ADR-0060 point 3 still requires an
ADR to add one — but nothing is browser-only by decision any more.

The removal doc's verification block was predicting numbers; it now records measured ones. The
counts moved in both directions and the note says why: the baseline had grown to 67 files /
1,012 tests by the time the cut ran, seven of the fourteen tsc errors lived in
useKeyboardShortcuts.test.ts and went with it, and the test files that vanished are the
player's, the sessions' and the offline stack's own.

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

The host mounts a document-shaped visualizer in a sandboxed iframe and posts it three events,
receiving one handshake back. It lends the plugin nothing — no React, no THREE, no globals —
which is the whole decision.

`sandbox="allow-scripts"`, deliberately without `allow-same-origin`. That gives the plugin an
opaque origin: no cookies, no storage, no reach into the host's DOM. The spike recorded in
ADR-0087's Implementation proved that a document can still load its own CSS and JS over a custom
scheme in that state, which is what made the strong form usable.

Two details that would otherwise be discovered later:

  `postMessage` targets `'*'`. An opaque origin has no string that would match, so a targeted
  post would silently never arrive. Inbound messages are checked by *source identity* instead,
  for the same reason — a string comparison here would be a check that never fires.

  Audio frames are sent interpolated rather than raw. ADR-0087's Consequences anticipated
  handing the plugin the 10 Hz feed and making it own the reconstruction; the host already does
  that work for its own render loop, so passing the result costs nothing and spares every plugin
  author the same piece of arithmetic.

`public/visualizers/spectrum/` is the proof: 106 lines of HTML with a canvas, no build step, no
bundler, no framework, no import from the host. If a visualizer cannot be written that way then
the contract is wrong, so it is worth having one that is.

Seven unit tests cover the bridge — including that nothing is sent before the handshake, and
that a ready message from another document is ignored. An end-to-end spec covers the contract
itself: handshake, all three events, canvas actually lit, and the two isolation properties, so
`allow-same-origin` cannot be added later to make something else easier without a test going
red. That surface is rendered inside a WKWebView on the Mac and the phone and nothing else
tests it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first r3f visualizer as a self-contained plugin: its own React, its own three.js, its own
@react-three/fiber, and playback from the three events the host posts in. `src/familiar.ts` is
the contract implemented locally — about 90 lines a plugin author writes once, and it is here to
be read as the worked example of what the contract actually costs.

**1,092 kB raw, 302 kB gzipped.** For comparison, `vendor-three` alone in the shared build is
2,560 kB / 764 kB — so the entire self-contained plugin is under half the size of the shared
THREE chunk on its own. ADR-0088 point 4 guessed per-plugin builds would tree-shake better; they
do, by a wide margin, because this scene uses about fourteen THREE classes and now ships only
those. The projected full set is ~4.5 MB against today's 3.2 MB single bundle, so the
duplication costs about a megabyte rather than the five point 8 assumed.

Two things this found that would have bitten every plugin author, both commented in
vite.config.ts with the reason rather than silently fixed:

  **Module scripts cannot load in a sandboxed frame.** `type="module"` is always fetched with
  CORS, and an opaque origin sends `Origin: null`, so the plugin's own bundle — in its own folder
  — is refused. The earlier spike and the `spectrum` example both passed only because they use
  *inline* scripts, which hides this completely. Plugins build to an IIFE and use a classic
  `<script src>`.

  **A library build throws `process is not defined`** on its first line, because React reads
  `process.env.NODE_ENV` and lib mode does not define it. Invisible in a sandboxed frame unless
  you go looking for it.

Verified running, not just building: handshake received, events arriving, the r3f canvas mounted
at 800x150 with a live WebGL context under swiftshader, `window.origin === "null"`, no errors.

The built folder is committed, matching the existing precedent of vendoring VisualizerBundle.html
rather than building at package time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
reactive-terrain, lyrics and lyric-storm, each a self-contained folder with its own React,
three.js and — for the two lyric scenes — its own drei. With spectrum and beat-tiles that is the
whole set: five documents, no registry entries, and no music video, which ADR-0066 point 2
removes from the visualizer set and ADR-0085 makes a native Mac player mode.

  spectrum              8 KB
  beat-tiles          1.1 MB
  reactive-terrain    1.2 MB
  lyrics              1.2 MB
  lyric-storm         1.2 MB
  ------------------------
  total               4.6 MB   against today's single 3.3 MB bundle

So the duplication costs about 1.3 MB, not the five ADR-0087 point 8 assumed when it argued for
shipping fewer. ADR-0088 point 4's expectation that per-plugin builds tree-shake better is
confirmed: each scene now carries the slice of three.js it uses rather than a build sized for
all of them.

**A third CORS finding, and the general form of the other two.** reactive-terrain came up drawing
but with no car: an opaque origin makes *every* CORS-checked request arrive as `Origin: null`,
so `fetch` — and therefore any model or shader loader — is refused without
`Access-Control-Allow-Origin`. Classic scripts, images and stylesheets are not checked, which is
why this did not show up until a plugin loaded a `.glb`. The scheme handler now sends the header,
which also means module scripts would work; the IIFE build stays anyway, because it needs no
cooperation from whatever is serving the folder.

Verified running, all five: handshake received, events delivered, canvas mounted, WebGL live
under swiftshader, `window.origin === "null"`, and the two data-carrying payloads visibly
arriving — spectrum renders the track title, lyrics renders the lyric lines it was sent.

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

The host no longer runs plugin code. `visualizerPluginHost` installed `window.Familiar` with
React, THREE, @react-three/fiber and drei on it and evaluated each bundle with `new Function`
inside this page; `visualizerCatalog` replaces it and only reads manifests. It is a third the
size, and nothing in it can execute a plugin.

Gone with it: `visualizerRegistry`, `registerVisualizer`, `getVisualizer(s)`, the five React
visualizer components, and the side-effect import that registered them before the loader ran.
`AudioVisualizer` resolves a catalog entry and mounts `DocumentVisualizer` at its URL; the
picker and ADR-0064's ranking read the same catalog, so "what can this host draw" still has one
definition.

MusicVideo goes with the others and does not come back as a document. ADR-0066 point 2 removes
it from the visualizer set and ADR-0085 makes it a native Mac player mode.

`packages/web/scripts/build-visualizer-index.mjs` generates the listing a browser cannot
enumerate. The native host has VisualizerSchemeHandler for that; one consumer, two producers,
and the shapes agree because both are manifests plus where they were found.

Two things the tests caught rather than review:

  The ranking effect depended on the catalog array, and a test mock returning a fresh literal
  each render made it re-request forever. The fix is not a signature or a ref — the real hook
  returns React state, whose identity is stable — so the mocks were made to behave like it. A
  mock that does not looks exactly like a hook bug.

  `Cannot access refs during render` was the first attempt at working around the above. Removing
  the workaround removed the lint error too.

The prop-wiring tests are kept, not deleted. They caught `currentTime` hardcoded to 0 once; the
same mistake now means a frozen playhead posted to every plugin, so they assert against a
stubbed DocumentVisualizer instead of a rendered component.

520 tests across 40 files, tsc at its 7-error baseline, lint clean, the embed guardrail walks
125 modules, the web build still emits all three documents, and the contract e2e passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The document said a visualizer was a React component that read window.Familiar for React, THREE,
@react-three/fiber and drei. None of that is true, and this is the file ADR-0063 proposes to
publish to outside authors — so it being wrong is not a documentation debt, it is the thing that
would be published.

It now opens with a working visualizer in twenty lines: a canvas, one message listener, no build
step, no dependencies, no imports. If the contract cannot be demonstrated that way then it is
not the contract ADR-0087 decided on.

The section worth having is "Three things that will bite you", and it is not style advice. Each
one produced a visualizer that loaded and drew nothing, and each cost real time to find because
the failure is invisible from outside a sandboxed frame:

  A module script cannot load — opaque origin, CORS, `Origin: null`. Inline scripts hide this,
  which is why the hand-written examples worked and the first bundled one did not.

  Anything fetched needs CORS headers, so GLTFLoader and friends fail against a server that does
  not send them. Familiar's loader does, for files in your own folder. `<script src>`, `<img>`
  and `<link>` are exempt, which is worth knowing when choosing how to express a dependency.

  A library build throws `process is not defined` if it bundles anything reading NODE_ENV.

Also states the thing the old document could not: what Familiar guarantees, and what it refuses
to. No shared libraries, no shared folder, bundle what you need — with the two shipped
visualizers named as worked examples at both ends of the range.

Also here: scripts/vendor-visualizers.sh in familiar-apple copies the built folders in to ship as
the `shipped` source. It prints the one step it cannot do — the folder has to be added to the
Xcode project as a folder reference, because bundle.url(forResource:) finds a directory only if
it was copied as one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both declared `main: "dist/index.js"`, so under the new contract the loader would point an
iframe at a JavaScript file and render nothing. They are the migration cost ADR-0087 accepted,
arriving in the one drop-in folder that exists.

Rewritten rather than rebuilt, because what they should demonstrate changed. The shipped five
already show what bundling a library looks like; a *drop-in example* is more useful showing the
other end — one file, no build step, no dependencies, nothing borrowed from the host.

  lyric-pulse   116 lines. The current lyric line, glow driven by bass. DOM and CSS only: the
                audio frame writes two custom properties and the browser does the animating.
                Its predecessor is why the contract changed — a React component compiled against
                injected globals, which crashed on its first frame because its build handed
                `React` in as a JSX runtime.

  non-places    146 lines. Silhouetted everyday objects drifting through fog in three parallax
                layers, on a 2D canvas. The old one drew this with three.js and GLB models, which
                would now mean a megabyte in the folder for silhouettes in fog — and the original
                description was "parallax layers of silhouetted mundane objects", which is a
                canvas. Kept as an example precisely because it is the case where the obvious
                tool is the wrong one.

Verified against the contract: both handshake, both report `window.origin === "null"`,
lyric-pulse renders the correct current and next line for the playhead it was sent, and
non-places lights 350,797 pixels. Installed into the local Visualizers folder so the drop-in
path can be tested alongside the shipped one; the old IIFE copies are in /tmp/old-visualizers.

Not done here: ADR-0065's seeding copies two files per example and now needs to copy folders.
That is familiar-apple #129's branch, not this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`VisualizerPluginSource` was `'shipped' | 'local'`. Under ADR-0089 the app bundle is seed
material rather than a place plugins are served from, so every plugin the page sees is local —
including the ones Familiar ships, which is what makes them editable.

That made most of `visualizerPlugins.ts` unreachable. `reviewPlugins` decided which of two
sources won a collision and whether a drop-in was claiming a compile-time visualizer's id;
neither can happen, since ADR-0087 deleted the registry and ADR-0089 leaves one directory.
`parseManifest` had already been replaced by `visualizerCatalog`, which reads the listing.
Counted before cutting: `parseAffinity`, `parseManifest`, `reviewPlugins`, `DiscoveredPlugin`,
`PluginVerdict`, `VisualizerPluginManifest` and `VisualizerFeatureRange` had zero consumers
outside the file and its own test. 367 lines become 53 of types.

`parseAffinity` is the one worth noting: it validated an affinity block and reported the fields it
ignored, which is what ADR-0064 says the picker should show. The catalog casts instead. It goes
with the rest — ADR-0077's rule is that a surface with no caller is deleted, not documented — but
wiring validation back in is a real improvement, not just a restoration.

488 frontend tests pass. The `PluginRefusal` union loses `shadowed`, `reserved-id`, `threw` and
`registered-nothing`: two described collisions needing a second source, two described running a
bundle inside this page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`VisualizerPlugins.indexJSON` wraps each manifest — `{source, folder, manifest: {…}}`, or
`{source, folder, problem: "…"}` when the folder could not be read — because the native host
splices the bytes through without parsing them. The web build's listing is a flat array of
manifests. This file read `id` off the row, so every native entry came back with no id, was
refused as malformed, and the Mac app showed "No visualizer available" for all seven visualizers.

The comment above `INDEX_URLS` claimed the shapes agreed. It now records that they do not, and
why, since the wrong version of that sentence is what stopped anyone checking.

`normaliseEntry` is exported so the two shapes can be tested directly. `visualizerCatalog.test.ts`
pins both against captured real output from each producer, including a native `problem` row, which
ADR-0034 point 7 requires be reported by folder name rather than dropped. The tests were confirmed
to fail with the fix patched back out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two faults behind "they don't react, they don't fill the window, and spectrum is blank".

**The audio, which is proven.** `getAudioData()` is a getter over a buffer that only moves while
the singleton analysis loop is running, and that loop is reference-counted by `useAudioAnalyser`:
"the first subscriber starts the singleton rAF loop; the last unsubscriber stops it." Every
visualizer used to be a React component that subscribed on its own behalf. A document cannot — it
is in an iframe. So when the registry went, the surface was left with a reader and no subscriber:
the buffer never filled, `getAudioData()` returned its empty value 60 times a second, and every
plugin was fed silence. `spectrum` draws nothing but what it is sent, so it rendered a black
rectangle; the three.js ones drew their scenes and sat still. Nothing errored anywhere, which is
why it looked like three unrelated bugs.

**The sizing, which is hardened rather than reproduced.** The iframe was `h-full`. A percentage
height is indeterminate unless every ancestor has a definite one, and an iframe whose height
cannot be resolved falls back to its intrinsic 150px rather than to zero — so the failure mode is
a short strip at the top of the window, which is what the screenshots show. `absolute inset-0`
depends on a positioned ancestor existing rather than on an unbroken chain of heights, removing
the class of bug.

Honest about the limit: I could not reproduce the strip outside the app. The same page, served
standalone and measured in a headless browser at 1400x900, put the iframe at exactly 1400x900 —
so the CSS chain is sound there and the remaining candidate is the `WKWebView`'s own frame. This
change makes the page side unable to be the cause; if the strip survives it, the constraint is
native.

The subscription now has a test. It is the kind that only exists after the fact — reading is not
subscribing, and there is nothing in the type system or in any error message that distinguishes
them.

496 frontend tests. Host page re-vendored, app rebuilt and installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe answered both open questions and left a third. The iframe was measured at 1529x826 —
the whole window, so last commit's anchoring worked — and audio frames arrived at 240 per four
seconds, exactly 60fps, so the subscription worked. Beat Tiles was still a small box in the
corner of a frame the size of the screen.

**The scene laid itself out with Tailwind, in a document that has no Tailwind.** These components
were written inside the host app where it was ambient, so `<div className="w-full h-full">` wrapped
the canvas and the lyric visualizers centred their text with `flex items-center justify-center`.
Split out into standalone documents, not one plugin shipped a stylesheet — the folders contained
`app.js`, `index.html` and a manifest, nothing else. Those class names matched no rule at all.
They are not an error; they do nothing. Measured: `#root` 800px, the div inside it 150px, which is
what a canvas falls back to.

Twenty-four `className` attributes across the four, and the lyric ones are real typography —
`font-extrabold`, `text-white`, `max-w-4xl` — so hand-converting to inline styles would have been
24 chances to get the look wrong. Tailwind v4 scans each package's own source and emits only what
it finds: 4.7-8.0 kB per plugin. That is what point 7 means by bundling what you need.

`cssFileName: 'style'` is pinned rather than left to Vite, which names it after the package —
`visualizer-beat-tiles.css`, against a `<link>` that said `style.css`. A stylesheet that 404s is
silent, and this surface has produced enough of those.

**And `pnpm build` was deleting `car.glb`.** `emptyOutDir` clears the output folder and the copy
step listed only `index.html` and the manifest, so the model reached the output by hand once, got
committed, and survived exactly as long as nobody re-ran the build. Reactive Terrain would have
lost its car with a 404 in a console no one can open. An asset a plugin loads at runtime is part
of the plugin, so the build copies it.

Verified at 1200x800 in a real browser: all five fill the viewport, no 404s, no page errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Beat Tiles filled the window and stayed still. It reads `audio.beat` and `audio.onset`; the
`familiar:audio` payload carried neither.

**The host has computed both all along.** `AudioAnalysisData` declares them — "decaying beat
envelope (0-1)" and "true only on the single frame an onset is detected" — and they were simply
not among the fields copied into the message. So `beat` arrived as `undefined`, the plugin's own
`?? 0` turned it into zero, and `onset` was never truthy, so no ripple ever spawned. The scene
rendered perfectly and never moved, with nothing wrong anywhere to find.

Not one plugin's mistake: across the five, `beat` is read four times and `onset` once, by
beat-tiles and reactive-terrain. The contract was short by two fields and every rhythmic
visualizer was inert.

`apiVersion` stays 1. Adding a field cannot break a plugin that destructures what it wants -- which
is what the version note in `useAudioAnalyser` says the number is for, and how `beat` and `onset`
reached the host in the first place.

Verified beyond "it renders": with an identical 60fps stream driven twice, once with beat/onset
pulses and once with them flat, 58,803 of 59,191 canvas bytes differ. Animating and reacting are
not the same evidence, and the first run only showed the former.

`VISUALIZER_API.md` gains both fields and the reason to prefer them: `bass` follows the low end
continuously, so a sustained bass note holds it high and produces no beats at all.

497 frontend tests, including one asserting the two fields reach the frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Choppy" has four different causes and they are not distinguishable by watching, so the panel
reports four rates rather than one:

  plugin fps      what a person actually sees
  host loop       this page's rAF, which reads the analysis buffer
  analysis in     frames from the native player -- ~10/s, because macOS clamps the tap to 100ms
  posted out      familiar:audio messages into the plugin

plus the last values sent and how long ago a frame arrived, and a one-line verdict: no analysis at
all reads differently from analysis arriving at 3/s, which reads differently again from a plugin
whose own scene is slow while the pipeline is fine.

**The panel is in the host page, over the iframe, not injected into it.** A plugin document has an
opaque origin, so nothing here can reach in and instrument it -- which is also why the plugin's own
frame rate is the one number the host cannot take: it has to be volunteered. `familiar:stats` is
that, and it is optional in the strongest sense. A plugin that never sends it loses nothing and the
panel shows a dash. All five ship it because it is one loop in the shim they share.

Counted into one-second buckets rather than averaged over the session, because a session average
hides a stall, which is the thing being looked for.

Verified against the real page and a real plugin rather than a mock: the panel rendered live
numbers and correctly diagnosed the harness -- "no analysis from the player" -- because a headless
browser has no native player attached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jeffcrouse and others added 5 commits August 22, 2026 17:42
CI never ran these. The jobs queued behind a wedged macOS runner instead, so this branch has been
red-in-waiting rather than green — 10 failures, all reaching for things this PR deletes.

**One dead affordance, found by the tests rather than by reading.** `useAppBootstrap` went with the
player, and the `navigate-to-settings` listener went with it, but `StatusMenu`'s "View System
Status" button still dispatched that event — a control wired to nothing, failing silently. That is
the shape ADR-0057 point 5 exists to prevent and the one this codebase has now shipped four times
(#70, #74, #76). `useAppNavigation` gains `navigateToSettings`, which is a call rather than an
event: delete the target and it stops compiling instead of going quiet.

**The helper pointed at a link this PR removed.** `VIEW_PATHS.Tracks` still routed through Tools →
"Track list", the affordance ADR-0057 point 5 took away, so every spec touching it timed out on a
locator. `Tracks` joins the views mapped to `null`, which is the file's existing way of saying "ask
for this and fail saying so". `navigateToTab`'s Library case goes to the Library destination
instead, and `Queue` leaves its union — that case was already a silent no-op, so a spec asking for
it asserted against whatever page it happened to be on.

**Four spec files describe capabilities that no longer exist.** `audio-playback`,
`crossfade-playback`, `offline-fallback` and `offline-invariant` — the player, crossfade, stream
fallback and the downloads view. Only the offline pair was failing; the other two "passed" by
skipping, every test, because no track row and no `<audio>` element were ever there to find. A suite
that green-lights a deleted capability is worse than no suite. Same reasoning trims two tests from
`playlists` (a track list to add from, a queue to display) and two from `settings-admin` (the
track-list grid toggle, album art in a player bar), and retires eleven `<audio>` helpers left with
no callers.

`library-sync`'s "Library shows content after sync" is rerouted rather than deleted: a sync is still
worth asserting, and the Library destination reports the size of the library, which is what all that
test ever really checked.

44 tests to 20, in 4 files. Verified: lint 0 errors, 532 frontend unit tests pass, web build clean,
`playwright test --list` loads all four files.

Not fixed here, and worth a decision: `screenshots.spec.ts` and `mobile-screenshots.spec.ts` are
excluded from CI by `--grep-invert="screenshot"` and are broken independently of this — they
navigate to Artists, Albums, Mood Grid, Music Map and 3D Explorer, all unmounted since ADR-0050,
and still dispatch the `navigate-to-settings` event deleted above. Deciding what the README should
show now that the web app is three admin destinations is a product question, not a mechanical one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Emjrwedq7W1scadHdgiLfs
The contract spec ADR-0087 added has never passed in CI. It waits on `familiar:ready` from the
plugin document and times out with `window.__ready` still null, which reads as a broken handshake.
The handshake is fine. `packages/web/public/visualizers/` was added without ever being served.

Nothing mounts `/visualizers`, so `/visualizers/spectrum/index.html` fell through to the SPA
catch-all and came back as **`index.html` with HTTP 200** — the entire web app, loaded into the
sandboxed iframe in place of the plugin. It parses, it renders, it sends no `familiar:ready`,
because it is not a visualizer. The test then blames the plugin.

This is the failure `serve_visualizer` two hundred lines below already documents for the singular
`/visualizer`, and for the same reason: *"the SPA fallback answers any unknown path with
index.html — HTTP 200, the whole web app."* The folders needed the same protection and did not get
it. It is the fourth time this codebase has shipped an affordance whose destination is not mounted
(#70, #74, #76).

A mount rather than a route, because ADR-0087 makes a visualizer a *folder* — `index.html` beside a
manifest and whatever it brings with it. `check_dir=False` so a build predating these folders
answers 404 rather than the SPA: a missing visualizer should look missing.

`visualizers/` also joins `NON_SPA_PREFIXES`. That is belt-and-braces for the no-folder build, and
it is the only half of this a test can hold: the mount is registered inside `if STATIC_DIR.exists()`
and `static/` never exists under pytest, so `spa_fallback` is the sole reachable seam. The new test
fails without it, logging `spa_fallback_served path=visualizers/spectrum/index.html` — the defect,
named. A second test pins `/visualizer` singular against the prefix that guards `/visualizers/`,
since one is a sibling name one character shorter and a 404 there would take the Mac's visualizer
web view down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Emjrwedq7W1scadHdgiLfs
With the folders served, the plugin document handshakes, draws, renders its title and proves its
isolation — and the test still failed, on `expect(errors).toEqual([])`, collecting:

    SecurityError: Failed to read the 'serviceWorker' property from 'Navigator':
    Service worker is disabled because the context is sandboxed and lacks the
    'allow-same-origin' flag.

Nothing in the app or in any visualizer reads `navigator.serviceWorker`; the only caller is
`packages/web/src/main.tsx`, and that runs in the top-level page, where the read is legal and
throws nothing. **The thrower is Playwright.** `serviceWorkers: 'block'` installs an init script
into every frame, that script reads the property, and in a frame sandboxed without
`allow-same-origin` the origin is opaque and the read is a `SecurityError`.

So the error is caused by the isolation this very test asserts twenty lines below — ADR-0087
point 5, `allow-scripts` without `allow-same-origin`. It is evidence the sandbox works, arriving
in the one channel the test treats as failure.

Confirmed rather than reasoned: a probe against a static server showed zero errors after
`page.goto('/')` and the `SecurityError` only after the sandboxed iframe attaches, with a stack in
`<anonymous>` rather than any file; flipping the option to `serviceWorkers: 'allow'` makes it
disappear with nothing else changed.

Filtered by exact cause, not relaxed to a count, so a real uncaught error inside a visualizer still
fails this test.

Verified: the full spec passes locally against `dist/` on a static server with the real config's
`serviceWorkers: 'block'` — handshake, canvas, title, opaque origin, no parent access.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Emjrwedq7W1scadHdgiLfs
jeffcrouse added a commit that referenced this pull request Aug 23, 2026
Both were proposed on 2026-08-20 and are implemented by #192, which cannot merge while they are
unratified — implementing an unapproved decision is the trap ADR-0089 itself exists to correct,
after the wrong shape got built from ADR-0034.

Approved by Jeff on 2026-08-22. Status only; neither Decision changes, and the Dates stay at
proposal time, matching ADR-0087.

The eight still-proposed ADRs in #188 (0073-0077, 0080-0082) are deliberately left alone. They
govern work that has not started, so ratifying them today buys nothing and would spend approval
on decisions nobody has had to live with yet.


Claude-Session: https://claude.ai/code/session_01Emjrwedq7W1scadHdgiLfs

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffcrouse
jeffcrouse merged commit c00d99f into main Aug 23, 2026
13 checks passed
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