@vectojs/core 1.29.0 → 1.30.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
@@ -16,8 +16,9 @@ import {
16
16
  Entity,
17
17
  MSDFTextEntity,
18
18
  SVGEntity,
19
- VectoJSEvent
20
- } from "./chunk-MVFPN4Y5.mjs";
19
+ VectoJSEvent,
20
+ contentLineInHint
21
+ } from "./chunk-2WXZCPYQ.mjs";
21
22
 
22
23
  // src/tree/Scene.ts
23
24
  import { SpringDriver, TweenDriver } from "@vectojs/animation";
@@ -1430,6 +1431,45 @@ function parseInlinePx(value) {
1430
1431
  const n = parseFloat(value);
1431
1432
  return Number.isFinite(n) && n > 0 ? n : null;
1432
1433
  }
1434
+ function projectionGridLineWindow(grid, projectionLines, band) {
1435
+ const count = grid.lines.length;
1436
+ const all = { start: 0, end: count, gated: false };
1437
+ if (!band || count === 0) return all;
1438
+ const lines = [];
1439
+ for (let i = 0; i < count; i++) {
1440
+ const projected = projectionLines?.[i];
1441
+ lines.push({
1442
+ y: projected?.y ?? i * grid.lineHeight,
1443
+ lineHeight: projected?.lineHeight ?? grid.lineHeight
1444
+ });
1445
+ }
1446
+ return projectionLineWindow(lines, band, grid.lineHeight);
1447
+ }
1448
+ function projectionLineWindow(lines, band, fallbackLineHeight) {
1449
+ const all = {
1450
+ start: 0,
1451
+ end: lines.length,
1452
+ gated: false
1453
+ };
1454
+ if (!band || lines.length === 0) return all;
1455
+ let start = -1;
1456
+ let end = -1;
1457
+ for (let i = 0; i < lines.length; i++) {
1458
+ const line = lines[i];
1459
+ const h = line.lineHeight ?? fallbackLineHeight;
1460
+ if (line.y + h >= band.minY && line.y <= band.maxY) {
1461
+ if (start === -1) start = i;
1462
+ end = i + 1;
1463
+ } else if (start !== -1 && line.y > band.maxY) {
1464
+ break;
1465
+ }
1466
+ }
1467
+ if (start === -1) {
1468
+ return { start: 0, end: Math.min(1, lines.length), gated: true };
1469
+ }
1470
+ if (start === 0 && end === lines.length) return all;
1471
+ return { start, end, gated: true };
1472
+ }
1433
1473
  function collectTextNodes(root) {
1434
1474
  const out = [];
1435
1475
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
@@ -1988,6 +2028,14 @@ var Scene = class _Scene {
1988
2028
  * mid-hover synthesize the `pointerleave` the browser never sends for a
1989
2029
  * detached element, so the entity doesn't keep its hover state. */
1990
2030
  hoveredA11yElements = /* @__PURE__ */ new WeakSet();
2031
+ /**
2032
+ * Entity ids the application has pinned via {@link requestA11yProjection}.
2033
+ *
2034
+ * Ids rather than entities so a removed entity cannot be retained by this set;
2035
+ * a stale id simply never matches. Cleared per-entity by
2036
+ * {@link releaseA11yProjection}.
2037
+ */
2038
+ a11yProjectionRequests = /* @__PURE__ */ new Set();
1991
2039
  /** Persistent tabindex=-1 element in a11yRoot. When the focused a11y mirror is
1992
2040
  * pruned (virtualization/streaming/removal) while it holds focus, we move
1993
2041
  * focus here instead of letting the browser drop it to <body> — keeping the
@@ -2941,7 +2989,9 @@ var Scene = class _Scene {
2941
2989
  const el = this.contentElements?.get(node.id);
2942
2990
  if (el) {
2943
2991
  const projectedText = el.textContent || "";
2944
- if (projectedText !== "" && projectedText !== proj.text) {
2992
+ const windowed = el.dataset.vectoProjectionWindow !== void 0;
2993
+ const mismatched = windowed ? !proj.text.includes(projectedText) : projectedText !== proj.text;
2994
+ if (projectedText !== "" && mismatched) {
2945
2995
  this._devWarn(
2946
2996
  `Content projection mismatch for entity "${node.id}": projection says "${proj.text.slice(0, 60)}" but DOM shows "${projectedText.slice(0, 60)}". Ensure getContentProjection() output matches what drawSelf renders.`
2947
2997
  );
@@ -3885,7 +3935,92 @@ var Scene = class _Scene {
3885
3935
  * predicate, which is only tractable while it has one home.
3886
3936
  */
3887
3937
  shouldProjectA11y(node) {
3888
- return node.interactive && (node.width > 0 || node.a11yFullViewport);
3938
+ if (!node.interactive) return false;
3939
+ if (!(node.width > 0 || node.a11yFullViewport)) return false;
3940
+ switch (node.a11yProjection) {
3941
+ case "never":
3942
+ return false;
3943
+ case "onDemand":
3944
+ return this.a11yEngaged(node);
3945
+ default:
3946
+ return true;
3947
+ }
3948
+ }
3949
+ /**
3950
+ * Whether an `a11yProjection: 'onDemand'` entity is currently engaged enough to
3951
+ * deserve a shadow node.
3952
+ *
3953
+ * Deliberately **not** hover alone. A keyboard or assistive-technology user
3954
+ * generates no pointer events, so a hover-only trigger would withhold the
3955
+ * semantic node from precisely the users it exists for. Three signals, any of
3956
+ * which counts:
3957
+ *
3958
+ * - **Focus.** Covers keyboard traversal and AT-driven focus. Checked against
3959
+ * the live element so a node keeps its own focus rather than being pruned out
3960
+ * from under the user mid-interaction.
3961
+ * - **Pointer target.** The entity under the pointer, so a mouse user gets the
3962
+ * same node a hover-gated design would have given them.
3963
+ * - **Explicit request.** {@link Scene.requestA11yProjection}, for anything the
3964
+ * app knows is significant — the selected item, a search hit, a
3965
+ * just-announced element. This is the escape hatch that keeps the mode usable
3966
+ * when neither focus nor pointer applies.
3967
+ *
3968
+ * The entity stays hit-testable on canvas regardless, so a click always reaches
3969
+ * it and promotes it on the next sync.
3970
+ */
3971
+ a11yEngaged(node) {
3972
+ if (this.a11yProjectionRequests.has(node.id)) return true;
3973
+ if (this.mouseX > -9e3 && !this.projectsSelectableText(node)) {
3974
+ if (node.isPointInside(this.mouseX, this.mouseY)) return true;
3975
+ }
3976
+ const existing = this.a11yElements?.get(node.id);
3977
+ if (existing && typeof document !== "undefined" && document.activeElement === existing) {
3978
+ return true;
3979
+ }
3980
+ return false;
3981
+ }
3982
+ /**
3983
+ * Whether `node` mirrors selectable text of its own.
3984
+ *
3985
+ * Such an entity must not be promoted by the pointer: its interactive a11y node
3986
+ * would sit above the text mirror and eat the mousedown that starts a native
3987
+ * selection.
3988
+ */
3989
+ projectsSelectableText(node) {
3990
+ const projection = node.getContentProjection?.();
3991
+ return !!projection?.text && projection.selectable !== false;
3992
+ }
3993
+ /**
3994
+ * Keep `entity`'s a11y shadow node projected while it has
3995
+ * `a11yProjection: 'onDemand'`.
3996
+ *
3997
+ * For anything the application knows matters but the engine cannot infer — the
3998
+ * selected danmaku, a search hit, a node just announced in a live region.
3999
+ * Without this, `'onDemand'` would be reachable only by focus or pointer, and
4000
+ * an app-driven selection change would leave the selected entity semantically
4001
+ * invisible.
4002
+ *
4003
+ * Idempotent. Has no effect on an `'eager'` entity, which is always projected.
4004
+ */
4005
+ requestA11yProjection(entity) {
4006
+ const id = typeof entity === "string" ? entity : entity.id;
4007
+ if (this.a11yProjectionRequests.has(id)) return;
4008
+ this.a11yProjectionRequests.add(id);
4009
+ this.a11yNeedsReorder = true;
4010
+ this.markDirty({ entity: id, reason: "a11y-reorder" });
4011
+ }
4012
+ /**
4013
+ * Drop a projection request made by {@link requestA11yProjection}.
4014
+ *
4015
+ * The node is not removed immediately: it survives while it is focused or under
4016
+ * the pointer, and is pruned on the next sync that finds it unengaged. Releasing
4017
+ * a request the scene does not hold is a no-op.
4018
+ */
4019
+ releaseA11yProjection(entity) {
4020
+ const id = typeof entity === "string" ? entity : entity.id;
4021
+ if (!this.a11yProjectionRequests.delete(id)) return;
4022
+ this.a11yNeedsReorder = true;
4023
+ this.markDirty({ entity: id, reason: "a11y-reorder" });
3889
4024
  }
3890
4025
  syncA11y(node, container = null) {
3891
4026
  if (!this.a11yRoot) return;
@@ -4359,6 +4494,57 @@ var Scene = class _Scene {
4359
4494
  }
4360
4495
  return true;
4361
4496
  }
4497
+ /**
4498
+ * The band of an entity's own y coordinates that is worth projecting, or
4499
+ * `null` to project everything.
4500
+ *
4501
+ * {@link projectionBoxVisible} answers "is this entity near the viewport",
4502
+ * which frees whole blocks that scroll away. It cannot help a single entity
4503
+ * *taller* than the viewport: that entity's box always intersects, so every
4504
+ * one of its visual lines was materialized — a `<span>` per line and, on the
4505
+ * grid path, a `<span>` per glyph cluster. That is where "14.8k elements for a
4506
+ * 346KB Markdown doc" comes from, and it is O(document) rather than
4507
+ * O(viewport) in both element count and per-frame walk cost.
4508
+ *
4509
+ * Measured on one entity scrolled to its middle, real headed browsers
4510
+ * (`benchmarks/projection-per-line/`): at 4000 lines, materializing every line
4511
+ * costs 6.28 ms/frame on Chrome and 6.51 ms on Firefox with 36,000 child
4512
+ * elements, against 0.28/0.16 ms and 963 elements when only the visible band
4513
+ * is emitted. The gated cost is *flat* across a 20x document-size range, so
4514
+ * this converts an asymptote rather than shaving a constant.
4515
+ *
4516
+ * Returns local-y bounds in the entity's own coordinate space, already
4517
+ * expanded by `margin` and intersected with every `clipChildren` ancestor, so
4518
+ * a line inside a scrolled container is measured against the container rather
4519
+ * than the window. `null` means "no useful bound" — a degenerate transform, a
4520
+ * rotation/skew that makes a y-band meaningless, or a boundless entity — and
4521
+ * the caller must then project every line, because emitting nothing would
4522
+ * silently drop text from selection, find-in-page and screen readers.
4523
+ */
4524
+ projectionVisibleLocalYBand(node, tf, margin) {
4525
+ const { b, d, f } = tf;
4526
+ if (b !== 0 || d === 0 || !Number.isFinite(d) || !Number.isFinite(f)) return null;
4527
+ const top = (-margin - f) / d;
4528
+ const bottom = (this.height + margin - f) / d;
4529
+ let minY = Math.min(top, bottom);
4530
+ let maxY = Math.max(top, bottom);
4531
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
4532
+ if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
4533
+ const originWorldY = f;
4534
+ const unitWorldY = d + f;
4535
+ const originLocal = ancestor.worldToLocal(tf.e, originWorldY);
4536
+ const unitLocal = ancestor.worldToLocal(tf.e + tf.c, unitWorldY);
4537
+ if (!originLocal || !unitLocal) return null;
4538
+ const slope = unitLocal.y - originLocal.y;
4539
+ if (slope === 0 || !Number.isFinite(slope)) return null;
4540
+ const a1 = (-margin - originLocal.y) / slope;
4541
+ const a2 = (ancestor.height + margin - originLocal.y) / slope;
4542
+ minY = Math.max(minY, Math.min(a1, a2));
4543
+ maxY = Math.min(maxY, Math.max(a1, a2));
4544
+ }
4545
+ if (!Number.isFinite(minY) || !Number.isFinite(maxY) || maxY < minY) return null;
4546
+ return { minY, maxY };
4547
+ }
4362
4548
  syncContentProjection(node) {
4363
4549
  if (!this.contentProjectionEnabled || !this.a11yRoot) return;
4364
4550
  let el = this.contentElements.get(node.id);
@@ -4376,7 +4562,10 @@ var Scene = class _Scene {
4376
4562
  releaseProjectionEl();
4377
4563
  return;
4378
4564
  }
4379
- const projection = node.getContentProjection();
4565
+ const lineBand = Number.isFinite(margin) ? this.projectionVisibleLocalYBand(node, worldTf, margin) : null;
4566
+ const projection = node.getContentProjection(
4567
+ lineBand ? { minY: lineBand.minY, maxY: lineBand.maxY } : void 0
4568
+ );
4380
4569
  if (!projection || !projection.text) {
4381
4570
  releaseProjectionEl();
4382
4571
  return;
@@ -4411,18 +4600,24 @@ var Scene = class _Scene {
4411
4600
  }
4412
4601
  if (projection.grid) {
4413
4602
  const gridSyncStart = this._phaseTiming ? performance.now() : 0;
4414
- this.syncContentGridProjection(node, el, projection, projection.grid);
4603
+ this.syncContentGridProjection(node, el, projection, projection.grid, lineBand);
4415
4604
  if (this._phaseTiming) this._recordPhase("gridSync", performance.now() - gridSyncStart);
4416
4605
  } else if (lines && lines.length > 0) {
4606
+ const lineWindow = projectionLineWindow(lines, lineBand, projection.lineHeight ?? 16);
4417
4607
  const signature = JSON.stringify({
4418
4608
  lines,
4419
4609
  fallbackFont: projection.font ?? "",
4420
- fallbackLineHeight: projection.lineHeight ?? 16
4610
+ fallbackLineHeight: projection.lineHeight ?? 16,
4611
+ // Part of the signature, or scrolling would not rebuild the carriers and
4612
+ // the window would stay frozen where it was first built. Quantized to
4613
+ // whole line indices by construction, so a sub-pixel scroll inside one
4614
+ // line does not churn the DOM.
4615
+ window: lineWindow.gated ? `${lineWindow.start}-${lineWindow.end}` : "all"
4421
4616
  });
4422
4617
  if (el.dataset.vectoProjectionLines !== signature) {
4423
4618
  this.preserveContentSelectionAcrossRebuild(el, () => {
4424
4619
  el.replaceChildren();
4425
- for (let index = 0; index < lines.length; index++) {
4620
+ for (let index = lineWindow.start; index < lineWindow.end; index++) {
4426
4621
  const line = lines[index];
4427
4622
  const lineElement = document.createElement("span");
4428
4623
  const lineFont = line.font ?? projection.font ?? "";
@@ -4463,6 +4658,11 @@ var Scene = class _Scene {
4463
4658
  }
4464
4659
  });
4465
4660
  el.dataset.vectoProjectionLines = signature;
4661
+ if (lineWindow.gated) {
4662
+ el.dataset.vectoProjectionWindow = `${lineWindow.start}-${lineWindow.end}/${lines.length}`;
4663
+ } else {
4664
+ delete el.dataset.vectoProjectionWindow;
4665
+ }
4466
4666
  }
4467
4667
  } else {
4468
4668
  if (el.textContent !== projection.text) {
@@ -4511,11 +4711,12 @@ var Scene = class _Scene {
4511
4711
  * carrier from the shared canvas geometry. Browser font measurement happens
4512
4712
  * later in one cold read/write batch, never inside projection synchronization.
4513
4713
  */
4514
- syncContentGridProjection(node, el, projection, grid) {
4714
+ syncContentGridProjection(node, el, projection, grid, lineBand) {
4515
4715
  if (grid.source !== projection.text) {
4516
4716
  throw new Error("ContentProjection.grid.source must equal ContentProjection.text");
4517
4717
  }
4518
- const signature = `${grid.revision}`;
4718
+ const gridWindow = projectionGridLineWindow(grid, projection.lines, lineBand);
4719
+ const signature = gridWindow.gated ? `${grid.revision}:${gridWindow.start}-${gridWindow.end}` : `${grid.revision}`;
4519
4720
  if (el.dataset.vectoContentGrid !== signature) {
4520
4721
  const materializeStart = typeof performance !== "undefined" ? performance.now() : 0;
4521
4722
  this.clearContentGridState(node.id, el, false);
@@ -4523,7 +4724,8 @@ var Scene = class _Scene {
4523
4724
  const selectionLine = this.contentGridSelectionLine(el);
4524
4725
  let rebuiltSelectionLine = false;
4525
4726
  const existingLines = el.children;
4526
- for (let lineIndex = 0; lineIndex < grid.lines.length; lineIndex++) {
4727
+ for (let lineIndex = gridWindow.start; lineIndex < gridWindow.end; lineIndex++) {
4728
+ const domIndex = lineIndex - gridWindow.start;
4527
4729
  const gridLine = grid.lines[lineIndex];
4528
4730
  const projectedLine = projectionLines[lineIndex];
4529
4731
  const lineHeight = projectedLine?.lineHeight ?? grid.lineHeight;
@@ -4538,7 +4740,7 @@ var Scene = class _Scene {
4538
4740
  lineFont,
4539
4741
  lineIndex === 0
4540
4742
  );
4541
- const reusable = existingLines[lineIndex];
4743
+ const reusable = existingLines[domIndex];
4542
4744
  if (reusable !== void 0 && reusable.dataset.vectoGridLineSig === lineSignature && reusable.dataset.vectoGridLine === `${lineIndex}`) {
4543
4745
  continue;
4544
4746
  }
@@ -4611,12 +4813,13 @@ var Scene = class _Scene {
4611
4813
  lineElement.appendChild(marker);
4612
4814
  }
4613
4815
  }
4614
- const occupant = el.children[lineIndex];
4816
+ const occupant = el.children[domIndex];
4615
4817
  if (occupant) el.replaceChild(lineElement, occupant);
4616
4818
  else el.appendChild(lineElement);
4617
4819
  }
4618
- while (el.children.length > grid.lines.length) {
4619
- if (selectionLine !== null && selectionLine >= grid.lines.length) {
4820
+ const windowLength = gridWindow.end - gridWindow.start;
4821
+ while (el.children.length > windowLength) {
4822
+ if (selectionLine !== null && selectionLine >= gridWindow.end) {
4620
4823
  rebuiltSelectionLine = true;
4621
4824
  }
4622
4825
  el.lastElementChild?.remove();
@@ -4624,6 +4827,11 @@ var Scene = class _Scene {
4624
4827
  if (rebuiltSelectionLine) this.releaseContentSelectionForRebuild(el);
4625
4828
  el.dataset.vectoProjectionLines = signature;
4626
4829
  el.dataset.vectoContentGrid = signature;
4830
+ if (gridWindow.gated) {
4831
+ el.dataset.vectoProjectionWindow = `${gridWindow.start}-${gridWindow.end}/${grid.lines.length}`;
4832
+ } else {
4833
+ delete el.dataset.vectoProjectionWindow;
4834
+ }
4627
4835
  el.dataset.vectoGridCarriers = `${el.querySelectorAll("[data-vecto-grid-cell]").length}`;
4628
4836
  if (typeof performance !== "undefined") {
4629
4837
  const materializeMs = performance.now() - materializeStart;
@@ -6670,6 +6878,7 @@ export {
6670
6878
  VectoJSEvent,
6671
6879
  WebGPUParticleSystemManager,
6672
6880
  beginVectoUserTiming,
6881
+ contentLineInHint,
6673
6882
  createWebGLPointRenderer,
6674
6883
  endVectoUserTiming,
6675
6884
  installRendererDevTraps,
package/dist/text.js CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
 
4
4
 
5
- var _chunkZ3HFY75Rjs = require('./chunk-Z3HFY75R.js');
5
+ var _chunk7PI7LVWWjs = require('./chunk-7PI7LVWW.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 = _chunkZ3HFY75Rjs.MSDFTextEntity; exports.SVGEntity = _chunkZ3HFY75Rjs.SVGEntity;
12
+ exports.MSDFTextEntity = _chunk7PI7LVWWjs.MSDFTextEntity; exports.SVGEntity = _chunk7PI7LVWWjs.SVGEntity;
package/dist/text.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  MSDFTextEntity,
3
3
  SVGEntity
4
- } from "./chunk-MVFPN4Y5.mjs";
4
+ } from "./chunk-2WXZCPYQ.mjs";
5
5
 
6
6
  // src/text/index.ts
7
7
  export * from "@vectojs/text";
@@ -103,6 +103,43 @@ export interface ContentProjectionLine {
103
103
  /** Styled text runs in visual order. */
104
104
  runs?: ContentProjectionRun[];
105
105
  }
106
+ /**
107
+ * Advice from the {@link Scene} about which part of an entity is worth
108
+ * describing in {@link Entity.getContentProjection}.
109
+ *
110
+ * **Purely an optimization, and ignoring it is always correct.** The Scene
111
+ * windows the DOM itself, so an entity that returns its whole document still
112
+ * behaves correctly — it just pays to build lines that get discarded. An entity
113
+ * whose projection is O(glyphs) can use this to make that build O(visible)
114
+ * instead, which is the difference between per-frame cost that scales with the
115
+ * document and cost that scales with the viewport.
116
+ *
117
+ * Why a hint rather than a strict window: the entity owns the mapping from its
118
+ * own text to visual lines, and only it knows things like where a wrapped
119
+ * paragraph begins. Handing it a band and letting it round outward keeps that
120
+ * knowledge in one place. An entity may return more than asked — never less
121
+ * than it can, because text absent from the projection is invisible to
122
+ * find-in-page, copy and, for static text, the screen reader.
123
+ */
124
+ export interface ContentProjectionHint {
125
+ /**
126
+ * Inclusive band of entity-local y worth projecting, already expanded by the
127
+ * scene's `contentProjectionMargin` and intersected with every clipping
128
+ * ancestor. Absent when no useful bound exists (a rotated or skewed
129
+ * transform, a boundless entity), in which case project everything.
130
+ */
131
+ minY?: number;
132
+ maxY?: number;
133
+ }
134
+ /**
135
+ * Whether a line at `y` of height `height` is worth projecting under `hint`.
136
+ *
137
+ * Shared so every consumer rounds the same way: a line is kept when its box
138
+ * overlaps the band at all, which retains a line straddling the edge whole
139
+ * rather than clipping it mid-glyph. Returns `true` when the hint carries no
140
+ * band, so the default is always "project it".
141
+ */
142
+ export declare function contentLineInHint(hint: ContentProjectionHint | undefined, y: number, height: number): boolean;
106
143
  export interface ContentProjection {
107
144
  /** The logical source text exposed to find, selection, copy, and assistive technology. */
108
145
  text: string;
@@ -540,6 +577,42 @@ export declare abstract class Entity {
540
577
  * nodes, so on-top components stay clickable.
541
578
  */
542
579
  a11yFullViewport: boolean;
580
+ /**
581
+ * When this entity's a11y shadow node is materialized.
582
+ *
583
+ * `'eager'` (the default) keeps today's behaviour: a shadow node exists for as
584
+ * long as the entity is `interactive` with a box. That is right for a button or
585
+ * a link, and wrong for thousands of ephemeral, individually-meaningless
586
+ * entities — particles, danmaku, graph nodes — where it produces one DOM node
587
+ * per entity every frame.
588
+ *
589
+ * Measured on 5,000 moving interactive entities (`benchmarks/lazy-a11y/`):
590
+ * eager costs **72.2 ms/frame on Chrome and 114.3 ms on Firefox**, missing even
591
+ * 60 Hz, against **1.55/1.63 ms** for the same scene with one node projected —
592
+ * within noise of the 1.26/1.65 ms floor of projecting nothing at all.
593
+ *
594
+ * `'onDemand'` projects a node only while {@link Scene} considers the entity
595
+ * *engaged*: it is focused, it is the current pointer target, or it has been
596
+ * explicitly requested via {@link Scene.requestA11yProjection}. Crucially the
597
+ * trigger is not hover alone — a keyboard or assistive-technology user
598
+ * generates no hover, so a hover-only gate would remove exactly those users'
599
+ * access. Engagement therefore includes focus and an explicit request, and the
600
+ * entity stays hit-testable on canvas throughout, so a click still reaches it
601
+ * and promotes it.
602
+ *
603
+ * `'never'` suppresses the node entirely. Prefer `interactive = false` unless
604
+ * the entity genuinely needs pointer events without any semantic presence;
605
+ * this exists so a purely decorative interactive surface can opt out without
606
+ * losing canvas hit-testing.
607
+ *
608
+ * **This does not replace an aggregate description.** A thousand `'onDemand'`
609
+ * danmaku are individually reachable but say nothing collectively. The proven
610
+ * pattern is one aggregate live region (`role: 'status'`, `a11yFullViewport`)
611
+ * plus a small pool of persistent hotspots for the current selection — see
612
+ * `vectojs-native/danmaku`. Use `'onDemand'` to stop paying per entity, not as
613
+ * the whole accessibility story.
614
+ */
615
+ a11yProjection: 'eager' | 'onDemand' | 'never';
543
616
  /**
544
617
  * Hide this entity AND its whole subtree from the accessibility/automation
545
618
  * projection, regardless of each node's own `interactive` flag.
@@ -920,9 +993,13 @@ export declare abstract class Entity {
920
993
  * `selectable` is set — natively selectable. Returns `null` by default.
921
994
  * Read on the a11y sync cadence, so text changes propagate automatically.
922
995
  *
996
+ * @param hint - Optional advice about which part of the entity is worth
997
+ * describing. Purely an optimization: ignoring it is always correct, which
998
+ * is why it is a parameter rather than a required contract change. See
999
+ * {@link ContentProjectionHint}.
923
1000
  * @returns The projection descriptor, or `null` to project nothing.
924
1001
  */
925
- getContentProjection(): ContentProjection | null;
1002
+ getContentProjection(hint?: ContentProjectionHint): ContentProjection | null;
926
1003
  /**
927
1004
  * Whether this entity still has a queued/running tween animation, or an
928
1005
  * active {@link setTransition}/{@link animateTo}/{@link springTo} property
@@ -555,6 +555,14 @@ export declare class Scene {
555
555
  * mid-hover synthesize the `pointerleave` the browser never sends for a
556
556
  * detached element, so the entity doesn't keep its hover state. */
557
557
  private readonly hoveredA11yElements;
558
+ /**
559
+ * Entity ids the application has pinned via {@link requestA11yProjection}.
560
+ *
561
+ * Ids rather than entities so a removed entity cannot be retained by this set;
562
+ * a stale id simply never matches. Cleared per-entity by
563
+ * {@link releaseA11yProjection}.
564
+ */
565
+ private readonly a11yProjectionRequests;
558
566
  /** Persistent tabindex=-1 element in a11yRoot. When the focused a11y mirror is
559
567
  * pruned (virtualization/streaming/removal) while it holds focus, we move
560
568
  * focus here instead of letting the browser drop it to <body> — keeping the
@@ -1324,6 +1332,58 @@ export declare class Scene {
1324
1332
  * predicate, which is only tractable while it has one home.
1325
1333
  */
1326
1334
  private shouldProjectA11y;
1335
+ /**
1336
+ * Whether an `a11yProjection: 'onDemand'` entity is currently engaged enough to
1337
+ * deserve a shadow node.
1338
+ *
1339
+ * Deliberately **not** hover alone. A keyboard or assistive-technology user
1340
+ * generates no pointer events, so a hover-only trigger would withhold the
1341
+ * semantic node from precisely the users it exists for. Three signals, any of
1342
+ * which counts:
1343
+ *
1344
+ * - **Focus.** Covers keyboard traversal and AT-driven focus. Checked against
1345
+ * the live element so a node keeps its own focus rather than being pruned out
1346
+ * from under the user mid-interaction.
1347
+ * - **Pointer target.** The entity under the pointer, so a mouse user gets the
1348
+ * same node a hover-gated design would have given them.
1349
+ * - **Explicit request.** {@link Scene.requestA11yProjection}, for anything the
1350
+ * app knows is significant — the selected item, a search hit, a
1351
+ * just-announced element. This is the escape hatch that keeps the mode usable
1352
+ * when neither focus nor pointer applies.
1353
+ *
1354
+ * The entity stays hit-testable on canvas regardless, so a click always reaches
1355
+ * it and promotes it on the next sync.
1356
+ */
1357
+ private a11yEngaged;
1358
+ /**
1359
+ * Whether `node` mirrors selectable text of its own.
1360
+ *
1361
+ * Such an entity must not be promoted by the pointer: its interactive a11y node
1362
+ * would sit above the text mirror and eat the mousedown that starts a native
1363
+ * selection.
1364
+ */
1365
+ private projectsSelectableText;
1366
+ /**
1367
+ * Keep `entity`'s a11y shadow node projected while it has
1368
+ * `a11yProjection: 'onDemand'`.
1369
+ *
1370
+ * For anything the application knows matters but the engine cannot infer — the
1371
+ * selected danmaku, a search hit, a node just announced in a live region.
1372
+ * Without this, `'onDemand'` would be reachable only by focus or pointer, and
1373
+ * an app-driven selection change would leave the selected entity semantically
1374
+ * invisible.
1375
+ *
1376
+ * Idempotent. Has no effect on an `'eager'` entity, which is always projected.
1377
+ */
1378
+ requestA11yProjection(entity: Entity | string): void;
1379
+ /**
1380
+ * Drop a projection request made by {@link requestA11yProjection}.
1381
+ *
1382
+ * The node is not removed immediately: it survives while it is focused or under
1383
+ * the pointer, and is pruned on the next sync that finds it unengaged. Releasing
1384
+ * a request the scene does not hold is a no-op.
1385
+ */
1386
+ releaseA11yProjection(entity: Entity | string): void;
1327
1387
  private syncA11y;
1328
1388
  /**
1329
1389
  * Mirror one entity's static text ({@link Entity.getContentProjection}) as a
@@ -1341,6 +1401,34 @@ export declare class Scene {
1341
1401
  * culling and always count as visible, matching the legacy behavior.
1342
1402
  */
1343
1403
  private projectionBoxVisible;
1404
+ /**
1405
+ * The band of an entity's own y coordinates that is worth projecting, or
1406
+ * `null` to project everything.
1407
+ *
1408
+ * {@link projectionBoxVisible} answers "is this entity near the viewport",
1409
+ * which frees whole blocks that scroll away. It cannot help a single entity
1410
+ * *taller* than the viewport: that entity's box always intersects, so every
1411
+ * one of its visual lines was materialized — a `<span>` per line and, on the
1412
+ * grid path, a `<span>` per glyph cluster. That is where "14.8k elements for a
1413
+ * 346KB Markdown doc" comes from, and it is O(document) rather than
1414
+ * O(viewport) in both element count and per-frame walk cost.
1415
+ *
1416
+ * Measured on one entity scrolled to its middle, real headed browsers
1417
+ * (`benchmarks/projection-per-line/`): at 4000 lines, materializing every line
1418
+ * costs 6.28 ms/frame on Chrome and 6.51 ms on Firefox with 36,000 child
1419
+ * elements, against 0.28/0.16 ms and 963 elements when only the visible band
1420
+ * is emitted. The gated cost is *flat* across a 20x document-size range, so
1421
+ * this converts an asymptote rather than shaving a constant.
1422
+ *
1423
+ * Returns local-y bounds in the entity's own coordinate space, already
1424
+ * expanded by `margin` and intersected with every `clipChildren` ancestor, so
1425
+ * a line inside a scrolled container is measured against the container rather
1426
+ * than the window. `null` means "no useful bound" — a degenerate transform, a
1427
+ * rotation/skew that makes a y-band meaningless, or a boundless entity — and
1428
+ * the caller must then project every line, because emitting nothing would
1429
+ * silently drop text from selection, find-in-page and screen readers.
1430
+ */
1431
+ private projectionVisibleLocalYBand;
1344
1432
  private syncContentProjection;
1345
1433
  /**
1346
1434
  * Materialize a prepared grid in logical source order while positioning each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/core",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },