@beyondwork/docx-react-component 1.0.88 → 1.0.89

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/api/v3/_runtime-handle.ts +5 -0
  3. package/src/api/v3/ai/replacement.ts +82 -0
  4. package/src/api/v3/runtime/content.ts +3 -0
  5. package/src/api/v3/runtime/formatting.ts +64 -0
  6. package/src/core/commands/formatting-commands.ts +107 -0
  7. package/src/core/state/text-transaction.ts +11 -4
  8. package/src/runtime/document-runtime.ts +51 -0
  9. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  10. package/src/runtime/scopes/action-validation.ts +12 -3
  11. package/src/runtime/scopes/audit-bundle.ts +2 -2
  12. package/src/runtime/scopes/compiler-service.ts +70 -0
  13. package/src/runtime/scopes/formatting/apply.ts +262 -0
  14. package/src/runtime/scopes/index.ts +12 -0
  15. package/src/runtime/scopes/replacement/propose.ts +2 -0
  16. package/src/runtime/scopes/scope-kinds/paragraph.ts +1 -0
  17. package/src/runtime/scopes/semantic-scope-types.ts +48 -4
  18. package/src/runtime/scopes/workflow-overlap.ts +9 -11
  19. package/src/shell/session-bootstrap.ts +1 -0
  20. package/src/ui/WordReviewEditor.tsx +277 -28
  21. package/src/ui/editor-command-bag.ts +11 -0
  22. package/src/ui/editor-shell-view.tsx +10 -0
  23. package/src/ui/headless/chrome-registry.ts +6 -6
  24. package/src/ui/headless/role-action-sets.ts +4 -10
  25. package/src/ui/headless/selection-tool-resolver.ts +11 -0
  26. package/src/ui-tailwind/chrome/editor-action-registry.ts +1 -1
  27. package/src/ui-tailwind/chrome/tw-context-band.tsx +7 -7
  28. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +13 -18
  29. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +8 -5
  30. package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +100 -0
  31. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +0 -40
  32. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +9 -7
  33. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +17 -1
  34. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +6 -1
  35. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +17 -7
  36. package/src/ui-tailwind/editor-surface/preserve-position.ts +30 -5
  37. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
  38. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +5 -1
  39. package/src/ui-tailwind/review/tw-review-rail.tsx +5 -0
  40. package/src/ui-tailwind/review/tw-workflow-tab.tsx +32 -38
  41. package/src/ui-tailwind/review-workspace/types.ts +2 -0
  42. package/src/ui-tailwind/theme/editor-theme.css +25 -12
  43. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +13 -4
  44. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +6 -15
  45. package/src/ui-tailwind/tw-review-workspace.tsx +28 -18
  46. package/src/ui-tailwind/workflow-scope-layers.ts +70 -0
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared detach/attach primitive for chrome surfaces that can float vs
3
- * dock. Extracted from the hand-rolled implementation in
2
+ * Shared placement primitive for chrome surfaces that can be pinned near
3
+ * the document. Extracted from the hand-rolled implementation in
4
4
  * `tw-selection-tool-host.tsx` so the same UX works on the topnav, the
5
5
  * selection tier, and any future overlay layer that opts in.
6
6
  *
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import React, { useCallback, useEffect, useRef } from "react";
13
- import { GripHorizontal } from "lucide-react";
13
+ import { GripHorizontal, Pin, PinOff } from "lucide-react";
14
14
 
15
15
  import type {
16
16
  ChromePinSurface,
@@ -21,11 +21,11 @@ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-edi
21
21
  export interface TwDetachHandleProps {
22
22
  /** Which chrome surface this handle controls; stored in ViewState. */
23
23
  surface: ChromePinSurface;
24
- /** Current pin state; `undefined` means docked-default. */
24
+ /** Current pin state; `undefined` means the surface follows its anchor. */
25
25
  pin?: PinState;
26
26
  /** Callback fired with the next pin state (null = clear). */
27
27
  onChange: (surface: ChromePinSurface, pin: PinState | null) => void;
28
- /** Human label rendered next to the status chip. */
28
+ /** Human label used for accessible labels. No visible product copy is rendered. */
29
29
  label: string;
30
30
  /** Optional test id override. */
31
31
  "data-testid"?: string;
@@ -34,7 +34,7 @@ export interface TwDetachHandleProps {
34
34
  }
35
35
 
36
36
  /**
37
- * Compact grip + Float/Dock toggle row. Consumers mount this inline
37
+ * Compact grip + keep-visible toggle row. Consumers mount this inline
38
38
  * above the surface's content; when `pin.detached === true` the consumer
39
39
  * translates the surface by `pin.offset.x / y` itself (the handle does
40
40
  * not wrap the payload).
@@ -110,39 +110,34 @@ export function TwDetachHandle(props: TwDetachHandleProps): React.JSX.Element {
110
110
  return (
111
111
  <div
112
112
  className={[
113
- "inline-flex items-center gap-1.5 self-center rounded-lg border border-border/70 bg-canvas/94 px-1.5 py-1 shadow-[0_8px_20px_-18px_var(--color-shadow-strong)]",
113
+ "inline-flex items-center gap-0.5 self-center rounded-lg border border-border/70 bg-canvas/94 px-1 py-1 shadow-[0_8px_20px_-18px_var(--color-shadow-strong)]",
114
114
  props.className ?? "",
115
115
  ]
116
116
  .filter(Boolean)
117
117
  .join(" ")}
118
118
  data-testid={props["data-testid"] ?? `detach-handle-${surface}`}
119
119
  data-surface={surface}
120
+ aria-label={`${label} placement controls`}
120
121
  >
121
122
  <button
122
123
  type="button"
123
- aria-label={isDetached ? "Drag floating menu" : "Drag to float menu"}
124
+ aria-label={`Move ${label}`}
124
125
  data-testid={dragHandleTestId}
125
- className="inline-flex h-6 items-center justify-center rounded-md border border-transparent px-1.5 text-tertiary transition-colors hover:border-border/60 hover:bg-surface hover:text-primary"
126
+ className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-transparent text-tertiary transition-colors hover:border-border/60 hover:bg-surface hover:text-primary"
126
127
  onMouseDown={beginDrag}
127
128
  >
128
129
  <GripHorizontal className="h-3 w-3" />
129
130
  </button>
130
- <span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
131
- {label}
132
- </span>
133
- <span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.08em] text-secondary">
134
- {isDetached ? "Floating" : "Docked"}
135
- </span>
136
131
  <button
137
132
  type="button"
138
- aria-label={isDetached ? "Dock menu" : "Float menu"}
133
+ aria-label={isDetached ? `Return ${label} to selection` : `Keep ${label} visible`}
139
134
  aria-pressed={isDetached}
140
135
  data-testid={toggleTestId}
141
- className="inline-flex h-6 items-center rounded-md border border-border/60 px-2 text-[10px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary"
136
+ className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/60 text-secondary transition-colors hover:bg-surface hover:text-primary"
142
137
  onMouseDown={preserveEditorSelectionMouseDown}
143
138
  onClick={toggle}
144
139
  >
145
- {isDetached ? "Dock menu" : "Float menu"}
140
+ {isDetached ? <PinOff className="h-3 w-3" /> : <Pin className="h-3 w-3" />}
146
141
  </button>
147
142
  </div>
148
143
  );
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { MoreHorizontal } from "lucide-react";
2
3
 
3
4
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
4
5
  import type { ActiveImageContext } from "../../ui/headless/selection-tool-types";
@@ -17,7 +18,7 @@ export interface TwImageContextToolbarProps {
17
18
  ) => void;
18
19
  /**
19
20
  * Phase D.3 — progressive-disclosure compact mode. When `true`,
20
- * the toolbar reduces to the image badge + a single "More…" button
21
+ * the toolbar reduces to the image badge + a single actions button
21
22
  * that opens the shared context menu via `onOpenMore`. Size
22
23
  * presets + nudge controls move into the registry's image actions
23
24
  * (see `editor-action-registry.ts` — image-size-small/medium/large
@@ -37,6 +38,7 @@ const NUDGE_EMU = 228600;
37
38
 
38
39
  export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
39
40
  const { activeImage } = props;
41
+ const displayLabel = activeImage.display === "floating" ? "Positioned" : "Inline";
40
42
 
41
43
  if (props.compact) {
42
44
  return (
@@ -49,13 +51,14 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
49
51
  Image
50
52
  </span>
51
53
  <span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.1em] text-secondary">
52
- {activeImage.display}
54
+ {displayLabel}
53
55
  </span>
54
56
  <button
55
57
  type="button"
56
58
  data-testid="image-context-toolbar-more"
57
59
  aria-label="Image actions menu"
58
- className="inline-flex h-6 items-center gap-1 rounded-[var(--radius-sm)] px-2 text-xs font-medium text-secondary hover:bg-hover focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]"
60
+ title="Image actions"
61
+ className="inline-flex h-6 w-6 items-center justify-center rounded-[var(--radius-sm)] text-secondary hover:bg-hover focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]"
59
62
  disabled={props.disabled}
60
63
  onMouseDown={preserveEditorSelectionMouseDown}
61
64
  onClick={(ev) => {
@@ -63,7 +66,7 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
63
66
  props.onOpenMore?.({ clientX: rect.left, clientY: rect.bottom });
64
67
  }}
65
68
  >
66
- More…
69
+ <MoreHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
67
70
  </button>
68
71
  </div>
69
72
  );
@@ -78,7 +81,7 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
78
81
  Image
79
82
  </span>
80
83
  <span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.1em] text-secondary">
81
- {activeImage.display}
84
+ {displayLabel}
82
85
  </span>
83
86
  <div role="group" aria-label="Image size" className="inline-flex items-center rounded-[var(--radius-sm)] bg-[var(--color-bg-muted)] p-0.5">
84
87
  {IMAGE_SIZE_PRESETS.map((preset) => {
@@ -0,0 +1,100 @@
1
+ import * as React from "react";
2
+ import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
3
+
4
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
5
+
6
+ export interface TwInlineFindBarProps {
7
+ query: string;
8
+ activeIndex: number;
9
+ resultCount: number;
10
+ onQueryChange: (query: string) => void;
11
+ onPrevious: () => void;
12
+ onNext: () => void;
13
+ onClose: () => void;
14
+ }
15
+
16
+ export function TwInlineFindBar(props: TwInlineFindBarProps): React.JSX.Element {
17
+ const inputRef = React.useRef<HTMLInputElement | null>(null);
18
+ const hasResults = props.resultCount > 0;
19
+
20
+ React.useEffect(() => {
21
+ inputRef.current?.focus();
22
+ inputRef.current?.select();
23
+ }, []);
24
+
25
+ return (
26
+ <div
27
+ className="pointer-events-auto flex w-[min(420px,calc(100vw-2rem))] items-center gap-2 rounded-2xl border border-[color:color-mix(in_srgb,var(--color-accent-primary)_28%,var(--color-border-subtle))] bg-[color:color-mix(in_srgb,var(--color-bg-canvas)_94%,white)] px-2.5 py-2 shadow-[0_18px_50px_rgba(20,31,29,0.18)]"
28
+ data-testid="inline-find-bar"
29
+ role="search"
30
+ aria-label="Find in document"
31
+ >
32
+ <Search className="h-4 w-4 shrink-0 text-accent" aria-hidden="true" />
33
+ <input
34
+ ref={inputRef}
35
+ aria-label="Find text"
36
+ className="min-w-0 flex-1 bg-transparent px-1 text-[13px] font-medium text-primary outline-none placeholder:text-tertiary"
37
+ placeholder="Find in document"
38
+ type="search"
39
+ value={props.query}
40
+ onChange={(event) => props.onQueryChange(event.currentTarget.value)}
41
+ onKeyDown={(event) => {
42
+ if (event.key === "Escape") {
43
+ event.preventDefault();
44
+ props.onClose();
45
+ return;
46
+ }
47
+ if (event.key === "Enter") {
48
+ event.preventDefault();
49
+ if (event.shiftKey) {
50
+ props.onPrevious();
51
+ } else {
52
+ props.onNext();
53
+ }
54
+ }
55
+ }}
56
+ />
57
+ <span
58
+ className="min-w-[56px] rounded-full bg-[color:color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)] px-2 py-1 text-center text-[11px] font-semibold tabular-nums text-accent"
59
+ aria-live="polite"
60
+ >
61
+ {props.query.trim()
62
+ ? hasResults
63
+ ? `${props.activeIndex + 1}/${props.resultCount}`
64
+ : "0/0"
65
+ : "Find"}
66
+ </span>
67
+ <button
68
+ type="button"
69
+ aria-label="Previous match"
70
+ disabled={!hasResults}
71
+ className="inline-flex h-7 w-7 items-center justify-center rounded-full text-secondary transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-35"
72
+ onMouseDown={preserveEditorSelectionMouseDown}
73
+ onClick={props.onPrevious}
74
+ >
75
+ <ChevronUp className="h-4 w-4" aria-hidden="true" />
76
+ </button>
77
+ <button
78
+ type="button"
79
+ aria-label="Next match"
80
+ disabled={!hasResults}
81
+ className="inline-flex h-7 w-7 items-center justify-center rounded-full text-secondary transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-35"
82
+ onMouseDown={preserveEditorSelectionMouseDown}
83
+ onClick={props.onNext}
84
+ >
85
+ <ChevronDown className="h-4 w-4" aria-hidden="true" />
86
+ </button>
87
+ <button
88
+ type="button"
89
+ aria-label="Close find"
90
+ className="inline-flex h-7 w-7 items-center justify-center rounded-full text-tertiary transition-colors hover:bg-hover hover:text-primary"
91
+ onMouseDown={preserveEditorSelectionMouseDown}
92
+ onClick={props.onClose}
93
+ >
94
+ <X className="h-4 w-4" aria-hidden="true" />
95
+ </button>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ export default TwInlineFindBar;
@@ -49,7 +49,6 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
49
49
  const density = props.density ?? "full";
50
50
  const addCommentDisabled = !model.canAddComment;
51
51
  const formattingDisabled = !model.canToggleFormatting;
52
- const contextLabel = summarizeSelectionContext(model);
53
52
  const tooltipLabel = addCommentDisabled
54
53
  ? props.disabledReason ?? "Select text within one paragraph to add a DOCX comment"
55
54
  : "Add comment";
@@ -144,49 +143,10 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
144
143
  </Tooltip.Portal>
145
144
  </Tooltip.Root>
146
145
 
147
- {model.previewText ? (
148
- <>
149
- <div className="mx-0.5 h-4 w-px bg-[var(--color-border-subtle)]" />
150
- <span className="max-w-[7rem] truncate text-[10px] text-secondary">
151
- {model.previewText}
152
- </span>
153
- </>
154
- ) : null}
155
-
156
- {contextLabel ? (
157
- <>
158
- {!model.previewText ? <div className="mx-0.5 h-4 w-px bg-[var(--color-border-subtle)]" /> : null}
159
- <span
160
- className={`min-w-0 max-w-[9rem] truncate rounded-full px-1.5 py-0.5 text-[9px] font-medium tracking-[0.08em] ${
161
- model.badges.some((badge) => badge.tone === "accent")
162
- ? "bg-canvas text-accent ring-1 ring-accent/25"
163
- : "bg-surface text-tertiary"
164
- }`}
165
- >
166
- {contextLabel}
167
- </span>
168
- </>
169
- ) : null}
170
146
  </div>
171
147
  );
172
148
  });
173
149
 
174
- function summarizeSelectionContext(model: SelectionToolbarModel): string | null {
175
- if (model.badges.length === 0) {
176
- return null;
177
- }
178
-
179
- const accentBadges = model.badges.filter((badge) => badge.tone === "accent");
180
- const source = accentBadges.length > 0 ? accentBadges : model.badges;
181
- const labels = source.slice(0, 2).map((badge) => badge.label.trim()).filter(Boolean);
182
- if (labels.length === 0) {
183
- return null;
184
- }
185
-
186
- const summary = labels.join(" · ");
187
- return summary.length > 30 ? `${summary.slice(0, 27)}...` : summary;
188
- }
189
-
190
150
  interface ToolbarActionButtonProps {
191
151
  icon: React.ReactNode;
192
152
  label: string;
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { MoreHorizontal } from "lucide-react";
2
3
 
3
4
  import type {
4
5
  TableOperationCapabilitySnapshot,
@@ -34,7 +35,7 @@ export interface TwTableContextToolbarProps {
34
35
  * When `true`, the toolbar stays action-first:
35
36
  * - a small context label
36
37
  * - the tier's highest-frequency table actions
37
- * - a single "More…" button that opens the shared command graph
38
+ * - a single icon button that opens the shared command graph
38
39
  *
39
40
  * Diagnostic metadata such as "3 x 4" or "R1 C1" is intentionally
40
41
  * omitted from the compact surface. It belongs in properties /
@@ -46,7 +47,7 @@ export interface TwTableContextToolbarProps {
46
47
  */
47
48
  compact?: boolean;
48
49
  /**
49
- * Fires when the user clicks the "More…" button in compact mode.
50
+ * Fires when the user clicks the compact actions button.
50
51
  * Receives the button's clientX / clientY so the integrator can
51
52
  * open `TwContextMenu` at the button anchor via
52
53
  * `chromeControllerRef.current?.openWithKinds({ kinds: ["table-cell"], clientX, clientY })`.
@@ -84,7 +85,7 @@ const CELL_FILL_PRESETS = [
84
85
  *
85
86
  * - `caret-in-cell` (T2) — single-cell selection. Minimal inline set:
86
87
  * row +/−, column +/−. Anything structural (merge/split/fill/delete
87
- * table) lives behind "More" to keep the panel ~180px wide.
88
+ * table) lives behind the actions button to keep the panel ~180px wide.
88
89
  * - `multi-cell` (T3) — >1 cell but not a full row/column/table. Adds
89
90
  * merge/split/fill palette.
90
91
  * - `row-selected` (T4a) — selection spans exactly one full row
@@ -141,9 +142,9 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
141
142
  : null;
142
143
  const selectionLabel = tableContext ? formatSelectionLabel(tableContext, tier) : null;
143
144
 
144
- // Product compact variant: action-first local chrome + one "More…"
145
+ // Product compact variant: action-first local chrome + one actions
145
146
  // button that opens the shared context menu. The full action set
146
- // still lives in editor-action-registry so right-click and "More…"
147
+ // still lives in editor-action-registry so right-click and local actions
147
148
  // stay identical; this surface only promotes the tier's obvious next
148
149
  // actions and leaves metadata to deeper surfaces.
149
150
  if (props.compact) {
@@ -170,7 +171,8 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
170
171
  type="button"
171
172
  data-testid="table-context-toolbar-more"
172
173
  aria-label="Table actions menu"
173
- className="inline-flex h-6 items-center gap-1 rounded-[var(--radius-sm)] px-2 text-xs font-medium text-secondary hover:bg-hover focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]"
174
+ title="Table actions"
175
+ className="inline-flex h-6 w-6 items-center justify-center rounded-[var(--radius-sm)] text-secondary hover:bg-hover focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]"
174
176
  disabled={props.disabled || !props.onOpenMore}
175
177
  onMouseDown={preserveEditorSelectionMouseDown}
176
178
  onClick={(ev) => {
@@ -181,7 +183,7 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
181
183
  });
182
184
  }}
183
185
  >
184
- More…
186
+ <MoreHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
185
187
  </button>
186
188
  </div>
187
189
  );
@@ -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,
@@ -49,6 +49,8 @@ export interface TwChromeOverlayProps {
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,17 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
242
245
  mediaPreviews,
243
246
  activeBandRibbonProps,
244
247
  }) => {
248
+ const visibleScopeIds = React.useMemo(() => {
249
+ if (!visibleScopePostures) return undefined;
250
+ const ids = new Set<string>();
251
+ for (const segment of workflowFacet?.getAllRailSegments() ?? []) {
252
+ if (visibleScopePostures.has(segment.posture)) {
253
+ ids.add(segment.scopeId);
254
+ }
255
+ }
256
+ return ids;
257
+ }, [visibleScopePostures, workflowFacet]);
258
+
245
259
  return (
246
260
  <div
247
261
  className="wre-chrome-overlay pointer-events-none absolute inset-0 z-30"
@@ -268,6 +282,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
268
282
  workflowFacet={workflowFacet}
269
283
  space={space}
270
284
  activeScopeId={activeScopeId}
285
+ visibleScopePostures={visibleScopePostures}
271
286
  onStripeClick={onScopeStripeClick}
272
287
  onSegmentClick={onScopeSegmentClick}
273
288
  />
@@ -275,6 +290,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
275
290
  facet={facet}
276
291
  workflowFacet={workflowFacet}
277
292
  activeScopeId={activeScopeId ?? null}
293
+ visibleScopeIds={visibleScopeIds}
278
294
  onClose={onScopeCardClose ?? noop}
279
295
  onModeChange={onScopeCardModeChange ?? noopModeChange}
280
296
  onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
@@ -52,6 +52,8 @@ export interface TwScopeCardLayerProps {
52
52
  */
53
53
  workflowFacet: WorkflowFacet | null;
54
54
  activeScopeId: string | null;
55
+ /** Scope ids currently visible under the Workflow rail layer filters. */
56
+ visibleScopeIds?: ReadonlySet<string>;
55
57
  onClose: () => void;
56
58
  onModeChange: (scopeId: string, mode: WorkflowScopeMode) => void;
57
59
  onIssueAction: (
@@ -91,6 +93,7 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
91
93
  facet,
92
94
  workflowFacet,
93
95
  activeScopeId,
96
+ visibleScopeIds,
94
97
  onClose,
95
98
  onModeChange,
96
99
  onIssueAction,
@@ -119,7 +122,9 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
119
122
  // The effective scope is the pinned one if it still resolves to a
120
123
  // model, else the active one. When a pinned scope disappears
121
124
  // (e.g. the host cleared the overlay), drop the pin.
122
- const models = workflowFacet?.getAllScopeCardModels() ?? [];
125
+ const models = (workflowFacet?.getAllScopeCardModels() ?? []).filter((model) =>
126
+ visibleScopeIds ? visibleScopeIds.has(model.scopeId) : true,
127
+ );
123
128
 
124
129
  const pinnedModel = pinnedScopeId
125
130
  ? models.find((m) => m.scopeId === pinnedScopeId) ?? null
@@ -46,6 +46,8 @@ export interface TwScopeRailLayerProps {
46
46
  railLaneWidthPx?: number;
47
47
  /** Scope id that should render with the `active` emphasis. */
48
48
  activeScopeId?: string | null;
49
+ /** Posture filters shared with the Workflow rail. Omitted means all scopes render. */
50
+ visibleScopePostures?: ReadonlySet<ScopeRailPosture>;
49
51
  /**
50
52
  * Fires when the user clicks the rail stripe — opens the scope card.
51
53
  * P0 wires this directly; P1 replaces with card-layer-aware routing.
@@ -87,8 +89,8 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
87
89
  // ---------------------------------------------------------------------------
88
90
 
89
91
  const DEFAULT_RAIL_LANE_PX = 44;
90
- const STRIPE_WIDTH_PX = 4;
91
- const LABEL_WIDTH_PX = 40;
92
+ const STRIPE_WIDTH_PX = 6;
93
+ const LABEL_WIDTH_PX = 58;
92
94
  const STACK_OFFSET_PX = 6;
93
95
 
94
96
  export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
@@ -97,12 +99,15 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
97
99
  space,
98
100
  railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
99
101
  activeScopeId,
102
+ visibleScopePostures,
100
103
  onStripeClick,
101
104
  onSegmentClick,
102
105
  "data-testid": testId,
103
106
  }) => {
104
107
  const frame = geometryFacet.getRenderFrame() ?? null;
105
- const segments = workflowFacet?.getAllRailSegments() ?? [];
108
+ const segments = (workflowFacet?.getAllRailSegments() ?? []).filter((segment) =>
109
+ visibleScopePostures ? visibleScopePostures.has(segment.posture) : true,
110
+ );
106
111
 
107
112
  if (!frame || segments.length === 0) {
108
113
  return null;
@@ -223,16 +228,21 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
223
228
  style={projectRectToOverlay(stripeRect, projectorSpace)}
224
229
  />
225
230
  {/* Label pill — revealed on stripe hover via CSS. */}
226
- <div
227
- className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken}`}
231
+ <button
232
+ type="button"
233
+ tabIndex={-1}
234
+ className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} ${
235
+ isActive ? "wre-scope-rail-label-active" : ""
236
+ }`}
228
237
  data-scope-id={segment.scopeId}
229
238
  data-posture={segment.posture}
230
- aria-hidden="true"
239
+ aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
240
+ onClick={handleActivate}
231
241
  style={projectRectToOverlay(labelRect, projectorSpace)}
232
242
  >
233
243
  <span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
234
244
  <span className="wre-scope-rail-label-text">{style.labelText}</span>
235
- </div>
245
+ </button>
236
246
  </React.Fragment>
237
247
  );
238
248
  })}
@@ -190,8 +190,8 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
190
190
  * When `true`, wrap the state swap in `capturePosition` /
191
191
  * `restorePosition` so the scroll anchor block stays at the same
192
192
  * viewport-Y across the replacement. Shipped **disabled by default**
193
- * after the 2026-04-24 jump-to-top regression re-enable under a
194
- * diagnosed-safe codepath only.
193
+ * after the 2026-04-24 jump-to-top regression; typing paths re-enable
194
+ * it only through a bounded same-story policy.
195
195
  */
196
196
  preserveScrollAnchor?: boolean;
197
197
  /**
@@ -229,8 +229,9 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
229
229
  * after the 2026-04-24 jump-to-top regression report — enabling it
230
230
  * requires evidence that the anchor math holds under the
231
231
  * rebuild-effect's exact timing (PM DOM mid-mutation, observer-driven
232
- * scrollTop resets, etc.). The capture/restore helpers are still
233
- * exported + unit-tested for the eventual re-enable.
232
+ * scrollTop resets, etc.). When the bounded anchor restore refuses its
233
+ * target, the helper restores the captured scrollTop instead of leaving
234
+ * a PM/browser-origin top jump in place.
234
235
  */
235
236
  export function replaceStatePreservingPosition(
236
237
  options: ReplaceStateOptions,
@@ -242,7 +243,10 @@ export function replaceStatePreservingPosition(
242
243
  options.suppressionRef.current = true;
243
244
  options.view.updateState(newState);
244
245
  if (preserved) {
245
- restorePosition(preserved, options);
246
+ const restored = restorePosition(preserved, options);
247
+ if (!restored) {
248
+ restoreCapturedScrollTop(preserved);
249
+ }
246
250
  }
247
251
  const release = () => {
248
252
  options.suppressionRef.current = false;
@@ -253,3 +257,24 @@ export function replaceStatePreservingPosition(
253
257
  queueMicrotask(release);
254
258
  }
255
259
  }
260
+
261
+ /**
262
+ * Last-resort guard for the typing rebuild path. If PM clears scrollTop
263
+ * during `updateState()` and the precise anchor restore refuses to write
264
+ * (unsafe target, missing block), keep the user near the same viewport
265
+ * instead of accepting a top jump. Requires a real captured anchor so
266
+ * empty/pre-mount roots remain no-op.
267
+ */
268
+ function restoreCapturedScrollTop(captured: PreservedPosition): boolean {
269
+ if (!captured.scrollRoot || !captured.anchor) return false;
270
+ if (!Number.isFinite(captured.scrollTop) || captured.scrollTop < 0) {
271
+ return false;
272
+ }
273
+ const maxScrollTop = captured.scrollRoot.scrollHeight - captured.scrollRoot.clientHeight;
274
+ const target =
275
+ Number.isFinite(maxScrollTop) && maxScrollTop > 0
276
+ ? Math.min(captured.scrollTop, maxScrollTop)
277
+ : captured.scrollTop;
278
+ captured.scrollRoot.scrollTop = target;
279
+ return true;
280
+ }
@@ -958,7 +958,8 @@ export const TwProseMirrorSurface = forwardRef<
958
958
  // replacement stays in the same focused story with a live geometry
959
959
  // facet. `maxScrollDeltaPx` is the guardrail: if the anchor target
960
960
  // would move by more than the small local-edit budget, the helper
961
- // refuses the restore and leaves the browser/runtime position alone.
961
+ // refuses that exact target but restores the captured scrollTop so
962
+ // a PM/browser-origin top jump is not accepted as the final state.
962
963
  //
963
964
  // Ordering invariant is regression-guarded by
964
965
  // `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
@@ -30,6 +30,7 @@ interface OpenVerticalMerge {
30
30
  col: number;
31
31
  colSpan: number;
32
32
  continuedThisRow: boolean;
33
+ hasMaterializedRowSpan: boolean;
33
34
  layout: TableCellLayout;
34
35
  }
35
36
 
@@ -159,7 +160,9 @@ function computeTableLayout(tableNode: PMNode): TableCellLayout[][] {
159
160
  if (verticalMerge === "continue") {
160
161
  const owner = findVerticalMergeOwner(openVerticalMerges, column, colSpan);
161
162
  if (owner) {
162
- owner.layout.rowSpan += 1;
163
+ if (!owner.hasMaterializedRowSpan) {
164
+ owner.layout.rowSpan += 1;
165
+ }
163
166
  owner.continuedThisRow = true;
164
167
  layoutRow.push({
165
168
  cellIndex,
@@ -189,6 +192,7 @@ function computeTableLayout(tableNode: PMNode): TableCellLayout[][] {
189
192
  col: column,
190
193
  colSpan,
191
194
  continuedThisRow: true,
195
+ hasMaterializedRowSpan: explicitRowSpan > 1,
192
196
  layout,
193
197
  });
194
198
  }
@@ -23,6 +23,7 @@ import {
23
23
  TwReviewRailFooter,
24
24
  type TwReviewRailFooterProps,
25
25
  } from "./tw-review-rail-footer";
26
+ import type { WorkflowScopeLayerKey } from "../workflow-scope-layers";
26
27
 
27
28
  /**
28
29
  * Review rail with up to four tabs (Workflow / Comments / Changes / Health).
@@ -66,6 +67,8 @@ export interface TwReviewRailProps {
66
67
  */
67
68
  scopeRailSegments?: readonly ScopeRailSegment[];
68
69
  activeScopeId?: string | null;
70
+ workflowLayerFilters?: ReadonlySet<WorkflowScopeLayerKey>;
71
+ onWorkflowLayerFiltersChange?: (filters: ReadonlySet<WorkflowScopeLayerKey>) => void;
69
72
  /**
70
73
  * Optional host-provided Workflow-tab override. When supplied this
71
74
  * ReactNode replaces the default TwWorkflowTab content while still using
@@ -262,6 +265,8 @@ export function TwReviewRail(props: TwReviewRailProps) {
262
265
  <TwWorkflowTab
263
266
  segments={workflowSegments}
264
267
  activeScopeId={props.activeScopeId ?? null}
268
+ enabledLayerFilters={props.workflowLayerFilters}
269
+ onEnabledLayerFiltersChange={props.onWorkflowLayerFiltersChange}
265
270
  onOpenScope={props.onOpenScope}
266
271
  />
267
272
  )}