@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.
- package/package.json +1 -1
- package/src/api/anchor-conversion.ts +2 -2
- package/src/api/public-types.ts +40 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- package/src/api/v3/runtime/workflow.ts +130 -1
- package/src/api/v3/ui/_types.ts +21 -0
- package/src/api/v3/ui/overlays.ts +276 -2
- package/src/api/v3/ui/scope.ts +113 -1
- package/src/compare/diff-engine.ts +1 -2
- package/src/core/commands/index.ts +14 -15
- package/src/core/selection/anchor-conversion.ts +2 -2
- package/src/core/selection/mapping.ts +10 -8
- package/src/core/selection/review-anchors.ts +3 -3
- package/src/io/export/export-session.ts +53 -0
- package/src/io/export/serialize-comments.ts +4 -4
- package/src/io/export/serialize-runtime-revisions.ts +10 -10
- package/src/io/export/split-review-boundaries.ts +4 -4
- package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
- package/src/io/ooxml/parse-comments.ts +2 -2
- package/src/io/ooxml/parse-headers-footers.ts +7 -13
- package/src/io/ooxml/parse-main-document.ts +7 -31
- package/src/io/ooxml/table-opaque-preservation.ts +171 -0
- package/src/model/anchor.ts +9 -1
- package/src/model/canonical-document.ts +76 -3
- package/src/preservation/store.ts +24 -0
- package/src/review/store/comment-anchors.ts +1 -1
- package/src/review/store/comment-remapping.ts +1 -1
- package/src/review/store/revision-actions.ts +4 -4
- package/src/review/store/revision-types.ts +1 -1
- package/src/review/store/scope-tag-diff.ts +1 -1
- package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
- package/src/runtime/document-runtime.ts +233 -38
- package/src/runtime/formatting/formatting-context.ts +1 -1
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +9 -1
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/scopes/evidence.ts +1 -1
- package/src/runtime/scopes/review-bundle.ts +1 -1
- package/src/runtime/scopes/scope-range.ts +1 -1
- package/src/runtime/selection/post-edit-validator.ts +4 -4
- package/src/runtime/surface-projection.ts +48 -4
- package/src/runtime/workflow/scope-writer.ts +212 -10
- package/src/session/import/review-import.ts +12 -12
- package/src/session/import/workflow-scope-import.ts +9 -8
- package/src/shell/session-bootstrap.ts +4 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +22 -2
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +22 -3
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +7 -3
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +7 -5
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
- package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +0 -13
- package/src/ui-tailwind/tw-review-workspace.tsx +13 -41
- package/src/validation/compatibility-engine.ts +1 -1
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
- 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.
|
|
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": [
|
package/src/api/public-types.ts
CHANGED
|
@@ -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
|
|
1585
|
-
* Blocks
|
|
1586
|
-
*
|
|
1587
|
-
* are real (legacy
|
|
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
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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`).
|
package/src/api/v3/ui/_types.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|