Skip to content

8.0.0-beta.1

@katspaugh katspaugh tagged this 30 Jul 07:47
* fix(reactive): truthful visibleRange/scrollPosition; dragToSeek toggle fixes

R1: scroll-stream gains refresh()/shared readScrollData (maintainer WIP,
preserved); Renderer.render()/onContainerResize() call it so visibleRange
is truthful immediately after layout, without needing a DOM 'scroll' event.
Adds Renderer-level failing-first coverage (stub metrics before render,
assert truthful endTime with no scroll event; zoom() updates it too) on top
of the maintainer's scroll-stream unit tests.

R5: fixes the setOptions dragToSeek object-form bug (maintainer WIP,
preserved) and completes the intended per-drag child-scope design: initDrag()
creates a `dragScope = this.scope.child()` and registers the drag
stream/effect on it; the new disposeDrag() disposes that scope and nulls the
stream fields. Replaces the now-redundant disposeDragStream field (a disposed
child scope detaches itself from its parent, so nothing else needs to track
its remover). Adds tests for both-direction runtime toggling of the object
form and a disposer/listener-balance assertion across repeated toggles.

R4: wires the renderer's existing scroll handler to
actions.setScrollPosition(scrollLeft); composes Player's mutedSignal into
WaveSurferState as `muted`, parallel to volume; documents `progress` as a
historical alias of currentTime. createScrollStreamWithAction was already
deleted in the maintainer's WIP (verified via grep, no remaining references).
The six no-caller state actions (setCurrentTime/setDuration/setPlaying/
setSeeking/setVolume/setPlaybackRate) are kept rather than deleted: they're
pinned as public contract by wavesurfer-state's dedicated standalone tests,
so they're marked @internal-style (no caller in WaveSurfer's own wiring,
which writes through Player instead) rather than removed.

Gates green: bare jest (545/545), tsc --noEmit clean, eslint clean, full
build succeeds, test:leaks (7/7).

Not signed: no ssh-agent socket available in this sandbox (ssh-add -l
failed to reach the agent) -- committed with --no-gpg-sign; needs
re-signing by the controller before push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* fix(spectrogram): windowed dirty-flag re-render, sliced worker payloads, error routing, shared crop loop

Task 2 of the post-refactor cleanup plan (R2, R6, R18 parts):

- R2: SegmentManager.renderVisibleWindow() re-arms a pendingRender flag when an
  overlapping call bails on the isRendering guard, and gives the viewport one more
  pass (reading fresh deps) once the in-flight call finishes, instead of dropping
  the bailed call's work for good.
- R6: the windowed worker path slices each channel to the segment's own sample
  range (.slice(startSample, endSample)) before postMessage, instead of posting
  the full-length getChannelData() view - postMessage's structured clone copies
  the whole backing ArrayBuffer of a TypedArray view, not just the viewed range,
  so the old code cloned the entire decoded channel on every segment request.
  Worker protocol unchanged; startTime/endTime sent are shifted to be relative to
  the now-pre-sliced data.
- Unified splitChannels resolution (plugin option falls back to the wavesurfer
  instance's own option) across every full-mode and windowed-mode call site via
  one effectiveSplitChannels() helper - windowed previously ignored the
  wavesurfer-level fallback full mode already had.
- frequenciesDataUrl documented as full-mode-only (windowed already silently
  ignored it; honoring it there would fight the segment renderer's own canvases).
- Fire-and-forget renders (throttledRender's render(), scheduleWindowedRender's
  renderVisibleWindow(), scheduleQualityUpdate's updateVisibleSegmentQuality())
  now route rejections to the plugin's 'error' event via a shared emitAsError
  helper instead of leaving them as unhandled promise rejections.
- getFrequencies/calculateFrequenciesForRange no longer recreate a worker once
  ctx.scope.disposed - closes a live-Worker leak for a computation that runs
  against a destroyed plugin.
- Extracted the duplicated colorIndex/crop pixel-painting loop (full mode's
  fillImageDataQuality, windowed's renderChannelToCanvas) into one shared
  paintColumnPixels helper in fft.ts (host-agnostic leaf both files already
  depend on, avoiding a spectrogram-setup.ts <-> spectrogram-windowing.ts import
  cycle), unified on the clamped form - the drift: windowed clamped out-of-range
  bin values before indexing colorMap, full mode didn't, so an out-of-range
  frequenciesDataUrl value could throw instead of rendering the nearest color.
- Deleted the write-only pendingBitmaps bookkeeping: added to/deleted from/cleared
  but its .clear() on destroy never actually cancelled anything (the real guard
  is the canvasCtx.canvas.parentNode check in the bitmap's own .then()).
- windowed 'progress' event constant-0 for viewport-only segments: checked the
  pre-unification windowed plugin's own getLoadingProgress()/emitProgress() and
  confirmed it had the identical behavior (progress only reflects progressive-
  loading advancement, never on-demand viewport segment loads) - not a port
  regression, left as-is.

All new tests verified failing-first against the pre-fix code before being
confirmed green against the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* fix(spectrogram): exact segment-length reconstruction across worker boundary

Code review fix round 1 on Task 2 (R6 follow-up):

- The windowed worker path's sliced-segment endTime (added in 8b5c2d8) sent
  `sliceLength / sampleRate` to the worker, which re-derives its own end sample
  via `Math.floor(endTime * sampleRate)`. That division-then-remultiplication
  round-trip doesn't always recover the exact sliceLength - for ~5% of realistic
  (length, sampleRate) pairs it lands one sample short, silently dropping the
  segment's last sample (concrete instance: sampleRate=8000, len=1015 ->
  endTime=0.126875 -> floor(0.126875*8000)=1014). Fixed by sending a half-sample
  epsilon instead: `(sliceLength + 0.5) / sampleRate` - brute-force verified 0
  mismatches across 9 common sample rates x 5000 lengths each, both before (to
  confirm the ~5% failure rate) and after the fix.
- Replaced the tolerant `toBeCloseTo` assertion on the worker payload's endTime
  with a strict check of the worker's own reconstruction formula
  (`Math.floor(endTime * sampleRate)` must equal the slice length exactly), and
  added a dedicated adversarial-pairs regression test covering (1015, 8000) and
  five other pairs chosen to exercise the same rounding hazard at different
  sample rates.
- Added a regression test pinning that the dirty-flag re-arm's coalesced rerun
  (spectrogram-windowing.ts, from 8b5c2d8) is skipped once disposed while
  pendingRender is set, not attempted against a torn-down host - was previously
  covered only implicitly (the guard existed, but no test exercised the
  disposed-while-pending-rerun ordering specifically).

Both new/changed assertions verified failing-first against the pre-fix code
before being confirmed green against the fix; the worker-formula test hung
(unresolvable promise, correctly caught by Jest's timeout) rather than merely
asserting false when the disposed-guard was removed, which is itself confirming
evidence the guard prevents unbounded rerun work against a disposed host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* fix(core): revive WaveSurfer/Player event bridges on post-destroy reuse

destroy() disposes and recreates this.scope/mediaEventScope (WaveSurfer)
and mediaScope (Player), but historically only the constructor ever
called initPlayerEvents()/initRendererEvents()/setupReactiveMediaEvents()
-- so a plain destroy() -> load() reuse (no explicit setMediaElement()
call) left every WaveSurfer/Player event bridge permanently dead:
timeupdate stopped flowing, renderer clicks stopped seeking, play/pause
stopped forwarding, and the reactive state signals (including the Task 1
scrollPosition wiring, which lives in initRendererEvents) froze.

Mirrors Renderer's own ensureInputEvents() one layer up: WaveSurfer gets
an idempotent ensureCoreEvents() (initPlayerEvents + initRendererEvents +
Player's reactive media bridge) and Player gets ensureMediaEvents(),
each guarded by an initialized flag that destroy() resets and the
respective constructor / loadAudio() top calls. setMediaElement() keeps
rebinding unconditionally (it must -- the element itself changed) and
marks its flag initialized afterward so a later ensureCoreEvents() call
in the same reuse cycle doesn't double-register on top of it.

Added failing-first reuse tests in memory-leaks.test.ts (verified red
against the pre-fix code, green after) covering timeupdate flow, renderer
click-to-seek, play forwarding, reactive state tracking, and the Task 1
scrollPosition ledger note. All existing reuse/abort tests stay green.

Gates: bare jest (559/559), test:leaks (7/7), tsc --noEmit, eslint, full
build -- all clean.

Signing: no SSH agent socket available in this environment: committed
with --no-gpg-sign (controller re-signs pre-push per repo convention).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* chore(release): v8 beta versioning, changelog, package exports fixes, docs refresh

Task 4 of the post-refactor cleanup plan (R7, R8, R13 export bits):

- Version bumped to 8.0.0-beta.1. Maintainer-amendable release call -- flag
  prominently in the PR body per the plan's self-review checklist.
- Landed CHANGELOG.md at the repo root: the repo had no existing changelog
  convention (GitHub releases are auto-generated from commit messages via
  .github/workflows/release.yml, no CHANGELOG.md ever existed), so this
  creates one with the 8.0.0-beta.1 entry, transformed from
  docs/RELEASE_NOTES-scope-refactor.md + PR_DESCRIPTION.md (both phase notes)
  plus the three post-review fix commits already on this branch, condensed to
  a terse, user-focused, breaking-changes-first entry. Deleted both source
  docs (RELEASE_NOTES-scope-refactor.md was git-tracked/force-added;
  PR_DESCRIPTION.md was untracked, so a plain rm) -- docs/superpowers/plans/
  left untouched (untracked, out of scope).
- tsconfig.json: added "src/**/__tests__" to exclude. Verified dist/__tests__
  is absent after a full build, and bare jest is still 559/559 green (ts-jest
  resolves test files dynamically per Jest's testMatch, not from the
  program's initial file list, so excluding tests from tsc's own build
  doesn't affect ts-jest).
- package.json#exports: "types" condition moved first in every entry (Node/TS
  resolve the first matching condition key, so "types" must precede
  "import"/"require" for type resolution to actually route through it).
  Fixed the broken "./dist/*" wildcard: its "types"/"require" templates
  appended ".d.ts"/".cjs" onto the full captured subpath (which already
  included ".js"), producing paths like "./dist/webaudio.js.d.ts" that never
  existed -- a real break, since examples/webaudio-shim.js imports
  'wavesurfer.js/dist/webaudio.js' through exactly this wildcard. Narrowed
  the pattern to "./dist/*.js" (types + import only; dropped "require" since
  there's no per-module .cjs build for internal dist modules under
  "type": "module" -- honest absence beats a template that resolves to a
  file that was never going to exist). Verified every export template
  against real post-build dist files with a new script,
  scripts/verify-exports.cjs (also wired as `npm run verify-exports`),
  which reimplements Node's actual pattern-key specificity/tie-break
  algorithm rather than a naive prefix sort.
- dist/types.d.ts (a rollup-plugin-dts bundle of wavesurfer.d.ts) was built
  but referenced by nothing -- not package.json's "types" field, not any
  export entry, not any doc. Stopped building it (removed the rollup config
  block + the now-unused rollup-plugin-dts devDependency) rather than wiring
  it in, since nothing in-repo or in the examples ever pointed at it.
- README.md: added a concise "Advanced / reactive API" section (getState()
  signals incl. loadPhase/scrollPosition, getRenderer().getVisibleRange(),
  definePlugin(), and SpectrogramPlugin's rendering: 'windowed' merged
  option with a pointer to the WindowedSpectrogramPlugin deprecation) in the
  existing doc's voice, plus a TOC entry.
- examples/spectrogram-windowed.js: added a deprecation banner comment
  pointing at rendering: 'windowed'. examples/spectrogram.js: added a
  commented-out rendering: 'windowed'/progressiveLoading usage note as the
  merged-option example, alongside the plugin's existing option docs.
- AI_OVERVIEW.md: refreshed the project-structure section with
  src/scope.ts, src/reactive/, src/state/, src/define-plugin.ts, and
  src/__tests__/, corrected the stale ".eslintrc" reference to
  eslint.config.js (flat config) with a note on its resource-acquisition
  bans, and added the two-project Jest setup (test:unit vs test:leaks) to
  Common Tasks.

Gates green: bare jest (559/559), test:leaks (7/7), tsc --noEmit, eslint,
full build (tsc + rollup) -- all clean. Verified post-build: dist/__tests__
absent, dist/types.d.ts absent, every checked export template resolves to a
real dist file (scripts/verify-exports.cjs), package.json's "files" field
unchanged ([\"dist\"]).

Not signed: no ssh-agent socket available in this sandbox (ssh-add -l
failed to reach the agent) -- committed with --no-gpg-sign; needs
re-signing by the controller before push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* chore(release): enforce exports verification in the build

Fix round 1 on Task 4 review: scripts/verify-exports.cjs's header comment
claimed it was "not wired into package.json scripts", but it already was
(`npm run verify-exports`) -- corrected the comment. More substantively, an
unenforced verification script rots: nothing stopped a future exports edit
from silently reintroducing a broken template. Appended it to the `build`
script (`... && node ./scripts/verify-exports.cjs`), which also covers
`prepublishOnly` automatically since that just runs `npm run build`.

Verified end-to-end:
- Full chained build (clean -> tsc -> rollup -> verify-exports) runs and
  exits 0 against the current, correct exports map.
- Deliberately broke `exports["./dist/*.js"].types` to a template pointing
  at nonexistent files (`./dist/*.nonexistent.d.ts`), reran the same chain:
  verify-exports correctly FAILed 2 targets (`dist/webaudio.js` and
  `dist/scope.js` types) and the chain exited 1, proving a broken exports
  entry now fails the build instead of shipping silently. Reverted the
  deliberate breakage (package.json restored via backup + diffed to confirm
  only the `build` script line differs from before) and rebuilt clean.

Gates green after revert: full build (incl. the new verify-exports tail),
bare jest (559/559), tsc --noEmit, eslint -- all clean.

Not signed: no ssh-agent socket available in this sandbox (ssh-add -l
failed to reach the agent) -- committed with --no-gpg-sign; needs
re-signing by the controller before push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* chore: debt sweep — single scroll math, one RAF primitive, fft split, typed streams, comment cleanup

R10: delete the duplicate, deprecated calculateScrollPercentages from
renderer-utils.ts (scroll-stream.ts's copy is the only one anything calls);
migrate its one test case with distinct coverage into scroll-stream.test.ts.

R11: port record.ts from Timer to FrameScheduler and delete src/timer.ts
(+ its test + its eslint raw-resource-acquisition allowlist entry). Public
behavior (record-progress emission, start/stop/pause/resume timing) is
unchanged; FrameScheduler's synchronous-first-tick already matches Timer's.

R16: split src/fft.ts. The ~700 fully-typed lines (frequency-scale math,
autoGain/color helpers, colormap/UI helpers, and paintColumnPixels per
Task 2's ledger requirement) move to a new leaf module,
spectrogram-render-utils.ts, out from under fft.ts's blanket @ts-nocheck;
fft.ts is trimmed to just the legacy ES5 FFT constructor. Deletes the dead
dense filter-bank trio (createFilterBank/applyFilterBank/
createFilterBankForScale) and its test coverage, replacing the one
dense-vs-sparse comparison test that depended on it with a narrower
real-spectrum sanity check on the sparse path.

R14: type renderer.ts's scrollStream/dragStream fields (ScrollStream,
Signal<DragEvent | null>) instead of any; widen createDragStream's element
param from HTMLElement to Element so envelope.ts's SVG drag targets no
longer need `as unknown as HTMLElement` casts.

R18 leftovers: delete renderer.ts's write-only resizeObserver field (never
read; its disconnect is already scope-owned); unify envelope.ts's Polyline
class onto its own Scope instead of a hand-rolled subscriptions array run
alongside it.

R12: comment sweep across src/** removing dangling references to
uncommitted plan/task/report artifacts (.superpowers/sdd/** is gitignored)
while preserving the underlying invariants inline. Also fixes one
pre-existing wrong cross-file doc reference in spectrogram-setup.ts found
while editing the same sentence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* test(spectrogram): independent filter-bank oracle; drop plan-citation comments

Review fix round 1 on the debt-sweep commit: the sparse filter bank's
correctness oracle was lost when the dense-vs-sparse byte-identical
comparison test was deleted alongside the dead dense functions — the
replacement only asserted finite/sized output, and the frequencies oracle
test exercises the same production sparse implementation, not an
independent one.

Restores independence two ways in spectrogram-render-utils.test.ts:
- An analytic invariant per filter: weightLo + weightHi === 1, and linear
  interpolation (hzLow*weightLo + hzHigh*weightHi) reconstructs centerHz,
  with hzLow/hzHigh/scale recomputed fresh in the test rather than read off
  any production internal. Catches a regression in the bin index, weight
  formula, or scale divisor, independent of whether centerHz itself is
  "right" in absolute terms.
- Golden-value baselines: literal expected {lo, weightLo, weightHi,
  centerHz} arrays for three (scale, numFilters, fftSamples, sampleRate)
  configs, captured now while the math is known-good and hardcoded as a
  frozen baseline (not re-derived at test time). Unlike the analytic
  invariant, this also covers the underlying hzToMel/hzToBark/hzToErb
  formulas themselves.

Also drops the two `(R16)` plan-citation comments in fft.ts and
spectrogram-render-utils.ts flagged by review, plus every other `(R\d+)`
citation found in a src/ sweep (scroll-stream.test.ts, envelope.ts,
renderer.test.ts, wavesurfer.test.ts) — same class of dangling-artifact
reference R12 targeted, the grep just missed the R-number pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* test: consolidate fixtures, delete tautologies, add missing lifecycle cases

R9: rewrite/delete the memory-leaks.test.ts cases that asserted nothing real
(spy-asserts-itself, toBeDefined-on-always-defined, not-called-when-nothing-emits)
against actual observables (Scope.disposed, EventEmitter listener maps, real
media-event tracking) or drop them where already subsumed by other cases in
the file.

R17: pull shared fixtures duplicated across 6-13 spectrogram/plugin test files
into src/__tests__/helpers/ (createEmitter, a typed FakeWaveSurfer + factory,
an AudioBuffer factory + global stub installer, a matchMedia stub installer),
and move the virtual web-worker mock into jest.config.js's moduleNameMapper
(shared by both the default and leaks projects) instead of seven near-identical
per-file jest.mock(..., { virtual: true }) blocks. Excluded helpers/ from Jest's
default testMatch via testPathIgnorePatterns so it isn't picked up as an
(empty) test suite; already covered by the existing src/**/__tests__ tsconfig
exclude for dist emission.

Added the five missing lifecycle cases: unregisterPlugin (destroy + scope
cleanup), registerPlugin after ws.destroy() (pins current behavior: it
re-registers cleanly on the fresh post-destroy scope), Scope.child() on an
already-disposed parent, batch() with a throwing body (still flushes before
rethrowing) and nested batch() (single flush at the outermost call), and
FrameScheduler restart after stop().

No production code changed; every migrated file's existing assertions are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* test(core): pin dormant-until-reload semantics for post-destroy registration

Review fix (round 1): the registerPlugin-after-destroy() test's comment
overstated coherence, claiming a post-destroy registration behaves "exactly
like a freshly-constructed instance." False: registerPlugin() itself
succeeds structurally (touches only this.scope/this.plugins, neither left
disposed by destroy()), but the media-event -> public-event forwarding
bridge (initPlayerEvents(), gated by coreEventsInitialized) stays dormant
until the NEXT load() call runs ensureCoreEvents() at its top - a real
window where a post-destroy-registered plugin exists but receives nothing.

Strengthened the test to demonstrate both halves of that boundary: dispatch
a real timeupdate on the media element right after destroy() + registration
(asserts WaveSurfer's public 'timeupdate' does NOT fire - dormant), then
load() to revive the bridge and dispatch again (asserts it DOES fire).
Rewrote the leading comment to state the dormant-until-reload semantics
precisely instead of the false "like a fresh instance" claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* docs: post-refactor cleanup complete

Final gate for chore/post-refactor-cleanup: all gates green (563 jest
tests, 7/7 leak tests, tsc --noEmit clean, eslint clean, full build incl.
verify-exports clean); grep sweeps from Tasks 1-6 re-run clean (zero
R-number/plan-citation comments, zero timer.ts references, single
calculateScrollPercentages, dist/__tests__ absent post-build).

Close out the roadmap doc with the cleanup phase's summary. Backfill
CHANGELOG.md (written in Task 4, before Tasks 5-6) with two
consumer-visible dist/*.js wildcard export changes that were missing:
Timer's deletion (dist/timer.js no longer built) and fft.ts's split
(dist/fft.js now exports only FFT; scale/color/UI helpers moved to the
new dist/spectrogram-render-utils.js, dead dense filter-bank functions
deleted outright).

Note: commit signed with --no-gpg-sign -- no ssh-agent socket available
in this environment; controller should re-sign before push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

* fix: final review wave — same-duration reuse re-tracking, idempotent refresh, changelog completeness, beta publish tag

Fixes all 5 findings from the final whole-branch review of
chore/post-refactor-cleanup:

1. (Critical) Renderer.visibleRange's auto-tracked computed stayed
   subscribed to the disposed pre-destroy scrollStream.percentages forever
   after a destroy() -> render() reuse with the SAME audio duration, since
   audioDuration.set(duration) no-ops via Object.is when the duration is
   unchanged (the most common reuse: reloading the same track) and nothing
   else told the computed to recompute and re-subscribe. Added a
   streamEpoch signal, bumped in initEvents() every time a new scrollStream
   is created and read inside visibleRange's computed body purely to force
   it to be a tracked dependency -- guarantees the recompute fires on every
   stream rebuild independent of whether audioDuration itself changed.
   Regression test: render(dur=42) -> destroy() -> render(dur=42) -> stub
   metrics + dispatch scroll -> getVisibleRange() must react.

2. (Important) ScrollStream.refresh() called scrollData.set() with a fresh
   object literal unconditionally, so signal.set()'s Object.is check always
   saw two distinct objects and always notified -- meaning the Renderer's
   public 'scroll' event fired on every render()/resize even when scroll
   metrics were unchanged, and any scroll handler that re-renders had a
   recursion hazard. refresh() now field-compares scrollLeft/scrollWidth/
   clientWidth against the current value and only sets on a genuine change.
   Verified the existing R1 truthfulness tests ('visibleRange is truthful
   right after render...', 'zoom() updates visibleRange via the same
   refresh path') still pass -- both exercise a real metrics change from
   construction-time zeros.

3. (Important) CHANGELOG gaps closed with a new "Changed" section: the
   exports-map narrowing from "./dist/*" to "./dist/*.js", dist/types.d.ts
   no longer being built, createDragStream's HTMLElement -> Element
   widening, and ScrollStream gaining refresh().

4. (Minor) Added publishConfig.tag = "beta" to package.json so a plain
   `npm publish` of 8.0.0-beta.1 tags it "beta" instead of clobbering the
   "latest" dist-tag.

5. (Minor) Added a CHANGELOG "Known limitations" section documenting the
   windowed spectrogram's request-side segment-boundary Math.floor rounding
   hazard (previously only noted in a test comment) and the dist/*.min.js
   missing-.d.ts gap; scripts/verify-exports.cjs now has a knownGap-flagged
   case for the latter that prints as an explicit SKIP line in `npm run
   build`'s verify-exports output instead of silently passing or failing
   the build.

Gates green: bare jest (565/565), test:leaks (7/7), tsc --noEmit, eslint,
full build (clean + tsc + rollup + verify-exports, SKIP line confirmed in
output) -- all clean.

Not signed: no ssh-agent socket available in this sandbox (ssh-add -l
failed to reach the agent) -- committed with --no-gpg-sign; needs
re-signing by the controller before push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018m5srLP9Jps5svV6NA1XeB

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Assets 2
Loading