@beyondwork/docx-react-component 1.0.73 → 1.0.74

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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +33 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/ui/_types.ts +21 -0
  6. package/src/api/v3/ui/overlays.ts +276 -2
  7. package/src/api/v3/ui/scope.ts +113 -1
  8. package/src/compare/diff-engine.ts +1 -2
  9. package/src/core/commands/index.ts +14 -15
  10. package/src/core/selection/anchor-conversion.ts +2 -2
  11. package/src/core/selection/mapping.ts +10 -8
  12. package/src/core/selection/review-anchors.ts +3 -3
  13. package/src/io/export/export-session.ts +53 -0
  14. package/src/io/export/serialize-comments.ts +4 -4
  15. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  16. package/src/io/export/split-review-boundaries.ts +4 -4
  17. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  18. package/src/io/ooxml/parse-comments.ts +2 -2
  19. package/src/model/anchor.ts +9 -1
  20. package/src/model/canonical-document.ts +76 -3
  21. package/src/preservation/store.ts +24 -0
  22. package/src/review/store/comment-anchors.ts +1 -1
  23. package/src/review/store/comment-remapping.ts +1 -1
  24. package/src/review/store/revision-actions.ts +4 -4
  25. package/src/review/store/revision-types.ts +1 -1
  26. package/src/review/store/scope-tag-diff.ts +1 -1
  27. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  28. package/src/runtime/document-runtime.ts +205 -37
  29. package/src/runtime/formatting/formatting-context.ts +1 -1
  30. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  31. package/src/runtime/layout/layout-engine-version.ts +9 -1
  32. package/src/runtime/layout/public-facet.ts +27 -0
  33. package/src/runtime/scopes/evidence.ts +1 -1
  34. package/src/runtime/scopes/review-bundle.ts +1 -1
  35. package/src/runtime/scopes/scope-range.ts +1 -1
  36. package/src/runtime/selection/post-edit-validator.ts +4 -4
  37. package/src/runtime/surface-projection.ts +39 -4
  38. package/src/session/import/review-import.ts +12 -12
  39. package/src/session/import/workflow-scope-import.ts +9 -8
  40. package/src/shell/session-bootstrap.ts +4 -0
  41. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  42. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  43. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  44. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
  45. package/src/ui-tailwind/tw-review-workspace.tsx +13 -35
  46. package/src/validation/compatibility-engine.ts +1 -1
  47. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  48. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.73",
4
+ "version": "1.0.74",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -23,8 +23,8 @@ export function toPublicAnchorProjection(
23
23
  case "range":
24
24
  return {
25
25
  kind: "range",
26
- from: anchor.range.from,
27
- to: anchor.range.to,
26
+ from: anchor.from,
27
+ to: anchor.to,
28
28
  assoc: anchor.assoc,
29
29
  };
30
30
  case "node":
@@ -92,6 +92,8 @@ export type {
92
92
  ScopeRailSegment,
93
93
  } from "../runtime/workflow/rail/types.ts";
94
94
 
95
+ import type { ScopeRailSegment as _ScopeRailSegment } from "../runtime/workflow/rail/types.ts";
96
+
95
97
  /**
96
98
  * Substrate-type re-exports consumed by Layer-11 overlay + chrome
97
99
  * surfaces (refactor/11 type-only retirement sweep). Every entry below
@@ -1581,14 +1583,17 @@ export interface EditorSurfaceSnapshot {
1581
1583
  lockedFragmentIds: string[];
1582
1584
  secondaryStories: SecondaryStorySurface[];
1583
1585
  /**
1584
- * Block index range rendered as real (non-placeholder) in this snapshot.
1585
- * Blocks outside this range are placeholder opaque_blocks carrying the
1586
- * original position range but no content. `null` (default) = all blocks
1587
- * are real (legacy behavior).
1586
+ * Block-index intervals rendered as real (non-placeholder) in this snapshot.
1587
+ * Blocks whose index falls in ANY interval are real; all others are
1588
+ * placeholder opaque_blocks that preserve the original position range but
1589
+ * carry no content. `null` (default) = all blocks are real (legacy).
1588
1590
  *
1589
- * See CLAUDE.md (lane status table)
1591
+ * Typically one interval (the current viewport + overscan), but when the
1592
+ * caret is scrolled off-screen a second interval covering the caret's page
1593
+ * keeps the selection block real without realizing the full gap between
1594
+ * viewport and caret. See `docs/wiki/performance.md` §"Viewport realization".
1590
1595
  */
1591
- viewportBlockRange: { start: number; end: number } | null;
1596
+ viewportBlockRanges: readonly { start: number; end: number }[] | null;
1592
1597
  }
1593
1598
 
1594
1599
  export type EditorWarningCode =
@@ -2959,6 +2964,28 @@ export interface ScopeCardModel {
2959
2964
  agentPending: boolean;
2960
2965
  }
2961
2966
 
2967
+ /**
2968
+ * UI-API scope rail snapshot — KI-008 presentation projection (2026-04-24).
2969
+ *
2970
+ * Plain-value envelope wrapping `ScopeRailSegment[]` so L10
2971
+ * `ui.scope.rail(options?)` returns a stable shape regardless of
2972
+ * page filtering. Chrome consumers reading the rail get the segments
2973
+ * + a stamp of what narrowing the snapshot represents (no page =
2974
+ * "all pages" on the active story; `pageIndex` set = only segments
2975
+ * on that page).
2976
+ *
2977
+ * No React handlers, no DOM refs — agents + non-React hosts consume
2978
+ * this safely. Host chrome wires event handlers in its own tree.
2979
+ */
2980
+ export interface ScopeRailSnapshot {
2981
+ readonly segments: readonly _ScopeRailSegment[];
2982
+ /**
2983
+ * Present only when the caller narrowed to a single page. Absent =
2984
+ * the snapshot spans every page the runtime's page graph knows about.
2985
+ */
2986
+ readonly pageIndex?: number;
2987
+ }
2988
+
2962
2989
  export interface WorkflowBlockedCommandReason {
2963
2990
  code:
2964
2991
  | "outside_workflow_scope"
@@ -91,6 +91,18 @@ export type RuntimeApiHandle = Pick<
91
91
  // Exposing this scopeId-keyed compile primitive on the handle lets
92
92
  // `ui/scope.ts` deliver `SemanticScope` / `ScopeBundle` reads directly.
93
93
  | "compileScopeBundleById"
94
+ // KI-008 (2026-04-24): batch enumeration primitive — L10 `ui.scope.list`
95
+ // reaches `SemanticScope[]` through the handle without importing
96
+ // src/runtime/scopes/** (blocked by ci-check-ui-api-layer-purity).
97
+ // Mirror / delegate of `ai.listScopes({kind?, limit?})`.
98
+ | "compileScopeList"
99
+ // KI-008 close (2026-04-24): presentation-shaped projections —
100
+ // `ui.scope.card(scopeId)` + `ui.scope.rail(options?)`. Both
101
+ // delegate through the same WorkflowFacet rail/card projectors
102
+ // L11 chrome already consumes; the handle primitives are the
103
+ // L10-seam equivalents.
104
+ | "compileScopeCardById"
105
+ | "compileScopeRailSnapshot"
94
106
  // Layer-08 Slice-5 — scope-scoped replacement dispatch. `ai.apply
95
107
  // ReplacementScope` + `runtime.content.replaceText(scopeId, …)`
96
108
  // route compiled plans through this seam.
@@ -152,6 +164,9 @@ export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true
152
164
  getScope: true,
153
165
  getLocationForAnchor: true,
154
166
  compileScopeBundleById: true,
167
+ compileScopeList: true,
168
+ compileScopeCardById: true,
169
+ compileScopeRailSnapshot: true,
155
170
  applyScopeReplacement: true,
156
171
  debug: true,
157
172
  layout: true,
@@ -436,7 +436,28 @@ export interface ApiV3UiOverlays {
436
436
  getAnchor(query: OverlayAnchorQuery): GeometryRect | null;
437
437
  /** Multi-rect coverage for scope envelopes. Empty array when unavailable. */
438
438
  getRects(query: OverlayAnchorQuery): readonly GeometryRect[];
439
+ /**
440
+ * Coarse overlay-invalidation subscription. Listener wakes on every
441
+ * render-frame revision. Callers interested in a specific anchor
442
+ * should prefer `subscribeQuery(query, listener)` (KI-006 close) —
443
+ * rect-diffed per-query delivery over a shared coarse tick.
444
+ */
439
445
  subscribe(listener: UiListener<OverlayAnchorQuery>): UiUnsubscribe;
446
+ /**
447
+ * Per-query coalesced subscription. Registers on the shared coarse
448
+ * `subscribeOverlays` tick lazily; re-resolves the query on every
449
+ * tick via `getAnchor`; fires only when the query's rect actually
450
+ * changes (null → rect, rect → null, or structural delta). Listener
451
+ * receives the new `GeometryRect | null` value.
452
+ *
453
+ * N subscribers on the same query share one memoized rect channel
454
+ * and all fire once per real change. Torn down automatically when
455
+ * the last subscriber for the last query unsubscribes.
456
+ */
457
+ subscribeQuery(
458
+ query: OverlayAnchorQuery,
459
+ listener: UiListener<GeometryRect | null>,
460
+ ): UiUnsubscribe;
440
461
 
441
462
  /**
442
463
  * U9 · Composed overlay visibility. Merges the class-A policy from
@@ -39,6 +39,7 @@
39
39
 
40
40
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
41
41
  import type {
42
+ ChromePosture,
42
43
  GeometryRect,
43
44
  OverlayAnchorQuery,
44
45
  OverlayVisibility,
@@ -165,7 +166,34 @@ export const subscribeMetadata: ApiV3FnMetadata = {
165
166
  payloadType: "OverlayAnchorQuery",
166
167
  coalescing: "raf",
167
168
  },
168
- rwdReference: "§UI API § ui.overlays.subscribe. Adapter delegates to UiController.subscribeOverlays; throws when the active binding has no hook. Subscribe call emits one `ux.response.ui.overlays.subscribe` acknowledgement; per-invalidation OverlayAnchorQuery deliveries flow through the listener.",
169
+ rwdReference: "§UI API § ui.overlays.subscribe. Adapter delegates to UiController.subscribeOverlays; throws when the active binding has no hook. Subscribe call emits one `ux.response.ui.overlays.subscribe` acknowledgement; per-invalidation OverlayAnchorQuery deliveries flow through the listener. Coarse channel — listener wakes on every overlay invalidation; callers interested in a specific anchor should use `subscribeQuery(query, listener)` (KI-006 close) for rect-diffed per-query delivery.",
170
+ };
171
+
172
+ // ----- KI-006 close — per-query coalesced subscription ---------------------
173
+
174
+ export const subscribeQueryMetadata: ApiV3FnMetadata = {
175
+ name: "ui.overlays.subscribeQuery",
176
+ status: "live-with-adapter",
177
+ sourceLayer: "presentation",
178
+ liveEvidence: {
179
+ runnerTest: "test/api/v3/ui/overlays-subscribe-query.test.ts",
180
+ commit: "refactor-10-slice-ki-006",
181
+ },
182
+ uxIntent: {
183
+ uiVisible: true,
184
+ expectsUxResponse: "surface-refresh",
185
+ expectedDelta: "per-query overlay subscriber attached; listener fires only when the query's rect actually changes (rect-diffed coalescer on top of the coarse subscribe channel)",
186
+ },
187
+ agentMetadata: { readOrMutate: "read", boundedScope: "document", auditCategory: "ui-overlays-subscribe" },
188
+ stateClass: "C-local",
189
+ persistsTo: "none",
190
+ bidirectional: true,
191
+ subscriptionShape: {
192
+ eventType: "ui.overlays.query_changed",
193
+ payloadType: "GeometryRect",
194
+ coalescing: "raf",
195
+ },
196
+ rwdReference: "§UI API § ui.overlays.subscribeQuery (KI-006 close). Per-query coalesced variant of `subscribe`. Registers a shared coarse `controller.subscribeOverlays` tick on the first query subscription; re-resolves each attached query on every tick via `getAnchor(query)` (memoized); fires per-query listeners only when the rect actually changes. Torn down on last unsubscribe. Listener receives the new `GeometryRect | null` value. Callers that need the full invalidation stream keep using `subscribe(listener)`.",
169
197
  };
170
198
 
171
199
  // ----- U9 overlay-visibility metadata (state-classes X3) -----
@@ -245,7 +273,7 @@ export const subscribeVisibilityMetadata: ApiV3FnMetadata = {
245
273
  payloadType: "OverlayVisibility",
246
274
  coalescing: "microtask",
247
275
  },
248
- rwdReference: "§UI API § ui.overlays.subscribeVisibility (U9). Fires on BOTH local-preference mutation AND class-A policy-change (closed 2026-04-22 chained to L06's handle.subscribeVisibilityPolicy). debugMode-change fan-out remains a Phase Q follow-up.",
276
+ rwdReference: "§UI API § ui.overlays.subscribeVisibility (U9). Fires on THREE channels: (a) local-preference mutation, (b) class-A policy-change (chained to L06's `handle.subscribeVisibilityPolicy`, shipped 2026-04-22), (c) `ChromePosture.debugMode` transitions on the `debug-panel` kind (chained to the bound controller's `subscribeChrome`, shipped 2026-04-24 closing KI-007). Chrome fan-out is lazy — registered on the first `debug-panel` subscriber, torn down on the last. debugMode-comparison-based firing avoids false fan-out on unrelated chrome ticks (reviewMode, markupDisplay).",
249
277
  };
250
278
 
251
279
  export function createOverlaysFamily(ctx: UiApiContext) {
@@ -285,6 +313,170 @@ export function createOverlaysFamily(ctx: UiApiContext) {
285
313
  }
286
314
  });
287
315
 
316
+ // KI-007 close — chrome-change fan-out for `debug-panel` visibility.
317
+ //
318
+ // The composed `debug-panel` visibility depends on three inputs:
319
+ // (a) class-A policy, (b) class-C local preference, and (c)
320
+ // ChromePosture.debugMode. Fan-out for (a) + (b) was already wired;
321
+ // (c) was the missing channel. Now registered lazily on the first
322
+ // `subscribeVisibility("debug-panel", …)` call and torn down when the
323
+ // last such subscriber unsubscribes, so the chrome-listener cost only
324
+ // applies when someone is actually listening.
325
+ //
326
+ // Comparison-based firing: we compare `ChromePosture.debugMode` to
327
+ // the previous value on each chrome tick and only re-notify when it
328
+ // changes. This avoids firing debug-panel visibility subscribers on
329
+ // unrelated chrome changes (reviewMode flip, markup-display change,
330
+ // etc.) — cost-efficient + semantically correct.
331
+ //
332
+ // Note on re-bind: if the host calls `ui.session.release()` then
333
+ // `bind(newController)`, the chrome-listener's underlying hook is
334
+ // torn down with the old controller. Consumers already subscribed to
335
+ // debug-panel visibility continue holding their listeners but stop
336
+ // receiving chrome-tick fan-out until they unsubscribe and re-
337
+ // subscribe. Release+rebind is a lifecycle event hosts handle
338
+ // explicitly; documented here rather than auto-re-registering
339
+ // because a full bind-cycle integration would also require reading
340
+ // the initial posture to seed `lastKnownDebugMode`, which is out of
341
+ // scope for this targeted fix.
342
+ let chromeUnsubscribe: (() => void) | null = null;
343
+ let lastKnownDebugMode: ChromePosture["debugMode"] | undefined;
344
+
345
+ function ensureChromeFanout(): void {
346
+ if (chromeUnsubscribe) return;
347
+ const controller = ctx.binding?.controller;
348
+ if (!controller?.subscribeChrome) return;
349
+ // Seed last-known from the current posture so the first real change
350
+ // doesn't false-fire against undefined.
351
+ const posture = readPostureForDebugModeSeed(ctx);
352
+ lastKnownDebugMode = posture?.debugMode;
353
+ chromeUnsubscribe = controller.subscribeChrome((next) => {
354
+ const nextMode = next?.debugMode;
355
+ if (nextMode === lastKnownDebugMode) return;
356
+ lastKnownDebugMode = nextMode;
357
+ notifyVisibilitySubscribers(ctx, visibilityState, "debug-panel");
358
+ });
359
+ }
360
+
361
+ function releaseChromeFanoutIfEmpty(): void {
362
+ const debugPanelSubs = visibilityState.subscribers.get("debug-panel");
363
+ if (debugPanelSubs && debugPanelSubs.size > 0) return;
364
+ if (chromeUnsubscribe) {
365
+ chromeUnsubscribe();
366
+ chromeUnsubscribe = null;
367
+ lastKnownDebugMode = undefined;
368
+ }
369
+ }
370
+
371
+ // KI-006 close — per-query coalesced subscribe channel.
372
+ //
373
+ // The coarse `subscribe(listener)` delegates to `controller.
374
+ // subscribeOverlays` and fires on every render-frame revision. That's
375
+ // correct but ergonomically coarse: consumers that care about a
376
+ // specific anchor wake on every frame and must re-query + compare
377
+ // manually. `subscribeQuery(query, listener)` is the per-query
378
+ // variant: it shares a single coarse tick across all query
379
+ // subscribers, re-resolves each attached query on every tick via
380
+ // `getAnchor(query)`, compares the new rect to the memoized previous
381
+ // rect, and only fires listeners whose query's rect actually changed.
382
+ //
383
+ // - Lazy registration: coarse subscriber registered on first
384
+ // `subscribeQuery` call; torn down on last unsubscribe.
385
+ // - Shared tick: N query subscribers share 1 coarse subscription.
386
+ // - Rect-diff firing: null → rect, rect → null, and non-zero rect
387
+ // delta all count as changes; structural equality (same topPx /
388
+ // leftPx / widthPx / heightPx) suppresses fire.
389
+ // - Keyed by serialized query (`${kind}:${value}`): two subscribers
390
+ // on the same query share the memoized rect and both fire once
391
+ // per real change.
392
+ //
393
+ // Cost: O(attached queries) per frame. For typical overlay loads
394
+ // (scope-card, comment-bubble, revision-bar) this is O(10) per
395
+ // frame — bounded and cheap.
396
+ interface QueryChannel {
397
+ query: OverlayAnchorQuery;
398
+ lastRect: GeometryRect | null;
399
+ listeners: Set<UiListener<GeometryRect | null>>;
400
+ }
401
+ const queryChannels = new Map<string, QueryChannel>();
402
+ let queryCoarseUnsubscribe: (() => void) | null = null;
403
+
404
+ function queryKey(q: OverlayAnchorQuery): string {
405
+ // `selection` is a singleton kind with no `value` — key by kind alone.
406
+ return q.kind === "selection" ? "selection" : `${q.kind}:${q.value}`;
407
+ }
408
+
409
+ function rectsEqual(a: GeometryRect | null, b: GeometryRect | null): boolean {
410
+ if (a === null || b === null) return a === b;
411
+ return (
412
+ a.topPx === b.topPx &&
413
+ a.leftPx === b.leftPx &&
414
+ a.widthPx === b.widthPx &&
415
+ a.heightPx === b.heightPx
416
+ );
417
+ }
418
+
419
+ function resolveQueryRect(query: OverlayAnchorQuery): GeometryRect | null {
420
+ // Reuse the id-keyed geometry path from getAnchor; skip the
421
+ // selection bridge fallback because subscribeQuery is anchor-
422
+ // oriented and selection would be a caller-owned subscription
423
+ // off `ui.surface` when that ships. For selection queries today,
424
+ // we fall back to the getSelectionRects read and take the first
425
+ // rect (consistent with getAnchor's selection handling).
426
+ if (query.kind === "selection") {
427
+ const hook = ctx.binding?.controller.getOverlayAnchor;
428
+ if (hook) {
429
+ const fromBridge = hook(query);
430
+ if (fromBridge) return fromBridge;
431
+ }
432
+ const rects = resolveSelectionRects(ctx);
433
+ return rects.length > 0 ? (rects[0] ?? null) : null;
434
+ }
435
+ const anchorQuery = toAnchorQuery(query);
436
+ if (anchorQuery) {
437
+ const fromGeometry = ctx.handle.geometry?.getAnchor?.(anchorQuery) ?? null;
438
+ if (fromGeometry) return fromGeometry;
439
+ }
440
+ const hook = ctx.binding?.controller.getOverlayAnchor;
441
+ return hook ? hook(query) : null;
442
+ }
443
+
444
+ function diffAndFireAllQueries(): void {
445
+ for (const channel of queryChannels.values()) {
446
+ if (channel.listeners.size === 0) continue;
447
+ const next = resolveQueryRect(channel.query);
448
+ if (rectsEqual(channel.lastRect, next)) continue;
449
+ channel.lastRect = next;
450
+ for (const listener of channel.listeners) {
451
+ try {
452
+ listener(next);
453
+ } catch {
454
+ // Isolate per-listener errors.
455
+ }
456
+ }
457
+ }
458
+ }
459
+
460
+ function ensureQueryCoarseTick(): void {
461
+ if (queryCoarseUnsubscribe) return;
462
+ const controller = ctx.binding?.controller;
463
+ if (!controller?.subscribeOverlays) return;
464
+ queryCoarseUnsubscribe = controller.subscribeOverlays(() => {
465
+ diffAndFireAllQueries();
466
+ });
467
+ }
468
+
469
+ function releaseQueryCoarseTickIfEmpty(): void {
470
+ // If any channel still has listeners, keep the coarse tick alive.
471
+ for (const channel of queryChannels.values()) {
472
+ if (channel.listeners.size > 0) return;
473
+ }
474
+ if (queryCoarseUnsubscribe) {
475
+ queryCoarseUnsubscribe();
476
+ queryCoarseUnsubscribe = null;
477
+ }
478
+ }
479
+
288
480
  return {
289
481
  getAnchor(query: OverlayAnchorQuery): GeometryRect | null {
290
482
  // `kind: "selection"` — bridge-first, geometry fallback.
@@ -362,6 +554,59 @@ export function createOverlaysFamily(ctx: UiApiContext) {
362
554
  return unsubscribe;
363
555
  },
364
556
 
557
+ subscribeQuery(
558
+ query: OverlayAnchorQuery,
559
+ listener: UiListener<GeometryRect | null>,
560
+ ): UiUnsubscribe {
561
+ // KI-006 close — per-query coalesced variant of `subscribe`.
562
+ // Shares one coarse `controller.subscribeOverlays` tick across
563
+ // all query subscribers, rect-diffs per query on each tick, and
564
+ // only fires listeners whose query rect actually changed.
565
+ const controller = ctx.binding?.controller;
566
+ if (!controller) {
567
+ throw new Error(
568
+ "ui.overlays.subscribeQuery: no controller bound — call ui.session.bind(controller) first",
569
+ );
570
+ }
571
+ if (!controller.subscribeOverlays) {
572
+ throw new Error(
573
+ `ui.overlays.subscribeQuery: controller of kind "${controller.kind}" did not provide a subscribeOverlays hook`,
574
+ );
575
+ }
576
+
577
+ const key = queryKey(query);
578
+ let channel = queryChannels.get(key);
579
+ if (!channel) {
580
+ channel = {
581
+ query,
582
+ lastRect: resolveQueryRect(query),
583
+ listeners: new Set(),
584
+ };
585
+ queryChannels.set(key, channel);
586
+ }
587
+ channel.listeners.add(listener);
588
+ ensureQueryCoarseTick();
589
+
590
+ emitUxResponse(ctx.handle, {
591
+ apiFn: subscribeQueryMetadata.name,
592
+ intent: subscribeQueryMetadata.uxIntent.expectedDelta ?? "",
593
+ mockOrLive: "live-with-adapter",
594
+ uiVisible: true,
595
+ expectedDelta: subscribeQueryMetadata.uxIntent.expectedDelta,
596
+ actualDelta: { kind: "surface-refresh", payload: { subscribed: "ui.overlays.query", query: key } },
597
+ });
598
+
599
+ return () => {
600
+ const current = queryChannels.get(key);
601
+ if (!current) return;
602
+ current.listeners.delete(listener);
603
+ if (current.listeners.size === 0) {
604
+ queryChannels.delete(key);
605
+ }
606
+ releaseQueryCoarseTickIfEmpty();
607
+ };
608
+ },
609
+
365
610
  // ----- U9 overlay-visibility (state-classes X3) -----
366
611
 
367
612
  getVisibility(kind: OverlayKind): OverlayVisibility {
@@ -408,6 +653,12 @@ export function createOverlaysFamily(ctx: UiApiContext) {
408
653
  visibilityState.subscribers.set(kind, listeners);
409
654
  }
410
655
  listeners.add(listener);
656
+ // KI-007 — lazy-register the chrome-change fan-out when the first
657
+ // debug-panel subscriber lands. No-op for other kinds + idempotent
658
+ // when a chrome listener is already active.
659
+ if (kind === "debug-panel") {
660
+ ensureChromeFanout();
661
+ }
411
662
  emitUxResponse(ctx.handle, {
412
663
  apiFn: subscribeVisibilityMetadata.name,
413
664
  intent: subscribeVisibilityMetadata.uxIntent.expectedDelta ?? "",
@@ -421,7 +672,30 @@ export function createOverlaysFamily(ctx: UiApiContext) {
421
672
  if (!current) return;
422
673
  current.delete(listener);
423
674
  if (current.size === 0) visibilityState.subscribers.delete(kind);
675
+ // KI-007 — tear down the chrome-change fan-out when the last
676
+ // debug-panel subscriber unsubscribes.
677
+ if (kind === "debug-panel") {
678
+ releaseChromeFanoutIfEmpty();
679
+ }
424
680
  };
425
681
  },
426
682
  };
427
683
  }
684
+
685
+ /**
686
+ * KI-007 helper — read the current `ChromePosture.debugMode` value so
687
+ * the chrome-change fan-out can seed its `lastKnownDebugMode` tracker
688
+ * without false-firing on the first tick. Mirrors the logic in
689
+ * `chrome.ts::getPosture` but only extracts `debugMode` + returns
690
+ * `undefined` on any missing piece (rather than composing a full
691
+ * posture). Keeps the debug-panel fan-out path cheap.
692
+ */
693
+ function readPostureForDebugModeSeed(
694
+ ctx: UiApiContext,
695
+ ): { debugMode: ChromePosture["debugMode"] } | null {
696
+ const hook = ctx.binding?.controller.getHostPosture;
697
+ if (!hook) return null;
698
+ const host = hook();
699
+ if (!host) return null;
700
+ return { debugMode: host.debugMode ?? "off" };
701
+ }
@@ -33,9 +33,92 @@
33
33
  */
34
34
 
35
35
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
36
- import type { ScopeBundle } from "../../public-types.ts";
36
+ import type {
37
+ ScopeBundle,
38
+ ScopeCardModel,
39
+ ScopeRailSnapshot,
40
+ SemanticScope,
41
+ SemanticScopeKind,
42
+ } from "../../public-types.ts";
37
43
  import type { UiApiContext } from "./_context.ts";
38
44
 
45
+ export interface UiScopeListFilter {
46
+ readonly kind?: SemanticScopeKind;
47
+ readonly limit?: number;
48
+ }
49
+
50
+ export interface UiScopeRailOptions {
51
+ /**
52
+ * Narrow the snapshot to a single page index. Omit (default) to
53
+ * span every page the runtime's page graph knows about.
54
+ */
55
+ readonly pageIndex?: number;
56
+ }
57
+
58
+ export const cardMetadata: ApiV3FnMetadata = {
59
+ name: "ui.scope.card",
60
+ status: "live-with-adapter",
61
+ sourceLayer: "presentation",
62
+ liveEvidence: {
63
+ runnerTest: "test/api/v3/ui/scope-card-rail.test.ts",
64
+ commit: "refactor-10-ki-008-ui-scope-card-rail",
65
+ },
66
+ uxIntent: { uiVisible: false },
67
+ agentMetadata: {
68
+ readOrMutate: "read",
69
+ boundedScope: "scope",
70
+ auditCategory: "ui-scope-card-read",
71
+ },
72
+ // Same classification as other `ui.scope.*` reads — canonical scope
73
+ // projection (not class-C local preference). KI-008 close.
74
+ stateClass: "A-canonical",
75
+ persistsTo: "canonical",
76
+ rwdReference:
77
+ "§UI API § ui.scope.card. Wraps handle.compileScopeCardById(scopeId) which projects runtime.workflow.getAllScopeCardModels() filtered to the matching scope. Returns plain-value ScopeCardModel — no React handlers, no DOM refs. Hosts wire event handlers in their own chrome tree.",
78
+ };
79
+
80
+ export const railMetadata: ApiV3FnMetadata = {
81
+ name: "ui.scope.rail",
82
+ status: "live-with-adapter",
83
+ sourceLayer: "presentation",
84
+ liveEvidence: {
85
+ runnerTest: "test/api/v3/ui/scope-card-rail.test.ts",
86
+ commit: "refactor-10-ki-008-ui-scope-card-rail",
87
+ },
88
+ uxIntent: { uiVisible: false },
89
+ agentMetadata: {
90
+ readOrMutate: "read",
91
+ boundedScope: "document",
92
+ auditCategory: "ui-scope-rail-read",
93
+ },
94
+ stateClass: "A-canonical",
95
+ persistsTo: "canonical",
96
+ rwdReference:
97
+ "§UI API § ui.scope.rail. Wraps handle.compileScopeRailSnapshot({pageIndex?}) which projects runtime.workflow.getRailSegments(pageIndex) / getAllRailSegments() into a ScopeRailSnapshot envelope. Plain-value data; no React handlers.",
98
+ };
99
+
100
+ export const listMetadata: ApiV3FnMetadata = {
101
+ name: "ui.scope.list",
102
+ status: "live-with-adapter",
103
+ sourceLayer: "presentation",
104
+ liveEvidence: {
105
+ runnerTest: "test/api/v3/ui/scope-list.test.ts",
106
+ commit: "refactor-10-ki-008-ui-scope-list",
107
+ },
108
+ uxIntent: { uiVisible: false },
109
+ agentMetadata: {
110
+ readOrMutate: "read",
111
+ boundedScope: "document",
112
+ auditCategory: "ui-scope-list",
113
+ },
114
+ // Same classification as `ui.scope.getBundle` — reads canonical
115
+ // scope state (not class-C local preference). KI-008 closure.
116
+ stateClass: "A-canonical",
117
+ persistsTo: "canonical",
118
+ rwdReference:
119
+ "§UI API § ui.scope.list. Wraps handle.compileScopeList(filter?) to enumerate compiled SemanticScope records through the L10 seam without importing src/runtime/scopes/**. Filter matches ai.listScopes({kind?, limit?}) for identity-set symmetry.",
120
+ };
121
+
39
122
  export const getBundleMetadata: ApiV3FnMetadata = {
40
123
  name: "ui.scope.getBundle",
41
124
  status: "live-with-adapter",
@@ -67,5 +150,34 @@ export function createScopeFamily(ctx: UiApiContext) {
67
150
  const nowUtc = new Date().toISOString();
68
151
  return compile.call(ctx.handle, scopeId, nowUtc);
69
152
  },
153
+ list(filter?: UiScopeListFilter): readonly SemanticScope[] {
154
+ // KI-008 — enumeration through the handle's batch primitive
155
+ // (L10 purity blocks importing src/runtime/scopes/**). Returns
156
+ // a fresh array so callers that sort / splice don't leak into
157
+ // internal compiler state.
158
+ const enumerate = ctx.handle.compileScopeList;
159
+ if (typeof enumerate !== "function") return Object.freeze([]);
160
+ return enumerate.call(ctx.handle, filter);
161
+ },
162
+ card(scopeId: string): ScopeCardModel | null {
163
+ // KI-008 close — single-scope presentation projection. Returns
164
+ // a plain-value ScopeCardModel (posture, label, issue, suggestion
165
+ // groups, review actions, agent-pending flag). Host chrome wires
166
+ // event handlers around it.
167
+ const compile = ctx.handle.compileScopeCardById;
168
+ if (typeof compile !== "function") return null;
169
+ return compile.call(ctx.handle, scopeId);
170
+ },
171
+ rail(options?: UiScopeRailOptions): ScopeRailSnapshot {
172
+ // KI-008 close — rail snapshot. When `options.pageIndex` is
173
+ // supplied, narrows to that page; otherwise spans every page
174
+ // in the runtime's page graph. Envelope shape is stable either
175
+ // way; callers check `snapshot.pageIndex` to detect narrowing.
176
+ const compile = ctx.handle.compileScopeRailSnapshot;
177
+ if (typeof compile !== "function") {
178
+ return { segments: Object.freeze([]) };
179
+ }
180
+ return compile.call(ctx.handle, options);
181
+ },
70
182
  };
71
183
  }
@@ -410,8 +410,7 @@ function createParagraphRevisionRecords(
410
410
  changeId: revision.changeId,
411
411
  kind: revision.kind,
412
412
  anchor: {
413
- kind: "range",
414
- range: { from: position, to: position },
413
+ kind: "range", from: position, to: position,
415
414
  assoc: { start: -1, end: 1 },
416
415
  },
417
416
  authorId,