diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index d5c5335..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-version: 2
-jobs:
- build:
- working_directory: ~/repo
- docker:
- - image: circleci/node:latest-browsers
- steps:
- - checkout
- - restore_cache:
- name: Restore npm Package Data Cache
- key: dependency-cache-{{ checksum "package.json" }}
- - run:
- name: Install npm
- command: npm install
- - save_cache:
- name: Cache npm Package Data
- key: dependency-cache-{{ checksum "package.json" }}
- paths:
- - ./node_modules
- - run:
- name: Lint Source
- command: npm run lint
- - run:
- name: Run Unit Test
- command: npm run unit-test
- - run:
- name: Run Functional Test
- command: npm run func-test -- --single-run
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b620587
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node-version: [22, 24]
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm exec playwright install --with-deps chromium-headless-shell
+ - run: pnpm lint
+ - run: pnpm unit-test
+ - run: pnpm func-test
+ - run: pnpm build
+ - run: pnpm smoke-test
diff --git a/.npmignore b/.npmignore
deleted file mode 100644
index b655a0d..0000000
--- a/.npmignore
+++ /dev/null
@@ -1,2 +0,0 @@
-examples
-test
\ No newline at end of file
diff --git a/.oxlintrc.json b/.oxlintrc.json
new file mode 100644
index 0000000..a5952fb
--- /dev/null
+++ b/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["typescript", "unicorn", "oxc"],
+ "rules": {
+ // unused params mostly match interface or override signatures
+ "no-unused-vars": ["warn", { "args": "none" }]
+ }
+}
diff --git a/.scratch/modernize/issues/01-baseline.md b/.scratch/modernize/issues/01-baseline.md
new file mode 100644
index 0000000..451128c
--- /dev/null
+++ b/.scratch/modernize/issues/01-baseline.md
@@ -0,0 +1,25 @@
+# 01: Baseline: green from a clean checkout, CI on GitHub Actions
+
+**What to build:** A clean clone of `master` installs with one command and passes lint, unit and func tests on the existing webpack 3 / karma toolchain, and GitHub Actions checks every PR. See the spec's "PR 1: Baseline" and the Phase 0 baseline under Testing Decisions.
+
+**Blocked by:** None (can start immediately)
+
+**Status:** ready-for-human
+
+- [x] Lockfile regenerated so a fresh install succeeds without `--no-package-lock`. Dependencies pinned where Phase 0 found drift: early-2018 @types set, pegjs-loader 0.5.4, tslint 5.9.1
+- [x] Func tests default to a headless Chrome launcher with the SwiftShader flags and get WebGL
+- [x] Mocha timeout for func tests is 10s
+- [x] Locally from a clean clone: lint passes, unit 6/6, func 60/60. Thresholds and reference PNGs unchanged
+- [ ] A GitHub Actions workflow runs lint, unit and func on Node 16 with Chrome and passes on the PR
+- [x] CircleCI config deleted
+- [x] Hand-written `gitHead` removed from package metadata
+
+## Comments
+
+**Agent, 2026-11-01:** Implemented; only the CI checkbox is open, it needs a push and a green PR run.
+
+- Transitive @types pins (node, lodash, glob, normalize-package-data) are done via npm `overrides`.
+- Added `browserNoActivityTimeout: 60000` in karma.conf.js: karma's 10s default killed the browser during the slow DynamicMovement cases. Not in the spec.
+- Local func: 60/60 on 2 of 3 runs. The failing run was DynamicMovement hitting its own `this.timeout(10000)` at load average ~30 on 10 cores.
+- CI risks: headless Chrome on ubuntu-24.04 may need `--no-sandbox`; DynamicMovement (500 frames on SwiftShader) may be slow on the runner.
+- Convolution.test.ts sets `this.timeout(5000)`, which overrides the new 10s default. Left unchanged.
diff --git a/.scratch/modernize/issues/02-typescript-6.md b/.scratch/modernize/issues/02-typescript-6.md
new file mode 100644
index 0000000..8e2b636
--- /dev/null
+++ b/.scratch/modernize/issues/02-typescript-6.md
@@ -0,0 +1,19 @@
+# 02: TypeScript 6 on the existing toolchain
+
+**What to build:** The codebase compiles with TypeScript 6 and an es2022 lib on the existing webpack 3 / karma toolchain, so later tickets can use modern native methods. See the spec's "PR 2: TypeScript 6".
+
+**Blocked by:** 01 (Baseline)
+
+**Status:** resolved
+
+- [x] TypeScript 6 installed. The TS config sets explicitly: `strict` off, lib dom + es2022, emit target es2017, module commonjs, types mocha + node
+- [x] eventemitter3 imported as a default import, so the base model's emitter type-checks under always-on interop
+- [x] The image-loading error callback in the resource manager is typed as returning a boolean, matching how it's used
+- [x] Unit-test mocks injected via inject-loader are shaped as ES modules, so default imports resolve
+- [x] Type check reports 0 errors
+- [x] Lint, unit 6/6 and func 60/60 pass locally and in CI. Thresholds and PNGs unchanged
+- [x] Dev and CI stay on Node 16. The legacy OpenSSL flag is added only if the toolchain needs it
+
+## Comments
+
+**Agent, 2026-11-01:** Implemented. Local on Node 16.20.1: tsc 0 errors, lint passes, unit 6/6, func 60/60, no OpenSSL flag needed. Also needed beyond the spec: `@types/mocha` 5.2.7 and the `@types/node` override bumped to 16.18.126 (the 2018 typings don't parse under TS 6), `require.context` cast in the func test index, a seedrandom default import in the Mirror test (it failed at runtime under interop), and the WebGL-only `preserveDrawingBuffer` dropped from a 2d `getContext` call. CI passed after the push.
diff --git a/.scratch/modernize/issues/03-replace-lodash.md b/.scratch/modernize/issues/03-replace-lodash.md
new file mode 100644
index 0000000..a85b8ea
--- /dev/null
+++ b/.scratch/modernize/issues/03-replace-lodash.md
@@ -0,0 +1,13 @@
+# 03: Replace lodash-es with native code
+
+**What to build:** webvs no longer depends on lodash-es. Every call, including the ones inside the expression grammar, becomes plain native JavaScript, and rendering stays identical. See the spec's "PR 3: Replace lodash-es".
+
+**Blocked by:** 02 (TypeScript 6)
+
+**Status:** resolved
+
+- [x] No lodash import or require remains in source, grammar or tests
+- [x] Replacements are native equivalents, not a home-grown lodash-style utility layer. `uniqueId` becomes a module-local counter, `flow`/`partialRight` become plain composition
+- [x] lodash-es and its @types removed from package metadata and lockfile
+- [x] Func 60/60 with thresholds and PNGs unchanged. Unit 6/6. Lint passes
+- [x] CI green
diff --git a/.scratch/modernize/issues/04-emitter-and-stats.md b/.scratch/modernize/issues/04-emitter-and-stats.md
new file mode 100644
index 0000000..ec40af4
--- /dev/null
+++ b/.scratch/modernize/issues/04-emitter-and-stats.md
@@ -0,0 +1,14 @@
+# 04: Minimal emitter and pinned stats.js
+
+**What to build:** eventemitter3 is replaced by a minimal emitter in the base model, so consumers keep `on`, `addListener`, `removeListener` and `emit` with the same signatures. stats.js is pinned to a real version range. See the spec's "PR 4: Emitter and stats.js".
+
+**Blocked by:** 02 (TypeScript 6)
+
+**Status:** resolved
+
+- [x] The base model provides exactly `on`, `addListener`, `removeListener` and `emit`, backed by a map of event name to a set of callbacks. Signatures are compatible with eventemitter3 for those four
+- [x] `listenTo` and its cleanup counterpart work against the new emitter. `Main`, `Container`, `Component` and `ResourceManager` need no API change
+- [x] eventemitter3 removed from package metadata and lockfile
+- [x] stats.js pinned to `^0.17.0`
+- [x] Func 60/60 with thresholds and PNGs unchanged (this covers resize and resource wait/ready events end to end). Unit 6/6. Lint passes
+- [x] CI green
diff --git a/.scratch/modernize/issues/05-peggy.md b/.scratch/modernize/issues/05-peggy.md
new file mode 100644
index 0000000..5dd6c27
--- /dev/null
+++ b/.scratch/modernize/issues/05-peggy.md
@@ -0,0 +1,13 @@
+# 05: peggy with a committed parser
+
+**What to build:** The expression grammar is compiled by peggy instead of pegjs. The generated parser is committed and imported as a plain module, so no build step needs a grammar loader. See the spec's "PR 6" notes on peggy, pulled forward.
+
+**Blocked by:** 03 (Replace lodash-es), because the grammar must be lodash-free first
+
+**Status:** resolved
+
+- [x] pegjs and pegjs-loader removed. peggy added as a dev dependency
+- [x] Generated parser committed. The expression compiler imports it directly, with no loader rule
+- [x] A `gen:grammar` script regenerates the parser from the grammar. Re-running it on an unchanged grammar produces no diff
+- [x] Still builds and tests on webpack 3: func 60/60, unit 6/6, lint passes
+- [x] CI green
diff --git a/.scratch/modernize/issues/06-vitest.md b/.scratch/modernize/issues/06-vitest.md
new file mode 100644
index 0000000..89716d3
--- /dev/null
+++ b/.scratch/modernize/issues/06-vitest.md
@@ -0,0 +1,18 @@
+# 06: vitest for unit and func tests
+
+**What to build:** Unit and func tests run on vitest. The WebGL pixel tests run in browser mode on headless Playwright Chromium with SwiftShader, the old karma / mocha-webpack stack is gone, and development moves to a current Node. See the spec's "PR 5: vitest".
+
+**Blocked by:** 03 (Replace lodash-es), 04 (Minimal emitter and pinned stats.js), to keep the code-first order
+
+**Status:** resolved
+
+- [x] Unit tests run under vitest with `vi.fn` / `vi.mock`. typemoq, ts-mockito, inject-loader, chai and mocha-webpack removed
+- [x] Func tests run in vitest browser mode on Playwright Chromium, headless, with the SwiftShader flags. Reference PNGs and resources served as static files. Test discovery uses vitest globs, not the webpack context index
+- [x] `testTimeout` 10s
+- [x] Func 60/60 with thresholds and reference PNGs unchanged. Any change needs a written reason in the PR
+- [x] karma and its plugins removed
+- [x] Dev on Node ≥22. CI runs lint, unit and func on Node 22 and 24, and passes
+
+## Comments
+
+**Agent, 2026-11-01:** Implemented. Local on Node 24.20.0: tsc 0 errors outside the stale, gitignored `dist/`, lint passes, unit 9/9, func 60/60. Thresholds and PNGs unchanged. vitest 5.0.5, @vitest/browser-playwright 5.0.5, playwright 1.64.1. Also needed beyond the spec: `@types/node` 22.20.5 replaces the Node 16 override (vitest's peer range), the config is `vitest.config.mts` because the package isn't ESM yet, mocha's `before`/`after` became `beforeAll`/`afterAll`, and the per-suite `this.timeout` calls were dropped in favour of the global 10s (Convolution goes from 5s to 10s). Unit mocks use a small Proxy `autoMock` in place of typemoq's dynamic mocks. webpack 3 still builds on Node 24 without the legacy OpenSSL flag. vitest 5 requires Node ≥22.12, which enforces "dev on Node ≥22" until ticket 07 adds `engines`. CI passed on Node 22 and 24 after the push.
diff --git a/.scratch/modernize/issues/07-tsdown-esm-cjs.md b/.scratch/modernize/issues/07-tsdown-esm-cjs.md
new file mode 100644
index 0000000..9855ae9
--- /dev/null
+++ b/.scratch/modernize/issues/07-tsdown-esm-cjs.md
@@ -0,0 +1,18 @@
+# 07: tsdown build: ESM + CJS only (4.0.0 break)
+
+**What to build:** The package is built by tsdown as ESM + CJS with type declarations, and the `window.Webvs` global build is gone, per the ADR on ESM + CJS only. npm consumers keep `import Webvs from 'webvs'` and can also use named imports. See the spec's "PR 6".
+
+**Blocked by:** 05 (peggy with a committed parser), 06 (vitest for unit and func tests)
+
+**Status:** resolved
+
+- [x] tsdown builds the public entry point to ESM and CJS with type declarations, on a neutral platform. The resource pack URL (defaulting to unpkg for the matching version) and the library version are injected via define
+- [x] The default-export object (`Main`, `Component`, `ShaderProgram`, `WebAudioAnalyser`, `AnalyserAdapter`) is unchanged. The same five are also named exports
+- [x] Package metadata: `"type": "module"`, an `exports` map (import / require / types), `files` limited to build output plus resources, `engines` node ≥22. Old `main` and `readmeFilename` removed. `.npmignore` deleted
+- [x] webpack, ts-loader, webpack-serve, cross-env, shx, npm-run-all and typedoc (with its docs script) removed
+- [x] Package smoke check passes: `require('webvs')` and `import('webvs')` both expose `Main` via default and named exports, type declarations resolve, and the pack listing contains only build output and resources
+- [x] Func 60/60 with thresholds and PNGs unchanged. Unit 6/6. Lint passes. CI green, including the smoke check
+
+## Comments
+
+**Agent, 2026-11-01:** Implemented. CI passed on Node 22 and 24 after the push. Local on Node 24.20.0: tsc 0 errors, lint passes, unit 9/9, func 60/60, thresholds and PNGs unchanged. tsdown 0.23.1 pinned (newest release at least two weeks old). The build emits `dist/index.{js,cjs,d.ts,d.cts}`, with `stats.js` left external. `outputOptions.exports: "named"` stops the CJS mixed-exports warning; `require('webvs')` exposes both `.default` and the named exports. The smoke check (`npm run smoke-test`, run after `npm run build`) resolves the package through its own exports map (self-reference) for both `require` and `import`. It type-checks an ESM fixture and a CJS `.cts` fixture against `index.d.ts` and `index.d.cts`, and asserts the `npm pack` listing has only `dist/`, `resources/` and the files npm always includes. CI gains build + smoke steps. The `dev` and `clean` scripts went with webpack-serve and shx. Ticket 08 restores a dev server via vite. `version` is still 3.0.0, so the baked-in resource URL points at `webvs@3.0.0` until the release bump.
diff --git a/.scratch/modernize/issues/08-examples-readme.md b/.scratch/modernize/issues/08-examples-readme.md
new file mode 100644
index 0000000..9be0503
--- /dev/null
+++ b/.scratch/modernize/issues/08-examples-readme.md
@@ -0,0 +1,14 @@
+# 08: Examples and README on the module build
+
+**What to build:** The examples page runs straight from source through a vite dev server, and the README shows CDN users how to load webvs as an ES module now that the global build is gone.
+
+**Blocked by:** 07 (tsdown build: ESM + CJS only)
+
+**Status:** resolved
+
+- [x] The examples' test page is an ES module importing the library from source. A dev script serves it via vite, and the preset files stay plain scripts
+- [x] Manual check: a preset renders in the browser and the `rsrcMan.on('ready')` listener fires (record the result in the PR)
+- [x] The README's CDN section uses a `
-
-or for a minified version
+```js
+import { Main, WebAudioAnalyser } from '@visbot/webvs';
+const webvs = new Main(....)
+```
-
+Both `import` and `require` are supported.
-This script exposes a `Webvs` global which you can use to initialize like so `new Webvs.Main(...)`.
+Builtin resources (images used by some presets) load from `resources/` relative to the page: serve the package's `resources/` folder next to your page, or pass `resourcePrefix` to `Main`.
## Usage
A typical usage involves creating an Analyser from your music source (e.g. [Webvs.WebAudioAnalyser](http://azeemarshad.in/webvs/classes/webaudioanalyser.html) to use an `audio` tag or song url as a source) and initializing an instance of `Webvs.Main` with it. The main object serves as the primary interface or controlling the visualization. e.g.
@@ -75,13 +73,13 @@ analyser.play();
To develop Webvs clone the repo and install packages
- npm install
+ pnpm install
For typical workflow, start a dev server with
- npm run dev
+ pnpm dev
-and point browser to `http://localhost:8080/examples/` to view webvs test page.
+It serves the webvs test page at `http://localhost:5173/examples/` straight from the source, no build needed.
## Contributing
diff --git a/docs/adr/0001-esm-cjs-only.md b/docs/adr/0001-esm-cjs-only.md
new file mode 100644
index 0000000..af10fbe
--- /dev/null
+++ b/docs/adr/0001-esm-cjs-only.md
@@ -0,0 +1,7 @@
+# Ship ESM and CJS only, no `window.Webvs` global build
+
+From 4.0.0, webvs publishes only ESM and CJS builds; the `dist/webvs.js` / `webvs.min.js` script-tag bundles that exposed a `window.Webvs` global are gone. No known consumer used the global (react-webvs-player and svelte-webvs-player both `import Webvs from 'webvs'`); it only served the README's unpkg `
-
-
-
-
-
-
-
-
+
+
+
+
+
+