@beyondwork/docx-react-component 1.0.94 → 1.0.95

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.94",
4
+ "version": "1.0.95",
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": [
@@ -384,9 +384,9 @@ export function createReplacementFamily(runtime: RuntimeApiHandle) {
384
384
  },
385
385
 
386
386
  applyScopeAction(input: ApplyScopeActionInput): ApplyResult {
387
- // @endStateApi — live-with-adapter. Routes through the scope
388
- // compiler's applyFormatting + the same ApplyResult projection
389
- // used by applyReplacementScope.
387
+ // @endStateApi — live-with-adapter. Routes scope actions through the
388
+ // shipped compiler/applyFormatting facade so AI-triggered formatting
389
+ // mutations share validation, audit, and undo semantics.
390
390
  const proposalId =
391
391
  input.proposalId ??
392
392
  mockId(
@@ -216,9 +216,9 @@ export function createFormattingFamily(runtime: RuntimeApiHandle) {
216
216
  },
217
217
 
218
218
  applyToScope(input: FormattingApplyToScopeInput): FormattingApplyToScopeResult {
219
- // @endStateApi — live-with-adapter. Routes through the scope
220
- // compiler's applyFormatting with actor=user + origin=ui; returns
221
- // the authored-revision + audit shape projected for v3 callers.
219
+ // @endStateApi — live-with-adapter. Routes through the scope-compiler
220
+ // facade so scope-targeted formatting shares Layer-08 validation,
221
+ // audit, and runtime-owned mutation semantics.
222
222
  const result = compiler.applyFormatting({
223
223
  targetScopeId: input.scopeId,
224
224
  action: input.action,
@@ -591,7 +591,7 @@ export function applyFormattingOperationToDocument(
591
591
  export type TextMarkClearTarget = TextMark["type"] | "visualHighlight";
592
592
 
593
593
  export type TextMarkRangeOperation =
594
- | { type: "clear-mark"; mark: TextMarkClearTarget }
594
+ | { type: "clear-mark"; mark: TextMarkClearTarget; expandToFullHighlight?: boolean }
595
595
  | { type: "set-mark"; mark: TextMark };
596
596
 
597
597
  /**
@@ -605,8 +605,14 @@ export function applyTextMarkOperationToDocumentRange(
605
605
  range: { from: number; to: number },
606
606
  operation: TextMarkRangeOperation,
607
607
  ): FormattingMutationResult {
608
- const selectionFrom = Math.min(range.from, range.to);
609
- const selectionTo = Math.max(range.from, range.to);
608
+ const inputFrom = Math.min(range.from, range.to);
609
+ const inputTo = Math.max(range.from, range.to);
610
+ const expandedRange =
611
+ operation.type === "clear-mark" && operation.expandToFullHighlight === true
612
+ ? expandRangeToCanonicalHighlightExtent(document, inputFrom, inputTo)
613
+ : { from: inputFrom, to: inputTo };
614
+ const selectionFrom = expandedRange.from;
615
+ const selectionTo = expandedRange.to;
610
616
  const selection: RuntimeRenderSnapshot["selection"] = {
611
617
  anchor: selectionFrom,
612
618
  head: selectionTo,
@@ -671,6 +677,143 @@ export function applyTextMarkOperationToDocumentRange(
671
677
  };
672
678
  }
673
679
 
680
+ function expandRangeToCanonicalHighlightExtent(
681
+ document: CanonicalDocumentEnvelope,
682
+ inputFrom: number,
683
+ inputTo: number,
684
+ ): { from: number; to: number } {
685
+ let from = inputFrom;
686
+ let to = inputTo;
687
+ const root = document.content as DocumentRootNode;
688
+ let cursor = 0;
689
+
690
+ for (let blockIndex = 0; blockIndex < root.children.length; blockIndex += 1) {
691
+ const block = root.children[blockIndex]!;
692
+ const blockFrom = cursor;
693
+ const blockLength =
694
+ block.type === "paragraph"
695
+ ? block.children.reduce(
696
+ (total, child) => total + inlineNodeLength(child as InlineNode),
697
+ 0,
698
+ )
699
+ : 1;
700
+ const blockTo = blockFrom + blockLength;
701
+
702
+ if (
703
+ block.type === "paragraph" &&
704
+ rangesOverlap(inputFrom, inputTo, blockFrom, blockTo)
705
+ ) {
706
+ const spans = collectInlineHighlightSpans(block.children, blockFrom).spans;
707
+ const expanded = expandRangeWithinHighlightSpans(spans, inputFrom, inputTo);
708
+ if (expanded.from < from) from = expanded.from;
709
+ if (expanded.to > to) to = expanded.to;
710
+ }
711
+
712
+ cursor = blockTo;
713
+ if (blockIndex < root.children.length - 1) {
714
+ cursor += 1;
715
+ }
716
+ }
717
+
718
+ return { from, to };
719
+ }
720
+
721
+ interface HighlightSpan {
722
+ readonly from: number;
723
+ readonly to: number;
724
+ readonly highlighted: boolean;
725
+ }
726
+
727
+ function collectInlineHighlightSpans(
728
+ nodes: readonly InlineNode[],
729
+ start: number,
730
+ ): { spans: HighlightSpan[]; nextPosition: number } {
731
+ const spans: HighlightSpan[] = [];
732
+ let position = start;
733
+
734
+ for (const node of nodes) {
735
+ if (node.type === "text") {
736
+ const length = Array.from(node.text).length;
737
+ if (length > 0) {
738
+ spans.push({
739
+ from: position,
740
+ to: position + length,
741
+ highlighted: marksHaveVisualHighlight(node.marks),
742
+ });
743
+ }
744
+ position += length;
745
+ continue;
746
+ }
747
+
748
+ if (node.type === "hyperlink") {
749
+ const nested = collectInlineHighlightSpans(node.children as InlineNode[], position);
750
+ spans.push(...nested.spans);
751
+ position = nested.nextPosition;
752
+ continue;
753
+ }
754
+
755
+ const length = inlineNodeLength(node);
756
+ if (length > 0) {
757
+ spans.push({
758
+ from: position,
759
+ to: position + length,
760
+ highlighted: false,
761
+ });
762
+ }
763
+ position += length;
764
+ }
765
+
766
+ return { spans, nextPosition: position };
767
+ }
768
+
769
+ function expandRangeWithinHighlightSpans(
770
+ spans: readonly HighlightSpan[],
771
+ inputFrom: number,
772
+ inputTo: number,
773
+ ): { from: number; to: number } {
774
+ let from = inputFrom;
775
+ let to = inputTo;
776
+ let touchedLeftIndex = -1;
777
+ let touchedRightIndex = -1;
778
+
779
+ for (let i = 0; i < spans.length; i += 1) {
780
+ const span = spans[i]!;
781
+ if (!span.highlighted) continue;
782
+ if (rangesOverlap(inputFrom, inputTo, span.from, span.to)) {
783
+ if (touchedLeftIndex === -1) touchedLeftIndex = i;
784
+ touchedRightIndex = i;
785
+ }
786
+ }
787
+
788
+ if (touchedLeftIndex === -1) {
789
+ return { from, to };
790
+ }
791
+
792
+ for (let i = touchedLeftIndex; i >= 0; i -= 1) {
793
+ const span = spans[i]!;
794
+ if (!span.highlighted) break;
795
+ if (span.to < from) break;
796
+ if (span.from < from) from = span.from;
797
+ }
798
+
799
+ for (let i = touchedRightIndex; i < spans.length; i += 1) {
800
+ const span = spans[i]!;
801
+ if (!span.highlighted) break;
802
+ if (span.from > to) break;
803
+ if (span.to > to) to = span.to;
804
+ }
805
+
806
+ return { from, to };
807
+ }
808
+
809
+ function marksHaveVisualHighlight(marks: readonly TextMark[] | undefined): boolean {
810
+ return (
811
+ marks?.some((mark) =>
812
+ mark.type === "highlight" || mark.type === "backgroundColor"
813
+ ) ?? false
814
+ );
815
+ }
816
+
674
817
  function resolveTextMarkRangeUpdater(
675
818
  operation: TextMarkRangeOperation,
676
819
  ): (marks?: TextMark[]) => TextMark[] | undefined {
@@ -130,9 +130,12 @@ function applyLinearTextTransaction(
130
130
 
131
131
  // `normalizedRange.{from,to}` are logical positions (scope markers = 0 width,
132
132
  // matching surface-projection). Translate to unit-array indices so scope
133
- // marker units preserved at the boundary stay intact on either side.
134
- const unitFrom = logicalPositionToUnitIndex(story.units, normalizedRange.from, "before");
135
- const unitTo = logicalPositionToUnitIndex(story.units, normalizedRange.to, "after");
133
+ // marker units sitting exactly on the replacement boundaries stay intact:
134
+ // start-boundary markers remain before the inserted payload, end-boundary
135
+ // markers remain after it. Markers strictly inside the range are still part
136
+ // of the replacement/delete slice.
137
+ const unitFrom = logicalPositionToUnitIndex(story.units, normalizedRange.from, "after");
138
+ const unitTo = logicalPositionToUnitIndex(story.units, normalizedRange.to, "before");
136
139
 
137
140
  ensureEditableRange(story.units.slice(unitFrom, unitTo));
138
141
 
@@ -3531,6 +3531,9 @@ export function createDocumentRuntime(
3531
3531
  ? {
3532
3532
  type: "clear-mark" as const,
3533
3533
  mark: step.formattingAction.mark,
3534
+ ...(step.formattingAction.expandToFullHighlight === true
3535
+ ? { expandToFullHighlight: true as const }
3536
+ : {}),
3534
3537
  }
3535
3538
  : {
3536
3539
  type: "set-mark" as const,
@@ -332,6 +332,15 @@ export type ScopeFormattingAction =
332
332
  * `"visualHighlight"` to remove both visual highlight layers.
333
333
  */
334
334
  readonly mark: ScopeFormattingClearTarget;
335
+ /**
336
+ * When true, expand the target range to the full contiguous visual
337
+ * highlight span that touches it before clearing. Mirrors
338
+ * `clearHighlight({expandToFullHighlight:true})`; the clear remains
339
+ * source-layer exact, so `mark:"highlight"` still removes only
340
+ * `w:highlight`, while `mark:"visualHighlight"` removes highlight +
341
+ * shading across the expanded span.
342
+ */
343
+ readonly expandToFullHighlight?: boolean;
335
344
  }
336
345
  | {
337
346
  readonly kind: "set-mark";
@@ -3931,18 +3931,11 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3931
3931
  }}
3932
3932
  onDeselectObject={() => activeRuntime.deselectObject()}
3933
3933
  onScopeAskAgent={(payload) => {
3934
- // Resolve the scope's anchor + story from the workflow
3935
- // facet's card model so the agent request carries the
3936
- // canonical range. Layer-06 Slice 4 made `runtime.workflow`
3937
- // the canonical source for scope-card data.
3938
- const models = activeRuntime.workflow.getAllScopeCardModels();
3939
- const model = models.find((entry) => entry.scopeId === payload.scopeId);
3940
- if (!model) return;
3941
- const scopeSnapshot = activeRuntime.getWorkflowScopeSnapshot();
3942
- const scopes = scopeSnapshot?.scopes ?? [];
3943
- const scope = scopes.find((entry) => entry.scopeId === payload.scopeId);
3944
- if (!scope) return;
3945
- const anchor = scope.anchor;
3934
+ // Resolve scope card + story through the mounted UI API seam so
3935
+ // the shell root does not re-own workflow facet reads.
3936
+ const model = api.ui?.scope.card(payload.scopeId) ?? null;
3937
+ const bundle = api.ui?.scope.getBundle(payload.scopeId) ?? null;
3938
+ const anchor = payload.anchor ?? model?.anchor;
3946
3939
  if (!anchor) return;
3947
3940
  const requestId =
3948
3941
  typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
@@ -3957,8 +3950,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3957
3950
  requestId,
3958
3951
  scopeId: payload.scopeId,
3959
3952
  anchor,
3960
- selectionText: model.label ?? "",
3961
- ...(scope.storyTarget ? { storyTarget: scope.storyTarget } : {}),
3953
+ selectionText: model?.label ?? "",
3954
+ ...(bundle?.scope.handle.storyTarget
3955
+ ? { storyTarget: bundle.scope.handle.storyTarget }
3956
+ : {}),
3962
3957
  };
3963
3958
  onEventRef.current?.(eventPayload);
3964
3959
  }}
@@ -162,7 +162,10 @@ export interface EditorShellViewProps {
162
162
  groupId: string;
163
163
  }) => void;
164
164
  /** K2 — forwarded from workspace to WordReviewEditor. */
165
- onScopeAskAgent?: (payload: { scopeId: string }) => void;
165
+ onScopeAskAgent?: (payload: {
166
+ scopeId: string;
167
+ anchor?: import("../api/public-types.ts").EditorAnchorProjection;
168
+ }) => void;
166
169
  /** N6 — deselects the currently grabbed object; wired to runtime.deselectObject(). */
167
170
  onDeselectObject?: () => void;
168
171
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
@@ -14,7 +14,7 @@
14
14
 
15
15
  import * as React from "react";
16
16
  import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
17
- import type { ScopeRailSegment } from "../../api/public-types.ts";
17
+ import type { ScopeRailPosture, ScopeRailSegment } from "../../api/public-types.ts";
18
18
  import type {
19
19
  EditorRole,
20
20
  EditorStoryTarget,
@@ -28,6 +28,7 @@ import { TwScopeCardLayer } from "./tw-scope-card-layer";
28
28
  import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
29
29
  import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
30
30
  import { TwObjectSelectionOverlay } from "./tw-object-selection-overlay";
31
+ import { useUiApi } from "../ui-api-context";
31
32
 
32
33
  export interface TwChromeOverlayProps {
33
34
  /** Layout facet the overlay layers read from (layout-semantic data). */
@@ -39,16 +40,17 @@ export interface TwChromeOverlayProps {
39
40
  */
40
41
  geometryFacet: import("../../runtime/geometry/index.ts").GeometryFacet;
41
42
  /**
42
- * Workflow facet — Layer-06 canonical source of scope rail segments
43
- * + scope card models (`runtime.workflow`). Required for scope-rail
44
- * / scope-card rendering; pass `null` when no runtime is attached
45
- * (e.g., during initial mount).
43
+ * Workflow facet — no-provider fallback for scope rail/card reads.
44
+ * Mounted editor paths prefer `api.ui.scope.*`; pass `null` when no
45
+ * runtime is attached (e.g., during initial mount).
46
46
  */
47
47
  workflowFacet: import("../../runtime/workflow/rail/types.ts").WorkflowFacet | null;
48
48
  /** Optional coordinate space override. Defaults to the overlay origin. */
49
49
  space?: OverlayCoordinateSpace;
50
50
  /** Active scope id (for emphasis + rail tab sync). */
51
51
  activeScopeId?: string | null;
52
+ /** Posture filters shared with the Workflow rail. Omitted means all scopes render. */
53
+ visibleScopePostures?: ReadonlySet<ScopeRailPosture>;
52
54
  /**
53
55
  * Click handler fired when the user clicks a scope rail stripe.
54
56
  * P0 wires this to open the scope card (P1 ships the card layer).
@@ -213,6 +215,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
213
215
  geometryFacet,
214
216
  space,
215
217
  activeScopeId,
218
+ visibleScopePostures,
216
219
  onScopeStripeClick,
217
220
  onScopeSegmentClick,
218
221
  onScopeCardClose,
@@ -242,6 +245,25 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
242
245
  mediaPreviews,
243
246
  activeBandRibbonProps,
244
247
  }) => {
248
+ const ui = useUiApi();
249
+ const scopeRailSegments = React.useMemo(
250
+ () =>
251
+ ui?.scope.rail().segments ??
252
+ workflowFacet?.getAllRailSegments() ??
253
+ [],
254
+ [ui, workflowFacet, renderFrameRevision],
255
+ );
256
+ const visibleScopeIds = React.useMemo(() => {
257
+ if (!visibleScopePostures) return undefined;
258
+ const ids = new Set<string>();
259
+ for (const segment of scopeRailSegments) {
260
+ if (visibleScopePostures.has(segment.posture)) {
261
+ ids.add(segment.scopeId);
262
+ }
263
+ }
264
+ return ids;
265
+ }, [scopeRailSegments, visibleScopePostures]);
266
+
245
267
  return (
246
268
  <div
247
269
  className="wre-chrome-overlay pointer-events-none absolute inset-0 z-30"
@@ -266,8 +288,10 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
266
288
  <TwScopeRailLayer
267
289
  geometryFacet={geometryFacet}
268
290
  workflowFacet={workflowFacet}
291
+ scopeRailSegments={scopeRailSegments}
269
292
  space={space}
270
293
  activeScopeId={activeScopeId}
294
+ visibleScopePostures={visibleScopePostures}
271
295
  onStripeClick={onScopeStripeClick}
272
296
  onSegmentClick={onScopeSegmentClick}
273
297
  />
@@ -275,6 +299,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
275
299
  facet={facet}
276
300
  workflowFacet={workflowFacet}
277
301
  activeScopeId={activeScopeId ?? null}
302
+ visibleScopeIds={visibleScopeIds}
278
303
  onClose={onScopeCardClose ?? noop}
279
304
  onModeChange={onScopeCardModeChange ?? noopModeChange}
280
305
  onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
@@ -17,6 +17,7 @@ import {
17
17
  projectRectToOverlay,
18
18
  type OverlayCoordinateSpace,
19
19
  } from "./chrome-overlay-projector";
20
+ import { useUiApi } from "../ui-api-context";
20
21
  import type { RenderFrame, RenderFrameRect } from "../../api/public-types.ts";
21
22
  import type { ScopeRailSegment, ScopeRailPosture } from "../../api/public-types.ts";
22
23
  import type { WorkflowFacet } from "../../api/public-types.ts";
@@ -30,22 +31,29 @@ export interface TwScopeRailLayerProps {
30
31
  /**
31
32
  * Geometry facet — used for `getRenderFrame()`. Migrated from
32
33
  * `facet: WordReviewEditorLayoutFacet` in refactor/05 cross-lane-coord
33
- * §8.4 pass. Rail-segment + scope-card data flows through
34
- * `workflowFacet` per Layer-06 Slice 4's seam inversion.
34
+ * §8.4 pass. Mounted rail/card data flows through `api.ui.scope.*`.
35
35
  */
36
36
  geometryFacet: GeometryFacet;
37
37
  /**
38
- * Workflow facet — canonical source of scope rail segments + scope
39
- * card models (`runtime.workflow.*`). Required for the layer to
40
- * render anything; passing `null` makes the layer a no-op.
38
+ * Workflow facet — no-provider fallback for scope rail/card reads.
39
+ * Mounted editor paths prefer `api.ui.scope.*`; passing `null` makes
40
+ * fallback reads no-op.
41
41
  */
42
42
  workflowFacet: WorkflowFacet | null;
43
+ /**
44
+ * Optional pre-read rail snapshot from `ui.scope.rail()`. When omitted,
45
+ * the layer reads the mounted UI API itself, then falls back to the
46
+ * workflow facet for no-provider paths.
47
+ */
48
+ scopeRailSegments?: readonly ScopeRailSegment[];
43
49
  /** Overlay's coordinate space. Defaults to the overlay's own origin. */
44
50
  space?: OverlayCoordinateSpace;
45
51
  /** Horizontal pad (px) the rail gutter occupies to the left of body. */
46
52
  railLaneWidthPx?: number;
47
53
  /** Scope id that should render with the `active` emphasis. */
48
54
  activeScopeId?: string | null;
55
+ /** Posture filters shared with the Workflow rail. Omitted means all scopes render. */
56
+ visibleScopePostures?: ReadonlySet<ScopeRailPosture>;
49
57
  /**
50
58
  * Fires when the user clicks the rail stripe — opens the scope card.
51
59
  * P0 wires this directly; P1 replaces with card-layer-aware routing.
@@ -87,22 +95,32 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
87
95
  // ---------------------------------------------------------------------------
88
96
 
89
97
  const DEFAULT_RAIL_LANE_PX = 44;
90
- const STRIPE_WIDTH_PX = 4;
91
- const LABEL_WIDTH_PX = 40;
98
+ const STRIPE_WIDTH_PX = 6;
99
+ const LABEL_WIDTH_PX = 58;
92
100
  const STACK_OFFSET_PX = 6;
93
101
 
94
102
  export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
95
103
  geometryFacet,
96
104
  workflowFacet,
105
+ scopeRailSegments,
97
106
  space,
98
107
  railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
99
108
  activeScopeId,
109
+ visibleScopePostures,
100
110
  onStripeClick,
101
111
  onSegmentClick,
102
112
  "data-testid": testId,
103
113
  }) => {
114
+ const ui = useUiApi();
104
115
  const frame = geometryFacet.getRenderFrame() ?? null;
105
- const segments = workflowFacet?.getAllRailSegments() ?? [];
116
+ const railSegments =
117
+ scopeRailSegments ??
118
+ ui?.scope.rail().segments ??
119
+ workflowFacet?.getAllRailSegments() ??
120
+ [];
121
+ const segments = railSegments.filter((segment) =>
122
+ visibleScopePostures ? visibleScopePostures.has(segment.posture) : true,
123
+ );
106
124
 
107
125
  if (!frame || segments.length === 0) {
108
126
  return null;
@@ -110,15 +128,23 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
110
128
 
111
129
  // P2: which scopes currently have a `source: "ai"` candidate
112
130
  // overlapping — drives the agent-pending shimmer class on their
113
- // tints. Reads from the workflow facet's card-model projection so the
114
- // shimmer logic lives in the Layer-06 runtime, not the overlay.
115
- const cardModels = workflowFacet?.getAllScopeCardModels() ?? [];
131
+ // tints. Mounted surfaces read card projections through ui.scope.card;
132
+ // no-provider paths fall back to the workflow facet.
133
+ const cardModels = ui ? [] : workflowFacet?.getAllScopeCardModels() ?? [];
116
134
  const agentPendingByScope = new Map<string, boolean>();
117
135
  for (const model of cardModels) {
118
136
  if (model.agentPending) {
119
137
  agentPendingByScope.set(model.scopeId, true);
120
138
  }
121
139
  }
140
+ if (ui) {
141
+ for (const segment of segments) {
142
+ const model = ui.scope.card(segment.scopeId);
143
+ if (model?.agentPending) {
144
+ agentPendingByScope.set(segment.scopeId, true);
145
+ }
146
+ }
147
+ }
122
148
 
123
149
  // P3c: stack offsets for overlapping scopes. Two scopes whose
124
150
  // offset ranges intersect on the same page render as stacked
@@ -223,16 +249,21 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
223
249
  style={projectRectToOverlay(stripeRect, projectorSpace)}
224
250
  />
225
251
  {/* Label pill — revealed on stripe hover via CSS. */}
226
- <div
227
- className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken}`}
252
+ <button
253
+ type="button"
254
+ tabIndex={-1}
255
+ className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} ${
256
+ isActive ? "wre-scope-rail-label-active" : ""
257
+ }`}
228
258
  data-scope-id={segment.scopeId}
229
259
  data-posture={segment.posture}
230
- aria-hidden="true"
260
+ aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
261
+ onClick={handleActivate}
231
262
  style={projectRectToOverlay(labelRect, projectorSpace)}
232
263
  >
233
264
  <span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
234
265
  <span className="wre-scope-rail-label-text">{style.labelText}</span>
235
- </div>
266
+ </button>
236
267
  </React.Fragment>
237
268
  );
238
269
  })}
@@ -402,13 +402,7 @@ function buildParagraph(
402
402
  indentRight: paragraphLayout.indentation?.right ?? null,
403
403
  indentFirstLine: paragraphLayout.indentation?.firstLine ?? null,
404
404
  indentHanging: paragraphLayout.indentation?.hanging ?? null,
405
- numberingMarkerWidth:
406
- paragraphLayout.markerLane?.width ??
407
- paragraphLayout.indentation?.hanging ??
408
- (paragraphLayout.indentation?.firstLine !== undefined &&
409
- paragraphLayout.indentation.firstLine < 0
410
- ? Math.abs(paragraphLayout.indentation.firstLine)
411
- : null),
405
+ numberingMarkerWidth: paragraphLayout.markerLane?.width ?? null,
412
406
  numberingMarkerStart: paragraphLayout.markerLane?.start ?? null,
413
407
  numberingMarkerJustification: paragraphLayout.markerJustification ?? null,
414
408
  numberingMarkerRunProperties: block.resolvedNumbering?.markerRunProperties ?? null,
@@ -9,13 +9,13 @@ import {
9
9
  cycleScopeIndex,
10
10
  shouldHandleScopeNavKey,
11
11
  } from "../chrome-overlay/scope-keyboard-cycle";
12
+ import { useUiApi } from "../ui-api-context.tsx";
12
13
 
13
14
  export interface UseScopeCardStateOptions {
14
15
  layoutFacet?: import("../../runtime/layout/index.ts").WordReviewEditorLayoutFacet;
15
16
  /**
16
- * Layer-06 workflow facet — canonical source of scope card models.
17
- * Required by the keyboard-nav loop + Ask-agent handler after Slice 4
18
- * rail-seam inversion removed those methods from `layoutFacet`.
17
+ * Layer-06 workflow facet — no-provider fallback for scope card models.
18
+ * Mounted paths prefer `api.ui.scope.rail/card`.
19
19
  */
20
20
  workflowFacet?: import("../../runtime/workflow/rail/types.ts").WorkflowFacet;
21
21
  onScopeModeChangeRequested?: (payload: {
@@ -79,6 +79,31 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
79
79
  void layoutFacet;
80
80
 
81
81
  const [activeScopeId, setActiveScopeId] = useState<string | null>(null);
82
+ const ui = useUiApi();
83
+
84
+ const readScopeIds = useCallback((): string[] => {
85
+ if (ui) {
86
+ const ids: string[] = [];
87
+ const seen = new Set<string>();
88
+ for (const segment of ui.scope.rail().segments) {
89
+ if (seen.has(segment.scopeId)) continue;
90
+ seen.add(segment.scopeId);
91
+ ids.push(segment.scopeId);
92
+ }
93
+ return ids;
94
+ }
95
+ return workflowFacet?.getAllScopeCardModels().map((model) => model.scopeId) ?? [];
96
+ }, [ui, workflowFacet]);
97
+
98
+ const readScopeCard = useCallback(
99
+ (scopeId: string) => {
100
+ if (ui) return ui.scope.card(scopeId);
101
+ return workflowFacet
102
+ ?.getAllScopeCardModels()
103
+ .find((m) => m.scopeId === scopeId) ?? null;
104
+ },
105
+ [ui, workflowFacet],
106
+ );
82
107
 
83
108
  const handleScopeStripeClick = useCallback(
84
109
  (segment: { scopeId: string }) => {
@@ -94,14 +119,13 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
94
119
  }, []);
95
120
 
96
121
  useEffect(() => {
97
- if (!workflowFacet) {
122
+ if (!ui && !workflowFacet) {
98
123
  return undefined;
99
124
  }
100
125
  const onKey = (event: KeyboardEvent) => {
101
126
  if (!shouldHandleScopeNavKey(event)) return;
102
- const models = workflowFacet.getAllScopeCardModels();
103
- if (models.length === 0) return;
104
- const ids = models.map((model) => model.scopeId);
127
+ const ids = readScopeIds();
128
+ if (ids.length === 0) return;
105
129
  const key = event.key.toLowerCase();
106
130
  if (key === "enter") {
107
131
  if (!activeScopeId) {
@@ -117,7 +141,7 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
117
141
  };
118
142
  window.addEventListener("keydown", onKey);
119
143
  return () => window.removeEventListener("keydown", onKey);
120
- }, [workflowFacet, activeScopeId]);
144
+ }, [ui, workflowFacet, activeScopeId, readScopeIds]);
121
145
 
122
146
  const handleScopeCardModeChange = useCallback(
123
147
  (scopeId: string, mode: WorkflowScopeMode) => {
@@ -149,12 +173,10 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
149
173
 
150
174
  const handleScopeCardAskAgent = useCallback(
151
175
  (scopeId: string) => {
152
- const cardModel = workflowFacet
153
- ?.getAllScopeCardModels()
154
- .find((m) => m.scopeId === scopeId);
176
+ const cardModel = readScopeCard(scopeId);
155
177
  onScopeAskAgent?.({ scopeId, anchor: cardModel?.anchor });
156
178
  },
157
- [onScopeAskAgent, workflowFacet],
179
+ [onScopeAskAgent, readScopeCard],
158
180
  );
159
181
 
160
182
  return {
@@ -283,13 +283,20 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
283
283
  // changed, re-read any attached anchors" marker. Per-query
284
284
  // invalidation fan-out is a Phase Q follow-up — the geometry
285
285
  // facet does not yet expose per-kind invalidation events.
286
- const uiApiForEmit = useUiApi();
286
+ const uiApi = useUiApi();
287
287
  const shellChannels = useUiShellChannels();
288
288
  React.useEffect(() => {
289
- if (!uiApiForEmit || !shellChannels) return;
290
- shellChannels.viewport.emit(uiApiForEmit.viewport.get());
289
+ if (!uiApi || !shellChannels) return;
290
+ shellChannels.viewport.emit(uiApi.viewport.get());
291
291
  shellChannels.overlays.emit({ kind: "page", value: 0 });
292
- }, [renderFrameRevision, uiApiForEmit, shellChannels]);
292
+ }, [renderFrameRevision, uiApi, shellChannels]);
293
+ const scopeRailSegments = useMemo(
294
+ () =>
295
+ uiApi?.scope.rail().segments ??
296
+ props.workflowFacet?.getAllRailSegments() ??
297
+ [],
298
+ [uiApi, props.workflowFacet, renderFrameRevision],
299
+ );
293
300
  const headings = props.documentNavigation?.headings ?? [];
294
301
  const headerVariant = snapshot.pageLayout?.headerVariants[0]?.variant ?? "default";
295
302
  const footerVariant = snapshot.pageLayout?.footerVariants[0]?.variant ?? "default";
@@ -1272,10 +1279,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1272
1279
  onRejectRevision: props.onRejectRevision,
1273
1280
  onAcceptAllChanges: props.onAcceptAllChanges,
1274
1281
  onRejectAllChanges: props.onRejectAllChanges,
1275
- // Slice 4C rail-seam inversion: segments now come from the
1276
- // Layer-06 workflow facet. Layout facet no longer exposes
1277
- // `getAllScopeRailSegments` (methods removed in v40 / Slice 4C).
1278
- scopeRailSegments: props.workflowFacet?.getAllRailSegments() ?? [],
1282
+ // Layer 11 closeout: mounted workspace chrome reads scope
1283
+ // rail data through `api.ui.scope.rail()`, with the workflow
1284
+ // facet retained as the no-provider fallback.
1285
+ scopeRailSegments,
1279
1286
  activeScopeId,
1280
1287
  onOpenScope: (segment) => {
1281
1288
  handleScopeStripeClick({ scopeId: segment.scopeId });