@beyondwork/docx-react-component 1.0.94 → 1.0.96

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.96",
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
 
@@ -13,6 +13,7 @@ import {
13
13
  type EditorWarning as InternalEditorWarning,
14
14
  } from "../core/state/editor-state.ts";
15
15
  import {
16
+ createPlainText,
16
17
  logicalPositionToUnitIndex,
17
18
  parseTextStory,
18
19
  serializeTextStory,
@@ -3195,6 +3196,13 @@ export function createDocumentRuntime(
3195
3196
  replaceText(text, target, formatting) {
3196
3197
  try {
3197
3198
  const timestamp = clock();
3199
+ const selection = target ? createSelectionFromPublicAnchor(target) : state.selection;
3200
+ if (
3201
+ shouldPreserveEquivalentReplacement(formatting) &&
3202
+ replacementTextMatchesCurrentRange(state.document, activeStory, selection, text)
3203
+ ) {
3204
+ return;
3205
+ }
3198
3206
  applyTextCommandInActiveStory(
3199
3207
  {
3200
3208
  type: "text.insert",
@@ -3203,7 +3211,7 @@ export function createDocumentRuntime(
3203
3211
  origin: createOrigin("api", timestamp),
3204
3212
  },
3205
3213
  {
3206
- selection: target ? createSelectionFromPublicAnchor(target) : state.selection,
3214
+ selection,
3207
3215
  blockedCommandName: "replaceText",
3208
3216
  },
3209
3217
  );
@@ -3531,6 +3539,9 @@ export function createDocumentRuntime(
3531
3539
  ? {
3532
3540
  type: "clear-mark" as const,
3533
3541
  mark: step.formattingAction.mark,
3542
+ ...(step.formattingAction.expandToFullHighlight === true
3543
+ ? { expandToFullHighlight: true as const }
3544
+ : {}),
3534
3545
  }
3535
3546
  : {
3536
3547
  type: "set-mark" as const,
@@ -6445,6 +6456,43 @@ function createSelectionFromPublicAnchor(
6445
6456
  }
6446
6457
  }
6447
6458
 
6459
+ function shouldPreserveEquivalentReplacement(formatting: TextFormattingDirective | undefined): boolean {
6460
+ return !formatting || formatting.mode === "match-replaced-range";
6461
+ }
6462
+
6463
+ function replacementTextMatchesCurrentRange(
6464
+ document: CanonicalDocumentEnvelope,
6465
+ activeStory: EditorStoryTarget,
6466
+ selection: import("../core/state/editor-state.ts").SelectionSnapshot,
6467
+ replacement: string,
6468
+ ): boolean {
6469
+ const from = Math.max(0, Math.min(selection.anchor, selection.head));
6470
+ const to = Math.max(0, Math.max(selection.anchor, selection.head));
6471
+ if (from === to) {
6472
+ return replacement.length === 0;
6473
+ }
6474
+
6475
+ const content = activeStory.kind === "main"
6476
+ ? document.content
6477
+ : {
6478
+ type: "doc" as const,
6479
+ children: [...getStoryBlocks(document, activeStory)],
6480
+ };
6481
+ const story = parseTextStory(content);
6482
+ if (from > story.size || to > story.size) {
6483
+ return false;
6484
+ }
6485
+
6486
+ const unitFrom = logicalPositionToUnitIndex(story.units, from, "after");
6487
+ const unitTo = logicalPositionToUnitIndex(story.units, to, "before");
6488
+ const selectedText = createPlainText({
6489
+ firstParagraph: story.firstParagraph,
6490
+ units: story.units.slice(unitFrom, unitTo),
6491
+ size: to - from,
6492
+ });
6493
+ return selectedText === replacement;
6494
+ }
6495
+
6448
6496
  /**
6449
6497
  * I2 Tier B Slice 4b — extract the selection range from a document as a
6450
6498
  * `CanonicalDocumentFragment`. The fragment preserves text + marks +
@@ -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";
@@ -94,6 +94,11 @@ export function collectScopeRailSegments(
94
94
  const activeIds = new Set(input.activeWorkItemScopeIds ?? []);
95
95
 
96
96
  for (const scope of input.scopes ?? []) {
97
+ // Invisible scopes are runtime/agent context only. They may still
98
+ // participate in guard decisions, but they must not surface as rail,
99
+ // card, or body-tint chrome.
100
+ if (scope.visibility === "invisible") continue;
101
+
97
102
  const range = anchorToRuntimeRange(scope.anchor);
98
103
  if (!range) continue;
99
104
  const storyTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
@@ -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>;
@@ -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,10 +40,9 @@ 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. */
@@ -242,6 +242,15 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
242
242
  mediaPreviews,
243
243
  activeBandRibbonProps,
244
244
  }) => {
245
+ const ui = useUiApi();
246
+ const scopeRailSegments = React.useMemo(
247
+ () =>
248
+ ui?.scope.rail().segments ??
249
+ workflowFacet?.getAllRailSegments() ??
250
+ [],
251
+ [ui, workflowFacet, renderFrameRevision],
252
+ );
253
+
245
254
  return (
246
255
  <div
247
256
  className="wre-chrome-overlay pointer-events-none absolute inset-0 z-30"
@@ -266,6 +275,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
266
275
  <TwScopeRailLayer
267
276
  geometryFacet={geometryFacet}
268
277
  workflowFacet={workflowFacet}
278
+ scopeRailSegments={scopeRailSegments}
269
279
  space={space}
270
280
  activeScopeId={activeScopeId}
271
281
  onStripeClick={onScopeStripeClick}
@@ -53,8 +53,6 @@ export interface TwScopeCardLayerProps {
53
53
  */
54
54
  workflowFacet: WorkflowFacet | null;
55
55
  activeScopeId: string | null;
56
- /** Scope ids currently visible under the Workflow rail layer filters. */
57
- visibleScopeIds?: ReadonlySet<string>;
58
56
  onClose: () => void;
59
57
  onModeChange: (scopeId: string, mode: WorkflowScopeMode) => void;
60
58
  onIssueAction: (
@@ -94,7 +92,6 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
94
92
  facet,
95
93
  workflowFacet,
96
94
  activeScopeId,
97
- visibleScopeIds,
98
95
  onClose,
99
96
  onModeChange,
100
97
  onIssueAction,
@@ -131,11 +128,10 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
131
128
  const getVisibleScopeCardModel = React.useCallback(
132
129
  (scopeId: string | null): ScopeCardModel | null => {
133
130
  if (!scopeId) return null;
134
- if (visibleScopeIds && !visibleScopeIds.has(scopeId)) return null;
135
131
  if (ui) return ui.scope.card(scopeId);
136
132
  return getWorkflowScopeCardModel(scopeId);
137
133
  },
138
- [getWorkflowScopeCardModel, ui, visibleScopeIds],
134
+ [getWorkflowScopeCardModel, ui],
139
135
  );
140
136
 
141
137
  // The effective scope is the pinned one if it still resolves to a
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Scope rail layer — renders workflow scopes as a thin color stripe in
3
- * the reserved left-gutter lane plus a per-line flat tint behind the
4
- * scoped text runs.
3
+ * the reserved left-gutter lane plus a border-only line outline. The
4
+ * visible scope ownership border lives on the PM inline text decoration.
5
5
  *
6
6
  * Per runtime-rendering-and-chrome-phase.md §5 and
7
7
  * docs/plans/scope-card-overlay.md P0, the rail is a projection over
@@ -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,16 +31,21 @@ 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. */
@@ -88,12 +94,13 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
88
94
 
89
95
  const DEFAULT_RAIL_LANE_PX = 44;
90
96
  const STRIPE_WIDTH_PX = 4;
91
- const LABEL_WIDTH_PX = 40;
97
+ const LABEL_WIDTH_PX = 28;
92
98
  const STACK_OFFSET_PX = 6;
93
99
 
94
100
  export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
95
101
  geometryFacet,
96
102
  workflowFacet,
103
+ scopeRailSegments,
97
104
  space,
98
105
  railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
99
106
  activeScopeId,
@@ -101,8 +108,13 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
101
108
  onSegmentClick,
102
109
  "data-testid": testId,
103
110
  }) => {
111
+ const ui = useUiApi();
104
112
  const frame = geometryFacet.getRenderFrame() ?? null;
105
- const segments = workflowFacet?.getAllRailSegments() ?? [];
113
+ const segments =
114
+ scopeRailSegments ??
115
+ ui?.scope.rail().segments ??
116
+ workflowFacet?.getAllRailSegments() ??
117
+ [];
106
118
 
107
119
  if (!frame || segments.length === 0) {
108
120
  return null;
@@ -110,15 +122,23 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
110
122
 
111
123
  // P2: which scopes currently have a `source: "ai"` candidate
112
124
  // 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() ?? [];
125
+ // tints. Mounted surfaces read card projections through ui.scope.card;
126
+ // no-provider paths fall back to the workflow facet.
127
+ const cardModels = ui ? [] : workflowFacet?.getAllScopeCardModels() ?? [];
116
128
  const agentPendingByScope = new Map<string, boolean>();
117
129
  for (const model of cardModels) {
118
130
  if (model.agentPending) {
119
131
  agentPendingByScope.set(model.scopeId, true);
120
132
  }
121
133
  }
134
+ if (ui) {
135
+ for (const segment of segments) {
136
+ const model = ui.scope.card(segment.scopeId);
137
+ if (model?.agentPending) {
138
+ agentPendingByScope.set(segment.scopeId, true);
139
+ }
140
+ }
141
+ }
122
142
 
123
143
  // P3c: stack offsets for overlapping scopes. Two scopes whose
124
144
  // offset ranges intersect on the same page render as stacked
@@ -222,17 +242,20 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
222
242
  onKeyDown={handleStripeKey}
223
243
  style={projectRectToOverlay(stripeRect, projectorSpace)}
224
244
  />
225
- {/* Label pill — revealed on stripe hover via CSS. */}
226
- <div
245
+ {/* Edit handle — revealed on stripe hover via CSS. */}
246
+ <button
247
+ type="button"
248
+ tabIndex={-1}
227
249
  className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken}`}
228
250
  data-scope-id={segment.scopeId}
229
251
  data-posture={segment.posture}
230
- aria-hidden="true"
252
+ aria-label={`Edit scope${segment.label ? `: ${segment.label}` : ""}`}
253
+ onClick={handleActivate}
231
254
  style={projectRectToOverlay(labelRect, projectorSpace)}
232
255
  >
233
256
  <span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
234
257
  <span className="wre-scope-rail-label-text">{style.labelText}</span>
235
- </div>
258
+ </button>
236
259
  </React.Fragment>
237
260
  );
238
261
  })}
@@ -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 {
@@ -379,17 +379,31 @@
379
379
  /*
380
380
  * ─── Workflow inline text emphasis ───
381
381
  *
382
- * Since R3a the workflow scope rail + flat block tint are painted on the
383
- * ChromeOverlay plane (see src/ui-tailwind/chrome-overlay/). PM
384
- * decorations retain ONLY inline text emphasis for postures that carry
385
- * unique per-text signals (candidate = dashed underline, blocked-import =
386
- * wavy underline, active = thin outline). The rounded in-text background
387
- * boxes that previously wrapped every run are gone — the overlay's flat
388
- * tint handles that signal.
382
+ * Scopes should read as text ownership, not block selection. PM inline
383
+ * decorations carry the visible border on the scoped text, while the
384
+ * ChromeOverlay plane supplies the gutter/action rail. Keep this
385
+ * border-only: no filled boxes over document content.
389
386
  */
390
387
  .prosemirror-surface .ProseMirror .wre-workflow-inline {
391
388
  -webkit-box-decoration-break: clone;
392
389
  box-decoration-break: clone;
390
+ border-radius: 2px;
391
+ }
392
+
393
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-edit {
394
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent) 52%, transparent);
395
+ }
396
+
397
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-suggest {
398
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-warning) 56%, transparent);
399
+ }
400
+
401
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-comment {
402
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-insert) 48%, transparent);
403
+ }
404
+
405
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-view {
406
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-secondary) 46%, transparent);
393
407
  }
394
408
 
395
409
  .prosemirror-surface .ProseMirror .wre-workflow-inline-candidate {
@@ -408,31 +422,29 @@
408
422
 
409
423
  /*
410
424
  * Locked zone marker for inline runs: a subtle dotted right edge so the
411
- * reader can tell where the locked range ends when the gutter label scrolls
412
- * out of view. The overlay's flat tint carries the primary signal.
425
+ * reader can tell where the locked range ends when the gutter handle scrolls
426
+ * out of view. The rail carries the action affordance.
413
427
  */
414
428
  .prosemirror-surface .ProseMirror .wre-workflow-inline-locked-zone {
415
429
  box-shadow: inset -1px 0 0 color-mix(in srgb, var(--color-danger) 35%, transparent);
416
430
  }
417
431
 
418
432
  /*
419
- * `wre-workflow-inline-active` no longer emits a visual outline. The
420
- * per-run inset box-shadow produced a halo around every text fragment
421
- * (one box per run, due to box-decoration-break: clone above), which
422
- * fought with the overlay's flat tint. The class name is kept on the
423
- * inline decoration as a data hook (no visual), and emphasis for the
424
- * active scope now lives on the ChromeOverlay rail stripe + scope card.
433
+ * Active scope emphasis is a stronger text border plus the gutter handle.
434
+ * This keeps focus local to scoped text without reintroducing filled green
435
+ * rectangles.
425
436
  */
426
437
  .prosemirror-surface .ProseMirror .wre-workflow-inline-active {
427
- /* intentionally empty — visual emphasis handled by ChromeOverlay */
438
+ box-shadow:
439
+ 0 0 0 1px color-mix(in srgb, var(--color-accent) 72%, transparent),
440
+ 0 0 0 3px color-mix(in srgb, var(--color-accent) 12%, transparent);
428
441
  }
429
442
 
430
443
  /*
431
444
  * ─── ChromeOverlay: scope rail layer ───
432
445
  *
433
- * The overlay sits above PM and paints the flat block-tint + gutter labels
434
- * that used to be inline PM decorations. Positions come from the render
435
- * kernel's anchor index, not DOM rects.
446
+ * The overlay sits above PM and paints gutter handles plus optional
447
+ * border-only line outlines. It must not fill document content.
436
448
  */
437
449
  .wre-scope-rail-layer {
438
450
  pointer-events: none;
@@ -471,58 +483,36 @@
471
483
  border-radius: 0.2rem;
472
484
  pointer-events: none;
473
485
  z-index: 0;
474
- transition: background 140ms ease-out;
475
- }
476
-
477
- .wre-scope-rail-tint-accent {
478
- background: color-mix(in srgb, var(--color-accent) 12%, transparent);
479
- }
480
- .wre-scope-rail-tint-warning {
481
- background: color-mix(in srgb, var(--color-warning) 14%, transparent);
482
- }
483
- .wre-scope-rail-tint-insert {
484
- background: color-mix(in srgb, var(--color-insert) 12%, transparent);
485
- }
486
- .wre-scope-rail-tint-secondary {
487
- background: color-mix(in srgb, var(--color-secondary) 9%, transparent);
488
- }
489
- .wre-scope-rail-tint-danger {
490
- background: color-mix(in srgb, var(--color-danger) 14%, transparent);
491
- }
492
-
493
- /* §3.7 canonical scope families */
494
- .wre-scope-rail-tint-blocked {
495
- background: var(--color-scope-tint-blocked);
496
- }
497
- .wre-scope-rail-tint-in-scope {
498
- background: var(--color-scope-tint-in-scope);
499
- }
500
- .wre-scope-rail-tint-suggest {
501
- background: var(--color-scope-tint-suggest);
502
- }
503
- .wre-scope-rail-tint-comment {
504
- background: var(--color-scope-tint-comment);
505
- }
506
- .wre-scope-rail-tint-scheduled {
507
- background: var(--color-scope-tint-scheduled);
508
- }
486
+ background: transparent;
487
+ transition: box-shadow 140ms ease-out;
488
+ }
489
+
490
+ .wre-scope-rail-tint-accent,
491
+ .wre-scope-rail-tint-warning,
492
+ .wre-scope-rail-tint-insert,
493
+ .wre-scope-rail-tint-secondary,
494
+ .wre-scope-rail-tint-danger,
495
+ .wre-scope-rail-tint-blocked,
496
+ .wre-scope-rail-tint-in-scope,
497
+ .wre-scope-rail-tint-suggest,
498
+ .wre-scope-rail-tint-comment,
499
+ .wre-scope-rail-tint-scheduled,
509
500
  .wre-scope-rail-tint-proposed {
510
- background: var(--color-scope-tint-proposed);
501
+ background: transparent;
511
502
  }
512
503
 
513
504
  .wre-scope-rail-tint-active {
514
- outline: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
505
+ outline: 1px solid color-mix(in srgb, var(--color-accent) 52%, transparent);
515
506
  outline-offset: -1px;
516
507
  }
517
508
 
518
509
  /*
519
510
  * ─── Agent-pending shimmer (K2 / scope-card-overlay P2) ───
520
511
  *
521
- * Painted on every scope tint that overlaps a WorkflowCandidateRange
522
- * with `source: "ai"`. A soft 1.8s pulse signals the agent is
523
- * thinking without competing with the active outline. Reduced-
524
- * motion disables the animation and holds a static 60% opacity
525
- * border so the posture is still readable.
512
+ * Painted on every scope outline that overlaps a WorkflowCandidateRange
513
+ * with `source: "ai"`. A soft 1.8s pulse signals the agent is thinking
514
+ * without competing with the active outline. Reduced-motion disables the
515
+ * animation and holds a static 60% opacity outline.
526
516
  */
527
517
  @keyframes wre-agent-pulse {
528
518
  0%, 100% { opacity: 0.4; }
@@ -544,9 +534,9 @@
544
534
  * ─── Scope rail stripe ───
545
535
  *
546
536
  * The rail stripe is the rest-state representation of a scope: a 4px
547
- * color stripe in the gutter lane. Posture color comes from the
548
- * accent/warning/insert/secondary/danger tokens. Hover widens the
549
- * stripe via transform (zero layout cost) and reveals the label pill.
537
+ * color stripe in the gutter lane. Posture color comes from the
538
+ * accent/warning/insert/secondary/danger tokens. Hover widens the stripe
539
+ * via transform (zero layout cost) and reveals the edit handle.
550
540
  */
551
541
  .wre-scope-rail-stripe {
552
542
  position: absolute;
@@ -595,34 +585,31 @@
595
585
  .wre-scope-rail-stripe.wre-scope-rail-tint-proposed { background: var(--color-scope-tint-proposed); }
596
586
 
597
587
  /*
598
- * ─── Scope rail label pill ───
588
+ * ─── Scope rail edit handle ───
599
589
  *
600
- * Shown only on stripe hover (CSS-driven). The pill overlays the
601
- * stripe with icon + short posture label, anchored to the first line
602
- * of the scope.
590
+ * Shown only on stripe hover (CSS-driven). The handle overlays the
591
+ * stripe with a compact icon anchored to the first line of the scope.
603
592
  */
604
593
  .wre-scope-rail-label {
605
594
  position: absolute;
606
595
  display: flex;
607
596
  align-items: center;
608
597
  justify-content: center;
609
- gap: 0.2rem;
610
- padding: 0.15rem 0.3rem;
611
- border-radius: var(--radius-sm);
598
+ width: 24px;
599
+ height: 24px;
600
+ padding: 0;
601
+ border-radius: 999px;
612
602
  border: 1px solid transparent;
613
603
  background: var(--color-canvas, #fff);
614
604
  box-shadow: var(--shadow-sm);
615
- font-size: 9.5px;
616
- line-height: 1;
617
- text-transform: uppercase;
618
- letter-spacing: 0.06em;
619
- font-weight: 600;
605
+ font: inherit;
620
606
  cursor: pointer;
621
607
  z-index: 2;
622
608
  opacity: 0;
623
609
  pointer-events: none;
624
610
  transition: opacity 140ms ease-out, transform 140ms ease-out;
625
611
  transform: translateX(-4px);
612
+ margin: 0;
626
613
  }
627
614
 
628
615
  .wre-scope-rail-stripe:hover + .wre-scope-rail-label,
@@ -697,8 +684,8 @@
697
684
 
698
685
  .wre-scope-rail-icon {
699
686
  display: inline-block;
700
- width: 14px;
701
- height: 14px;
687
+ width: 13px;
688
+ height: 13px;
702
689
  background-color: currentColor;
703
690
  mask-repeat: no-repeat;
704
691
  mask-position: center;
@@ -708,6 +695,10 @@
708
695
  -webkit-mask-size: contain;
709
696
  }
710
697
 
698
+ .wre-scope-rail-label-text {
699
+ display: none;
700
+ }
701
+
711
702
  /* Simple inline-SVG-as-mask icons so consumers don't need an icon font. */
712
703
  .wre-scope-rail-icon-lock {
713
704
  mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 018 0v4"/></svg>');
@@ -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 });