@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,147 @@
1
+ /**
2
+ * The a11y projection's DOM **ordering** engine: what order the projected
3
+ * mirrors sit in, and what that reordering must not break.
4
+ *
5
+ * Extraction 2 of the `Scene.ts` decomposition
6
+ * (`forge/decisions/file-decomposition-2026-08.md` §2), reduced in scope by
7
+ * carryctx `DEC-0020`. `Scene` keeps `enforceA11yDomOrder` and
8
+ * `a11yNeedsReorder` under their original names; the ordering half of the former
9
+ * and the whole of the latter live here.
10
+ *
11
+ * ## What this owns
12
+ *
13
+ * The per-pass scratch collections (all reused rather than reallocated, which is
14
+ * why they are fields and not locals), the reorder flag, the visual reading-order
15
+ * sort, and the cursor-based `insertBefore` pass with its focus and `Selection`
16
+ * preservation.
17
+ *
18
+ * ## What it deliberately does not own
19
+ *
20
+ * `Scene.enforceA11yDomOrder` still runs the collect-and-prune walk. Collecting
21
+ * needs `shouldProjectA11y` — which reaches the pointer position (extraction 5)
22
+ * and the content-projection tier (extraction 3) — and pruning needs
23
+ * `focusedA11yElement`, `caretBlinkTimer` and `preserveFocusOnRemoval`. So
24
+ * `Scene` walks and prunes, feeding each element in through {@link collect}, and
25
+ * this class orders what it was given.
26
+ *
27
+ * Three members the original extraction plan assigned here stayed on `Scene`,
28
+ * measured rather than assumed (`DEC-0020`):
29
+ *
30
+ * - `syncA11y` is not an a11y method. It is the shared depth-first walk driver
31
+ * for a11y **and** content projection: it calls `syncContentProjection` at its
32
+ * own recursion point and initialises four per-sync fields whose readers live
33
+ * in three other domains. It moves once extraction 3 has taken its co-driver
34
+ * out, not before — taking it now would need a back-edge into `Scene`, which
35
+ * `DEC-0019` rule 1 forbids.
36
+ * - `removeA11yRecursively` deletes content projections and releases DOM portals,
37
+ * which are extraction 3 and the ninth portal domain respectively.
38
+ * - `syncOverlayGeometry` is overlay-**layer** geometry: every dependency except
39
+ * `a11yRoot` (`canvas`, `glCanvas`, `gpuCanvas`, `width`, `height`) belongs to
40
+ * extraction 5. It sits under an a11y banner, which is a fourth instance of
41
+ * `DEC-0016`'s finding that the banners expose wrong cuts.
42
+ *
43
+ * ## Reading direction is passed in
44
+ *
45
+ * The inline sort is direction-sensitive and `_readingDirection` belongs to the
46
+ * text/layout side of `Scene`, so {@link reorder} takes it as an argument rather
47
+ * than reaching for it — `DEC-0019` rule 5, the same shape as
48
+ * `WasmBackendFacade.report(webgpuActive)`.
49
+ */
50
+ import type { Entity } from '../Entity';
51
+ export declare class A11yProjectionManager {
52
+ /**
53
+ * Set by anything that changes which elements exist or how they nest, and
54
+ * cleared by the reorder pass. Public so `Scene` can expose it under its
55
+ * original name: `Entity` assigns to `scene.a11yNeedsReorder` as a public
56
+ * cross-class contract, so the flag needs a setter and not only
57
+ * {@link markNeedsReorder}.
58
+ */
59
+ needsReorder: boolean;
60
+ /** Overlay mirrors, kept in insertion order — they cover everything, so the
61
+ * author's declared order is the right one. */
62
+ private readonly fullViewportElements;
63
+ /** Everything else, sorted into visual reading order by {@link reorder}. */
64
+ private readonly normalElements;
65
+ /** Ids collected this pass, read by `Scene`'s prune pass through
66
+ * {@link isActive}. */
67
+ private readonly activeIdSet;
68
+ /** Per-parent insertion cursor, reused by {@link reorder}. */
69
+ private readonly orderCursors;
70
+ /** Membership set for the elements being ordered, reused per reorder pass. */
71
+ private readonly orderMembers;
72
+ /**
73
+ * Elements that are an *ancestor* of another ordered element, reused per pass.
74
+ *
75
+ * A composite widget's container (a `grid` around its rows, a `tree` around its
76
+ * items) spans every descendant row, so it must not extend a visual row band —
77
+ * see {@link sortNormalElementsVisually}.
78
+ */
79
+ private readonly orderContainers;
80
+ /**
81
+ * Nearest region-establishing ancestor per ordered element — its *region*.
82
+ * Written by `Scene.enforceA11yDomOrder`'s collect walk through
83
+ * {@link collect}, which already has the entity in hand, so a region costs one
84
+ * comparison per node rather than an ancestor walk per element.
85
+ *
86
+ * An ancestor establishes a region by setting `a11yRegion` (grouping declared
87
+ * directly) or `clipChildren` (a clipper is usually the column boundary you
88
+ * want anyway). Absent means the element sits under neither and belongs to the
89
+ * implicit root region. See {@link sortNormalElementsVisually}.
90
+ */
91
+ private readonly orderRegions;
92
+ /** Mark the projected DOM as needing a reorder on the next pass. */
93
+ markNeedsReorder(): void;
94
+ /** Reset the per-pass collections. Zero-GC: length/clear, never reallocate. */
95
+ beginCollect(): void;
96
+ /**
97
+ * Add one projected element to this pass.
98
+ *
99
+ * `region` is the nearest ancestor that sets `a11yRegion` or `clipChildren`,
100
+ * or `null` for the implicit root region.
101
+ */
102
+ collect(el: HTMLElement, fullViewport: boolean, region: Entity | null): void;
103
+ /** Record that `id` still has a live a11y mirror this pass. */
104
+ markActive(id: string): void;
105
+ /** Whether `id` was collected this pass. Drives `Scene`'s prune pass. */
106
+ isActive(id: string): boolean;
107
+ /**
108
+ * Put the collected elements into visual reading order in the DOM.
109
+ *
110
+ * No-op unless {@link needsReorder} is set; clears it on the way out.
111
+ */
112
+ reorder(rtl: boolean): void;
113
+ /**
114
+ * Reorder `normalElements` (in place) into visual reading order using the
115
+ * positions `syncA11y` already wrote to each element's inline style
116
+ * (`top`/`left`/`height`). Elements are grouped into rows top-to-bottom (an
117
+ * element belongs to the current row while its top is above the row's
118
+ * running bottom edge), then sorted within a row by `left` — ascending for
119
+ * `'ltr'`, descending for `'rtl'`. The sort is stable, so entities at the
120
+ * same position keep their scene-graph (collection) order as a tiebreak.
121
+ *
122
+ * Those inline values are world coordinates for a top-level mirror but
123
+ * PARENT-RELATIVE for a nested one, so this list mixes coordinate spaces.
124
+ * That is sound because the result is only ever applied per DOM parent
125
+ * ({@link reorder} advances a cursor per parent), and all of one parent's
126
+ * children share one space: a `grid`'s rows are all grid-relative, a `row`'s
127
+ * cells all row-relative. Comparisons ACROSS spaces do happen while banding,
128
+ * but they only affect the relative order of elements in different parents,
129
+ * which no `insertBefore` ever acts on. Normalizing everything back to world
130
+ * coordinates here would cost a transform per element per frame to change
131
+ * nothing observable.
132
+ *
133
+ * Banding runs **per region** — per nearest ancestor setting `a11yRegion` or
134
+ * `clipChildren`, recorded by `Scene.enforceA11yDomOrder`'s collect walk —
135
+ * rather than once over the whole scene. Purely visual banding is right for a
136
+ * screen reader but wrong for selection: a DOM `Selection` covers everything
137
+ * between anchor and focus in DOM order, so under one global banding a
138
+ * vertical drag through a transcript also swallowed a sidebar whose headings
139
+ * happened to fall in the same rows. Regions are laid out side by side, so
140
+ * ordering region-major keeps each one a contiguous DOM run and a drag stays
141
+ * inside it, while reading order *within* a region is unchanged. Regions are
142
+ * emitted in the order their establishing ancestor is first reached by the
143
+ * depth-first walk, so a screen reader still meets them in the author's
144
+ * declared order.
145
+ */
146
+ private sortNormalElementsVisually;
147
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Canvas box geometry: the CSS↔logical mapping, the overlay layer alignment, and
3
+ * the device-pixel-ratio math.
4
+ *
5
+ * Extraction 5 of the `Scene.ts` decomposition
6
+ * (`forge/decisions/file-decomposition-2026-08.md` §2), shipped at **reduced
7
+ * scope** for the same measured reason extractions 2 and 3 were reduced
8
+ * (`DEC-0020`, `DEC-0022`): the decided `ContextAndResize` scope named nine
9
+ * members, and six of them cannot move without a `Scene` back-edge. See the
10
+ * decision record for the per-member measurement.
11
+ *
12
+ * ## What this owns
13
+ *
14
+ * Everything that is a pure function of the canvas's CSS box and the scene's
15
+ * logical size:
16
+ *
17
+ * - {@link clientToScene} — browser viewport coordinates to logical ones.
18
+ * - {@link syncOverlay} — keeps the a11y, portal, WebGL and WebGPU layers
19
+ * aligned with the canvas box, including the `position` that layers share with
20
+ * the canvas so a scroll needs no JS compensation, and the memo that makes an
21
+ * unchanged frame write nothing.
22
+ * - {@link effectiveDPR} and {@link sizeGpuCanvas} — the DPR clamp and the
23
+ * backing-store sizing that follows from it.
24
+ *
25
+ * ## What is held, and what is passed in
26
+ *
27
+ * `canvas`, `a11yRoot` and `portalRoot` are held: `Scene` assigns all three
28
+ * exactly once in its constructor and never reassigns them (`canvas` at
29
+ * `Scene.ts:2423`, `a11yRoot` at `:2526`/`:2683`, `portalRoot` at
30
+ * `:2670`/`:2684`).
31
+ *
32
+ * Everything else is a per-call argument (`DEC-0019` rule 5), because it is
33
+ * genuinely mutable and holding it would go stale silently:
34
+ *
35
+ * - `width`/`height` are **public** fields that `resize` mutates and that code
36
+ * outside this package assigns directly (9 sites across the test suite,
37
+ * benchmarks and comparisons write `scene.width = …`). They cannot become this
38
+ * collaborator's state without changing the public API.
39
+ * - `maxDPR` is public and externally assignable (`Scene.test.ts:1013`).
40
+ * - `glCanvas` and `gpuCanvas` are created lazily — `gpuCanvas` only when the
41
+ * WebGPU particle path first runs (`Scene.ts:6468`) — so the overlay sync is
42
+ * told which layers exist at the moment it runs.
43
+ *
44
+ * ## What deliberately did not move
45
+ *
46
+ * `resize` stays on `Scene` and keeps its whole body. It mutates `width`,
47
+ * `height`, `contentFontEpoch` and `contentViewportEpoch`, drives `renderer` and
48
+ * `pointRenderer`, and calls `markDirty` — four domains, none of them this one.
49
+ * It calls in here for the GPU canvas sizing and that is the honest extent of it
50
+ * (`DEC-0019` rule 2 applied without inventing a back-edge).
51
+ *
52
+ * `setupEvents` and `watchDevicePixelRatio` both call `this.resize(…)`, so moving
53
+ * either needs a `Scene` reference; a bound `resize` injected at construction is
54
+ * still that reference captured in a closure, which `DEC-0020` refused for
55
+ * `syncA11y` and `DEC-0021` refused for `_recordPhase`.
56
+ *
57
+ * `watchCanvasVisibility` reads `isRunning`, writes `lastTime`, calls
58
+ * `scheduleFrame`, and owns `_canvasOnScreen`, whose readers are `loop` and
59
+ * `stop`. That is render-scheduler state, so it belongs to extraction 6.
60
+ *
61
+ * `initWebGPUContext` is one member of a device-lifecycle cluster (`device`,
62
+ * `deviceLost`, `manager`, `recoveryTimerId`, `gpuContext`,
63
+ * `setupDeviceLostHandler`, `recreateWebGPUDeviceWithRetry`,
64
+ * `clearGPUCanvasIfStale`) that `render` and `destroy` also drive.
65
+ *
66
+ * `getContentMetricScaleX` caches into `contentMetricScaleX` keyed by
67
+ * `contentFontEpoch` and is called only by `syncContentGridProjection`, so it is
68
+ * content-projection state and moves with the deferred projection walk.
69
+ */
70
+ /** The overlay box last written by {@link CanvasGeometry.syncOverlay}. */
71
+ export interface OverlayGeometry {
72
+ left: number;
73
+ top: number;
74
+ cssWidth: number;
75
+ cssHeight: number;
76
+ width: number;
77
+ height: number;
78
+ /**
79
+ * `position` written to the overlay layers, mirroring the canvas's own.
80
+ *
81
+ * Part of the memo because it is part of the written state: a canvas that
82
+ * becomes `fixed` after the first sync (a scroll-driven CSS class, a
83
+ * full-screen toggle) changes nothing else in here — same box, same logical
84
+ * size — so without this the memo would short-circuit and leave the overlay
85
+ * positioned the old way.
86
+ */
87
+ position: 'absolute' | 'fixed';
88
+ }
89
+ export declare class CanvasGeometry {
90
+ private readonly canvas;
91
+ private readonly a11yRoot;
92
+ private readonly portalRoot;
93
+ /** Last geometry {@link syncOverlay} wrote, so an unchanged frame can skip the
94
+ * style writes entirely. Cleared by {@link invalidateOverlay} to force the next
95
+ * sync (a new overlay layer was created and has never been positioned). */
96
+ private overlayGeometry;
97
+ constructor(canvas: HTMLCanvasElement, a11yRoot: HTMLDivElement | null, portalRoot: HTMLDivElement | null);
98
+ /** Convert browser viewport coordinates into the scene's logical coordinates. */
99
+ clientToScene(clientX: number, clientY: number, width: number, height: number): {
100
+ x: number;
101
+ y: number;
102
+ };
103
+ /** Effective device pixel ratio, matching CanvasRenderer: real DPR clamped to
104
+ * `maxDPR` when set. */
105
+ effectiveDPR(maxDPR: number | undefined): number;
106
+ /** Size the WebGPU particle canvas: backing store at logical × DPR, CSS box at
107
+ * the logical size. Sizing the backing store in logical px (the old
108
+ * behavior) left it rasterized at 1× and CSS-stretched — blurry on HiDPI. */
109
+ sizeGpuCanvas(gpuCanvas: HTMLCanvasElement, width: number, height: number, maxDPR: number | undefined): void;
110
+ /** The memo, for the delegating accessor `Scene` keeps for its test readers. */
111
+ get overlay(): OverlayGeometry | null;
112
+ /** Force the next {@link syncOverlay} to write: a new overlay layer exists and
113
+ * has never been positioned. */
114
+ invalidateOverlay(): void;
115
+ /** Keep DOM/WebGL overlay layers aligned with the canvas's CSS box. */
116
+ syncOverlay(width: number, height: number, glCanvas: HTMLCanvasElement | null, gpuCanvas: HTMLCanvasElement | null): void;
117
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Grid carrier materialization: turning a prepared content grid into positioned
3
+ * DOM carriers, with per-line reuse so streaming stays affordable.
4
+ *
5
+ * The deferred **projection walk** extraction (`DEC-0028` measurement,
6
+ * `DEC-0019` pattern). The walk was deferred by `DEC-0020` and `DEC-0022` on the
7
+ * grounds that `syncA11y` and `syncContentProjection` are one shared depth-first
8
+ * walk and would have to move as a pair. Re-measured on `58c77ac` that premise
9
+ * held for the two walk drivers and **not** for this member: the grid path is a
10
+ * leaf of the walk, not part of it.
11
+ *
12
+ * ## Why this is separable when its callers are not
13
+ *
14
+ * `syncContentGridProjection` has exactly one call site, takes everything it
15
+ * needs as parameters already, and calls nothing else on `Scene` except the
16
+ * content-projection manager and the phase timer. Its own state is two memo
17
+ * fields nobody outside it reads. It writes no state any other domain reads, so
18
+ * there is no back-edge to invent and `DEC-0019` rule 1 is satisfied outright.
19
+ *
20
+ * ## What is held, and what is passed in
21
+ *
22
+ * Held: the {@link ContentProjectionManager} and the {@link PhaseTimer}. Both are
23
+ * `readonly` on `Scene`, constructed once, and already shared collaborators — the
24
+ * timer is the shared leaf `DEC-0021` extracted for exactly this reason.
25
+ *
26
+ * Per-call (`DEC-0019` rule 5): `pageScaleX` and `fontEpoch`. `Scene` keeps
27
+ * `getContentMetricScaleX`, which reads `canvas` and the **public mutable**
28
+ * `width`, and whose memo is keyed on `contentFontEpoch` that `resize` and a font
29
+ * load both bump. Holding either here would go stale silently. `entityId` is
30
+ * passed rather than the `Entity`, because the id is all this needs.
31
+ *
32
+ * ## What deliberately did not move
33
+ *
34
+ * `syncContentProjection` (the walk's content driver) and `syncA11y` (its a11y
35
+ * driver) stay together on the facade, still blocked on each other exactly as
36
+ * `DEC-0020` measured.
37
+ *
38
+ * `projectionBoxVisible` also stays, for a reason measurement found rather than
39
+ * predicted: `test/ContentProjectionSettledWalk.test.ts` replaces it on the
40
+ * `Scene` instance and asserts call counts (the settled-walk fast path is
41
+ * verified by counting box tests, 2 when settled against 802 unpruned). Moving
42
+ * it onto a collaborator would make those calls invisible to the patch and turn
43
+ * a behavioural regression test into one that cannot fail. The suite is unedited
44
+ * by contract, so the member stays where the test can see it.
45
+ */
46
+ import type { ContentProjection } from '../Entity';
47
+ import type { PreparedContentGrid } from '@vectojs/text';
48
+ import type { ContentProjectionManager } from './ContentProjectionManager';
49
+ import type { PhaseTimer } from './PhaseTimer';
50
+ export declare class ContentGridProjector {
51
+ private readonly contentProjection;
52
+ private readonly phases;
53
+ constructor(contentProjection: ContentProjectionManager, phases: PhaseTimer);
54
+ /**
55
+ * Materialize a prepared grid in logical source order while positioning each
56
+ * carrier from the shared canvas geometry. Browser font measurement happens
57
+ * later in one cold read/write batch, never inside projection synchronization.
58
+ */
59
+ syncGrid(entityId: string, el: HTMLElement, projection: ContentProjection, grid: PreparedContentGrid, lineBand: {
60
+ minY: number;
61
+ maxY: number;
62
+ } | null, pageScaleX: number, fontEpoch: number): void;
63
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The content projection's **selection preservation** and **grid calibration** —
3
+ * the two halves of extraction 3 that are separable today.
4
+ *
5
+ * Extraction 3 of the `Scene.ts` decomposition
6
+ * (`forge/decisions/file-decomposition-2026-08.md` §2), reduced in scope by
7
+ * carryctx `DEC-0022` for the same measured reason `DEC-0020` reduced extraction
8
+ * 2: the projection *walk* is not separable yet.
9
+ *
10
+ * ## What this owns
11
+ *
12
+ * **Selection across rebuilds.** A streaming message replaces its projection
13
+ * children on every appended chunk, and a naive rebuild wipes a selection the
14
+ * user made in the unchanged prefix. This owns the tracked drag anchor, the
15
+ * blank-region drag flag, the per-walk "is anything selected at all" memo, and
16
+ * the snapshot/restore of selection endpoints as linear character offsets.
17
+ *
18
+ * **Grid calibration.** The cold read/write batch that measures a projected
19
+ * grid's real laid-out text and writes a per-cell `scaleX`, its generation
20
+ * stamping, its probe DOM, and the pending-frame/probe bookkeeping that lets a
21
+ * teardown cancel work in flight.
22
+ *
23
+ * ## What it deliberately does not own
24
+ *
25
+ * `syncContentProjection` (624 lines) and its helpers stay on `Scene`. It is the
26
+ * far side of the shared walk `DEC-0020` measured: `syncA11y` calls it at its own
27
+ * recursion point, and it reads the four per-sync fields `syncA11y` initialises
28
+ * (`_syncSerial`, `contentSemanticBudgetLeft`, `contentSemanticDeferred`,
29
+ * `contentSelectionPresentThisSync`). The two walk drivers move together, once
30
+ * they can be cut as a pair rather than threaded through each other — taking
31
+ * either alone needs a back-edge, which `DEC-0019` rule 1 forbids.
32
+ *
33
+ * `getContentMetricScaleX` also stays: it reads `canvas` and `width`, which
34
+ * `resize` mutates, so it is extraction 5's state. Its result is already a
35
+ * parameter of {@link scheduleGridCalibration}, so nothing here reaches for it.
36
+ *
37
+ * ## What is passed in, and why
38
+ *
39
+ * `a11yRoot` is injected because it is assigned once in `Scene`'s constructor and
40
+ * never reassigned, so it is safe to hold. {@link PhaseTimer} is injected because
41
+ * it is the shared leaf `DEC-0021` extracted for exactly this: the calibration
42
+ * pass records `calibScan` and `calibProbeBuild`, and reaching a `Scene` private
43
+ * to do it — or holding a bound `Scene` method — is the rule 1 violation this
44
+ * whole sequence is avoiding.
45
+ *
46
+ * The font epoch is a per-call argument rather than held state (`DEC-0019` rule
47
+ * 5): a font load and a resize both bump it, and both belong to other domains.
48
+ */
49
+ import { PhaseTimer } from './PhaseTimer';
50
+ import { type TextCaretPosition } from './content-caret';
51
+ export declare class ContentProjectionManager {
52
+ /**
53
+ * `a11yRoot` doubles as the projection root: carriers are appended to it, and
54
+ * the calibration probe is parented under it so CSS zoom and font substitution
55
+ * match the live carriers. `null` in non-DOM (SSR/Node) environments.
56
+ */
57
+ private readonly a11yRoot;
58
+ private readonly phases;
59
+ /**
60
+ * True while a drag that started in a blank region of the projection is live.
61
+ * Mid-drag the browser is authoritative, so a rebuild must not try to preserve
62
+ * anything.
63
+ */
64
+ private blankRegionDrag;
65
+ /** Tracked drag anchor. Survives a drag, unlike the live DOM selection. */
66
+ private anchor;
67
+ /**
68
+ * Memo for {@link selectionPresent}, valid for one sync walk.
69
+ *
70
+ * Reading any `Selection` property forces a synchronous layout, so the answer
71
+ * is resolved once per walk rather than once per rebuilt element. `null` means
72
+ * "not yet asked this walk".
73
+ */
74
+ private presentThisSync;
75
+ /** Pending calibration rAF handle per projected grid entity. */
76
+ private readonly calibrationFrameHandles;
77
+ /** Detached, untransformed font probes used by the cold calibration pass. */
78
+ private readonly calibrationProbes;
79
+ /**
80
+ * Bumped when the conditions a measurement depends on change, which
81
+ * invalidates every existing per-cell `scaleX` at once without touching them.
82
+ */
83
+ private calibrationGeneration;
84
+ /** The conditions the current generation was measured under. */
85
+ private calibrationStamp;
86
+ constructor(a11yRoot: HTMLDivElement | null, phases: PhaseTimer);
87
+ /** Pending calibration frames, keyed by entity id. Read by the e2e lifecycle probe. */
88
+ get calibrationFrames(): ReadonlyMap<string, number>;
89
+ /**
90
+ * Start tracking a drag that began in a blank region of the projection.
91
+ *
92
+ * Called from the projection root's `mousedown` handler, which has already
93
+ * resolved the caret and collapsed or extended the live selection.
94
+ */
95
+ beginBlankRegionDrag(anchor: TextCaretPosition): void;
96
+ /** The tracked drag anchor, for extending a selection as the pointer moves. */
97
+ get selectionAnchor(): TextCaretPosition | null;
98
+ /**
99
+ * Whether a manually-driven blank-region drag is live.
100
+ *
101
+ * The projection root's `mousemove` handler gates on this: with no native
102
+ * anchor, the browser will not extend the selection itself.
103
+ */
104
+ get blankRegionDragActive(): boolean;
105
+ /**
106
+ * Drop the memo describing whether the document holds a selection.
107
+ *
108
+ * Called at the top of each a11y sync walk: the memo answers a question about
109
+ * the live document, and a value from an earlier frame would be wrong.
110
+ */
111
+ invalidateSelectionMemo(): void;
112
+ endDrag(): void;
113
+ /**
114
+ * Index of the carrier line currently holding a selection inside `el`, or
115
+ * `null`.
116
+ *
117
+ * Lets a partial re-materialization decide whether the user's selection is even
118
+ * affected. Checks the tracked anchor first (it survives a drag) and falls back
119
+ * to the live DOM selection.
120
+ */
121
+ gridSelectionLine(el: HTMLElement): number | null;
122
+ /**
123
+ * Does the document hold a selection right now, memoized for this sync walk?
124
+ *
125
+ * Pays one forced layout per walk instead of one per rebuilt element — see
126
+ * {@link presentThisSync} for the measurements. When the answer is `false` no
127
+ * element can own a selection, so every per-element ownership test can be
128
+ * skipped without touching the object.
129
+ */
130
+ selectionPresent(): boolean;
131
+ releaseSelectionForRebuild(el: HTMLElement): void;
132
+ /**
133
+ * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
134
+ * text selection the user made inside it. A streaming message replaces its
135
+ * projection children on every appended chunk; without this, a selection in
136
+ * the UNCHANGED prefix is wiped on each frame ("can't select text in a
137
+ * message still receiving tokens"). We snapshot the selection's anchor/focus
138
+ * as linear character offsets within `el` before the rebuild and re-resolve
139
+ * them against the new DOM after, clamped to the new text length.
140
+ *
141
+ * Only fires when `el` owns the current selection and there is no active drag
142
+ * (mid-drag the browser is authoritative). The virtualization case — where
143
+ * `el` itself is removed from the DOM — is out of scope here (the node is
144
+ * genuinely freed; the browser clears the selection and there is nothing to
145
+ * restore against).
146
+ */
147
+ preserveSelectionAcrossRebuild(el: HTMLElement, rebuild: () => void): void;
148
+ /**
149
+ * A selection inside a projected grid, expressed as offsets into `grid.source`.
150
+ *
151
+ * Source offsets rather than the linear DOM offsets
152
+ * {@link preserveSelectionAcrossRebuild} uses, because the grid path windows its
153
+ * carriers: the DOM holds only the lines near the viewport, so linear offset 0
154
+ * means "the first line that happens to be materialized" and moves whenever the
155
+ * window does. A reflow changes both the line breaks and the window, so a linear
156
+ * offset would restore the selection onto different characters. Every carrier
157
+ * cell already records its own `sourceStart`/`sourceEnd`, which are stable
158
+ * against line breaking, windowing, and per-cell calibration.
159
+ */
160
+ private gridSelectionEndpointOffset;
161
+ /**
162
+ * Where in `grid.source` the live selection sits, or `null` when this element
163
+ * does not own one that can be expressed that way.
164
+ *
165
+ * Cheap-rejects exactly as {@link releaseSelectionForRebuild} does: the tracked
166
+ * anchor is a local field, and the memo costs one forced layout per sync walk
167
+ * rather than one per element.
168
+ */
169
+ snapshotGridSelection(el: HTMLElement): {
170
+ anchor: number;
171
+ focus: number;
172
+ } | null;
173
+ /** The carrier caret for a source offset, or `null` when it is not projected. */
174
+ private gridCaretAtSourceOffset;
175
+ /**
176
+ * Put a {@link snapshotGridSelection} result back after a re-materialization,
177
+ * releasing instead whenever the selected text is no longer projected.
178
+ *
179
+ * Restoring is what keeps a selection alive across a reflow or a browser zoom,
180
+ * where every carrier line is rebuilt (the line breaks moved) but the selected
181
+ * characters are still on screen. When the window scrolled past them instead,
182
+ * the offsets resolve to nothing and the selection is dropped — a `Range` left
183
+ * pointing into detached carriers reports stale geometry and copies the wrong
184
+ * text.
185
+ */
186
+ restoreGridSelection(el: HTMLElement, snapshot: {
187
+ anchor: number;
188
+ focus: number;
189
+ } | null): void;
190
+ /**
191
+ * Reset per-grid calibration and bookkeeping before a (re)materialization.
192
+ *
193
+ * @param entityId - Owning entity, keyed into the calibration maps.
194
+ * @param el - The projection element.
195
+ * @param releaseSelection - Whether to drop a selection this element owns.
196
+ * Pass `false` when carrier lines are being reused: the selection's DOM nodes
197
+ * survive the pass, so tearing it down would wipe a user's selection on every
198
+ * streamed chunk — the exact bug {@link preserveSelectionAcrossRebuild}
199
+ * exists to prevent on the non-grid path.
200
+ */
201
+ clearGridState(entityId: string, el: HTMLElement, releaseSelection?: boolean): void;
202
+ /**
203
+ * Cancel every calibration in flight and drop every probe.
204
+ *
205
+ * For `Scene.destroy()`: an outstanding rAF would otherwise run against a
206
+ * destroyed scene, and a probe left in the document keeps a detached subtree
207
+ * alive.
208
+ */
209
+ dispose(): void;
210
+ /**
211
+ * Measure a projected grid's real laid-out text and write a per-cell `scaleX`.
212
+ *
213
+ * @param fontEpoch - `Scene`'s font epoch. Passed in rather than held: a font
214
+ * load and a resize both bump it, and both belong to other domains.
215
+ */
216
+ scheduleGridCalibration(entityId: string, el: HTMLElement, calibrationKey: string, pageScaleX: number, fontEpoch: number): void;
217
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The dirty flag and its attribution: whether the next frame must redraw, and
3
+ * who asked for it.
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`. The decided `RenderScheduler` scope named
8
+ * `loop` and `render` as its centre; neither is movable, so what ships is this
9
+ * cluster and {@link DriverTicker}.
10
+ *
11
+ * ## What this owns
12
+ *
13
+ * The flag itself, the opt-in attribution map, and its FIFO bound. The two are
14
+ * one domain rather than two: `markDirty` is the only writer of the flag and the
15
+ * only trigger of an attribution, so splitting them would put a branch on the hot
16
+ * path in one class and its data in another.
17
+ *
18
+ * ## The hot path
19
+ *
20
+ * {@link mark} is called from dozens of sites, several per frame. It stays a
21
+ * single field write plus one already-false branch when tracking is off, which is
22
+ * why {@link record} is a separate method: V8 inlines the one-field version
23
+ * reliably, and that split was deliberate before this extraction.
24
+ *
25
+ * ## What is passed in
26
+ *
27
+ * `currentFrame` is a per-call argument (`DEC-0019` rule 5). It is written by
28
+ * `Scene.render`, which did not move, so capturing it would go stale every frame.
29
+ *
30
+ * ## What deliberately did not move
31
+ *
32
+ * `Scene.markDirty` keeps its exact name and signature and delegates here. It is
33
+ * a cross-class contract — `Entity.ts` calls `scene.markDirty()`, and there are
34
+ * 129 call sites across the workspace packages — so it stays reachable on `Scene`
35
+ * (`DEC-0019` rule 2).
36
+ *
37
+ * `renderMode`, `maxFPS` and `autoThrottle` stay on `Scene` as public fields:
38
+ * they are consumed by `loop`, which did not move, and `renderMode` has three
39
+ * external readers in `@vectojs/devtools`. The frame telemetry counters stay with
40
+ * `loop` for the same reason — it is their only writer.
41
+ */
42
+ /**
43
+ * Who marked the scene dirty, and why.
44
+ *
45
+ * Every field is optional except `reason` so a call site can be as specific as it
46
+ * cheaply can — an entity id costs nothing to pass, a property name is often
47
+ * already in scope.
48
+ */
49
+ export interface DirtySource {
50
+ /** Entity id responsible, when one is. Omitted for scene-level invalidation. */
51
+ entity?: string;
52
+ /** Short, stable category — e.g. `'text-changed'`, `'animation'`, `'resize'`. */
53
+ reason: string;
54
+ /** Property that changed, when the reason alone is ambiguous. */
55
+ property?: string;
56
+ }
57
+ /** An aggregated dirty attribution. */
58
+ export interface DirtyReasonEntry {
59
+ entity?: string;
60
+ reason: string;
61
+ property?: string;
62
+ /** How many times this exact attribution was recorded. */
63
+ count: number;
64
+ firstFrame: number;
65
+ lastFrame: number;
66
+ }
67
+ export declare class DirtyTracker {
68
+ /** Cap on distinct recorded dirty reasons (see {@link record}). */
69
+ private static readonly MAX_DIRTY_REASONS;
70
+ private tracking;
71
+ private readonly reasons;
72
+ private isDirty;
73
+ /** Whether the next frame must redraw. */
74
+ get dirty(): boolean;
75
+ /**
76
+ * Mark the scene as needing a redraw on the next frame.
77
+ *
78
+ * Attribution is opt-in and costs nothing when off: this is called from dozens
79
+ * of sites, several of them per-frame, so the common path must stay a single
80
+ * field write.
81
+ */
82
+ mark(source: DirtySource | undefined, currentFrame: number): void;
83
+ /**
84
+ * Consume the flag.
85
+ *
86
+ * `Scene.loop` clears it BEFORE the update/render pass: any `markDirty()` call
87
+ * made inside an entity's `update()` must survive into the next frame
88
+ * (self-animating entities re-arm themselves this way). Clearing after render
89
+ * would silently wipe those marks and freeze the entity.
90
+ */
91
+ clear(): void;
92
+ /**
93
+ * Record who marked the scene dirty and why.
94
+ *
95
+ * Kept separate from {@link mark} so the hot path is not a function call with a
96
+ * branch — V8 inlines the one-field version reliably.
97
+ */
98
+ private record;
99
+ /** Start or stop recording dirty attributions. */
100
+ setTracking(enabled: boolean): void;
101
+ /** Whether dirty attribution is currently being recorded. */
102
+ get trackingEnabled(): boolean;
103
+ /** Recorded dirty attributions, most frequent first. */
104
+ get sortedReasons(): DirtyReasonEntry[];
105
+ /** Drop recorded attributions, keeping tracking enabled. */
106
+ clearReasons(): void;
107
+ }