@vectojs/core 1.31.0 → 1.32.1

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.
package/dist/index.mjs CHANGED
@@ -1379,6 +1379,7 @@ var SCENE_OPTION_KEYS = [
1379
1379
  "autoThrottle",
1380
1380
  "contentProjection",
1381
1381
  "contentProjectionMargin",
1382
+ "contentSemanticBudget",
1382
1383
  "contentSemanticMargin",
1383
1384
  "debugA11y",
1384
1385
  "disableWindowResize",
@@ -1432,6 +1433,7 @@ function parseInlinePx(value) {
1432
1433
  const n = parseFloat(value);
1433
1434
  return Number.isFinite(n) && n > 0 ? n : null;
1434
1435
  }
1436
+ var DEFAULT_CONTENT_SEMANTIC_BUDGET = 256;
1435
1437
  function projectionGridLineWindow(grid, projectionLines, band) {
1436
1438
  const count = grid.lines.length;
1437
1439
  const all = { start: 0, end: count, gated: false };
@@ -2007,6 +2009,41 @@ var Scene = class _Scene {
2007
2009
  // any projected DOM. `undefined` → falls back to contentProjectionMargin, so
2008
2010
  // the default keeps one gate. `Infinity` = every block keeps resident text.
2009
2011
  contentSemanticMargin = void 0;
2012
+ // How many coarse-tier blocks may be MATERIALIZED per sync, spreading the
2013
+ // resident tier's document-open cost across frames. `Infinity` = one
2014
+ // synchronous pass.
2015
+ contentSemanticBudget = DEFAULT_CONTENT_SEMANTIC_BUDGET;
2016
+ // Remaining materializations in the CURRENT sync. Reset at the start of each
2017
+ // a11y walk; decremented per coarse block that creates its element.
2018
+ contentSemanticBudgetLeft = 0;
2019
+ // Set when a sync deferred at least one block, so the scene knows to keep
2020
+ // drawing frames until the resident tier is complete. Without it a static
2021
+ // scene in `onDemand` mode would stop rendering with the document half
2022
+ // materialized and never finish.
2023
+ contentSemanticDeferred = false;
2024
+ /**
2025
+ * Per-sync memo of "does the document hold a selection at all".
2026
+ *
2027
+ * Reading ANY property of a `Selection` (`anchorNode`, `rangeCount`, `type`,
2028
+ * `isCollapsed`) forces a synchronous layout, because Blink validates the
2029
+ * selection against current box geometry before answering. Measured in real
2030
+ * Chrome against a 1000-carrier subtree with layout dirtied between reads:
2031
+ * `anchorNode` 0.5ms, `rangeCount` 0.4ms, `type` 0.5ms, `isCollapsed` 0.5ms —
2032
+ * all indistinguishable from `offsetHeight` (0.5ms), against a 0ms floor for
2033
+ * mutating without reading. So there is no cheap property to probe with; the
2034
+ * only way to avoid the layout is to not touch the object at all.
2035
+ *
2036
+ * Materializing a block rebuilds its carriers, which asks whether the rebuild
2037
+ * would destroy a selection. Once per block, that read cost a forced layout
2038
+ * over the whole (and growing) projection subtree, which is what made
2039
+ * per-block cost rise with resident count: profiled at 1973 forced layouts
2040
+ * totalling 633ms of an 847ms 1000-block drain (75%).
2041
+ *
2042
+ * A selection is a single document-wide object and a sync walk cannot yield to
2043
+ * the user, so its presence cannot change mid-walk. Resolving it once per walk
2044
+ * turns O(blocks) forced layouts into O(1). `null` = not yet resolved.
2045
+ */
2046
+ contentSelectionPresentThisSync = null;
2010
2047
  /**
2011
2048
  * True while a text-selection drag that started on a projection's blank
2012
2049
  * region (no text node under the press) is being driven manually — the
@@ -2074,6 +2111,16 @@ var Scene = class _Scene {
2074
2111
  * see {@link sortNormalElementsVisually}.
2075
2112
  */
2076
2113
  a11yOrderContainers = /* @__PURE__ */ new Set();
2114
+ /**
2115
+ * Nearest `clipChildren` ancestor per ordered element — its *region* — reused
2116
+ * per pass. Written by `enforceA11yDomOrder`'s collect walk, which already has
2117
+ * the entity in hand, so a region costs one comparison per node rather than an
2118
+ * ancestor walk per element.
2119
+ *
2120
+ * Absent means the element sits under no clipping ancestor and belongs to the
2121
+ * implicit root region. See {@link sortNormalElementsVisually}.
2122
+ */
2123
+ a11yOrderRegions = /* @__PURE__ */ new Map();
2077
2124
  activePortalsThisFrame = /* @__PURE__ */ new Set();
2078
2125
  activePortalsPrevFrame = /* @__PURE__ */ new Set();
2079
2126
  portalEntities = /* @__PURE__ */ new Map();
@@ -3043,6 +3090,7 @@ var Scene = class _Scene {
3043
3090
  this.contentProjectionEnabled = options.contentProjection ?? true;
3044
3091
  this.contentProjectionMargin = options.contentProjectionMargin;
3045
3092
  this.contentSemanticMargin = options.contentSemanticMargin;
3093
+ this.contentSemanticBudget = options.contentSemanticBudget ?? DEFAULT_CONTENT_SEMANTIC_BUDGET;
3046
3094
  this.readingDirection = options.readingDirection ?? "ltr";
3047
3095
  this.renderMode = options.renderMode ?? "always";
3048
3096
  this._devActive = _Scene._devModeDetected();
@@ -3324,12 +3372,31 @@ var Scene = class _Scene {
3324
3372
  }
3325
3373
  return null;
3326
3374
  }
3375
+ /**
3376
+ * Does the document hold a selection right now, memoized for this sync walk?
3377
+ *
3378
+ * Pays one forced layout per walk instead of one per rebuilt element — see
3379
+ * {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
3380
+ * answer is `false` no element can own a selection, so every per-element
3381
+ * ownership test can be skipped without touching the object.
3382
+ */
3383
+ contentSelectionPresent() {
3384
+ if (this.contentSelectionPresentThisSync !== null) {
3385
+ return this.contentSelectionPresentThisSync;
3386
+ }
3387
+ const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3388
+ const present = !!selection && (!!selection.anchorNode || !!selection.focusNode);
3389
+ this.contentSelectionPresentThisSync = present;
3390
+ return present;
3391
+ }
3327
3392
  releaseContentSelectionForRebuild(el) {
3393
+ if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) return;
3328
3394
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3329
3395
  const ownsSelection = this.contentSelectionAnchor && el.contains(this.contentSelectionAnchor.node) || (selection?.anchorNode ? el.contains(selection.anchorNode) : false) || (selection?.focusNode ? el.contains(selection.focusNode) : false);
3330
3396
  if (!ownsSelection) return;
3331
3397
  this.endContentSelectionDrag();
3332
3398
  selection?.removeAllRanges();
3399
+ this.contentSelectionPresentThisSync = null;
3333
3400
  }
3334
3401
  /**
3335
3402
  * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
@@ -3347,6 +3414,10 @@ var Scene = class _Scene {
3347
3414
  * restore against).
3348
3415
  */
3349
3416
  preserveContentSelectionAcrossRebuild(el, rebuild) {
3417
+ if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) {
3418
+ rebuild();
3419
+ return;
3420
+ }
3350
3421
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3351
3422
  const owns = !!selection && !this.blankRegionSelectionDrag && ((selection.anchorNode ? el.contains(selection.anchorNode) : false) || (selection.focusNode ? el.contains(selection.focusNode) : false));
3352
3423
  if (!owns || !selection.anchorNode || !selection.focusNode) {
@@ -4042,6 +4113,11 @@ var Scene = class _Scene {
4042
4113
  }
4043
4114
  syncA11y(node, container = null) {
4044
4115
  if (!this.a11yRoot) return;
4116
+ if (node === this.root) {
4117
+ this.contentSemanticBudgetLeft = this.contentSemanticBudget;
4118
+ this.contentSemanticDeferred = false;
4119
+ this.contentSelectionPresentThisSync = null;
4120
+ }
4045
4121
  if (node.isDOMPortal) {
4046
4122
  return;
4047
4123
  }
@@ -4460,7 +4536,9 @@ var Scene = class _Scene {
4460
4536
  * transparent DOM node positioned over the drawn glyphs. Runs on the a11y
4461
4537
  * sync cadence; all writes are dirty-checked. Off-viewport projections are
4462
4538
  * hidden (`display: none`) so text-heavy scenes only materialize what is
4463
- * visible to the browser's text machinery anyway.
4539
+ * visible to the browser's text machinery anyway — except in the coarse
4540
+ * (resident) tier, which stays displayed because hiding it would make its text
4541
+ * unfindable and remove it from the accessibility tree, defeating the tier.
4464
4542
  */
4465
4543
  /**
4466
4544
  * Whether `node`'s world-space box, expanded by `margin` px on every side,
@@ -4469,8 +4547,15 @@ var Scene = class _Scene {
4469
4547
  * at `margin = contentProjectionMargin`) and for the exact `display:none`
4470
4548
  * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
4471
4549
  * culling and always count as visible, matching the legacy behavior.
4550
+ *
4551
+ * `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower
4552
+ * question "does this box overlap the viewport at all". The coarse content tier
4553
+ * needs the two apart: text that is merely off-viewport is clipped by
4554
+ * `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text
4555
+ * rejected by an ancestor clip box that itself overlaps the viewport would sit
4556
+ * transparently on top of whatever is really drawn there.
4472
4557
  */
4473
- projectionBoxVisible(node, tf, margin) {
4558
+ projectionBoxVisible(node, tf, margin, viewportOnly = false) {
4474
4559
  if (!(node.width > 0 && node.height > 0)) return true;
4475
4560
  const { a, b, c, d, e, f } = tf;
4476
4561
  const worldCorners = [];
@@ -4492,6 +4577,7 @@ var Scene = class _Scene {
4492
4577
  if (!(maxX >= -margin && minX <= this.width + margin && maxY >= -margin && minY <= this.height + margin)) {
4493
4578
  return false;
4494
4579
  }
4580
+ if (viewportOnly) return true;
4495
4581
  for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
4496
4582
  if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
4497
4583
  let localMinX = Infinity;
@@ -4594,6 +4680,10 @@ var Scene = class _Scene {
4594
4680
  return;
4595
4681
  }
4596
4682
  }
4683
+ if (tier === "coarse" && !el && this.contentSemanticBudgetLeft <= 0) {
4684
+ this.contentSemanticDeferred = true;
4685
+ return;
4686
+ }
4597
4687
  const projection = node.getContentProjection(
4598
4688
  lineBand ? { minY: lineBand.minY, maxY: lineBand.maxY } : void 0
4599
4689
  );
@@ -4602,6 +4692,7 @@ var Scene = class _Scene {
4602
4692
  return;
4603
4693
  }
4604
4694
  if (!el) {
4695
+ if (tier === "coarse") this.contentSemanticBudgetLeft--;
4605
4696
  el = document.createElement("div");
4606
4697
  el.setAttribute("data-vecto-content", node.id);
4607
4698
  const s = el.style;
@@ -4736,7 +4827,8 @@ var Scene = class _Scene {
4736
4827
  if (node.width > 0) el.style.width = `${node.width}px`;
4737
4828
  if (node.height > 0) el.style.height = `${node.height}px`;
4738
4829
  el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
4739
- const display = visible ? "" : "none";
4830
+ const residentTier = semanticMargin > interactionMargin;
4831
+ const display = visible || residentTier && !this.projectionBoxVisible(node, worldTf, 0, true) ? "" : "none";
4740
4832
  if (el.style.display !== display) el.style.display = display;
4741
4833
  if (epoch !== null) {
4742
4834
  const { a: a2, b: b2, c: c2, d: d2, e: e2, f: f2 } = worldTf;
@@ -5087,12 +5179,14 @@ var Scene = class _Scene {
5087
5179
  this.fullViewportElements.length = 0;
5088
5180
  this.normalElements.length = 0;
5089
5181
  this.activeIds.clear();
5090
- const collect = (node) => {
5182
+ this.a11yOrderRegions.clear();
5183
+ const collect = (node, region) => {
5091
5184
  if (node.isDOMPortal) return;
5092
5185
  const contentEl = this.contentElements.get(node.id);
5093
5186
  if (contentEl) {
5094
5187
  if (node.a11yFullViewport) this.fullViewportElements.push(contentEl);
5095
5188
  else this.normalElements.push(contentEl);
5189
+ if (region) this.a11yOrderRegions.set(contentEl, region);
5096
5190
  }
5097
5191
  if (this.shouldProjectA11y(node)) {
5098
5192
  const el = this.a11yElements.get(node.id);
@@ -5100,14 +5194,16 @@ var Scene = class _Scene {
5100
5194
  this.activeIds.add(node.id);
5101
5195
  if (node.a11yFullViewport) this.fullViewportElements.push(el);
5102
5196
  else this.normalElements.push(el);
5197
+ if (region) this.a11yOrderRegions.set(el, region);
5103
5198
  }
5104
5199
  }
5105
- for (const child of node.children) collect(child);
5200
+ const childRegion = node.clipChildren && node.width > 0 && node.height > 0 ? node : region;
5201
+ for (const child of node.children) collect(child, childRegion);
5106
5202
  if (node === this.root) {
5107
- for (const overlay of this.overlayRoot.children) collect(overlay);
5203
+ for (const overlay of this.overlayRoot.children) collect(overlay, null);
5108
5204
  }
5109
5205
  };
5110
- collect(this.root);
5206
+ collect(this.root, null);
5111
5207
  let elementsPruned = false;
5112
5208
  for (const [id, el] of this.a11yElements.entries()) {
5113
5209
  if (!this.activeIds.has(id)) {
@@ -5169,6 +5265,18 @@ var Scene = class _Scene {
5169
5265
  * parents, which no `insertBefore` ever acts on. Normalizing everything back
5170
5266
  * to world coordinates here would cost a transform per element per frame to
5171
5267
  * change nothing observable.
5268
+ *
5269
+ * Banding runs **per region** — per nearest `clipChildren` ancestor, recorded
5270
+ * by {@link enforceA11yDomOrder}'s collect walk — rather than once over the
5271
+ * whole scene. Purely visual banding is right for a screen reader but wrong
5272
+ * for selection: a DOM `Selection` covers everything between anchor and focus
5273
+ * in DOM order, so under one global banding a vertical drag through a
5274
+ * transcript also swallowed a sidebar whose headings happened to fall in the
5275
+ * same rows. Regions are laid out side by side, so ordering region-major keeps
5276
+ * each one a contiguous DOM run and a drag stays inside it, while reading
5277
+ * order *within* a region is unchanged. Regions are emitted in the order their
5278
+ * clipper is first reached by the depth-first walk, so a screen reader still
5279
+ * meets them in the author's declared order.
5172
5280
  */
5173
5281
  sortNormalElementsVisually() {
5174
5282
  const els = this.normalElements;
@@ -5196,30 +5304,40 @@ var Scene = class _Scene {
5196
5304
  }
5197
5305
  return { top, left };
5198
5306
  };
5199
- const order = els.map((el, i) => {
5307
+ const decorated = els.map((el, i) => {
5200
5308
  const { top, left } = absolute(el);
5201
5309
  return { el, i, top, left, container: containers.has(el) };
5202
5310
  });
5203
- order.sort((p, q) => p.top - q.top || p.i - q.i);
5311
+ const regions = this.a11yOrderRegions;
5312
+ const buckets = /* @__PURE__ */ new Map();
5313
+ for (const d of decorated) {
5314
+ const key = regions.get(d.el) ?? null;
5315
+ const bucket = buckets.get(key);
5316
+ if (bucket) bucket.push(d);
5317
+ else buckets.set(key, [d]);
5318
+ }
5204
5319
  const bandBottom = (r) => r.top + (r.container ? 4 : heightOf(r.el));
5205
5320
  const sorted = [];
5206
- let rowStart = 0;
5207
- let rowBottom = order.length ? bandBottom(order[0]) : 0;
5208
- const flushRow = (end) => {
5209
- const row = order.slice(rowStart, end);
5210
- row.sort((p, q) => (rtl ? q.left - p.left : p.left - q.left) || p.i - q.i);
5211
- for (const r of row) sorted.push(r.el);
5212
- };
5213
- for (let k = 1; k < order.length; k++) {
5214
- if (order[k].top < rowBottom) {
5215
- rowBottom = Math.max(rowBottom, bandBottom(order[k]));
5216
- } else {
5217
- flushRow(k);
5218
- rowStart = k;
5219
- rowBottom = bandBottom(order[k]);
5321
+ for (const order of buckets.values()) {
5322
+ order.sort((p, q) => p.top - q.top || p.i - q.i);
5323
+ let rowStart = 0;
5324
+ let rowBottom = order.length ? bandBottom(order[0]) : 0;
5325
+ const flushRow = (end) => {
5326
+ const row = order.slice(rowStart, end);
5327
+ row.sort((p, q) => (rtl ? q.left - p.left : p.left - q.left) || p.i - q.i);
5328
+ for (const r of row) sorted.push(r.el);
5329
+ };
5330
+ for (let k = 1; k < order.length; k++) {
5331
+ if (order[k].top < rowBottom) {
5332
+ rowBottom = Math.max(rowBottom, bandBottom(order[k]));
5333
+ } else {
5334
+ flushRow(k);
5335
+ rowStart = k;
5336
+ rowBottom = bandBottom(order[k]);
5337
+ }
5220
5338
  }
5339
+ flushRow(order.length);
5221
5340
  }
5222
- flushRow(order.length);
5223
5341
  for (let i = 0; i < sorted.length; i++) els[i] = sorted[i];
5224
5342
  }
5225
5343
  /** Keep DOM/WebGL overlay layers aligned with the canvas's CSS box. */
@@ -5379,7 +5497,7 @@ var Scene = class _Scene {
5379
5497
  if (!this.isRunning) return;
5380
5498
  if (!this._canvasOnScreen) return;
5381
5499
  let cap = this.effectiveMaxFPS();
5382
- const isIdle = !this.dirty && !this.frameHadAnimation;
5500
+ const isIdle = !this.dirty && !this.frameHadAnimation && !this.contentSemanticDeferred;
5383
5501
  if (isIdle && this.autoThrottle && this.renderMode === "always" && this.maxFPS > 0) {
5384
5502
  cap = Math.min(cap, 2);
5385
5503
  }
@@ -6910,6 +7028,7 @@ export {
6910
7028
  CanvasRenderer,
6911
7029
  Circle,
6912
7030
  ComputeParticleEntity,
7031
+ DEFAULT_CONTENT_SEMANTIC_BUDGET,
6913
7032
  DOMPortalEntity,
6914
7033
  Entity,
6915
7034
  GlyphRasterAtlas,
@@ -220,15 +220,45 @@ export interface SceneOptions {
220
220
  * unwindowed carrier band, not from resident text.
221
221
  *
222
222
  * Note the one-time cost. A resident tier materializes one element per block
223
- * on the first sync — measured ~13µs per node created, so ~20ms at 1000 blocks
224
- * and ~146ms at 10000 — as one synchronous block. Steady state is cheap
223
+ * on the first sync — measured unbudgeted at 21.3ms for 1000 blocks and 139.5ms
224
+ * for 10000 on Chrome — as one synchronous block. Steady state is cheap
225
225
  * (unchanged blocks skip via {@link Entity.getContentEpoch}), so this is a
226
- * document-open stall, not a per-frame cost.
226
+ * document-open stall, not a per-frame cost. That stall is what
227
+ * {@link SceneOptions.contentSemanticBudget} spreads across frames.
227
228
  *
228
229
  * Default: whatever `contentProjectionMargin` resolves to, so omitting this
229
230
  * leaves behaviour unchanged.
230
231
  */
231
232
  contentSemanticMargin?: number;
233
+ /**
234
+ * How many resident (coarse-tier) blocks may be materialized in **one** sync,
235
+ * bounding the document-open stall a wide {@link
236
+ * SceneOptions.contentSemanticMargin} otherwise pays all at once.
237
+ *
238
+ * The cost of a resident tier is per node **created**, not per node held: 10000
239
+ * resident blocks cost ~3.0 ms/sync at steady state, while creating them costs
240
+ * ~0.03 ms each plus a per-pass floor that grows with how many are already
241
+ * resident. So the front-load is a *scheduling* problem, and this is the
242
+ * schedule — remaining blocks materialize on subsequent syncs, a few per frame,
243
+ * until the document is fully resident.
244
+ *
245
+ * What it does **not** change is the end state: the same blocks end up with the
246
+ * same DOM, only later. Nothing is dropped, so the reachability the semantic
247
+ * tier exists for is preserved; a block still waiting is simply not yet in the
248
+ * DOM, exactly as a block beyond the margin is not.
249
+ *
250
+ * Applies **only** to the coarse tier. A block inside the interaction margin is
251
+ * on screen and materializes immediately regardless of this budget — deferring
252
+ * visible text would make it briefly unselectable, which is a user-visible
253
+ * regression rather than a cost saving.
254
+ *
255
+ * `Infinity` disables the budget and restores one synchronous pass. Default:
256
+ * {@link DEFAULT_CONTENT_SEMANTIC_BUDGET}. Because the coarse tier exists only
257
+ * when `contentSemanticMargin` is wider than `contentProjectionMargin`, a scene
258
+ * that does not opt into a resident tier has no coarse blocks and is therefore
259
+ * unaffected by any value here.
260
+ */
261
+ contentSemanticBudget?: number;
232
262
  /**
233
263
  * Reading direction used to order the accessibility/automation shadow tree so
234
264
  * keyboard **tab order** and screen-reader traversal follow the *visual*
@@ -268,7 +298,7 @@ export interface SceneOptions {
268
298
  * against. A new option must be added here too — the test suite asserts the two
269
299
  * stay in sync.
270
300
  */
271
- export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
301
+ export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticBudget', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
272
302
  /** Frame-rate the loop is capped to when the OS requests reduced motion. */
273
303
  export declare const REDUCED_MOTION_FPS = 30;
274
304
  /**
@@ -359,6 +389,45 @@ export interface A11yTreeNode {
359
389
  valuemax?: string;
360
390
  children: A11yTreeNode[];
361
391
  }
392
+ /**
393
+ * Default {@link SceneOptions.contentSemanticBudget}: resident blocks
394
+ * materialized per sync.
395
+ *
396
+ * Sized against the two costs a pass actually pays, both measured in real headed
397
+ * Chrome on a 240Hz panel. Per created block is cheap and flat (~0.03ms). What
398
+ * dominates is style+layout of the projection subtree, which scales with how many
399
+ * blocks are already RESIDENT and is paid once per pass: traced at 10000 blocks,
400
+ * `UpdateLayoutTree` 391.7ms + `Layout` 305.8ms over 40 passes (~17ms each), with
401
+ * per-pass cost roughly doubling from the first pass to the last while the number
402
+ * created stayed constant.
403
+ *
404
+ * So total drain cost is approximately `passes × f(resident)`, and a SMALLER
405
+ * budget multiplies the term that does not shrink. Measured to completion, 3
406
+ * repeats, medians:
407
+ *
408
+ * ```text
409
+ * 1000 blocks budget 32 → 67.1ms total, 4.3ms worst pass
410
+ * budget 64 → 54.0ms total, 5.1ms worst pass
411
+ * budget 256 → 27.7ms total, 7.7ms worst pass
412
+ * Infinity → 24.1ms total, 23.6ms worst pass
413
+ * 10000 blocks budget 32 → 3773.2ms total, 42.6ms worst pass
414
+ * budget 64 → 1896.2ms total, 41.6ms worst pass
415
+ * budget 256 → 648.1ms total, 35.2ms worst pass
416
+ * Infinity → 319.4ms total, 307.3ms worst pass
417
+ * ```
418
+ *
419
+ * 256 is where the two goals stop trading against each other. Below it there is no
420
+ * frame-bound improvement at 10000 blocks — every budget lands at 35-43ms, because
421
+ * the worst pass is the LAST one laying out the complete subtree — while total time
422
+ * rises 6x. At 1000 blocks it still holds 7.7ms, inside a 60Hz frame, for less than
423
+ * half the total time of 64.
424
+ *
425
+ * This replaces an earlier default of 64, which was sized against a per-block cost
426
+ * of ~0.4ms. That figure was inflated by a forced layout per materialized block
427
+ * (see `contentSelectionPresentThisSync`); with that removed, 64 spends 6x the
428
+ * total time for no frame-bound gain.
429
+ */
430
+ export declare const DEFAULT_CONTENT_SEMANTIC_BUDGET = 256;
362
431
  /**
363
432
  * Top-level orchestrator that owns the entity tree, drive the render loop,
364
433
  * and maintains the accessibility/automation shadow layer.
@@ -560,6 +629,32 @@ export declare class Scene {
560
629
  private contentProjectionEnabled;
561
630
  private contentProjectionMargin;
562
631
  private contentSemanticMargin;
632
+ private contentSemanticBudget;
633
+ private contentSemanticBudgetLeft;
634
+ private contentSemanticDeferred;
635
+ /**
636
+ * Per-sync memo of "does the document hold a selection at all".
637
+ *
638
+ * Reading ANY property of a `Selection` (`anchorNode`, `rangeCount`, `type`,
639
+ * `isCollapsed`) forces a synchronous layout, because Blink validates the
640
+ * selection against current box geometry before answering. Measured in real
641
+ * Chrome against a 1000-carrier subtree with layout dirtied between reads:
642
+ * `anchorNode` 0.5ms, `rangeCount` 0.4ms, `type` 0.5ms, `isCollapsed` 0.5ms —
643
+ * all indistinguishable from `offsetHeight` (0.5ms), against a 0ms floor for
644
+ * mutating without reading. So there is no cheap property to probe with; the
645
+ * only way to avoid the layout is to not touch the object at all.
646
+ *
647
+ * Materializing a block rebuilds its carriers, which asks whether the rebuild
648
+ * would destroy a selection. Once per block, that read cost a forced layout
649
+ * over the whole (and growing) projection subtree, which is what made
650
+ * per-block cost rise with resident count: profiled at 1973 forced layouts
651
+ * totalling 633ms of an 847ms 1000-block drain (75%).
652
+ *
653
+ * A selection is a single document-wide object and a sync walk cannot yield to
654
+ * the user, so its presence cannot change mid-walk. Resolving it once per walk
655
+ * turns O(blocks) forced layouts into O(1). `null` = not yet resolved.
656
+ */
657
+ private contentSelectionPresentThisSync;
563
658
  /**
564
659
  * True while a text-selection drag that started on a projection's blank
565
660
  * region (no text node under the press) is being driven manually — the
@@ -624,6 +719,16 @@ export declare class Scene {
624
719
  * see {@link sortNormalElementsVisually}.
625
720
  */
626
721
  private a11yOrderContainers;
722
+ /**
723
+ * Nearest `clipChildren` ancestor per ordered element — its *region* — reused
724
+ * per pass. Written by `enforceA11yDomOrder`'s collect walk, which already has
725
+ * the entity in hand, so a region costs one comparison per node rather than an
726
+ * ancestor walk per element.
727
+ *
728
+ * Absent means the element sits under no clipping ancestor and belongs to the
729
+ * implicit root region. See {@link sortNormalElementsVisually}.
730
+ */
731
+ private a11yOrderRegions;
627
732
  private activePortalsThisFrame;
628
733
  private activePortalsPrevFrame;
629
734
  private portalEntities;
@@ -1101,6 +1206,15 @@ export declare class Scene {
1101
1206
  * to the live DOM selection.
1102
1207
  */
1103
1208
  private contentGridSelectionLine;
1209
+ /**
1210
+ * Does the document hold a selection right now, memoized for this sync walk?
1211
+ *
1212
+ * Pays one forced layout per walk instead of one per rebuilt element — see
1213
+ * {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
1214
+ * answer is `false` no element can own a selection, so every per-element
1215
+ * ownership test can be skipped without touching the object.
1216
+ */
1217
+ private contentSelectionPresent;
1104
1218
  private releaseContentSelectionForRebuild;
1105
1219
  /**
1106
1220
  * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
@@ -1428,7 +1542,9 @@ export declare class Scene {
1428
1542
  * transparent DOM node positioned over the drawn glyphs. Runs on the a11y
1429
1543
  * sync cadence; all writes are dirty-checked. Off-viewport projections are
1430
1544
  * hidden (`display: none`) so text-heavy scenes only materialize what is
1431
- * visible to the browser's text machinery anyway.
1545
+ * visible to the browser's text machinery anyway — except in the coarse
1546
+ * (resident) tier, which stays displayed because hiding it would make its text
1547
+ * unfindable and remove it from the accessibility tree, defeating the tier.
1432
1548
  */
1433
1549
  /**
1434
1550
  * Whether `node`'s world-space box, expanded by `margin` px on every side,
@@ -1437,6 +1553,13 @@ export declare class Scene {
1437
1553
  * at `margin = contentProjectionMargin`) and for the exact `display:none`
1438
1554
  * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
1439
1555
  * culling and always count as visible, matching the legacy behavior.
1556
+ *
1557
+ * `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower
1558
+ * question "does this box overlap the viewport at all". The coarse content tier
1559
+ * needs the two apart: text that is merely off-viewport is clipped by
1560
+ * `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text
1561
+ * rejected by an ancestor clip box that itself overlaps the viewport would sit
1562
+ * transparently on top of whatever is really drawn there.
1440
1563
  */
1441
1564
  private projectionBoxVisible;
1442
1565
  /**
@@ -1496,6 +1619,18 @@ export declare class Scene {
1496
1619
  * parents, which no `insertBefore` ever acts on. Normalizing everything back
1497
1620
  * to world coordinates here would cost a transform per element per frame to
1498
1621
  * change nothing observable.
1622
+ *
1623
+ * Banding runs **per region** — per nearest `clipChildren` ancestor, recorded
1624
+ * by {@link enforceA11yDomOrder}'s collect walk — rather than once over the
1625
+ * whole scene. Purely visual banding is right for a screen reader but wrong
1626
+ * for selection: a DOM `Selection` covers everything between anchor and focus
1627
+ * in DOM order, so under one global banding a vertical drag through a
1628
+ * transcript also swallowed a sidebar whose headings happened to fall in the
1629
+ * same rows. Regions are laid out side by side, so ordering region-major keeps
1630
+ * each one a contiguous DOM run and a drag stays inside it, while reading
1631
+ * order *within* a region is unchanged. Regions are emitted in the order their
1632
+ * clipper is first reached by the depth-first walk, so a screen reader still
1633
+ * meets them in the author's declared order.
1499
1634
  */
1500
1635
  private sortNormalElementsVisually;
1501
1636
  /** Keep DOM/WebGL overlay layers aligned with the canvas's CSS box. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/core",
3
- "version": "1.31.0",
3
+ "version": "1.32.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },