@vectojs/core 1.32.7 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The batched property-driver tick: which entities have drivers in flight, and
3
+ * advancing them for one frame.
4
+ *
5
+ * Extraction 6 of the `Scene.ts` decomposition
6
+ * (`forge/decisions/file-decomposition-2026-08.md` §2), shipped at **heavily
7
+ * reduced scope** — see `DEC-0025` for the per-member measurement. The decided
8
+ * `RenderScheduler` scope named nine members and 989 lines; `loop` and `render`
9
+ * are not movable at all, so what ships is the two separable sub-clusters this
10
+ * file and {@link DirtyTracker} own.
11
+ *
12
+ * ## What this owns
13
+ *
14
+ * The candidate registry (`_activeDriverEntities`), the six reused scratch
15
+ * arrays, and the batch pass itself. The registry is the reason the pass is
16
+ * O(active drivers) rather than O(tree size), so registration and ticking are one
17
+ * domain: every write to the set exists to serve the walk that reads it.
18
+ *
19
+ * ## What is held, and what is passed in
20
+ *
21
+ * {@link WasmBackendFacade} is held: it is assigned once in `Scene`'s constructor
22
+ * and the anim backend is reached only through its public surface (`anim`,
23
+ * `animReason`, `animBatchedLastFrame`), exactly as `HitTester` reaches the hit
24
+ * backend.
25
+ *
26
+ * `dt`, the per-kind `gate` and `currentFrame` are per-call arguments
27
+ * (`DEC-0019` rule 5):
28
+ *
29
+ * - `currentFrame` is written by `render` and belongs to the frame loop, which
30
+ * did not move.
31
+ * - `gate` is `Scene.animGate`, a **public mutable field** that tests and
32
+ * benchmarks assign directly (`scene.animGate = { … }` at 8 sites, plus the
33
+ * `animDriverGateCount` alias setter). Holding it would both go stale and force
34
+ * the public field to become an accessor pair, which this sequence exists to
35
+ * avoid.
36
+ *
37
+ * ## What deliberately did not move
38
+ *
39
+ * `_tickBatchedDrivers`'s caller. `render` (575 lines) calls twelve `Scene`
40
+ * methods spanning six domains, and `loop` calls `render`, `syncA11y` and
41
+ * `enforceA11yDomOrder`. Both would need a `Scene` back-edge, which `DEC-0019`
42
+ * rule 1 forbids and which `DEC-0020` and `DEC-0021` already refused in the
43
+ * bound-callback form.
44
+ */
45
+ import { Entity } from '../Entity';
46
+ import type { WasmBackendFacade } from './WasmBackendFacade';
47
+ /** Per-kind driver gates, in active batchable drivers. See `Scene.animGate`. */
48
+ export interface AnimDriverGate {
49
+ spring: number;
50
+ tween: number;
51
+ mixed: number;
52
+ }
53
+ export declare class DriverTicker {
54
+ private readonly backends;
55
+ private readonly activeEntities;
56
+ private springEntities;
57
+ private springProps;
58
+ private springDrivers;
59
+ private tweenEntities;
60
+ private tweenProps;
61
+ private tweenDrivers;
62
+ constructor(backends: WasmBackendFacade);
63
+ /** The candidate set, for the delegating accessor `Scene` keeps for its tests. */
64
+ get active(): Set<Entity>;
65
+ /** Register a single entity whose driver has just started. */
66
+ register(entity: Entity): void;
67
+ /**
68
+ * Drop `entity` and its whole subtree from the batched-driver candidate set.
69
+ * Called by `Scene.remove`/`Scene.hideOverlay` on detach: without this a
70
+ * removed-but-still-animating entity stays pinned in the Set (a leak) and its
71
+ * drivers keep ticking every frame even though it is off-tree. If it is later
72
+ * re-added, {@link registerSubtree} re-registers any node that still has live
73
+ * drivers, so the motion resumes.
74
+ */
75
+ unregisterSubtree(entity: Entity): void;
76
+ /**
77
+ * Re-register every node in `entity`'s subtree that still has live property
78
+ * drivers. Called by `Scene.add`/`Scene.showOverlay` so re-attaching a subtree
79
+ * that was removed mid-animation resumes its batched drivers (they were
80
+ * dropped from the candidate set on removal, but the driver state still lives
81
+ * on each entity).
82
+ */
83
+ registerSubtree(entity: Entity): void;
84
+ /**
85
+ * Advance every registered entity's active drivers for this frame, batching
86
+ * whichever are batchable (`SpringDriver`; `TweenDriver` with a named
87
+ * easing) through one WASM call each when the driver-count gate is open, and
88
+ * ticking the rest (a `TweenDriver` using a custom `EasingFn`) directly in
89
+ * JS regardless of the gate. A "claimed" entity must have ALL its drivers
90
+ * advanced here so it can be safely stamped `_driversTickedFrame` — leaving
91
+ * one unclaimed would silently stall it, since `tickDrivers()` skips the
92
+ * whole entity once stamped.
93
+ *
94
+ * Must run before ANY entity's `update()`/`tickDrivers()` this frame (see
95
+ * the call site in `Scene.render`) — the same ordering constraint G1 Stage 4
96
+ * discovered: a value this pass writes must be final before anything reads
97
+ * it, including the JS-mode interleaved walk and the WASM-mode transform
98
+ * pre-pass.
99
+ */
100
+ tick(dt: number, gate: AnimDriverGate, currentFrame: number): void;
101
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pointer hit-testing: which entity is under a point, by two paths that must
3
+ * agree.
4
+ *
5
+ * Extraction 4 of the `Scene.ts` decomposition
6
+ * (`forge/decisions/file-decomposition-2026-08.md` §2). `Scene.findEntityAt`
7
+ * keeps its name and signature and delegates here.
8
+ *
9
+ * ## The two paths, and why both exist
10
+ *
11
+ * The **JS depth-first walk** ({@link findHitRecursively}) is the permanent
12
+ * fallback and the definition of correct: children in reverse draw order,
13
+ * topmost hit wins, clipped by every `clipChildren` ancestor.
14
+ *
15
+ * The **WASM broad phase** ({@link findEntityAt}'s accelerated arm) asks a
16
+ * spatial grid for one cell's candidates. It is flat, so it has no recursion
17
+ * clip-stack, which is why {@link isHitEligible} re-applies the same
18
+ * visibility/clip/pointer gating the walk gets structurally. Keeping those two
19
+ * in lockstep is the whole correctness argument for having a second path — if
20
+ * they can disagree, the accelerator is a bug generator rather than an
21
+ * optimisation.
22
+ *
23
+ * ## What this owns
24
+ *
25
+ * The hit-grid *contents* — the slot→entity table, the boundless list, and the
26
+ * reused fused-gather buffer. The cache *key* (`hitGridFrame`, `hitGridOk`) stays
27
+ * on {@link WasmBackendFacade}, because installing a backend has to invalidate
28
+ * it and that is the facade's business.
29
+ *
30
+ * ## What it deliberately does not own
31
+ *
32
+ * `clientToScene` and `setupEvents` sit under hit-test banners but are not hit
33
+ * testing:
34
+ *
35
+ * - `clientToScene` maps browser viewport coordinates to logical ones from
36
+ * `canvas`, `width` and `height` — all `ContextAndResize` state (extraction 5).
37
+ * - `setupEvents` wires the window resize listener, the embedded-canvas
38
+ * `ResizeObserver`, the DPR watch, and the pointer listeners that write
39
+ * `mouseX`/`mouseY`. Almost all of it is resize and canvas lifecycle.
40
+ *
41
+ * That is a fifth instance of `DEC-0016`'s finding that the domain banners
42
+ * expose wrong cuts — the same shape as `syncOverlayGeometry` in extraction 2.
43
+ *
44
+ * ## What is passed in, and why
45
+ *
46
+ * `root` and `overlayRoot` are held: `Scene` assigns both once in its constructor
47
+ * and never reassigns them. The facade is held because the hit backend is reached
48
+ * only through its public surface (`hit`, `hitReason`, `hitGridFrame`,
49
+ * `hitGridOk`, `ensureAabbs()`, `slotEntity`, `hitFusedGather`, `transform`).
50
+ *
51
+ * The frame counter and the viewport size are **per-call arguments** (`DEC-0019`
52
+ * rule 5): `currentFrame` belongs to the render scheduler (extraction 6) and
53
+ * `width`/`height` are mutated by `resize` (extraction 5), so neither can be
54
+ * captured at construction without going stale.
55
+ */
56
+ import type { Bounds, Entity } from '../Entity';
57
+ import type { WasmBackendFacade } from './WasmBackendFacade';
58
+ export declare class HitTester {
59
+ private readonly root;
60
+ private readonly overlayRoot;
61
+ private readonly backends;
62
+ /** Slot index → entity, as built by the most recent grid build. */
63
+ private slotEntity;
64
+ /** Entities with no `getBounds()`, which the grid cannot index. */
65
+ private boundless;
66
+ /** Reused buffer for the fused gather, so a pointer query allocates nothing. */
67
+ private gatherBuffer;
68
+ constructor(root: Entity, overlayRoot: Entity, backends: WasmBackendFacade);
69
+ /**
70
+ * Finds the topmost interactive entity at the given coordinates.
71
+ *
72
+ * @param frame - `Scene.currentFrame`, the grid cache key's frame stamp.
73
+ * @param width - Scene logical width, for the grid's extent.
74
+ * @param height - Scene logical height, for the grid's extent.
75
+ */
76
+ findEntityAt(x: number, y: number, frame: number, width: number, height: number): Entity | null;
77
+ /**
78
+ * Refresh the hit-test grid for the CURRENT tree state if it is stale (a
79
+ * structural or transform change may have happened since the last build —
80
+ * there is no cheap "nothing moved" shortcut for a spatial index the way
81
+ * there is for the transform store's topology-only run table, since ANY
82
+ * entity moving invalidates its AABB, not just add/remove/reparent; the
83
+ * measured build cost is cheap enough to redo per call). Returns `false`
84
+ * (grid untrustworthy — caller must use the JS walk) when there is no
85
+ * backend or the build overflowed its item budget.
86
+ */
87
+ ensureHitGrid(frame: number, width: number, height: number): boolean;
88
+ /**
89
+ * `findEntityAt`'s WASM-accelerated path for the main tree. Scans only the
90
+ * queried cell's candidates (confirming each against its own AABB and precise
91
+ * `isPointInside`) merged against the (typically empty or tiny) list of
92
+ * entities with no `getBounds()`, taking whichever confirmed match has the
93
+ * higher pre-order index — see hit-store.ts for why that is exactly
94
+ * equivalent to findHitRecursively's topmost-hit priority. Always
95
+ * conclusive: returns the correct entity or `null`, never "inconclusive".
96
+ */
97
+ private findEntityAtWasm;
98
+ findHitRecursively(node: Entity, x: number, y: number, clip?: Bounds | null): Entity | null;
99
+ /** Whether `node` opts out of being a pointer hit target: a disabled control
100
+ * or an explicit `pointerEvents: 'none'` in its a11y attributes. Its children
101
+ * are still walked (a transparent container can hold hittable descendants). */
102
+ private isPointerTransparent;
103
+ /**
104
+ * Whether a confirmed geometric hit on `node` at world `(x, y)` is a REAL hit,
105
+ * applying the same visibility/input gating as {@link findHitRecursively} but
106
+ * from a flat candidate (the WASM grid has no recursion clip-stack): the node
107
+ * and all ancestors are visible (`opacity > 0`), the point lies inside every
108
+ * `clipChildren` ancestor's world box, and the node isn't pointer-transparent
109
+ * (disabled / `pointerEvents: 'none'`). Keeps the WASM and JS hit paths in
110
+ * lockstep so they return the same entity.
111
+ */
112
+ private isHitEligible;
113
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Per-phase frame timing: which phase owned the frame, and how the samples add
3
+ * up.
4
+ *
5
+ * A **shared leaf** of the `Scene.ts` decomposition rather than one of the six
6
+ * domain collaborators. It is extracted ahead of extraction 3 because the
7
+ * content-projection calibration pass records two of its own phases
8
+ * (`calibScan`, `calibProbeBuild`) and cannot move while the only way to reach
9
+ * the accumulator is `Scene._recordPhase`. Handing a collaborator
10
+ * `scene._recordPhase.bind(scene)` would put a `Scene` reference inside a
11
+ * closure, which is the violation of `DEC-0019` rule 1 that `DEC-0020` refused
12
+ * for `syncA11y`. A shared object that nobody's `Scene` is reachable through has
13
+ * neither problem: `Scene` owns one of these, and each collaborator that records
14
+ * a phase holds the same instance.
15
+ *
16
+ * Nine methods across four domains read the enable flag today — the render walk,
17
+ * the frame loop, the a11y sync, the content projection and its grid pass, the
18
+ * calibration scheduler — so this would have had to become shared state at
19
+ * whichever extraction reached it first regardless.
20
+ *
21
+ * ## Why totals rather than a log
22
+ *
23
+ * The question is always "which phase owns the frame". A per-frame log of
24
+ * thousands of samples answers that less directly and costs far more memory.
25
+ * `maxMs` is kept because a phase that is cheap on average but spikes is a
26
+ * different problem from one that is uniformly slow.
27
+ *
28
+ * ## The disabled path must cost nothing
29
+ *
30
+ * These probes sit on the frame path, so {@link enabled} is a plain boolean field
31
+ * and every call site is expected to test it before doing any timing work —
32
+ * including the `performance.now()` calls themselves.
33
+ */
34
+ /**
35
+ * A timed phase of a frame.
36
+ *
37
+ * `render` is the ENCLOSING phase — it contains `transform`, `drawWalk` and
38
+ * `flush` — so it is reported without a share to avoid double-counting.
39
+ * `a11ySync` and `a11yOrder` run after `render` in the frame loop, so they are
40
+ * siblings of it, not children.
41
+ */
42
+ export type RenderPhase = 'render' | 'transform' | 'drawWalk' | 'flush' | 'a11ySync'
43
+ /**
44
+ * Time inside {@link Scene.syncContentGridProjection} materializing DOM
45
+ * carriers, nested inside `a11ySync`.
46
+ *
47
+ * Split out because `a11ySync` for a streaming code block measured 1661-1875 ms
48
+ * against a 210-671 ms render, and attributing that to grid materialization was
49
+ * an assumption. Nothing should be optimised here on the strength of the parent
50
+ * phase alone.
51
+ */
52
+ | 'gridMaterialize'
53
+ /**
54
+ * Whole of {@link Scene.syncContentProjection}, nested inside `a11ySync`.
55
+ *
56
+ * Measured at 99.8-99.9% of `a11ySync` for a streaming code block, so per-node
57
+ * a11y attribute and geometry work is not where that phase's cost lives.
58
+ */
59
+ | 'contentProjection'
60
+ /** Per-node a11y attribute/geometry work, excluding content projection and descendants. */
61
+ | 'a11yNodes'
62
+ /** Whole of `syncContentGridProjection`, of which `gridMaterialize` is one part. */
63
+ | 'gridSync'
64
+ /**
65
+ * Synchronous part of `scheduleContentGridCalibration` — building the probe DOM.
66
+ *
67
+ * The measurement itself is deferred to a rAF, but the probe is constructed
68
+ * here. Measured at 77-80% of `gridSync` on Chrome (3.7-4.5 ms per frame, i.e.
69
+ * the entire 240Hz budget) against about 1 ms on Firefox, making it the largest
70
+ * remaining cost of projecting a streaming code block once carrier reuse landed.
71
+ */
72
+ | 'gridCalibrateSchedule'
73
+ /** The `querySelectorAll` + per-cell scan inside calibration scheduling. */
74
+ | 'calibScan'
75
+ /** Probe DOM construction and insertion inside calibration scheduling. */
76
+ | 'calibProbeBuild' | 'a11yOrder'
77
+ /** Sum of every entity's own render(), nested inside drawWalk. */
78
+ | 'entityPaint';
79
+ export interface RenderPhaseEntry {
80
+ phase: RenderPhase;
81
+ totalMs: number;
82
+ calls: number;
83
+ avgMs: number;
84
+ /** Worst single sample — a spiky phase is a different problem from a slow one. */
85
+ maxMs: number;
86
+ /** Percent of the measured total, or `null` for the enclosing `render` phase. */
87
+ share: number | null;
88
+ }
89
+ export declare class PhaseTimer {
90
+ /**
91
+ * Whether per-phase timing is being recorded.
92
+ *
93
+ * A field rather than an accessor: it is tested on the frame path once per
94
+ * probe, and the disabled cost has to be a single boolean read.
95
+ */
96
+ enabled: boolean;
97
+ /** Whether browser User Timing instrumentation is enabled. */
98
+ userTiming: boolean;
99
+ private readonly totals;
100
+ /** Start or stop recording. Disabling also drops what was collected. */
101
+ setEnabled(enabled: boolean): void;
102
+ /** Accumulate one phase sample. */
103
+ record(phase: RenderPhase, ms: number): void;
104
+ /**
105
+ * Recorded phase timings, most expensive first, with each phase's share of the
106
+ * measured total.
107
+ *
108
+ * `share` is the number that matters: a phase at 4% cannot be worth optimising
109
+ * however inefficient it looks in isolation.
110
+ */
111
+ get entries(): RenderPhaseEntry[];
112
+ /** Drop recorded timings, keeping recording enabled. */
113
+ clear(): void;
114
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The four invisible WASM accelerators, and the per-frame report that describes
3
+ * what each of them actually did.
4
+ *
5
+ * Extraction 1 of the `Scene.ts` decomposition
6
+ * (`forge/decisions/file-decomposition-2026-08.md` §2). `Scene` keeps every
7
+ * public method and getter it had — `setTransformBackend`, `enableWasmHitTest`,
8
+ * `accelerators`, and the rest — and each one now delegates here. The public API
9
+ * is byte-identical; only where the state lives changed.
10
+ *
11
+ * ## What this owns, and what it deliberately does not
12
+ *
13
+ * It owns the four backend handles, the shared runtime, the resident transform
14
+ * store, and every field the accelerator report reads.
15
+ *
16
+ * It does NOT own three things that sit inside `Scene.ts`'s WASM comment region
17
+ * but belong to other domains — the domain banners were added first precisely to
18
+ * make that visible before any code moved (carryctx `DEC-0016`):
19
+ *
20
+ * - `_ensureHitGrid` / `_findEntityAtWasm` are the hit-test broad phase
21
+ * (`HitTester`, extraction 4). They *read* this facade.
22
+ * - `_tickBatchedDrivers` is the scheduler's batched driver tick
23
+ * (`RenderScheduler`, extraction 6). It reads the anim backend from here.
24
+ * - `_computeEntitiesFor` walks the tree for `ComputeParticleEntity` instances
25
+ * and is consumed by the render walk. Only its cache key — the structure
26
+ * version — is store state, so the version lives here and the walk stays on
27
+ * `Scene`.
28
+ *
29
+ * ## Why the reporting fields live here rather than with their writers
30
+ *
31
+ * `transformReason` is written by the render walk, `animReason` by the driver
32
+ * tick, `hitReason` by the hit-grid build, `particleReason` by the particle
33
+ * pass — four domains, four different extractions. They are collected here
34
+ * anyway because {@link report} is the single reader that has to make them
35
+ * consistent, and splitting them across four collaborators would leave that one
36
+ * getter reaching into all four. Each writer sets its own field through a public
37
+ * property on this object; none of them touches `Scene`'s privates.
38
+ *
39
+ * ## The one fact this cannot know
40
+ *
41
+ * `particle.available` is true when a WASM particle backend is installed **or**
42
+ * WebGPU is live, and WebGPU device state belongs to `ContextAndResize`
43
+ * (extraction 5). So {@link report} takes it as an argument rather than
44
+ * reaching for it. That is the whole of this facade's dependency on the rest of
45
+ * `Scene`.
46
+ */
47
+ import type { Entity } from '../Entity';
48
+ import { type WasmTransformBackend } from '../../wasm/backend';
49
+ import { type CoreModuleSource, type CoreWasmRuntime } from '../../wasm/runtime';
50
+ import type { HitTestBackend } from '../../wasm/hit-backend';
51
+ import type { AnimBackend } from '../../wasm/anim-backend';
52
+ import type { ParticleBackend } from '../../wasm/particle-backend';
53
+ /**
54
+ * Why an accelerator did or did not run on the most recent frame.
55
+ *
56
+ * `'active'` is the only value that means the accelerator ran. Everything else
57
+ * is a distinct decline, kept separate because they call for different actions:
58
+ * `'not-installed'` means enable it, `'below-gate'` means the workload is too
59
+ * small to be worth it (working as designed), and `'rejected'` means the kernel
60
+ * refused its arguments — a fault worth reporting, not a tuning outcome.
61
+ *
62
+ * Declared here rather than in `Scene.ts` because this is the class that owns
63
+ * every field of this type. `Scene.ts` re-exports all three accelerator types,
64
+ * so `@vectojs/core`'s barrel keeps publishing them unchanged — `AcceleratorReason`
65
+ * in particular is consumed by `@vectojs/devtools`.
66
+ */
67
+ export type AcceleratorReason =
68
+ /** Ran on this frame. */
69
+ 'active'
70
+ /** No backend installed; the JS path is the permanent fallback. */
71
+ | 'not-installed'
72
+ /** Installed, but the per-frame gate chose JS (workload below threshold). */
73
+ | 'below-gate'
74
+ /** Installed and gated in, but the kernel rejected the call and wrote nothing. */
75
+ | 'rejected'
76
+ /** Not applicable to this pass (e.g. a non-main renderer, or nothing to do). */
77
+ | 'not-applicable';
78
+ /**
79
+ * One accelerator's per-frame status, read from {@link Scene.accelerators}.
80
+ *
81
+ * The pair exists because `available` and `activeThisFrame` genuinely differ:
82
+ * before this shape, `transformBackend`/`animBackend` reported only that a
83
+ * backend was *installed*, which invites concluding an accelerator is doing work
84
+ * when its gate never opens. Read `activeThisFrame` for what actually happened
85
+ * and `reason` for why.
86
+ */
87
+ export interface AcceleratorStatus {
88
+ /** A backend is installed and could run, gate permitting. */
89
+ available: boolean;
90
+ /** It ran on the most recent frame. */
91
+ activeThisFrame: boolean;
92
+ /** Why it did or did not run. */
93
+ reason: AcceleratorReason;
94
+ /** Which implementation actually did the work on the most recent frame. */
95
+ path: string;
96
+ }
97
+ /**
98
+ * Per-frame status of every invisible accelerator, read from
99
+ * {@link Scene.accelerators}. Each is independent: a scene can compose
100
+ * transforms in WASM while ticking drivers in JS.
101
+ */
102
+ export interface AcceleratorReport {
103
+ /** World-matrix composition (`compose_simd`). */
104
+ transform: AcceleratorStatus;
105
+ /** Batched property drivers (`spring_step`/`tween_step`). */
106
+ animation: AcceleratorStatus;
107
+ /** Hit-test broad phase (`hit_build`/`hit_query`) and its gather source. */
108
+ hitTest: AcceleratorStatus;
109
+ /** Particle simulation — WebGPU compute, the WASM CPU kernel, or JS. */
110
+ particle: AcceleratorStatus;
111
+ }
112
+ export declare class WasmBackendFacade {
113
+ /**
114
+ * The main tree's root.
115
+ *
116
+ * Held by value rather than as a `Scene` reference: it is assigned exactly
117
+ * once in `Scene`'s constructor and never reassigned, and taking it directly
118
+ * is what keeps this class from holding a back-edge to its facade. Only the
119
+ * main tree is ever composed through the store — overlays render on the JS
120
+ * path, so the overlay root is not needed here.
121
+ */
122
+ private readonly root;
123
+ constructor(root: Entity);
124
+ private _transform;
125
+ private _mode;
126
+ private _treeStore;
127
+ private _slotEntity;
128
+ private _inputs;
129
+ private _world;
130
+ private _structureVersion;
131
+ private _storeStructureVersion;
132
+ /**
133
+ * Consecutive `uploadRuns` rejections. Reset by any success and by
134
+ * {@link setTransform}; at {@link WASM_UPLOAD_REJECT_LIMIT} the backend mode
135
+ * flips to `'js'` permanently.
136
+ */
137
+ private _uploadRejections;
138
+ /** Latch for the permanent-fallback warning, which fires from a per-frame path. */
139
+ private hasWarnedUploadFallback;
140
+ /**
141
+ * Whether `compute_aabbs` has run against the current frame's world matrices.
142
+ * The AABB pass is only meaningful after a `compose_*`, so the fused gather
143
+ * must not read the views before then.
144
+ */
145
+ private _aabbsFresh;
146
+ /** The installed transform backend, or `null` on the JS path. */
147
+ get transform(): WasmTransformBackend | null;
148
+ /** Which backend composes world matrices for the main render walk. */
149
+ get mode(): 'js' | 'wasm';
150
+ /**
151
+ * Whether the render walk should source world matrices from the store this
152
+ * frame: a backend is installed AND the mode has not fallen back to JS.
153
+ */
154
+ get transformActive(): boolean;
155
+ /** Store slot -> entity, for the render walk and the fused hit gather. */
156
+ get slotEntity(): Entity[];
157
+ /** Bumped by every topology change; the store layout's cache key. */
158
+ get structureVersion(): number;
159
+ /** Which structure version the resident store layout was built for. */
160
+ get storeStructureVersion(): number;
161
+ /** Consecutive `uploadRuns` rejections; see {@link WASM_UPLOAD_REJECT_LIMIT}. */
162
+ get uploadRejections(): number;
163
+ /** Invalidate the resident WASM store layout; the next wasm-mode frame
164
+ * rebuilds it. Called by `Entity.add`/`remove` (topology changes only). */
165
+ markStructureChanged(): void;
166
+ /**
167
+ * Install (or clear) a WASM transform backend. Passing a backend switches the
168
+ * main render walk onto it; passing `null` reverts to the JS path.
169
+ */
170
+ setTransform(backend: WasmTransformBackend | null): void;
171
+ /**
172
+ * The one WASM instance this Scene's accelerators share.
173
+ *
174
+ * Each `enableWasm*` used to instantiate the binary itself, so enabling all
175
+ * four compiled the same module four times and held four linear memories. The
176
+ * Rust crate already keeps transform/anim/hit/particle in separate statics, so
177
+ * one instance serves all of them without aliasing. The compiled module is
178
+ * cached globally; the instance is per-Scene, which is the isolation that
179
+ * actually matters.
180
+ */
181
+ private _runtime;
182
+ /** The shared WASM runtime, if one has been loaded. */
183
+ get runtime(): CoreWasmRuntime | null;
184
+ /**
185
+ * Install a pre-built runtime, so several Scenes can share one compile while
186
+ * each keeps its own stores. Pass `null` to detach (backends already installed
187
+ * keep working; only subsequent `enableWasm*` calls re-load).
188
+ */
189
+ setRuntime(runtime: CoreWasmRuntime | null): void;
190
+ /**
191
+ * Load (or reuse) this Scene's shared WASM runtime.
192
+ *
193
+ * Returns `null` on any failure — CSP `wasm-unsafe-eval`, a 404, corrupt bytes,
194
+ * unsupported SIMD — so every caller keeps its JS path. Failure is the default
195
+ * state here, not an error path.
196
+ */
197
+ ensureRuntime(source: CoreModuleSource): Promise<CoreWasmRuntime | null>;
198
+ private _hit;
199
+ private _anim;
200
+ private _particle;
201
+ /** The installed hit-test backend, or `null` for the JS depth-first walk. */
202
+ get hit(): HitTestBackend | null;
203
+ /** Install (or clear) a WASM hit-test backend. */
204
+ setHit(backend: HitTestBackend | null): void;
205
+ /** The installed batched-animation backend, or `null` for the JS tick. */
206
+ get anim(): AnimBackend | null;
207
+ /** Install (or clear) a WASM batched-animation backend. */
208
+ setAnim(backend: AnimBackend | null): void;
209
+ /** The installed particle backend, or `null` for the JS `updateCPU` path. */
210
+ get particle(): ParticleBackend | null;
211
+ /** Install (or clear) a WASM particle backend. */
212
+ setParticle(backend: ParticleBackend | null): void;
213
+ /**
214
+ * Why the transform accelerator did or did not run on the most recent frame.
215
+ * Written by the render walk and {@link syncStore}.
216
+ */
217
+ transformReason: AcceleratorReason;
218
+ /** Why the batched-driver accelerator did or did not run. */
219
+ animReason: AcceleratorReason;
220
+ /**
221
+ * Why the hit-test accelerator did or did not serve the last pointer query.
222
+ * The grid is built lazily on demand, not every frame, so this describes the
223
+ * most recent BUILD. Starts at `'not-installed'` because that is the truth
224
+ * before a backend exists; the grid build moves it to `'not-applicable'` once
225
+ * one is installed but nothing has queried yet.
226
+ */
227
+ hitReason: AcceleratorReason;
228
+ /** Why the particle accelerator did or did not run. */
229
+ particleReason: AcceleratorReason;
230
+ /** Which particle implementation actually simulated the most recent frame. */
231
+ particlePath: string;
232
+ /**
233
+ * Whether the last grid build sourced its AABBs from the WASM transform store
234
+ * rather than recomputing them in JS. Diagnostic only — both paths must
235
+ * produce the same entity for a given point.
236
+ */
237
+ hitFusedGather: boolean;
238
+ /** Whether the WASM batch path actually ran on the most recent frame. */
239
+ animBatchedLastFrame: boolean;
240
+ /** Frame the hit grid was last built for; `-1` forces a rebuild. */
241
+ hitGridFrame: number;
242
+ /** Whether that build succeeded (did not overflow its item budget). */
243
+ hitGridOk: boolean;
244
+ /**
245
+ * Per-frame status of every invisible accelerator: whether each is installed,
246
+ * whether it actually ran on the most recent frame, and why.
247
+ *
248
+ * `webgpuActive` is passed in because WebGPU device state belongs to
249
+ * `ContextAndResize`, not here — see the class comment.
250
+ */
251
+ report(webgpuActive: boolean): AcceleratorReport;
252
+ /**
253
+ * Compose the whole main tree's world matrices through the resident WASM store
254
+ * and return the world-matrix views for the render walk to read. Rebuilds the
255
+ * store layout (slots + runs) only when the tree structure changed since the
256
+ * last rebuild; otherwise it just gathers current transforms into the resident
257
+ * input view and runs the kernel. Returns `null` if there is no backend.
258
+ */
259
+ syncStore(): ReturnType<WasmTransformBackend['worldView']> | null;
260
+ /**
261
+ * Run the WASM world-AABB pass over the current frame's world matrices, so the
262
+ * fused hit gather can read AABBs straight out of the store.
263
+ *
264
+ * Local bounds are uploaded here rather than in the per-frame transform sync
265
+ * because `getBounds()` is a virtual call that allocates a rect on most
266
+ * entities — paying it every frame for a query that may never come would move
267
+ * cost onto the render path to save it on hover. Returns `false` if any entity
268
+ * cannot supply bounds through the store, so the caller uses the JS gather.
269
+ */
270
+ ensureAabbs(): boolean;
271
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Stateless helpers for the a11y projection's DOM mirrors.
3
+ *
4
+ * First module of the `Scene.ts` decomposition
5
+ * (`forge/decisions/file-decomposition-2026-08.md` §2, `TODO.md`'s P3 refactor
6
+ * entry). The decision record calls for extracting the stateless top-level
7
+ * functions before any stateful manager, and these are both: pure functions of
8
+ * their arguments, no `Scene` reference, no back-edge to `Scene.ts`. That keeps
9
+ * the import graph a one-way edge (`Scene.ts → scene/a11y-dom.ts`) and avoids
10
+ * the temporal-dead-zone hazard the Markdown split hit, where a module
11
+ * importing a class back from its facade to `extends` it threw during module
12
+ * initialization.
13
+ *
14
+ * Neither symbol is re-exported from `Scene.ts`. Both were module-private
15
+ * there, and `packages/core/src/index.ts` is `export * from './tree/Scene'`, so
16
+ * re-exporting would silently widen the public API — the opposite of this
17
+ * refactor's byte-identical-surface requirement.
18
+ *
19
+ * When `A11yProjectionManager` (extraction 2) lands, it is the natural owner of
20
+ * both. Until then `Scene` imports them directly.
21
+ */
22
+ import type { AffineTransform } from '../Entity';
23
+ /**
24
+ * Whether the element takes focus without an explicit `tabindex`.
25
+ *
26
+ * The projection adds `tabindex="0"` only to mirrors that need it: a mirror
27
+ * carrying an interactive ARIA role but rendered as a non-focusable tag is
28
+ * unreachable by keyboard, while adding `tabindex` to an element that is
29
+ * already focusable is redundant and reorders nothing.
30
+ */
31
+ export declare function isNativelyFocusable(element: HTMLElement): boolean;
32
+ /**
33
+ * A nested mirror's box, expressed relative to its projected parent.
34
+ *
35
+ * Reused across calls rather than returned fresh: the geometry write runs for
36
+ * every projected node on every synced frame, and a virtualized table can
37
+ * nest several hundred cells, so a per-node literal here would allocate in the
38
+ * frame loop. The single caller consumes the fields immediately.
39
+ */
40
+ export interface RebasedBox {
41
+ left: number;
42
+ top: number;
43
+ matrix: string;
44
+ }
45
+ /**
46
+ * Re-express a child's **world** transform as a box positioned inside its
47
+ * projected parent's box.
48
+ *
49
+ * Mirrors are `position: absolute`, so their `left`/`top` resolve against the
50
+ * nearest positioned ancestor. While the projection is flat that ancestor is
51
+ * always `a11yRoot` and world coordinates are correct as-written. The moment a
52
+ * mirror is nested inside another mirror, its containing block becomes the
53
+ * parent — writing world coordinates then *double-offsets* every descendant,
54
+ * and the parent's `matrix()` compounds on top. Measured in real Chrome and
55
+ * Firefox: a row at world (110, 80) under a grid at (100, 50) landed at
56
+ * (210, 130), and a cell at (120, 90) landed at (330, 220).
57
+ *
58
+ * The correction is not a plain subtraction. `left`/`top` are applied *before*
59
+ * the ancestor's `transform`, so the offset has to be expressed in the parent's
60
+ * pre-transform space: divide the world delta by the parent's linear part
61
+ * rather than subtracting its translation. The linear part is likewise relative
62
+ * — `inv(P) · C`, which collapses to the identity when the child adds no
63
+ * rotation or scale of its own.
64
+ *
65
+ * Both engines reproduce the flat layout exactly under this transform,
66
+ * including a parent rotated 30° and scaled 1.5×. That agreement depends on
67
+ * `transformOrigin: '0 0'` (set on every mirror at creation); with the default
68
+ * `50% 50%` each nested box rotates about its own centre and the results
69
+ * diverge by tens of pixels.
70
+ *
71
+ * A singular parent matrix (zero width or height, or a collapsed scale) has no
72
+ * inverse. Rather than emit `NaN` — which reads as `left: 0` and silently
73
+ * relocates the element to the parent's origin, where the reading-order sort
74
+ * would then treat it as the top-left-most element on screen — the child is
75
+ * pinned to the parent's origin with an identity matrix. A zero-area parent is
76
+ * already invisible, and `projectionBoxVisible` hides it on the same frame.
77
+ */
78
+ export declare function rebaseChildBox(parent: AffineTransform, parentOriginX: number, parentOriginY: number, child: AffineTransform, childOriginX: number, childOriginY: number): RebasedBox;