>;
+ notify: (input: { type: string; payload: P; priority?: NotificationPriority }) => string; // local -> SAME funnel
+ toast: ToastSugar; // toast.success/error/dismiss -> built-in '__toast' type
+ markRead: (id: string) => void; markAllRead: () => void;
+ dismissToast: (id: string) => void; archive: (id: string) => void;
+ runAction: (id: string, actionId: string) => Promise;
+ openModel: (id: string) => Ref;
+}
+export function useNotifications(): UseNotificationsReturn; // full surface
+export function useNotificationCenter(): UseNotificationCenterReturn; // inbox/groups/filter/markAllRead
+export function useUnreadCount(): Readonly>; // cheap badge — does NOT pull the whole store
+```
+
+End-to-end (a Nuxt plugin / app entry):
+
+```ts
+// plugins/notifications.client.ts
+import { createNotificationRegistry, appProvideNotificationRegistry, registerNotificationTypes,
+ useSharedRealtime } from '@robonen/vue';
+import { createWebSocketTransport } from '@robonen/realtime';
+import { authPlugin, jsonCodec, heartbeatPlugin, resumePlugin, loggerPlugin } from '@robonen/realtime/plugins';
+
+export default defineNuxtPlugin((nuxtApp) => {
+ const registry = createNotificationRegistry();
+ registerNotificationTypes([friendRequest, systemAlert, message]); // bootstrap, once
+
+ // CORRECTED: use appProvide at the plugin layer (component-level `provide` only works inside setup)
+ appProvideNotificationRegistry(nuxtApp.vueApp)(registry);
+
+ useSharedRealtime({
+ transport: createWebSocketTransport(),
+ url: () => `wss://api.example.com/notifications?u=${useUserId().value}`,
+ plugins: [authPlugin(getToken), jsonCodec, heartbeatPlugin(), resumePlugin(), loggerPlugin(console.log)],
+ backoff: { base: 500, cap: 30_000, jitter: true },
+ });
+});
+```
+
+```vue
+
+
+
+
+
+
+
+
+🔔 {{ unread.value }}
+```
+
+```ts
+// anywhere: local notification + imperative toast (SAME ingest funnel)
+const { notify, toast, runAction } = useNotifications();
+notify({ type: 'message', payload: { threadId: 't1', from: 'Ana', text: 'hi' } });
+toast.success('Saved', { description: 'Your changes are live.' });
+toast.error('Upload failed', { actions: [{ id: 'retry', label: 'Retry', altText: 'Retry upload' }] });
+await runAction(friendReqId, 'accept'); // optimistic -> WS action -> ack commit / timeout rollback
+```
+
+---
+
+## 9. SSR, testing, tree-shaking
+
+**SSR.** `core/realtime` imports nothing from Vue and touches `WebSocket`/`BroadcastChannel`/timers only
+after `connect()`. `useRealtime` returns `ssrNoopRealtime()` when `!isClient`. `useStorage` defers with
+`initOnMounted`. `useSharedRealtime` (`createSharedComposable`) returns a fresh raw instance on the
+server. The unread badge renders only after `onMounted`. `` lives under ``.
+
+**Testing** (house style: `describe(fnName)`, `effectScope()` disposal, `MockTransport`, fake registry):
+
+```ts
+// store.test.ts
+describe('createNotificationStore', () => {
+ it('surfacing interrupt evicts lowest toast back to inbox, never deletes', () => {
+ const scope = effectScope();
+ scope.run(() => {
+ const store = createNotificationStore({ registry: fakeRegistry, maxToasts: 1 });
+ store.commit(ingest(frame('a', { priority: Priority.low })), 'ingest');
+ store.commit(ingest(frame('b', { priority: Priority.high })), 'ingest'); // interrupts
+ expect(store.toasting.value.map(n => n.id)).toEqual(['b']);
+ expect(store.entities.value.get('a')?.lifecycle).toBe('queued'); // demoted, still present
+ });
+ scope.stop();
+ });
+
+ it('hydration-commit never toasts (cross-tab no-re-toast)', () => {
+ const store = createNotificationStore({ registry: fakeRegistry });
+ store.commit(ingest(frame('x', { priority: Priority.critical })), 'hydration');
+ expect(store.toasting.value).toHaveLength(0); // inbox only
+ expect(store.unreadCount.value).toBe(1);
+ });
+
+ it('two-key dedup folds identical content into meta.count', () => { /* same contentHash, distinct id -> count=2 */ });
+ it('disposes timers/subscriptions on scope stop', () => { /* spy + effectScope().stop() */ });
+});
+
+// machine.test.ts — drive via await machine.send(event)
+describe('buildLifecycleMachine', () => {
+ it('NET_OFFLINE pauses retries without scheduling RETRY', async () => {
+ const m = buildLifecycleMachine(depsOffline);
+ await m.send('CONNECT'); await m.send('OPENED'); await m.send('NET_OFFLINE');
+ expect(depsOffline.scheduleRetry).not.toHaveBeenCalled();
+ });
+ it('cursor advances ONLY on contiguous delivery', () => {
+ const rb = createReorderBuffer({});
+ expect(rb.accept(frame('a', { seq: 1 })).map(f => f.seq)).toEqual([1]); expect(rb.cursor).toBe('1');
+ expect(rb.accept(frame('c', { seq: 3 }))).toEqual([]); expect(rb.cursor).toBe('1'); // gap held
+ expect(rb.accept(frame('b', { seq: 2 })).map(f => f.seq)).toEqual([2, 3]); expect(rb.cursor).toBe('3'); // drains
+ });
+ it('drops duplicates by id and seq<=lastSeq', () => { /* idempotent at-least-once */ });
+});
+```
+
+Plus explicit tests for **seq-gap-then-reconnect** (assert no loss/dup), **storage-event toast
+suppression**, **bounded-LRU dedup eviction**, and **leader handoff on abrupt tab close**.
+
+**Tree-shaking.** Per-category barrels (`composables/notifications/index.ts` → `composables/index.ts` →
+`src/index.ts`). Registry descriptors are **side-effect-free**; only the `defineNotificationType`s a
+consumer imports + `register()`s ship. Realtime plugins are separate modules under
+`@robonen/realtime/plugins`. `useUnreadCount` imports only the badge selector.
+
+---
+
+## 10. Build order / milestones
+
+| # | Milestone | Deliverables | Gate |
+|---|---|---|---|
+| **M1** | Transport core skeleton | `core/realtime`: `Transport` iface, `createWebSocketTransport`, `envelope.ts`, `MockTransport`. | Connect/send/recv against a mock; JSON round-trip. |
+| **M2** | Lifecycle + reliability | `machine.ts`, `backoff.ts` (jitter, gated), `heartbeat`/`resume` plugins, `reorder.ts` (contiguous cursor + gap-timeout + LruSet), `outbound.ts` (`AsyncPool`+`Deque`+ack by clientToken). | machine/reorder/backoff tests green; seq-gap-reconnect asserts no loss. |
+| **M3** | Plugin pipeline + 2nd transport | `plugin.ts` 1:1 with `core/fetch`; `auth`/`codec`/`logger`/`compression`; ship `createSseTransport`. | SSE swaps in with zero upper-layer change. |
+| **M4** | Registry + ingest funnel | `registry.ts`, `defineNotificationType`, `ingest.ts` (pure, catches `validate` throws → `onReject`), `contentHash`. | One funnel for WS + local; malformed payload never crashes the pump. |
+| **M5** | Domain store | `store.ts`, `surfacing.ts` (victim-min PQ + guarded enqueue + `ensureCapacity`), `selectors.ts` (explicit sort), pure-reducer lifecycle, two-key dedup. | Interrupt/eviction/dedup/disposal tests green. |
+| **M6** | Persistence + cross-tab | `persistence.ts` (`useStorage` map, merge lattice, `toastedIds`), hydration-commit separation, optional `useTabLeader`+`broadcastedRef`. | Storage-event suppression + leader-handoff tests green. |
+| **M7** | Presentation | `` (drives existing toast), `` primitive, shared `surface`-prop renderers; `useNotifications`/`useNotificationCenter`/`useUnreadCount`; `notify()`/`toast.*`. | Optimistic ack/rollback + a11y verified; the 3 demo types work end-to-end. |
+
+**Hardest risks & mitigations**
+
+1. **Resume gap / cursor correctness.** Cursor advances *only* on contiguous delivery; gap-timeout
+ deliver-with-gap + a server reconciliation request for permanently-lost ranges. Test: inject gaps +
+ reconnect, assert no loss/dup.
+2. **Zombie connections (TCP half-open).** Watchdog cleared by *any* inbound frame, armed whenever
+ `connected`; conservative configurable `ping 20s`/`watchdog 10s`.
+3. **`PriorityQueue` is a min-heap.** The surfacing PQ comparator makes the *eviction victim* the
+ minimum (lowest priority, then oldest); viewport order is a **separate** display sort. `surface()`
+ `peek`/`isFull`-guards then `dequeue`-then-`enqueue`; never `toArray()` for display.
+4. **Cross-tab double-toast.** `commit()` separates ingest (may toast) from hydration (never); per-tab
+ non-persisted `toastedIds`; `useTabLeader` (Web Locks) for the optional single socket; **never**
+ `useEventBus` for cross-tab; **never** a hand-rolled lease.
+5. **Singleton arg-dropping.** Inbox/registry via `useAppSharedState` (options bound once); socket via
+ `createSharedComposable` (disposable, raw-on-SSR).
+6. **Optimistic double-apply.** Idempotent server commands keyed by `clientToken` (the one canonical
+ correlation key); ack-after-timeout ignored via token compare; do **not** rollback on mid-action
+ socket drop; eviction skips `pendingAction`/`surfaced`.
+7. **Persistence schema.** Persist only committed lifecycle + read/dismiss receipts + cursor (exclude
+ runtime fields); Map serializer; validate-on-hydrate, drop+log unknown-type entities. Whole-Map LWW
+ is the multi-tab durability limit — opt into leader single-writer for strictness.
+8. **Durable-Deque overflow.** `pushBack` throws at `maxSize`, so `ensureCapacity()` runs *before* every
+ insert and force-evicts the oldest non-active entity if all candidates are protected.
+9. **SSR hydration mismatch.** Badge/connection UI render only after mount; `` under
+ ``; `isClient` guards throughout.
+
+---
+
+## Appendix — review corrections folded into this document
+
+This design was adversarially verified against the real repo source. Corrections already applied above:
+
+1. **PriorityQueue orientation** — it's a min-heap; the surfacing comparator now makes the eviction
+ victim the minimum, and the viewport uses a separate display sort (§5.2, §5.3).
+2. **Registry provisioning** — the Nuxt plugin uses `appProvide(nuxtApp.vueApp)` instead of
+ component-level `provide` (§6, §8).
+3. **Bounded LRU dedup** — `seen` is a real bounded `LruSet`, not an unbounded `Set` (§4.5).
+4. **Backoff** — `backoffDelay` is bespoke and does not reuse stdlib `retry()` (§4.3).
+5. **Ack correlation** — one canonical key (`clientToken`) end-to-end; `AckFrame.ref` echoes it
+ (§4.1, §4.6, §5.5).
+6. **Network boundary** — the Vue `useRealtime` wrapper watches `useNetwork().isOnline` and dispatches
+ `NET_OFFLINE`/`NET_ONLINE`; `core/realtime` never imports Vue (§4.7).
+7. **Durable overflow** — explicit `ensureCapacity()` before insert (the `Deque` throws when full)
+ (§5.2).
+8. **Server contract** — the monotonic-seq / replay / reconcile / token-echo requirements are stated
+ explicitly (§4.1).
diff --git a/docs/content.config.ts b/docs/content.config.ts
new file mode 100644
index 0000000..4e560ae
--- /dev/null
+++ b/docs/content.config.ts
@@ -0,0 +1,26 @@
+import { defineCollection, defineContentConfig } from '@nuxt/content';
+
+const repositories = [
+ '../configs/tsconfig',
+ '../core/stdlib',
+ '../core/platform',
+ '../infra/renovate',
+ '../web/vue',
+];
+
+export default defineContentConfig({
+ collections: repositories.reduce((acc, repo) => {
+ const name = repo.split('/').pop();
+
+ acc[name] = defineCollection({
+ source: {
+ include: `**/*.md`,
+ exclude: ['**/node_modules/**', '**/dist/**'],
+ cwd: repo,
+ },
+ type: 'page',
+ });
+
+ return acc;
+ }, {}),
+});
diff --git a/docs/eslint.config.ts b/docs/eslint.config.ts
index f394f09..9cc100a 100644
--- a/docs/eslint.config.ts
+++ b/docs/eslint.config.ts
@@ -1,4 +1,4 @@
-import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint';
+import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, {
name: 'docs/build-scripts',
@@ -7,4 +7,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
/* Build-time tooling (doc extractor) logs progress to the console. */
'no-console': 'off',
},
-});
+}, tests);
diff --git a/docs/modules/extractor/extract.ts b/docs/modules/extractor/extract.ts
index 2145a68..19baa49 100644
--- a/docs/modules/extractor/extract.ts
+++ b/docs/modules/extractor/extract.ts
@@ -88,7 +88,7 @@ const PACKAGES: PackageConfig[] = [
{ path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' },
// ── vue ──
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' },
- { path: 'vue/editor', slug: 'editor', kind: 'api', group: 'vue' },
+ { path: 'vue/writekit', slug: 'writekit', kind: 'api', group: 'vue' },
{ path: 'vue/primitives', slug: 'primitives', kind: 'components', group: 'vue' },
// ── configs ──
{ path: 'configs/eslint', slug: 'eslint', kind: 'guide', group: 'configs', guideSources: ['README.md', 'rules/*.md'] },
@@ -98,6 +98,27 @@ const PACKAGES: PackageConfig[] = [
{ path: 'infra/renovate', slug: 'renovate', kind: 'guide', group: 'infra', guideSources: ['README.md'] },
];
+/**
+ * Display label for each category FOLDER under `src/`. Components now live at
+ * `src///`, so the folder is the source of truth for a
+ * component's category. Unlisted folders fall back to `toPascalCase(folder)`.
+ * The display order of categories lives in `useDocs` (`COMPONENT_CATEGORY_ORDER`).
+ */
+const CATEGORY_LABELS: Record = {
+ forms: 'Forms',
+ selection: 'Selection',
+ color: 'Color',
+ overlays: 'Overlays',
+ menus: 'Menus',
+ disclosure: 'Disclosure',
+ navigation: 'Navigation',
+ display: 'Display',
+ feedback: 'Feedback',
+ canvas: 'Canvas & editors',
+ utilities: 'Utilities',
+ internal: 'Internal',
+};
+
// ── Helpers ────────────────────────────────────────────────────────────────
function toKebabCase(str: string): string {
@@ -716,14 +737,14 @@ function inferCategoryFromItem(item: ItemMeta): string {
}
/** Resolve a package's export subpaths to source entry files. */
-function resolveEntryPoints(pkgDir: string, exportsField: Record): Array<{ subpath: string; filePath: string }> {
+function resolveEntryPoints(pkgDir: string, exportsField: Record): Array<{ subpath: string; filePath: string }> {
const entryPoints: Array<{ subpath: string; filePath: string }> = [];
for (const [subpath, value] of Object.entries(exportsField)) {
if (typeof value !== 'object' || value === null) continue;
- let entry: any = (value as Record).import ?? (value as Record).types;
- if (typeof entry === 'object' && entry !== null) entry = entry.types || entry.default;
+ let entry: unknown = (value as Record).import ?? (value as Record).types;
+ if (typeof entry === 'object' && entry !== null) entry = (entry as Record).types || (entry as Record).default;
if (!entry || typeof entry !== 'string') continue;
// Wildcard exports (e.g. "./*") can't be resolved to a single file here.
if (entry.includes('*')) continue;
@@ -942,75 +963,100 @@ function roleFromName(componentName: string, base: string): string {
return role || 'Root';
}
+/**
+ * Build a single component group from its directory, or `null` when the dir is
+ * not a component group (no `.vue`). `category` is the display label; `entryPoint`
+ * is the package subpath (e.g. `./forms/checkbox`).
+ */
+function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
+ // A component group is any dir that ships at least one .vue file.
+ const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
+ if (vueFiles.length === 0) return null;
+
+ const base = toPascalCase(slug);
+
+ // Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
+ // excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
+ // that aren't part of the public API. Fall back to all .vue (minus demo) only
+ // when the barrel exposes no parseable `export { default as X }`.
+ const order = readPartOrder(resolve(dir, 'index.ts'));
+ const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
+ const candidates = publicFiles.length > 0
+ ? publicFiles
+ : vueFiles.filter(f => f !== 'demo.vue');
+ // Drop internal implementation/variant parts users never compose directly
+ // (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
+ const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
+ const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
+
+ const parts: ComponentPartMeta[] = [];
+ let groupDescription = '';
+
+ for (const file of orderedFiles) {
+ const sfc = readFileSync(resolve(dir, file), 'utf-8');
+ const plain = extractScriptBlock(sfc, false);
+ const setup = extractScriptBlock(sfc, true);
+ const { props, description } = extractPartProps(plain);
+ const name = file.replace(/\.vue$/, '');
+ const role = roleFromName(name, base);
+ if (role === 'Root' && description && !groupDescription) groupDescription = description;
+
+ // Merge in `defineModel` v-model props/emits (invisible to the interface/
+ // defineEmits parsers), de-duping against any explicitly-declared ones.
+ const models = extractModels(setup);
+ const emits = extractEmits(setup);
+ for (const mp of models.props)
+ if (!props.some(p => p.name === mp.name)) props.push(mp);
+ for (const me of models.emits)
+ if (!emits.some(e => e.name === me.name)) emits.push(me);
+
+ parts.push({ name, role, description, props, emits });
+ }
+
+ return {
+ name: base,
+ slug,
+ category,
+ description: groupDescription,
+ entryPoint,
+ parts,
+ hasDemo: existsSync(resolve(dir, 'demo.vue')),
+ demoSource: '', // loaded lazily client-side via #docs/demo-sources
+ sourcePath: relative(ROOT, dir),
+ };
+}
+
function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return [];
const components: ComponentMeta[] = [];
- for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- const dir = resolve(srcDir, entry.name);
+ // Components live one level deep, in category folders: src///.
+ // The category folder IS the source of truth for the component's category.
+ for (const catEntry of readdirSync(srcDir, { withFileTypes: true })) {
+ if (!catEntry.isDirectory()) continue;
+ const catDir = resolve(srcDir, catEntry.name);
+ const label = CATEGORY_LABELS[catEntry.name];
- // A component group is any dir that ships at least one .vue file.
- const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
- if (vueFiles.length === 0) continue;
-
- const slug = entry.name;
- const base = toPascalCase(slug);
-
- // Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
- // excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
- // that aren't part of the public API. Fall back to all .vue (minus demo) only
- // when the barrel exposes no parseable `export { default as X }`.
- const order = readPartOrder(resolve(dir, 'index.ts'));
- const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
- const candidates = publicFiles.length > 0
- ? publicFiles
- : vueFiles.filter(f => f !== 'demo.vue');
- // Drop internal implementation/variant parts users never compose directly
- // (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
- const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
- const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
-
- const parts: ComponentPartMeta[] = [];
- let groupDescription = '';
-
- for (const file of orderedFiles) {
- const sfc = readFileSync(resolve(dir, file), 'utf-8');
- const plain = extractScriptBlock(sfc, false);
- const setup = extractScriptBlock(sfc, true);
- const { props, description } = extractPartProps(plain);
- const name = file.replace(/\.vue$/, '');
- const role = roleFromName(name, base);
- if (role === 'Root' && description && !groupDescription) groupDescription = description;
-
- // Merge in `defineModel` v-model props/emits (invisible to the interface/
- // defineEmits parsers), de-duping against any explicitly-declared ones.
- const models = extractModels(setup);
- const emits = extractEmits(setup);
- for (const mp of models.props)
- if (!props.some(p => p.name === mp.name)) props.push(mp);
- for (const me of models.emits)
- if (!emits.some(e => e.name === me.name)) emits.push(me);
-
- parts.push({ name, role, description, props, emits });
+ if (label) {
+ // A known category folder — each child dir is a component group.
+ for (const compEntry of readdirSync(catDir, { withFileTypes: true })) {
+ if (!compEntry.isDirectory()) continue;
+ const c = buildComponentAt(
+ resolve(catDir, compEntry.name),
+ compEntry.name,
+ label,
+ `./${catEntry.name}/${compEntry.name}`,
+ );
+ if (c) components.push(c);
+ }
+ }
+ else {
+ // Backward-compat: a flat component dir directly under src.
+ const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
+ if (c) components.push(c);
}
-
- const entryPoint = `./${slug}`;
- const demoPath = resolve(dir, 'demo.vue');
- const hasDemo = existsSync(demoPath);
-
- components.push({
- name: base,
- slug,
- description: groupDescription,
- entryPoint,
- parts,
- hasDemo,
- demoSource: '', // loaded lazily client-side via #docs/demo-sources
- sourcePath: relative(ROOT, dir),
- });
}
return components.sort((a, b) => a.name.localeCompare(b.name));
diff --git a/docs/modules/extractor/index.ts b/docs/modules/extractor/index.ts
index 36b8387..f165e9b 100644
--- a/docs/modules/extractor/index.ts
+++ b/docs/modules/extractor/index.ts
@@ -44,7 +44,7 @@ export default defineNuxtModule({
'@robonen/fetch': 'core/fetch/src',
'@robonen/encoding': 'core/encoding/src',
'@robonen/crdt': 'core/crdt/src',
- '@robonen/editor': 'vue/editor/src',
+ '@robonen/writekit': 'vue/writekit/src',
'@robonen/primitives': 'vue/primitives/src',
'@robonen/vue': vueSrc,
};
@@ -58,7 +58,13 @@ export default defineNuxtModule({
// Primitive `as="template"` / Slot path), silently blanking every demo
// that hits it. `import.meta.env.DEV` resolves correctly in dev & prod.
config.define ??= {};
- (config.define as Record).__DEV__ ??= 'import.meta.env.DEV';
+ // Inline a STATIC boolean, not `import.meta.env.DEV`: a define value is
+ // inserted verbatim and is NOT re-scanned for Vite's `import.meta.env`
+ // replacement, so in a prod build it shipped a literal `import.meta.env.DEV`
+ // into chunks where `import.meta.env` is undefined at runtime →
+ // "Cannot read properties of undefined (reading 'DEV')". A literal
+ // true/false has no runtime dependency and tree-shakes the dev branches.
+ (config.define as Record).__DEV__ ??= JSON.stringify(nuxt.options.dev);
const existing = config.resolve?.alias;
const sourceAliases = [
diff --git a/docs/modules/extractor/types.ts b/docs/modules/extractor/types.ts
index 8ee1ae9..16491fd 100644
--- a/docs/modules/extractor/types.ts
+++ b/docs/modules/extractor/types.ts
@@ -115,6 +115,8 @@ export interface ComponentMeta {
name: string;
/** URL-friendly slug, e.g. "accordion" */
slug: string;
+ /** Functional category for grouping in the docs, e.g. "Forms", "Overlays". */
+ category: string;
/** Short description (from README heading or first JSDoc) */
description: string;
/** Subpath export, e.g. "./accordion" */
diff --git a/docs/modules/mcp/docs-index.test.ts b/docs/modules/mcp/docs-index.test.ts
index bd5df48..7ad92cd 100644
--- a/docs/modules/mcp/docs-index.test.ts
+++ b/docs/modules/mcp/docs-index.test.ts
@@ -159,15 +159,15 @@ describe('getPackage / resolveEntry', () => {
describe('slug uniqueness & collisions', () => {
// A function and a co-located type/interface whose names differ only in case
// both slugify to the same value — the real extractor produces these in
- // @robonen/editor and @robonen/vue.
+ // @robonen/writekit and @robonen/vue.
const colliding: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z',
packages: [
{
- name: '@robonen/editor',
+ name: '@robonen/writekit',
version: '1.0.0',
- description: 'Editor',
- slug: 'editor',
+ description: 'Writekit',
+ slug: 'writekit',
kind: 'api',
group: 'vue',
entryPoints: ['.'],
@@ -197,12 +197,12 @@ describe('slug uniqueness & collisions', () => {
it('reaches both colliding symbols — function and interface — independently', () => {
const leaves = buildLeaves(colliding);
// Exact case-sensitive name disambiguates the function from the interface.
- const fn = resolveEntry(leaves, 'editor', 'position');
- const iface = resolveEntry(leaves, 'editor', 'Position');
+ const fn = resolveEntry(leaves, 'writekit', 'position');
+ const iface = resolveEntry(leaves, 'writekit', 'Position');
expect(fn?.kind === 'api' && fn.item.kind).toBe('function');
expect(iface?.kind === 'api' && iface.item.kind).toBe('interface');
// The disambiguated slug also resolves the interface directly.
- const bySlug = resolveEntry(leaves, 'editor', 'position-interface');
+ const bySlug = resolveEntry(leaves, 'writekit', 'position-interface');
expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface');
});
diff --git a/docs/nuxt.config.ts b/docs/nuxt.config.ts
index 7879013..b016e77 100644
--- a/docs/nuxt.config.ts
+++ b/docs/nuxt.config.ts
@@ -20,6 +20,8 @@ export default defineNuxtConfig({
vite: {
plugins: [
+ // `as any`: @tailwindcss/vite and Nuxt resolve different `vite` versions, so
+ // their `Plugin` types are structurally identical but nominally incompatible.
tailwindcss() as any,
],
},
diff --git a/docs/public/favicon.ico b/docs/public/favicon.ico
new file mode 100644
index 0000000..18993ad
Binary files /dev/null and b/docs/public/favicon.ico differ
diff --git a/docs/public/robots.txt b/docs/public/robots.txt
new file mode 100644
index 0000000..0ad279c
--- /dev/null
+++ b/docs/public/robots.txt
@@ -0,0 +1,2 @@
+User-Agent: *
+Disallow:
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e32b45f..94f87f6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -299,89 +299,6 @@ importers:
specifier: ^43.216.1
version: 43.216.1(typanion@3.14.0)
- vue/editor:
- dependencies:
- '@floating-ui/vue':
- specifier: ^1.1.11
- version: 1.1.11(vue@3.5.35(typescript@6.0.3))
- '@robonen/crdt':
- specifier: workspace:*
- version: link:../../core/crdt
- '@robonen/platform':
- specifier: workspace:*
- version: link:../../core/platform
- '@robonen/stdlib':
- specifier: workspace:*
- version: link:../../core/stdlib
- '@vue/shared':
- specifier: 'catalog:'
- version: 3.5.35
- vue:
- specifier: 'catalog:'
- version: 3.5.35(typescript@6.0.3)
- devDependencies:
- '@robonen/eslint':
- specifier: workspace:*
- version: link:../../configs/eslint
- '@robonen/tsconfig':
- specifier: workspace:*
- version: link:../../configs/tsconfig
- '@robonen/tsdown':
- specifier: workspace:*
- version: link:../../configs/tsdown
- '@vitest/browser':
- specifier: 'catalog:'
- version: 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
- '@vitest/browser-playwright':
- specifier: ^4.1.8
- version: 4.1.8(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
- '@vue/test-utils':
- specifier: 'catalog:'
- version: 2.4.11(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))
- eslint:
- specifier: 'catalog:'
- version: 10.4.1(jiti@2.7.0)
- jsdom:
- specifier: 'catalog:'
- version: 29.1.1
- playwright:
- specifier: ^1.60.0
- version: 1.60.0
- tsdown:
- specifier: 'catalog:'
- version: 0.22.2(oxc-resolver@11.20.0)(typescript@6.0.3)(unrun@0.2.33)(vue-tsc@3.3.4(typescript@6.0.3))
- unplugin-vue:
- specifier: ^7.2.0
- version: 7.2.0(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(vue@3.5.35(typescript@6.0.3))(yaml@2.9.0)
- vitest-browser-vue:
- specifier: ^2.1.0
- version: 2.1.0(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vitest@4.1.8)(vue@3.5.35(typescript@6.0.3))
- vue-tsc:
- specifier: ^3.3.4
- version: 3.3.4(typescript@6.0.3)
-
- vue/editor/playground:
- dependencies:
- '@robonen/editor':
- specifier: workspace:*
- version: link:..
- vue:
- specifier: 'catalog:'
- version: 3.5.35(typescript@6.0.3)
- devDependencies:
- '@robonen/tsconfig':
- specifier: workspace:*
- version: link:../../../configs/tsconfig
- '@vitejs/plugin-vue':
- specifier: ^6.0.7
- version: 6.0.7(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3))
- vite:
- specifier: ^8.0.16
- version: 8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0)
- vue-tsc:
- specifier: ^3.3.4
- version: 3.3.4(typescript@6.0.3)
-
vue/primitives:
dependencies:
'@floating-ui/vue':
@@ -548,6 +465,92 @@ importers:
specifier: 'catalog:'
version: 0.22.2(oxc-resolver@11.20.0)(typescript@6.0.3)(unrun@0.2.33)(vue-tsc@3.2.6(typescript@6.0.3))
+ vue/writekit:
+ dependencies:
+ '@robonen/crdt':
+ specifier: workspace:*
+ version: link:../../core/crdt
+ '@robonen/platform':
+ specifier: workspace:*
+ version: link:../../core/platform
+ '@robonen/primitives':
+ specifier: workspace:*
+ version: link:../primitives
+ '@robonen/stdlib':
+ specifier: workspace:*
+ version: link:../../core/stdlib
+ '@robonen/vue':
+ specifier: workspace:*
+ version: link:../toolkit
+ '@vue/shared':
+ specifier: 'catalog:'
+ version: 3.5.35
+ vue:
+ specifier: 'catalog:'
+ version: 3.5.35(typescript@6.0.3)
+ devDependencies:
+ '@robonen/eslint':
+ specifier: workspace:*
+ version: link:../../configs/eslint
+ '@robonen/tsconfig':
+ specifier: workspace:*
+ version: link:../../configs/tsconfig
+ '@robonen/tsdown':
+ specifier: workspace:*
+ version: link:../../configs/tsdown
+ '@vitest/browser':
+ specifier: 'catalog:'
+ version: 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
+ '@vitest/browser-playwright':
+ specifier: ^4.1.8
+ version: 4.1.8(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
+ '@vue/test-utils':
+ specifier: 'catalog:'
+ version: 2.4.11(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))
+ eslint:
+ specifier: 'catalog:'
+ version: 10.4.1(jiti@2.7.0)
+ jsdom:
+ specifier: 'catalog:'
+ version: 29.1.1
+ playwright:
+ specifier: ^1.60.0
+ version: 1.60.0
+ tsdown:
+ specifier: 'catalog:'
+ version: 0.22.2(oxc-resolver@11.20.0)(typescript@6.0.3)(unrun@0.2.33)(vue-tsc@3.3.4(typescript@6.0.3))
+ unplugin-vue:
+ specifier: ^7.2.0
+ version: 7.2.0(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(vue@3.5.35(typescript@6.0.3))(yaml@2.9.0)
+ vitest-browser-vue:
+ specifier: ^2.1.0
+ version: 2.1.0(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vitest@4.1.8)(vue@3.5.35(typescript@6.0.3))
+ vue-tsc:
+ specifier: ^3.3.4
+ version: 3.3.4(typescript@6.0.3)
+
+ vue/writekit/playground:
+ dependencies:
+ '@robonen/writekit':
+ specifier: workspace:*
+ version: link:..
+ vue:
+ specifier: 'catalog:'
+ version: 3.5.35(typescript@6.0.3)
+ devDependencies:
+ '@robonen/tsconfig':
+ specifier: workspace:*
+ version: link:../../../configs/tsconfig
+ '@vitejs/plugin-vue':
+ specifier: ^6.0.7
+ version: 6.0.7(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3))
+ vite:
+ specifier: ^8.0.16
+ version: 8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0)
+ vue-tsc:
+ specifier: ^3.3.4
+ version: 3.3.4(typescript@6.0.3)
+
packages:
'@adobe/css-tools@4.4.4':
diff --git a/vue/editor/docs/01-playground.vue b/vue/editor/docs/01-playground.vue
deleted file mode 100644
index 6943eea..0000000
--- a/vue/editor/docs/01-playground.vue
+++ /dev/null
@@ -1,443 +0,0 @@
-
-
-
-
-
-
-
-
Playground
-
- Live @robonen/editor instances built with the default registry — the real
- headless controller, single-contenteditable view, and CRDT-backed model from the API
- reference. Switch tabs to explore the capabilities.
-
-
-
-
-
-
- {{ tb.label }}
-
-
-
-
-
-
-
- B
- I
- <>
- H
-
- H1
- H2
- ❝
- ¶
-
- ↺
- ↻
-
-
-
-
-
-
-
-
-
-
-
- {{ blockCount }} blocks
- {{ wordCount }} words
- selection: {{ selectionSummary }}
-
- {{ showJson ? 'Hide' : 'Show' }} document JSON
-
-
-
{{ docJson }}
-
-
-
-
-
-
- Alice
- Bob
-
-
- {{ inSync ? 'in sync' : 'diverged' }}
-
-
- {{ connected ? 'Connected' : 'Offline' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Each pane is a separate CRDT replica synced over an in-memory channel. Toggle
- Offline , edit both sides so they diverge, then reconnect — the replicas
- converge automatically (no Yjs).
-
-
-
-
-
-
-
-
- Loading editor…
-
-
-
-
-
-
How it's wired
-
- The editor is created from a registry and a document, then rendered with a single
- EditorRoot. Multiplayer is just two editors, each bound to its own CRDT
- replica with bindCrdt, exchanging ops over any transport.
-
-
-
-
-
-
- See createEditor ,
- bindCrdt and
- toggleMark in the API reference for the full surface.
-
-
-
-
-
-
diff --git a/vue/editor/playground/src/App.vue b/vue/editor/playground/src/App.vue
deleted file mode 100644
index 7a92b52..0000000
--- a/vue/editor/playground/src/App.vue
+++ /dev/null
@@ -1,156 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/vue/editor/playground/src/Toolbar.vue b/vue/editor/playground/src/Toolbar.vue
deleted file mode 100644
index d6fe6df..0000000
--- a/vue/editor/playground/src/Toolbar.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
-
- B
- I
-
- H1
- H2
- P
-
- Undo
- Redo
-
-
diff --git a/vue/editor/playground/src/demos/CommandsDemo.vue b/vue/editor/playground/src/demos/CommandsDemo.vue
deleted file mode 100644
index 6c7cc4a..0000000
--- a/vue/editor/playground/src/demos/CommandsDemo.vue
+++ /dev/null
@@ -1,62 +0,0 @@
-
-
-
-
- Commands API
- Programmatic control — every button is a command or transaction on the editor.
-
-
- Append paragraph
- Move block ↑
- Move block ↓
- → H1
- → Paragraph
- Toggle bold
- Delete block
-
-
-
- document JSON {{ docJson }}
-
-
diff --git a/vue/editor/playground/src/demos/ComplexBlocksDemo.vue b/vue/editor/playground/src/demos/ComplexBlocksDemo.vue
deleted file mode 100644
index cb303f0..0000000
--- a/vue/editor/playground/src/demos/ComplexBlocksDemo.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-
-
-
-
- Complex blocks
- Quote, callout, code block, lists (bulleted / numbered / to-do), image & divider atoms, plus the full mark set. Everything is registry-driven.
-
-
- B
- I
- U
- S
- </>
- HL
- Link
-
- P
- H1
- H2
- Quote
- Code
- Callout
-
- • List
- 1. List
- ☐ Todo
- Toggle ✓
-
- + Image
- + Divider
-
- Undo
- Redo
-
-
- Type / to insert a block; select text for the bubble toolbar; hover a block and drag the ⠿ handle to reorder. Markdown shortcuts work too: # , - , > , 1. , [] .
-
-
-
-
-
- document JSON {{ docJson }}
-
-
diff --git a/vue/editor/playground/src/demos/MultiEditorDemo.vue b/vue/editor/playground/src/demos/MultiEditorDemo.vue
deleted file mode 100644
index 6f955af..0000000
--- a/vue/editor/playground/src/demos/MultiEditorDemo.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
- Multiple editors
- Two independent editors on one page — selection and editing in one must never affect the other.
-
-
-
diff --git a/vue/editor/src/commands/__test__/commands.test.ts b/vue/editor/src/commands/__test__/commands.test.ts
deleted file mode 100644
index 0a2b009..0000000
--- a/vue/editor/src/commands/__test__/commands.test.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
-import { createDefaultRegistry } from '../../preset';
-import { createEditor, createEditorState } from '../../state';
-import { joinBackward, splitBlock, toggleMark } from '..';
-
-function para(id: string, text: string) {
- return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
-}
-
-function editorWith(blocks: Array>, selection?: ReturnType) {
- const registry = createDefaultRegistry();
- return createEditor({ state: createEditorState({ registry, doc: createDoc(blocks), selection }) });
-}
-
-describe('commands', () => {
- it('toggleMark applies then removes bold on a range', () => {
- const registry = createDefaultRegistry();
- const editor = createEditor({
- state: createEditorState({
- registry,
- doc: createDoc([para('a', 'abc')]),
- selection: textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 3 }),
- }),
- });
-
- expect(editor.command(toggleMark('bold'))).toBe(true);
- expect(nodeInline(editor.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [{ type: 'bold' }] }]);
-
- editor.command(toggleMark('bold'));
- expect(nodeInline(editor.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [] }]);
- });
-
- it('splitBlock splits at the caret', () => {
- const editor = editorWith([para('a', 'hello')], caret('a', 2));
- expect(editor.command(splitBlock)).toBe(true);
- expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['he', 'llo']);
- expect(editor.state.selection.kind).toBe('text');
- });
-
- it('joinBackward merges into the previous block', () => {
- const editor = editorWith([para('a', 'foo'), para('b', 'bar')], caret('b', 0));
- expect(editor.command(joinBackward)).toBe(true);
- expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']);
- });
-
- it('undo restores the document after a split', () => {
- const editor = editorWith([para('a', 'hello')], caret('a', 2));
- editor.command(splitBlock);
- expect(editor.state.doc.content.length).toBe(2);
- expect(editor.undo()).toBe(true);
- expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['hello']);
- });
-});
diff --git a/vue/editor/src/view/composables/index.ts b/vue/editor/src/view/composables/index.ts
deleted file mode 100644
index 32077b1..0000000
--- a/vue/editor/src/view/composables/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { useContextFactory } from './useContextFactory';
-export { useEventListener } from './useEventListener';
diff --git a/vue/editor/src/view/composables/useContextFactory.ts b/vue/editor/src/view/composables/useContextFactory.ts
deleted file mode 100644
index 637cd4e..0000000
--- a/vue/editor/src/view/composables/useContextFactory.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { App, InjectionKey } from 'vue';
-import { inject as vueInject, provide as vueProvide } from 'vue';
-
-/**
- * Factory for a strongly-typed provide/inject pair keyed by a unique Symbol.
- * Local copy of the `@robonen/vue` helper so the editor stays self-contained.
- */
-export function useContextFactory(name: string) {
- const injectionKey: InjectionKey = Symbol(name);
-
- const inject = (fallback?: Fallback) => {
- const context = vueInject(injectionKey, fallback);
-
- if (context !== undefined)
- return context;
-
- throw new Error(`useContextFactory: '${name}' context is not provided`);
- };
-
- const provide = (context: ContextValue) => {
- vueProvide(injectionKey, context);
- return context;
- };
-
- const appProvide = (app: App) => (context: ContextValue) => {
- app.provide(injectionKey, context);
- return context;
- };
-
- return { inject, provide, appProvide, key: injectionKey };
-}
diff --git a/vue/editor/src/view/composables/useEventListener.ts b/vue/editor/src/view/composables/useEventListener.ts
deleted file mode 100644
index 8efcd7d..0000000
--- a/vue/editor/src/view/composables/useEventListener.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { MaybeRefOrGetter } from 'vue';
-import { onScopeDispose, toValue, watch } from 'vue';
-
-type ListenerTarget = Window | Document | HTMLElement | null | undefined;
-
-/**
- * Attach an event listener to a (possibly reactive) target, re-binding when the
- * target changes and cleaning up on scope dispose. Minimal local replacement for
- * the `@robonen/vue` composable.
- */
-export function useEventListener(
- target: MaybeRefOrGetter,
- event: string,
- handler: (event: Event) => void,
- options?: boolean | AddEventListenerOptions,
-): () => void {
- let detach = (): void => {};
-
- const stopWatch = watch(
- () => toValue(target),
- (el) => {
- detach();
- if (!el)
- return;
- el.addEventListener(event, handler, options);
- detach = () => el.removeEventListener(event, handler, options);
- },
- { immediate: true, flush: 'post' },
- );
-
- const stop = (): void => {
- detach();
- stopWatch();
- };
-
- onScopeDispose(stop);
- return stop;
-}
diff --git a/vue/editor/src/view/primitive/Primitive.ts b/vue/editor/src/view/primitive/Primitive.ts
deleted file mode 100644
index 1d2726e..0000000
--- a/vue/editor/src/view/primitive/Primitive.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { AllowedComponentProps, Component, IntrinsicElementAttributes, SetupContext, VNodeProps } from 'vue';
-import { h } from 'vue';
-import { renderSlotChild } from './Slot';
-
-type FunctionalComponentContext = Omit;
-
-export interface PrimitiveProps {
- as?: keyof IntrinsicElementAttributes | Component;
-}
-
-/**
- * Polymorphic element renderer: renders `as` (a tag or component), or the single
- * slotted child when `as === 'template'`. Local copy of the primitives helper.
- */
-export function Primitive(props: PrimitiveProps & VNodeProps & AllowedComponentProps & Record, ctx: FunctionalComponentContext) {
- const as = props.as;
-
- return as === 'template'
- ? renderSlotChild(ctx.slots, ctx.attrs)
- : h(as!, ctx.attrs, ctx.slots);
-}
-
-Primitive.inheritAttrs = false;
-
-Primitive.props = {
- as: {
- type: [String, Object],
- default: 'div' as const,
- },
-};
diff --git a/vue/editor/src/view/primitive/Slot.ts b/vue/editor/src/view/primitive/Slot.ts
deleted file mode 100644
index 04ab41e..0000000
--- a/vue/editor/src/view/primitive/Slot.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import type { SetupContext, Slots, VNode } from 'vue';
-import { Comment, Fragment, cloneVNode, warn } from 'vue';
-import { getRawChildren } from './getRawChildren';
-
-type FunctionalComponentContext = Omit;
-
-/**
- * Renders a single child from the provided default slot, applying attrs to it.
- * Shared between `` and ``.
- *
- * @param slots - Component slots
- * @param attrs - Attrs to apply to the slotted child
- * @returns Cloned VNode with merged attrs or null
- */
-export function renderSlotChild(slots: Slots, attrs: Record): VNode | null {
- if (!slots.default) return null;
-
- const raw = slots.default();
-
- if (raw.length === 1) {
- const only = raw[0] as VNode;
- const t = only.type;
- if (t !== Fragment && t !== Comment)
- return cloneVNode(only, attrs, true);
- }
-
- const children = getRawChildren(raw);
-
- if (!children.length) return null;
-
- if (__DEV__ && children.length > 1) {
- warn(' can only be used on a single element or component.');
- }
-
- return cloneVNode(children[0]!, attrs, true);
-}
-
-/**
- * A component that renders a single child from its default slot, applying the
- * provided attributes to it.
- *
- * @param _ - Props (unused)
- * @param context - Setup context containing slots and attrs
- * @returns Cloned VNode with merged attrs or null
- */
-export function Slot(_: Record, { slots, attrs }: FunctionalComponentContext) {
- return renderSlotChild(slots, attrs);
-}
-
-Slot.inheritAttrs = false;
diff --git a/vue/editor/src/view/primitive/getRawChildren.ts b/vue/editor/src/view/primitive/getRawChildren.ts
deleted file mode 100644
index 836ee67..0000000
--- a/vue/editor/src/view/primitive/getRawChildren.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { VNode } from 'vue';
-import { Comment, Fragment } from 'vue';
-import { PatchFlags } from '@vue/shared';
-
-/**
- * Recursively extracts and flattens VNodes from potentially nested Fragments
- * while filtering out Comment nodes. Local copy of the primitives helper to keep
- * `@robonen/editor` self-contained.
- *
- * @param children - Array of VNodes to process
- * @returns Flattened array of non-Comment VNodes
- */
-export function getRawChildren(children: VNode[]): VNode[] {
- const result: VNode[] = [];
- flatten(children, result);
- return result;
-}
-
-function flatten(children: VNode[], result: VNode[]): void {
- let keyedFragmentCount = 0;
- const startIdx = result.length;
-
- for (let i = 0, len = children.length; i < len; i++) {
- const child = children[i]!;
-
- if (child.type === Fragment) {
- if (child.patchFlag & PatchFlags.KEYED_FRAGMENT) {
- keyedFragmentCount++;
- }
-
- flatten(child.children as VNode[], result);
- }
- else if (child.type !== Comment) {
- result.push(child);
- }
- }
-
- if (keyedFragmentCount > 1) {
- for (let i = startIdx; i < result.length; i++) {
- result[i]!.patchFlag = PatchFlags.BAIL;
- }
- }
-}
diff --git a/vue/editor/src/view/primitive/index.ts b/vue/editor/src/view/primitive/index.ts
deleted file mode 100644
index c88baf4..0000000
--- a/vue/editor/src/view/primitive/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { Primitive } from './Primitive';
-export type { PrimitiveProps } from './Primitive';
-export { Slot, renderSlotChild } from './Slot';
-export { getRawChildren } from './getRawChildren';
diff --git a/vue/editor/src/view/ui/EditorBubbleMenu.vue b/vue/editor/src/view/ui/EditorBubbleMenu.vue
deleted file mode 100644
index 3fc8eb7..0000000
--- a/vue/editor/src/view/ui/EditorBubbleMenu.vue
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
-
-
-
-
-
diff --git a/vue/editor/src/view/ui/index.ts b/vue/editor/src/view/ui/index.ts
deleted file mode 100644
index ff705e4..0000000
--- a/vue/editor/src/view/ui/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export * from './slash-items';
-
-export { default as EditorBubbleMenu } from './EditorBubbleMenu.vue';
-export type { EditorBubbleMenuProps } from './EditorBubbleMenu.vue';
-export { default as EditorSlashMenu } from './EditorSlashMenu.vue';
-export type { EditorSlashMenuProps } from './EditorSlashMenu.vue';
-export { default as EditorRemoteCursors } from './EditorRemoteCursors.vue';
-export type { EditorRemoteCursorsProps } from './EditorRemoteCursors.vue';
diff --git a/vue/primitives/.bench-baseline.txt b/vue/primitives/.bench-baseline.txt
new file mode 100644
index 0000000..0e0c4b1
--- /dev/null
+++ b/vue/primitives/.bench-baseline.txt
@@ -0,0 +1,542 @@
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > screenToContent — over N points 1376ms
+ · 100 points 1,192,582.00 0.0000 0.8000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.78% 596291
+ · 1000 points 143,410.00 0.0000 0.2000 0.0070 0.0000 0.1000 0.1000 0.1000 ±2.68% 71705
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > contentToScreen — over N points 1375ms
+ · 100 points 1,182,360.00 0.0000 6.5000 0.0008 0.0000 0.0000 0.1000 0.1000 ±4.42% 591180
+ · 1000 points 146,178.76 0.0000 0.3000 0.0068 0.0000 0.1000 0.1000 0.1000 ±2.69% 73104
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > round-trip screen→content→screen — over N points 1365ms
+ · 100 points 1,179,437.99 0.0000 0.2000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 589719
+ · 1000 points 141,443.71 0.0000 0.2000 0.0071 0.0000 0.1000 0.1000 0.1000 ±2.68% 70736
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > zoomAtPointer — over N anchor points 1373ms
+ · 100 points 1,095,020.00 0.0000 0.2000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.76% 547510
+ · 1000 points 128,084.38 0.0000 0.2000 0.0078 0.0000 0.1000 0.1000 0.1000 ±2.67% 64055
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > clampViewport — zoom-only (no extent) 1343ms
+ · 100 viewports 964,282.00 0.0000 0.3000 0.0010 0.0000 0.1000 0.1000 0.1000 ±2.77% 482141
+ · 1000 viewports 107,640.47 0.0000 0.2000 0.0093 0.0000 0.1000 0.1000 0.1000 ±2.65% 53831
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > clampViewport — with translate extent 1330ms
+ · 100 viewports 902,672.00 0.0000 0.2000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 451336
+ · 1000 viewports 97,734.45 0.0000 0.4000 0.0102 0.0000 0.1000 0.1000 0.1000 ±2.66% 48877
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > clampViewport — degenerate extent (centring branch) 1334ms
+ · 100 viewports 919,704.06 0.0000 0.3000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 459944
+ · 1000 viewports 105,346.00 0.0000 0.2000 0.0095 0.0000 0.1000 0.1000 0.1000 ±2.64% 52673
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > wheelToZoomFactor — over N wheel events 1232ms
+ · 100 events 174,063.19 0.0000 0.4000 0.0057 0.0000 0.1000 0.1000 0.1000 ±2.70% 87049
+ · 1000 events 16,542.00 0.0000 0.4000 0.0605 0.1000 0.2000 0.2000 0.2000 ±2.01% 8271
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > wheel-zoom pipeline (factor → clamp → zoomAtPointer) 1238ms
+ · 100 steps 205,246.00 0.0000 0.3000 0.0049 0.0000 0.1000 0.1000 0.1000 ±2.72% 102623
+ · 1000 steps 19,588.00 0.0000 0.4000 0.0511 0.1000 0.2000 0.2000 0.2000 ±2.11% 9794
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > drag-pan move (translate + clamp) 1316ms
+ · 100 moves 822,331.53 0.0000 0.2000 0.0012 0.0000 0.1000 0.1000 0.1000 ±2.76% 411248
+ · 1000 moves 85,706.86 0.0000 0.2000 0.0117 0.0000 0.1000 0.1000 0.1000 ±2.62% 42862
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > fitViewTransform 2304ms
+ · single fit 7,124,967.01 0.0000 4.5000 0.0001 0.0000 0.0000 0.0000 0.1000 ±3.29% 3563196
+ · 100 fits 1,157,900.00 0.0000 1.0000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.80% 578950
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > measureContentRect (real getBoundingClientRect) 616ms
+ · 100 measurements 17,414.52 0.0000 2.0000 0.0574 0.1000 0.2000 0.2000 0.2000 ±2.20% 8709
+ ✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > ViewportRoot — mount with N tiles 1236ms
+ · 50 tiles — mount + unmount 2,345.61 0.2000 14.2000 0.4263 0.4000 1.0000 6.5000 13.7000 ±10.02% 1204
+ · 500 tiles — mount + unmount 875.12 0.9000 8.1000 1.1427 1.1000 5.4000 6.8000 8.1000 ±5.54% 438
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > ruler ticks — timecode (per pan/zoom) 1933ms
+ · timecodeTicks — 100-clip span (~150s) 53,809.24 0.0000 0.3000 0.0186 0.0000 0.1000 0.1000 0.2000 ±2.52% 26910
+ · timecodeTicks — 1000-clip span (~1500s) 18,988.20 0.0000 0.3000 0.0527 0.1000 0.2000 0.2000 0.2000 ±2.11% 9496
+ · timecodeTicks — wide window, fixed viewport (1200px) 838,271.99 0.0000 0.3000 0.0012 0.0000 0.1000 0.1000 0.1000 ±2.77% 419136
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > ruler ticks — wall clock (per pan/zoom) 1233ms
+ · timeTicks — 100-clip span (~150s) 157,950.41 0.0000 0.4000 0.0063 0.0000 0.1000 0.1000 0.1000 ±2.71% 78991
+ · timeTicks — 1000-clip span (~1500s) 32,748.00 0.0000 0.6000 0.0305 0.1000 0.1000 0.2000 0.2000 ±2.39% 16374
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > scale projection (scaleLinear over clips) 1741ms
+ · scaleLinear — project 100 clip edges 3,262,253.56 0.0000 0.2000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1631453
+ · scaleLinear — project 1000 clip edges 466,074.79 0.0000 0.2000 0.0021 0.0000 0.1000 0.1000 0.1000 ±2.74% 233084
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > timecode formatting (per clip label) 1826ms
+ · timeToTimecode — 100 clip durations 113,117.38 0.0000 0.2000 0.0088 0.0000 0.1000 0.1000 0.1000 ±2.65% 56570
+ · timeToTimecode — 1000 clip durations 11,245.75 0.0000 0.4000 0.0889 0.1000 0.2000 0.2000 0.3000 ±1.71% 5624
+ · framesToTimecode — 1000 (raw, pre-converted) 14,099.18 0.0000 0.3000 0.0709 0.1000 0.2000 0.2000 0.2000 ±1.88% 7051
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > snapToFrame (nudge / grid granularity) 1537ms
+ · snapToFrame — 100 clip starts 2,369,298.14 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1184886
+ · snapToFrame — 1000 clip starts 308,024.40 0.0000 0.1000 0.0032 0.0000 0.1000 0.1000 0.1000 ±2.73% 154043
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > marquee hit-test (clipIntersectsTime per pointer move) 1743ms
+ · clipIntersectsTime — 100 clips 3,878,759.99 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1939380
+ · clipIntersectsTime — 1000 clips 600,243.95 0.0000 0.1000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 300182
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > clipsDuration (auto-duration recompute) 1897ms
+ · clipsDuration — 100 clips 4,189,313.97 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2094657
+ · clipsDuration — 1000 clips 639,822.00 0.0000 0.1000 0.0016 0.0000 0.1000 0.1000 0.1000 ±2.75% 319911
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > applyClipChanges (controlled reducer) 1828ms
+ · applyClipChanges — 100 clips / 100 changes 115,978.00 0.0000 0.3000 0.0086 0.0000 0.1000 0.1000 0.1000 ±2.67% 57989
+ · applyClipChanges — 1000 clips / 1000 changes 10,148.00 0.0000 0.5000 0.0985 0.1000 0.2000 0.2000 0.3000 ±1.65% 5074
+ · applyClipChanges — 1000 clips / single move 17,258.00 0.0000 0.4000 0.0579 0.1000 0.2000 0.2000 0.3000 ±2.05% 8629
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > applyTrackChanges (controlled reducer) 1236ms
+ · applyTrackChanges — 50 tracks / 50 patches 190,324.00 0.0000 0.3000 0.0053 0.0000 0.1000 0.1000 0.1000 ±2.71% 95162
+ · applyTrackChanges — 500 tracks / 500 patches 18,284.00 0.0000 0.4000 0.0547 0.1000 0.2000 0.2000 0.3000 ±2.09% 9142
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > TimelineRoot — mount (full tree) 1446ms
+ · mount — 4 tracks / 50 clips 196.51 3.7000 52.7000 5.0889 4.3000 52.7000 52.7000 52.7000 ±20.03% 99
+ · mount — 8 tracks / 500 clips 22.7790 34.9000 94.4000 43.9000 41.2000 94.4000 94.4000 94.4000 ±23.28% 12
+ ✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > TimelineRoot — update after prop change 2151ms
+ · zoom change (pxPerSecond) — 8 tracks / 500 clips 14.4949 59.8000 138.30 68.9900 62.1000 138.30 138.30 138.30 ±25.29% 10
+ · clips-array swap — 8 tracks / 500 clips 13.0225 66.7000 150.90 76.7900 70.0000 150.90 150.90 150.90 ±24.30% 10
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > buildEvaluator — build cost 5205ms
+ · linear — 16 anchors 1,234,628.00 0.0000 0.3000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 617314
+ · linear — 256 anchors 125,932.00 0.0000 0.3000 0.0079 0.0000 0.1000 0.1000 0.1000 ±2.67% 62966
+ · monotone — 16 anchors 383,598.00 0.0000 0.2000 0.0026 0.0000 0.1000 0.1000 0.1000 ±2.74% 191799
+ · monotone — 256 anchors 33,197.36 0.0000 0.2000 0.0301 0.1000 0.1000 0.1000 0.2000 ±2.37% 16602
+ · catmull-rom — 16 anchors 79,434.11 0.0000 1.5000 0.0126 0.0000 0.1000 0.1000 0.1000 ±2.79% 39725
+ · catmull-rom — 256 anchors 50,563.89 0.0000 1.5000 0.0198 0.0000 0.1000 0.1000 0.2000 ±2.70% 25287
+ · bezier — 16 anchors 720,043.99 0.0000 0.2000 0.0014 0.0000 0.1000 0.1000 0.1000 ±2.75% 360094
+ · bezier — 256 anchors 68,496.00 0.0000 0.2000 0.0146 0.0000 0.1000 0.1000 0.1000 ±2.58% 34248
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > evaluator sampling — 256 samples 2576ms
+ · linear 343,429.32 0.0000 0.2000 0.0029 0.0000 0.1000 0.1000 0.1000 ±2.73% 171749
+ · monotone 592,140.00 0.0000 0.1000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 296070
+ · catmull-rom 248,890.00 0.0000 0.2000 0.0040 0.0000 0.1000 0.1000 0.1000 ±2.72% 124445
+ · bezier (Newton-Raphson per call) 161,676.00 0.0000 0.2000 0.0062 0.0000 0.1000 0.1000 0.1000 ±2.69% 80838
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > evaluator sampling — 1024 samples (stress) 1848ms
+ · monotone — 16 anchors 156,256.00 0.0000 0.2000 0.0064 0.0000 0.1000 0.1000 0.1000 ±2.68% 78128
+ · monotone — 256 anchors (deep binary search) 111,976.00 0.0000 0.2000 0.0089 0.0000 0.1000 0.1000 0.1000 ±2.65% 55988
+ · bezier — 16 anchors 48,394.00 0.0000 0.3000 0.0207 0.0000 0.1000 0.1000 0.2000 ±2.50% 24197
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > build + sample 256 (full per-edit, 16 anchors) 1864ms
+ · monotone 185,120.98 0.0000 0.5000 0.0054 0.0000 0.1000 0.1000 0.1000 ±2.71% 92579
+ · catmull-rom 58,420.32 0.0000 1.5000 0.0171 0.0000 0.1000 0.1000 0.2000 ±2.75% 29216
+ · bezier 120,559.89 0.0000 0.3000 0.0083 0.0000 0.1000 0.1000 0.1000 ±2.67% 60292
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > toLUT — spline lookup table 1841ms
+ · monotone — 256 entries 110,670.00 0.0000 0.3000 0.0090 0.0000 0.1000 0.1000 0.1000 ±2.65% 55335
+ · monotone — 1024 entries 27,216.56 0.0000 0.2000 0.0367 0.1000 0.1000 0.2000 0.2000 ±2.29% 13611
+ · bezier — 256 entries 82,832.00 0.0000 0.3000 0.0121 0.0000 0.1000 0.1000 0.1000 ±2.62% 41416
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > curve path `d` build — sampled polyline (256 samples) 1217ms
+ · monotone — sample + project + buildPolylinePath 40,060.00 0.0000 2.3000 0.0250 0.0000 0.1000 0.1000 0.2000 ±2.61% 20030
+ · catmull-rom — sample + project + buildPolylinePath 36,482.00 0.0000 1.9000 0.0274 0.1000 0.1000 0.1000 0.2000 ±2.53% 18241
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > curve path `d` build — bezier segment chain 1242ms
+ · 16 anchors (15 segments) 234,902.00 0.0000 1.6000 0.0043 0.0000 0.1000 0.1000 0.1000 ±2.87% 117451
+ · 256 anchors (255 segments) 11,619.68 0.0000 2.1000 0.0861 0.1000 0.2000 0.2000 0.5000 ±2.14% 5811
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > spline primitives — per-call baselines 5370ms
+ · linearInterpolate — 256-pt table lookup 7,088,362.35 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3544890
+ · catmullRom — 16-pt parametric eval 7,313,673.99 0.0000 0.7000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.79% 3656837
+ · evalCubicBezier — single cubic eval 6,966,088.85 0.0000 0.7000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.79% 3483741
+ · monotoneCubic — build closure (16 pts) 510,439.91 0.0000 0.3000 0.0020 0.0000 0.1000 0.1000 0.1000 ±2.75% 255271
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > anchor housekeeping 2732ms
+ · sortAnchors — 16 (unsorted) 1,003,727.99 0.0000 0.3000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.77% 501864
+ · sortAnchors — 256 (unsorted) 46,338.73 0.0000 0.4000 0.0216 0.0000 0.1000 0.1000 0.2000 ±2.49% 23174
+ · anchorsToPoints — 16 1,232,089.59 0.0000 0.6000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.78% 616168
+ · anchorsToPoints — 256 125,538.00 0.0000 0.3000 0.0080 0.0000 0.1000 0.1000 0.1000 ±2.67% 62769
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > pointer-move clamp math 7694ms
+ · clampAnchorX — interior anchor (neighbour clamp), 16 7,403,125.41 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3702303
+ · clampAnchorX — interior anchor (neighbour clamp), 256 7,373,969.27 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3687722
+ · clampAnchorY — domain clamp 7,443,999.99 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3722000
+ · simulated updateAnchor step — clamp + slice-replace, 16 6,432,109.99 0.0000 7.2000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.96% 3216055
+ · simulated updateAnchor step — clamp + slice-replace, 256 5,501,865.63 0.0000 0.3000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.78% 2751483
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > simulated drag stroke (60 frames, monotone, 16 anchors) 603ms
+ · clamp + rebuild + sample-256 per frame 3,084.00 0.2000 0.8000 0.3243 0.4000 0.4000 0.5000 0.7000 ±0.94% 1542
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > mount — Root + Curve + N Points 2026ms
+ · 50 points (monotone) 231.22 3.0000 52.5000 4.3248 3.4000 25.9000 52.5000 52.5000 ±21.15% 121
+ · 500 points (monotone, stress) 23.6220 32.1000 101.60 42.3333 38.5000 101.60 101.60 101.60 ±28.33% 12
+ · 50 points (bezier path) 239.62 2.9000 78.0000 4.1733 3.2000 9.8000 78.0000 78.0000 ±30.21% 120
+ ✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > update after prop change (50 points) 1215ms
+ · switch interpolation monotone→bezier→monotone 165.90 5.1000 10.5000 6.0277 5.6000 10.5000 10.5000 10.5000 ±5.79% 83
+ · replace model array (commit an edit) 118.34 7.5000 12.3000 8.4500 8.1000 12.3000 12.3000 12.3000 ±4.39% 60
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > rotatePoint — kernel 2625ms
+ · rotatePoint × 100 4,186,828.67 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2093833
+ · rotatePoint × 1000 893,619.28 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 446899
+ · rotateVector (origin-free) × 1000 896,738.65 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 448459
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > pointer angle math 3950ms
+ · pointerAngle × 100 3,031,405.99 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1515703
+ · pointerAngle × 1000 889,550.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 444775
+ · shortestAngleDelta × 1000 900,573.89 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 450377
+ · normalizeRotation × 1000 1,020,559.89 0.0000 0.1000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.76% 510382
+ · snapRotation (15°) × 1000 1,030,022.00 0.0000 0.1000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.76% 515011
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > rotate drag — per-frame 1280ms
+ · rotationFromPointer × 100 frames 533,194.00 0.0000 0.1000 0.0019 0.0000 0.1000 0.1000 0.1000 ±2.75% 266597
+ · rotationFromPointer × 1000 frames 52,782.00 0.0000 0.2000 0.0189 0.0000 0.1000 0.1000 0.1000 ±2.51% 26391
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > resizeEdge — per-frame 3636ms
+ · resizeEdge corner (no options) × 100 108,148.00 0.0000 0.3000 0.0092 0.0000 0.1000 0.1000 0.1000 ±2.65% 54074
+ · resizeEdge corner (no options) × 1000 10,806.00 0.0000 0.5000 0.0925 0.1000 0.2000 0.2000 0.3000 ±1.67% 5403
+ · resizeEdge aspect-locked corner × 1000 8,762.25 0.0000 0.4000 0.1141 0.1000 0.2000 0.2000 0.3000 ±1.50% 4382
+ · resizeEdge symmetric (Alt) corner × 1000 9,848.00 0.0000 0.3000 0.1015 0.1000 0.2000 0.2000 0.3000 ±1.55% 4924
+ · resizeEdge edge handle × 1000 13,704.00 0.0000 0.3000 0.0730 0.1000 0.2000 0.2000 0.2000 ±1.85% 6852
+ · rotated scale frame (rotateVector → resizeEdge) × 1000 8,680.53 0.0000 0.4000 0.1152 0.2000 0.2000 0.2000 0.3000 ±1.55% 4342
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > aspect + axes helpers 1376ms
+ · applyAspectRatio × 1000 421,907.62 0.0000 0.1000 0.0024 0.0000 0.1000 0.1000 0.1000 ±2.74% 210996
+ · handleAxes × 8 positions × 125 (=1000) 967,260.00 0.0000 0.1000 0.0010 0.0000 0.1000 0.1000 0.1000 ±2.76% 483630
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > constrain + move 2025ms
+ · constrainRect × 1000 901,852.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 450926
+ · moveBox × 1000 61,595.68 0.0000 0.2000 0.0162 0.0000 0.1000 0.1000 0.2000 ±2.56% 30804
+ · resolvePivot (center) × 1000 898,150.37 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 449165
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > local ⇄ world 703ms
+ · localToWorld → worldToLocal round-trip × 1000 892,126.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 446063
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > decomposeTransform — corners 1230ms
+ · decomposeTransform × 100 175,760.00 0.0000 0.5000 0.0057 0.0000 0.1000 0.1000 0.1000 ±2.72% 87880
+ · decomposeTransform × 1000 15,972.00 0.0000 0.3000 0.0626 0.1000 0.2000 0.2000 0.2000 ±2.00% 7986
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > TransformBoxRoot — mount full part set 7944ms
+ · mount + unmount — 1 box (root + 8 handles + rotate + status) 1,188.34 0.5000 47.3000 0.8415 0.7000 6.2000 6.8000 47.3000 ±19.41% 595
+ · mount + unmount — 50 boxes 26.9024 28.2000 95.9000 37.1714 34.1000 95.9000 95.9000 95.9000 ±26.49% 14
+ · mount + unmount — 500 boxes (stress) 2.5223 338.90 636.70 396.47 373.10 636.70 636.70 636.70 ±19.60% 10
+ ✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > TransformBoxRoot — update after transform change 1010ms
+ · mount → setProps(transform) → update — 50 boxes 30.7515 27.7000 36.6000 32.5188 34.6000 36.6000 36.6000 36.6000 ±5.11% 16
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — straight 1308ms
+ · 100 edges 664,128.00 0.0000 0.5000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.78% 332064
+ · 1000 edges 72,543.49 0.0000 0.5000 0.0138 0.0000 0.1000 0.1000 0.2000 ±2.61% 36279
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — bezier 1221ms
+ · 100 edges 77,522.00 0.0000 0.8000 0.0129 0.0000 0.1000 0.1000 0.2000 ±2.64% 38761
+ · 1000 edges 6,873.25 0.0000 0.6000 0.1455 0.2000 0.3000 0.3000 0.4000 ±1.43% 3438
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — smoothstep (corner builder) 1209ms
+ · 100 edges 13,001.40 0.0000 0.6000 0.0769 0.1000 0.2000 0.2000 0.4000 ±1.88% 6502
+ · 1000 edges 1,290.19 0.6000 1.3000 0.7751 0.8000 1.1000 1.3000 1.3000 ±0.87% 646
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — step (zero-radius smoothstep) 1208ms
+ · 100 edges 12,797.44 0.0000 0.5000 0.0781 0.1000 0.2000 0.2000 0.4000 ±1.89% 6400
+ · 1000 edges 1,268.48 0.6000 1.6000 0.7883 0.8000 1.3000 1.5000 1.6000 ±1.08% 635
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — screenToFlow 1772ms
+ · 100 moves 3,327,878.00 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1663939
+ · 1000 moves 533,703.26 0.0000 0.1000 0.0019 0.0000 0.1000 0.1000 0.1000 ±2.75% 266905
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — flowToScreen 1695ms
+ · 100 points 3,548,374.33 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1774542
+ · 1000 points 596,212.76 0.0000 0.1000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 298166
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — zoomAtPointer (wheel zoom) 1708ms
+ · 100 wheel steps 3,119,281.99 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1559641
+ · 1000 wheel steps 466,404.72 0.0000 0.1000 0.0021 0.0000 0.1000 0.1000 0.1000 ±2.74% 233249
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — snapPoint (drag with snap-to-grid) 1399ms
+ · 100 moves 1,187,976.00 0.0000 0.1000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 593988
+ · 1000 moves 137,674.00 0.0000 0.2000 0.0073 0.0000 0.1000 0.1000 0.1000 ±2.67% 68837
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getNodesBounds 1430ms
+ · 100 nodes 1,637,925.99 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 818963
+ · 1000 nodes 167,484.00 0.0000 0.2000 0.0060 0.0000 0.1000 0.1000 0.1000 ±2.69% 83742
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > fitViewTransform (bounds + fit) 1421ms
+ · 100 nodes 1,622,870.00 0.0000 0.4000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.77% 811435
+ · 1000 nodes 168,224.36 0.0000 0.1000 0.0059 0.0000 0.1000 0.1000 0.1000 ±2.69% 84129
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getNodePositionAbsolute — parent chain (depth 64) 1295ms
+ · single leaf walk 725,752.85 0.0000 0.1000 0.0014 0.0000 0.1000 0.1000 0.1000 ±2.75% 362949
+ · 64 nodes (all walked) 25,778.00 0.0000 0.2000 0.0388 0.1000 0.1000 0.2000 0.2000 ±2.25% 12889
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > visibleFlowRect + getVisibleNodeIds (node cull) 1309ms
+ · 100 nodes 800,992.00 0.0000 0.1000 0.0012 0.0000 0.1000 0.1000 0.1000 ±2.75% 400496
+ · 1000 nodes 80,856.00 0.0000 0.2000 0.0124 0.0000 0.1000 0.1000 0.1000 ±2.60% 40428
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getVisibleEdgeIds (edge cull by visible node set) 1310ms
+ · 100 edges 796,650.67 0.0000 0.2000 0.0013 0.0000 0.1000 0.1000 0.1000 ±2.76% 398405
+ · 1000 edges 79,270.15 0.0000 0.3000 0.0126 0.0000 0.1000 0.1000 0.1000 ±2.60% 39643
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getNodesInsideRect (marquee selection) 1429ms
+ · 100 nodes 1,742,606.00 0.0000 0.4000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.78% 871303
+ · 1000 nodes 159,956.01 0.0000 0.3000 0.0063 0.0000 0.1000 0.1000 0.1000 ±2.69% 79994
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > findClosestHandle (connect-drag snapping) 1216ms
+ · 100 nodes 3,915.22 0.1000 0.6000 0.2554 0.3000 0.4000 0.4000 0.6000 ±1.04% 1958
+ · 1000 nodes 360.41 2.7000 3.2000 2.7746 2.8000 3.2000 3.2000 3.2000 ±0.45% 181
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > applyNodeChanges (drag → position changes) 1223ms
+ · 100 position changes 115,198.00 0.0000 0.4000 0.0087 0.0000 0.1000 0.1000 0.1000 ±2.68% 57599
+ · 1000 position changes 10,344.00 0.0000 0.5000 0.0967 0.1000 0.2000 0.2000 0.4000 ±1.67% 5172
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > applyEdgeChanges (select changes) 1223ms
+ · 100 select changes 113,930.00 0.0000 0.4000 0.0088 0.0000 0.1000 0.1000 0.1000 ±2.68% 56965
+ · 1000 select changes 10,150.00 0.0000 0.5000 0.0985 0.1000 0.2000 0.2000 0.3000 ±1.63% 5075
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > addEdge (dedupe scan on connect) 1271ms
+ · append into 100 edges 422,387.52 0.0000 0.3000 0.0024 0.0000 0.1000 0.1000 0.1000 ±2.75% 211236
+ · append into 1000 edges 41,092.00 0.0000 0.3000 0.0243 0.0000 0.1000 0.1000 0.2000 ±2.47% 20546
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > FlowRoot — mount + unmount 1879ms
+ · 50 nodes / 50 edges 127.81 5.7000 43.9000 7.8242 6.8000 43.9000 43.9000 43.9000 ±16.28% 66
+ · 500 nodes / 500 edges 13.8007 64.9000 104.80 72.4600 72.8000 104.80 104.80 104.80 ±11.67% 10
+ ✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > FlowRoot — re-render after prop change (viewport pan) 4494ms
+ · 50 nodes — viewport setProps 78.8022 9.3000 97.7000 12.6900 10.0000 97.7000 97.7000 97.7000 ±35.07% 40
+ · 500 nodes — nodes setProps (controlled replace) 3.8464 234.50 331.70 259.98 258.90 331.70 331.70 331.70 ±7.26% 10
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — timeTicks (seconds mode) 1585ms
+ · realistic window (~15s @ 40px/s) 2,270,653.88 0.0000 0.5000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.80% 1135554
+ · stress window (1000s @ 4px/s) 628,932.00 0.0000 0.3000 0.0016 0.0000 0.1000 0.1000 0.1000 ±2.76% 314466
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — timecodeTicks (timecode mode) 2025ms
+ · realistic window 659,680.07 0.0000 5.2000 0.0015 0.0000 0.1000 0.1000 0.1000 ±3.44% 329906
+ · stress window 212,063.59 0.0000 0.3000 0.0047 0.0000 0.1000 0.1000 0.1000 ±2.72% 106053
+ · realistic window — drop-frame labels 639,334.00 0.0000 0.3000 0.0016 0.0000 0.1000 0.1000 0.1000 ±2.76% 319667
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — frameTicks (frames mode) 1226ms
+ · realistic window — timecode ticker w/ frame labels 13,785.24 0.0000 0.2000 0.0725 0.1000 0.2000 0.2000 0.2000 ±1.87% 6894
+ · stress window — integer-frame axis 3,373.33 0.2000 0.4000 0.2964 0.3000 0.4000 0.4000 0.4000 ±0.90% 1687
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — niceTicks (generic axis) 1620ms
+ · realistic window 2,482,165.58 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1241331
+ · stress window 689,030.00 0.0000 0.3000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.76% 344515
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > projection math — scaleLinear (time → px) 2085ms
+ · 100 values 5,357,289.99 0.0000 4.6000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.29% 2678645
+ · 1000 values 954,867.03 0.0000 0.1000 0.0010 0.0000 0.1000 0.1000 0.1000 ±2.76% 477529
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > projection math — scaleLinear (px → time, invert) 2082ms
+ · 100 pixels 5,374,567.97 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2687284
+ · 1000 pixels 897,614.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 448807
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > projection math — roundToStep (snap, pointer path) 1228ms
+ · 100 values 145,666.87 0.0000 1.2000 0.0069 0.0000 0.1000 0.1000 0.1000 ±2.72% 72848
+ · 1000 values 13,971.21 0.0000 0.4000 0.0716 0.1000 0.2000 0.2000 0.2000 ±1.90% 6987
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > useScale — projector closures 1260ms
+ · scale() × 1000 24,637.07 0.0000 0.3000 0.0406 0.1000 0.2000 0.2000 0.2000 ±2.24% 12321
+ · invert() × 100 (pointer sweep) 362,189.56 0.0000 5.2000 0.0028 0.0000 0.1000 0.1000 0.1000 ±3.41% 181131
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > label formatting — per mode 3045ms
+ · formatClock × 1000 (seconds) 58,456.00 0.0000 0.4000 0.0171 0.0000 0.1000 0.1000 0.2000 ±2.55% 29228
+ · formatTimecode × 1000 (timecode) 14,244.00 0.0000 0.4000 0.0702 0.1000 0.2000 0.2000 0.2000 ±1.92% 7122
+ · framesToTimecode × 1000 — drop-frame 11,798.00 0.0000 0.3000 0.0848 0.1000 0.2000 0.2000 0.2000 ±1.75% 5899
+ · formatFrames × 1000 (frames) 126.51 7.8000 8.0000 7.9047 7.9000 8.0000 8.0000 8.0000 ±0.20% 64
+ · formatTimeForMode × 1000 — dispatch (timecode) 14,360.00 0.0000 0.5000 0.0696 0.1000 0.2000 0.2000 0.2000 ±1.90% 7180
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > mode plumbing 3848ms
+ · modeToTickKind × 3 modes 7,138,575.98 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3569288
+ · tickFormatFor × 3 modes 7,160,973.99 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3580487
+ · secondsToFrames × 1000 900,848.00 0.0000 0.2000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 450424
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > TimeRulerRoot — mount 1816ms
+ · mount — seconds mode 3,622.55 0.1000 7.5000 0.2760 0.3000 0.5000 5.6000 6.9000 ±7.69% 1812
+ · mount — timecode mode 3,641.08 0.1000 15.8000 0.2746 0.3000 0.5000 4.9000 10.0000 ±9.53% 1826
+ · mount — frames mode 3,690.00 0.1000 17.4000 0.2710 0.3000 0.4000 3.8000 5.9000 ±8.82% 1845
+ ✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > TimeRulerRoot — re-render after prop change 1807ms
+ · zoom change (pan/zoom gesture stream) 2,864.85 0.2000 16.5000 0.3491 0.3000 0.6000 4.5000 5.0000 ±8.18% 1433
+ · offset change (pan stream) 2,864.85 0.2000 19.1000 0.3491 0.3000 0.6000 4.7000 18.3000 ±11.20% 1433
+ · mode change (timecode → frames, regenerate ladder) 2,636.42 0.2000 19.4000 0.3793 0.4000 0.7000 4.7000 5.4000 ±9.04% 1319
+ ✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > resolveAxisLock — per-frame axis decision 4332ms
+ · static axis "x" — fast path (100 frames) 5,190,889.99 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2595445
+ · axis "both", no shift-lock (100 frames) 5,139,134.18 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2570081
+ · axis "both" + shift-lock dominant-axis pick (100 frames) 3,188,760.26 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1594699
+ · axis "both" + shift-lock dominant-axis pick (1000 frames) 536,272.00 0.0000 0.1000 0.0019 0.0000 0.1000 0.1000 0.1000 ±2.75% 268136
+ ✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > computeFrame — single frame (feature on/off matrix) 4741ms
+ · free move, no snap/bounds/rect 7,265,881.98 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3632941
+ · axis-locked + scalar snap + bounds + rect (all features) 7,268,978.27 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3635216
+ · tuple snap + bounds (per-axis grid) 7,100,307.97 0.0000 0.3000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3550154
+ ✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > computeFrame — full gesture stream 2307ms
+ · 100 frames — free move (no snap/bounds) 2,542,427.52 0.0000 0.1000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1271468
+ · 100 frames — snap + bounds + rect 1,352,931.41 0.0000 0.1000 0.0007 0.0000 0.0000 0.1000 0.1000 ±2.76% 676601
+ · 1000 frames — snap + bounds + rect (stress) 158,140.37 0.0000 0.2000 0.0063 0.0000 0.1000 0.1000 0.1000 ±2.68% 79086
+ ✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > simulated flush() pipeline — resolveAxisLock + computeFrame 2103ms
+ · 100 moves — shift-lock, no snap/bounds 1,252,108.00 0.0000 0.2000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 626054
+ · 100 moves — shift-lock + snap + bounds + rect 714,692.00 0.0000 0.2000 0.0014 0.0000 0.1000 0.1000 0.1000 ±2.76% 357346
+ · 1000 moves — shift-lock + snap + bounds + rect (stress) 71,891.62 0.0000 0.2000 0.0139 0.0000 0.1000 0.1000 0.1000 ±2.59% 35953
+ ✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > usePointerDrag — mount N instances 1444ms
+ · mount 50 draggable handles 3,532.68 0.1000 16.5000 0.2831 0.3000 4.5000 5.5000 8.6000 ±10.78% 1778
+ · mount 500 draggable handles (stress) 375.97 2.0000 9.8000 2.6598 2.3000 8.8000 9.8000 9.8000 ±8.40% 189
+ ✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > usePointerDrag — update after prop change 806ms
+ · 50 handles → re-render to 60 handles 1,159.21 0.2000 379.80 0.8627 0.4000 5.2000 5.8000 379.80 ±105.99% 814
+ ✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > usePointerDrag — live event round-trip (rAF-coalesced) 2625ms
+ · mount + down + 20 moves + up 5.7202 173.00 176.30 174.82 176.10 176.30 176.30 176.30 ±0.48% 10
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > countBars 3183ms
+ · small body (300px) 7,352,141.59 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3676806
+ · large body (1800px) 7,120,649.88 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3561037
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > resamplePeaks — by source length (100 buckets) 2620ms
+ · 100 peaks 1,162,090.00 0.0000 0.4000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.82% 581045
+ · 1000 peaks 274,426.00 0.0000 0.3000 0.0036 0.0000 0.1000 0.1000 0.1000 ±2.74% 137213
+ · 10000 peaks 32,241.55 0.0000 0.3000 0.0310 0.1000 0.1000 0.2000 0.2000 ±2.39% 16124
+ · 10000 peaks (Float32Array) 28,240.00 0.0000 0.4000 0.0354 0.1000 0.2000 0.2000 0.2000 ±2.35% 14120
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > resamplePeaks — by bucket count (10000 peaks) 1815ms
+ · 100 buckets 30,416.00 0.0000 0.3000 0.0329 0.1000 0.1000 0.2000 0.2000 ±2.37% 15208
+ · 600 buckets 24,258.00 0.0000 0.4000 0.0412 0.1000 0.2000 0.2000 0.2000 ±2.27% 12129
+ · upsample → 2000 buckets 17,884.42 0.0000 0.4000 0.0559 0.1000 0.2000 0.2000 0.3000 ±2.12% 8944
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > resamplePeaks — windowed slice (zoom/scroll) 1219ms
+ · full window — 600 buckets over 10000 24,211.16 0.0000 0.4000 0.0413 0.1000 0.2000 0.2000 0.3000 ±2.26% 12108
+ · 25% zoom window — 600 buckets over slice 81,302.00 0.0000 0.5000 0.0123 0.0000 0.1000 0.1000 0.2000 ±2.65% 40651
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildBars — bars-mode geometry 1837ms
+ · 100 bars from 1000 peaks 192,124.00 0.0000 0.4000 0.0052 0.0000 0.1000 0.1000 0.1000 ±2.73% 96062
+ · 600 bars from 10000 peaks 20,179.96 0.0000 0.5000 0.0496 0.1000 0.2000 0.2000 0.3000 ±2.19% 10092
+ · 600 bars from 10000 peaks (Float32Array) 20,065.99 0.0000 0.5000 0.0498 0.1000 0.2000 0.2000 0.3000 ±2.19% 10035
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildBars — sliding window (simulated scrub/zoom recompute) 608ms
+ · 600 bars, window slides per iteration 48,556.00 0.0000 0.3000 0.0206 0.0000 0.1000 0.1000 0.2000 ±2.52% 24278
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildPathPoints — path-mode silhouette 1229ms
+ · 256 samples from 1000 peaks 143,684.00 0.0000 0.3000 0.0070 0.0000 0.1000 0.1000 0.1000 ±2.68% 71842
+ · 1024 samples from 10000 peaks 17,946.41 0.0000 0.4000 0.0557 0.1000 0.2000 0.2000 0.2000 ±2.08% 8975
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildSmoothPath — Catmull-Rom path string 1814ms
+ · 256 points, tension 0 20,822.00 0.0000 12.7000 0.0480 0.1000 0.2000 0.2000 0.3000 ±5.58% 10411
+ · 256 points, tension 0.5 21,690.00 0.0000 1.8000 0.0461 0.1000 0.2000 0.2000 0.3000 ±2.49% 10845
+ · 1024 points, tension 0 3,891.22 0.1000 1.9000 0.2570 0.3000 0.5000 0.6000 1.9000 ±1.71% 1946
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > WaveformRoot + WaveformBars — mount 1212ms
+ · mount with ~50-bar fixture 3,469.92 0.1000 25.3000 0.2882 0.3000 0.6000 4.6000 14.5000 ±12.57% 1736
+ · mount with ~500-bar fixture 2,211.16 0.3000 7.5000 0.4523 0.4000 2.1000 2.3000 3.4000 ±4.26% 1110
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > WaveformRoot — update after prop change 1209ms
+ · currentTime change → patch 3,270.69 0.1000 4.4000 0.3057 0.3000 0.6000 3.7000 4.3000 ±5.00% 1636
+ · peaks swap → re-resample + patch 3,008.00 0.1000 26.9000 0.3324 0.3000 0.4000 4.2000 18.1000 ±13.36% 1504
+ ✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > WaveformRoot + WaveformPath — mount 1212ms
+ · path mode, 256 samples 3,722.51 0.1000 26.3000 0.2686 0.3000 0.4000 4.8000 21.6000 ±14.62% 1862
+ · path mode, 1024 samples 3,783.24 0.1000 24.8000 0.2643 0.2000 0.5000 4.8000 23.8000 ±14.72% 1892
+ ✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > sampleKeyframes — single sample by curve size 2895ms
+ · 100 keyframes — sample mid-range 6,003,987.24 0.0000 0.4000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 3002594
+ · 1000 keyframes — sample mid-range 6,322,849.43 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 3162057
+ ✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > sampleKeyframes — full curve sweep (per-frame readout) 1241ms
+ · 100 keyframes × 120 samples 131,984.00 0.0000 0.3000 0.0076 0.0000 0.1000 0.1000 0.1000 ±2.68% 65992
+ · 1000 keyframes × 120 samples 132,100.00 0.0000 0.4000 0.0076 0.0000 0.1000 0.1000 0.1000 ±2.68% 66050
+ ✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > solveBezierX — easing solve 1954ms
+ · identity (linear) × 64 4,689,626.08 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2345282
+ · ease-in-out (Newton-Raphson) × 64 684,475.11 0.0000 0.1000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.75% 342306
+ ✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > sortKeyframes — reconcile / commit 1289ms
+ · 100 keyframes (reverse-sorted input) 572,597.48 0.0000 0.3000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.77% 286356
+ · 1000 keyframes (reverse-sorted input) 58,944.00 0.0000 0.5000 0.0170 0.0000 0.1000 0.1000 0.2000 ±2.58% 29472
+ ✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > clampKeyframeTime — neighbour clamp (pointer drag) 1495ms
+ · 100 keyframes × 100 moves 1,118,014.00 0.0000 0.1000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.76% 559007
+ · 1000 keyframes × 100 moves 1,098,890.23 0.0000 0.1000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.76% 549555
+ ✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > snapTimeToFrame — frame-grid quantize 942ms
+ · 100 quantize ops @30fps 2,803,763.98 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1401882
+ ✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > defaultKeyframeValueText — aria-valuetext 653ms
+ · 100 value-text formats (with property) 374,868.00 0.0000 2.1000 0.0027 0.0000 0.1000 0.1000 0.1000 ±2.87% 187434
+ ✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > KeyframeTrackRoot — mount + unmount 3241ms
+ · mount 50 keyframes 124.68 6.5000 17.3000 8.0206 7.5000 17.3000 17.3000 17.3000 ±7.59% 63
+ · mount 500 keyframes 5.5850 167.40 205.80 179.05 182.80 205.80 205.80 205.80 ±4.79% 10
+ ✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > KeyframeTrackRoot — re-render after prop change 3593ms
+ · 50 keyframes — duration change + flush 106.93 8.1000 14.6000 9.3519 8.8000 14.6000 14.6000 14.6000 ±5.96% 54
+ · 500 keyframes — duration change + flush 5.0792 191.20 211.80 196.88 198.90 211.80 211.80 211.80 ±2.12% 10
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > evalCubicBezier — sweep t 2273ms
+ · 100 params 5,712,887.99 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2856444
+ · 1000 params 1,745,702.86 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 873026
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > cubicBezierTangent — sweep t 2221ms
+ · 100 params 5,713,947.23 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2857545
+ · 1000 params 1,732,195.56 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 866271
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > solveBezierX — ease (x→y) 1966ms
+ · 100 params 430,674.00 0.0000 0.3000 0.0023 0.0000 0.1000 0.1000 0.1000 ±2.75% 215337
+ · 1000 params 75,640.00 0.0000 0.2000 0.0132 0.0000 0.1000 0.1000 0.1000 ±2.59% 37820
+ · 1000 params — identity short-circuit 746,870.00 0.0000 0.1000 0.0013 0.0000 0.1000 0.1000 0.1000 ±2.75% 373435
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > cubicBezier1D — scalar Bernstein 799ms
+ · 1000 params 1,727,796.00 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 863898
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > catmullRom — sweep t 2559ms
+ · 50 knots × 100 params 573,049.39 0.0000 0.2000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 286582
+ · 500 knots × 100 params 566,750.00 0.0000 0.2000 0.0018 0.0000 0.1000 0.1000 0.1000 ±2.75% 283375
+ · 500 knots × 1000 params 61,514.00 0.0000 0.2000 0.0163 0.0000 0.1000 0.1000 0.2000 ±2.56% 30757
+ · 500 knots × 1000 params — closed 25,026.00 0.0000 0.5000 0.0400 0.1000 0.2000 0.2000 0.2000 ±2.28% 12513
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > monotoneCubic — build 1223ms
+ · 100 knots 107,972.00 0.0000 0.4000 0.0093 0.0000 0.1000 0.1000 0.1000 ±2.65% 53986
+ · 1000 knots 11,616.00 0.0000 0.4000 0.0861 0.1000 0.2000 0.2000 0.3000 ±1.74% 5808
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > monotoneCubic — apply (pre-built fn) 1841ms
+ · 100 knots → 256-LUT 112,353.53 0.0000 0.2000 0.0089 0.0000 0.1000 0.1000 0.1000 ±2.65% 56188
+ · 1000 knots → 256-LUT 98,026.39 0.0000 0.3000 0.0102 0.0000 0.1000 0.1000 0.1000 ±2.64% 49023
+ · 1000 knots → 1024-LUT 22,792.00 0.0000 0.3000 0.0439 0.1000 0.2000 0.2000 0.2000 ±2.19% 11396
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > monotoneCubic — build + apply (knots changed) 1211ms
+ · 100 knots → build + 256-LUT 51,035.79 0.0000 0.3000 0.0196 0.0000 0.1000 0.1000 0.2000 ±2.51% 25523
+ · 1000 knots → build + 256-LUT 10,241.95 0.0000 0.4000 0.0976 0.1000 0.2000 0.2000 0.3000 ±1.64% 5122
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > linearInterpolate — query sweep 1218ms
+ · 100 knots × 1000 queries 58,300.00 0.0000 0.2000 0.0172 0.0000 0.1000 0.1000 0.2000 ±2.54% 29150
+ · 1000 knots × 1000 queries 45,376.00 0.0000 0.2000 0.0220 0.0000 0.1000 0.1000 0.2000 ±2.47% 22688
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > sampleToPolyline — bezier curve 1256ms
+ · 100 segments 302,246.00 0.0000 0.2000 0.0033 0.0000 0.1000 0.1000 0.1000 ±2.73% 151123
+ · 1000 segments 29,642.07 0.0000 0.2000 0.0337 0.1000 0.1000 0.2000 0.2000 ±2.33% 14824
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > sampleFnToPolyline — monotone curve 1244ms
+ · 100 segments 237,716.46 0.0000 0.2000 0.0042 0.0000 0.1000 0.1000 0.1000 ±2.72% 118882
+ · 1000 segments 24,476.00 0.0000 0.3000 0.0409 0.1000 0.2000 0.2000 0.2000 ±2.24% 12238
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > buildPolylinePath — string concat 1248ms
+ · 100 points 292,709.46 0.0000 5.2000 0.0034 0.0000 0.1000 0.1000 0.1000 ±3.40% 146384
+ · 1000 points 20,331.93 0.0000 4.1000 0.0492 0.1000 0.2000 0.2000 0.3000 ±2.83% 10168
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > buildSmoothPath — Catmull-Rom cubics 1836ms
+ · 50 points 164,496.00 0.0000 1.7000 0.0061 0.0000 0.1000 0.1000 0.1000 ±2.90% 82248
+ · 500 points 11,876.00 0.0000 2.8000 0.0842 0.1000 0.2000 0.3000 0.5000 ±2.30% 5938
+ · 500 points — tension 0.5 11,907.62 0.0000 1.5000 0.0840 0.1000 0.2000 0.2000 0.8000 ±2.10% 5955
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > buildBezierPath — single segment 1584ms
+ · 1 segment 7,229,343.95 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3614672
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > pointer-move — smooth path rebuild 622ms
+ · drag mutate + buildSmoothPath (64 points) 124,047.19 0.0000 3.3000 0.0081 0.0000 0.1000 0.1000 0.1000 ±3.04% 62036
+ ✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > pointer-move — curve recompute 609ms
+ · mutate knot + monotoneCubic + 256-LUT (100 knots) 50,884.00 0.0000 0.4000 0.0197 0.0000 0.1000 0.1000 0.2000 ±2.52% 25442
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: scaleLinear (pointer projection) 2187ms
+ · scaleLinear ×100 5,601,259.99 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2800630
+ · scaleLinear ×1000 1,711,881.63 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 856112
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: roundToStep (snap-to-step hot path) 1223ms
+ · roundToStep ×100 139,038.19 0.0000 0.4000 0.0072 0.0000 0.1000 0.1000 0.1000 ±2.69% 69533
+ · roundToStep ×1000 14,550.00 0.0000 0.3000 0.0687 0.1000 0.2000 0.2000 0.2000 ±1.93% 7275
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: getStepDecimals (per-step cache miss) 609ms
+ · getStepDecimals ×1000 (varied step) 57,814.44 0.0000 0.4000 0.0173 0.0000 0.1000 0.1000 0.2000 ±2.55% 28913
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: getClosestValueIndex (nearest-thumb pick) 1233ms
+ · 100 thumbs ×100 picks 185,872.83 0.0000 0.2000 0.0054 0.0000 0.1000 0.1000 0.1000 ±2.70% 92955
+ · 1000 thumbs ×100 picks 19,448.00 0.0000 0.2000 0.0514 0.1000 0.2000 0.2000 0.2000 ±2.09% 9724
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: hasMinStepsBetweenSortedValues (drag invariant) 2044ms
+ · 100 values 5,019,368.13 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2510186
+ · 1000 values 1,238,818.25 0.0000 0.1000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 619533
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: niceNum (tick rounding primitive) 866ms
+ · niceNum ×1000 (varied magnitude) 1,927,046.59 0.0000 0.1000 0.0005 0.0000 0.0000 0.1000 0.1000 ±2.76% 963716
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: niceTicks (realistic vs stress) 2234ms
+ · realistic (600s axis) 2,542,638.00 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1271319
+ · stress (10h axis, dense range) 186,886.00 0.0000 0.3000 0.0054 0.0000 0.1000 0.1000 0.1000 ±2.72% 93443
+ · stress + custom format 164,223.16 0.0000 0.4000 0.0061 0.0000 0.1000 0.1000 0.1000 ±2.73% 82128
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: timeTicks (human time ladder) 1426ms
+ · realistic (600s axis) 1,647,574.49 0.0000 0.4000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.77% 823952
+ · stress (10h axis, dense range) 53,833.23 0.0000 0.3000 0.0186 0.0000 0.1000 0.1000 0.2000 ±2.53% 26922
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: timecodeTicks (frame-aligned, fps conversion) 1297ms
+ · realistic (600s @ 30fps) 548,354.33 0.0000 0.4000 0.0018 0.0000 0.1000 0.1000 0.1000 ±2.77% 274232
+ · stress (10h @ 29.97fps drop-frame labels) 57,158.00 0.0000 0.4000 0.0175 0.0000 0.1000 0.1000 0.2000 ±2.57% 28579
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: frameTicks (integer-frame axis) 1210ms
+ · realistic (18000-frame axis) 15,666.00 0.0000 0.2000 0.0638 0.1000 0.2000 0.2000 0.2000 ±1.96% 7833
+ · stress (1.08M-frame axis, dense range) 583.65 1.6000 2.2000 1.7134 1.7000 1.8000 1.8000 2.2000 ±0.45% 292
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > timecode: framesToTimecode label formatting 1843ms
+ · non-drop ×100 131,950.00 0.0000 0.5000 0.0076 0.0000 0.1000 0.1000 0.1000 ±2.69% 65975
+ · drop-frame 29.97 ×100 98,946.00 0.0000 0.4000 0.0101 0.0000 0.1000 0.1000 0.1000 ±2.65% 49473
+ · drop-frame 29.97 ×1000 11,649.67 0.0000 0.5000 0.0858 0.1000 0.2000 0.2000 0.2000 ±1.76% 5826
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > timecode: scalar label formatters 2705ms
+ · formatClock ×1000 38,464.00 0.0000 0.5000 0.0260 0.1000 0.1000 0.1000 0.2000 ±2.44% 19232
+ · formatTimecode ×1000 (@30fps) 14,127.17 0.0000 0.5000 0.0708 0.1000 0.2000 0.2000 0.3000 ±1.93% 7065
+ · formatFrames ×1000 125.00 7.8000 8.1000 8.0000 8.0000 8.1000 8.1000 8.1000 ±0.20% 63
+ · secondsToFrames ×1000 1,958,522.30 0.0000 0.1000 0.0005 0.0000 0.0000 0.1000 0.1000 ±2.76% 979457
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > useScale: composable construction 2233ms
+ · build (plain options) 3,913,615.30 0.0000 0.2000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1957199
+ · build (clamp + step + ticks) 3,798,454.33 0.0000 5.6000 0.0003 0.0000 0.0000 0.0000 0.1000 ±3.54% 1899607
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > useScale: pointer-move loop (scale/invert/roundValue) 1825ms
+ · invert+round ×100 events 75,092.00 0.0000 0.3000 0.0133 0.0000 0.1000 0.1000 0.1000 ±2.59% 37546
+ · invert+round ×1000 events 7,250.55 0.0000 0.5000 0.1379 0.2000 0.3000 0.3000 0.4000 ±1.43% 3626
+ · scale ×1000 events 32,496.00 0.0000 0.2000 0.0308 0.1000 0.1000 0.2000 0.2000 ±2.36% 16248
+ ✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > useScale: reactive tick recompute on domain change (zoom/pan) 664ms
+ · zoom step → recompute ticks/major/minor 425,508.00 0.0000 11.8000 0.0024 0.0000 0.1000 0.1000 0.1000 ±6.84% 212754
+ ✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > pointer → saturation/value math 2226ms
+ · pointerToSV — 100 moves (ltr) 2,405,101.98 0.0000 0.1000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1202551
+ · pointerToSV — 1000 moves (ltr) 333,625.28 0.0000 0.1000 0.0030 0.0000 0.1000 0.1000 0.1000 ±2.73% 166846
+ · pointerToSV — 1000 moves (rtl flip) 336,208.76 0.0000 0.1000 0.0030 0.0000 0.1000 0.1000 0.1000 ±2.73% 168138
+ ✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > clampChannel — channel clamp 681ms
+ · clampChannel — 1000 calls 675,342.93 0.0000 0.2000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.75% 337739
+ ✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > hsvToRgb — hue background recompute 1839ms
+ · hsvToRgb — 100 colors 187,550.00 0.0000 0.4000 0.0053 0.0000 0.1000 0.1000 0.1000 ±2.71% 93775
+ · hsvToRgb — 1000 colors 21,726.00 0.0000 0.3000 0.0460 0.1000 0.2000 0.2000 0.2000 ±2.19% 10863
+ · hsvaToCss — 1000 colors (full hsva) 15,998.80 0.0000 0.3000 0.0625 0.1000 0.2000 0.2000 0.2000 ±1.96% 8001
+ ✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > preserve-hue setters — drag/key commit 1228ms
+ · setSaturationValue — 1000 commits (sweep incl. grey) 477.43 1.7000 14.6000 2.0946 1.9000 9.7000 9.8000 14.6000 ±8.93% 239
+ · setSaturation + setValue — 1000 key nudges 215.27 3.7000 20.6000 4.6454 4.0000 14.5000 20.6000 20.6000 ±11.18% 108
+ ✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > mount — ColorAreaRoot + N thumbs 1574ms
+ · mount + unmount — 50 thumbs 481.33 1.6000 8.4000 2.0776 1.8000 8.1000 8.3000 8.4000 ±7.89% 241
+ · mount + unmount — 500 thumbs 48.6003 16.5000 31.7000 20.5760 24.1000 31.7000 31.7000 31.7000 ±8.52% 25
+ ✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > update — re-render after HSVA change 603ms
+ · 1 thumb — mount then patch new HSVA 8,746.25 0.0000 14.5000 0.1143 0.1000 0.2000 0.3000 6.4000 ±9.94% 4374
+ ✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > histogramMax — peak scan 4098ms
+ · 100 bins 4,995,467.98 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2497734
+ · 256 bins 3,199,458.11 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1600049
+ · 1000 bins 1,277,438.00 0.0000 0.1000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 638719
+ · 256 bins — all zero (guard path) 3,267,112.59 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1633883
+ ✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBars — linear (peak scan + normalise + alloc) 1886ms
+ · 100 bins 340,717.86 0.0000 1.0000 0.0029 0.0000 0.1000 0.1000 0.1000 ±2.76% 170393
+ · 256 bins 141,573.69 0.0000 0.5000 0.0071 0.0000 0.1000 0.1000 0.1000 ±2.70% 70801
+ · 1000 bins 35,842.00 0.0000 0.6000 0.0279 0.1000 0.1000 0.1000 0.2000 ±2.44% 17921
+ ✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBars — log (log1p per bin + alloc) 1865ms
+ · 100 bins 269,120.18 0.0000 0.6000 0.0037 0.0000 0.1000 0.1000 0.1000 ±2.73% 134587
+ · 256 bins 107,862.00 0.0000 0.6000 0.0093 0.0000 0.1000 0.1000 0.1000 ±2.68% 53931
+ · 1000 bins 24,446.00 0.0000 0.9000 0.0409 0.1000 0.2000 0.2000 0.2000 ±2.29% 12223
+ ✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBars — all-zero guard (no NaN, no divide) 1244ms
+ · 256 bins — linear 144,297.14 0.0000 0.6000 0.0069 0.0000 0.1000 0.1000 0.1000 ±2.72% 72163
+ · 256 bins — log 142,830.00 0.0000 0.5000 0.0070 0.0000 0.1000 0.1000 0.1000 ±2.71% 71415
+ ✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBarHeight — per-bin scalar (1000x loop) 1613ms
+ · linear x1000 1,734,069.99 0.0000 0.2000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 867035
+ · log x1000 1,715,284.95 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 857814
+ ✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > per-channel projection (RGB composite, record data) 1209ms
+ · 4 channels x 100 bins x 2 scales 33,410.00 0.0000 1.1000 0.0299 0.1000 0.1000 0.1000 0.2000 ±2.45% 16705
+ · 4 channels x 1000 bins x 2 scales 3,681.26 0.1000 0.9000 0.2716 0.3000 0.4000 0.4000 0.9000 ±1.11% 1841
+ ✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > HistogramRoot + HistogramBars — mount 1843ms
+ · 50 bars (linear) 1,078.00 0.6000 31.0000 0.9276 0.8000 5.1000 5.2000 31.0000 ±13.21% 539
+ · 500 bars (linear) 137.50 5.5000 40.9000 7.2725 6.3000 40.9000 40.9000 40.9000 ±14.68% 69
+ · 500 bars (log) 135.59 5.6000 49.4000 7.3750 6.3000 49.4000 49.4000 49.4000 ±17.87% 68
+ ✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > HistogramRoot + HistogramBars — update after prop change 1266ms
+ · 500 bars — scaleType linear → log 96.9721 8.8000 13.6000 10.3122 12.2000 13.6000 13.6000 13.6000 ±4.41% 49
+ · record data — channel l → rgb (expand to 3 primaries) 166.20 4.6000 47.2000 6.0167 5.0000 47.2000 47.2000 47.2000 ±17.36% 84
+ ✓ |chromium| src/internal/utils/__test__/getRawChildren.bench.ts > getRawChildren 4228ms
+ · flat elements 6,294,584.19 0.0000 4.6000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.32% 3148551
+ · mixed elements and comments 1,001,372.00 0.0000 0.3000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.77% 500686
+ · single fragment with children 1,208,230.35 0.0000 0.3000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 604236
+ · nested fragments (depth 5) 499,336.13 0.0000 0.3000 0.0020 0.0000 0.1000 0.1000 0.1000 ±2.75% 249718
+ · wide fragment (50 children) 86,290.74 0.0000 0.4000 0.0116 0.0000 0.1000 0.1000 0.1000 ±2.63% 43154
+ ✓ |chromium| src/internal/utils/__test__/getRawChildren.bench.ts > getRawChildren — BAIL path 2383ms
+ · 1 keyed fragment (no BAIL) 1,520,578.00 0.0000 0.3000 0.0007 0.0000 0.0000 0.1000 0.1000 ±2.78% 760289
+ · 2 keyed fragments (BAIL triggered) 1,533,169.38 0.0000 0.4000 0.0007 0.0000 0.0000 0.1000 0.1000 ±2.79% 766738
+ · 3 keyed fragments (BAIL triggered) 1,175,990.00 0.0000 0.4000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.77% 587995
+ ✓ |chromium| src/internal/utils/__test__/getRawChildren.bench.ts > patch — optimized vs BAIL patchFlag 2535ms
+ · patch with TEXT patchFlag 188,428.83 0.0000 3.6000 0.0053 0.0000 0.1000 0.1000 0.1000 ±5.49% 94384
+ · patch with BAIL patchFlag 186,314.00 0.0000 3.8000 0.0054 0.0000 0.1000 0.1000 0.1000 ±5.34% 93157
+ · patch with CLASS patchFlag 186,798.64 0.0000 5.4000 0.0054 0.0000 0.1000 0.1000 0.1000 ±5.87% 93418
+ · patch with CLASS→BAIL patchFlag 189,736.00 0.0000 3.9000 0.0053 0.0000 0.1000 0.1000 0.1000 ±5.56% 94868
+ ✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > baseline: raw h() 2736ms
+ · h() — 1 attr 2,030,339.94 0.0000 0.5000 0.0005 0.0000 0.0000 0.0000 0.1000 ±2.79% 1015373
+ · h() — 5 attrs 2,006,400.00 0.0000 4.1000 0.0005 0.0000 0.0000 0.0000 0.1000 ±3.20% 1003200
+ · h() — 15 attrs 1,971,350.00 0.0000 0.5000 0.0005 0.0000 0.0000 0.1000 0.1000 ±2.79% 985675
+ ✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > baseline: raw cloneVNode() 3321ms
+ · cloneVNode — 1 attr 4,866,745.99 0.0000 4.4000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.26% 2433373
+ · cloneVNode — 5 attrs 3,803,831.25 0.0000 0.4000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.78% 1902296
+ · cloneVNode — 15 attrs 2,318,270.00 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1159135
+ ✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Primitive vs h() 2658ms
+ · h("div") — baseline 1,979,428.12 0.0000 5.8000 0.0005 0.0000 0.0000 0.0000 0.1000 ±3.58% 989912
+ · Primitive({ as: "div" }) 2,033,420.00 0.0000 0.4000 0.0005 0.0000 0.0000 0.0000 0.1000 ±2.78% 1016710
+ · Primitive({ as: "template" }) — Slot mode 2,260,389.99 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1130195
+ ✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Slot — scaling by attrs 2697ms
+ · 1 attr 2,604,471.11 0.0000 4.7000 0.0004 0.0000 0.0000 0.0000 0.1000 ±3.32% 1302496
+ · 5 attrs 2,245,925.99 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1122963
+ · 15 attrs (mixed types) 1,642,142.00 0.0000 0.3000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.77% 821071
+ ✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Slot — edge cases 2331ms
+ · child with comments to skip 1,212,457.51 0.0000 0.3000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 606350
+ · no default slot 7,219,866.03 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3610655
+ ✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Slot — fresh attrs per call 1768ms
+ · 5 attrs (stable ref) 2,232,887.43 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1116667
+ · 5 attrs (new object) 2,244,653.07 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.79% 1122551
+ ✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Primitive — mount + update via render() 1852ms
+ · h("div") — mount + update 135,494.00 0.0000 6.2000 0.0074 0.0000 0.1000 0.1000 0.1000 ±6.78% 67747
+ · Primitive({ as: "div" }) — mount + update 39,210.00 0.0000 6.5000 0.0255 0.0000 0.1000 0.1000 0.2000 ±6.71% 19605
+ · Primitive({ as: "template" }) — mount + update 39,744.05 0.0000 19.9000 0.0252 0.0000 0.1000 0.1000 0.2000 ±10.03% 19876
diff --git a/vue/primitives/PERF_AUDIT.md b/vue/primitives/PERF_AUDIT.md
new file mode 100644
index 0000000..0efa042
--- /dev/null
+++ b/vue/primitives/PERF_AUDIT.md
@@ -0,0 +1,1038 @@
+# Performance & Memory-Leak Audit — `@robonen/primitives`
+
+> Generated by a 120-agent workflow audit (per-component review against a Vue-internals + V8-JIT checklist, with adversarial verification of every critical/high/leak finding). This is the **baseline** stage; fixes and re-measurement follow.
+
+## Headline numbers
+
+| Metric | Count |
+|---|---|
+| Components audited | 77 |
+| Components clean (0 findings) | 16 |
+| Total findings (non-false-positive) | 142 |
+| High severity | 10 |
+| Confirmed memory leaks | 8 |
+| Hot-path findings | 87 |
+| Bench baselines authored | 13 |
+
+## Executive summary
+
+Across ~70 confirmed findings, @robonen/primitives is structurally sound but carries two categories of real risk that concentrate in the canvas/media-editor and selection/menu families. (1) Memory leaks: at least four high-severity gesture leaks (canvas/crop, canvas/curve-editor, canvas/levels, forms/slider) and several medium/low ones (overlays/drawer body-position pin, drawer/canvas-stage/date-picker timers and stale element refs) all share one root cause — resources (window pointer listeners, ResizeObservers, setTimeout, body style mutations, context element refs) are torn down only on the gesture's own happy-path end event, never on onScopeDispose/unmount, so any mid-gesture unmount (v-if toggle, route change, panel close) leaks listeners plus the retained reactive component graph and detached DOM. (2) Hot-path waste: the single largest multiplier is the shared collection getItems() (utilities/collection/useCollection.ts:94), whose sort comparator calls orderedNodes.indexOf twice per comparison — O(n^2 log n) plus a fresh querySelectorAll — and is invoked per keystroke/pointer-move by listbox, menu, menubar, toolbar, tabs, tree, roving-focus, radio-group, stepper, accordion and more; fixing its comparator to a precomputed Map turns dozens of interactions from quadratic to O(n log n) in one edit. The second systemic multiplier is per-pointer-frame overhead: the internal usePointerDrag state is a deep reactive() firing ~13 subscriber-less triggers per frame, and many canvas components re-read getBoundingClientRect() every drag frame instead of caching the rect at gesture start (color-area, hue-slider, angle-dial, levels, waveform, compare-slider). Recurring micro-patterns — deep ref() on wholesale-replaced HSVA/array state that should be shallowRef, deep:true watches on objects only ever replaced by reference, O(n) find/indexOf per list item driven by a per-frame-invalidated computed (O(n^2)), Array.from({length}) defeating V8 packed arrays, and per-call Intl.DateTimeFormat construction — repeat across 8+ components each. Headline: 4 high-sev leaks + ~3 more leak findings, 1 shared collection fix unblocking ~12 components, and ~25 per-frame allocation/layout-read findings clustered in the canvas editors.
+
+## Cross-cutting themes
+
+### 1. Gesture-scoped resources removed only on the happy-path end event, never on scope-dispose/unmount (window listeners, observers, timers, body-style, context element refs leak on mid-gesture unmount)
+
+**Components:** `canvas/crop`, `canvas/curve-editor`, `canvas/levels`, `forms/slider`, `overlays/drawer`, `canvas/canvas-stage`, `display/date-picker`
+
+**Impact:** High. Window pointermove/up/cancel listeners, ResizeObservers, setTimeout callbacks and body position:fixed are torn down only inside the pointerup/settle handler reachable from the gesture itself. If the component unmounts while a drag/zoom is in flight (parent v-if, route change, panel teardown), the resource survives and its closure retains the unmounted instance, its reactive state and detached DOM — a real per-interrupted-gesture leak, plus broken page scroll (drawer body pin) and callbacks firing on dead refs. Uniform fix: route gestures through usePointerDrag (which scope-disposes) or add onScopeDispose(stopGesture) so unmount runs the same teardown.
+
+### 2. Shared collection getItems() uses indexOf-in-sort-comparator (O(n^2 log n)) plus per-call querySelectorAll, fanned out per keystroke and per list item
+
+**Components:** `utilities/collection`, `selection/listbox`, `menus/menu`, `menus/menubar`, `menus/toolbar`, `disclosure/tabs`, `navigation/tree`, `utilities/roving-focus`, `forms/radio-group`, `forms/stepper`, `forms/toggle-group`, `disclosure/accordion`, `menus/navigation-menu`
+
+**Impact:** High leverage / single root cause. The comparator `orderedNodes.indexOf(a.ref) - orderedNodes.indexOf(b.ref)` is O(n) twice per comparison, making every keyboard nav, typeahead, and per-item isTabStop/index computed quadratic; listbox Shift-nav alone re-runs it ~6x per keystroke. One fix (build a Map once, sort by lookup) drops the whole family to O(n log n). Compounded by callers that call getItems multiple times per interaction and by per-item computeds that each re-invoke it on every invalidation.
+
+### 3. Per-item O(n) find/findIndex/indexOf inside a computed that is invalidated every drag frame or every selection change -> O(n^2)
+
+**Components:** `canvas/curve-editor`, `canvas/gradient-editor`, `canvas/keyframe-track`, `canvas/timeline`, `canvas/waveform`, `navigation/tree`, `forms/toggle-group`, `forms/radio-group`, `menus/toolbar`, `selection/select`
+
+**Impact:** High in the canvas editors. Each list-item part resolves its own data by scanning the shared array (find by id, indexOf), and the shared array is replaced wholesale every rAF during a drag (or on every selection mutation), so all N items re-scan every frame = O(n^2) per frame / per select. Uniform fix: build one memoized id->{item,index} Map (or a single shared tab-stop computed) in the Root and have each item do an O(1) lookup / equality check.
+
+### 4. deep ref() / deep reactive() on state that is always replaced wholesale and never mutated in place
+
+**Components:** `internal/pointer-drag`, `internal/snapping`, `internal/utils`, `overlays/tooltip`, `color/alpha-slider`, `color/color-field`, `forms/pin-input`, `forms/checkbox`, `feedback/toast`
+
+**Impact:** Medium-high on hot paths. usePointerDrag's deep reactive() state fires ~13 subscriber-less triggers per frame; HSVA color state, snap-target arrays, grace-area polygons, pin/checkbox arrays and toast swipe deltas are deep ref()s whose objects are replaced by reference, so deep proxying buys nothing and adds per-property proxy-get/track cost on every pointer-move. Uniform fix: shallowRef (or plain object/locals) since identity replacement still triggers correctly.
+
+### 5. deep:true watch on an object/array that only ever changes by reference
+
+**Components:** `color/alpha-slider`, `color/color-field`, `color/hue-slider`, `selection/combobox`, `canvas/keyframe-track`, `disclosure/tabs`
+
+**Impact:** Medium. Watchers on HSVA / model arrays use deep:true, forcing a full recursive traverse() to re-collect deps on every fire — once per pointer-move during a color drag — even though the wholesale ref-identity change already triggers a shallow watch. Drop deep:true (or use deep:1 if a bounded splice walk is needed). Several share the same useColorState/useHsvaSetters module, so one fix compounds across hue/alpha/saturation sliders.
+
+### 6. getBoundingClientRect() (or layout read) per pointer-move frame instead of caching the rect at gesture start
+
+**Components:** `color/color-area`, `color/hue-slider`, `canvas/angle-dial`, `canvas/levels`, `canvas/waveform`, `canvas/compare-slider`, `display/scroll-area`, `overlays/drawer`
+
+**Impact:** Medium. Each drag frame forces a synchronous layout/reflow to re-measure an element whose box does not change mid-drag; usePointerDrag already exposes trackElementRect/elementPoint or an onStart hook to snapshot it once. Cache rect/center/size in onStart and reuse on move; clear on end. Removes one forced reflow per frame per active gesture.
+
+### 7. Fresh object/array/style literal allocated per render or per frame on hot/repeated elements (no stable identity)
+
+**Components:** `canvas/zoom-pan`, `canvas/histogram`, `canvas/waveform`, `canvas/flow`, `overlays/popover`, `overlays/popper`, `menus/menu`, `menus/context-menu`, `feedback/toast`, `canvas/angle-dial`
+
+**Impact:** Medium/low. Static :style objects, per-bar style objects, per-edge interaction styles, and rect/point literals are re-created every render or every position-update frame, churning the young gen (and, for v-bind=obj, flipping vnodes to FULL_PROPS which disables patchFlag fast paths). Fixes: hoist static portions to module-level frozen consts, precompute per-item styles inside the existing series/buckets computed, and split static-vs-dynamic style with `:style="[BASE, dynamic]"`.
+
+### 8. Array.from({length:n}) / new Array(n) pre-fill defeating V8 packed-double arrays on numeric hot paths
+
+**Components:** `internal/spline`, `internal/histogram (canvas/histogram)`, `display/calendar (formatter caching is sibling pattern)`
+
+**Impact:** Medium/low. LUT/polyline/bar builders seed arrays with `undefined` (tagged PACKED_ELEMENTS) before writing numbers, so V8 never transitions to unboxed PACKED_DOUBLE, and the pre-fill is a wasted pass — on per-pixel/per-frame paths (toLUT 256+ entries, projectBars per channel, sampleFnToPolyline SAMPLES=256). Fix: build packed with [] + push (or strictly ascending index writes).
+
+### 9. Constructing Intl formatters / RegExp / reflection descriptors per call instead of caching by key
+
+**Components:** `display/calendar`, `forms/number-field`, `forms/visually-hidden (utilities/visually-hidden)`, `internal/color`
+
+**Impact:** Medium/low. new Intl.DateTimeFormat per day-cell/weekday, new RegExp per keystroke, and Object.getOwnPropertyDescriptor per value-sync are 1-2 orders of magnitude costlier than reusing a cached instance, and run on mount-of-a-42-cell-grid / per-character-typed / per-driven-value-change paths. Fix: module-scope Map cache keyed by (locale,options) / separator, and memoize the two HTMLInputElement setters once.
+
+### 10. Per-pointer-move work running while idle / hover (handler not gated, no rAF coalescing, listener bound unconditionally)
+
+**Components:** `display/scroll-area`, `canvas/compare-slider`, `canvas/levels`, `forms/slider`
+
+**Impact:** Medium. pointermove handlers fire on mere hover or on every raw event (no rAF batching), doing emits, layout reads and scroll writes for zero visual benefit — scroll-area even scrolls-to-0 on hover. Fixes: early-return when not actively dragging (use the existing drag flag), coalesce hover updates into one rAF, and bind the listener via a reactive getter target so it only attaches when enabled.
+
+## Recommended fix order
+
+1. **utilities/collection getItems() — replace indexOf-in-comparator with a precomputed Map (and short-circuit size<=1)** — Single ~5-line edit in one shared file flips ~12 components (listbox/menu/menubar/toolbar/tabs/tree/roving-focus/radio-group/stepper/accordion/navigation-menu) from O(n^2 log n) to O(n log n) per keystroke; highest leverage, lowest risk, behavior-identical.
+2. **internal/pointer-drag — make DragState a plain (non-reactive) object; remove the total/delta computeds** — Eliminates ~13 subscriber-less reactive triggers + nested-Proxy set-traps per frame on the package's single shared drag primitive; all consumers verified to read state imperatively, so non-breaking and benefits every draggable at once.
+3. **High-severity gesture leaks — canvas/crop, canvas/levels, forms/slider, canvas/curve-editor (and overlays/drawer body-pin, canvas-stage/drawer/date-picker timers/refs)** — Correctness/leak class: add onScopeDispose teardown (or route through usePointerDrag) so a mid-gesture unmount removes window listeners/observers/timers and restores body style; one pattern resolves all of them and they accumulate retained component graphs in real app teardown flows.
+4. **color/* shared state — shallowRef HSVA in alpha/color-field roots and drop deep:true in useColorState/useHsvaSetters; same shallowRef for snapping/grace-area/pin-input/checkbox/toast** — One change in the shared useColorState/useHsvaSetters module fixes hue+alpha+saturation+color-field per-frame deep-traverse; the shallowRef rule is a mechanical, behavior-identical sweep across all wholesale-replaced state.
+5. **Canvas O(n^2)-per-frame item lookups — gradient-editor, keyframe-track, timeline, curve-editor, waveform; plus shared tab-stop computeds for toggle-group/radio-group/timeline** — Build one memoized id->{item,index} Map (or single shared tab-stop computed) in each Root; same pattern across the canvas family collapses per-frame/per-select cost from O(n^2) to O(n) where large lists actually jank.
+6. **Per-frame getBoundingClientRect caching — color-area, hue-slider, angle-dial, levels, waveform, compare-slider** — Cache rect/center/size in usePointerDrag.onStart and reuse on move; removes one forced reflow per frame per active drag using an already-available hook, low risk, directly improves drag smoothness.
+7. **display/scroll-area — gate onPointerMove on the drag flag, stop the per-frame rAF object allocation, debounce/share the ResizeObservers, scope the wheel listener** — Fixes a correctness bug (scroll-to-0 on hover) plus steady idle GC/main-thread cost from 2-4 permanent rAF loops and page-wide non-passive wheel; several independent wins in one component.
+8. **V8 packed-array + cache fixes — spline toLUT/sample, histogram projectBars/styles, calendar/number-field/visually-hidden formatter & regex & setter caching** — Mechanical hot-loop hygiene ([]+push, module-scope Map/regex/setter cache) on per-pixel/per-keystroke paths; independent, low-risk, and each is contained to one util.
+9. **Static :style / object-literal hoisting and v-bind=obj -> explicit props — zoom-pan, popover, popper, menu, context-menu, histogram, flow, toast, hover-card** — Lowest-risk allocation cleanups: hoist constant styles to frozen consts, precompute per-item styles in existing computeds, split static/dynamic style; do last as a batch since each is small and non-load-bearing.
+10. **menus/context-menu — switch content update-position-strategy from "always" to "optimized"** — One-line change stops a continuous per-animation-frame reposition + getBoundingClientRect loop for a static cursor anchor; scroll/resize listeners fully cover correctness, so it is safe and removes a steady rAF cost while the menu is open.
+
+## Findings by component
+
+Verification: ✅ = adversarially verified against real code · ❓ = uncertain (needs runtime proof) · · = medium/low candidate (not individually re-verified). `H` = hot path, `L` = leak.
+
+### `canvas/angle-dial` — 2 finding(s)
+
+- **🟡 medium** · dom · H · ✅ verified — `AngleDialRoot.vue:151` (geometry)
+ - **Issue:** geometry() calls el.getBoundingClientRect() on EVERY pointer frame. applyPointer (line 179) invokes geometry() per rAF-coalesced onMove, so each drag frame forces a synchronous layout/reflow read and allocates a fresh { cx, cy, radius } object. The dial's rect does not change during a drag (no resize/scroll-tracking), so this read is redundant after the first frame. The sibling canvas/crop/CropRoot.vue caches the rect once in startGesture (surfaceRect = ...getBoundingClientRect() on pointerdown) and reuses it across all moves.
+ - **Fix:** Compute the rect/center/radius once at gesture start and reuse it for the rest of the gesture. usePointerDrag already exposes an onStart and getRect/trackElementRect hook: in onStart, read rootRef.value.getBoundingClientRect() once into a gesture-scoped local (e.g. let geo: {cx,cy,radius}|null), and have applyPointer read that cached geo on move frames instead of calling geometry() each frame. Clear it onCommit/onEnd. This removes the per-frame forced layout and the per-frame object allocation.
+- **⚪ low** · reactivity · H · · candidate — `AngleDialThumb.vue:47` (point)
+ - **Issue:** The point computed calls angleToPoint(...), which already returns a fresh { x, y } object, then re-wraps it into a second literal `return { x: p.x, y: p.y }`. This allocates two objects per recompute. point recomputes on every value change (i.e. once per frame while dragging) and feeds positionStyle, so it is on the drag update path.
+ - **Fix:** Return the object from angleToPoint directly: `const point = computed(() => angleToPoint(value.value, 0.5, { x: 0.5, y: 0.5 }))`. (The center literal arg can also be hoisted to a module-scope const CENTER = { x: 0.5, y: 0.5 } to drop one more per-call allocation, since angleToPoint only reads center.x/center.y.)
+
+### `canvas/canvas-stage` — 3 finding(s), 1 leak
+
+- **⚪ low** · memory-leak · L · ✅ verified — `CanvasStageZoomIndicator.vue:54` (watch(() => ctx.viewport.value.zoom) debounce timer)
+ - **Issue:** The debounce timer (let timer = setTimeout(...)) is cleared only on the NEXT zoom change inside the watcher; it is never cleared on component unmount. If the indicator unmounts while a settle-debounce timer is pending (common: toggling the indicator in a tool panel during an active pinch/wheel zoom, or unmounting within settleDelay=200ms of the last zoom tick), the timer survives unmount and its callback fires on a disposed component, writing announced.value on a dead reactive ref. Each such unmount-with-pending-timer leaves one dangling timer + the closure capturing this component's reactive state until it fires.
+ - **Fix:** Register cleanup so the pending timer is killed on unmount: add `import { onScopeDispose } from 'vue'` and `onScopeDispose(() => { if (timer !== null) clearTimeout(timer); });` (or onUnmounted). Cleaner still: replace the manual let-timer + setTimeout with the repo's useDebounceFn / a useTimeoutFn composable, which self-stops on scope dispose.
+- **⚪ low** · watchers · · candidate — `CanvasStageRoot.vue:120` (watch([paneWidth, paneHeight, contentSize], ...))
+ - **Issue:** The source array mixes two primitive refs (paneWidth, paneHeight) with contentSize, a computed returning a fresh object literal each evaluation. The callback only ever reads paneWidth/paneHeight (to flip `measured` once), so including contentSize adds a dependency whose new-object identity retriggers the watcher on every content-size change for no benefit. Cold (fires on resize/measure, not per-frame), but the contentSize entry is dead weight and the array form forces a shallow array-diff each run.
+ - **Fix:** Drop contentSize from the source and watch a single getter: `watch(() => paneWidth.value > 0 && paneHeight.value > 0, ready => { if (ready) measured.value = true; }, { immediate: true })`. Once measured flips true it stays true, so this also lets you `stop()` it after first success like the fitOnReady watcher does.
+- **⚪ low** · v8-jit · · candidate — `CanvasStageRoot.vue:240` (onKeydown -> arrows Record literal)
+ - **Issue:** onKeydown allocates a fresh `arrows: Record` object (plus four tuple arrays) on every keydown, even for keys that aren't arrows. This is per-keypress, not per-frame, so it is cold and low impact, but the table is rebuilt needlessly and the tuple literals churn the young gen on key-repeat (holding an arrow auto-repeats keydown).
+ - **Fix:** Hoist the direction lookup to module scope as a constant keyed by event.key returning unit deltas, then scale by step inside the handler: `const PAN_DIRS = { ArrowUp:[0,1], ArrowDown:[0,-1], ArrowLeft:[1,0], ArrowRight:[-1,0] };` and `const d = PAN_DIRS[event.key]; if (d) api.setViewport({ zoom: vp.zoom, x: vp.x + d[0]*step, y: vp.y + d[1]*step });`. No allocation per keypress.
+
+### `canvas/compare-slider` — 3 finding(s)
+
+- **🟡 medium** · watchers · H · ✅ verified — `CompareSliderRoot.vue:148` (useEventListener(currentElement, 'pointermove', ...))
+ - **Issue:** The hover-follow pointermove handler runs synchronously on EVERY raw pointermove over the root: it calls positionFromClient() which does a forced getBoundingClientRect() layout read, then writes position.value, which fans out to the After clip-path computed plus the Divider and Handle position-style computeds and their DOM patches. Unlike the drag path (usePointerDrag coalesces all moves into exactly one requestAnimationFrame flush per event burst), this hover path has NO coalescing. On high-Hz pointers or coalesced pointermove bursts the browser fires many events per frame, multiplying layout reads + reactive style recomputes + DOM patches per displayed frame for zero visual benefit.
+ - **Fix:** Coalesce the hover update into a single rAF like the drag primitive does: stash event.clientX/clientY into module/closure scratch vars on each pointermove, schedule one requestAnimationFrame if not already pending, and apply position.value = positionFromClient(...) inside the rAF callback (cancel the pending rAF on pointerleave/unmount). This caps hover work at one layout read + one position write per frame regardless of pointer event rate.
+- **🟡 medium** · dom · H · · candidate — `CompareSliderRoot.vue:148` (useEventListener(currentElement, 'pointermove', ...))
+ - **Issue:** The hover pointermove listener is attached unconditionally to the root element regardless of the `hover` prop. When hover is false (the default), the handler still fires on every pointer move over the root and only then bails via `if (disabled || !hover || drag.isDragging.value) return`. That is a wasted function call + closure invocation per pointer move for the common non-hover configuration.
+ - **Fix:** Bind the listener conditionally by passing a getter target so useEventListener's reactive-target watch attaches/detaches it: useEventListener(() => (hover && !disabled ? currentElement.value : null), 'pointermove', onHoverMove). When hover (or enabled) is false the target getter returns null and no native listener is registered, eliminating the per-move callback entirely; it rebinds automatically if hover flips on.
+- **⚪ low** · reactivity · · candidate — `CompareSliderRoot.vue:171` (rootStyle)
+ - **Issue:** rootStyle is `computed(() => ({ position: 'relative' }))` but the value never changes, so the computed's ReactiveEffect and cache are allocated per instance for nothing. The sibling CompareSliderBefore already uses a plain frozen const object for the identical 'stable monomorphic style' purpose, so this is inconsistent as well as wasteful.
+ - **Fix:** Replace with a module- or setup-level plain const: `const rootStyle = { position: 'relative' } as const;` (mirroring CompareSliderBefore's style const). Identity stays stable for the :style patch and no effect is created.
+
+### `canvas/crop` — 1 finding(s), 1 high, 1 leak
+
+- **🔴 high** · memory-leak · L · ✅ verified — `CropRoot.vue:270` (startGesture / endGesture)
+ - **Issue:** startGesture attaches three window (globalThis) listeners — 'pointermove' (onPointerMove), 'pointerup' (onPointerUp), 'pointercancel' (onPointerCancel) — at lines 270-272. They are removed ONLY inside endGesture (lines 231-233), which is reachable solely from the pointerup/pointercancel handlers. There is no onScopeDispose / onUnmounted. If CropRoot unmounts while a drag/resize/create gesture is in flight (parent v-if toggles the crop off mid-drag, route change, conditional editor teardown), all three window listeners leak. Their closures retain onPointerMove/onPointerUp/onPointerCancel, which in turn capture the component's reactive state (localRect shallowRef, defineModel model, gestureStartRect, captureEl element ref), keeping the unmounted instance and a detached DOM element alive. The captureEl pointer capture is also never released. This is a real leak that accumulates one set of window listeners + a retained component graph per interrupted gesture.
+ - **Fix:** Register a setup-scope cleanup so the gesture tears down on unmount: import { onScopeDispose } from 'vue' and add `onScopeDispose(() => { if (pointerId !== -1) endGesture(false); });`. Setup-time onScopeDispose auto-runs on unmount and will remove all three window listeners and release pointer capture via the existing endGesture path (which already handles releasePointerCapture and resets pointerId/mode). No manual stop bookkeeping needed.
+
+### `canvas/curve-editor` — 4 finding(s), 2 high, 1 leak
+
+- **🔴 high** · memory-leak · L · ✅ verified — `CurveEditorRoot.vue:388` (watch(currentElement) -> new ResizeObserver(measure))
+ - **Issue:** The ResizeObserver created at line 388-391 is observe()'d but never disconnected. `ro` is a function-local with no reference retained for cleanup, so it survives component unmount (it keeps the root node, the measure closure, and the component scope alive) and leaks. Worse, the watcher fires whenever currentElement changes; each run allocates and observes a NEW ResizeObserver without disconnecting the prior one, so observers stack. The watcher uses immediate:false and never returns/registers a teardown.
+ - **Fix:** Disconnect on teardown and on re-run. Hoist the observer reference and disconnect it: declare `let ro: ResizeObserver | undefined` at setup scope, set it inside the watcher, `ro?.disconnect()` before creating a new one, and register `onScopeDispose(() => ro?.disconnect())` (or use the watch cleanup callback / onWatcherCleanup). Simplest: use the project's useEventListener-style ResizeObserver wrapper or call `watch(currentElement, (node, _old, onCleanup) => { ...; const ro = new ResizeObserver(measure); ro.observe(node); onCleanup(() => ro.disconnect()); })`.
+- **🔴 high** · watchers · H · ✅ verified — `CurveEditorRoot.vue:239` (commit() / updateAnchor / updateHandle)
+ - **Issue:** commit() (line 239) deep-copies the entire anchor array `localAnchors.value.map(a => ({ ...a }))` and emits 'anchorsCommit' on EVERY updateAnchor (line 274) and updateHandle (line 293) call. Those run from the drag onMove callback (CurveEditorPoint.vue:101, CurveEditorHandle.vue:79), i.e. once per requestAnimationFrame for the whole duration of a drag. So a drag fires N-object-allocating array copies + a parent emit every frame. This is per-frame hot-path allocation/notify waste AND a semantic defect: the emit doc (CurveEditorRoot.vue:65) says anchorsCommit fires 'after a drag or keypress settles', but it currently fires continuously mid-drag.
+ - **Fix:** Separate the live update from the commit. Keep updateAnchor/updateHandle mutating localAnchors (live), but do NOT call commit() inside them. Fire commit() from the gesture end: wire usePointerDrag's onCommit (or onEnd) in CurveEditorPoint/Handle to call a ctx.commit() exposed on the context, and for keyboard nudges call commit() once per keydown (already discrete). This drops the per-frame array deep-copy + emit to one per settle.
+- **🟡 medium** · reactivity · H · · candidate — `CurveEditorPoint.vue:43` (index = computed(() => ctx.indexOf(anchor.id)) / isEndpoint)
+ - **Issue:** ctx.indexOf (CurveEditorRoot.vue:193) is an O(n) linear id scan. CurveEditorPoint derives `index` (line 43) and `isEndpoint` (line 44, which itself calls indexOf again internally) as computeds that invalidate every time localAnchors is replaced — i.e. every drag frame. Across a list of N points that is O(n^2) id scans per dragged frame. Negligible for typical tone curves (handful of anchors) but scales poorly and is pure churn on the hot path.
+ - **Fix:** Maintain an id->index Map in CurveEditorRoot rebuilt once whenever localAnchors changes (e.g. a computed Map), and have indexOf/isEndpoint read from it (O(1)). Alternatively pass the index down as a prop from the v-for in the consumer so each Point needn't scan. isEndpoint should reuse the already-computed index rather than calling indexOf a second time.
+- **⚪ low** · template · H · · candidate — `CurveEditorPoint.vue:51` (positionStyle computed -> { left, top })
+ - **Issue:** positionStyle allocates a fresh {left, top} object whenever pxX/pxY change (every drag frame for the dragged point). Vue value-diffs :style so it does not force extra patches, but it is a per-frame allocation per moving point. Minor.
+ - **Fix:** Acceptable as-is given :style value-diffing; if optimizing, bind left/top via CSS custom properties or a transform string and update a single string, or accept the allocation. Not worth changing unless profiling shows GC pressure with many simultaneously animating points.
+
+### `canvas/flow` — 3 finding(s)
+
+- **🟡 medium** · list · H · · candidate — `FlowMiniMap.vue:88` (nodes /