@vectojs/core 1.30.0 → 1.32.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.
package/dist/index.mjs CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  SVGEntity,
19
19
  VectoJSEvent,
20
20
  contentLineInHint
21
- } from "./chunk-2WXZCPYQ.mjs";
21
+ } from "./chunk-RWN3ITBD.mjs";
22
22
 
23
23
  // src/tree/Scene.ts
24
24
  import { SpringDriver, TweenDriver } from "@vectojs/animation";
@@ -1379,6 +1379,8 @@ var SCENE_OPTION_KEYS = [
1379
1379
  "autoThrottle",
1380
1380
  "contentProjection",
1381
1381
  "contentProjectionMargin",
1382
+ "contentSemanticBudget",
1383
+ "contentSemanticMargin",
1382
1384
  "debugA11y",
1383
1385
  "disableWindowResize",
1384
1386
  "maxDPR",
@@ -1431,6 +1433,7 @@ function parseInlinePx(value) {
1431
1433
  const n = parseFloat(value);
1432
1434
  return Number.isFinite(n) && n > 0 ? n : null;
1433
1435
  }
1436
+ var DEFAULT_CONTENT_SEMANTIC_BUDGET = 256;
1434
1437
  function projectionGridLineWindow(grid, projectionLines, band) {
1435
1438
  const count = grid.lines.length;
1436
1439
  const all = { start: 0, end: count, gated: false };
@@ -1956,6 +1959,15 @@ var Scene = class _Scene {
1956
1959
  a11yElements = /* @__PURE__ */ new Map();
1957
1960
  /** DOM nodes mirroring static text content, keyed by entity id. */
1958
1961
  contentElements = /* @__PURE__ */ new Map();
1962
+ /**
1963
+ * What the last completed content-projection sync was built from, per entity.
1964
+ *
1965
+ * Compared at the top of {@link syncContentProjection} to skip a block whose
1966
+ * content AND geometry are both unchanged, before the O(glyphs) projection
1967
+ * build. Only populated for entities that opt in via
1968
+ * {@link Entity.getContentEpoch}. (carryctx CTX-0199)
1969
+ */
1970
+ contentSyncState = /* @__PURE__ */ new Map();
1959
1971
  /** Pending cold font-calibration frame per projected grid entity. */
1960
1972
  contentGridCalibrationFrames = /* @__PURE__ */ new Map();
1961
1973
  /** Detached, untransformed font probes used by the cold calibration pass. */
@@ -1989,9 +2001,49 @@ var Scene = class _Scene {
1989
2001
  contentMetricScaleEpoch = -1;
1990
2002
  contentMetricScaleX = 1;
1991
2003
  contentProjectionEnabled = true;
1992
- // Virtualization margin (px) for content projection; `undefined` → one
1993
- // viewport height, resolved at sync time. `Infinity` = materialize everything.
2004
+ // Virtualization margin (px) for the content-projection CARRIER band;
2005
+ // `undefined` → one viewport height, resolved at sync time. `Infinity` is
2006
+ // unsupported here: it unwindows every carrier, which is O(total glyphs).
1994
2007
  contentProjectionMargin = void 0;
2008
+ // Virtualization margin (px) for the SEMANTIC tier — whether a block has
2009
+ // any projected DOM. `undefined` → falls back to contentProjectionMargin, so
2010
+ // the default keeps one gate. `Infinity` = every block keeps resident text.
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;
1995
2047
  /**
1996
2048
  * True while a text-selection drag that started on a projection's blank
1997
2049
  * region (no text node under the press) is being driven manually — the
@@ -3027,6 +3079,8 @@ var Scene = class _Scene {
3027
3079
  this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
3028
3080
  this.contentProjectionEnabled = options.contentProjection ?? true;
3029
3081
  this.contentProjectionMargin = options.contentProjectionMargin;
3082
+ this.contentSemanticMargin = options.contentSemanticMargin;
3083
+ this.contentSemanticBudget = options.contentSemanticBudget ?? DEFAULT_CONTENT_SEMANTIC_BUDGET;
3030
3084
  this.readingDirection = options.readingDirection ?? "ltr";
3031
3085
  this.renderMode = options.renderMode ?? "always";
3032
3086
  this._devActive = _Scene._devModeDetected();
@@ -3308,12 +3362,31 @@ var Scene = class _Scene {
3308
3362
  }
3309
3363
  return null;
3310
3364
  }
3365
+ /**
3366
+ * Does the document hold a selection right now, memoized for this sync walk?
3367
+ *
3368
+ * Pays one forced layout per walk instead of one per rebuilt element — see
3369
+ * {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
3370
+ * answer is `false` no element can own a selection, so every per-element
3371
+ * ownership test can be skipped without touching the object.
3372
+ */
3373
+ contentSelectionPresent() {
3374
+ if (this.contentSelectionPresentThisSync !== null) {
3375
+ return this.contentSelectionPresentThisSync;
3376
+ }
3377
+ const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3378
+ const present = !!selection && (!!selection.anchorNode || !!selection.focusNode);
3379
+ this.contentSelectionPresentThisSync = present;
3380
+ return present;
3381
+ }
3311
3382
  releaseContentSelectionForRebuild(el) {
3383
+ if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) return;
3312
3384
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3313
3385
  const ownsSelection = this.contentSelectionAnchor && el.contains(this.contentSelectionAnchor.node) || (selection?.anchorNode ? el.contains(selection.anchorNode) : false) || (selection?.focusNode ? el.contains(selection.focusNode) : false);
3314
3386
  if (!ownsSelection) return;
3315
3387
  this.endContentSelectionDrag();
3316
3388
  selection?.removeAllRanges();
3389
+ this.contentSelectionPresentThisSync = null;
3317
3390
  }
3318
3391
  /**
3319
3392
  * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
@@ -3331,6 +3404,10 @@ var Scene = class _Scene {
3331
3404
  * restore against).
3332
3405
  */
3333
3406
  preserveContentSelectionAcrossRebuild(el, rebuild) {
3407
+ if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) {
3408
+ rebuild();
3409
+ return;
3410
+ }
3334
3411
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3335
3412
  const owns = !!selection && !this.blankRegionSelectionDrag && ((selection.anchorNode ? el.contains(selection.anchorNode) : false) || (selection.focusNode ? el.contains(selection.focusNode) : false));
3336
3413
  if (!owns || !selection.anchorNode || !selection.focusNode) {
@@ -3449,6 +3526,7 @@ var Scene = class _Scene {
3449
3526
  this.clearContentGridState(node.id, contentEl);
3450
3527
  contentEl.remove();
3451
3528
  this.contentElements.delete(node.id);
3529
+ this.contentSyncState.delete(node.id);
3452
3530
  this.a11yNeedsReorder = true;
3453
3531
  }
3454
3532
  const el = this.a11yElements.get(node.id);
@@ -3593,6 +3671,7 @@ var Scene = class _Scene {
3593
3671
  this.a11yElements.clear();
3594
3672
  for (const el of this.contentElements.values()) el.remove();
3595
3673
  this.contentElements.clear();
3674
+ this.contentSyncState.clear();
3596
3675
  if (typeof cancelAnimationFrame === "function") {
3597
3676
  for (const frame of this.contentGridCalibrationFrames.values()) {
3598
3677
  cancelAnimationFrame(frame);
@@ -4024,6 +4103,11 @@ var Scene = class _Scene {
4024
4103
  }
4025
4104
  syncA11y(node, container = null) {
4026
4105
  if (!this.a11yRoot) return;
4106
+ if (node === this.root) {
4107
+ this.contentSemanticBudgetLeft = this.contentSemanticBudget;
4108
+ this.contentSemanticDeferred = false;
4109
+ this.contentSelectionPresentThisSync = null;
4110
+ }
4027
4111
  if (node.isDOMPortal) {
4028
4112
  return;
4029
4113
  }
@@ -4442,7 +4526,9 @@ var Scene = class _Scene {
4442
4526
  * transparent DOM node positioned over the drawn glyphs. Runs on the a11y
4443
4527
  * sync cadence; all writes are dirty-checked. Off-viewport projections are
4444
4528
  * hidden (`display: none`) so text-heavy scenes only materialize what is
4445
- * visible to the browser's text machinery anyway.
4529
+ * visible to the browser's text machinery anyway — except in the coarse
4530
+ * (resident) tier, which stays displayed because hiding it would make its text
4531
+ * unfindable and remove it from the accessibility tree, defeating the tier.
4446
4532
  */
4447
4533
  /**
4448
4534
  * Whether `node`'s world-space box, expanded by `margin` px on every side,
@@ -4451,8 +4537,15 @@ var Scene = class _Scene {
4451
4537
  * at `margin = contentProjectionMargin`) and for the exact `display:none`
4452
4538
  * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
4453
4539
  * culling and always count as visible, matching the legacy behavior.
4540
+ *
4541
+ * `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower
4542
+ * question "does this box overlap the viewport at all". The coarse content tier
4543
+ * needs the two apart: text that is merely off-viewport is clipped by
4544
+ * `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text
4545
+ * rejected by an ancestor clip box that itself overlaps the viewport would sit
4546
+ * transparently on top of whatever is really drawn there.
4454
4547
  */
4455
- projectionBoxVisible(node, tf, margin) {
4548
+ projectionBoxVisible(node, tf, margin, viewportOnly = false) {
4456
4549
  if (!(node.width > 0 && node.height > 0)) return true;
4457
4550
  const { a, b, c, d, e, f } = tf;
4458
4551
  const worldCorners = [];
@@ -4474,6 +4567,7 @@ var Scene = class _Scene {
4474
4567
  if (!(maxX >= -margin && minX <= this.width + margin && maxY >= -margin && minY <= this.height + margin)) {
4475
4568
  return false;
4476
4569
  }
4570
+ if (viewportOnly) return true;
4477
4571
  for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
4478
4572
  if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
4479
4573
  let localMinX = Infinity;
@@ -4555,14 +4649,31 @@ var Scene = class _Scene {
4555
4649
  this.contentElements.delete(node.id);
4556
4650
  this.a11yNeedsReorder = true;
4557
4651
  }
4652
+ this.contentSyncState.delete(node.id);
4558
4653
  };
4559
4654
  const worldTf = node.getWorldTransform();
4560
- const margin = this.contentProjectionMargin ?? this.height;
4561
- if (Number.isFinite(margin) && !this.projectionBoxVisible(node, worldTf, margin)) {
4655
+ const interactionMargin = this.contentProjectionMargin ?? this.height;
4656
+ const semanticMargin = this.contentSemanticMargin ?? interactionMargin;
4657
+ if (Number.isFinite(semanticMargin) && !this.projectionBoxVisible(node, worldTf, semanticMargin)) {
4562
4658
  releaseProjectionEl();
4563
4659
  return;
4564
4660
  }
4565
- const lineBand = Number.isFinite(margin) ? this.projectionVisibleLocalYBand(node, worldTf, margin) : null;
4661
+ const inInteractionBand = !Number.isFinite(interactionMargin) || this.projectionBoxVisible(node, worldTf, interactionMargin);
4662
+ const tier = inInteractionBand ? "fine" : "coarse";
4663
+ const lineBand = tier === "coarse" ? null : Number.isFinite(interactionMargin) ? this.projectionVisibleLocalYBand(node, worldTf, interactionMargin) : null;
4664
+ const visible = this.projectionBoxVisible(node, worldTf, 0);
4665
+ const epoch = node.getContentEpoch();
4666
+ const prior = this.contentSyncState.get(node.id);
4667
+ if (epoch !== null && el && prior !== void 0) {
4668
+ const { a: a2, b: b2, c: c2, d: d2, e: e2, f: f2 } = worldTf;
4669
+ if (prior.epoch === epoch && prior.fontEpoch === this.contentFontEpoch && prior.a === a2 && prior.b === b2 && prior.c === c2 && prior.d === d2 && prior.e === e2 && prior.f === f2 && prior.tier === tier && prior.hasBand === (lineBand !== null) && (lineBand === null || prior.bandMin === lineBand.minY && prior.bandMax === lineBand.maxY) && prior.visible === visible && prior.width === node.width && prior.height === node.height && prior.interactive === node.interactive) {
4670
+ return;
4671
+ }
4672
+ }
4673
+ if (tier === "coarse" && !el && this.contentSemanticBudgetLeft <= 0) {
4674
+ this.contentSemanticDeferred = true;
4675
+ return;
4676
+ }
4566
4677
  const projection = node.getContentProjection(
4567
4678
  lineBand ? { minY: lineBand.minY, maxY: lineBand.maxY } : void 0
4568
4679
  );
@@ -4571,6 +4682,7 @@ var Scene = class _Scene {
4571
4682
  return;
4572
4683
  }
4573
4684
  if (!el) {
4685
+ if (tier === "coarse") this.contentSemanticBudgetLeft--;
4574
4686
  el = document.createElement("div");
4575
4687
  el.setAttribute("data-vecto-content", node.id);
4576
4688
  const s = el.style;
@@ -4595,14 +4707,15 @@ var Scene = class _Scene {
4595
4707
  this.a11yNeedsReorder = true;
4596
4708
  }
4597
4709
  const lines = projection.lines;
4598
- if (!projection.grid && el.dataset.vectoContentGrid !== void 0) {
4710
+ const useCarriers = tier === "fine";
4711
+ if ((!projection.grid || !useCarriers) && el.dataset.vectoContentGrid !== void 0) {
4599
4712
  this.clearContentGridState(node.id, el);
4600
4713
  }
4601
- if (projection.grid) {
4714
+ if (projection.grid && useCarriers) {
4602
4715
  const gridSyncStart = this._phaseTiming ? performance.now() : 0;
4603
4716
  this.syncContentGridProjection(node, el, projection, projection.grid, lineBand);
4604
4717
  if (this._phaseTiming) this._recordPhase("gridSync", performance.now() - gridSyncStart);
4605
- } else if (lines && lines.length > 0) {
4718
+ } else if (useCarriers && lines && lines.length > 0) {
4606
4719
  const lineWindow = projectionLineWindow(lines, lineBand, projection.lineHeight ?? 16);
4607
4720
  const signature = JSON.stringify({
4608
4721
  lines,
@@ -4665,11 +4778,13 @@ var Scene = class _Scene {
4665
4778
  }
4666
4779
  }
4667
4780
  } else {
4668
- if (el.textContent !== projection.text) {
4781
+ const demoted = tier === "coarse" && el.children.length > 0;
4782
+ if (el.textContent !== projection.text || demoted) {
4669
4783
  this.releaseContentSelectionForRebuild(el);
4670
4784
  el.textContent = projection.text;
4671
4785
  }
4672
4786
  delete el.dataset.vectoProjectionLines;
4787
+ if (tier === "coarse") delete el.dataset.vectoProjectionWindow;
4673
4788
  }
4674
4789
  const font = projection.font ?? "";
4675
4790
  if (el.style.font !== font) el.style.font = font;
@@ -4702,9 +4817,30 @@ var Scene = class _Scene {
4702
4817
  if (node.width > 0) el.style.width = `${node.width}px`;
4703
4818
  if (node.height > 0) el.style.height = `${node.height}px`;
4704
4819
  el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
4705
- const visible = this.projectionBoxVisible(node, worldTf, 0);
4706
- const display = visible ? "" : "none";
4820
+ const residentTier = semanticMargin > interactionMargin;
4821
+ const display = visible || residentTier && !this.projectionBoxVisible(node, worldTf, 0, true) ? "" : "none";
4707
4822
  if (el.style.display !== display) el.style.display = display;
4823
+ if (epoch !== null) {
4824
+ const { a: a2, b: b2, c: c2, d: d2, e: e2, f: f2 } = worldTf;
4825
+ const next = prior ?? {};
4826
+ next.epoch = epoch;
4827
+ next.fontEpoch = this.contentFontEpoch;
4828
+ next.a = a2;
4829
+ next.b = b2;
4830
+ next.c = c2;
4831
+ next.d = d2;
4832
+ next.e = e2;
4833
+ next.f = f2;
4834
+ next.tier = tier;
4835
+ next.hasBand = lineBand !== null;
4836
+ next.bandMin = lineBand?.minY ?? 0;
4837
+ next.bandMax = lineBand?.maxY ?? 0;
4838
+ next.visible = visible;
4839
+ next.width = node.width;
4840
+ next.height = node.height;
4841
+ next.interactive = node.interactive;
4842
+ if (prior === void 0) this.contentSyncState.set(node.id, next);
4843
+ }
4708
4844
  }
4709
4845
  /**
4710
4846
  * Materialize a prepared grid in logical source order while positioning each
@@ -5325,7 +5461,7 @@ var Scene = class _Scene {
5325
5461
  if (!this.isRunning) return;
5326
5462
  if (!this._canvasOnScreen) return;
5327
5463
  let cap = this.effectiveMaxFPS();
5328
- const isIdle = !this.dirty && !this.frameHadAnimation;
5464
+ const isIdle = !this.dirty && !this.frameHadAnimation && !this.contentSemanticDeferred;
5329
5465
  if (isIdle && this.autoThrottle && this.renderMode === "always" && this.maxFPS > 0) {
5330
5466
  cap = Math.min(cap, 2);
5331
5467
  }
@@ -6107,6 +6243,8 @@ var TextEntity = class extends Entity {
6107
6243
  hoveredFillStyle = "#ffffff";
6108
6244
  lineWidth = 1;
6109
6245
  isHovered = false;
6246
+ /** Bumped by {@link applyLayout}; read by `Scene` to skip an unchanged sync. */
6247
+ contentEpoch = 0;
6110
6248
  constructor(text, atlas, maxWidth, fontSize = 24) {
6111
6249
  super();
6112
6250
  this.text = text;
@@ -6127,6 +6265,9 @@ var TextEntity = class extends Entity {
6127
6265
  if (!this.text) return null;
6128
6266
  return { text: this.text, font: `${this.fontSize}px sans-serif` };
6129
6267
  }
6268
+ getContentEpoch() {
6269
+ return this.contentEpoch;
6270
+ }
6130
6271
  /**
6131
6272
  * Replace the text content. Runs the **cold** measurement pass (re-segment +
6132
6273
  * re-measure) since the glyphs changed, then re-lays out.
@@ -6173,6 +6314,7 @@ var TextEntity = class extends Entity {
6173
6314
  }
6174
6315
  /** Hot pass: place the cached {@link PreparedText} and refresh the a11y box. */
6175
6316
  applyLayout() {
6317
+ this.contentEpoch++;
6176
6318
  const result = this.layout.layoutPrepared(this.prepared);
6177
6319
  this.nodes = result.nodes;
6178
6320
  this.width = result.totalWidth;
@@ -6850,6 +6992,7 @@ export {
6850
6992
  CanvasRenderer,
6851
6993
  Circle,
6852
6994
  ComputeParticleEntity,
6995
+ DEFAULT_CONTENT_SEMANTIC_BUDGET,
6853
6996
  DOMPortalEntity,
6854
6997
  Entity,
6855
6998
  GlyphRasterAtlas,
@@ -34,6 +34,8 @@ export declare class MSDFTextEntity extends Entity {
34
34
  private layoutText;
35
35
  private text;
36
36
  private lastRenderedSeqId;
37
+ /** Bumped by {@link queueLayout}; read by `Scene` to skip an unchanged sync. */
38
+ private contentEpoch;
37
39
  private atlasDecodeTarget;
38
40
  private atlasDecodeHandler;
39
41
  private rgbColorCache;
@@ -91,6 +93,7 @@ export declare class MSDFTextEntity extends Entity {
91
93
  * readers, crawlers, and translation see the same string the canvas draws.
92
94
  */
93
95
  getContentProjection(): ContentProjection | null;
96
+ getContentEpoch(): number;
94
97
  isPointInside(globalX: number, globalY: number): boolean;
95
98
  render(renderer: any): void;
96
99
  destroy(): void;
package/dist/text.js CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
 
4
4
 
5
- var _chunk7PI7LVWWjs = require('./chunk-7PI7LVWW.js');
5
+ var _chunkTRT2TRCWjs = require('./chunk-TRT2TRCW.js');
6
6
 
7
7
  // src/text/index.ts
8
8
  var _text = require('@vectojs/text'); _createStarExport(_text);
9
9
 
10
10
 
11
11
 
12
- exports.MSDFTextEntity = _chunk7PI7LVWWjs.MSDFTextEntity; exports.SVGEntity = _chunk7PI7LVWWjs.SVGEntity;
12
+ exports.MSDFTextEntity = _chunkTRT2TRCWjs.MSDFTextEntity; exports.SVGEntity = _chunkTRT2TRCWjs.SVGEntity;
package/dist/text.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  MSDFTextEntity,
3
3
  SVGEntity
4
- } from "./chunk-2WXZCPYQ.mjs";
4
+ } from "./chunk-RWN3ITBD.mjs";
5
5
 
6
6
  // src/text/index.ts
7
7
  export * from "@vectojs/text";
@@ -1000,6 +1000,32 @@ export declare abstract class Entity {
1000
1000
  * @returns The projection descriptor, or `null` to project nothing.
1001
1001
  */
1002
1002
  getContentProjection(hint?: ContentProjectionHint): ContentProjection | null;
1003
+ /**
1004
+ * A cheap, monotonically-increasing stamp of this entity's projected content.
1005
+ *
1006
+ * Purely an optimization, and opt-in: returning `null` (the default) means
1007
+ * "I cannot cheaply tell whether my content changed", and the {@link Scene}
1008
+ * then rebuilds the projection every synced frame exactly as before. An
1009
+ * implementation must bump the value whenever anything
1010
+ * {@link getContentProjection} would report changes — text, fonts, line
1011
+ * geometry, `selectable`, grid revision.
1012
+ *
1013
+ * When two consecutive syncs report the same epoch AND the entity's geometry
1014
+ * is unchanged, `Scene` skips the block *before* calling
1015
+ * {@link getContentProjection}. That matters because the projection call is
1016
+ * O(glyphs-in-block) and the DOM diff around it costs about the same again:
1017
+ * measured on a 1500-resident-block document, a sync in which the projected
1018
+ * text was byte-identical before and after still cost 17.875 ms, and skipping
1019
+ * unchanged blocks took that to 0.475 ms (carryctx CTX-0199, vectojs#343).
1020
+ *
1021
+ * Correctness is entirely on the implementer: a stale epoch means stale DOM,
1022
+ * so bump it in the same place the content is invalidated rather than trying
1023
+ * to enumerate mutation sites afterwards. Any monotonic counter works; the
1024
+ * value is only ever compared for equality with the previous sync's.
1025
+ *
1026
+ * @returns The current content epoch, or `null` to disable skipping.
1027
+ */
1028
+ getContentEpoch(): number | null;
1003
1029
  /**
1004
1030
  * Whether this entity still has a queued/running tween animation, or an
1005
1031
  * active {@link setTransition}/{@link animateTo}/{@link springTo} property
@@ -201,6 +201,64 @@ export interface SceneOptions {
201
201
  * height (`undefined` → resolved to `Scene.height` at sync time).
202
202
  */
203
203
  contentProjectionMargin?: number;
204
+ /**
205
+ * Virtualization margin (px) for the *semantic* tier of content projection —
206
+ * whether a block has **any** projected DOM at all, as opposed to
207
+ * {@link SceneOptions.contentProjectionMargin}, which decides whether that
208
+ * block's per-line **carriers** are windowed.
209
+ *
210
+ * Splitting the two makes a coarse resident tier expressible: with
211
+ * `contentSemanticMargin: Infinity` and a finite `contentProjectionMargin`,
212
+ * every block in the document keeps an element holding its full text — so
213
+ * find-in-page and screen-reader read-ahead see the whole document — while
214
+ * only blocks near the viewport pay for per-line carriers. One scalar could
215
+ * not express that, because a finite value freed off-band blocks entirely and
216
+ * `Infinity` also unwindowed every carrier, which is O(total document glyphs).
217
+ *
218
+ * `Infinity` is safe **here** and remains unsupported for
219
+ * `contentProjectionMargin`: the cost that made it unsupported comes from an
220
+ * unwindowed carrier band, not from resident text.
221
+ *
222
+ * Note the one-time cost. A resident tier materializes one element per block
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
+ * (unchanged blocks skip via {@link Entity.getContentEpoch}), so this is a
226
+ * document-open stall, not a per-frame cost. That stall is what
227
+ * {@link SceneOptions.contentSemanticBudget} spreads across frames.
228
+ *
229
+ * Default: whatever `contentProjectionMargin` resolves to, so omitting this
230
+ * leaves behaviour unchanged.
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;
204
262
  /**
205
263
  * Reading direction used to order the accessibility/automation shadow tree so
206
264
  * keyboard **tab order** and screen-reader traversal follow the *visual*
@@ -240,7 +298,7 @@ export interface SceneOptions {
240
298
  * against. A new option must be added here too — the test suite asserts the two
241
299
  * stay in sync.
242
300
  */
243
- export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', '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'];
244
302
  /** Frame-rate the loop is capped to when the OS requests reduced motion. */
245
303
  export declare const REDUCED_MOTION_FPS = 30;
246
304
  /**
@@ -331,6 +389,45 @@ export interface A11yTreeNode {
331
389
  valuemax?: string;
332
390
  children: A11yTreeNode[];
333
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;
334
431
  /**
335
432
  * Top-level orchestrator that owns the entity tree, drive the render loop,
336
433
  * and maintains the accessibility/automation shadow layer.
@@ -488,6 +585,15 @@ export declare class Scene {
488
585
  private a11yElements;
489
586
  /** DOM nodes mirroring static text content, keyed by entity id. */
490
587
  private contentElements;
588
+ /**
589
+ * What the last completed content-projection sync was built from, per entity.
590
+ *
591
+ * Compared at the top of {@link syncContentProjection} to skip a block whose
592
+ * content AND geometry are both unchanged, before the O(glyphs) projection
593
+ * build. Only populated for entities that opt in via
594
+ * {@link Entity.getContentEpoch}. (carryctx CTX-0199)
595
+ */
596
+ private contentSyncState;
491
597
  /** Pending cold font-calibration frame per projected grid entity. */
492
598
  private contentGridCalibrationFrames;
493
599
  /** Detached, untransformed font probes used by the cold calibration pass. */
@@ -522,6 +628,33 @@ export declare class Scene {
522
628
  private contentMetricScaleX;
523
629
  private contentProjectionEnabled;
524
630
  private contentProjectionMargin;
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;
525
658
  /**
526
659
  * True while a text-selection drag that started on a projection's blank
527
660
  * region (no text node under the press) is being driven manually — the
@@ -1063,6 +1196,15 @@ export declare class Scene {
1063
1196
  * to the live DOM selection.
1064
1197
  */
1065
1198
  private contentGridSelectionLine;
1199
+ /**
1200
+ * Does the document hold a selection right now, memoized for this sync walk?
1201
+ *
1202
+ * Pays one forced layout per walk instead of one per rebuilt element — see
1203
+ * {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
1204
+ * answer is `false` no element can own a selection, so every per-element
1205
+ * ownership test can be skipped without touching the object.
1206
+ */
1207
+ private contentSelectionPresent;
1066
1208
  private releaseContentSelectionForRebuild;
1067
1209
  /**
1068
1210
  * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
@@ -1390,7 +1532,9 @@ export declare class Scene {
1390
1532
  * transparent DOM node positioned over the drawn glyphs. Runs on the a11y
1391
1533
  * sync cadence; all writes are dirty-checked. Off-viewport projections are
1392
1534
  * hidden (`display: none`) so text-heavy scenes only materialize what is
1393
- * visible to the browser's text machinery anyway.
1535
+ * visible to the browser's text machinery anyway — except in the coarse
1536
+ * (resident) tier, which stays displayed because hiding it would make its text
1537
+ * unfindable and remove it from the accessibility tree, defeating the tier.
1394
1538
  */
1395
1539
  /**
1396
1540
  * Whether `node`'s world-space box, expanded by `margin` px on every side,
@@ -1399,6 +1543,13 @@ export declare class Scene {
1399
1543
  * at `margin = contentProjectionMargin`) and for the exact `display:none`
1400
1544
  * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
1401
1545
  * culling and always count as visible, matching the legacy behavior.
1546
+ *
1547
+ * `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower
1548
+ * question "does this box overlap the viewport at all". The coarse content tier
1549
+ * needs the two apart: text that is merely off-viewport is clipped by
1550
+ * `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text
1551
+ * rejected by an ancestor clip box that itself overlaps the viewport would sit
1552
+ * transparently on top of whatever is really drawn there.
1402
1553
  */
1403
1554
  private projectionBoxVisible;
1404
1555
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/core",
3
- "version": "1.30.0",
3
+ "version": "1.32.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },