@beyondwork/docx-react-component 1.0.73 → 1.0.75

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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +40 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/runtime/workflow.ts +130 -1
  6. package/src/api/v3/ui/_types.ts +21 -0
  7. package/src/api/v3/ui/overlays.ts +276 -2
  8. package/src/api/v3/ui/scope.ts +113 -1
  9. package/src/compare/diff-engine.ts +1 -2
  10. package/src/core/commands/index.ts +14 -15
  11. package/src/core/selection/anchor-conversion.ts +2 -2
  12. package/src/core/selection/mapping.ts +10 -8
  13. package/src/core/selection/review-anchors.ts +3 -3
  14. package/src/io/export/export-session.ts +53 -0
  15. package/src/io/export/serialize-comments.ts +4 -4
  16. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  17. package/src/io/export/split-review-boundaries.ts +4 -4
  18. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  19. package/src/io/ooxml/parse-comments.ts +2 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +7 -13
  21. package/src/io/ooxml/parse-main-document.ts +7 -31
  22. package/src/io/ooxml/table-opaque-preservation.ts +171 -0
  23. package/src/model/anchor.ts +9 -1
  24. package/src/model/canonical-document.ts +76 -3
  25. package/src/preservation/store.ts +24 -0
  26. package/src/review/store/comment-anchors.ts +1 -1
  27. package/src/review/store/comment-remapping.ts +1 -1
  28. package/src/review/store/revision-actions.ts +4 -4
  29. package/src/review/store/revision-types.ts +1 -1
  30. package/src/review/store/scope-tag-diff.ts +1 -1
  31. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  32. package/src/runtime/document-runtime.ts +233 -38
  33. package/src/runtime/formatting/formatting-context.ts +1 -1
  34. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  35. package/src/runtime/layout/layout-engine-version.ts +9 -1
  36. package/src/runtime/layout/public-facet.ts +27 -0
  37. package/src/runtime/scopes/evidence.ts +1 -1
  38. package/src/runtime/scopes/review-bundle.ts +1 -1
  39. package/src/runtime/scopes/scope-range.ts +1 -1
  40. package/src/runtime/selection/post-edit-validator.ts +4 -4
  41. package/src/runtime/surface-projection.ts +48 -4
  42. package/src/runtime/workflow/scope-writer.ts +212 -10
  43. package/src/session/import/review-import.ts +12 -12
  44. package/src/session/import/workflow-scope-import.ts +9 -8
  45. package/src/shell/session-bootstrap.ts +4 -0
  46. package/src/ui-tailwind/editor-surface/pm-schema.ts +22 -2
  47. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  48. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +22 -3
  49. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +7 -3
  50. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +5 -1
  51. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +5 -1
  52. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +7 -5
  53. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  54. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  55. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +0 -13
  56. package/src/ui-tailwind/tw-review-workspace.tsx +13 -41
  57. package/src/validation/compatibility-engine.ts +1 -1
  58. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  59. 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.75",
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"
@@ -5598,6 +5625,13 @@ export interface ShortcutDelegationContext {
5598
5625
  selectionRange: SelectionSnapshot;
5599
5626
  }
5600
5627
 
5628
+ /**
5629
+ * Resolver contract (`resolveChromeVisibilityForPreset`): every field on
5630
+ * this interface is REQUIRED — no `| undefined`, no optional sugar. Each
5631
+ * preset's default-visibility block (`src/api/v3/ui/chrome-preset-model.ts`)
5632
+ * MUST set every field. If you add a new surface visibility flag, add it
5633
+ * here AND to all preset default blocks in the same commit.
5634
+ */
5601
5635
  export interface WordReviewEditorChromeVisibility {
5602
5636
  toolbar: boolean;
5603
5637
  alerts: boolean;
@@ -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,
@@ -11,12 +11,16 @@
11
11
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
12
12
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
13
13
  import type {
14
+ EditorStoryTarget,
14
15
  OverlayKind,
15
16
  OverlayVisibilityPolicy,
16
17
  WorkflowMarkupModePolicy,
17
18
  } from "../../public-types.ts";
18
19
  import { emitUxResponse } from "../_ux-response.ts";
19
- import { createScopeFromBlockId } from "../../../runtime/workflow/scope-writer.ts";
20
+ import {
21
+ createScopeFromAnchor,
22
+ createScopeFromBlockId,
23
+ } from "../../../runtime/workflow/scope-writer.ts";
20
24
  import { attachScopeMetadata } from "../../../runtime/workflow/metadata-writer.ts";
21
25
  import {
22
26
  DEFAULT_REGISTRY_ENTRIES,
@@ -113,6 +117,59 @@ export interface CreateScopeResult {
113
117
  readonly status: "created" | "block-not-found";
114
118
  }
115
119
 
120
+ /**
121
+ * Input for `createScopeFromAnchor` — sub-block marker-backed scope.
122
+ *
123
+ * Use this variant when the scope must bracket a range *within* a block
124
+ * (a phrase inside a paragraph) or across blocks (a selection that
125
+ * spans paragraph boundaries). For whole-block scopes, prefer
126
+ * `createScope({blockId})` — it stays block-aligned after edits at the
127
+ * block boundary.
128
+ *
129
+ * The `from`/`to` positions are consumed **once** at creation. After
130
+ * this call returns, use the returned `scopeId` as the reference. Do
131
+ * not cache, store, or reuse the positions — they are positional
132
+ * queries (KI-P9), valid only in the document state they were captured
133
+ * in. The scope itself is marker-backed and travels with its bracketed
134
+ * content through any number of subsequent edits.
135
+ */
136
+ export interface CreateScopeFromAnchorInput {
137
+ readonly anchor: {
138
+ /** Zero-based surface offset, inclusive. Must be `>= 0`. */
139
+ readonly from: number;
140
+ /** Zero-based surface offset, exclusive of the marker slot. Must be `>= from` and `<= storyLength`. */
141
+ readonly to: number;
142
+ /** Non-main-body story (footnote / header / endnote). Defaults to `{kind:"main"}`. */
143
+ readonly storyTarget?: EditorStoryTarget;
144
+ };
145
+ readonly mode?: "edit" | "suggest" | "comment" | "view";
146
+ readonly label?: string;
147
+ readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
148
+ readonly stableRefHint?:
149
+ | "scope-id"
150
+ | "bookmark"
151
+ | "semantic-path"
152
+ | "runtime-handle";
153
+ }
154
+
155
+ export type CreateScopeFromAnchorResult =
156
+ | { readonly scopeId: string; readonly status: "created" }
157
+ | {
158
+ readonly scopeId: "";
159
+ readonly status: "range-invalid";
160
+ readonly reason:
161
+ | "from-negative"
162
+ | "to-less-than-from"
163
+ | "range-exceeds-story-length";
164
+ readonly from: number;
165
+ readonly to: number;
166
+ readonly storyLength: number;
167
+ /** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
168
+ readonly message: string;
169
+ /** Machine-routable next-step hint (`"clamp-from-to-zero"` / `"swap-from-and-to"` / `"clamp-to-to-storyLength-or-pick-a-different-range"`). */
170
+ readonly nextStep: string;
171
+ };
172
+
116
173
  export const createScopeMetadata: ApiV3FnMetadata = {
117
174
  name: "runtime.workflow.createScope",
118
175
  status: "live-with-adapter",
@@ -129,6 +186,32 @@ export const createScopeMetadata: ApiV3FnMetadata = {
129
186
  rwdReference: "§Runtime API § runtime.workflow.createScope",
130
187
  };
131
188
 
189
+ export const createScopeFromAnchorMetadata: ApiV3FnMetadata = {
190
+ name: "runtime.workflow.createScopeFromAnchor",
191
+ status: "live-with-adapter",
192
+ sourceLayer: "workflow-review",
193
+ liveEvidence: {
194
+ runnerTest: "test/api/v3/workflow-create-scope-from-anchor-live.test.ts",
195
+ commit: "pending",
196
+ },
197
+ uxIntent: {
198
+ uiVisible: true,
199
+ expectsUxResponse: "scope-created",
200
+ expectedDelta:
201
+ "rail shows new scope chip for the sub-block range; editor surface refreshes with inline markers planted at from/to",
202
+ },
203
+ agentMetadata: {
204
+ readOrMutate: "mutate",
205
+ boundedScope: "selection",
206
+ auditCategory: "scope-creation",
207
+ },
208
+ stateClass: "A-canonical",
209
+ persistsTo: "customXml",
210
+ broadcastsVia: "crdt",
211
+ rwdReference:
212
+ "§Runtime API § runtime.workflow.createScopeFromAnchor. Companion to createScope({blockId}) for sub-block ranges. Consumes from/to once to plant inline scope_marker_start/end; the returned scopeId is the durable reference (KI-P9).",
213
+ };
214
+
132
215
  export interface AttachMetadataInput {
133
216
  readonly scopeId: string;
134
217
  readonly metadataId: string;
@@ -411,6 +494,52 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
411
494
  return { scopeId: adapterResult.scopeId, status: "created" };
412
495
  },
413
496
 
497
+ createScopeFromAnchor(
498
+ input: CreateScopeFromAnchorInput,
499
+ ): CreateScopeFromAnchorResult {
500
+ // @endStateApi — live-with-adapter. Delegates to the layer-06
501
+ // scope-writer with the anchor range passed through directly.
502
+ // Bounds are validated against the story length; out-of-bounds
503
+ // ranges return a typed `range-invalid` discriminator rather than
504
+ // inventing a scopeId.
505
+ const adapterResult = createScopeFromAnchor(runtime, {
506
+ anchor: input.anchor,
507
+ mode: input.mode,
508
+ label: input.label,
509
+ ...(input.assoc ? { assoc: input.assoc } : {}),
510
+ ...(input.stableRefHint
511
+ ? { stableRefHint: input.stableRefHint }
512
+ : {}),
513
+ });
514
+ emitUxResponse(runtime, {
515
+ apiFn: createScopeFromAnchorMetadata.name,
516
+ intent: createScopeFromAnchorMetadata.uxIntent.expectedDelta ?? "",
517
+ mockOrLive: "live",
518
+ uiVisible: true,
519
+ expectedDelta: createScopeFromAnchorMetadata.uxIntent.expectedDelta,
520
+ actualDelta:
521
+ adapterResult.status === "created"
522
+ ? {
523
+ kind: "inline-change",
524
+ payload: { scopeId: adapterResult.scopeId },
525
+ }
526
+ : undefined,
527
+ });
528
+ if (adapterResult.status !== "created") {
529
+ return {
530
+ scopeId: "",
531
+ status: "range-invalid",
532
+ reason: adapterResult.reason,
533
+ from: adapterResult.from,
534
+ to: adapterResult.to,
535
+ storyLength: adapterResult.storyLength,
536
+ message: adapterResult.message,
537
+ nextStep: adapterResult.nextStep,
538
+ };
539
+ }
540
+ return { scopeId: adapterResult.scopeId, status: "created" };
541
+ },
542
+
414
543
  getVisibilityPolicy(kind: OverlayKind): OverlayVisibilityPolicy | null {
415
544
  // @endStateApi — live. Class-A policy read; composition with
416
545
  // class-C local preference lives in L10 (`ui.overlays.getVisibility`).
@@ -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
+ }