@beyondwork/docx-react-component 1.0.93 → 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.93",
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>;
@@ -13,14 +13,15 @@
13
13
  * names sees the right option highlighted without code changes.
14
14
  */
15
15
 
16
- import React, { useState } from "react";
17
- import * as Popover from "@radix-ui/react-popover";
16
+ import React, { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
17
+ import { createPortal } from "react-dom";
18
18
  import { ChevronDown, Eye, EyeOff, Highlighter, Scroll } from "lucide-react";
19
19
 
20
20
  import {
21
21
  normalizeMarkupDisplay,
22
22
  type MarkupDisplay,
23
23
  } from "../../ui/headless/comment-decoration-model";
24
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
24
25
 
25
26
  export type DisplayMode = "all-markup" | "simple-markup" | "no-markup" | "original";
26
27
 
@@ -67,70 +68,136 @@ const MODES: readonly ModeEntry[] = [
67
68
 
68
69
  export function TwDisplayModeSelector(props: TwDisplayModeSelectorProps): React.ReactElement {
69
70
  const [open, setOpen] = useState(false);
71
+ const triggerRef = useRef<HTMLButtonElement>(null);
70
72
  const canonical = normalizeMarkupDisplay(props.value);
71
73
  const activeEntry = MODES.find((m) => m.mode === canonical) ?? MODES[0]!;
72
74
 
73
75
  return (
74
- <Popover.Root open={open} onOpenChange={setOpen}>
75
- <Popover.Trigger asChild>
76
+ <>
76
77
  <button
78
+ ref={triggerRef}
77
79
  type="button"
78
80
  disabled={props.disabled}
79
81
  data-testid={props["data-testid"] ?? "display-mode-selector-trigger"}
80
82
  aria-label={`Display mode: ${activeEntry.label}`}
81
- className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-surface focus-visible:outline-none focus-visible:bg-surface disabled:opacity-50"
83
+ aria-expanded={open}
84
+ aria-haspopup="menu"
85
+ onMouseDown={preserveEditorSelectionMouseDown}
86
+ onClick={(event) => {
87
+ event.preventDefault();
88
+ setOpen((value) => !value);
89
+ }}
90
+ className={[
91
+ "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-primary",
92
+ "hover:bg-surface focus-visible:outline-none focus-visible:bg-surface disabled:opacity-50",
93
+ open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : "",
94
+ ].join(" ")}
82
95
  >
83
96
  <activeEntry.icon className="h-3.5 w-3.5 text-tertiary" />
84
97
  <span>{activeEntry.label}</span>
85
98
  <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
86
99
  </button>
87
- </Popover.Trigger>
88
- <Popover.Portal>
89
- <Popover.Content
90
- className="z-50 w-[260px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
91
- sideOffset={8}
92
- align="end"
93
- data-testid="display-mode-selector-content"
94
- >
100
+ <DisplayModePortalMenu anchorRef={triggerRef} open={open}>
95
101
  <div className="mb-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
96
102
  Display mode
97
103
  </div>
98
104
  {MODES.map((entry) => {
99
105
  const isActive = entry.mode === canonical;
100
106
  return (
101
- <Popover.Close key={entry.mode} asChild>
102
- <button
103
- type="button"
104
- role="menuitemradio"
105
- aria-checked={isActive}
106
- onClick={() => {
107
- props.onChange(entry.mode);
108
- }}
109
- className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-[11px] transition-colors hover:bg-surface focus-visible:outline-none focus-visible:bg-surface"
110
- data-testid={`display-mode-option-${entry.mode}`}
111
- data-mode={entry.mode}
112
- data-active={isActive ? "true" : undefined}
113
- >
114
- <entry.icon
115
- className={[
116
- "mt-0.5 h-3.5 w-3.5 shrink-0",
117
- isActive ? "text-accent" : "text-tertiary",
118
- ].join(" ")}
119
- />
120
- <span className="flex flex-col">
121
- <span className={`font-medium ${isActive ? "text-accent" : "text-primary"}`}>
122
- {entry.label}
123
- </span>
124
- <span className="text-[10px] text-secondary">{entry.hint}</span>
107
+ <button
108
+ key={entry.mode}
109
+ type="button"
110
+ role="menuitemradio"
111
+ aria-checked={isActive}
112
+ onClick={() => {
113
+ props.onChange(entry.mode);
114
+ setOpen(false);
115
+ }}
116
+ className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-[11px] transition-colors hover:bg-surface focus-visible:outline-none focus-visible:bg-surface"
117
+ data-testid={`display-mode-option-${entry.mode}`}
118
+ data-mode={entry.mode}
119
+ data-active={isActive ? "true" : undefined}
120
+ >
121
+ <entry.icon
122
+ className={[
123
+ "mt-0.5 h-3.5 w-3.5 shrink-0",
124
+ isActive ? "text-accent" : "text-tertiary",
125
+ ].join(" ")}
126
+ />
127
+ <span className="flex flex-col">
128
+ <span className={`font-medium ${isActive ? "text-accent" : "text-primary"}`}>
129
+ {entry.label}
125
130
  </span>
126
- </button>
127
- </Popover.Close>
131
+ <span className="text-[10px] text-secondary">{entry.hint}</span>
132
+ </span>
133
+ </button>
128
134
  );
129
135
  })}
130
- </Popover.Content>
131
- </Popover.Portal>
132
- </Popover.Root>
136
+ </DisplayModePortalMenu>
137
+ </>
138
+ );
139
+ }
140
+
141
+ function DisplayModePortalMenu(props: {
142
+ anchorRef: React.RefObject<HTMLButtonElement | null>;
143
+ children: React.ReactNode;
144
+ open: boolean;
145
+ }): React.ReactPortal | null {
146
+ const style = useDisplayModePortalPosition(props.anchorRef, props.open);
147
+ const body = props.anchorRef.current?.ownerDocument?.body;
148
+ if (!props.open || !body) return null;
149
+ return createPortal(
150
+ <div
151
+ className="z-50 w-[260px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
152
+ data-testid="display-mode-selector-content"
153
+ style={style}
154
+ >
155
+ {props.children}
156
+ </div>,
157
+ body,
133
158
  );
134
159
  }
135
160
 
161
+ function useDisplayModePortalPosition(
162
+ anchorRef: React.RefObject<HTMLButtonElement | null>,
163
+ open: boolean,
164
+ ): CSSProperties {
165
+ const [style, setStyle] = useState<CSSProperties>({
166
+ left: 8,
167
+ position: "fixed",
168
+ top: 8,
169
+ zIndex: 50,
170
+ });
171
+
172
+ useLayoutEffect(() => {
173
+ if (!open) return;
174
+ const anchor = anchorRef.current;
175
+ const ownerWindow = anchor?.ownerDocument?.defaultView;
176
+ if (!anchor || !ownerWindow) return;
177
+ const update = () => {
178
+ const rect = anchor.getBoundingClientRect();
179
+ const width = 260;
180
+ const left = Math.min(
181
+ Math.max(8, rect.right - width),
182
+ Math.max(8, (ownerWindow.innerWidth || width + 16) - width - 8),
183
+ );
184
+ setStyle({
185
+ left,
186
+ position: "fixed",
187
+ top: Math.max(8, rect.bottom + 8),
188
+ zIndex: 50,
189
+ });
190
+ };
191
+ update();
192
+ ownerWindow.addEventListener("resize", update);
193
+ ownerWindow.addEventListener("scroll", update, true);
194
+ return () => {
195
+ ownerWindow.removeEventListener("resize", update);
196
+ ownerWindow.removeEventListener("scroll", update, true);
197
+ };
198
+ }, [anchorRef, open]);
199
+
200
+ return style;
201
+ }
202
+
136
203
  export default TwDisplayModeSelector;
@@ -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}