@vectojs/core 1.32.6 → 1.33.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.
@@ -14,90 +14,16 @@ import { Entity } from './Entity';
14
14
  import { IRenderer } from '../renderer/IRenderer';
15
15
  import type { WebGLDrawStats } from '../renderer/WebGLPointRenderer';
16
16
  import { type WasmModuleSource, type WasmTransformBackend } from '../wasm/backend';
17
- import { type CoreWasmRuntime } from '../wasm/runtime';
17
+ import type { CoreWasmRuntime } from '../wasm/runtime';
18
18
  import { type HitModuleSource, type HitTestBackend } from '../wasm/hit-backend';
19
19
  import { type AnimModuleSource, type AnimBackend } from '../wasm/anim-backend';
20
20
  import { type ParticleModuleSource, type ParticleBackend } from '../wasm/particle-backend';
21
- /**
22
- * A timed phase of a frame.
23
- *
24
- * `render` is the ENCLOSING phase it contains `transform`, `drawWalk` and
25
- * `flush` so it is reported without a share to avoid double-counting.
26
- * `a11ySync` and `a11yOrder` run after `render` in the frame loop, so they are
27
- * siblings of it, not children.
28
- */
29
- export type RenderPhase = 'render' | 'transform' | 'drawWalk' | 'flush' | 'a11ySync'
30
- /**
31
- * Time inside {@link Scene.syncContentGridProjection} materializing DOM
32
- * carriers, nested inside `a11ySync`.
33
- *
34
- * Split out because `a11ySync` for a streaming code block measured 1661-1875 ms
35
- * against a 210-671 ms render, and attributing that to grid materialization was
36
- * an assumption. Nothing should be optimised here on the strength of the parent
37
- * phase alone.
38
- */
39
- | 'gridMaterialize'
40
- /**
41
- * Whole of {@link Scene.syncContentProjection}, nested inside `a11ySync`.
42
- *
43
- * Measured at 99.8-99.9% of `a11ySync` for a streaming code block, so per-node
44
- * a11y attribute and geometry work is not where that phase's cost lives.
45
- */
46
- | 'contentProjection'
47
- /** Per-node a11y attribute/geometry work, excluding content projection and descendants. */
48
- | 'a11yNodes'
49
- /** Whole of `syncContentGridProjection`, of which `gridMaterialize` is one part. */
50
- | 'gridSync'
51
- /**
52
- * Synchronous part of `scheduleContentGridCalibration` — building the probe DOM.
53
- *
54
- * The measurement itself is deferred to a rAF, but the probe is constructed
55
- * here. Measured at 77-80% of `gridSync` on Chrome (3.7-4.5 ms per frame, i.e.
56
- * the entire 240Hz budget) against about 1 ms on Firefox, making it the largest
57
- * remaining cost of projecting a streaming code block once carrier reuse landed.
58
- */
59
- | 'gridCalibrateSchedule'
60
- /** The `querySelectorAll` + per-cell scan inside calibration scheduling. */
61
- | 'calibScan'
62
- /** Probe DOM construction and insertion inside calibration scheduling. */
63
- | 'calibProbeBuild' | 'a11yOrder'
64
- /** Sum of every entity's own render(), nested inside drawWalk. */
65
- | 'entityPaint';
66
- export interface RenderPhaseEntry {
67
- phase: RenderPhase;
68
- totalMs: number;
69
- calls: number;
70
- avgMs: number;
71
- /** Worst single sample — a spiky phase is a different problem from a slow one. */
72
- maxMs: number;
73
- /** Percent of the measured total, or `null` for the enclosing `render` phase. */
74
- share: number | null;
75
- }
76
- /**
77
- * Who marked the scene dirty, and why.
78
- *
79
- * Every field is optional except `reason` so a call site can be as specific as it
80
- * cheaply can — an entity id costs nothing to pass, a property name is often
81
- * already in scope.
82
- */
83
- export interface DirtySource {
84
- /** Entity id responsible, when one is. Omitted for scene-level invalidation. */
85
- entity?: string;
86
- /** Short, stable category — e.g. `'text-changed'`, `'animation'`, `'resize'`. */
87
- reason: string;
88
- /** Property that changed, when the reason alone is ambiguous. */
89
- property?: string;
90
- }
91
- /** An aggregated dirty attribution. */
92
- export interface DirtyReasonEntry {
93
- entity?: string;
94
- reason: string;
95
- property?: string;
96
- /** How many times this exact attribution was recorded. */
97
- count: number;
98
- firstFrame: number;
99
- lastFrame: number;
100
- }
21
+ import { type OverlayGeometry } from './scene/CanvasGeometry';
22
+ import { type DirtyReasonEntry, type DirtySource } from './scene/DirtyTracker';
23
+ import { type RenderPhase, type RenderPhaseEntry } from './scene/PhaseTimer';
24
+ import { type AcceleratorReason, type AcceleratorReport, type AcceleratorStatus } from './scene/WasmBackendFacade';
25
+ export type { RenderPhase, RenderPhaseEntry };
26
+ export type { DirtyReasonEntry, DirtySource };
101
27
  /**
102
28
  * Options for {@link Scene}.
103
29
  */
@@ -301,60 +227,7 @@ export interface SceneOptions {
301
227
  export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticBudget', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
302
228
  /** Frame-rate the loop is capped to when the OS requests reduced motion. */
303
229
  export declare const REDUCED_MOTION_FPS = 30;
304
- /**
305
- * Why an accelerator did or did not run on the most recent frame.
306
- *
307
- * `'active'` is the only value that means the accelerator ran. Everything else
308
- * is a distinct decline, kept separate because they call for different actions:
309
- * `'not-installed'` means enable it, `'below-gate'` means the workload is too
310
- * small to be worth it (working as designed), and `'rejected'` means the kernel
311
- * refused its arguments — a fault worth reporting, not a tuning outcome.
312
- */
313
- export type AcceleratorReason =
314
- /** Ran on this frame. */
315
- 'active'
316
- /** No backend installed; the JS path is the permanent fallback. */
317
- | 'not-installed'
318
- /** Installed, but the per-frame gate chose JS (workload below threshold). */
319
- | 'below-gate'
320
- /** Installed and gated in, but the kernel rejected the call and wrote nothing. */
321
- | 'rejected'
322
- /** Not applicable to this pass (e.g. a non-main renderer, or nothing to do). */
323
- | 'not-applicable';
324
- /**
325
- * One accelerator's per-frame status, read from {@link Scene.accelerators}.
326
- *
327
- * The pair exists because `available` and `activeThisFrame` genuinely differ:
328
- * before this shape, `transformBackend`/`animBackend` reported only that a
329
- * backend was *installed*, which invites concluding an accelerator is doing work
330
- * when its gate never opens. Read `activeThisFrame` for what actually happened
331
- * and `reason` for why.
332
- */
333
- export interface AcceleratorStatus {
334
- /** A backend is installed and could run, gate permitting. */
335
- available: boolean;
336
- /** It ran on the most recent frame. */
337
- activeThisFrame: boolean;
338
- /** Why it did or did not run. */
339
- reason: AcceleratorReason;
340
- /** Which implementation actually did the work on the most recent frame. */
341
- path: string;
342
- }
343
- /**
344
- * Per-frame status of every invisible accelerator, read from
345
- * {@link Scene.accelerators}. Each is independent: a scene can compose
346
- * transforms in WASM while ticking drivers in JS.
347
- */
348
- export interface AcceleratorReport {
349
- /** World-matrix composition (`compose_simd`). */
350
- transform: AcceleratorStatus;
351
- /** Batched property drivers (`spring_step`/`tween_step`). */
352
- animation: AcceleratorStatus;
353
- /** Hit-test broad phase (`hit_build`/`hit_query`) and its gather source. */
354
- hitTest: AcceleratorStatus;
355
- /** Particle simulation — WebGPU compute, the WASM CPU kernel, or JS. */
356
- particle: AcceleratorStatus;
357
- }
230
+ export type { AcceleratorReason, AcceleratorStatus, AcceleratorReport };
358
231
  /**
359
232
  * Live render-loop telemetry, read from {@link Scene.frameStats}. See that
360
233
  * getter for how each field is measured.
@@ -470,11 +343,16 @@ export declare class Scene {
470
343
  * event-driven UIs where idle frames should cost ~0.
471
344
  */
472
345
  renderMode: 'always' | 'onDemand';
473
- /** Cap on distinct recorded dirty reasons (see `recordDirtyReason`). */
474
- private static readonly MAX_DIRTY_REASONS;
475
- private _phaseTiming;
476
- private _userTiming;
477
- private _phaseTotals;
346
+ /**
347
+ * Per-phase frame timing and the browser User Timing flag.
348
+ *
349
+ * Extracted ahead of extraction 3 because the content-grid calibration pass is
350
+ * instrumented (`calibScan`, `calibProbeBuild`) and could not move while its
351
+ * only remaining dependency was a `Scene` private. Nine methods across four
352
+ * domains write phases, so this is a shared leaf rather than any one domain's
353
+ * property — see `DEC-0021`.
354
+ */
355
+ private readonly phases;
478
356
  /**
479
357
  * Start or stop per-phase render timing.
480
358
  *
@@ -500,15 +378,6 @@ export declare class Scene {
500
378
  setUserTiming(enabled: boolean): void;
501
379
  /** Whether browser User Timing phase instrumentation is enabled. */
502
380
  get userTiming(): boolean;
503
- /**
504
- * Accumulate one phase sample.
505
- *
506
- * Totals rather than a per-frame log: the question is always "which phase owns
507
- * the frame", and a log of thousands of samples answers it less directly while
508
- * costing far more memory. `maxMs` is kept because a phase that is cheap on
509
- * average but spikes is a different problem from one that is uniformly slow.
510
- */
511
- private _recordPhase;
512
381
  /**
513
382
  * Recorded phase timings, most expensive first, with each phase's share of the
514
383
  * measured total.
@@ -519,9 +388,24 @@ export declare class Scene {
519
388
  get renderPhases(): RenderPhaseEntry[];
520
389
  /** Drop recorded phase timings, keeping timing enabled. */
521
390
  clearRenderPhases(): void;
522
- private _dirtyTracking;
523
- private _dirtyReasons;
524
- private dirty;
391
+ /**
392
+ * The dirty flag and its opt-in attribution (extraction 6, `DEC-0025`).
393
+ *
394
+ * A field initializer rather than a constructor assignment: it needs no
395
+ * injected input, so it follows {@link phases} and {@link a11yOrder} rather
396
+ * than the definite-assignment collaborators.
397
+ */
398
+ private readonly _dirty;
399
+ /**
400
+ * The redraw-pending flag, under its original name.
401
+ *
402
+ * `Scene.test.ts:2153` assigns `false` and `:2158` reads it, and the suite is
403
+ * unedited (`DEC-0019` rule 4). `private` is correct here rather than
404
+ * `protected` — unlike the extraction 1/3/4 accessors, this one still has
405
+ * in-class readers (`loop`, `step`, `frameStats`).
406
+ */
407
+ private get dirty();
408
+ private set dirty(value);
525
409
  /** Whether to throttle rendering to 2 FPS when the scene is static to save power. */
526
410
  autoThrottle: boolean;
527
411
  /** Wall-clock ms spent inside the last `render()` call. */
@@ -594,33 +478,6 @@ export declare class Scene {
594
478
  * {@link Entity.getContentEpoch}. (carryctx CTX-0199)
595
479
  */
596
480
  private contentSyncState;
597
- /** Pending cold font-calibration frame per projected grid entity. */
598
- private contentGridCalibrationFrames;
599
- /** Detached, untransformed font probes used by the cold calibration pass. */
600
- private contentGridCalibrationProbes;
601
- /**
602
- * Monotonic stamp identifying the conditions grid cells were calibrated under.
603
- *
604
- * Calibration measures the difference between the advance the canvas grid assigns
605
- * a cluster and the width the browser lays it out at, then writes a per-cell
606
- * `scaleX`. That result stays valid until the font or the page scale changes, and
607
- * it lives on the cell element — so a cell carrying this stamp needs no further
608
- * work.
609
- *
610
- * The scan that feeds calibration was O(cells) on every revision bump: for a
611
- * streaming code block it re-derived a measurement key for every cell in the
612
- * block each frame in order to produce only ~20 distinct keys, costing about
613
- * 2.5 ms/frame after the `style.font` fix and still over half of `a11ySync`. Since
614
- * carrier reuse (#244) leaves untouched lines — and therefore their calibrated
615
- * transforms — in place, cells stamped with the current generation can simply be
616
- * skipped, making the scan O(new cells) instead.
617
- *
618
- * A plain incrementing integer rather than the descriptive calibration key,
619
- * because it goes into an attribute selector and must not need escaping.
620
- */
621
- private contentGridCalibrationGeneration;
622
- /** The `(fontEpoch, pageScale)` pair the current generation corresponds to. */
623
- private contentGridCalibrationStamp;
624
481
  /** Invalidates grid font calibration after browser font availability changes. */
625
482
  private contentFontEpoch;
626
483
  /**
@@ -681,37 +538,6 @@ export declare class Scene {
681
538
  private contentSemanticBudget;
682
539
  private contentSemanticBudgetLeft;
683
540
  private contentSemanticDeferred;
684
- /**
685
- * Per-sync memo of "does the document hold a selection at all".
686
- *
687
- * Reading ANY property of a `Selection` (`anchorNode`, `rangeCount`, `type`,
688
- * `isCollapsed`) forces a synchronous layout, because Blink validates the
689
- * selection against current box geometry before answering. Measured in real
690
- * Chrome against a 1000-carrier subtree with layout dirtied between reads:
691
- * `anchorNode` 0.5ms, `rangeCount` 0.4ms, `type` 0.5ms, `isCollapsed` 0.5ms —
692
- * all indistinguishable from `offsetHeight` (0.5ms), against a 0ms floor for
693
- * mutating without reading. So there is no cheap property to probe with; the
694
- * only way to avoid the layout is to not touch the object at all.
695
- *
696
- * Materializing a block rebuilds its carriers, which asks whether the rebuild
697
- * would destroy a selection. Once per block, that read cost a forced layout
698
- * over the whole (and growing) projection subtree, which is what made
699
- * per-block cost rise with resident count: profiled at 1973 forced layouts
700
- * totalling 633ms of an 847ms 1000-block drain (75%).
701
- *
702
- * A selection is a single document-wide object and a sync walk cannot yield to
703
- * the user, so its presence cannot change mid-walk. Resolving it once per walk
704
- * turns O(blocks) forced layouts into O(1). `null` = not yet resolved.
705
- */
706
- private contentSelectionPresentThisSync;
707
- /**
708
- * True while a text-selection drag that started on a projection's blank
709
- * region (no text node under the press) is being driven manually — the
710
- * browser has no native anchor for it, so mousemove extends the Selection
711
- * from the position we resolved ourselves.
712
- */
713
- private blankRegionSelectionDrag;
714
- private contentSelectionAnchor;
715
541
  private contentSelectionEndListener;
716
542
  private frameHadAnimation;
717
543
  private frameHadInteractive;
@@ -729,10 +555,25 @@ export declare class Scene {
729
555
  private canvasResizeObserver;
730
556
  private dprChangeHandler;
731
557
  private focusedA11yElement;
732
- /** Last geometry `syncOverlayGeometry` wrote, so an unchanged frame can skip the
733
- * style writes entirely. Reset to `null` to force the next sync (a new overlay
734
- * layer was created and has never been positioned). */
735
- private _overlayGeometry;
558
+ /**
559
+ * Canvas box geometry: the CSS↔logical mapping, overlay layer alignment, and
560
+ * the DPR math (extraction 5, `DEC-0024`).
561
+ *
562
+ * Definite-assignment because it holds `a11yRoot` and `portalRoot`, which the
563
+ * constructor only decides partway through — the same shape as
564
+ * {@link _contentProjection}.
565
+ */
566
+ private _geometry;
567
+ /**
568
+ * The overlay memo, for the unedited suite.
569
+ *
570
+ * `OverlayGeometrySkip.test.ts:75,143` assigns `null` to force the next sync,
571
+ * so the setter delegates to `invalidateOverlay()`. `protected` rather than
572
+ * `private` because it has no in-class reader and `noUnusedLocals` fails the
573
+ * build on a private with none (`DEC-0019` rule 4).
574
+ */
575
+ protected get _overlayGeometry(): OverlayGeometry | null;
576
+ protected set _overlayGeometry(value: OverlayGeometry | null);
736
577
  /** Shadow elements the pointer is currently inside. Lets a removal that happens
737
578
  * mid-hover synthesize the `pointerleave` the browser never sends for a
738
579
  * detached element, so the entity doesn't keep its hover state. */
@@ -751,33 +592,60 @@ export declare class Scene {
751
592
  * screen-reader virtual cursor inside the scene's a11y region. */
752
593
  private focusSentinel;
753
594
  private caretBlinkTimer;
754
- a11yNeedsReorder: boolean;
755
- private portalRoot;
756
- private fullViewportElements;
757
- private normalElements;
758
- private activeIds;
759
- /** Per-parent insertion cursor, reused by `enforceA11yDomOrder`. */
760
- private a11yOrderCursors;
761
- /** Membership set for the elements being ordered, reused per reorder pass. */
762
- private a11yOrderMembers;
763
595
  /**
764
- * Elements that are an *ancestor* of another ordered element, reused per pass.
596
+ * The projected DOM's ordering engine the reorder flag, the per-pass scratch
597
+ * collections, the visual reading-order sort and the cursor-based
598
+ * `insertBefore` pass (extraction 2, `DEC-0020`).
599
+ *
600
+ * `a11yNeedsReorder` and `enforceA11yDomOrder` keep their names on `Scene` and
601
+ * delegate here; the collect-and-prune walk stays behind because it needs
602
+ * {@link shouldProjectA11y} and the focus/caret state.
603
+ */
604
+ private readonly a11yOrder;
605
+ /**
606
+ * The content projection's selection preservation and grid calibration
607
+ * (extraction 3, `DEC-0022`).
608
+ *
609
+ * Definite-assignment because it needs `a11yRoot`, which the constructor
610
+ * creates partway through — the same shape as {@link _wasmBackend}. Every
611
+ * constructor use is inside a deferred event listener, so it is always
612
+ * assigned before anything can reach it.
613
+ */
614
+ private _contentProjection;
615
+ /** Grid carrier materialization (the projection walk's separable leaf). */
616
+ private _gridProjector;
617
+ /**
618
+ * Pending grid-calibration frames, keyed by entity id.
619
+ *
620
+ * Delegates to {@link ContentProjectionManager}. Kept on `Scene` under its
621
+ * original name because the text-projection e2e reads
622
+ * `scene.contentGridCalibrationFrames.size` to assert calibration is not left
623
+ * in flight after a rebuild. `protected` rather than `private`: there is no
624
+ * in-class reader, and `private` plus no reader fails `noUnusedLocals`
625
+ * (`DEC-0019` rule 4).
626
+ */
627
+ protected get contentGridCalibrationFrames(): ReadonlyMap<string, number>;
628
+ /**
629
+ * Index of the carrier line holding a selection inside `el`, or `null`.
765
630
  *
766
- * A composite widget's container (a `grid` around its rows, a `tree` around its
767
- * items) spans every descendant row, so it must not extend a visual row band —
768
- * see {@link sortNormalElementsVisually}.
631
+ * Delegates to {@link ContentProjectionManager}. Kept under its original name
632
+ * because `ContentGridSelectionWindow.test.ts` calls it through a cast to
633
+ * assert the selection-window behaviour. `protected` per `DEC-0019` rule 4 —
634
+ * the in-class callers now go through the manager directly.
769
635
  */
770
- private a11yOrderContainers;
636
+ protected contentGridSelectionLine(el: HTMLElement): number | null;
771
637
  /**
772
- * Nearest `clipChildren` ancestor per ordered element its *region* reused
773
- * per pass. Written by `enforceA11yDomOrder`'s collect walk, which already has
774
- * the entity in hand, so a region costs one comparison per node rather than an
775
- * ancestor walk per element.
638
+ * Whether the projected a11y DOM needs reordering on the next pass.
776
639
  *
777
- * Absent means the element sits under no clipping ancestor and belongs to the
778
- * implicit root region. See {@link sortNormalElementsVisually}.
640
+ * Delegates to {@link A11yProjectionManager}. Kept on `Scene` under its
641
+ * original name and with a setter — because `Entity` assigns to
642
+ * `scene.a11yNeedsReorder` as a public cross-class contract (`Entity.ts` sets
643
+ * it when `interactive` flips and when a child is added or removed), and the
644
+ * unedited suite writes it directly too.
779
645
  */
780
- private a11yOrderRegions;
646
+ get a11yNeedsReorder(): boolean;
647
+ set a11yNeedsReorder(value: boolean);
648
+ private portalRoot;
781
649
  private activePortalsThisFrame;
782
650
  private activePortalsPrevFrame;
783
651
  private portalEntities;
@@ -791,14 +659,58 @@ export declare class Scene {
791
659
  * `_setWorldCache` are: it is a cross-class render-internal contract.
792
660
  */
793
661
  currentFrame: number;
794
- private _wasm;
795
- private _transformBackend;
796
- private _treeStore;
797
- private _slotEntity;
798
- private _wasmInputs;
799
- private _wasmWorld;
800
- private _structureVersion;
801
- private _storeStructureVersion;
662
+ /**
663
+ * The four invisible WASM accelerators and the resident transform store.
664
+ *
665
+ * Extraction 1 of this file's decomposition. Every public member below keeps
666
+ * its name, signature and behaviour and delegates here, so the public API is
667
+ * byte-identical; only where the state lives changed.
668
+ *
669
+ * Constructed in the constructor rather than initialized here because it needs
670
+ * {@link root}, which is itself assigned there.
671
+ */
672
+ private _wasmBackend;
673
+ /**
674
+ * The transform backend object itself.
675
+ *
676
+ * Kept under its original name and delegating, rather than deleted:
677
+ * `test/wasm/scene-accelerators.test.ts` reaches for `scene._wasm` to
678
+ * monkey-patch a kernel into rejecting, and that unedited suite is this
679
+ * refactor's gate — rewriting it to match the new shape would be marking our
680
+ * own homework. Same reasoning for `_animWasm`, `_wasmUploadRejections`,
681
+ * `_storeStructureVersion` and `_structureVersion` below.
682
+ *
683
+ * `protected` rather than `private` since extraction 4: the hit-grid build was
684
+ * this getter's last in-class reader, and `private` plus no reader fails
685
+ * `noUnusedLocals` (`DEC-0019` rule 4).
686
+ * @internal
687
+ */
688
+ protected get _wasm(): WasmTransformBackend | null;
689
+ /** @internal Read by the WASM anim suites; see {@link _wasm}. */
690
+ private get _animWasm();
691
+ /**
692
+ * @internal
693
+ * `protected`, not `private`, and that is the one deliberate access change in
694
+ * this extraction. Both of these are read by a test and by nothing inside this
695
+ * class, so `private` makes them dead code and `noUnusedLocals` rejects the
696
+ * build. `protected` is the honest encoding of "exists to be read from
697
+ * outside, never from in here" — and it is invisible to API consumers, who
698
+ * cannot reach a protected member either. `Scene` has no subclasses in this
699
+ * repo and is not documented as subclassable.
700
+ *
701
+ * Read by the run-table fallback suite; see {@link _wasm}.
702
+ */
703
+ protected get _wasmUploadRejections(): number;
704
+ /** @internal Read by the resident-store suite; see {@link _wasmUploadRejections}. */
705
+ protected get _storeStructureVersion(): number;
706
+ /**
707
+ * Tree topology version, bumped by every add/remove/reparent.
708
+ *
709
+ * Lives on the facade because the resident store layout is keyed by it, but is
710
+ * read here by the compute-entity cache below and by `Scene.test.ts`.
711
+ * @internal
712
+ */
713
+ private get _structureVersion();
802
714
  private _computeEntities;
803
715
  private _computeEntitiesVersion;
804
716
  /** Invalidate the resident WASM store layout; the next wasm-mode frame rebuilds
@@ -837,25 +749,6 @@ export declare class Scene {
837
749
  * Resolves `true` if WASM is now active, `false` if the JS path remains.
838
750
  */
839
751
  enableWasmTransforms(source: WasmModuleSource): Promise<boolean>;
840
- /**
841
- * The one WASM instance this Scene's accelerators share.
842
- *
843
- * Each `enableWasm*` used to instantiate the binary itself, so enabling all
844
- * four compiled the same module four times and held four linear memories. The
845
- * Rust crate already keeps transform/anim/hit/particle in separate statics, so
846
- * one instance serves all of them without aliasing. The compiled module is
847
- * cached globally; the instance is per-Scene, which is the isolation that
848
- * actually matters.
849
- */
850
- private _wasmRuntime;
851
- /**
852
- * Load (or reuse) this Scene's shared WASM runtime.
853
- *
854
- * Returns `null` on any failure — CSP `wasm-unsafe-eval`, a 404, corrupt bytes,
855
- * unsupported SIMD — so every caller keeps its JS path. Failure is the default
856
- * state here, not an error path.
857
- */
858
- private ensureWasmRuntime;
859
752
  /**
860
753
  * Install a pre-built runtime, so several Scenes can share one compile while
861
754
  * each keeps its own stores. Pass `null` to detach (backends already installed
@@ -864,46 +757,17 @@ export declare class Scene {
864
757
  setWasmRuntime(runtime: CoreWasmRuntime | null): void;
865
758
  /** The shared WASM runtime, if one has been loaded. */
866
759
  get wasmRuntime(): CoreWasmRuntime | null;
867
- private _hitWasm;
868
- private _hitGridFrame;
869
- private _hitGridOk;
870
- private _hitSlotEntity;
871
- private _hitBoundless;
872
- /** Reused buffer for the fused gather, so a pointer query allocates nothing. */
873
- private _hitGatherBuffer;
874
760
  /**
875
- * Whether the last grid build sourced its AABBs from the WASM transform store
876
- * rather than recomputing them in JS. Diagnostic only both paths must
877
- * produce the same entity for a given point.
878
- */
879
- private _hitFusedGather;
880
- /**
881
- * Whether `compute_aabbs` has run against the current frame's world matrices.
882
- * The AABB pass is only meaningful after a `compose_*`, so the fused gather
883
- * must not read the views before then.
761
+ * The pointer hit-test: the WASM broad-phase grid, the permanent JS
762
+ * depth-first walk, and the eligibility gating that keeps the two in lockstep
763
+ * (extraction 4, `DEC-0023`).
764
+ *
765
+ * Definite-assignment because it needs {@link _wasmBackend}, which is built
766
+ * partway through the constructor.
884
767
  */
885
- private _wasmAabbsFresh;
768
+ private _hitTester;
886
769
  /** Did the last hit-grid build use the fused (WASM-store) gather? */
887
770
  get hitGatherPath(): 'fused' | 'js';
888
- /**
889
- * Why the transform accelerator did or did not run on the most recent frame.
890
- * Written by the render walk and `_syncWasmStore`.
891
- */
892
- private _transformReason;
893
- /** Why the batched-driver accelerator did or did not run. */
894
- private _animReason;
895
- /**
896
- * Why the hit-test accelerator did or did not serve the last pointer query.
897
- * The grid is built lazily on demand, not every frame, so this describes the
898
- * most recent BUILD. Starts at `'not-installed'` because that is the truth
899
- * before a backend exists; `_ensureHitGrid` moves it to `'not-applicable'`
900
- * once one is installed but nothing has queried yet.
901
- */
902
- private _hitReason;
903
- /** Why the particle accelerator did or did not run. */
904
- private _particleReason;
905
- /** Which particle implementation actually simulated the most recent frame. */
906
- private _particlePath;
907
771
  /**
908
772
  * Per-frame status of every invisible accelerator: whether each is installed,
909
773
  * whether it actually ran on the most recent frame, and why.
@@ -918,6 +782,10 @@ export declare class Scene {
918
782
  *
919
783
  * Reflects the most recent main-renderer frame; a secondary renderer (SVG
920
784
  * export, offscreen snapshot) does not overwrite it.
785
+ *
786
+ * `webgpuActive` is handed to the facade rather than read by it: WebGPU device
787
+ * state belongs to the context/resize domain (extraction 5), and this getter
788
+ * is the only place the two domains have to agree.
921
789
  */
922
790
  get accelerators(): AcceleratorReport;
923
791
  /** Which backend answers `findEntityAt` for the main tree. */
@@ -934,34 +802,23 @@ export declare class Scene {
934
802
  */
935
803
  enableWasmHitTest(source: HitModuleSource): Promise<boolean>;
936
804
  /**
937
- * Refresh the hit-test grid for the CURRENT tree state if it is stale (a
938
- * structural or transform change may have happened since the last build —
939
- * there is no cheap "nothing moved" shortcut for a spatial index the way
940
- * there is for the transform store's topology-only run table, since ANY
941
- * entity moving invalidates its AABB, not just add/remove/reparent; the
942
- * measured build cost is cheap enough to redo per call). Returns `false`
943
- * (grid untrustworthy — caller must use the JS walk) when there is no
944
- * backend or the build overflowed its item budget.
945
- */
946
- private _ensureHitGrid;
947
- /**
948
- * `findEntityAt`'s WASM-accelerated path for the main tree. Scans only the
949
- * queried cell's candidates (confirming each against its own AABB and precise
950
- * `isPointInside`) merged against the (typically empty or tiny) list of
951
- * entities with no `getBounds()`, taking whichever confirmed match has the
952
- * higher pre-order index — see hit-store.ts for why that is exactly
953
- * equivalent to findHitRecursively's topmost-hit priority. Always
954
- * conclusive: returns the correct entity or `null`, never "inconclusive".
955
- */
956
- private _findEntityAtWasm;
957
- private _animWasm;
958
- private _activeDriverEntities;
959
- private _springEntities;
960
- private _springProps;
961
- private _springDrivers;
962
- private _tweenEntities;
963
- private _tweenProps;
964
- private _tweenDrivers;
805
+ * The batched driver tick and its candidate registry (extraction 6,
806
+ * `DEC-0025`).
807
+ *
808
+ * Definite-assignment because it holds {@link _wasmBackend}, which is built
809
+ * partway through the constructor the same shape as {@link _hitTester}.
810
+ */
811
+ private _driverTicker;
812
+ /**
813
+ * The candidate set, for the unedited suite.
814
+ *
815
+ * `test/wasm/scene-anim-batch.test.ts` reads it through a cast at four sites to
816
+ * assert the registry self-prunes, unregisters a removed subtree, and
817
+ * re-registers a re-added one. `protected` rather than `private` because it has
818
+ * no in-class reader and `noUnusedLocals` fails the build on a private with
819
+ * none (`DEC-0019` rule 4).
820
+ */
821
+ protected get _activeDriverEntities(): Set<Entity>;
965
822
  /**
966
823
  * Minimum number of batchable (spring, or named-easing tween) active drivers
967
824
  * before a frame engages the WASM batch path at all; below it, every driver
@@ -1029,7 +886,6 @@ export declare class Scene {
1029
886
  * Setting {@link animDriverGateCount} overwrites all three, so existing code
1030
887
  * that tuned the single knob keeps working unchanged.
1031
888
  */
1032
- private _animBatchedLastFrame;
1033
889
  /**
1034
890
  * Whether the WASM batch path actually ran on the most recent frame.
1035
891
  *
@@ -1060,7 +916,6 @@ export declare class Scene {
1060
916
  * if WASM is now available (not necessarily active every frame).
1061
917
  */
1062
918
  enableWasmAnimBatching(source: AnimModuleSource): Promise<boolean>;
1063
- private _particleWasm;
1064
919
  /** Which backend runs the CPU particle simulation. Reflects only whether a
1065
920
  * backend is installed (the WebGPU compute path, when active, is used first
1066
921
  * regardless). */
@@ -1113,25 +968,6 @@ export declare class Scene {
1113
968
  * pre-pass.
1114
969
  */
1115
970
  private _tickBatchedDrivers;
1116
- /**
1117
- * Compose the whole main tree's world matrices through the resident WASM store
1118
- * and return the world-matrix views for the render walk to read. Rebuilds the
1119
- * store layout (slots + runs) only when the tree structure changed since the
1120
- * last rebuild; otherwise it just gathers current transforms into the resident
1121
- * input view and runs the kernel. Returns `null` if there is no backend.
1122
- */
1123
- private _syncWasmStore;
1124
- /**
1125
- * Run the WASM world-AABB pass over the current frame's world matrices, so the
1126
- * fused hit gather can read AABBs straight out of the store.
1127
- *
1128
- * Local bounds are uploaded here rather than in the per-frame transform sync
1129
- * because `getBounds()` is a virtual call that allocates a rect on most
1130
- * entities — paying it every frame for a query that may never come would move
1131
- * cost onto the render path to save it on hover. Returns `false` if any entity
1132
- * cannot supply bounds through the store, so the caller uses the JS gather.
1133
- */
1134
- private _ensureWasmAabbs;
1135
971
  /**
1136
972
  * Authoritative paint order for semantic nodes discovered during the main
1137
973
  * render. A node may not have a DOM projection until the following a11y
@@ -1188,6 +1024,16 @@ export declare class Scene {
1188
1024
  * else the canvas). Stored so `destroy()` detaches from the same element. */
1189
1025
  private pointerEventTarget;
1190
1026
  private hasWarnedZeroSize;
1027
+ /**
1028
+ * Latch for {@link Scene.resize}'s invalid-dimension warning.
1029
+ *
1030
+ * Separate from `hasWarnedZeroSize`: that one fires for a tolerated zero-size
1031
+ * scene at `start()`, this one for a rejected resize, and sharing the flag
1032
+ * would let either suppress the other's first (and only) report. Latched
1033
+ * because `resize()` is commonly driven from a `ResizeObserver`, which would
1034
+ * otherwise warn on every frame of a drag.
1035
+ */
1036
+ private hasWarnedInvalidResize;
1191
1037
  private fontLoadHandler;
1192
1038
  private static _devMode;
1193
1039
  /**
@@ -1245,48 +1091,22 @@ export declare class Scene {
1245
1091
  * on the same canvas, restore DPR/size, and repaint.
1246
1092
  */
1247
1093
  private setupGLContextRecovery;
1248
- private endContentSelectionDrag;
1249
- /**
1250
- * Index of the carrier line currently holding a selection inside `el`, or
1251
- * `null`.
1252
- *
1253
- * Lets a partial re-materialization decide whether the user's selection is even
1254
- * affected. Checks the tracked anchor first (it survives a drag) and falls back
1255
- * to the live DOM selection.
1256
- */
1257
- private contentGridSelectionLine;
1258
- /**
1259
- * Does the document hold a selection right now, memoized for this sync walk?
1260
- *
1261
- * Pays one forced layout per walk instead of one per rebuilt element — see
1262
- * {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
1263
- * answer is `false` no element can own a selection, so every per-element
1264
- * ownership test can be skipped without touching the object.
1265
- */
1266
- private contentSelectionPresent;
1267
- private releaseContentSelectionForRebuild;
1268
- /**
1269
- * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
1270
- * text selection the user made inside it. A streaming message replaces its
1271
- * projection children on every appended chunk; without this, a selection in
1272
- * the UNCHANGED prefix is wiped on each frame ("can't select text in a
1273
- * message still receiving tokens"). We snapshot the selection's anchor/focus
1274
- * as linear character offsets within `el` before the rebuild and re-resolve
1275
- * them against the new DOM after, clamped to the new text length.
1276
- *
1277
- * Only fires when `el` owns the current selection and there is no active drag
1278
- * (mid-drag the browser is authoritative). The virtualization case — where
1279
- * `el` itself is removed from the DOM — is out of scope here (the node is
1280
- * genuinely freed; the browser clears the selection and there is nothing to
1281
- * restore against).
1282
- */
1283
- private preserveContentSelectionAcrossRebuild;
1284
1094
  /**
1285
1095
  * Expose the underlying {@link IRenderer} for advanced direct-draw operations.
1286
1096
  *
1287
1097
  * @returns The active renderer instance.
1288
1098
  */
1289
1099
  getRenderer(): IRenderer;
1100
+ /**
1101
+ * Finds the topmost interactive entity at the given coordinates.
1102
+ *
1103
+ * Delegates to {@link HitTester}. The frame stamp and the logical size are
1104
+ * passed in rather than reached for: `currentFrame` is the scheduler's
1105
+ * (extraction 6) and `width`/`height` are mutated by `resize`
1106
+ * (extraction 5), so neither can be captured at construction
1107
+ * (`DEC-0019` rule 5).
1108
+ */
1109
+ findEntityAt(x: number, y: number): Entity | null;
1290
1110
  /** Convert browser viewport coordinates into this Scene's logical coordinates. */
1291
1111
  clientToScene(clientX: number, clientY: number): {
1292
1112
  x: number;
@@ -1300,18 +1120,6 @@ export declare class Scene {
1300
1120
  * @example scene.add(new CircleEntity());
1301
1121
  */
1302
1122
  add(entity: Entity): this;
1303
- /**
1304
- * Reset per-grid calibration and bookkeeping before a (re)materialization.
1305
- *
1306
- * @param entityId - Owning entity, keyed into the calibration maps.
1307
- * @param el - The projection element.
1308
- * @param releaseSelection - Whether to drop a selection this element owns.
1309
- * Pass `false` when carrier lines are being reused: the selection's DOM nodes
1310
- * survive the pass, so tearing it down would wipe a user's selection on every
1311
- * streamed chunk — the exact bug `preserveContentSelectionAcrossRebuild`
1312
- * exists to prevent on the non-grid path.
1313
- */
1314
- private clearContentGridState;
1315
1123
  /**
1316
1124
  * Drop any projected elements under `node` without touching the entity tree.
1317
1125
  *
@@ -1376,7 +1184,8 @@ export declare class Scene {
1376
1184
  /**
1377
1185
  * Begin the `requestAnimationFrame` render loop.
1378
1186
  *
1379
- * Idempotent — calling `start()` on an already-running scene is a no-op.
1187
+ * Idempotent — calling `start()` on an already-running scene is a no-op, and so
1188
+ * is calling it on a destroyed one.
1380
1189
  */
1381
1190
  start(): void;
1382
1191
  /** Schedule the next frame, or no-op where `requestAnimationFrame` is absent (SSR). */
@@ -1465,13 +1274,6 @@ export declare class Scene {
1465
1274
  * directly rather than rebuilding a tree.
1466
1275
  */
1467
1276
  get structureVersion(): number;
1468
- /**
1469
- * Record who marked the scene dirty and why.
1470
- *
1471
- * Kept separate from {@link markDirty} so the hot path is not a function call
1472
- * with a branch — V8 inlines the one-field version reliably.
1473
- */
1474
- private recordDirtyReason;
1475
1277
  /**
1476
1278
  * Start or stop recording dirty attributions.
1477
1279
  *
@@ -1649,49 +1451,15 @@ export declare class Scene {
1649
1451
  */
1650
1452
  private projectionVisibleLocalYBand;
1651
1453
  private syncContentProjection;
1652
- /**
1653
- * Materialize a prepared grid in logical source order while positioning each
1654
- * carrier from the shared canvas geometry. Browser font measurement happens
1655
- * later in one cold read/write batch, never inside projection synchronization.
1656
- */
1657
- private syncContentGridProjection;
1658
1454
  private getContentMetricScaleX;
1659
- private scheduleContentGridCalibration;
1660
1455
  private enforceA11yDomOrder;
1661
1456
  /**
1662
- * Reorder `normalElements` (in place) into visual reading order using the
1663
- * positions `syncA11y` already wrote to each element's inline style
1664
- * (`top`/`left`/`height`). Elements are grouped into rows top-to-bottom (an
1665
- * element belongs to the current row while its top is above the row's
1666
- * running bottom edge), then sorted within a row by `left` ascending for
1667
- * `'ltr'`, descending for `'rtl'`. The sort is stable, so entities at the
1668
- * same position keep their scene-graph (collection) order as a tiebreak.
1669
- *
1670
- * Those inline values are world coordinates for a top-level mirror but
1671
- * PARENT-RELATIVE for a nested one, so this list mixes coordinate spaces.
1672
- * That is sound because the result is only ever applied per DOM parent
1673
- * ({@link enforceA11yDomOrder} advances a cursor per parent), and all of one
1674
- * parent's children share one space: a `grid`'s rows are all grid-relative, a
1675
- * `row`'s cells all row-relative. Comparisons ACROSS spaces do happen while
1676
- * banding, but they only affect the relative order of elements in different
1677
- * parents, which no `insertBefore` ever acts on. Normalizing everything back
1678
- * to world coordinates here would cost a transform per element per frame to
1679
- * change nothing observable.
1680
- *
1681
- * Banding runs **per region** — per nearest `clipChildren` ancestor, recorded
1682
- * by {@link enforceA11yDomOrder}'s collect walk — rather than once over the
1683
- * whole scene. Purely visual banding is right for a screen reader but wrong
1684
- * for selection: a DOM `Selection` covers everything between anchor and focus
1685
- * in DOM order, so under one global banding a vertical drag through a
1686
- * transcript also swallowed a sidebar whose headings happened to fall in the
1687
- * same rows. Regions are laid out side by side, so ordering region-major keeps
1688
- * each one a contiguous DOM run and a drag stays inside it, while reading
1689
- * order *within* a region is unchanged. Regions are emitted in the order their
1690
- * clipper is first reached by the depth-first walk, so a screen reader still
1691
- * meets them in the author's declared order.
1692
- */
1693
- private sortNormalElementsVisually;
1694
- /** Keep DOM/WebGL overlay layers aligned with the canvas's CSS box. */
1457
+ * Keep DOM/WebGL overlay layers aligned with the canvas's CSS box.
1458
+ *
1459
+ * The logical size and the lazily-created layers are threaded through rather
1460
+ * than held: `resize` mutates `width`/`height`, and `gpuCanvas` does not exist
1461
+ * until the WebGPU particle path first runs (`DEC-0019` rule 5).
1462
+ */
1695
1463
  private syncOverlayGeometry;
1696
1464
  getA11yTree(): A11yTreeNode[];
1697
1465
  private renderPortalDOM;
@@ -1737,11 +1505,12 @@ export declare class Scene {
1737
1505
  toSVG(): string;
1738
1506
  /**
1739
1507
  * Manually resize the Scene's viewport.
1508
+ *
1509
+ * Rejects a negative or non-finite dimension, keeping the last known-good
1510
+ * size. `0` is accepted: a zero-size scene is tolerated and warned about at
1511
+ * {@link start} instead, and rejecting it here would contradict that.
1740
1512
  */
1741
1513
  resize(width: number, height: number): void;
1742
- /** Effective device pixel ratio, matching CanvasRenderer: real DPR clamped to
1743
- * `maxDPR` when set. */
1744
- private effectiveDPR;
1745
1514
  /** Size the WebGPU particle canvas: backing store at logical × DPR, CSS box at
1746
1515
  * the logical size. Sizing the backing store in logical px (the old
1747
1516
  * behavior) left it rasterized at 1× and CSS-stretched — blurry on HiDPI. */
@@ -1756,29 +1525,10 @@ export declare class Scene {
1756
1525
  * Gets the root entity of the scene.
1757
1526
  */
1758
1527
  getRoot(): Entity;
1759
- /**
1760
- * Finds the topmost interactive entity at the given coordinates.
1761
- */
1762
- findEntityAt(x: number, y: number): Entity | null;
1763
1528
  /** Submit one transparent clear pass when particle content lingers on the GPU canvas. */
1764
1529
  private clearGPUCanvasIfStale;
1765
1530
  private initWebGPUContext;
1766
1531
  private setupDeviceLostHandler;
1767
1532
  private recreateWebGPUDeviceWithRetry;
1768
1533
  private renderCPUParticles;
1769
- private findHitRecursively;
1770
- /** Whether `node` opts out of being a pointer hit target: a disabled control
1771
- * or an explicit `pointerEvents: 'none'` in its a11y attributes. Its children
1772
- * are still walked (a transparent container can hold hittable descendants). */
1773
- private isPointerTransparent;
1774
- /**
1775
- * Whether a confirmed geometric hit on `node` at world `(x, y)` is a REAL hit,
1776
- * applying the same visibility/input gating as {@link findHitRecursively} but
1777
- * from a flat candidate (the WASM grid has no recursion clip-stack): the node
1778
- * and all ancestors are visible (`opacity > 0`), the point lies inside every
1779
- * `clipChildren` ancestor's world box, and the node isn't pointer-transparent
1780
- * (disabled / `pointerEvents: 'none'`). Keeps the WASM and JS hit paths in
1781
- * lockstep so they return the same entity.
1782
- */
1783
- private isHitEligible;
1784
1534
  }