@vectojs/core 1.39.1 → 1.40.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
@@ -18,7 +18,7 @@ import {
18
18
  SVGEntity,
19
19
  VectoJSEvent,
20
20
  contentLineInHint
21
- } from "./chunk-7PYD5UDX.mjs";
21
+ } from "./chunk-CIRZ3S2Z.mjs";
22
22
 
23
23
  // src/tree/ComputeParticleEntity.ts
24
24
  var PARTICLE_STRIDE_FLOATS = 8;
@@ -1598,6 +1598,62 @@ var HitTester = class {
1598
1598
  findEntityAt(x, y, frame, width, height) {
1599
1599
  const overlayHit = this.findHitRecursively(this.overlayRoot, x, y);
1600
1600
  if (overlayHit) return overlayHit;
1601
+ return this.findMainTreeHit(x, y, frame, width, height);
1602
+ }
1603
+ /**
1604
+ * The merged hit list (RFC5 §2, CTX-0600): the canvas spatial test plus
1605
+ * caller-observed DOM-native candidates (mirror / portal / `dom-visual`
1606
+ * extension point for CTX-0598) as ONE ordered candidate list.
1607
+ *
1608
+ * Query API only — no dispatch change: `findEntityAt` keeps its single
1609
+ * topmost answer byte-for-byte. Coordinates enter in scene space (callers
1610
+ * map client coordinates first, e.g. `Scene.findHitsAtClient`); backend
1611
+ * attribution follows. DOM candidates pass through the same
1612
+ * `disabled` / `pointerEvents: 'none'` predicate as the canvas paths
1613
+ * (RFC5 §2 rule 4); the browser's hit test is their geometric authority,
1614
+ * so scene-space `isPointInside` is not re-checked.
1615
+ *
1616
+ * Overlay order is authoritative (RFC5 §2 rule 2): overlay-subtree
1617
+ * candidates sort above main-tree candidates, DOM-native backends above
1618
+ * canvas within one subtree. Sorted descending by `priority`; ties keep
1619
+ * insertion order (canvas overlay, canvas main, then DOM in caller order).
1620
+ */
1621
+ findHitsAt(x, y, frame, width, height, domCandidates = []) {
1622
+ const results = [];
1623
+ const overlayHit = this.findHitRecursively(this.overlayRoot, x, y);
1624
+ if (overlayHit) results.push(this.toHitResult(overlayHit, "canvas", x, y));
1625
+ const mainHit = this.findMainTreeHit(x, y, frame, width, height);
1626
+ if (mainHit) results.push(this.toHitResult(mainHit, "canvas", x, y));
1627
+ for (const candidate of domCandidates) {
1628
+ if (!this.isHitEligible(candidate.node, x, y)) continue;
1629
+ results.push(this.toHitResult(candidate.node, candidate.backend, x, y));
1630
+ }
1631
+ results.sort((a, b) => b.priority - a.priority);
1632
+ return results;
1633
+ }
1634
+ /**
1635
+ * Attribute one candidate: scene-space point plus node-local point, with
1636
+ * the compositor-layer priority (overlay subtree above main tree,
1637
+ * DOM-native backends above canvas within one subtree).
1638
+ */
1639
+ toHitResult(node, backend, x, y) {
1640
+ return {
1641
+ node,
1642
+ backend,
1643
+ localPoint: node.worldToLocal(x, y),
1644
+ worldPoint: { x, y },
1645
+ priority: (this.isInOverlaySubtree(node) ? 2 : 0) + (backend === "canvas" ? 0 : 1)
1646
+ };
1647
+ }
1648
+ /** Whether `node` lives under the overlay root (drawn above the main tree). */
1649
+ isInOverlaySubtree(node) {
1650
+ for (let current = node; current; current = current.parent) {
1651
+ if (current === this.overlayRoot) return true;
1652
+ }
1653
+ return false;
1654
+ }
1655
+ /** Main-tree arm of {@link findEntityAt}, shared with {@link findHitsAt}. */
1656
+ findMainTreeHit(x, y, frame, width, height) {
1601
1657
  if (this.backends.hit && this.ensureHitGrid(frame, width, height)) {
1602
1658
  return this.findEntityAtWasm(x, y);
1603
1659
  }
@@ -3501,6 +3557,207 @@ function normalizeChord(input) {
3501
3557
  return ordered.join("+");
3502
3558
  }
3503
3559
 
3560
+ // src/tree/scene/SemanticProjectionPolicy.ts
3561
+ var DEFAULT_SEMANTIC_PROJECTION_POLICY = {
3562
+ choose: () => "project"
3563
+ };
3564
+ function getSemanticProjectionCapabilities() {
3565
+ return {
3566
+ htmlInCanvas: supportsHTMLInCanvas(),
3567
+ canvasTextRecovery: false
3568
+ };
3569
+ }
3570
+ var CONTROL_SEMANTIC_ROLES = /* @__PURE__ */ new Set([
3571
+ "button",
3572
+ "link",
3573
+ "checkbox",
3574
+ "radio",
3575
+ "switch",
3576
+ "slider",
3577
+ "spinbutton",
3578
+ "combobox",
3579
+ "listbox",
3580
+ "option",
3581
+ "menuitem",
3582
+ "menuitemcheckbox",
3583
+ "menuitemradio",
3584
+ "tab",
3585
+ "textbox",
3586
+ "searchbox",
3587
+ "progressbar",
3588
+ "scrollbar",
3589
+ "meter"
3590
+ ]);
3591
+ function isDeferrableSemanticNode(node) {
3592
+ const attrs = node.getA11yAttributes();
3593
+ if (attrs.tag !== void 0 && attrs.tag !== "div") return false;
3594
+ if (attrs.role !== void 0 && CONTROL_SEMANTIC_ROLES.has(attrs.role)) return false;
3595
+ if (typeof attrs.tabIndex === "number" && attrs.tabIndex >= 0) return false;
3596
+ const projection = node.getContentProjection?.();
3597
+ if (projection?.selectable) return false;
3598
+ return true;
3599
+ }
3600
+ function describeHTMLInCanvasSupport() {
3601
+ if (typeof document === "undefined") return { supported: false, reason: "no-dom" };
3602
+ try {
3603
+ const canvas = document.createElement("canvas");
3604
+ const ctx = canvas.getContext("2d");
3605
+ const native2D = typeof CanvasRenderingContext2D !== "undefined" && ctx instanceof CanvasRenderingContext2D;
3606
+ if (native2D && typeof ctx["drawElementImage"] === "function") {
3607
+ return { supported: true };
3608
+ }
3609
+ } catch {
3610
+ return { supported: false, reason: "no-backend" };
3611
+ }
3612
+ return { supported: false, reason: "no-backend" };
3613
+ }
3614
+ function supportsHTMLInCanvas() {
3615
+ return describeHTMLInCanvasSupport().supported;
3616
+ }
3617
+
3618
+ // src/tree/scene/ProjectionPolicy.ts
3619
+ var DEFAULT_PROJECTION_CAPABILITIES = {
3620
+ /** Static text (short): findability already covered by content projection. */
3621
+ text: {
3622
+ domSupported: true,
3623
+ nativeValue: "medium",
3624
+ domCost: "low",
3625
+ canvasCost: "low",
3626
+ autoDefault: "canvas"
3627
+ },
3628
+ /** Long/selectable text, Markdown blocks: selection, Ctrl+F, translation. */
3629
+ prose: {
3630
+ domSupported: true,
3631
+ nativeValue: "high",
3632
+ domCost: "medium",
3633
+ canvasCost: "high",
3634
+ autoDefault: "dom"
3635
+ },
3636
+ /** Code blocks: selectable source, but canvas carriers already serve selection — rests canvas until measured (RFC4 §6 hybrid mapping). */
3637
+ code: {
3638
+ domSupported: true,
3639
+ nativeValue: "medium",
3640
+ domCost: "medium",
3641
+ canvasCost: "high",
3642
+ autoDefault: "canvas"
3643
+ },
3644
+ /** Text input / editable: IME, caret, clipboard, BiDi come free in DOM. */
3645
+ input: {
3646
+ domSupported: true,
3647
+ nativeValue: "high",
3648
+ domCost: "low",
3649
+ canvasCost: "prohibitive",
3650
+ autoDefault: "dom"
3651
+ },
3652
+ /** Button / Link: cheap either way, native wins on focus + AT clicks. */
3653
+ button: {
3654
+ domSupported: true,
3655
+ nativeValue: "medium",
3656
+ domCost: "low",
3657
+ canvasCost: "low",
3658
+ autoDefault: "dom"
3659
+ },
3660
+ link: {
3661
+ domSupported: true,
3662
+ nativeValue: "medium",
3663
+ domCost: "low",
3664
+ canvasCost: "low",
3665
+ autoDefault: "dom"
3666
+ },
3667
+ /** Select / Dropdown / Checkbox / Radio: popup, keyboard, form semantics. */
3668
+ select: {
3669
+ domSupported: true,
3670
+ nativeValue: "high",
3671
+ domCost: "low",
3672
+ canvasCost: "high",
3673
+ autoDefault: "dom"
3674
+ },
3675
+ /** Container / layout group: no direct representation, children negotiate. */
3676
+ container: {
3677
+ domSupported: true,
3678
+ nativeValue: "none",
3679
+ domCost: "n/a",
3680
+ canvasCost: "low",
3681
+ autoDefault: "canvas"
3682
+ },
3683
+ /** Transform / camera rig: owns space, not pixels — never materialized. */
3684
+ transform: {
3685
+ domSupported: true,
3686
+ nativeValue: "none",
3687
+ domCost: "n/a",
3688
+ canvasCost: "n/a",
3689
+ autoDefault: "canvas"
3690
+ },
3691
+ /** Image / video frame: blit is cheap, one element per item is not. */
3692
+ image: {
3693
+ domSupported: true,
3694
+ nativeValue: "low",
3695
+ domCost: "medium",
3696
+ canvasCost: "low",
3697
+ autoDefault: "canvas"
3698
+ },
3699
+ /** Particles / danmaku / chart glyphs: bulk stays canvas, unconditionally. */
3700
+ particles: {
3701
+ domSupported: true,
3702
+ nativeValue: "none",
3703
+ domCost: "prohibitive",
3704
+ canvasCost: "low",
3705
+ autoDefault: "canvas"
3706
+ },
3707
+ /** Graph nodes/edges (interactive, moderate count): canvas until measured. */
3708
+ graph: {
3709
+ domSupported: true,
3710
+ nativeValue: "low",
3711
+ domCost: "medium",
3712
+ canvasCost: "medium",
3713
+ autoDefault: "canvas"
3714
+ },
3715
+ /** Custom shader / GPU particles: no DOM representation exists. */
3716
+ shader: {
3717
+ domSupported: false,
3718
+ nativeValue: "none",
3719
+ domCost: "unsupported",
3720
+ canvasCost: "low",
3721
+ autoDefault: "canvas"
3722
+ }
3723
+ };
3724
+ var UNKNOWN_PROJECTION_CAPABILITY = {
3725
+ domSupported: false,
3726
+ nativeValue: "none",
3727
+ domCost: "unsupported",
3728
+ canvasCost: "low",
3729
+ autoDefault: "canvas"
3730
+ };
3731
+ var PROJECTION_AUTO_HYSTERESIS_FRAMES = 3;
3732
+ var PROJECTION_AUTO_DOM_BUDGET = 500;
3733
+ function resolveProjection(want, cap, ctx) {
3734
+ if (want === "canvas") return { resolved: "canvas", reason: null };
3735
+ if (!ctx.hasDOM || !ctx.domBackendMounted) {
3736
+ return { resolved: "canvas", reason: "no-dom-backend" };
3737
+ }
3738
+ if (!cap.domSupported) return { resolved: "canvas", reason: "unsupported-kind" };
3739
+ if (want === "dom") {
3740
+ if (cap.domCost === "prohibitive") return { resolved: "canvas", reason: "prohibitive-cost" };
3741
+ return { resolved: "dom", reason: null };
3742
+ }
3743
+ if (cap.nativeValue === "high" && cap.domCost !== "prohibitive") {
3744
+ return { resolved: "dom", reason: null };
3745
+ }
3746
+ if (cap.canvasCost === "prohibitive" && cap.domSupported) {
3747
+ return { resolved: "dom", reason: null };
3748
+ }
3749
+ if (cap.autoDefault === "dom") return { resolved: "dom", reason: null };
3750
+ return { resolved: "canvas", reason: null };
3751
+ }
3752
+ function hysteresisVote(current, desired, consecutive, hysteresisFrames) {
3753
+ if (current === void 0 || current === desired) {
3754
+ return { resolved: desired, consecutive: 0, flipped: false };
3755
+ }
3756
+ const next = consecutive + 1;
3757
+ if (next >= hysteresisFrames) return { resolved: desired, consecutive: 0, flipped: true };
3758
+ return { resolved: current, consecutive: next, flipped: false };
3759
+ }
3760
+
3504
3761
  // src/tree/Scene.ts
3505
3762
  var RANGE_VALUE_ROLES = /* @__PURE__ */ new Set(["slider", "spinbutton", "progressbar", "scrollbar", "meter"]);
3506
3763
  var INTERACTIVE_A11Y_ROLES = /* @__PURE__ */ new Set([
@@ -3524,7 +3781,8 @@ var KEYBOARD_OWNING_ROLES = /* @__PURE__ */ new Set([
3524
3781
  ]);
3525
3782
  function ownsKeyboard(el) {
3526
3783
  if (!el) return false;
3527
- if (el === document.body || el === document.documentElement) return false;
3784
+ if (typeof document !== "undefined" && (el === document.body || el === document.documentElement))
3785
+ return false;
3528
3786
  if (el.hasAttribute("data-vecto-a11y-root")) return false;
3529
3787
  const tag = el.tagName;
3530
3788
  if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
@@ -3560,10 +3818,13 @@ var SCENE_OPTION_KEYS = [
3560
3818
  "maxFPS",
3561
3819
  "particleBackend",
3562
3820
  "pointBackend",
3821
+ "projectionAutoDomBudget",
3822
+ "projectionHysteresisFrames",
3563
3823
  "readingDirection",
3564
3824
  "renderer",
3565
3825
  "renderMode",
3566
3826
  "respectReducedMotion",
3827
+ "semanticProjectionPolicy",
3567
3828
  "userTiming"
3568
3829
  ];
3569
3830
  var SCENE_FIELD_NOT_OPTION = {
@@ -4061,6 +4322,26 @@ var Scene = class _Scene {
4061
4322
  * frame. See {@link SceneOptions.a11ySyncInterval}.
4062
4323
  */
4063
4324
  a11ySyncInterval = 0;
4325
+ /**
4326
+ * Per-node semantic projection policy (RFC3 §5, CTX-0599). Consulted once
4327
+ * per node by {@link shouldProjectA11y}; the default projects everything
4328
+ * the legacy predicate projects, so reassigning nothing changes nothing.
4329
+ * See {@link SceneOptions.semanticProjectionPolicy}.
4330
+ */
4331
+ semanticProjectionPolicy = DEFAULT_SEMANTIC_PROJECTION_POLICY;
4332
+ /**
4333
+ * Consecutive syncs an `'auto'`-policy node must keep voting for the other
4334
+ * backend before its sticky resolution flips (RFC4 §3 rule 3, CTX-0601).
4335
+ * Documented tunable — see {@link PROJECTION_AUTO_HYSTERESIS_FRAMES}.
4336
+ */
4337
+ projectionHysteresisFrames = PROJECTION_AUTO_HYSTERESIS_FRAMES;
4338
+ /**
4339
+ * Maximum `'auto'`-resolved DOM residents per scene per frame (RFC4 §4
4340
+ * particle-row backstop: bulk-count nodes stay canvas). Explicit `'dom'`
4341
+ * requests bypass it. Documented tunable — see
4342
+ * {@link PROJECTION_AUTO_DOM_BUDGET}.
4343
+ */
4344
+ projectionAutoDomBudget = PROJECTION_AUTO_DOM_BUDGET;
4064
4345
  /** Timestamp of the last a11y sync, for throttling. */
4065
4346
  lastA11ySync = -Infinity;
4066
4347
  /** True if we skipped an a11y sync during animation and need to sync when at rest. */
@@ -4291,6 +4572,30 @@ var Scene = class _Scene {
4291
4572
  activePortalsThisFrame = /* @__PURE__ */ new Set();
4292
4573
  activePortalsPrevFrame = /* @__PURE__ */ new Set();
4293
4574
  portalEntities = /* @__PURE__ */ new Map();
4575
+ /**
4576
+ * Generic projection backends (RFC1 §5 vocabulary, `tree/scene/ProjectionBackend.ts`).
4577
+ * Render-independent: the interface exchanges only node identity, world
4578
+ * matrix, and lifecycle calls, so registering a DOM backend adds no
4579
+ * `HTMLElement` surface to core. `@vectojs/dom` owns the only implementation.
4580
+ */
4581
+ projectionBackends = [];
4582
+ /** Per-frame seen-sets driving {@link pruneProjectionBackends} (portal-precedent). */
4583
+ domSeenPrevFrame = /* @__PURE__ */ new Set();
4584
+ domSeenThisFrame = /* @__PURE__ */ new Set();
4585
+ domSeenNodes = /* @__PURE__ */ new Map();
4586
+ /**
4587
+ * Negotiation state (RFC4 §3, CTX-0601): last resolution per node (the
4588
+ * per-scene queryable surface behind {@link getProjectionResolutions}),
4589
+ * hysteresis votes, per-kind capability overrides, and explicit gesture
4590
+ * pins. Plain data only — no `HTMLElement` surface in core.
4591
+ */
4592
+ projectionResolutions = /* @__PURE__ */ new Map();
4593
+ projectionHysteresis = /* @__PURE__ */ new Map();
4594
+ projectionCapabilityOverrides = /* @__PURE__ */ new Map();
4595
+ projectionGesturePins = /* @__PURE__ */ new Map();
4596
+ /** Frame id the `'auto'` DOM budget count belongs to (bulk backstop). */
4597
+ projectionBudgetFrame = -1;
4598
+ projectionDomAutoCount = 0;
4294
4599
  renderOrderCounter = 0;
4295
4600
  // --- domain: render-scheduler — authoritative frame counter ---
4296
4601
  /**
@@ -4954,6 +5259,9 @@ var Scene = class _Scene {
4954
5259
  this.phases.userTiming = options.userTiming ?? false;
4955
5260
  this.particleBackend = options.particleBackend ?? "auto";
4956
5261
  this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
5262
+ this.semanticProjectionPolicy = options.semanticProjectionPolicy ?? DEFAULT_SEMANTIC_PROJECTION_POLICY;
5263
+ this.projectionHysteresisFrames = options.projectionHysteresisFrames ?? PROJECTION_AUTO_HYSTERESIS_FRAMES;
5264
+ this.projectionAutoDomBudget = options.projectionAutoDomBudget ?? PROJECTION_AUTO_DOM_BUDGET;
4957
5265
  this.contentProjectionEnabled = options.contentProjection ?? true;
4958
5266
  this.contentProjectionMargin = options.contentProjectionMargin;
4959
5267
  this.contentSemanticMargin = options.contentSemanticMargin;
@@ -5305,6 +5613,37 @@ var Scene = class _Scene {
5305
5613
  findEntityAt(x, y) {
5306
5614
  return this._hitTester.findEntityAt(x, y, this.currentFrame, this.width, this.height);
5307
5615
  }
5616
+ // --- domain: hit-test — merged HitResult query (RFC5 §2, CTX-0600) ---
5617
+ /**
5618
+ * The merged hit list for a scene-space point: the canvas spatial test
5619
+ * plus caller-observed DOM-native candidates (mirror / portal /
5620
+ * `dom-visual` extension point for CTX-0598) as ONE ordered candidate
5621
+ * list (`HitResult`, overlay order authoritative).
5622
+ *
5623
+ * Query API alongside {@link findEntityAt} — no dispatch change:
5624
+ * `findEntityAt` keeps its single topmost answer byte-for-byte, keyboard
5625
+ * and AT flows are untouched. For browser pointer coordinates use
5626
+ * {@link findHitsAtClient}, which maps through {@link clientToScene} first.
5627
+ */
5628
+ findHitsAt(x, y, domCandidates = []) {
5629
+ return this._hitTester.findHitsAt(
5630
+ x,
5631
+ y,
5632
+ this.currentFrame,
5633
+ this.width,
5634
+ this.height,
5635
+ domCandidates
5636
+ );
5637
+ }
5638
+ /**
5639
+ * {@link findHitsAt} for browser viewport coordinates: maps through
5640
+ * {@link clientToScene} so coordinates enter in scene space (RFC5 §2
5641
+ * rule 3) and backend attribution follows.
5642
+ */
5643
+ findHitsAtClient(clientX, clientY, domCandidates = []) {
5644
+ const point = this.clientToScene(clientX, clientY);
5645
+ return this.findHitsAt(point.x, point.y, domCandidates);
5646
+ }
5308
5647
  // --- domain: hit-test — client-to-scene mapping ---
5309
5648
  /** Convert browser viewport coordinates into this Scene's logical coordinates. */
5310
5649
  clientToScene(clientX, clientY) {
@@ -5345,6 +5684,15 @@ var Scene = class _Scene {
5345
5684
  this.activePortalsThisFrame.delete(node.id);
5346
5685
  this.activePortalsPrevFrame.delete(node.id);
5347
5686
  }
5687
+ if (node.domPolicy === "dom" || node.domResident) {
5688
+ for (const backend of this.projectionBackends) backend.unmount(node);
5689
+ this.domSeenThisFrame.delete(node.id);
5690
+ this.domSeenPrevFrame.delete(node.id);
5691
+ this.domSeenNodes.delete(node.id);
5692
+ }
5693
+ this.projectionResolutions.delete(node.id);
5694
+ this.projectionHysteresis.delete(node.id);
5695
+ this.projectionGesturePins.delete(node.id);
5348
5696
  const contentEl = this.contentElements.get(node.id);
5349
5697
  if (contentEl) {
5350
5698
  this._contentProjection.clearGridState(node.id, contentEl);
@@ -5456,6 +5804,15 @@ var Scene = class _Scene {
5456
5804
  while (this.overlayRoot.children.length > 0) {
5457
5805
  this.destroyEntitySubtree(this.overlayRoot.children.at(-1));
5458
5806
  }
5807
+ for (const node of this.domSeenNodes.values()) {
5808
+ for (const backend of this.projectionBackends) backend.unmount(node);
5809
+ }
5810
+ this.domSeenNodes.clear();
5811
+ this.domSeenThisFrame.clear();
5812
+ this.domSeenPrevFrame.clear();
5813
+ this.projectionResolutions.clear();
5814
+ this.projectionHysteresis.clear();
5815
+ this.projectionGesturePins.clear();
5459
5816
  if (typeof window !== "undefined" && !this.disableWindowResize) {
5460
5817
  window.removeEventListener("resize", this.resizeHandler);
5461
5818
  }
@@ -5954,15 +6311,48 @@ var Scene = class _Scene {
5954
6311
  shouldProjectA11y(node) {
5955
6312
  if (!node.interactive) return false;
5956
6313
  if (!(node.width > 0 || node.a11yFullViewport)) return false;
6314
+ if (this.resolveProjectionFor(node) === "dom") return false;
5957
6315
  switch (node.a11yProjection) {
5958
6316
  case "never":
5959
6317
  return false;
5960
6318
  case "onDemand":
5961
- return this.a11yEngaged(node);
6319
+ return this.a11yEngaged(node) && this.resolveSemanticProjection(node);
5962
6320
  default:
5963
- return true;
6321
+ return this.resolveSemanticProjection(node);
5964
6322
  }
5965
6323
  }
6324
+ /**
6325
+ * Policy half of {@link shouldProjectA11y} (RFC3 §5, CTX-0599).
6326
+ *
6327
+ * The legacy gates above (interactive, box, `a11yProjection` engagement)
6328
+ * are unchanged; this only maps a {@link SemanticProjectionPolicy} decision
6329
+ * onto project/suppress. Out of the box the default policy returns
6330
+ * `'project'`, so behaviour is identical to having no policy.
6331
+ *
6332
+ * `'defer-to-browser'` always falls back to projection today: no deferral
6333
+ * backend exists (`supportsHTMLInCanvas()` is `false`), so deferring would
6334
+ * silently drop semantics — the exact failure RFC §3 rules out. When a
6335
+ * backend lands, only allow-listed plain display text
6336
+ * ({@link isDeferrableSemanticNode}; never controls) may actually defer. A
6337
+ * throwing policy likewise falls back to projection: a policy must never be
6338
+ * able to drop semantics by accident.
6339
+ */
6340
+ resolveSemanticProjection(node) {
6341
+ const policy = this.semanticProjectionPolicy ?? DEFAULT_SEMANTIC_PROJECTION_POLICY;
6342
+ let decision;
6343
+ try {
6344
+ decision = policy.choose(node, getSemanticProjectionCapabilities(), {
6345
+ hasDOM: this.a11yRoot !== null
6346
+ });
6347
+ } catch {
6348
+ decision = "project";
6349
+ }
6350
+ if (decision === "never") return false;
6351
+ if (decision === "defer-to-browser" && isDeferrableSemanticNode(node) && supportsHTMLInCanvas()) {
6352
+ return false;
6353
+ }
6354
+ return true;
6355
+ }
5966
6356
  /**
5967
6357
  * Whether an `a11yProjection: 'onDemand'` entity is currently engaged enough to
5968
6358
  * deserve a shadow node.
@@ -6131,14 +6521,17 @@ var Scene = class _Scene {
6131
6521
  if (e.target === capEl && typeof capEl.setPointerCapture === "function") {
6132
6522
  capEl.setPointerCapture(e.pointerId);
6133
6523
  }
6524
+ this.pinProjectionForGesture(node);
6134
6525
  node.dispatchEvent(new VectoJSEvent("pointerdown", node, e));
6135
6526
  });
6136
6527
  el.addEventListener("pointerup", (e) => {
6137
6528
  releasePointer(e);
6529
+ this.unpinProjectionForGesture(node);
6138
6530
  node.dispatchEvent(new VectoJSEvent("pointerup", node, e));
6139
6531
  });
6140
6532
  el.addEventListener("pointercancel", (e) => {
6141
6533
  releasePointer(e);
6534
+ this.unpinProjectionForGesture(node);
6142
6535
  node.dispatchEvent(new VectoJSEvent("pointercancel", node, e));
6143
6536
  });
6144
6537
  el.addEventListener(
@@ -7099,6 +7492,197 @@ var Scene = class _Scene {
7099
7492
  this.activePortalsPrevFrame = new Set(this.activePortalsThisFrame);
7100
7493
  this.activePortalsThisFrame.clear();
7101
7494
  }
7495
+ /**
7496
+ * Register a generic projection backend (RFC1 §5, RFC2 P1 CTX-0598).
7497
+ * Registration is idempotent per backend instance. Core drives
7498
+ * mount/update/unmount from the render walk; the backend owns all
7499
+ * medium-specific state.
7500
+ */
7501
+ addProjectionBackend(backend) {
7502
+ if (!this.projectionBackends.includes(backend)) this.projectionBackends.push(backend);
7503
+ return this;
7504
+ }
7505
+ /**
7506
+ * Remove a previously registered projection backend by instance or
7507
+ * {@link ProjectionBackendKind | kind}. Removing does not unmount resident
7508
+ * nodes — unmount the backend first if teardown order matters.
7509
+ */
7510
+ removeProjectionBackend(backend) {
7511
+ this.projectionBackends = this.projectionBackends.filter(
7512
+ (b) => typeof backend === "string" ? b.kind !== backend : b !== backend
7513
+ );
7514
+ return this;
7515
+ }
7516
+ /**
7517
+ * Scene-level projection capabilities (RFC4 §3 rule 2, CTX-0601).
7518
+ *
7519
+ * Plain-data feature detection in the shape of the proposed
7520
+ * `scene.inputCapabilities` (input-dispatch-contract-v2 §4): apps and
7521
+ * devtools query where a node _would_ materialize and why, without touching
7522
+ * any backend. Additive only — no existing surface moves.
7523
+ */
7524
+ get projectionCapabilities() {
7525
+ return {
7526
+ hasDOM: typeof document !== "undefined",
7527
+ domBackendMounted: this.projectionBackends.some((b) => b.kind === "dom"),
7528
+ backends: this.projectionBackends.map((b) => b.kind),
7529
+ autoHysteresisFrames: this.projectionHysteresisFrames,
7530
+ autoDomBudget: this.projectionAutoDomBudget
7531
+ };
7532
+ }
7533
+ /**
7534
+ * Register (or replace) the Capability Matrix row for one `domKind`
7535
+ * (RFC4 §4, CTX-0601). Custom kinds start at the unknown-kind default
7536
+ * (`unsupported-kind` → canvas); registering a row is how a custom backend
7537
+ * kind opts into `'auto'` resolution and how tests drive oscillating costs.
7538
+ */
7539
+ registerProjectionCapability(domKind, row) {
7540
+ this.projectionCapabilityOverrides.set(domKind, row);
7541
+ return this;
7542
+ }
7543
+ /** Matrix row for a node's `domKind` (override wins, unknown → canvas). */
7544
+ getProjectionCapability(node) {
7545
+ return this.projectionCapabilityOverrides.get(node.domKind) ?? DEFAULT_PROJECTION_CAPABILITIES[node.domKind] ?? UNKNOWN_PROJECTION_CAPABILITY;
7546
+ }
7547
+ /**
7548
+ * Pin a node's backend for the duration of an active gesture (RFC4 §5 +
7549
+ * input-dispatch-contract-v2 §4 gesture stickiness: no mid-gesture handoff).
7550
+ * The a11y-mirror pointerdown/up/cancel listeners maintain this; DOM-side
7551
+ * gestures are consulted through
7552
+ * {@link ProjectionBackend.hasActiveGesture | backend.hasActiveGesture}.
7553
+ * Refcounted so multi-pointer gestures on one node unpin exactly once.
7554
+ */
7555
+ pinProjectionForGesture(node) {
7556
+ this.projectionGesturePins.set(node.id, (this.projectionGesturePins.get(node.id) ?? 0) + 1);
7557
+ }
7558
+ /** Release one gesture pin taken by {@link pinProjectionForGesture}. */
7559
+ unpinProjectionForGesture(node) {
7560
+ const count = (this.projectionGesturePins.get(node.id) ?? 0) - 1;
7561
+ if (count <= 0) this.projectionGesturePins.delete(node.id);
7562
+ else this.projectionGesturePins.set(node.id, count);
7563
+ }
7564
+ /** Whether `node` currently owns an active gesture on a core mirror. */
7565
+ isProjectionPinned(node) {
7566
+ return (this.projectionGesturePins.get(node.id) ?? 0) > 0;
7567
+ }
7568
+ /**
7569
+ * Last negotiation for one node, if it was ever resolved on this scene.
7570
+ * Accepts the node or its id; returns the stored record (not a copy — treat
7571
+ * as read-only).
7572
+ */
7573
+ getProjectionResolution(node) {
7574
+ return this.projectionResolutions.get(typeof node === "string" ? node : node.id);
7575
+ }
7576
+ /** Every node's last negotiation, in first-resolution order. */
7577
+ getProjectionResolutions() {
7578
+ return [...this.projectionResolutions.values()];
7579
+ }
7580
+ /**
7581
+ * Resolve one node's `domPolicy` to its effective backend (RFC4 §3, CTX-0601).
7582
+ *
7583
+ * Memoized per main frame (`currentFrame`, bumped once per authoritative
7584
+ * render): the render walk and the later a11y sync must agree within a
7585
+ * frame, or a node could lose its mirror the same frame it gains an element.
7586
+ * `'auto'` applies, in order: no-DOM short-circuit → pure negotiation →
7587
+ * gesture/focus hard bars (a pinned node keeps its current backend, never
7588
+ * flips mid-gesture, focus is preserved-or-moved never dropped to `body` per
7589
+ * input-dispatch-contract-v2 §4) → hysteresis stickiness → bulk budget.
7590
+ * Every outcome is recorded on the per-scene queryable surface
7591
+ * ({@link getProjectionResolutions}) with its reason — fallbacks are
7592
+ * reported, not silent (§3 rule 2).
7593
+ */
7594
+ resolveProjectionFor(node) {
7595
+ const want = node.domPolicy;
7596
+ if (this.projectionBackends.length === 0 && want === "canvas") return "canvas";
7597
+ const memo = this.projectionHysteresis.get(node.id);
7598
+ if (memo && memo.lastFrame === this.currentFrame) return memo.resolved;
7599
+ const hasDOM = typeof document !== "undefined";
7600
+ const domBackendMounted = this.projectionBackends.some((b) => b.kind === "dom");
7601
+ const outcome = resolveProjection(want, this.getProjectionCapability(node), {
7602
+ hasDOM,
7603
+ domBackendMounted
7604
+ });
7605
+ let resolved = outcome.resolved;
7606
+ let reason = outcome.reason;
7607
+ if (want === "auto" && memo !== void 0) {
7608
+ if (resolved !== memo.resolved) {
7609
+ const gesturePinned = this.isProjectionPinned(node) || this.projectionBackends.some((b) => b.hasActiveGesture?.(node) === true);
7610
+ if (gesturePinned) {
7611
+ resolved = memo.resolved;
7612
+ reason = "active-gesture";
7613
+ } else if (this.ownsProjectionFocus(node)) {
7614
+ resolved = memo.resolved;
7615
+ reason = "focus-pinned";
7616
+ } else {
7617
+ const vote = hysteresisVote(
7618
+ memo.resolved,
7619
+ resolved,
7620
+ memo.consecutive,
7621
+ this.projectionHysteresisFrames
7622
+ );
7623
+ resolved = vote.resolved;
7624
+ memo.consecutive = vote.consecutive;
7625
+ reason = vote.flipped ? reason : "hysteresis";
7626
+ }
7627
+ } else {
7628
+ memo.consecutive = 0;
7629
+ }
7630
+ }
7631
+ if (want === "auto" && resolved === "dom") {
7632
+ if (this.projectionBudgetFrame !== this.currentFrame) {
7633
+ this.projectionBudgetFrame = this.currentFrame;
7634
+ this.projectionDomAutoCount = 0;
7635
+ }
7636
+ this.projectionDomAutoCount += 1;
7637
+ if (this.projectionDomAutoCount > this.projectionAutoDomBudget) {
7638
+ resolved = "canvas";
7639
+ reason = "bulk-budget";
7640
+ }
7641
+ }
7642
+ this.projectionHysteresis.set(node.id, {
7643
+ resolved,
7644
+ consecutive: memo?.consecutive ?? 0,
7645
+ lastFrame: this.currentFrame
7646
+ });
7647
+ const prev = this.projectionResolutions.get(node.id);
7648
+ if (!prev || prev.want !== want || prev.resolved !== resolved || prev.reason !== reason) {
7649
+ this.projectionResolutions.set(node.id, { nodeId: node.id, want, resolved, reason });
7650
+ }
7651
+ return resolved;
7652
+ }
7653
+ /**
7654
+ * Whether `node`'s a11y mirror currently owns browser focus (RFC4 §5 focus
7655
+ * rule, input-dispatch-contract-v2 §4: focus stays DOM-based; flipping
7656
+ * `projection` must preserve or deliberately move focus, never drop it to
7657
+ * `body`). A focused node keeps its backend until blur — the actual move, if
7658
+ * any, goes through the existing sentinels (`preserveFocusOnRemoval`, the
7659
+ * DOM backend's own fallback), never a bare removal.
7660
+ */
7661
+ ownsProjectionFocus(node) {
7662
+ if (typeof document === "undefined") return false;
7663
+ const mirror = this.a11yElements.get(node.id);
7664
+ if (!mirror || mirror !== this.focusedA11yElement) return false;
7665
+ return document.activeElement === mirror;
7666
+ }
7667
+ /**
7668
+ * RFC2 P1 (CTX-0598): unmount DOM-projection residents the walk did not see
7669
+ * this frame (viewport-culled, removed without `remove()`, policy-flipped).
7670
+ * Mirrors {@link reconcilePortals} above; unmount is idempotent per the
7671
+ * `ProjectionBackend` contract so notifying every backend is safe.
7672
+ */
7673
+ pruneProjectionBackends() {
7674
+ for (const id of this.domSeenPrevFrame) {
7675
+ if (!this.domSeenThisFrame.has(id)) {
7676
+ const node = this.domSeenNodes.get(id);
7677
+ if (node) {
7678
+ for (const backend of this.projectionBackends) backend.unmount(node);
7679
+ this.domSeenNodes.delete(id);
7680
+ }
7681
+ }
7682
+ }
7683
+ this.domSeenPrevFrame = new Set(this.domSeenThisFrame);
7684
+ this.domSeenThisFrame.clear();
7685
+ }
7102
7686
  // --- domain: render-scheduler — the loop and the render walk ---
7103
7687
  /**
7104
7688
  * The frame-rate cap actually in effect: the explicit {@link maxFPS}, further
@@ -7176,6 +7760,13 @@ var Scene = class _Scene {
7176
7760
  /**
7177
7761
  * Render the entire scene graph onto the specified renderer.
7178
7762
  *
7763
+ * This is the `CanvasProjection` row of {@link ProjectionBackend}:
7764
+ * the per-frame materialization of scene semantics into pixels through the
7765
+ * backend-agnostic `IRenderer` contract. (The `A11yProjection` /
7766
+ * `ContentProjection` rows run on the same cadence via {@link Scene.syncA11y}
7767
+ * below; the `DOMProjection` row lives in `@vectojs/dom` and is driven from
7768
+ * the render walk further below.)
7769
+ *
7179
7770
  * Main-frame causal order is a correctness contract:
7180
7771
  *
7181
7772
  * 1. Browser/input callbacks finish before the scheduled frame begins.
@@ -7468,6 +8059,16 @@ var Scene = class _Scene {
7468
8059
  visible = maxX >= 0 && minX <= vw && maxY >= 0 && minY <= vh;
7469
8060
  }
7470
8061
  if (!visible && node.children.length === 0) return;
8062
+ if (isMainRenderer && this.projectionBackends.length > 0 && this.resolveProjectionFor(node) === "dom" && visible) {
8063
+ for (const backend of this.projectionBackends) {
8064
+ backend.update(node, { a, b, c, d, e: te, f: tf });
8065
+ }
8066
+ if (node.domResident) {
8067
+ this.domSeenThisFrame.add(node.id);
8068
+ this.domSeenNodes.set(node.id, node);
8069
+ if (node.children.length === 0) return;
8070
+ }
8071
+ }
7471
8072
  if (node.children.length === 0 && node.scaleX === node.scaleY) {
7472
8073
  const bc = node.getBatchCircle();
7473
8074
  if (bc) {
@@ -7597,6 +8198,7 @@ var Scene = class _Scene {
7597
8198
  this.frameHadAnimation = walkHadAnimation;
7598
8199
  this.frameHadInteractive = walkHadInteractive;
7599
8200
  this.reconcilePortals();
8201
+ this.pruneProjectionBackends();
7600
8202
  }
7601
8203
  const flushTiming = this.phases.userTiming ? beginVectoUserTiming(VECTO_USER_TIMING.scene.flush) : null;
7602
8204
  const flushT0 = this.phases.enabled ? performance.now() : 0;
@@ -8033,6 +8635,238 @@ var TextEntity = class extends Entity {
8033
8635
  }
8034
8636
  };
8035
8637
 
8638
+ // src/components/VirtualizedSetAggregate.ts
8639
+ var VirtualizedSetAggregate = class extends Entity {
8640
+ _items;
8641
+ _label;
8642
+ _role;
8643
+ _itemRole;
8644
+ _rowHeight;
8645
+ _visibleCapacity;
8646
+ _onActivate;
8647
+ /** First item index currently bound to the pool (the scroll window). */
8648
+ _visibleStart = 0;
8649
+ /** Item id owning the roving tab stop / keyboard focus. */
8650
+ _activeId = null;
8651
+ _selectedId = null;
8652
+ /** One focusable hotspot per visible slot; re-bound, never rebuilt per item. */
8653
+ _hotspots = [];
8654
+ constructor(opts = {}) {
8655
+ super();
8656
+ this._items = opts.items ?? [];
8657
+ this._label = opts.label ?? "Items";
8658
+ this._role = opts.role ?? "list";
8659
+ this._itemRole = opts.itemRole ?? "listitem";
8660
+ this._rowHeight = opts.rowHeight ?? 28;
8661
+ this._visibleCapacity = opts.visibleCapacity;
8662
+ this._onActivate = opts.onActivate;
8663
+ this.width = opts.width ?? 0;
8664
+ this.height = opts.height ?? 0;
8665
+ this.interactive = true;
8666
+ this._syncHotspots();
8667
+ }
8668
+ /** Full item count — the number the container label states. */
8669
+ get itemCount() {
8670
+ return this._items.length;
8671
+ }
8672
+ /** Live pool size (bounded by the visible capacity, never by the item count). */
8673
+ get poolSize() {
8674
+ return this._hotspots.length;
8675
+ }
8676
+ /** First item index currently bound to the pool. */
8677
+ get visibleStart() {
8678
+ return this._visibleStart;
8679
+ }
8680
+ getA11yAttributes() {
8681
+ return {
8682
+ role: this._role,
8683
+ label: `${this._label}, ${this._items.length} items`,
8684
+ pointerEvents: "none"
8685
+ };
8686
+ }
8687
+ render(_renderer) {
8688
+ }
8689
+ /** Axis-aligned hit-test against the container box (the `UIComponent` default). */
8690
+ isPointInside(globalX, globalY) {
8691
+ const local = this.worldToLocal(globalX, globalY);
8692
+ if (!local) return false;
8693
+ return local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height;
8694
+ }
8695
+ /** Replace the item set; the pool re-binds and the label count follows. */
8696
+ setItems(items) {
8697
+ this._items = items;
8698
+ if (this._activeId !== null && !items.some((item) => item.id === this._activeId)) {
8699
+ this._activeId = null;
8700
+ }
8701
+ if (this._selectedId !== null && !items.some((item) => item.id === this._selectedId)) {
8702
+ this._selectedId = null;
8703
+ }
8704
+ this._visibleStart = Math.max(0, Math.min(this._visibleStart, Math.max(0, items.length - 1)));
8705
+ this._syncHotspots();
8706
+ this.scene?.markDirty();
8707
+ }
8708
+ /** Move the pool window to start at `start` (the scroll position). */
8709
+ setVisibleStart(start) {
8710
+ const clamped = Math.max(0, Math.min(start, Math.max(0, this._items.length - 1)));
8711
+ if (clamped === this._visibleStart) return;
8712
+ this._visibleStart = clamped;
8713
+ this._syncHotspots();
8714
+ this.scene?.markDirty();
8715
+ }
8716
+ /**
8717
+ * Keep one hotspot per visible slot, positioned over it. The pool is sized
8718
+ * to the viewport (capacity), each slot re-bound to whatever item currently
8719
+ * occupies it — the `Tree._syncHotspots` shape, minus tree structure.
8720
+ */
8721
+ _syncHotspots() {
8722
+ const capacity = this._visibleCapacity ?? Math.max(1, Math.ceil(this.height / this._rowHeight));
8723
+ const need = Math.max(0, Math.min(capacity, this._items.length - this._visibleStart));
8724
+ while (this._hotspots.length < need) {
8725
+ const hotspot = new AggregateItemHotspot(this);
8726
+ this._hotspots.push(hotspot);
8727
+ this.add(hotspot);
8728
+ }
8729
+ while (this._hotspots.length > need) {
8730
+ const hotspot = this._hotspots.pop();
8731
+ this.scene?.detachA11y(hotspot);
8732
+ this.remove(hotspot);
8733
+ }
8734
+ for (let slot = 0; slot < need; slot++) {
8735
+ const index = this._visibleStart + slot;
8736
+ const hotspot = this._hotspots[slot];
8737
+ hotspot.bind(index, this._items[index]);
8738
+ hotspot.x = 0;
8739
+ hotspot.y = slot * this._rowHeight;
8740
+ hotspot.width = this.width;
8741
+ hotspot.height = this._rowHeight;
8742
+ }
8743
+ }
8744
+ /** Whether `id` owns the roving tab stop: active, else selected, else first. */
8745
+ isTabStop(id) {
8746
+ const anchor = (this._activeId !== null && this._items.some((item) => item.id === this._activeId) ? this._activeId : null) ?? (this._selectedId !== null && this._items.some((item) => item.id === this._selectedId) ? this._selectedId : null) ?? this._items[0]?.id;
8747
+ return id === anchor;
8748
+ }
8749
+ isSelected(id) {
8750
+ return this._selectedId === id;
8751
+ }
8752
+ /** Item role the hotspots announce (kept on the parent: one source of truth). */
8753
+ get itemRole() {
8754
+ return this._itemRole;
8755
+ }
8756
+ /** Activate an item (pointer tap-through or Enter/Space): selects and notifies. */
8757
+ activateItem(id, focusIt = false) {
8758
+ const index = this._items.findIndex((item) => item.id === id);
8759
+ if (index === -1) return;
8760
+ this._activeId = id;
8761
+ this._selectedId = id;
8762
+ this._onActivate?.(this._items[index], index);
8763
+ if (focusIt) this.focusItem(id);
8764
+ this.scene?.markDirty();
8765
+ }
8766
+ /** Move keyboard focus to the hotspot currently bound to `id`, if any. */
8767
+ focusItem(id) {
8768
+ this._hotspots.find((hotspot) => hotspot.itemId === id)?.focus();
8769
+ }
8770
+ /**
8771
+ * Aggregate keyboard model: Up/Down move the active item (scrolling the pool
8772
+ * window so it stays bound), Home/End jump, Enter/Space activate. The active
8773
+ * item is focused as it moves so focus is never stranded on a re-bound slot.
8774
+ */
8775
+ handleItemKey(e, id) {
8776
+ const index = this._items.findIndex((item) => item.id === id);
8777
+ if (index === -1) return;
8778
+ let next = -1;
8779
+ switch (e.key) {
8780
+ case "ArrowDown":
8781
+ next = Math.min(index + 1, this._items.length - 1);
8782
+ break;
8783
+ case "ArrowUp":
8784
+ next = Math.max(index - 1, 0);
8785
+ break;
8786
+ case "Home":
8787
+ next = 0;
8788
+ break;
8789
+ case "End":
8790
+ next = this._items.length - 1;
8791
+ break;
8792
+ case "Enter":
8793
+ case " ":
8794
+ e.preventDefault();
8795
+ this.activateItem(id, true);
8796
+ return;
8797
+ default:
8798
+ return;
8799
+ }
8800
+ e.preventDefault();
8801
+ this._activeId = this._items[next].id;
8802
+ this._ensureVisible(next);
8803
+ this.focusItem(this._activeId);
8804
+ this.scene?.markDirty();
8805
+ }
8806
+ /** Scroll the pool window just enough to keep `index` bound. */
8807
+ _ensureVisible(index) {
8808
+ const capacity = this._visibleCapacity ?? Math.max(1, Math.ceil(this.height / this._rowHeight));
8809
+ if (index < this._visibleStart) this.setVisibleStart(index);
8810
+ else if (index >= this._visibleStart + capacity) {
8811
+ this.setVisibleStart(index - capacity + 1);
8812
+ } else {
8813
+ this._syncHotspots();
8814
+ }
8815
+ }
8816
+ update(_dt, _time) {
8817
+ super.update(_dt, _time);
8818
+ this._syncHotspots();
8819
+ }
8820
+ };
8821
+ var AggregateItemHotspot = class extends Entity {
8822
+ constructor(aggregate) {
8823
+ super();
8824
+ this.aggregate = aggregate;
8825
+ this.interactive = true;
8826
+ this.on("click", () => this.aggregate.activateItem(this.itemId, true));
8827
+ this.on("keydown", (e) => this.aggregate.handleItemKey(e, this.itemId));
8828
+ }
8829
+ aggregate;
8830
+ itemId = "";
8831
+ _index = 0;
8832
+ _label = "";
8833
+ bind(index, item) {
8834
+ this._index = index;
8835
+ this.itemId = item.id;
8836
+ this._label = item.label;
8837
+ }
8838
+ /** The aggregate positions and sizes one pooled hotspot per visible slot. */
8839
+ getLayoutControlledProperties() {
8840
+ return ["x", "y", "width", "height"];
8841
+ }
8842
+ getA11yAttributes() {
8843
+ return {
8844
+ role: this.aggregate.itemRole,
8845
+ label: this._label,
8846
+ // 1-based position within the FULL set, not the pool: without these a
8847
+ // virtualized window announces the pool position as the set position.
8848
+ posInSet: this._index + 1,
8849
+ setSize: this.aggregate.itemCount,
8850
+ selected: this.aggregate.isSelected(this.itemId),
8851
+ // Roving tabindex: only the active item is a tab stop; arrows move within.
8852
+ tabIndex: this.aggregate.isTabStop(this.itemId) ? 0 : -1,
8853
+ // The owner keeps the pointer (canvas handling or an underlying
8854
+ // selectable-text mirror); this hotspot exists for semantics + keyboard
8855
+ // focus, so it opts out of hit-testing. Keyboard focus and
8856
+ // AT-synthesized `click` still work under `pointer-events:none`.
8857
+ pointerEvents: "none"
8858
+ };
8859
+ }
8860
+ render() {
8861
+ }
8862
+ /** Axis-aligned hit-test against the hotspot slot (the `UIComponent` default). */
8863
+ isPointInside(globalX, globalY) {
8864
+ const local = this.worldToLocal(globalX, globalY);
8865
+ if (!local) return false;
8866
+ return local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height;
8867
+ }
8868
+ };
8869
+
8036
8870
  // src/components/GridTextEntity.ts
8037
8871
  var GridTextEntity = class extends Entity {
8038
8872
  fontSize;
@@ -8728,6 +9562,8 @@ export {
8728
9562
  Circle,
8729
9563
  ComputeParticleEntity,
8730
9564
  DEFAULT_CONTENT_SEMANTIC_BUDGET,
9565
+ DEFAULT_PROJECTION_CAPABILITIES,
9566
+ DEFAULT_SEMANTIC_PROJECTION_POLICY,
8731
9567
  DOMPortalEntity,
8732
9568
  Entity,
8733
9569
  GlyphRasterAtlas,
@@ -8744,6 +9580,8 @@ export {
8744
9580
  PARTICLE_OFFSET_VELOCITY_X,
8745
9581
  PARTICLE_OFFSET_VELOCITY_Y,
8746
9582
  PARTICLE_STRIDE_FLOATS,
9583
+ PROJECTION_AUTO_DOM_BUDGET,
9584
+ PROJECTION_AUTO_HYSTERESIS_FRAMES,
8747
9585
  REDUCED_MOTION_FPS,
8748
9586
  Rect,
8749
9587
  SCENE_OPTION_KEYS,
@@ -8753,14 +9591,20 @@ export {
8753
9591
  SplineEntity,
8754
9592
  TextEntity,
8755
9593
  TextRasterCache,
9594
+ UNKNOWN_PROJECTION_CAPABILITY,
8756
9595
  VECTO_USER_TIMING,
8757
9596
  VectoJSEvent,
9597
+ VirtualizedSetAggregate,
8758
9598
  WebGPUParticleSystemManager,
8759
9599
  beginVectoUserTiming,
8760
9600
  contentLineInHint,
8761
9601
  createWebGLPointRenderer,
9602
+ describeHTMLInCanvasSupport,
8762
9603
  endVectoUserTiming,
9604
+ getSemanticProjectionCapabilities,
9605
+ hysteresisVote,
8763
9606
  installRendererDevTraps,
9607
+ isDeferrableSemanticNode,
8764
9608
  isRendererDevMode,
8765
9609
  isSafeUrl,
8766
9610
  loadSpline,
@@ -8769,6 +9613,8 @@ export {
8769
9613
  ownsKeyboard,
8770
9614
  parseColorToRGBA,
8771
9615
  polySegmentToBezier,
9616
+ resolveProjection,
8772
9617
  sanitizeUrl,
8773
- setRendererDevMode
9618
+ setRendererDevMode,
9619
+ supportsHTMLInCanvas
8774
9620
  };