@vectojs/core 1.39.0 → 1.40.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
@@ -11,14 +11,14 @@ import {
11
11
  parseColorToRGBA,
12
12
  sanitizeUrl,
13
13
  setRendererDevMode
14
- } from "./chunk-RUM2KEDI.mjs";
14
+ } from "./chunk-AISSDI6U.mjs";
15
15
  import {
16
16
  Entity,
17
17
  MSDFTextEntity,
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;
@@ -1069,8 +1069,11 @@ var CanvasGeometry = class {
1069
1069
  /** Effective device pixel ratio, matching CanvasRenderer: real DPR clamped to
1070
1070
  * `maxDPR` when set. */
1071
1071
  effectiveDPR(maxDPR) {
1072
- const real = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
1073
- return maxDPR !== void 0 ? Math.min(real, maxDPR) : real;
1072
+ const raw = typeof window !== "undefined" ? window.devicePixelRatio : 1;
1073
+ const real = Number.isFinite(raw) && raw > 0 ? raw : 1;
1074
+ if (maxDPR === void 0) return real;
1075
+ if (!Number.isFinite(maxDPR) || maxDPR <= 0) return real;
1076
+ return Math.min(real, maxDPR);
1074
1077
  }
1075
1078
  /** Size the WebGPU particle canvas: backing store at logical × DPR, CSS box at
1076
1079
  * the logical size. Sizing the backing store in logical px (the old
@@ -1595,6 +1598,62 @@ var HitTester = class {
1595
1598
  findEntityAt(x, y, frame, width, height) {
1596
1599
  const overlayHit = this.findHitRecursively(this.overlayRoot, x, y);
1597
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) {
1598
1657
  if (this.backends.hit && this.ensureHitGrid(frame, width, height)) {
1599
1658
  return this.findEntityAtWasm(x, y);
1600
1659
  }
@@ -3498,6 +3557,207 @@ function normalizeChord(input) {
3498
3557
  return ordered.join("+");
3499
3558
  }
3500
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
+
3501
3761
  // src/tree/Scene.ts
3502
3762
  var RANGE_VALUE_ROLES = /* @__PURE__ */ new Set(["slider", "spinbutton", "progressbar", "scrollbar", "meter"]);
3503
3763
  var INTERACTIVE_A11Y_ROLES = /* @__PURE__ */ new Set([
@@ -3521,7 +3781,8 @@ var KEYBOARD_OWNING_ROLES = /* @__PURE__ */ new Set([
3521
3781
  ]);
3522
3782
  function ownsKeyboard(el) {
3523
3783
  if (!el) return false;
3524
- if (el === document.body || el === document.documentElement) return false;
3784
+ if (typeof document !== "undefined" && (el === document.body || el === document.documentElement))
3785
+ return false;
3525
3786
  if (el.hasAttribute("data-vecto-a11y-root")) return false;
3526
3787
  const tag = el.tagName;
3527
3788
  if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
@@ -3561,6 +3822,7 @@ var SCENE_OPTION_KEYS = [
3561
3822
  "renderer",
3562
3823
  "renderMode",
3563
3824
  "respectReducedMotion",
3825
+ "semanticProjectionPolicy",
3564
3826
  "userTiming"
3565
3827
  ];
3566
3828
  var SCENE_FIELD_NOT_OPTION = {
@@ -4058,6 +4320,26 @@ var Scene = class _Scene {
4058
4320
  * frame. See {@link SceneOptions.a11ySyncInterval}.
4059
4321
  */
4060
4322
  a11ySyncInterval = 0;
4323
+ /**
4324
+ * Per-node semantic projection policy (RFC3 §5, CTX-0599). Consulted once
4325
+ * per node by {@link shouldProjectA11y}; the default projects everything
4326
+ * the legacy predicate projects, so reassigning nothing changes nothing.
4327
+ * See {@link SceneOptions.semanticProjectionPolicy}.
4328
+ */
4329
+ semanticProjectionPolicy = DEFAULT_SEMANTIC_PROJECTION_POLICY;
4330
+ /**
4331
+ * Consecutive syncs an `'auto'`-policy node must keep voting for the other
4332
+ * backend before its sticky resolution flips (RFC4 §3 rule 3, CTX-0601).
4333
+ * Documented tunable — see {@link PROJECTION_AUTO_HYSTERESIS_FRAMES}.
4334
+ */
4335
+ projectionHysteresisFrames = PROJECTION_AUTO_HYSTERESIS_FRAMES;
4336
+ /**
4337
+ * Maximum `'auto'`-resolved DOM residents per scene per frame (RFC4 §4
4338
+ * particle-row backstop: bulk-count nodes stay canvas). Explicit `'dom'`
4339
+ * requests bypass it. Documented tunable — see
4340
+ * {@link PROJECTION_AUTO_DOM_BUDGET}.
4341
+ */
4342
+ projectionAutoDomBudget = PROJECTION_AUTO_DOM_BUDGET;
4061
4343
  /** Timestamp of the last a11y sync, for throttling. */
4062
4344
  lastA11ySync = -Infinity;
4063
4345
  /** True if we skipped an a11y sync during animation and need to sync when at rest. */
@@ -4178,6 +4460,8 @@ var Scene = class _Scene {
4178
4460
  * this an embedded canvas stayed at its initial size forever. */
4179
4461
  canvasResizeObserver = null;
4180
4462
  dprChangeHandler = null;
4463
+ dprPollInterval = null;
4464
+ lastDpr = 1;
4181
4465
  // --- domain: a11y-projection — focus, overlay geometry, DOM ordering, portals ---
4182
4466
  focusedA11yElement = null;
4183
4467
  /**
@@ -4286,6 +4570,30 @@ var Scene = class _Scene {
4286
4570
  activePortalsThisFrame = /* @__PURE__ */ new Set();
4287
4571
  activePortalsPrevFrame = /* @__PURE__ */ new Set();
4288
4572
  portalEntities = /* @__PURE__ */ new Map();
4573
+ /**
4574
+ * Generic projection backends (RFC1 §5 vocabulary, `tree/scene/ProjectionBackend.ts`).
4575
+ * Render-independent: the interface exchanges only node identity, world
4576
+ * matrix, and lifecycle calls, so registering a DOM backend adds no
4577
+ * `HTMLElement` surface to core. `@vectojs/dom` owns the only implementation.
4578
+ */
4579
+ projectionBackends = [];
4580
+ /** Per-frame seen-sets driving {@link pruneProjectionBackends} (portal-precedent). */
4581
+ domSeenPrevFrame = /* @__PURE__ */ new Set();
4582
+ domSeenThisFrame = /* @__PURE__ */ new Set();
4583
+ domSeenNodes = /* @__PURE__ */ new Map();
4584
+ /**
4585
+ * Negotiation state (RFC4 §3, CTX-0601): last resolution per node (the
4586
+ * per-scene queryable surface behind {@link getProjectionResolutions}),
4587
+ * hysteresis votes, per-kind capability overrides, and explicit gesture
4588
+ * pins. Plain data only — no `HTMLElement` surface in core.
4589
+ */
4590
+ projectionResolutions = /* @__PURE__ */ new Map();
4591
+ projectionHysteresis = /* @__PURE__ */ new Map();
4592
+ projectionCapabilityOverrides = /* @__PURE__ */ new Map();
4593
+ projectionGesturePins = /* @__PURE__ */ new Map();
4594
+ /** Frame id the `'auto'` DOM budget count belongs to (bulk backstop). */
4595
+ projectionBudgetFrame = -1;
4596
+ projectionDomAutoCount = 0;
4289
4597
  renderOrderCounter = 0;
4290
4598
  // --- domain: render-scheduler — authoritative frame counter ---
4291
4599
  /**
@@ -4932,8 +5240,13 @@ var Scene = class _Scene {
4932
5240
  this.width = styleWidth ?? (canvas.width || canvas.clientWidth || 0);
4933
5241
  this.height = styleHeight ?? (canvas.height || canvas.clientHeight || 0);
4934
5242
  } else {
4935
- this.width = typeof window !== "undefined" ? window.innerWidth : canvas.clientWidth || canvas.width || 800;
4936
- this.height = typeof window !== "undefined" ? window.innerHeight : canvas.clientHeight || canvas.height || 600;
5243
+ if (typeof canvas.isConnected === "boolean" && !canvas.isConnected) {
5244
+ this.width = 0;
5245
+ this.height = 0;
5246
+ } else {
5247
+ this.width = typeof window !== "undefined" ? window.innerWidth : canvas.clientWidth || canvas.width || 800;
5248
+ this.height = typeof window !== "undefined" ? window.innerHeight : canvas.clientHeight || canvas.height || 600;
5249
+ }
4937
5250
  }
4938
5251
  const globalProcess = typeof globalThis !== "undefined" ? globalThis.process : void 0;
4939
5252
  const isTest = globalProcess && (globalProcess.env?.NODE_ENV === "test" || globalProcess.env?.VITEST === "true");
@@ -4944,6 +5257,9 @@ var Scene = class _Scene {
4944
5257
  this.phases.userTiming = options.userTiming ?? false;
4945
5258
  this.particleBackend = options.particleBackend ?? "auto";
4946
5259
  this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
5260
+ this.semanticProjectionPolicy = options.semanticProjectionPolicy ?? DEFAULT_SEMANTIC_PROJECTION_POLICY;
5261
+ this.projectionHysteresisFrames = options.projectionHysteresisFrames ?? PROJECTION_AUTO_HYSTERESIS_FRAMES;
5262
+ this.projectionAutoDomBudget = options.projectionAutoDomBudget ?? PROJECTION_AUTO_DOM_BUDGET;
4947
5263
  this.contentProjectionEnabled = options.contentProjection ?? true;
4948
5264
  this.contentProjectionMargin = options.contentProjectionMargin;
4949
5265
  this.contentSemanticMargin = options.contentSemanticMargin;
@@ -5165,16 +5481,52 @@ var Scene = class _Scene {
5165
5481
  if (this.dprMediaQuery && this.dprChangeHandler) {
5166
5482
  this.dprMediaQuery.removeEventListener?.("change", this.dprChangeHandler);
5167
5483
  }
5168
- const dpr = window.devicePixelRatio || 1;
5484
+ const raw = window.devicePixelRatio;
5485
+ const dpr = Number.isFinite(raw) && raw > 0 ? raw : 1;
5169
5486
  const query = window.matchMedia(`(resolution: ${dpr}dppx)`);
5170
5487
  const handler = () => {
5171
- this.resize(this.width, this.height);
5172
- if (this.renderer.isContextLost?.() !== true) this.render(this.renderer);
5488
+ const curRaw = window.devicePixelRatio;
5489
+ const cur = Number.isFinite(curRaw) && curRaw > 0 ? curRaw : 1;
5490
+ if (Math.abs(cur - dpr) <= 1e-3) {
5491
+ this.watchDevicePixelRatio();
5492
+ return;
5493
+ }
5494
+ this.lastDpr = cur;
5495
+ try {
5496
+ this.resize(this.width, this.height);
5497
+ } catch (err) {
5498
+ console.warn("[VectoJS] DPR resize failed", err);
5499
+ }
5500
+ try {
5501
+ if (this.renderer.isContextLost?.() !== true) this.render(this.renderer);
5502
+ } catch (err) {
5503
+ console.warn("[VectoJS] DPR repaint failed", err);
5504
+ }
5173
5505
  this.watchDevicePixelRatio();
5174
5506
  };
5175
5507
  query.addEventListener?.("change", handler);
5176
5508
  this.dprMediaQuery = query;
5177
5509
  this.dprChangeHandler = handler;
5510
+ this.lastDpr = dpr;
5511
+ if (this.dprPollInterval === null && typeof setInterval === "function") {
5512
+ this.dprPollInterval = setInterval(() => {
5513
+ const rawPoll = window.devicePixelRatio;
5514
+ const curPoll = Number.isFinite(rawPoll) && rawPoll > 0 ? rawPoll : 1;
5515
+ if (Math.abs(curPoll - this.lastDpr) <= 1e-3) return;
5516
+ this.lastDpr = curPoll;
5517
+ try {
5518
+ this.resize(this.width, this.height);
5519
+ } catch (err) {
5520
+ console.warn("[VectoJS] DPR poll resize failed", err);
5521
+ }
5522
+ try {
5523
+ if (this.renderer.isContextLost?.() !== true) this.render(this.renderer);
5524
+ } catch (err) {
5525
+ console.warn("[VectoJS] DPR poll repaint failed", err);
5526
+ }
5527
+ this.watchDevicePixelRatio();
5528
+ }, 1e3);
5529
+ }
5178
5530
  }
5179
5531
  /**
5180
5532
  * Recover the WebGL point layer from a GPU context loss (driver TDR reset,
@@ -5208,6 +5560,35 @@ var Scene = class _Scene {
5208
5560
  gl.addEventListener("webglcontextlost", this.glContextLostHandler);
5209
5561
  gl.addEventListener("webglcontextrestored", this.glContextRestoredHandler);
5210
5562
  }
5563
+ /**
5564
+ * One-shot adoption of the window viewport once an initially-detached canvas
5565
+ * gains layout (#817). A full-window scene constructed before its canvas is
5566
+ * attached starts at 0×0, and no window `resize` fires on attachment — so
5567
+ * without this latch the scene would stay unsized forever. The first nonzero
5568
+ * layout box adopts `window.innerWidth/innerHeight` (the same sizing the
5569
+ * window resize handler applies, keeping the full-window contract), then the
5570
+ * observer disconnects: steady-state sizing stays exactly as if the canvas
5571
+ * had been attached before construction. An explicit user `resize()` between
5572
+ * construction and attachment is respected and skips adoption.
5573
+ *
5574
+ * No-op when the scene already has a size (attached at construction) or when
5575
+ * `ResizeObserver` is unavailable (the caller then drives sizing explicitly).
5576
+ */
5577
+ armAttachmentViewportLatch() {
5578
+ if (this.width > 0 && this.height > 0) return;
5579
+ if (typeof ResizeObserver === "undefined") return;
5580
+ const observer = new ResizeObserver((entries) => {
5581
+ const box = entries[0]?.contentRect;
5582
+ if (!box || !(box.width > 0) || !(box.height > 0)) return;
5583
+ observer.disconnect();
5584
+ this.canvasResizeObserver = null;
5585
+ if (this.width === 0 || this.height === 0) {
5586
+ this.resize(window.innerWidth, window.innerHeight);
5587
+ }
5588
+ });
5589
+ this.canvasResizeObserver = observer;
5590
+ observer.observe(this.canvas);
5591
+ }
5211
5592
  // --- domain: scene-facade — renderer accessor ---
5212
5593
  /**
5213
5594
  * Expose the underlying {@link IRenderer} for advanced direct-draw operations.
@@ -5230,6 +5611,37 @@ var Scene = class _Scene {
5230
5611
  findEntityAt(x, y) {
5231
5612
  return this._hitTester.findEntityAt(x, y, this.currentFrame, this.width, this.height);
5232
5613
  }
5614
+ // --- domain: hit-test — merged HitResult query (RFC5 §2, CTX-0600) ---
5615
+ /**
5616
+ * The merged hit list for a scene-space point: the canvas spatial test
5617
+ * plus caller-observed DOM-native candidates (mirror / portal /
5618
+ * `dom-visual` extension point for CTX-0598) as ONE ordered candidate
5619
+ * list (`HitResult`, overlay order authoritative).
5620
+ *
5621
+ * Query API alongside {@link findEntityAt} — no dispatch change:
5622
+ * `findEntityAt` keeps its single topmost answer byte-for-byte, keyboard
5623
+ * and AT flows are untouched. For browser pointer coordinates use
5624
+ * {@link findHitsAtClient}, which maps through {@link clientToScene} first.
5625
+ */
5626
+ findHitsAt(x, y, domCandidates = []) {
5627
+ return this._hitTester.findHitsAt(
5628
+ x,
5629
+ y,
5630
+ this.currentFrame,
5631
+ this.width,
5632
+ this.height,
5633
+ domCandidates
5634
+ );
5635
+ }
5636
+ /**
5637
+ * {@link findHitsAt} for browser viewport coordinates: maps through
5638
+ * {@link clientToScene} so coordinates enter in scene space (RFC5 §2
5639
+ * rule 3) and backend attribution follows.
5640
+ */
5641
+ findHitsAtClient(clientX, clientY, domCandidates = []) {
5642
+ const point = this.clientToScene(clientX, clientY);
5643
+ return this.findHitsAt(point.x, point.y, domCandidates);
5644
+ }
5233
5645
  // --- domain: hit-test — client-to-scene mapping ---
5234
5646
  /** Convert browser viewport coordinates into this Scene's logical coordinates. */
5235
5647
  clientToScene(clientX, clientY) {
@@ -5270,6 +5682,15 @@ var Scene = class _Scene {
5270
5682
  this.activePortalsThisFrame.delete(node.id);
5271
5683
  this.activePortalsPrevFrame.delete(node.id);
5272
5684
  }
5685
+ if (node.domPolicy === "dom" || node.domResident) {
5686
+ for (const backend of this.projectionBackends) backend.unmount(node);
5687
+ this.domSeenThisFrame.delete(node.id);
5688
+ this.domSeenPrevFrame.delete(node.id);
5689
+ this.domSeenNodes.delete(node.id);
5690
+ }
5691
+ this.projectionResolutions.delete(node.id);
5692
+ this.projectionHysteresis.delete(node.id);
5693
+ this.projectionGesturePins.delete(node.id);
5273
5694
  const contentEl = this.contentElements.get(node.id);
5274
5695
  if (contentEl) {
5275
5696
  this._contentProjection.clearGridState(node.id, contentEl);
@@ -5393,6 +5814,10 @@ var Scene = class _Scene {
5393
5814
  this.dprMediaQuery = null;
5394
5815
  this.dprChangeHandler = null;
5395
5816
  }
5817
+ if (this.dprPollInterval !== null) {
5818
+ clearInterval(this.dprPollInterval);
5819
+ this.dprPollInterval = null;
5820
+ }
5396
5821
  if (this.forcedColorsQuery && this.forcedColorsChangeHandler) {
5397
5822
  this.forcedColorsQuery.removeEventListener?.("change", this.forcedColorsChangeHandler);
5398
5823
  this.forcedColorsQuery = null;
@@ -5466,6 +5891,7 @@ var Scene = class _Scene {
5466
5891
  setupEvents() {
5467
5892
  if (typeof window !== "undefined" && !this.disableWindowResize) {
5468
5893
  window.addEventListener("resize", this.resizeHandler);
5894
+ this.armAttachmentViewportLatch();
5469
5895
  } else if (this.disableWindowResize && typeof ResizeObserver !== "undefined" && this.canvas && typeof this.canvas.getBoundingClientRect === "function") {
5470
5896
  this.canvasResizeObserver = new ResizeObserver((entries) => {
5471
5897
  const entry = entries[0];
@@ -5874,14 +6300,47 @@ var Scene = class _Scene {
5874
6300
  shouldProjectA11y(node) {
5875
6301
  if (!node.interactive) return false;
5876
6302
  if (!(node.width > 0 || node.a11yFullViewport)) return false;
6303
+ if (node.domPolicy === "dom" || this.resolveProjectionFor(node) === "dom") return false;
5877
6304
  switch (node.a11yProjection) {
5878
6305
  case "never":
5879
6306
  return false;
5880
6307
  case "onDemand":
5881
- return this.a11yEngaged(node);
6308
+ return this.a11yEngaged(node) && this.resolveSemanticProjection(node);
5882
6309
  default:
5883
- return true;
6310
+ return this.resolveSemanticProjection(node);
6311
+ }
6312
+ }
6313
+ /**
6314
+ * Policy half of {@link shouldProjectA11y} (RFC3 §5, CTX-0599).
6315
+ *
6316
+ * The legacy gates above (interactive, box, `a11yProjection` engagement)
6317
+ * are unchanged; this only maps a {@link SemanticProjectionPolicy} decision
6318
+ * onto project/suppress. Out of the box the default policy returns
6319
+ * `'project'`, so behaviour is identical to having no policy.
6320
+ *
6321
+ * `'defer-to-browser'` always falls back to projection today: no deferral
6322
+ * backend exists (`supportsHTMLInCanvas()` is `false`), so deferring would
6323
+ * silently drop semantics — the exact failure RFC §3 rules out. When a
6324
+ * backend lands, only allow-listed plain display text
6325
+ * ({@link isDeferrableSemanticNode}; never controls) may actually defer. A
6326
+ * throwing policy likewise falls back to projection: a policy must never be
6327
+ * able to drop semantics by accident.
6328
+ */
6329
+ resolveSemanticProjection(node) {
6330
+ const policy = this.semanticProjectionPolicy ?? DEFAULT_SEMANTIC_PROJECTION_POLICY;
6331
+ let decision;
6332
+ try {
6333
+ decision = policy.choose(node, getSemanticProjectionCapabilities(), {
6334
+ hasDOM: this.a11yRoot !== null
6335
+ });
6336
+ } catch {
6337
+ decision = "project";
6338
+ }
6339
+ if (decision === "never") return false;
6340
+ if (decision === "defer-to-browser" && isDeferrableSemanticNode(node) && supportsHTMLInCanvas()) {
6341
+ return false;
5884
6342
  }
6343
+ return true;
5885
6344
  }
5886
6345
  /**
5887
6346
  * Whether an `a11yProjection: 'onDemand'` entity is currently engaged enough to
@@ -6051,14 +6510,17 @@ var Scene = class _Scene {
6051
6510
  if (e.target === capEl && typeof capEl.setPointerCapture === "function") {
6052
6511
  capEl.setPointerCapture(e.pointerId);
6053
6512
  }
6513
+ this.pinProjectionForGesture(node);
6054
6514
  node.dispatchEvent(new VectoJSEvent("pointerdown", node, e));
6055
6515
  });
6056
6516
  el.addEventListener("pointerup", (e) => {
6057
6517
  releasePointer(e);
6518
+ this.unpinProjectionForGesture(node);
6058
6519
  node.dispatchEvent(new VectoJSEvent("pointerup", node, e));
6059
6520
  });
6060
6521
  el.addEventListener("pointercancel", (e) => {
6061
6522
  releasePointer(e);
6523
+ this.unpinProjectionForGesture(node);
6062
6524
  node.dispatchEvent(new VectoJSEvent("pointercancel", node, e));
6063
6525
  });
6064
6526
  el.addEventListener(
@@ -7019,6 +7481,197 @@ var Scene = class _Scene {
7019
7481
  this.activePortalsPrevFrame = new Set(this.activePortalsThisFrame);
7020
7482
  this.activePortalsThisFrame.clear();
7021
7483
  }
7484
+ /**
7485
+ * Register a generic projection backend (RFC1 §5, RFC2 P1 CTX-0598).
7486
+ * Registration is idempotent per backend instance. Core drives
7487
+ * mount/update/unmount from the render walk; the backend owns all
7488
+ * medium-specific state.
7489
+ */
7490
+ addProjectionBackend(backend) {
7491
+ if (!this.projectionBackends.includes(backend)) this.projectionBackends.push(backend);
7492
+ return this;
7493
+ }
7494
+ /**
7495
+ * Remove a previously registered projection backend by instance or
7496
+ * {@link ProjectionBackendKind | kind}. Removing does not unmount resident
7497
+ * nodes — unmount the backend first if teardown order matters.
7498
+ */
7499
+ removeProjectionBackend(backend) {
7500
+ this.projectionBackends = this.projectionBackends.filter(
7501
+ (b) => typeof backend === "string" ? b.kind !== backend : b !== backend
7502
+ );
7503
+ return this;
7504
+ }
7505
+ /**
7506
+ * Scene-level projection capabilities (RFC4 §3 rule 2, CTX-0601).
7507
+ *
7508
+ * Plain-data feature detection in the shape of the proposed
7509
+ * `scene.inputCapabilities` (input-dispatch-contract-v2 §4): apps and
7510
+ * devtools query where a node _would_ materialize and why, without touching
7511
+ * any backend. Additive only — no existing surface moves.
7512
+ */
7513
+ get projectionCapabilities() {
7514
+ return {
7515
+ hasDOM: typeof document !== "undefined",
7516
+ domBackendMounted: this.projectionBackends.some((b) => b.kind === "dom"),
7517
+ backends: this.projectionBackends.map((b) => b.kind),
7518
+ autoHysteresisFrames: this.projectionHysteresisFrames,
7519
+ autoDomBudget: this.projectionAutoDomBudget
7520
+ };
7521
+ }
7522
+ /**
7523
+ * Register (or replace) the Capability Matrix row for one `domKind`
7524
+ * (RFC4 §4, CTX-0601). Custom kinds start at the unknown-kind default
7525
+ * (`unsupported-kind` → canvas); registering a row is how a custom backend
7526
+ * kind opts into `'auto'` resolution and how tests drive oscillating costs.
7527
+ */
7528
+ registerProjectionCapability(domKind, row) {
7529
+ this.projectionCapabilityOverrides.set(domKind, row);
7530
+ return this;
7531
+ }
7532
+ /** Matrix row for a node's `domKind` (override wins, unknown → canvas). */
7533
+ getProjectionCapability(node) {
7534
+ return this.projectionCapabilityOverrides.get(node.domKind) ?? DEFAULT_PROJECTION_CAPABILITIES[node.domKind] ?? UNKNOWN_PROJECTION_CAPABILITY;
7535
+ }
7536
+ /**
7537
+ * Pin a node's backend for the duration of an active gesture (RFC4 §5 +
7538
+ * input-dispatch-contract-v2 §4 gesture stickiness: no mid-gesture handoff).
7539
+ * The a11y-mirror pointerdown/up/cancel listeners maintain this; DOM-side
7540
+ * gestures are consulted through
7541
+ * {@link ProjectionBackend.hasActiveGesture | backend.hasActiveGesture}.
7542
+ * Refcounted so multi-pointer gestures on one node unpin exactly once.
7543
+ */
7544
+ pinProjectionForGesture(node) {
7545
+ this.projectionGesturePins.set(node.id, (this.projectionGesturePins.get(node.id) ?? 0) + 1);
7546
+ }
7547
+ /** Release one gesture pin taken by {@link pinProjectionForGesture}. */
7548
+ unpinProjectionForGesture(node) {
7549
+ const count = (this.projectionGesturePins.get(node.id) ?? 0) - 1;
7550
+ if (count <= 0) this.projectionGesturePins.delete(node.id);
7551
+ else this.projectionGesturePins.set(node.id, count);
7552
+ }
7553
+ /** Whether `node` currently owns an active gesture on a core mirror. */
7554
+ isProjectionPinned(node) {
7555
+ return (this.projectionGesturePins.get(node.id) ?? 0) > 0;
7556
+ }
7557
+ /**
7558
+ * Last negotiation for one node, if it was ever resolved on this scene.
7559
+ * Accepts the node or its id; returns the stored record (not a copy — treat
7560
+ * as read-only).
7561
+ */
7562
+ getProjectionResolution(node) {
7563
+ return this.projectionResolutions.get(typeof node === "string" ? node : node.id);
7564
+ }
7565
+ /** Every node's last negotiation, in first-resolution order. */
7566
+ getProjectionResolutions() {
7567
+ return [...this.projectionResolutions.values()];
7568
+ }
7569
+ /**
7570
+ * Resolve one node's `domPolicy` to its effective backend (RFC4 §3, CTX-0601).
7571
+ *
7572
+ * Memoized per main frame (`currentFrame`, bumped once per authoritative
7573
+ * render): the render walk and the later a11y sync must agree within a
7574
+ * frame, or a node could lose its mirror the same frame it gains an element.
7575
+ * `'auto'` applies, in order: no-DOM short-circuit → pure negotiation →
7576
+ * gesture/focus hard bars (a pinned node keeps its current backend, never
7577
+ * flips mid-gesture, focus is preserved-or-moved never dropped to `body` per
7578
+ * input-dispatch-contract-v2 §4) → hysteresis stickiness → bulk budget.
7579
+ * Every outcome is recorded on the per-scene queryable surface
7580
+ * ({@link getProjectionResolutions}) with its reason — fallbacks are
7581
+ * reported, not silent (§3 rule 2).
7582
+ */
7583
+ resolveProjectionFor(node) {
7584
+ const want = node.domPolicy;
7585
+ if (this.projectionBackends.length === 0 && want === "canvas") return "canvas";
7586
+ const memo = this.projectionHysteresis.get(node.id);
7587
+ if (memo && memo.lastFrame === this.currentFrame) return memo.resolved;
7588
+ const hasDOM = typeof document !== "undefined";
7589
+ const domBackendMounted = this.projectionBackends.some((b) => b.kind === "dom");
7590
+ const outcome = resolveProjection(want, this.getProjectionCapability(node), {
7591
+ hasDOM,
7592
+ domBackendMounted
7593
+ });
7594
+ let resolved = outcome.resolved;
7595
+ let reason = outcome.reason;
7596
+ if (want === "auto" && memo !== void 0) {
7597
+ if (resolved !== memo.resolved) {
7598
+ const gesturePinned = this.isProjectionPinned(node) || this.projectionBackends.some((b) => b.hasActiveGesture?.(node) === true);
7599
+ if (gesturePinned) {
7600
+ resolved = memo.resolved;
7601
+ reason = "active-gesture";
7602
+ } else if (this.ownsProjectionFocus(node)) {
7603
+ resolved = memo.resolved;
7604
+ reason = "focus-pinned";
7605
+ } else {
7606
+ const vote = hysteresisVote(
7607
+ memo.resolved,
7608
+ resolved,
7609
+ memo.consecutive,
7610
+ this.projectionHysteresisFrames
7611
+ );
7612
+ resolved = vote.resolved;
7613
+ memo.consecutive = vote.consecutive;
7614
+ reason = vote.flipped ? reason : "hysteresis";
7615
+ }
7616
+ } else {
7617
+ memo.consecutive = 0;
7618
+ }
7619
+ }
7620
+ if (want === "auto" && resolved === "dom") {
7621
+ if (this.projectionBudgetFrame !== this.currentFrame) {
7622
+ this.projectionBudgetFrame = this.currentFrame;
7623
+ this.projectionDomAutoCount = 0;
7624
+ }
7625
+ this.projectionDomAutoCount += 1;
7626
+ if (this.projectionDomAutoCount > this.projectionAutoDomBudget) {
7627
+ resolved = "canvas";
7628
+ reason = "bulk-budget";
7629
+ }
7630
+ }
7631
+ this.projectionHysteresis.set(node.id, {
7632
+ resolved,
7633
+ consecutive: memo?.consecutive ?? 0,
7634
+ lastFrame: this.currentFrame
7635
+ });
7636
+ const prev = this.projectionResolutions.get(node.id);
7637
+ if (!prev || prev.want !== want || prev.resolved !== resolved || prev.reason !== reason) {
7638
+ this.projectionResolutions.set(node.id, { nodeId: node.id, want, resolved, reason });
7639
+ }
7640
+ return resolved;
7641
+ }
7642
+ /**
7643
+ * Whether `node`'s a11y mirror currently owns browser focus (RFC4 §5 focus
7644
+ * rule, input-dispatch-contract-v2 §4: focus stays DOM-based; flipping
7645
+ * `projection` must preserve or deliberately move focus, never drop it to
7646
+ * `body`). A focused node keeps its backend until blur — the actual move, if
7647
+ * any, goes through the existing sentinels (`preserveFocusOnRemoval`, the
7648
+ * DOM backend's own fallback), never a bare removal.
7649
+ */
7650
+ ownsProjectionFocus(node) {
7651
+ if (typeof document === "undefined") return false;
7652
+ const mirror = this.a11yElements.get(node.id);
7653
+ if (!mirror || mirror !== this.focusedA11yElement) return false;
7654
+ return document.activeElement === mirror;
7655
+ }
7656
+ /**
7657
+ * RFC2 P1 (CTX-0598): unmount DOM-projection residents the walk did not see
7658
+ * this frame (viewport-culled, removed without `remove()`, policy-flipped).
7659
+ * Mirrors {@link reconcilePortals} above; unmount is idempotent per the
7660
+ * `ProjectionBackend` contract so notifying every backend is safe.
7661
+ */
7662
+ pruneProjectionBackends() {
7663
+ for (const id of this.domSeenPrevFrame) {
7664
+ if (!this.domSeenThisFrame.has(id)) {
7665
+ const node = this.domSeenNodes.get(id);
7666
+ if (node) {
7667
+ for (const backend of this.projectionBackends) backend.unmount(node);
7668
+ this.domSeenNodes.delete(id);
7669
+ }
7670
+ }
7671
+ }
7672
+ this.domSeenPrevFrame = new Set(this.domSeenThisFrame);
7673
+ this.domSeenThisFrame.clear();
7674
+ }
7022
7675
  // --- domain: render-scheduler — the loop and the render walk ---
7023
7676
  /**
7024
7677
  * The frame-rate cap actually in effect: the explicit {@link maxFPS}, further
@@ -7096,6 +7749,12 @@ var Scene = class _Scene {
7096
7749
  /**
7097
7750
  * Render the entire scene graph onto the specified renderer.
7098
7751
  *
7752
+ * This is the `CanvasProjection` row of {@link ProjectionBackend}:
7753
+ * the per-frame materialization of scene semantics into pixels through the
7754
+ * backend-agnostic `IRenderer` contract. (The `A11yProjection` /
7755
+ * `ContentProjection` rows run on the same cadence via {@link Scene.syncA11y}
7756
+ * below; the `DOMProjection` row does not exist yet — RFC2/CTX-0598.)
7757
+ *
7099
7758
  * Main-frame causal order is a correctness contract:
7100
7759
  *
7101
7760
  * 1. Browser/input callbacks finish before the scheduled frame begins.
@@ -7388,6 +8047,16 @@ var Scene = class _Scene {
7388
8047
  visible = maxX >= 0 && minX <= vw && maxY >= 0 && minY <= vh;
7389
8048
  }
7390
8049
  if (!visible && node.children.length === 0) return;
8050
+ if (isMainRenderer && this.projectionBackends.length > 0 && this.resolveProjectionFor(node) === "dom" && visible) {
8051
+ for (const backend of this.projectionBackends) {
8052
+ backend.update(node, { a, b, c, d, e: te, f: tf });
8053
+ }
8054
+ if (node.domResident) {
8055
+ this.domSeenThisFrame.add(node.id);
8056
+ this.domSeenNodes.set(node.id, node);
8057
+ if (node.children.length === 0) return;
8058
+ }
8059
+ }
7391
8060
  if (node.children.length === 0 && node.scaleX === node.scaleY) {
7392
8061
  const bc = node.getBatchCircle();
7393
8062
  if (bc) {
@@ -7517,6 +8186,7 @@ var Scene = class _Scene {
7517
8186
  this.frameHadAnimation = walkHadAnimation;
7518
8187
  this.frameHadInteractive = walkHadInteractive;
7519
8188
  this.reconcilePortals();
8189
+ this.pruneProjectionBackends();
7520
8190
  }
7521
8191
  const flushTiming = this.phases.userTiming ? beginVectoUserTiming(VECTO_USER_TIMING.scene.flush) : null;
7522
8192
  const flushT0 = this.phases.enabled ? performance.now() : 0;
@@ -7953,6 +8623,238 @@ var TextEntity = class extends Entity {
7953
8623
  }
7954
8624
  };
7955
8625
 
8626
+ // src/components/VirtualizedSetAggregate.ts
8627
+ var VirtualizedSetAggregate = class extends Entity {
8628
+ _items;
8629
+ _label;
8630
+ _role;
8631
+ _itemRole;
8632
+ _rowHeight;
8633
+ _visibleCapacity;
8634
+ _onActivate;
8635
+ /** First item index currently bound to the pool (the scroll window). */
8636
+ _visibleStart = 0;
8637
+ /** Item id owning the roving tab stop / keyboard focus. */
8638
+ _activeId = null;
8639
+ _selectedId = null;
8640
+ /** One focusable hotspot per visible slot; re-bound, never rebuilt per item. */
8641
+ _hotspots = [];
8642
+ constructor(opts = {}) {
8643
+ super();
8644
+ this._items = opts.items ?? [];
8645
+ this._label = opts.label ?? "Items";
8646
+ this._role = opts.role ?? "list";
8647
+ this._itemRole = opts.itemRole ?? "listitem";
8648
+ this._rowHeight = opts.rowHeight ?? 28;
8649
+ this._visibleCapacity = opts.visibleCapacity;
8650
+ this._onActivate = opts.onActivate;
8651
+ this.width = opts.width ?? 0;
8652
+ this.height = opts.height ?? 0;
8653
+ this.interactive = true;
8654
+ this._syncHotspots();
8655
+ }
8656
+ /** Full item count — the number the container label states. */
8657
+ get itemCount() {
8658
+ return this._items.length;
8659
+ }
8660
+ /** Live pool size (bounded by the visible capacity, never by the item count). */
8661
+ get poolSize() {
8662
+ return this._hotspots.length;
8663
+ }
8664
+ /** First item index currently bound to the pool. */
8665
+ get visibleStart() {
8666
+ return this._visibleStart;
8667
+ }
8668
+ getA11yAttributes() {
8669
+ return {
8670
+ role: this._role,
8671
+ label: `${this._label}, ${this._items.length} items`,
8672
+ pointerEvents: "none"
8673
+ };
8674
+ }
8675
+ render(_renderer) {
8676
+ }
8677
+ /** Axis-aligned hit-test against the container box (the `UIComponent` default). */
8678
+ isPointInside(globalX, globalY) {
8679
+ const local = this.worldToLocal(globalX, globalY);
8680
+ if (!local) return false;
8681
+ return local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height;
8682
+ }
8683
+ /** Replace the item set; the pool re-binds and the label count follows. */
8684
+ setItems(items) {
8685
+ this._items = items;
8686
+ if (this._activeId !== null && !items.some((item) => item.id === this._activeId)) {
8687
+ this._activeId = null;
8688
+ }
8689
+ if (this._selectedId !== null && !items.some((item) => item.id === this._selectedId)) {
8690
+ this._selectedId = null;
8691
+ }
8692
+ this._visibleStart = Math.max(0, Math.min(this._visibleStart, Math.max(0, items.length - 1)));
8693
+ this._syncHotspots();
8694
+ this.scene?.markDirty();
8695
+ }
8696
+ /** Move the pool window to start at `start` (the scroll position). */
8697
+ setVisibleStart(start) {
8698
+ const clamped = Math.max(0, Math.min(start, Math.max(0, this._items.length - 1)));
8699
+ if (clamped === this._visibleStart) return;
8700
+ this._visibleStart = clamped;
8701
+ this._syncHotspots();
8702
+ this.scene?.markDirty();
8703
+ }
8704
+ /**
8705
+ * Keep one hotspot per visible slot, positioned over it. The pool is sized
8706
+ * to the viewport (capacity), each slot re-bound to whatever item currently
8707
+ * occupies it — the `Tree._syncHotspots` shape, minus tree structure.
8708
+ */
8709
+ _syncHotspots() {
8710
+ const capacity = this._visibleCapacity ?? Math.max(1, Math.ceil(this.height / this._rowHeight));
8711
+ const need = Math.max(0, Math.min(capacity, this._items.length - this._visibleStart));
8712
+ while (this._hotspots.length < need) {
8713
+ const hotspot = new AggregateItemHotspot(this);
8714
+ this._hotspots.push(hotspot);
8715
+ this.add(hotspot);
8716
+ }
8717
+ while (this._hotspots.length > need) {
8718
+ const hotspot = this._hotspots.pop();
8719
+ this.scene?.detachA11y(hotspot);
8720
+ this.remove(hotspot);
8721
+ }
8722
+ for (let slot = 0; slot < need; slot++) {
8723
+ const index = this._visibleStart + slot;
8724
+ const hotspot = this._hotspots[slot];
8725
+ hotspot.bind(index, this._items[index]);
8726
+ hotspot.x = 0;
8727
+ hotspot.y = slot * this._rowHeight;
8728
+ hotspot.width = this.width;
8729
+ hotspot.height = this._rowHeight;
8730
+ }
8731
+ }
8732
+ /** Whether `id` owns the roving tab stop: active, else selected, else first. */
8733
+ isTabStop(id) {
8734
+ 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;
8735
+ return id === anchor;
8736
+ }
8737
+ isSelected(id) {
8738
+ return this._selectedId === id;
8739
+ }
8740
+ /** Item role the hotspots announce (kept on the parent: one source of truth). */
8741
+ get itemRole() {
8742
+ return this._itemRole;
8743
+ }
8744
+ /** Activate an item (pointer tap-through or Enter/Space): selects and notifies. */
8745
+ activateItem(id, focusIt = false) {
8746
+ const index = this._items.findIndex((item) => item.id === id);
8747
+ if (index === -1) return;
8748
+ this._activeId = id;
8749
+ this._selectedId = id;
8750
+ this._onActivate?.(this._items[index], index);
8751
+ if (focusIt) this.focusItem(id);
8752
+ this.scene?.markDirty();
8753
+ }
8754
+ /** Move keyboard focus to the hotspot currently bound to `id`, if any. */
8755
+ focusItem(id) {
8756
+ this._hotspots.find((hotspot) => hotspot.itemId === id)?.focus();
8757
+ }
8758
+ /**
8759
+ * Aggregate keyboard model: Up/Down move the active item (scrolling the pool
8760
+ * window so it stays bound), Home/End jump, Enter/Space activate. The active
8761
+ * item is focused as it moves so focus is never stranded on a re-bound slot.
8762
+ */
8763
+ handleItemKey(e, id) {
8764
+ const index = this._items.findIndex((item) => item.id === id);
8765
+ if (index === -1) return;
8766
+ let next = -1;
8767
+ switch (e.key) {
8768
+ case "ArrowDown":
8769
+ next = Math.min(index + 1, this._items.length - 1);
8770
+ break;
8771
+ case "ArrowUp":
8772
+ next = Math.max(index - 1, 0);
8773
+ break;
8774
+ case "Home":
8775
+ next = 0;
8776
+ break;
8777
+ case "End":
8778
+ next = this._items.length - 1;
8779
+ break;
8780
+ case "Enter":
8781
+ case " ":
8782
+ e.preventDefault();
8783
+ this.activateItem(id, true);
8784
+ return;
8785
+ default:
8786
+ return;
8787
+ }
8788
+ e.preventDefault();
8789
+ this._activeId = this._items[next].id;
8790
+ this._ensureVisible(next);
8791
+ this.focusItem(this._activeId);
8792
+ this.scene?.markDirty();
8793
+ }
8794
+ /** Scroll the pool window just enough to keep `index` bound. */
8795
+ _ensureVisible(index) {
8796
+ const capacity = this._visibleCapacity ?? Math.max(1, Math.ceil(this.height / this._rowHeight));
8797
+ if (index < this._visibleStart) this.setVisibleStart(index);
8798
+ else if (index >= this._visibleStart + capacity) {
8799
+ this.setVisibleStart(index - capacity + 1);
8800
+ } else {
8801
+ this._syncHotspots();
8802
+ }
8803
+ }
8804
+ update(_dt, _time) {
8805
+ super.update(_dt, _time);
8806
+ this._syncHotspots();
8807
+ }
8808
+ };
8809
+ var AggregateItemHotspot = class extends Entity {
8810
+ constructor(aggregate) {
8811
+ super();
8812
+ this.aggregate = aggregate;
8813
+ this.interactive = true;
8814
+ this.on("click", () => this.aggregate.activateItem(this.itemId, true));
8815
+ this.on("keydown", (e) => this.aggregate.handleItemKey(e, this.itemId));
8816
+ }
8817
+ aggregate;
8818
+ itemId = "";
8819
+ _index = 0;
8820
+ _label = "";
8821
+ bind(index, item) {
8822
+ this._index = index;
8823
+ this.itemId = item.id;
8824
+ this._label = item.label;
8825
+ }
8826
+ /** The aggregate positions and sizes one pooled hotspot per visible slot. */
8827
+ getLayoutControlledProperties() {
8828
+ return ["x", "y", "width", "height"];
8829
+ }
8830
+ getA11yAttributes() {
8831
+ return {
8832
+ role: this.aggregate.itemRole,
8833
+ label: this._label,
8834
+ // 1-based position within the FULL set, not the pool: without these a
8835
+ // virtualized window announces the pool position as the set position.
8836
+ posInSet: this._index + 1,
8837
+ setSize: this.aggregate.itemCount,
8838
+ selected: this.aggregate.isSelected(this.itemId),
8839
+ // Roving tabindex: only the active item is a tab stop; arrows move within.
8840
+ tabIndex: this.aggregate.isTabStop(this.itemId) ? 0 : -1,
8841
+ // The owner keeps the pointer (canvas handling or an underlying
8842
+ // selectable-text mirror); this hotspot exists for semantics + keyboard
8843
+ // focus, so it opts out of hit-testing. Keyboard focus and
8844
+ // AT-synthesized `click` still work under `pointer-events:none`.
8845
+ pointerEvents: "none"
8846
+ };
8847
+ }
8848
+ render() {
8849
+ }
8850
+ /** Axis-aligned hit-test against the hotspot slot (the `UIComponent` default). */
8851
+ isPointInside(globalX, globalY) {
8852
+ const local = this.worldToLocal(globalX, globalY);
8853
+ if (!local) return false;
8854
+ return local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height;
8855
+ }
8856
+ };
8857
+
7956
8858
  // src/components/GridTextEntity.ts
7957
8859
  var GridTextEntity = class extends Entity {
7958
8860
  fontSize;
@@ -8648,6 +9550,8 @@ export {
8648
9550
  Circle,
8649
9551
  ComputeParticleEntity,
8650
9552
  DEFAULT_CONTENT_SEMANTIC_BUDGET,
9553
+ DEFAULT_PROJECTION_CAPABILITIES,
9554
+ DEFAULT_SEMANTIC_PROJECTION_POLICY,
8651
9555
  DOMPortalEntity,
8652
9556
  Entity,
8653
9557
  GlyphRasterAtlas,
@@ -8664,6 +9568,8 @@ export {
8664
9568
  PARTICLE_OFFSET_VELOCITY_X,
8665
9569
  PARTICLE_OFFSET_VELOCITY_Y,
8666
9570
  PARTICLE_STRIDE_FLOATS,
9571
+ PROJECTION_AUTO_DOM_BUDGET,
9572
+ PROJECTION_AUTO_HYSTERESIS_FRAMES,
8667
9573
  REDUCED_MOTION_FPS,
8668
9574
  Rect,
8669
9575
  SCENE_OPTION_KEYS,
@@ -8673,14 +9579,20 @@ export {
8673
9579
  SplineEntity,
8674
9580
  TextEntity,
8675
9581
  TextRasterCache,
9582
+ UNKNOWN_PROJECTION_CAPABILITY,
8676
9583
  VECTO_USER_TIMING,
8677
9584
  VectoJSEvent,
9585
+ VirtualizedSetAggregate,
8678
9586
  WebGPUParticleSystemManager,
8679
9587
  beginVectoUserTiming,
8680
9588
  contentLineInHint,
8681
9589
  createWebGLPointRenderer,
9590
+ describeHTMLInCanvasSupport,
8682
9591
  endVectoUserTiming,
9592
+ getSemanticProjectionCapabilities,
9593
+ hysteresisVote,
8683
9594
  installRendererDevTraps,
9595
+ isDeferrableSemanticNode,
8684
9596
  isRendererDevMode,
8685
9597
  isSafeUrl,
8686
9598
  loadSpline,
@@ -8689,6 +9601,8 @@ export {
8689
9601
  ownsKeyboard,
8690
9602
  parseColorToRGBA,
8691
9603
  polySegmentToBezier,
9604
+ resolveProjection,
8692
9605
  sanitizeUrl,
8693
- setRendererDevMode
9606
+ setRendererDevMode,
9607
+ supportsHTMLInCanvas
8694
9608
  };