@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.
- package/package.json +1 -1
- package/src/api/anchor-conversion.ts +2 -2
- package/src/api/public-types.ts +33 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- 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/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 +205 -37
- 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 +39 -4
- 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/surface-build-keys.ts +5 -2
- 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 +8 -8
- package/src/ui-tailwind/tw-review-workspace.tsx +13 -35
- 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.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": [
|
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"
|
|
@@ -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,
|
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
|
+
}
|
package/src/api/v3/ui/scope.ts
CHANGED
|
@@ -33,9 +33,92 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
|
|
36
|
-
import type {
|
|
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,
|