@vectojs/core 1.29.0 → 1.31.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.
@@ -103,6 +103,43 @@ export interface ContentProjectionLine {
103
103
  /** Styled text runs in visual order. */
104
104
  runs?: ContentProjectionRun[];
105
105
  }
106
+ /**
107
+ * Advice from the {@link Scene} about which part of an entity is worth
108
+ * describing in {@link Entity.getContentProjection}.
109
+ *
110
+ * **Purely an optimization, and ignoring it is always correct.** The Scene
111
+ * windows the DOM itself, so an entity that returns its whole document still
112
+ * behaves correctly — it just pays to build lines that get discarded. An entity
113
+ * whose projection is O(glyphs) can use this to make that build O(visible)
114
+ * instead, which is the difference between per-frame cost that scales with the
115
+ * document and cost that scales with the viewport.
116
+ *
117
+ * Why a hint rather than a strict window: the entity owns the mapping from its
118
+ * own text to visual lines, and only it knows things like where a wrapped
119
+ * paragraph begins. Handing it a band and letting it round outward keeps that
120
+ * knowledge in one place. An entity may return more than asked — never less
121
+ * than it can, because text absent from the projection is invisible to
122
+ * find-in-page, copy and, for static text, the screen reader.
123
+ */
124
+ export interface ContentProjectionHint {
125
+ /**
126
+ * Inclusive band of entity-local y worth projecting, already expanded by the
127
+ * scene's `contentProjectionMargin` and intersected with every clipping
128
+ * ancestor. Absent when no useful bound exists (a rotated or skewed
129
+ * transform, a boundless entity), in which case project everything.
130
+ */
131
+ minY?: number;
132
+ maxY?: number;
133
+ }
134
+ /**
135
+ * Whether a line at `y` of height `height` is worth projecting under `hint`.
136
+ *
137
+ * Shared so every consumer rounds the same way: a line is kept when its box
138
+ * overlaps the band at all, which retains a line straddling the edge whole
139
+ * rather than clipping it mid-glyph. Returns `true` when the hint carries no
140
+ * band, so the default is always "project it".
141
+ */
142
+ export declare function contentLineInHint(hint: ContentProjectionHint | undefined, y: number, height: number): boolean;
106
143
  export interface ContentProjection {
107
144
  /** The logical source text exposed to find, selection, copy, and assistive technology. */
108
145
  text: string;
@@ -540,6 +577,42 @@ export declare abstract class Entity {
540
577
  * nodes, so on-top components stay clickable.
541
578
  */
542
579
  a11yFullViewport: boolean;
580
+ /**
581
+ * When this entity's a11y shadow node is materialized.
582
+ *
583
+ * `'eager'` (the default) keeps today's behaviour: a shadow node exists for as
584
+ * long as the entity is `interactive` with a box. That is right for a button or
585
+ * a link, and wrong for thousands of ephemeral, individually-meaningless
586
+ * entities — particles, danmaku, graph nodes — where it produces one DOM node
587
+ * per entity every frame.
588
+ *
589
+ * Measured on 5,000 moving interactive entities (`benchmarks/lazy-a11y/`):
590
+ * eager costs **72.2 ms/frame on Chrome and 114.3 ms on Firefox**, missing even
591
+ * 60 Hz, against **1.55/1.63 ms** for the same scene with one node projected —
592
+ * within noise of the 1.26/1.65 ms floor of projecting nothing at all.
593
+ *
594
+ * `'onDemand'` projects a node only while {@link Scene} considers the entity
595
+ * *engaged*: it is focused, it is the current pointer target, or it has been
596
+ * explicitly requested via {@link Scene.requestA11yProjection}. Crucially the
597
+ * trigger is not hover alone — a keyboard or assistive-technology user
598
+ * generates no hover, so a hover-only gate would remove exactly those users'
599
+ * access. Engagement therefore includes focus and an explicit request, and the
600
+ * entity stays hit-testable on canvas throughout, so a click still reaches it
601
+ * and promotes it.
602
+ *
603
+ * `'never'` suppresses the node entirely. Prefer `interactive = false` unless
604
+ * the entity genuinely needs pointer events without any semantic presence;
605
+ * this exists so a purely decorative interactive surface can opt out without
606
+ * losing canvas hit-testing.
607
+ *
608
+ * **This does not replace an aggregate description.** A thousand `'onDemand'`
609
+ * danmaku are individually reachable but say nothing collectively. The proven
610
+ * pattern is one aggregate live region (`role: 'status'`, `a11yFullViewport`)
611
+ * plus a small pool of persistent hotspots for the current selection — see
612
+ * `vectojs-native/danmaku`. Use `'onDemand'` to stop paying per entity, not as
613
+ * the whole accessibility story.
614
+ */
615
+ a11yProjection: 'eager' | 'onDemand' | 'never';
543
616
  /**
544
617
  * Hide this entity AND its whole subtree from the accessibility/automation
545
618
  * projection, regardless of each node's own `interactive` flag.
@@ -920,9 +993,39 @@ export declare abstract class Entity {
920
993
  * `selectable` is set — natively selectable. Returns `null` by default.
921
994
  * Read on the a11y sync cadence, so text changes propagate automatically.
922
995
  *
996
+ * @param hint - Optional advice about which part of the entity is worth
997
+ * describing. Purely an optimization: ignoring it is always correct, which
998
+ * is why it is a parameter rather than a required contract change. See
999
+ * {@link ContentProjectionHint}.
923
1000
  * @returns The projection descriptor, or `null` to project nothing.
924
1001
  */
925
- getContentProjection(): ContentProjection | null;
1002
+ getContentProjection(hint?: ContentProjectionHint): ContentProjection | null;
1003
+ /**
1004
+ * A cheap, monotonically-increasing stamp of this entity's projected content.
1005
+ *
1006
+ * Purely an optimization, and opt-in: returning `null` (the default) means
1007
+ * "I cannot cheaply tell whether my content changed", and the {@link Scene}
1008
+ * then rebuilds the projection every synced frame exactly as before. An
1009
+ * implementation must bump the value whenever anything
1010
+ * {@link getContentProjection} would report changes — text, fonts, line
1011
+ * geometry, `selectable`, grid revision.
1012
+ *
1013
+ * When two consecutive syncs report the same epoch AND the entity's geometry
1014
+ * is unchanged, `Scene` skips the block *before* calling
1015
+ * {@link getContentProjection}. That matters because the projection call is
1016
+ * O(glyphs-in-block) and the DOM diff around it costs about the same again:
1017
+ * measured on a 1500-resident-block document, a sync in which the projected
1018
+ * text was byte-identical before and after still cost 17.875 ms, and skipping
1019
+ * unchanged blocks took that to 0.475 ms (carryctx CTX-0199, vectojs#343).
1020
+ *
1021
+ * Correctness is entirely on the implementer: a stale epoch means stale DOM,
1022
+ * so bump it in the same place the content is invalidated rather than trying
1023
+ * to enumerate mutation sites afterwards. Any monotonic counter works; the
1024
+ * value is only ever compared for equality with the previous sync's.
1025
+ *
1026
+ * @returns The current content epoch, or `null` to disable skipping.
1027
+ */
1028
+ getContentEpoch(): number | null;
926
1029
  /**
927
1030
  * Whether this entity still has a queued/running tween animation, or an
928
1031
  * active {@link setTransition}/{@link animateTo}/{@link springTo} property
@@ -201,6 +201,34 @@ export interface SceneOptions {
201
201
  * height (`undefined` → resolved to `Scene.height` at sync time).
202
202
  */
203
203
  contentProjectionMargin?: number;
204
+ /**
205
+ * Virtualization margin (px) for the *semantic* tier of content projection —
206
+ * whether a block has **any** projected DOM at all, as opposed to
207
+ * {@link SceneOptions.contentProjectionMargin}, which decides whether that
208
+ * block's per-line **carriers** are windowed.
209
+ *
210
+ * Splitting the two makes a coarse resident tier expressible: with
211
+ * `contentSemanticMargin: Infinity` and a finite `contentProjectionMargin`,
212
+ * every block in the document keeps an element holding its full text — so
213
+ * find-in-page and screen-reader read-ahead see the whole document — while
214
+ * only blocks near the viewport pay for per-line carriers. One scalar could
215
+ * not express that, because a finite value freed off-band blocks entirely and
216
+ * `Infinity` also unwindowed every carrier, which is O(total document glyphs).
217
+ *
218
+ * `Infinity` is safe **here** and remains unsupported for
219
+ * `contentProjectionMargin`: the cost that made it unsupported comes from an
220
+ * unwindowed carrier band, not from resident text.
221
+ *
222
+ * Note the one-time cost. A resident tier materializes one element per block
223
+ * on the first sync — measured ~13µs per node created, so ~20ms at 1000 blocks
224
+ * and ~146ms at 10000 — as one synchronous block. Steady state is cheap
225
+ * (unchanged blocks skip via {@link Entity.getContentEpoch}), so this is a
226
+ * document-open stall, not a per-frame cost.
227
+ *
228
+ * Default: whatever `contentProjectionMargin` resolves to, so omitting this
229
+ * leaves behaviour unchanged.
230
+ */
231
+ contentSemanticMargin?: number;
204
232
  /**
205
233
  * Reading direction used to order the accessibility/automation shadow tree so
206
234
  * keyboard **tab order** and screen-reader traversal follow the *visual*
@@ -240,7 +268,7 @@ export interface SceneOptions {
240
268
  * against. A new option must be added here too — the test suite asserts the two
241
269
  * stay in sync.
242
270
  */
243
- export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
271
+ export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
244
272
  /** Frame-rate the loop is capped to when the OS requests reduced motion. */
245
273
  export declare const REDUCED_MOTION_FPS = 30;
246
274
  /**
@@ -488,6 +516,15 @@ export declare class Scene {
488
516
  private a11yElements;
489
517
  /** DOM nodes mirroring static text content, keyed by entity id. */
490
518
  private contentElements;
519
+ /**
520
+ * What the last completed content-projection sync was built from, per entity.
521
+ *
522
+ * Compared at the top of {@link syncContentProjection} to skip a block whose
523
+ * content AND geometry are both unchanged, before the O(glyphs) projection
524
+ * build. Only populated for entities that opt in via
525
+ * {@link Entity.getContentEpoch}. (carryctx CTX-0199)
526
+ */
527
+ private contentSyncState;
491
528
  /** Pending cold font-calibration frame per projected grid entity. */
492
529
  private contentGridCalibrationFrames;
493
530
  /** Detached, untransformed font probes used by the cold calibration pass. */
@@ -522,6 +559,7 @@ export declare class Scene {
522
559
  private contentMetricScaleX;
523
560
  private contentProjectionEnabled;
524
561
  private contentProjectionMargin;
562
+ private contentSemanticMargin;
525
563
  /**
526
564
  * True while a text-selection drag that started on a projection's blank
527
565
  * region (no text node under the press) is being driven manually — the
@@ -555,6 +593,14 @@ export declare class Scene {
555
593
  * mid-hover synthesize the `pointerleave` the browser never sends for a
556
594
  * detached element, so the entity doesn't keep its hover state. */
557
595
  private readonly hoveredA11yElements;
596
+ /**
597
+ * Entity ids the application has pinned via {@link requestA11yProjection}.
598
+ *
599
+ * Ids rather than entities so a removed entity cannot be retained by this set;
600
+ * a stale id simply never matches. Cleared per-entity by
601
+ * {@link releaseA11yProjection}.
602
+ */
603
+ private readonly a11yProjectionRequests;
558
604
  /** Persistent tabindex=-1 element in a11yRoot. When the focused a11y mirror is
559
605
  * pruned (virtualization/streaming/removal) while it holds focus, we move
560
606
  * focus here instead of letting the browser drop it to <body> — keeping the
@@ -1324,6 +1370,58 @@ export declare class Scene {
1324
1370
  * predicate, which is only tractable while it has one home.
1325
1371
  */
1326
1372
  private shouldProjectA11y;
1373
+ /**
1374
+ * Whether an `a11yProjection: 'onDemand'` entity is currently engaged enough to
1375
+ * deserve a shadow node.
1376
+ *
1377
+ * Deliberately **not** hover alone. A keyboard or assistive-technology user
1378
+ * generates no pointer events, so a hover-only trigger would withhold the
1379
+ * semantic node from precisely the users it exists for. Three signals, any of
1380
+ * which counts:
1381
+ *
1382
+ * - **Focus.** Covers keyboard traversal and AT-driven focus. Checked against
1383
+ * the live element so a node keeps its own focus rather than being pruned out
1384
+ * from under the user mid-interaction.
1385
+ * - **Pointer target.** The entity under the pointer, so a mouse user gets the
1386
+ * same node a hover-gated design would have given them.
1387
+ * - **Explicit request.** {@link Scene.requestA11yProjection}, for anything the
1388
+ * app knows is significant — the selected item, a search hit, a
1389
+ * just-announced element. This is the escape hatch that keeps the mode usable
1390
+ * when neither focus nor pointer applies.
1391
+ *
1392
+ * The entity stays hit-testable on canvas regardless, so a click always reaches
1393
+ * it and promotes it on the next sync.
1394
+ */
1395
+ private a11yEngaged;
1396
+ /**
1397
+ * Whether `node` mirrors selectable text of its own.
1398
+ *
1399
+ * Such an entity must not be promoted by the pointer: its interactive a11y node
1400
+ * would sit above the text mirror and eat the mousedown that starts a native
1401
+ * selection.
1402
+ */
1403
+ private projectsSelectableText;
1404
+ /**
1405
+ * Keep `entity`'s a11y shadow node projected while it has
1406
+ * `a11yProjection: 'onDemand'`.
1407
+ *
1408
+ * For anything the application knows matters but the engine cannot infer — the
1409
+ * selected danmaku, a search hit, a node just announced in a live region.
1410
+ * Without this, `'onDemand'` would be reachable only by focus or pointer, and
1411
+ * an app-driven selection change would leave the selected entity semantically
1412
+ * invisible.
1413
+ *
1414
+ * Idempotent. Has no effect on an `'eager'` entity, which is always projected.
1415
+ */
1416
+ requestA11yProjection(entity: Entity | string): void;
1417
+ /**
1418
+ * Drop a projection request made by {@link requestA11yProjection}.
1419
+ *
1420
+ * The node is not removed immediately: it survives while it is focused or under
1421
+ * the pointer, and is pruned on the next sync that finds it unengaged. Releasing
1422
+ * a request the scene does not hold is a no-op.
1423
+ */
1424
+ releaseA11yProjection(entity: Entity | string): void;
1327
1425
  private syncA11y;
1328
1426
  /**
1329
1427
  * Mirror one entity's static text ({@link Entity.getContentProjection}) as a
@@ -1341,6 +1439,34 @@ export declare class Scene {
1341
1439
  * culling and always count as visible, matching the legacy behavior.
1342
1440
  */
1343
1441
  private projectionBoxVisible;
1442
+ /**
1443
+ * The band of an entity's own y coordinates that is worth projecting, or
1444
+ * `null` to project everything.
1445
+ *
1446
+ * {@link projectionBoxVisible} answers "is this entity near the viewport",
1447
+ * which frees whole blocks that scroll away. It cannot help a single entity
1448
+ * *taller* than the viewport: that entity's box always intersects, so every
1449
+ * one of its visual lines was materialized — a `<span>` per line and, on the
1450
+ * grid path, a `<span>` per glyph cluster. That is where "14.8k elements for a
1451
+ * 346KB Markdown doc" comes from, and it is O(document) rather than
1452
+ * O(viewport) in both element count and per-frame walk cost.
1453
+ *
1454
+ * Measured on one entity scrolled to its middle, real headed browsers
1455
+ * (`benchmarks/projection-per-line/`): at 4000 lines, materializing every line
1456
+ * costs 6.28 ms/frame on Chrome and 6.51 ms on Firefox with 36,000 child
1457
+ * elements, against 0.28/0.16 ms and 963 elements when only the visible band
1458
+ * is emitted. The gated cost is *flat* across a 20x document-size range, so
1459
+ * this converts an asymptote rather than shaving a constant.
1460
+ *
1461
+ * Returns local-y bounds in the entity's own coordinate space, already
1462
+ * expanded by `margin` and intersected with every `clipChildren` ancestor, so
1463
+ * a line inside a scrolled container is measured against the container rather
1464
+ * than the window. `null` means "no useful bound" — a degenerate transform, a
1465
+ * rotation/skew that makes a y-band meaningless, or a boundless entity — and
1466
+ * the caller must then project every line, because emitting nothing would
1467
+ * silently drop text from selection, find-in-page and screen readers.
1468
+ */
1469
+ private projectionVisibleLocalYBand;
1344
1470
  private syncContentProjection;
1345
1471
  /**
1346
1472
  * Materialize a prepared grid in logical source order while positioning each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/core",
3
- "version": "1.29.0",
3
+ "version": "1.31.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },