@vectojs/core 1.31.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1380,6 +1380,7 @@ var SCENE_OPTION_KEYS = [
1380
1380
  "autoThrottle",
1381
1381
  "contentProjection",
1382
1382
  "contentProjectionMargin",
1383
+ "contentSemanticBudget",
1383
1384
  "contentSemanticMargin",
1384
1385
  "debugA11y",
1385
1386
  "disableWindowResize",
@@ -1433,6 +1434,7 @@ function parseInlinePx(value) {
1433
1434
  const n = parseFloat(value);
1434
1435
  return Number.isFinite(n) && n > 0 ? n : null;
1435
1436
  }
1437
+ var DEFAULT_CONTENT_SEMANTIC_BUDGET = 256;
1436
1438
  function projectionGridLineWindow(grid, projectionLines, band) {
1437
1439
  const count = grid.lines.length;
1438
1440
  const all = { start: 0, end: count, gated: false };
@@ -2008,42 +2010,77 @@ var Scene = (_class7 = class _Scene {
2008
2010
  // any projected DOM. `undefined` → falls back to contentProjectionMargin, so
2009
2011
  // the default keeps one gate. `Infinity` = every block keeps resident text.
2010
2012
  __init66() {this.contentSemanticMargin = void 0}
2013
+ // How many coarse-tier blocks may be MATERIALIZED per sync, spreading the
2014
+ // resident tier's document-open cost across frames. `Infinity` = one
2015
+ // synchronous pass.
2016
+ __init67() {this.contentSemanticBudget = DEFAULT_CONTENT_SEMANTIC_BUDGET}
2017
+ // Remaining materializations in the CURRENT sync. Reset at the start of each
2018
+ // a11y walk; decremented per coarse block that creates its element.
2019
+ __init68() {this.contentSemanticBudgetLeft = 0}
2020
+ // Set when a sync deferred at least one block, so the scene knows to keep
2021
+ // drawing frames until the resident tier is complete. Without it a static
2022
+ // scene in `onDemand` mode would stop rendering with the document half
2023
+ // materialized and never finish.
2024
+ __init69() {this.contentSemanticDeferred = false}
2025
+ /**
2026
+ * Per-sync memo of "does the document hold a selection at all".
2027
+ *
2028
+ * Reading ANY property of a `Selection` (`anchorNode`, `rangeCount`, `type`,
2029
+ * `isCollapsed`) forces a synchronous layout, because Blink validates the
2030
+ * selection against current box geometry before answering. Measured in real
2031
+ * Chrome against a 1000-carrier subtree with layout dirtied between reads:
2032
+ * `anchorNode` 0.5ms, `rangeCount` 0.4ms, `type` 0.5ms, `isCollapsed` 0.5ms —
2033
+ * all indistinguishable from `offsetHeight` (0.5ms), against a 0ms floor for
2034
+ * mutating without reading. So there is no cheap property to probe with; the
2035
+ * only way to avoid the layout is to not touch the object at all.
2036
+ *
2037
+ * Materializing a block rebuilds its carriers, which asks whether the rebuild
2038
+ * would destroy a selection. Once per block, that read cost a forced layout
2039
+ * over the whole (and growing) projection subtree, which is what made
2040
+ * per-block cost rise with resident count: profiled at 1973 forced layouts
2041
+ * totalling 633ms of an 847ms 1000-block drain (75%).
2042
+ *
2043
+ * A selection is a single document-wide object and a sync walk cannot yield to
2044
+ * the user, so its presence cannot change mid-walk. Resolving it once per walk
2045
+ * turns O(blocks) forced layouts into O(1). `null` = not yet resolved.
2046
+ */
2047
+ __init70() {this.contentSelectionPresentThisSync = null}
2011
2048
  /**
2012
2049
  * True while a text-selection drag that started on a projection's blank
2013
2050
  * region (no text node under the press) is being driven manually — the
2014
2051
  * browser has no native anchor for it, so mousemove extends the Selection
2015
2052
  * from the position we resolved ourselves.
2016
2053
  */
2017
- __init67() {this.blankRegionSelectionDrag = false}
2018
- __init68() {this.contentSelectionAnchor = null}
2019
- __init69() {this.contentSelectionEndListener = null}
2054
+ __init71() {this.blankRegionSelectionDrag = false}
2055
+ __init72() {this.contentSelectionAnchor = null}
2056
+ __init73() {this.contentSelectionEndListener = null}
2020
2057
  // Animation/interactive flags collected during the render walk (tree-walk
2021
2058
  // fusion): the loop reads last frame's answers instead of re-walking the
2022
2059
  // tree up to 4× per tick. Start true so the first tick stays conservative.
2023
- __init70() {this.frameHadAnimation = true}
2024
- __init71() {this.frameHadInteractive = true}
2060
+ __init74() {this.frameHadAnimation = true}
2061
+ __init75() {this.frameHadInteractive = true}
2025
2062
 
2026
2063
  /** Active `(resolution: Ndppx)` media query watching for a runtime DPR change
2027
2064
  * (window moved between monitors, browser zoom) so the canvas backing store
2028
2065
  * can be re-scaled — otherwise it stays rasterized at the old DPR and blurs.
2029
2066
  * A resolution media query only fires when leaving its exact value, so the
2030
2067
  * handler re-arms a fresh query for the new DPR each time. */
2031
- __init72() {this.dprMediaQuery = null}
2068
+ __init76() {this.dprMediaQuery = null}
2032
2069
  /** For embedded (`disableWindowResize`) scenes: observes the canvas element so
2033
2070
  * a CSS/layout-driven size change re-runs `resize()`. A window `resize`
2034
2071
  * listener never fires for these (the window isn't what changed), so without
2035
2072
  * this an embedded canvas stayed at its initial size forever. */
2036
- __init73() {this.canvasResizeObserver = null}
2037
- __init74() {this.dprChangeHandler = null}
2038
- __init75() {this.focusedA11yElement = null}
2073
+ __init77() {this.canvasResizeObserver = null}
2074
+ __init78() {this.dprChangeHandler = null}
2075
+ __init79() {this.focusedA11yElement = null}
2039
2076
  /** Last geometry `syncOverlayGeometry` wrote, so an unchanged frame can skip the
2040
2077
  * style writes entirely. Reset to `null` to force the next sync (a new overlay
2041
2078
  * layer was created and has never been positioned). */
2042
- __init76() {this._overlayGeometry = null}
2079
+ __init80() {this._overlayGeometry = null}
2043
2080
  /** Shadow elements the pointer is currently inside. Lets a removal that happens
2044
2081
  * mid-hover synthesize the `pointerleave` the browser never sends for a
2045
2082
  * detached element, so the entity doesn't keep its hover state. */
2046
- __init77() {this.hoveredA11yElements = /* @__PURE__ */ new WeakSet()}
2083
+ __init81() {this.hoveredA11yElements = /* @__PURE__ */ new WeakSet()}
2047
2084
  /**
2048
2085
  * Entity ids the application has pinned via {@link requestA11yProjection}.
2049
2086
  *
@@ -2051,22 +2088,22 @@ var Scene = (_class7 = class _Scene {
2051
2088
  * a stale id simply never matches. Cleared per-entity by
2052
2089
  * {@link releaseA11yProjection}.
2053
2090
  */
2054
- __init78() {this.a11yProjectionRequests = /* @__PURE__ */ new Set()}
2091
+ __init82() {this.a11yProjectionRequests = /* @__PURE__ */ new Set()}
2055
2092
  /** Persistent tabindex=-1 element in a11yRoot. When the focused a11y mirror is
2056
2093
  * pruned (virtualization/streaming/removal) while it holds focus, we move
2057
2094
  * focus here instead of letting the browser drop it to <body> — keeping the
2058
2095
  * screen-reader virtual cursor inside the scene's a11y region. */
2059
- __init79() {this.focusSentinel = null}
2060
- __init80() {this.caretBlinkTimer = null}
2061
- __init81() {this.a11yNeedsReorder = true}
2062
- __init82() {this.portalRoot = null}
2063
- __init83() {this.fullViewportElements = []}
2064
- __init84() {this.normalElements = []}
2065
- __init85() {this.activeIds = /* @__PURE__ */ new Set()}
2096
+ __init83() {this.focusSentinel = null}
2097
+ __init84() {this.caretBlinkTimer = null}
2098
+ __init85() {this.a11yNeedsReorder = true}
2099
+ __init86() {this.portalRoot = null}
2100
+ __init87() {this.fullViewportElements = []}
2101
+ __init88() {this.normalElements = []}
2102
+ __init89() {this.activeIds = /* @__PURE__ */ new Set()}
2066
2103
  /** Per-parent insertion cursor, reused by `enforceA11yDomOrder`. */
2067
- __init86() {this.a11yOrderCursors = /* @__PURE__ */ new Map()}
2104
+ __init90() {this.a11yOrderCursors = /* @__PURE__ */ new Map()}
2068
2105
  /** Membership set for the elements being ordered, reused per reorder pass. */
2069
- __init87() {this.a11yOrderMembers = /* @__PURE__ */ new Set()}
2106
+ __init91() {this.a11yOrderMembers = /* @__PURE__ */ new Set()}
2070
2107
  /**
2071
2108
  * Elements that are an *ancestor* of another ordered element, reused per pass.
2072
2109
  *
@@ -2074,11 +2111,11 @@ var Scene = (_class7 = class _Scene {
2074
2111
  * items) spans every descendant row, so it must not extend a visual row band —
2075
2112
  * see {@link sortNormalElementsVisually}.
2076
2113
  */
2077
- __init88() {this.a11yOrderContainers = /* @__PURE__ */ new Set()}
2078
- __init89() {this.activePortalsThisFrame = /* @__PURE__ */ new Set()}
2079
- __init90() {this.activePortalsPrevFrame = /* @__PURE__ */ new Set()}
2080
- __init91() {this.portalEntities = /* @__PURE__ */ new Map()}
2081
- __init92() {this.renderOrderCounter = 0}
2114
+ __init92() {this.a11yOrderContainers = /* @__PURE__ */ new Set()}
2115
+ __init93() {this.activePortalsThisFrame = /* @__PURE__ */ new Set()}
2116
+ __init94() {this.activePortalsPrevFrame = /* @__PURE__ */ new Set()}
2117
+ __init95() {this.portalEntities = /* @__PURE__ */ new Map()}
2118
+ __init96() {this.renderOrderCounter = 0}
2082
2119
  /**
2083
2120
  * Monotonic render-frame counter, bumped once per authoritative `render()`
2084
2121
  * pass. Entities stamp their per-frame world-matrix cache with this value and
@@ -2087,7 +2124,7 @@ var Scene = (_class7 = class _Scene {
2087
2124
  * back to the ancestor walk. Public for the same reason `Entity._getTrig`/
2088
2125
  * `_setWorldCache` are: it is a cross-class render-internal contract.
2089
2126
  */
2090
- __init93() {this.currentFrame = 0}
2127
+ __init97() {this.currentFrame = 0}
2091
2128
  // ── WASM transform backend (invisible accelerator) ──────────────────────────
2092
2129
  // When `_transformBackend === 'wasm'`, the main render walk sources each
2093
2130
  // entity's world matrix from an SoA store composed by `_wasm` (see
@@ -2095,24 +2132,24 @@ var Scene = (_class7 = class _Scene {
2095
2132
  // fallback and the default: a null backend, a non-main renderer, or any entity
2096
2133
  // absent from the store all fall back to the JS composition, so WASM can only
2097
2134
  // ever change *how fast* a world matrix is produced, never *what* it is.
2098
- __init94() {this._wasm = null}
2099
- __init95() {this._transformBackend = "js"}
2135
+ __init98() {this._wasm = null}
2136
+ __init99() {this._transformBackend = "js"}
2100
2137
  // Resident store state (Stage 3). The store layout — slot assignment + sibling
2101
2138
  // runs — depends only on tree TOPOLOGY, so it is rebuilt only when the
2102
2139
  // structure changes (add/remove/reparent bump `_structureVersion`). Between
2103
2140
  // rebuilds the per-frame cost is: gather each entity's transform into the
2104
2141
  // resident wasm input view + run the kernel — no reallocation, no readback.
2105
- __init96() {this._treeStore = null}
2106
- __init97() {this._slotEntity = []}
2142
+ __init100() {this._treeStore = null}
2143
+ __init101() {this._slotEntity = []}
2107
2144
  // store slot -> entity (also validates slots)
2108
- __init98() {this._wasmInputs = null}
2109
- __init99() {this._wasmWorld = null}
2110
- __init100() {this._structureVersion = 0}
2111
- __init101() {this._storeStructureVersion = -1}
2145
+ __init102() {this._wasmInputs = null}
2146
+ __init103() {this._wasmWorld = null}
2147
+ __init104() {this._structureVersion = 0}
2148
+ __init105() {this._storeStructureVersion = -1}
2112
2149
  // Cached list of ComputeParticleEntity instances in the tree, keyed by the
2113
2150
  // structure version it was gathered at. Rebuilt only on a topology change.
2114
- __init102() {this._computeEntities = []}
2115
- __init103() {this._computeEntitiesVersion = -1}
2151
+ __init106() {this._computeEntities = []}
2152
+ __init107() {this._computeEntitiesVersion = -1}
2116
2153
  /** Invalidate the resident WASM store layout; the next wasm-mode frame rebuilds
2117
2154
  * it. Called by `Entity.add`/`remove` (topology changes only). */
2118
2155
  markStructureChanged() {
@@ -2197,7 +2234,7 @@ var Scene = (_class7 = class _Scene {
2197
2234
  * cached globally; the instance is per-Scene, which is the isolation that
2198
2235
  * actually matters.
2199
2236
  */
2200
- __init104() {this._wasmRuntime = null}
2237
+ __init108() {this._wasmRuntime = null}
2201
2238
  /**
2202
2239
  * Load (or reuse) this Scene's shared WASM runtime.
2203
2240
  *
@@ -2225,30 +2262,30 @@ var Scene = (_class7 = class _Scene {
2225
2262
  get wasmRuntime() {
2226
2263
  return this._wasmRuntime;
2227
2264
  }
2228
- __init105() {this._hitWasm = null}
2265
+ __init109() {this._hitWasm = null}
2229
2266
  // Cache key: which frame + structure version the grid was last (successfully,
2230
2267
  // non-overflowing) built for. findEntityAt is called ad-hoc (pointer
2231
2268
  // hover/click), not every frame, so the grid is refreshed lazily on demand
2232
2269
  // rather than proactively every render() — unlike the transform store, which
2233
2270
  // every frame's draw depends on.
2234
- __init106() {this._hitGridFrame = -1}
2235
- __init107() {this._hitGridOk = false}
2236
- __init108() {this._hitSlotEntity = []}
2237
- __init109() {this._hitBoundless = []}
2271
+ __init110() {this._hitGridFrame = -1}
2272
+ __init111() {this._hitGridOk = false}
2273
+ __init112() {this._hitSlotEntity = []}
2274
+ __init113() {this._hitBoundless = []}
2238
2275
  /** Reused buffer for the fused gather, so a pointer query allocates nothing. */
2239
- __init110() {this._hitGatherBuffer = null}
2276
+ __init114() {this._hitGatherBuffer = null}
2240
2277
  /**
2241
2278
  * Whether the last grid build sourced its AABBs from the WASM transform store
2242
2279
  * rather than recomputing them in JS. Diagnostic only — both paths must
2243
2280
  * produce the same entity for a given point.
2244
2281
  */
2245
- __init111() {this._hitFusedGather = false}
2282
+ __init115() {this._hitFusedGather = false}
2246
2283
  /**
2247
2284
  * Whether `compute_aabbs` has run against the current frame's world matrices.
2248
2285
  * The AABB pass is only meaningful after a `compose_*`, so the fused gather
2249
2286
  * must not read the views before then.
2250
2287
  */
2251
- __init112() {this._wasmAabbsFresh = false}
2288
+ __init116() {this._wasmAabbsFresh = false}
2252
2289
  /** Did the last hit-grid build use the fused (WASM-store) gather? */
2253
2290
  get hitGatherPath() {
2254
2291
  return this._hitFusedGather ? "fused" : "js";
@@ -2257,9 +2294,9 @@ var Scene = (_class7 = class _Scene {
2257
2294
  * Why the transform accelerator did or did not run on the most recent frame.
2258
2295
  * Written by the render walk and `_syncWasmStore`.
2259
2296
  */
2260
- __init113() {this._transformReason = "not-installed"}
2297
+ __init117() {this._transformReason = "not-installed"}
2261
2298
  /** Why the batched-driver accelerator did or did not run. */
2262
- __init114() {this._animReason = "not-installed"}
2299
+ __init118() {this._animReason = "not-installed"}
2263
2300
  /**
2264
2301
  * Why the hit-test accelerator did or did not serve the last pointer query.
2265
2302
  * The grid is built lazily on demand, not every frame, so this describes the
@@ -2267,11 +2304,11 @@ var Scene = (_class7 = class _Scene {
2267
2304
  * before a backend exists; `_ensureHitGrid` moves it to `'not-applicable'`
2268
2305
  * once one is installed but nothing has queried yet.
2269
2306
  */
2270
- __init115() {this._hitReason = "not-installed"}
2307
+ __init119() {this._hitReason = "not-installed"}
2271
2308
  /** Why the particle accelerator did or did not run. */
2272
- __init116() {this._particleReason = "not-applicable"}
2309
+ __init120() {this._particleReason = "not-applicable"}
2273
2310
  /** Which particle implementation actually simulated the most recent frame. */
2274
- __init117() {this._particlePath = "none"}
2311
+ __init121() {this._particlePath = "none"}
2275
2312
  /**
2276
2313
  * Per-frame status of every invisible accelerator: whether each is installed,
2277
2314
  * whether it actually ran on the most recent frame, and why.
@@ -2431,25 +2468,25 @@ var Scene = (_class7 = class _Scene {
2431
2468
  // EasingFn (which cannot cross into WASM) all fall through to it — WASM can
2432
2469
  // only ever change *how* a driver is advanced, never *what* value it lands
2433
2470
  // on.
2434
- __init118() {this._animWasm = null}
2471
+ __init122() {this._animWasm = null}
2435
2472
  // Entities with at least one active driver, added by Entity._spawnDriver.
2436
2473
  // Self-pruning: _tickBatchedDrivers drops an entry the first time it visits
2437
2474
  // an entity whose drivers have since all completed or been removed. This is
2438
2475
  // what lets the batch pass find its candidates in O(active drivers), not
2439
2476
  // O(tree size) — the exact mistake G3's first integrated benchmark made.
2440
- __init119() {this._activeDriverEntities = /* @__PURE__ */ new Set()}
2477
+ __init123() {this._activeDriverEntities = /* @__PURE__ */ new Set()}
2441
2478
  // Reused across frames instead of allocating a fresh array + N {entity,prop,
2442
2479
  // driver} objects every call — the integrated benchmark
2443
2480
  // (benchmarks/anim-wasm-scene) found that allocation churn was the
2444
2481
  // dominant integrated cost, not the wasm kernel itself. Parallel arrays,
2445
2482
  // truncated to the live count after each use so a stale tail slot never
2446
2483
  // pins a no-longer-active entity/driver in memory.
2447
- __init120() {this._springEntities = []}
2448
- __init121() {this._springProps = []}
2449
- __init122() {this._springDrivers = []}
2450
- __init123() {this._tweenEntities = []}
2451
- __init124() {this._tweenProps = []}
2452
- __init125() {this._tweenDrivers = []}
2484
+ __init124() {this._springEntities = []}
2485
+ __init125() {this._springProps = []}
2486
+ __init126() {this._springDrivers = []}
2487
+ __init127() {this._tweenEntities = []}
2488
+ __init128() {this._tweenProps = []}
2489
+ __init129() {this._tweenDrivers = []}
2453
2490
  /**
2454
2491
  * Minimum number of batchable (spring, or named-easing tween) active drivers
2455
2492
  * before a frame engages the WASM batch path at all; below it, every driver
@@ -2521,7 +2558,7 @@ var Scene = (_class7 = class _Scene {
2521
2558
  * Setting {@link animDriverGateCount} overwrites all three, so existing code
2522
2559
  * that tuned the single knob keeps working unchanged.
2523
2560
  */
2524
- __init126() {this._animBatchedLastFrame = false}
2561
+ __init130() {this._animBatchedLastFrame = false}
2525
2562
  /**
2526
2563
  * Whether the WASM batch path actually ran on the most recent frame.
2527
2564
  *
@@ -2533,7 +2570,7 @@ var Scene = (_class7 = class _Scene {
2533
2570
  get animBatchedLastFrame() {
2534
2571
  return this._animBatchedLastFrame;
2535
2572
  }
2536
- __init127() {this.animGate = {
2573
+ __init131() {this.animGate = {
2537
2574
  spring: 128,
2538
2575
  tween: 256,
2539
2576
  mixed: 128
@@ -2571,7 +2608,7 @@ var Scene = (_class7 = class _Scene {
2571
2608
  // (benchmarks/particle-wasm). f32 (matches the WGSL shader), bit-identical to
2572
2609
  // a JS f32 reference oracle; updateCPU (f64) stays the permanent fallback when
2573
2610
  // no backend is installed or a scene runs on WebGPU.
2574
- __init128() {this._particleWasm = null}
2611
+ __init132() {this._particleWasm = null}
2575
2612
  /** Which backend runs the CPU particle simulation. Reflects only whether a
2576
2613
  * backend is installed (the WebGPU compute path, when active, is used first
2577
2614
  * regardless). */
@@ -2848,24 +2885,24 @@ var Scene = (_class7 = class _Scene {
2848
2885
  * sync, so retaining the order prevents a newly opened overlay from spending
2849
2886
  * its first frame below previously projected controls.
2850
2887
  */
2851
- __init129() {this.a11yRenderOrders = /* @__PURE__ */ new Map()}
2888
+ __init133() {this.a11yRenderOrders = /* @__PURE__ */ new Map()}
2852
2889
  // Optional WebGL point-cloud layer (see SceneOptions.pointBackend).
2853
- __init130() {this.pointRenderer = null}
2854
- __init131() {this.glCanvas = null}
2855
- __init132() {this.glContextLostHandler = null}
2856
- __init133() {this.glContextRestoredHandler = null}
2890
+ __init134() {this.pointRenderer = null}
2891
+ __init135() {this.glCanvas = null}
2892
+ __init136() {this.glContextLostHandler = null}
2893
+ __init137() {this.glContextRestoredHandler = null}
2857
2894
 
2858
2895
 
2859
2896
 
2860
- __init134() {this.disableWindowResize = false}
2897
+ __init138() {this.disableWindowResize = false}
2861
2898
  /** See {@link SceneOptions.maxDPR}. `undefined` = uncapped (real DPR). */
2862
2899
 
2863
2900
  // WebGPU properties
2864
- __init135() {this.destroyed = false}
2865
- __init136() {this.device = null}
2866
- __init137() {this.deviceLost = false}
2867
- __init138() {this.particleBackend = "auto"}
2868
- __init139() {this._webgpuDisabled = false}
2901
+ __init139() {this.destroyed = false}
2902
+ __init140() {this.device = null}
2903
+ __init141() {this.deviceLost = false}
2904
+ __init142() {this.particleBackend = "auto"}
2905
+ __init143() {this._webgpuDisabled = false}
2869
2906
  get webgpuDisabled() {
2870
2907
  return this._webgpuDisabled || this.particleBackend === "cpu";
2871
2908
  }
@@ -2893,22 +2930,22 @@ var Scene = (_class7 = class _Scene {
2893
2930
  set webgpuDisabled(value) {
2894
2931
  this._webgpuDisabled = value;
2895
2932
  }
2896
- __init140() {this.recoveryTimerId = null}
2897
- __init141() {this.manager = null}
2898
- __init142() {this.initializingWebGPU = false}
2899
- __init143() {this.gpuCanvas = null}
2900
- __init144() {this.gpuContext = null}
2933
+ __init144() {this.recoveryTimerId = null}
2934
+ __init145() {this.manager = null}
2935
+ __init146() {this.initializingWebGPU = false}
2936
+ __init147() {this.gpuCanvas = null}
2937
+ __init148() {this.gpuContext = null}
2901
2938
  /** True while the GPU canvas holds a presented particle frame (needs clearing when they leave). */
2902
- __init145() {this.gpuHasContent = false}
2903
- __init146() {this.mouseX = -9999}
2904
- __init147() {this.mouseY = -9999}
2905
- __init148() {this.pointerMoveListener = null}
2906
- __init149() {this.pointerLeaveListener = null}
2939
+ __init149() {this.gpuHasContent = false}
2940
+ __init150() {this.mouseX = -9999}
2941
+ __init151() {this.mouseY = -9999}
2942
+ __init152() {this.pointerMoveListener = null}
2943
+ __init153() {this.pointerLeaveListener = null}
2907
2944
  /** Element the pointer listeners are bound to (parent container if present,
2908
2945
  * else the canvas). Stored so `destroy()` detaches from the same element. */
2909
- __init150() {this.pointerEventTarget = null}
2910
- __init151() {this.hasWarnedZeroSize = false}
2911
- __init152() {this.fontLoadHandler = null}
2946
+ __init154() {this.pointerEventTarget = null}
2947
+ __init155() {this.hasWarnedZeroSize = false}
2948
+ __init156() {this.fontLoadHandler = null}
2912
2949
  // ── Dev-mode warning infrastructure ──────────────────────────────
2913
2950
  //
2914
2951
  // Enable with `Scene.devMode = true` or by setting `globalThis.__DEV__`.
@@ -2942,7 +2979,7 @@ var Scene = (_class7 = class _Scene {
2942
2979
  return false;
2943
2980
  }
2944
2981
 
2945
- __init153() {this._devFrameCount = 0}
2982
+ __init157() {this._devFrameCount = 0}
2946
2983
  _devWarn(message) {
2947
2984
  if (!this._devActive) return;
2948
2985
  console.warn(`[vectojs/dev] ${message}`);
@@ -3019,7 +3056,7 @@ var Scene = (_class7 = class _Scene {
3019
3056
  };
3020
3057
  walkProjections(this.root);
3021
3058
  }
3022
- constructor(canvas, options = {}) {;_class7.prototype.__init27.call(this);_class7.prototype.__init28.call(this);_class7.prototype.__init29.call(this);_class7.prototype.__init30.call(this);_class7.prototype.__init31.call(this);_class7.prototype.__init32.call(this);_class7.prototype.__init33.call(this);_class7.prototype.__init34.call(this);_class7.prototype.__init35.call(this);_class7.prototype.__init36.call(this);_class7.prototype.__init37.call(this);_class7.prototype.__init38.call(this);_class7.prototype.__init39.call(this);_class7.prototype.__init40.call(this);_class7.prototype.__init41.call(this);_class7.prototype.__init42.call(this);_class7.prototype.__init43.call(this);_class7.prototype.__init44.call(this);_class7.prototype.__init45.call(this);_class7.prototype.__init46.call(this);_class7.prototype.__init47.call(this);_class7.prototype.__init48.call(this);_class7.prototype.__init49.call(this);_class7.prototype.__init50.call(this);_class7.prototype.__init51.call(this);_class7.prototype.__init52.call(this);_class7.prototype.__init53.call(this);_class7.prototype.__init54.call(this);_class7.prototype.__init55.call(this);_class7.prototype.__init56.call(this);_class7.prototype.__init57.call(this);_class7.prototype.__init58.call(this);_class7.prototype.__init59.call(this);_class7.prototype.__init60.call(this);_class7.prototype.__init61.call(this);_class7.prototype.__init62.call(this);_class7.prototype.__init63.call(this);_class7.prototype.__init64.call(this);_class7.prototype.__init65.call(this);_class7.prototype.__init66.call(this);_class7.prototype.__init67.call(this);_class7.prototype.__init68.call(this);_class7.prototype.__init69.call(this);_class7.prototype.__init70.call(this);_class7.prototype.__init71.call(this);_class7.prototype.__init72.call(this);_class7.prototype.__init73.call(this);_class7.prototype.__init74.call(this);_class7.prototype.__init75.call(this);_class7.prototype.__init76.call(this);_class7.prototype.__init77.call(this);_class7.prototype.__init78.call(this);_class7.prototype.__init79.call(this);_class7.prototype.__init80.call(this);_class7.prototype.__init81.call(this);_class7.prototype.__init82.call(this);_class7.prototype.__init83.call(this);_class7.prototype.__init84.call(this);_class7.prototype.__init85.call(this);_class7.prototype.__init86.call(this);_class7.prototype.__init87.call(this);_class7.prototype.__init88.call(this);_class7.prototype.__init89.call(this);_class7.prototype.__init90.call(this);_class7.prototype.__init91.call(this);_class7.prototype.__init92.call(this);_class7.prototype.__init93.call(this);_class7.prototype.__init94.call(this);_class7.prototype.__init95.call(this);_class7.prototype.__init96.call(this);_class7.prototype.__init97.call(this);_class7.prototype.__init98.call(this);_class7.prototype.__init99.call(this);_class7.prototype.__init100.call(this);_class7.prototype.__init101.call(this);_class7.prototype.__init102.call(this);_class7.prototype.__init103.call(this);_class7.prototype.__init104.call(this);_class7.prototype.__init105.call(this);_class7.prototype.__init106.call(this);_class7.prototype.__init107.call(this);_class7.prototype.__init108.call(this);_class7.prototype.__init109.call(this);_class7.prototype.__init110.call(this);_class7.prototype.__init111.call(this);_class7.prototype.__init112.call(this);_class7.prototype.__init113.call(this);_class7.prototype.__init114.call(this);_class7.prototype.__init115.call(this);_class7.prototype.__init116.call(this);_class7.prototype.__init117.call(this);_class7.prototype.__init118.call(this);_class7.prototype.__init119.call(this);_class7.prototype.__init120.call(this);_class7.prototype.__init121.call(this);_class7.prototype.__init122.call(this);_class7.prototype.__init123.call(this);_class7.prototype.__init124.call(this);_class7.prototype.__init125.call(this);_class7.prototype.__init126.call(this);_class7.prototype.__init127.call(this);_class7.prototype.__init128.call(this);_class7.prototype.__init129.call(this);_class7.prototype.__init130.call(this);_class7.prototype.__init131.call(this);_class7.prototype.__init132.call(this);_class7.prototype.__init133.call(this);_class7.prototype.__init134.call(this);_class7.prototype.__init135.call(this);_class7.prototype.__init136.call(this);_class7.prototype.__init137.call(this);_class7.prototype.__init138.call(this);_class7.prototype.__init139.call(this);_class7.prototype.__init140.call(this);_class7.prototype.__init141.call(this);_class7.prototype.__init142.call(this);_class7.prototype.__init143.call(this);_class7.prototype.__init144.call(this);_class7.prototype.__init145.call(this);_class7.prototype.__init146.call(this);_class7.prototype.__init147.call(this);_class7.prototype.__init148.call(this);_class7.prototype.__init149.call(this);_class7.prototype.__init150.call(this);_class7.prototype.__init151.call(this);_class7.prototype.__init152.call(this);_class7.prototype.__init153.call(this);
3059
+ constructor(canvas, options = {}) {;_class7.prototype.__init27.call(this);_class7.prototype.__init28.call(this);_class7.prototype.__init29.call(this);_class7.prototype.__init30.call(this);_class7.prototype.__init31.call(this);_class7.prototype.__init32.call(this);_class7.prototype.__init33.call(this);_class7.prototype.__init34.call(this);_class7.prototype.__init35.call(this);_class7.prototype.__init36.call(this);_class7.prototype.__init37.call(this);_class7.prototype.__init38.call(this);_class7.prototype.__init39.call(this);_class7.prototype.__init40.call(this);_class7.prototype.__init41.call(this);_class7.prototype.__init42.call(this);_class7.prototype.__init43.call(this);_class7.prototype.__init44.call(this);_class7.prototype.__init45.call(this);_class7.prototype.__init46.call(this);_class7.prototype.__init47.call(this);_class7.prototype.__init48.call(this);_class7.prototype.__init49.call(this);_class7.prototype.__init50.call(this);_class7.prototype.__init51.call(this);_class7.prototype.__init52.call(this);_class7.prototype.__init53.call(this);_class7.prototype.__init54.call(this);_class7.prototype.__init55.call(this);_class7.prototype.__init56.call(this);_class7.prototype.__init57.call(this);_class7.prototype.__init58.call(this);_class7.prototype.__init59.call(this);_class7.prototype.__init60.call(this);_class7.prototype.__init61.call(this);_class7.prototype.__init62.call(this);_class7.prototype.__init63.call(this);_class7.prototype.__init64.call(this);_class7.prototype.__init65.call(this);_class7.prototype.__init66.call(this);_class7.prototype.__init67.call(this);_class7.prototype.__init68.call(this);_class7.prototype.__init69.call(this);_class7.prototype.__init70.call(this);_class7.prototype.__init71.call(this);_class7.prototype.__init72.call(this);_class7.prototype.__init73.call(this);_class7.prototype.__init74.call(this);_class7.prototype.__init75.call(this);_class7.prototype.__init76.call(this);_class7.prototype.__init77.call(this);_class7.prototype.__init78.call(this);_class7.prototype.__init79.call(this);_class7.prototype.__init80.call(this);_class7.prototype.__init81.call(this);_class7.prototype.__init82.call(this);_class7.prototype.__init83.call(this);_class7.prototype.__init84.call(this);_class7.prototype.__init85.call(this);_class7.prototype.__init86.call(this);_class7.prototype.__init87.call(this);_class7.prototype.__init88.call(this);_class7.prototype.__init89.call(this);_class7.prototype.__init90.call(this);_class7.prototype.__init91.call(this);_class7.prototype.__init92.call(this);_class7.prototype.__init93.call(this);_class7.prototype.__init94.call(this);_class7.prototype.__init95.call(this);_class7.prototype.__init96.call(this);_class7.prototype.__init97.call(this);_class7.prototype.__init98.call(this);_class7.prototype.__init99.call(this);_class7.prototype.__init100.call(this);_class7.prototype.__init101.call(this);_class7.prototype.__init102.call(this);_class7.prototype.__init103.call(this);_class7.prototype.__init104.call(this);_class7.prototype.__init105.call(this);_class7.prototype.__init106.call(this);_class7.prototype.__init107.call(this);_class7.prototype.__init108.call(this);_class7.prototype.__init109.call(this);_class7.prototype.__init110.call(this);_class7.prototype.__init111.call(this);_class7.prototype.__init112.call(this);_class7.prototype.__init113.call(this);_class7.prototype.__init114.call(this);_class7.prototype.__init115.call(this);_class7.prototype.__init116.call(this);_class7.prototype.__init117.call(this);_class7.prototype.__init118.call(this);_class7.prototype.__init119.call(this);_class7.prototype.__init120.call(this);_class7.prototype.__init121.call(this);_class7.prototype.__init122.call(this);_class7.prototype.__init123.call(this);_class7.prototype.__init124.call(this);_class7.prototype.__init125.call(this);_class7.prototype.__init126.call(this);_class7.prototype.__init127.call(this);_class7.prototype.__init128.call(this);_class7.prototype.__init129.call(this);_class7.prototype.__init130.call(this);_class7.prototype.__init131.call(this);_class7.prototype.__init132.call(this);_class7.prototype.__init133.call(this);_class7.prototype.__init134.call(this);_class7.prototype.__init135.call(this);_class7.prototype.__init136.call(this);_class7.prototype.__init137.call(this);_class7.prototype.__init138.call(this);_class7.prototype.__init139.call(this);_class7.prototype.__init140.call(this);_class7.prototype.__init141.call(this);_class7.prototype.__init142.call(this);_class7.prototype.__init143.call(this);_class7.prototype.__init144.call(this);_class7.prototype.__init145.call(this);_class7.prototype.__init146.call(this);_class7.prototype.__init147.call(this);_class7.prototype.__init148.call(this);_class7.prototype.__init149.call(this);_class7.prototype.__init150.call(this);_class7.prototype.__init151.call(this);_class7.prototype.__init152.call(this);_class7.prototype.__init153.call(this);_class7.prototype.__init154.call(this);_class7.prototype.__init155.call(this);_class7.prototype.__init156.call(this);_class7.prototype.__init157.call(this);
3023
3060
  this.canvas = canvas;
3024
3061
  this.debugA11y = _nullishCoalesce(options.debugA11y, () => ( false));
3025
3062
  this.disableWindowResize = _nullishCoalesce(options.disableWindowResize, () => ( false));
@@ -3044,6 +3081,7 @@ var Scene = (_class7 = class _Scene {
3044
3081
  this.contentProjectionEnabled = _nullishCoalesce(options.contentProjection, () => ( true));
3045
3082
  this.contentProjectionMargin = options.contentProjectionMargin;
3046
3083
  this.contentSemanticMargin = options.contentSemanticMargin;
3084
+ this.contentSemanticBudget = _nullishCoalesce(options.contentSemanticBudget, () => ( DEFAULT_CONTENT_SEMANTIC_BUDGET));
3047
3085
  this.readingDirection = _nullishCoalesce(options.readingDirection, () => ( "ltr"));
3048
3086
  this.renderMode = _nullishCoalesce(options.renderMode, () => ( "always"));
3049
3087
  this._devActive = _Scene._devModeDetected();
@@ -3325,12 +3363,31 @@ var Scene = (_class7 = class _Scene {
3325
3363
  }
3326
3364
  return null;
3327
3365
  }
3366
+ /**
3367
+ * Does the document hold a selection right now, memoized for this sync walk?
3368
+ *
3369
+ * Pays one forced layout per walk instead of one per rebuilt element — see
3370
+ * {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
3371
+ * answer is `false` no element can own a selection, so every per-element
3372
+ * ownership test can be skipped without touching the object.
3373
+ */
3374
+ contentSelectionPresent() {
3375
+ if (this.contentSelectionPresentThisSync !== null) {
3376
+ return this.contentSelectionPresentThisSync;
3377
+ }
3378
+ const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3379
+ const present = !!selection && (!!selection.anchorNode || !!selection.focusNode);
3380
+ this.contentSelectionPresentThisSync = present;
3381
+ return present;
3382
+ }
3328
3383
  releaseContentSelectionForRebuild(el) {
3384
+ if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) return;
3329
3385
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3330
3386
  const ownsSelection = this.contentSelectionAnchor && el.contains(this.contentSelectionAnchor.node) || (_optionalChain([selection, 'optionalAccess', _85 => _85.anchorNode]) ? el.contains(selection.anchorNode) : false) || (_optionalChain([selection, 'optionalAccess', _86 => _86.focusNode]) ? el.contains(selection.focusNode) : false);
3331
3387
  if (!ownsSelection) return;
3332
3388
  this.endContentSelectionDrag();
3333
3389
  _optionalChain([selection, 'optionalAccess', _87 => _87.removeAllRanges, 'call', _88 => _88()]);
3390
+ this.contentSelectionPresentThisSync = null;
3334
3391
  }
3335
3392
  /**
3336
3393
  * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
@@ -3348,6 +3405,10 @@ var Scene = (_class7 = class _Scene {
3348
3405
  * restore against).
3349
3406
  */
3350
3407
  preserveContentSelectionAcrossRebuild(el, rebuild) {
3408
+ if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) {
3409
+ rebuild();
3410
+ return;
3411
+ }
3351
3412
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3352
3413
  const owns = !!selection && !this.blankRegionSelectionDrag && ((selection.anchorNode ? el.contains(selection.anchorNode) : false) || (selection.focusNode ? el.contains(selection.focusNode) : false));
3353
3414
  if (!owns || !selection.anchorNode || !selection.focusNode) {
@@ -4043,6 +4104,11 @@ var Scene = (_class7 = class _Scene {
4043
4104
  }
4044
4105
  syncA11y(node, container = null) {
4045
4106
  if (!this.a11yRoot) return;
4107
+ if (node === this.root) {
4108
+ this.contentSemanticBudgetLeft = this.contentSemanticBudget;
4109
+ this.contentSemanticDeferred = false;
4110
+ this.contentSelectionPresentThisSync = null;
4111
+ }
4046
4112
  if (node.isDOMPortal) {
4047
4113
  return;
4048
4114
  }
@@ -4461,7 +4527,9 @@ var Scene = (_class7 = class _Scene {
4461
4527
  * transparent DOM node positioned over the drawn glyphs. Runs on the a11y
4462
4528
  * sync cadence; all writes are dirty-checked. Off-viewport projections are
4463
4529
  * hidden (`display: none`) so text-heavy scenes only materialize what is
4464
- * visible to the browser's text machinery anyway.
4530
+ * visible to the browser's text machinery anyway — except in the coarse
4531
+ * (resident) tier, which stays displayed because hiding it would make its text
4532
+ * unfindable and remove it from the accessibility tree, defeating the tier.
4465
4533
  */
4466
4534
  /**
4467
4535
  * Whether `node`'s world-space box, expanded by `margin` px on every side,
@@ -4470,8 +4538,15 @@ var Scene = (_class7 = class _Scene {
4470
4538
  * at `margin = contentProjectionMargin`) and for the exact `display:none`
4471
4539
  * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
4472
4540
  * culling and always count as visible, matching the legacy behavior.
4541
+ *
4542
+ * `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower
4543
+ * question "does this box overlap the viewport at all". The coarse content tier
4544
+ * needs the two apart: text that is merely off-viewport is clipped by
4545
+ * `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text
4546
+ * rejected by an ancestor clip box that itself overlaps the viewport would sit
4547
+ * transparently on top of whatever is really drawn there.
4473
4548
  */
4474
- projectionBoxVisible(node, tf, margin) {
4549
+ projectionBoxVisible(node, tf, margin, viewportOnly = false) {
4475
4550
  if (!(node.width > 0 && node.height > 0)) return true;
4476
4551
  const { a, b, c, d, e, f } = tf;
4477
4552
  const worldCorners = [];
@@ -4493,6 +4568,7 @@ var Scene = (_class7 = class _Scene {
4493
4568
  if (!(maxX >= -margin && minX <= this.width + margin && maxY >= -margin && minY <= this.height + margin)) {
4494
4569
  return false;
4495
4570
  }
4571
+ if (viewportOnly) return true;
4496
4572
  for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
4497
4573
  if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
4498
4574
  let localMinX = Infinity;
@@ -4595,6 +4671,10 @@ var Scene = (_class7 = class _Scene {
4595
4671
  return;
4596
4672
  }
4597
4673
  }
4674
+ if (tier === "coarse" && !el && this.contentSemanticBudgetLeft <= 0) {
4675
+ this.contentSemanticDeferred = true;
4676
+ return;
4677
+ }
4598
4678
  const projection = node.getContentProjection(
4599
4679
  lineBand ? { minY: lineBand.minY, maxY: lineBand.maxY } : void 0
4600
4680
  );
@@ -4603,6 +4683,7 @@ var Scene = (_class7 = class _Scene {
4603
4683
  return;
4604
4684
  }
4605
4685
  if (!el) {
4686
+ if (tier === "coarse") this.contentSemanticBudgetLeft--;
4606
4687
  el = document.createElement("div");
4607
4688
  el.setAttribute("data-vecto-content", node.id);
4608
4689
  const s = el.style;
@@ -4737,7 +4818,8 @@ var Scene = (_class7 = class _Scene {
4737
4818
  if (node.width > 0) el.style.width = `${node.width}px`;
4738
4819
  if (node.height > 0) el.style.height = `${node.height}px`;
4739
4820
  el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
4740
- const display = visible ? "" : "none";
4821
+ const residentTier = semanticMargin > interactionMargin;
4822
+ const display = visible || residentTier && !this.projectionBoxVisible(node, worldTf, 0, true) ? "" : "none";
4741
4823
  if (el.style.display !== display) el.style.display = display;
4742
4824
  if (epoch !== null) {
4743
4825
  const { a: a2, b: b2, c: c2, d: d2, e: e2, f: f2 } = worldTf;
@@ -5380,7 +5462,7 @@ var Scene = (_class7 = class _Scene {
5380
5462
  if (!this.isRunning) return;
5381
5463
  if (!this._canvasOnScreen) return;
5382
5464
  let cap = this.effectiveMaxFPS();
5383
- const isIdle = !this.dirty && !this.frameHadAnimation;
5465
+ const isIdle = !this.dirty && !this.frameHadAnimation && !this.contentSemanticDeferred;
5384
5466
  if (isIdle && this.autoThrottle && this.renderMode === "always" && this.maxFPS > 0) {
5385
5467
  cap = Math.min(cap, 2);
5386
5468
  }
@@ -6155,17 +6237,17 @@ var TextEntity = (_class8 = class extends _chunkTRT2TRCWjs.Entity {
6155
6237
 
6156
6238
 
6157
6239
 
6158
- __init154() {this.nodes = []}
6240
+ __init158() {this.nodes = []}
6159
6241
 
6160
- __init155() {this.fillStyle = "#94a3b8"}
6161
- __init156() {this.strokeStyle = null}
6162
- __init157() {this.hoveredFillStyle = "#ffffff"}
6163
- __init158() {this.lineWidth = 1}
6164
- __init159() {this.isHovered = false}
6242
+ __init159() {this.fillStyle = "#94a3b8"}
6243
+ __init160() {this.strokeStyle = null}
6244
+ __init161() {this.hoveredFillStyle = "#ffffff"}
6245
+ __init162() {this.lineWidth = 1}
6246
+ __init163() {this.isHovered = false}
6165
6247
  /** Bumped by {@link applyLayout}; read by `Scene` to skip an unchanged sync. */
6166
- __init160() {this.contentEpoch = 0}
6248
+ __init164() {this.contentEpoch = 0}
6167
6249
  constructor(text, atlas, maxWidth, fontSize = 24) {
6168
- super();_class8.prototype.__init154.call(this);_class8.prototype.__init155.call(this);_class8.prototype.__init156.call(this);_class8.prototype.__init157.call(this);_class8.prototype.__init158.call(this);_class8.prototype.__init159.call(this);_class8.prototype.__init160.call(this);;
6250
+ super();_class8.prototype.__init158.call(this);_class8.prototype.__init159.call(this);_class8.prototype.__init160.call(this);_class8.prototype.__init161.call(this);_class8.prototype.__init162.call(this);_class8.prototype.__init163.call(this);_class8.prototype.__init164.call(this);;
6169
6251
  this.text = text;
6170
6252
  this.atlas = atlas;
6171
6253
  this.fontSize = fontSize;
@@ -6284,15 +6366,15 @@ var TextEntity = (_class8 = class extends _chunkTRT2TRCWjs.Entity {
6284
6366
  // src/components/GridTextEntity.ts
6285
6367
  var GridTextEntity = (_class9 = class extends _chunkTRT2TRCWjs.Entity {
6286
6368
 
6287
- __init161() {this.fillStyle = "#ffffff"}
6288
- __init162() {this.grid = []}
6369
+ __init165() {this.fillStyle = "#ffffff"}
6370
+ __init166() {this.grid = []}
6289
6371
  // Array of rows
6290
- __init163() {this.cols = 0}
6291
- __init164() {this.rows = 0}
6372
+ __init167() {this.cols = 0}
6373
+ __init168() {this.rows = 0}
6292
6374
 
6293
6375
 
6294
6376
  constructor(_atlas, fontSize = 10) {
6295
- super();_class9.prototype.__init161.call(this);_class9.prototype.__init162.call(this);_class9.prototype.__init163.call(this);_class9.prototype.__init164.call(this);;
6377
+ super();_class9.prototype.__init165.call(this);_class9.prototype.__init166.call(this);_class9.prototype.__init167.call(this);_class9.prototype.__init168.call(this);;
6296
6378
  this.fontSize = fontSize;
6297
6379
  this.charWidth = fontSize * 1;
6298
6380
  this.charHeight = fontSize * 1.1;
@@ -6384,23 +6466,23 @@ var SplineEntity = (_class10 = class extends _chunkTRT2TRCWjs.Entity {
6384
6466
 
6385
6467
 
6386
6468
 
6387
- __init165() {this.offscreen = null}
6388
- __init166() {this.baked = false}
6469
+ __init169() {this.offscreen = null}
6470
+ __init170() {this.baked = false}
6389
6471
  /** Logical (CSS-pixel) size of the baked bitmap — the blit destination size. */
6390
- __init167() {this.bakedWidth = 0}
6391
- __init168() {this.bakedHeight = 0}
6472
+ __init171() {this.bakedWidth = 0}
6473
+ __init172() {this.bakedHeight = 0}
6392
6474
  /** Gradient strokes can't be baked to a solid-color bitmap; they render per-frame. */
6393
6475
 
6394
6476
  /** Lazily-flattened polylines (one Float32Array of [x,y,...] per segment) for hit-testing. */
6395
- __init169() {this.polylines = null}
6477
+ __init173() {this.polylines = null}
6396
6478
  /**
6397
6479
  * When `true`, the renderer draws a rounded-rect outline of the entity's
6398
6480
  * local bounds after painting the curves. Useful for drag feedback and
6399
6481
  * debugging hit areas. Defaults to `false`.
6400
6482
  */
6401
- __init170() {this.showBounds = false}
6483
+ __init174() {this.showBounds = false}
6402
6484
  constructor(doc, opts = {}) {
6403
- super();_class10.prototype.__init165.call(this);_class10.prototype.__init166.call(this);_class10.prototype.__init167.call(this);_class10.prototype.__init168.call(this);_class10.prototype.__init169.call(this);_class10.prototype.__init170.call(this);;
6485
+ super();_class10.prototype.__init169.call(this);_class10.prototype.__init170.call(this);_class10.prototype.__init171.call(this);_class10.prototype.__init172.call(this);_class10.prototype.__init173.call(this);_class10.prototype.__init174.call(this);;
6404
6486
  this.doc = doc;
6405
6487
  this.lineWidth = _nullishCoalesce(opts.lineWidth, () => ( 2));
6406
6488
  this.cache = _nullishCoalesce(opts.cache, () => ( true));
@@ -6781,19 +6863,19 @@ var _math = require('@vectojs/math'); _createStarExport(_math);
6781
6863
  // src/tree/DOMPortalEntity.ts
6782
6864
  var DOMPortalEntity = (_class11 = class extends _chunkTRT2TRCWjs.Entity {
6783
6865
 
6784
- __init171() {this.isDOMPortal = true}
6785
- __init172() {this.domListeners = []}
6786
- __init173() {this.resizeObserver = null}
6787
- __init174() {this.domBound = false}
6788
- __init175() {this.cachedWidth = 100}
6789
- __init176() {this.cachedHeight = 100}
6790
- __init177() {this.lastWidth = ""}
6791
- __init178() {this.lastHeight = ""}
6792
- __init179() {this.lastTransform = ""}
6793
- __init180() {this.lastZIndex = ""}
6794
- __init181() {this.lastOpacity = ""}
6866
+ __init175() {this.isDOMPortal = true}
6867
+ __init176() {this.domListeners = []}
6868
+ __init177() {this.resizeObserver = null}
6869
+ __init178() {this.domBound = false}
6870
+ __init179() {this.cachedWidth = 100}
6871
+ __init180() {this.cachedHeight = 100}
6872
+ __init181() {this.lastWidth = ""}
6873
+ __init182() {this.lastHeight = ""}
6874
+ __init183() {this.lastTransform = ""}
6875
+ __init184() {this.lastZIndex = ""}
6876
+ __init185() {this.lastOpacity = ""}
6795
6877
  constructor(domElement, width, height, id) {
6796
- super(id);_class11.prototype.__init171.call(this);_class11.prototype.__init172.call(this);_class11.prototype.__init173.call(this);_class11.prototype.__init174.call(this);_class11.prototype.__init175.call(this);_class11.prototype.__init176.call(this);_class11.prototype.__init177.call(this);_class11.prototype.__init178.call(this);_class11.prototype.__init179.call(this);_class11.prototype.__init180.call(this);_class11.prototype.__init181.call(this);;
6878
+ super(id);_class11.prototype.__init175.call(this);_class11.prototype.__init176.call(this);_class11.prototype.__init177.call(this);_class11.prototype.__init178.call(this);_class11.prototype.__init179.call(this);_class11.prototype.__init180.call(this);_class11.prototype.__init181.call(this);_class11.prototype.__init182.call(this);_class11.prototype.__init183.call(this);_class11.prototype.__init184.call(this);_class11.prototype.__init185.call(this);;
6797
6879
  this.domElement = domElement;
6798
6880
  this.width = _nullishCoalesce(width, () => ( 0));
6799
6881
  this.height = _nullishCoalesce(height, () => ( 0));
@@ -6951,4 +7033,5 @@ Scene.registerWebGPUParticleSystemManager(_chunkM73XB4ZKjs.WebGPUParticleSystemM
6951
7033
 
6952
7034
 
6953
7035
 
6954
- exports.CanvasRenderer = _chunkM73XB4ZKjs.CanvasRenderer; exports.Circle = Circle; exports.ComputeParticleEntity = ComputeParticleEntity; exports.DOMPortalEntity = DOMPortalEntity; exports.Entity = _chunkTRT2TRCWjs.Entity; exports.GlyphRasterAtlas = _chunkM73XB4ZKjs.GlyphRasterAtlas; exports.GridTextEntity = GridTextEntity; exports.Group = Group; exports.MSDFTextEntity = _chunkTRT2TRCWjs.MSDFTextEntity; exports.PARTICLE_OFFSET_LIFE = PARTICLE_OFFSET_LIFE; exports.PARTICLE_OFFSET_ORIGIN_X = PARTICLE_OFFSET_ORIGIN_X; exports.PARTICLE_OFFSET_ORIGIN_Y = PARTICLE_OFFSET_ORIGIN_Y; exports.PARTICLE_OFFSET_POSITION_X = PARTICLE_OFFSET_POSITION_X; exports.PARTICLE_OFFSET_POSITION_Y = PARTICLE_OFFSET_POSITION_Y; exports.PARTICLE_OFFSET_SIZE = PARTICLE_OFFSET_SIZE; exports.PARTICLE_OFFSET_VELOCITY_X = PARTICLE_OFFSET_VELOCITY_X; exports.PARTICLE_OFFSET_VELOCITY_Y = PARTICLE_OFFSET_VELOCITY_Y; exports.PARTICLE_STRIDE_FLOATS = PARTICLE_STRIDE_FLOATS; exports.REDUCED_MOTION_FPS = REDUCED_MOTION_FPS; exports.Rect = Rect; exports.SCENE_OPTION_KEYS = SCENE_OPTION_KEYS; exports.SVGEntity = _chunkTRT2TRCWjs.SVGEntity; exports.SVGRenderer = _chunkM73XB4ZKjs.SVGRenderer; exports.Scene = Scene; exports.SplineEntity = SplineEntity; exports.TextEntity = TextEntity; exports.TextRasterCache = _chunkM73XB4ZKjs.TextRasterCache; exports.VECTO_USER_TIMING = VECTO_USER_TIMING; exports.VectoJSEvent = _chunkTRT2TRCWjs.VectoJSEvent; exports.WebGPUParticleSystemManager = _chunkM73XB4ZKjs.WebGPUParticleSystemManager; exports.beginVectoUserTiming = beginVectoUserTiming; exports.contentLineInHint = _chunkTRT2TRCWjs.contentLineInHint; exports.createWebGLPointRenderer = _chunkM73XB4ZKjs.createWebGLPointRenderer; exports.endVectoUserTiming = endVectoUserTiming; exports.installRendererDevTraps = _chunkM73XB4ZKjs.installRendererDevTraps; exports.isRendererDevMode = _chunkM73XB4ZKjs.isRendererDevMode; exports.isSafeUrl = _chunkM73XB4ZKjs.isSafeUrl; exports.loadSpline = loadSpline; exports.measureVectoUserTiming = measureVectoUserTiming; exports.parseColorToRGBA = _chunkM73XB4ZKjs.parseColorToRGBA; exports.polySegmentToBezier = polySegmentToBezier; exports.sanitizeUrl = _chunkM73XB4ZKjs.sanitizeUrl; exports.setRendererDevMode = _chunkM73XB4ZKjs.setRendererDevMode;
7036
+
7037
+ exports.CanvasRenderer = _chunkM73XB4ZKjs.CanvasRenderer; exports.Circle = Circle; exports.ComputeParticleEntity = ComputeParticleEntity; exports.DEFAULT_CONTENT_SEMANTIC_BUDGET = DEFAULT_CONTENT_SEMANTIC_BUDGET; exports.DOMPortalEntity = DOMPortalEntity; exports.Entity = _chunkTRT2TRCWjs.Entity; exports.GlyphRasterAtlas = _chunkM73XB4ZKjs.GlyphRasterAtlas; exports.GridTextEntity = GridTextEntity; exports.Group = Group; exports.MSDFTextEntity = _chunkTRT2TRCWjs.MSDFTextEntity; exports.PARTICLE_OFFSET_LIFE = PARTICLE_OFFSET_LIFE; exports.PARTICLE_OFFSET_ORIGIN_X = PARTICLE_OFFSET_ORIGIN_X; exports.PARTICLE_OFFSET_ORIGIN_Y = PARTICLE_OFFSET_ORIGIN_Y; exports.PARTICLE_OFFSET_POSITION_X = PARTICLE_OFFSET_POSITION_X; exports.PARTICLE_OFFSET_POSITION_Y = PARTICLE_OFFSET_POSITION_Y; exports.PARTICLE_OFFSET_SIZE = PARTICLE_OFFSET_SIZE; exports.PARTICLE_OFFSET_VELOCITY_X = PARTICLE_OFFSET_VELOCITY_X; exports.PARTICLE_OFFSET_VELOCITY_Y = PARTICLE_OFFSET_VELOCITY_Y; exports.PARTICLE_STRIDE_FLOATS = PARTICLE_STRIDE_FLOATS; exports.REDUCED_MOTION_FPS = REDUCED_MOTION_FPS; exports.Rect = Rect; exports.SCENE_OPTION_KEYS = SCENE_OPTION_KEYS; exports.SVGEntity = _chunkTRT2TRCWjs.SVGEntity; exports.SVGRenderer = _chunkM73XB4ZKjs.SVGRenderer; exports.Scene = Scene; exports.SplineEntity = SplineEntity; exports.TextEntity = TextEntity; exports.TextRasterCache = _chunkM73XB4ZKjs.TextRasterCache; exports.VECTO_USER_TIMING = VECTO_USER_TIMING; exports.VectoJSEvent = _chunkTRT2TRCWjs.VectoJSEvent; exports.WebGPUParticleSystemManager = _chunkM73XB4ZKjs.WebGPUParticleSystemManager; exports.beginVectoUserTiming = beginVectoUserTiming; exports.contentLineInHint = _chunkTRT2TRCWjs.contentLineInHint; exports.createWebGLPointRenderer = _chunkM73XB4ZKjs.createWebGLPointRenderer; exports.endVectoUserTiming = endVectoUserTiming; exports.installRendererDevTraps = _chunkM73XB4ZKjs.installRendererDevTraps; exports.isRendererDevMode = _chunkM73XB4ZKjs.isRendererDevMode; exports.isSafeUrl = _chunkM73XB4ZKjs.isSafeUrl; exports.loadSpline = loadSpline; exports.measureVectoUserTiming = measureVectoUserTiming; exports.parseColorToRGBA = _chunkM73XB4ZKjs.parseColorToRGBA; exports.polySegmentToBezier = polySegmentToBezier; exports.sanitizeUrl = _chunkM73XB4ZKjs.sanitizeUrl; exports.setRendererDevMode = _chunkM73XB4ZKjs.setRendererDevMode;
package/dist/index.mjs CHANGED
@@ -1379,6 +1379,7 @@ var SCENE_OPTION_KEYS = [
1379
1379
  "autoThrottle",
1380
1380
  "contentProjection",
1381
1381
  "contentProjectionMargin",
1382
+ "contentSemanticBudget",
1382
1383
  "contentSemanticMargin",
1383
1384
  "debugA11y",
1384
1385
  "disableWindowResize",
@@ -1432,6 +1433,7 @@ function parseInlinePx(value) {
1432
1433
  const n = parseFloat(value);
1433
1434
  return Number.isFinite(n) && n > 0 ? n : null;
1434
1435
  }
1436
+ var DEFAULT_CONTENT_SEMANTIC_BUDGET = 256;
1435
1437
  function projectionGridLineWindow(grid, projectionLines, band) {
1436
1438
  const count = grid.lines.length;
1437
1439
  const all = { start: 0, end: count, gated: false };
@@ -2007,6 +2009,41 @@ var Scene = class _Scene {
2007
2009
  // any projected DOM. `undefined` → falls back to contentProjectionMargin, so
2008
2010
  // the default keeps one gate. `Infinity` = every block keeps resident text.
2009
2011
  contentSemanticMargin = void 0;
2012
+ // How many coarse-tier blocks may be MATERIALIZED per sync, spreading the
2013
+ // resident tier's document-open cost across frames. `Infinity` = one
2014
+ // synchronous pass.
2015
+ contentSemanticBudget = DEFAULT_CONTENT_SEMANTIC_BUDGET;
2016
+ // Remaining materializations in the CURRENT sync. Reset at the start of each
2017
+ // a11y walk; decremented per coarse block that creates its element.
2018
+ contentSemanticBudgetLeft = 0;
2019
+ // Set when a sync deferred at least one block, so the scene knows to keep
2020
+ // drawing frames until the resident tier is complete. Without it a static
2021
+ // scene in `onDemand` mode would stop rendering with the document half
2022
+ // materialized and never finish.
2023
+ contentSemanticDeferred = false;
2024
+ /**
2025
+ * Per-sync memo of "does the document hold a selection at all".
2026
+ *
2027
+ * Reading ANY property of a `Selection` (`anchorNode`, `rangeCount`, `type`,
2028
+ * `isCollapsed`) forces a synchronous layout, because Blink validates the
2029
+ * selection against current box geometry before answering. Measured in real
2030
+ * Chrome against a 1000-carrier subtree with layout dirtied between reads:
2031
+ * `anchorNode` 0.5ms, `rangeCount` 0.4ms, `type` 0.5ms, `isCollapsed` 0.5ms —
2032
+ * all indistinguishable from `offsetHeight` (0.5ms), against a 0ms floor for
2033
+ * mutating without reading. So there is no cheap property to probe with; the
2034
+ * only way to avoid the layout is to not touch the object at all.
2035
+ *
2036
+ * Materializing a block rebuilds its carriers, which asks whether the rebuild
2037
+ * would destroy a selection. Once per block, that read cost a forced layout
2038
+ * over the whole (and growing) projection subtree, which is what made
2039
+ * per-block cost rise with resident count: profiled at 1973 forced layouts
2040
+ * totalling 633ms of an 847ms 1000-block drain (75%).
2041
+ *
2042
+ * A selection is a single document-wide object and a sync walk cannot yield to
2043
+ * the user, so its presence cannot change mid-walk. Resolving it once per walk
2044
+ * turns O(blocks) forced layouts into O(1). `null` = not yet resolved.
2045
+ */
2046
+ contentSelectionPresentThisSync = null;
2010
2047
  /**
2011
2048
  * True while a text-selection drag that started on a projection's blank
2012
2049
  * region (no text node under the press) is being driven manually — the
@@ -3043,6 +3080,7 @@ var Scene = class _Scene {
3043
3080
  this.contentProjectionEnabled = options.contentProjection ?? true;
3044
3081
  this.contentProjectionMargin = options.contentProjectionMargin;
3045
3082
  this.contentSemanticMargin = options.contentSemanticMargin;
3083
+ this.contentSemanticBudget = options.contentSemanticBudget ?? DEFAULT_CONTENT_SEMANTIC_BUDGET;
3046
3084
  this.readingDirection = options.readingDirection ?? "ltr";
3047
3085
  this.renderMode = options.renderMode ?? "always";
3048
3086
  this._devActive = _Scene._devModeDetected();
@@ -3324,12 +3362,31 @@ var Scene = class _Scene {
3324
3362
  }
3325
3363
  return null;
3326
3364
  }
3365
+ /**
3366
+ * Does the document hold a selection right now, memoized for this sync walk?
3367
+ *
3368
+ * Pays one forced layout per walk instead of one per rebuilt element — see
3369
+ * {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
3370
+ * answer is `false` no element can own a selection, so every per-element
3371
+ * ownership test can be skipped without touching the object.
3372
+ */
3373
+ contentSelectionPresent() {
3374
+ if (this.contentSelectionPresentThisSync !== null) {
3375
+ return this.contentSelectionPresentThisSync;
3376
+ }
3377
+ const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3378
+ const present = !!selection && (!!selection.anchorNode || !!selection.focusNode);
3379
+ this.contentSelectionPresentThisSync = present;
3380
+ return present;
3381
+ }
3327
3382
  releaseContentSelectionForRebuild(el) {
3383
+ if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) return;
3328
3384
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3329
3385
  const ownsSelection = this.contentSelectionAnchor && el.contains(this.contentSelectionAnchor.node) || (selection?.anchorNode ? el.contains(selection.anchorNode) : false) || (selection?.focusNode ? el.contains(selection.focusNode) : false);
3330
3386
  if (!ownsSelection) return;
3331
3387
  this.endContentSelectionDrag();
3332
3388
  selection?.removeAllRanges();
3389
+ this.contentSelectionPresentThisSync = null;
3333
3390
  }
3334
3391
  /**
3335
3392
  * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
@@ -3347,6 +3404,10 @@ var Scene = class _Scene {
3347
3404
  * restore against).
3348
3405
  */
3349
3406
  preserveContentSelectionAcrossRebuild(el, rebuild) {
3407
+ if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) {
3408
+ rebuild();
3409
+ return;
3410
+ }
3350
3411
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
3351
3412
  const owns = !!selection && !this.blankRegionSelectionDrag && ((selection.anchorNode ? el.contains(selection.anchorNode) : false) || (selection.focusNode ? el.contains(selection.focusNode) : false));
3352
3413
  if (!owns || !selection.anchorNode || !selection.focusNode) {
@@ -4042,6 +4103,11 @@ var Scene = class _Scene {
4042
4103
  }
4043
4104
  syncA11y(node, container = null) {
4044
4105
  if (!this.a11yRoot) return;
4106
+ if (node === this.root) {
4107
+ this.contentSemanticBudgetLeft = this.contentSemanticBudget;
4108
+ this.contentSemanticDeferred = false;
4109
+ this.contentSelectionPresentThisSync = null;
4110
+ }
4045
4111
  if (node.isDOMPortal) {
4046
4112
  return;
4047
4113
  }
@@ -4460,7 +4526,9 @@ var Scene = class _Scene {
4460
4526
  * transparent DOM node positioned over the drawn glyphs. Runs on the a11y
4461
4527
  * sync cadence; all writes are dirty-checked. Off-viewport projections are
4462
4528
  * hidden (`display: none`) so text-heavy scenes only materialize what is
4463
- * visible to the browser's text machinery anyway.
4529
+ * visible to the browser's text machinery anyway — except in the coarse
4530
+ * (resident) tier, which stays displayed because hiding it would make its text
4531
+ * unfindable and remove it from the accessibility tree, defeating the tier.
4464
4532
  */
4465
4533
  /**
4466
4534
  * Whether `node`'s world-space box, expanded by `margin` px on every side,
@@ -4469,8 +4537,15 @@ var Scene = class _Scene {
4469
4537
  * at `margin = contentProjectionMargin`) and for the exact `display:none`
4470
4538
  * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
4471
4539
  * culling and always count as visible, matching the legacy behavior.
4540
+ *
4541
+ * `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower
4542
+ * question "does this box overlap the viewport at all". The coarse content tier
4543
+ * needs the two apart: text that is merely off-viewport is clipped by
4544
+ * `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text
4545
+ * rejected by an ancestor clip box that itself overlaps the viewport would sit
4546
+ * transparently on top of whatever is really drawn there.
4472
4547
  */
4473
- projectionBoxVisible(node, tf, margin) {
4548
+ projectionBoxVisible(node, tf, margin, viewportOnly = false) {
4474
4549
  if (!(node.width > 0 && node.height > 0)) return true;
4475
4550
  const { a, b, c, d, e, f } = tf;
4476
4551
  const worldCorners = [];
@@ -4492,6 +4567,7 @@ var Scene = class _Scene {
4492
4567
  if (!(maxX >= -margin && minX <= this.width + margin && maxY >= -margin && minY <= this.height + margin)) {
4493
4568
  return false;
4494
4569
  }
4570
+ if (viewportOnly) return true;
4495
4571
  for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
4496
4572
  if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
4497
4573
  let localMinX = Infinity;
@@ -4594,6 +4670,10 @@ var Scene = class _Scene {
4594
4670
  return;
4595
4671
  }
4596
4672
  }
4673
+ if (tier === "coarse" && !el && this.contentSemanticBudgetLeft <= 0) {
4674
+ this.contentSemanticDeferred = true;
4675
+ return;
4676
+ }
4597
4677
  const projection = node.getContentProjection(
4598
4678
  lineBand ? { minY: lineBand.minY, maxY: lineBand.maxY } : void 0
4599
4679
  );
@@ -4602,6 +4682,7 @@ var Scene = class _Scene {
4602
4682
  return;
4603
4683
  }
4604
4684
  if (!el) {
4685
+ if (tier === "coarse") this.contentSemanticBudgetLeft--;
4605
4686
  el = document.createElement("div");
4606
4687
  el.setAttribute("data-vecto-content", node.id);
4607
4688
  const s = el.style;
@@ -4736,7 +4817,8 @@ var Scene = class _Scene {
4736
4817
  if (node.width > 0) el.style.width = `${node.width}px`;
4737
4818
  if (node.height > 0) el.style.height = `${node.height}px`;
4738
4819
  el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
4739
- const display = visible ? "" : "none";
4820
+ const residentTier = semanticMargin > interactionMargin;
4821
+ const display = visible || residentTier && !this.projectionBoxVisible(node, worldTf, 0, true) ? "" : "none";
4740
4822
  if (el.style.display !== display) el.style.display = display;
4741
4823
  if (epoch !== null) {
4742
4824
  const { a: a2, b: b2, c: c2, d: d2, e: e2, f: f2 } = worldTf;
@@ -5379,7 +5461,7 @@ var Scene = class _Scene {
5379
5461
  if (!this.isRunning) return;
5380
5462
  if (!this._canvasOnScreen) return;
5381
5463
  let cap = this.effectiveMaxFPS();
5382
- const isIdle = !this.dirty && !this.frameHadAnimation;
5464
+ const isIdle = !this.dirty && !this.frameHadAnimation && !this.contentSemanticDeferred;
5383
5465
  if (isIdle && this.autoThrottle && this.renderMode === "always" && this.maxFPS > 0) {
5384
5466
  cap = Math.min(cap, 2);
5385
5467
  }
@@ -6910,6 +6992,7 @@ export {
6910
6992
  CanvasRenderer,
6911
6993
  Circle,
6912
6994
  ComputeParticleEntity,
6995
+ DEFAULT_CONTENT_SEMANTIC_BUDGET,
6913
6996
  DOMPortalEntity,
6914
6997
  Entity,
6915
6998
  GlyphRasterAtlas,
@@ -220,15 +220,45 @@ export interface SceneOptions {
220
220
  * unwindowed carrier band, not from resident text.
221
221
  *
222
222
  * Note the one-time cost. A resident tier materializes one element per block
223
- * on the first sync — measured ~13µs per node created, so ~20ms at 1000 blocks
224
- * and ~146ms at 10000 — as one synchronous block. Steady state is cheap
223
+ * on the first sync — measured unbudgeted at 21.3ms for 1000 blocks and 139.5ms
224
+ * for 10000 on Chrome — as one synchronous block. Steady state is cheap
225
225
  * (unchanged blocks skip via {@link Entity.getContentEpoch}), so this is a
226
- * document-open stall, not a per-frame cost.
226
+ * document-open stall, not a per-frame cost. That stall is what
227
+ * {@link SceneOptions.contentSemanticBudget} spreads across frames.
227
228
  *
228
229
  * Default: whatever `contentProjectionMargin` resolves to, so omitting this
229
230
  * leaves behaviour unchanged.
230
231
  */
231
232
  contentSemanticMargin?: number;
233
+ /**
234
+ * How many resident (coarse-tier) blocks may be materialized in **one** sync,
235
+ * bounding the document-open stall a wide {@link
236
+ * SceneOptions.contentSemanticMargin} otherwise pays all at once.
237
+ *
238
+ * The cost of a resident tier is per node **created**, not per node held: 10000
239
+ * resident blocks cost ~3.0 ms/sync at steady state, while creating them costs
240
+ * ~0.03 ms each plus a per-pass floor that grows with how many are already
241
+ * resident. So the front-load is a *scheduling* problem, and this is the
242
+ * schedule — remaining blocks materialize on subsequent syncs, a few per frame,
243
+ * until the document is fully resident.
244
+ *
245
+ * What it does **not** change is the end state: the same blocks end up with the
246
+ * same DOM, only later. Nothing is dropped, so the reachability the semantic
247
+ * tier exists for is preserved; a block still waiting is simply not yet in the
248
+ * DOM, exactly as a block beyond the margin is not.
249
+ *
250
+ * Applies **only** to the coarse tier. A block inside the interaction margin is
251
+ * on screen and materializes immediately regardless of this budget — deferring
252
+ * visible text would make it briefly unselectable, which is a user-visible
253
+ * regression rather than a cost saving.
254
+ *
255
+ * `Infinity` disables the budget and restores one synchronous pass. Default:
256
+ * {@link DEFAULT_CONTENT_SEMANTIC_BUDGET}. Because the coarse tier exists only
257
+ * when `contentSemanticMargin` is wider than `contentProjectionMargin`, a scene
258
+ * that does not opt into a resident tier has no coarse blocks and is therefore
259
+ * unaffected by any value here.
260
+ */
261
+ contentSemanticBudget?: number;
232
262
  /**
233
263
  * Reading direction used to order the accessibility/automation shadow tree so
234
264
  * keyboard **tab order** and screen-reader traversal follow the *visual*
@@ -268,7 +298,7 @@ export interface SceneOptions {
268
298
  * against. A new option must be added here too — the test suite asserts the two
269
299
  * stay in sync.
270
300
  */
271
- export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
301
+ export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticBudget', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
272
302
  /** Frame-rate the loop is capped to when the OS requests reduced motion. */
273
303
  export declare const REDUCED_MOTION_FPS = 30;
274
304
  /**
@@ -359,6 +389,45 @@ export interface A11yTreeNode {
359
389
  valuemax?: string;
360
390
  children: A11yTreeNode[];
361
391
  }
392
+ /**
393
+ * Default {@link SceneOptions.contentSemanticBudget}: resident blocks
394
+ * materialized per sync.
395
+ *
396
+ * Sized against the two costs a pass actually pays, both measured in real headed
397
+ * Chrome on a 240Hz panel. Per created block is cheap and flat (~0.03ms). What
398
+ * dominates is style+layout of the projection subtree, which scales with how many
399
+ * blocks are already RESIDENT and is paid once per pass: traced at 10000 blocks,
400
+ * `UpdateLayoutTree` 391.7ms + `Layout` 305.8ms over 40 passes (~17ms each), with
401
+ * per-pass cost roughly doubling from the first pass to the last while the number
402
+ * created stayed constant.
403
+ *
404
+ * So total drain cost is approximately `passes × f(resident)`, and a SMALLER
405
+ * budget multiplies the term that does not shrink. Measured to completion, 3
406
+ * repeats, medians:
407
+ *
408
+ * ```text
409
+ * 1000 blocks budget 32 → 67.1ms total, 4.3ms worst pass
410
+ * budget 64 → 54.0ms total, 5.1ms worst pass
411
+ * budget 256 → 27.7ms total, 7.7ms worst pass
412
+ * Infinity → 24.1ms total, 23.6ms worst pass
413
+ * 10000 blocks budget 32 → 3773.2ms total, 42.6ms worst pass
414
+ * budget 64 → 1896.2ms total, 41.6ms worst pass
415
+ * budget 256 → 648.1ms total, 35.2ms worst pass
416
+ * Infinity → 319.4ms total, 307.3ms worst pass
417
+ * ```
418
+ *
419
+ * 256 is where the two goals stop trading against each other. Below it there is no
420
+ * frame-bound improvement at 10000 blocks — every budget lands at 35-43ms, because
421
+ * the worst pass is the LAST one laying out the complete subtree — while total time
422
+ * rises 6x. At 1000 blocks it still holds 7.7ms, inside a 60Hz frame, for less than
423
+ * half the total time of 64.
424
+ *
425
+ * This replaces an earlier default of 64, which was sized against a per-block cost
426
+ * of ~0.4ms. That figure was inflated by a forced layout per materialized block
427
+ * (see `contentSelectionPresentThisSync`); with that removed, 64 spends 6x the
428
+ * total time for no frame-bound gain.
429
+ */
430
+ export declare const DEFAULT_CONTENT_SEMANTIC_BUDGET = 256;
362
431
  /**
363
432
  * Top-level orchestrator that owns the entity tree, drive the render loop,
364
433
  * and maintains the accessibility/automation shadow layer.
@@ -560,6 +629,32 @@ export declare class Scene {
560
629
  private contentProjectionEnabled;
561
630
  private contentProjectionMargin;
562
631
  private contentSemanticMargin;
632
+ private contentSemanticBudget;
633
+ private contentSemanticBudgetLeft;
634
+ private contentSemanticDeferred;
635
+ /**
636
+ * Per-sync memo of "does the document hold a selection at all".
637
+ *
638
+ * Reading ANY property of a `Selection` (`anchorNode`, `rangeCount`, `type`,
639
+ * `isCollapsed`) forces a synchronous layout, because Blink validates the
640
+ * selection against current box geometry before answering. Measured in real
641
+ * Chrome against a 1000-carrier subtree with layout dirtied between reads:
642
+ * `anchorNode` 0.5ms, `rangeCount` 0.4ms, `type` 0.5ms, `isCollapsed` 0.5ms —
643
+ * all indistinguishable from `offsetHeight` (0.5ms), against a 0ms floor for
644
+ * mutating without reading. So there is no cheap property to probe with; the
645
+ * only way to avoid the layout is to not touch the object at all.
646
+ *
647
+ * Materializing a block rebuilds its carriers, which asks whether the rebuild
648
+ * would destroy a selection. Once per block, that read cost a forced layout
649
+ * over the whole (and growing) projection subtree, which is what made
650
+ * per-block cost rise with resident count: profiled at 1973 forced layouts
651
+ * totalling 633ms of an 847ms 1000-block drain (75%).
652
+ *
653
+ * A selection is a single document-wide object and a sync walk cannot yield to
654
+ * the user, so its presence cannot change mid-walk. Resolving it once per walk
655
+ * turns O(blocks) forced layouts into O(1). `null` = not yet resolved.
656
+ */
657
+ private contentSelectionPresentThisSync;
563
658
  /**
564
659
  * True while a text-selection drag that started on a projection's blank
565
660
  * region (no text node under the press) is being driven manually — the
@@ -1101,6 +1196,15 @@ export declare class Scene {
1101
1196
  * to the live DOM selection.
1102
1197
  */
1103
1198
  private contentGridSelectionLine;
1199
+ /**
1200
+ * Does the document hold a selection right now, memoized for this sync walk?
1201
+ *
1202
+ * Pays one forced layout per walk instead of one per rebuilt element — see
1203
+ * {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
1204
+ * answer is `false` no element can own a selection, so every per-element
1205
+ * ownership test can be skipped without touching the object.
1206
+ */
1207
+ private contentSelectionPresent;
1104
1208
  private releaseContentSelectionForRebuild;
1105
1209
  /**
1106
1210
  * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
@@ -1428,7 +1532,9 @@ export declare class Scene {
1428
1532
  * transparent DOM node positioned over the drawn glyphs. Runs on the a11y
1429
1533
  * sync cadence; all writes are dirty-checked. Off-viewport projections are
1430
1534
  * hidden (`display: none`) so text-heavy scenes only materialize what is
1431
- * visible to the browser's text machinery anyway.
1535
+ * visible to the browser's text machinery anyway — except in the coarse
1536
+ * (resident) tier, which stays displayed because hiding it would make its text
1537
+ * unfindable and remove it from the accessibility tree, defeating the tier.
1432
1538
  */
1433
1539
  /**
1434
1540
  * Whether `node`'s world-space box, expanded by `margin` px on every side,
@@ -1437,6 +1543,13 @@ export declare class Scene {
1437
1543
  * at `margin = contentProjectionMargin`) and for the exact `display:none`
1438
1544
  * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
1439
1545
  * culling and always count as visible, matching the legacy behavior.
1546
+ *
1547
+ * `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower
1548
+ * question "does this box overlap the viewport at all". The coarse content tier
1549
+ * needs the two apart: text that is merely off-viewport is clipped by
1550
+ * `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text
1551
+ * rejected by an ancestor clip box that itself overlaps the viewport would sit
1552
+ * transparently on top of whatever is really drawn there.
1440
1553
  */
1441
1554
  private projectionBoxVisible;
1442
1555
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/core",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },