@beyondwork/docx-react-component 1.0.85 → 1.0.87

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +338 -13
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +112 -33
  29. package/src/ui/editor-command-bag.ts +4 -0
  30. package/src/ui/editor-shell-view.tsx +1 -0
  31. package/src/ui/editor-surface-controller.tsx +1 -0
  32. package/src/ui/headless/revision-decoration-model.ts +11 -13
  33. package/src/ui-tailwind/chrome/editor-action-registry.ts +7 -26
  34. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  35. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  36. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +6 -2
  37. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  38. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  39. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  40. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  41. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  42. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  43. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +41 -44
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  45. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +8 -3
  46. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -0
  47. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +75 -31
  48. package/src/ui-tailwind/review/tw-workflow-tab.tsx +159 -8
  49. package/src/ui-tailwind/review-workspace/types.ts +4 -0
  50. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  51. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +8 -10
  52. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -17
  53. package/src/ui-tailwind/tw-review-workspace.tsx +27 -2
@@ -1,15 +1,17 @@
1
1
  import React from "react";
2
- import { Check, X } from "lucide-react";
2
+ import { Check, MessageSquare, X } from "lucide-react";
3
3
 
4
4
  import type { TrackedChangesSnapshot, TrackedChangeEntrySnapshot } from "../../api/public-types";
5
5
  import { selectVisibleRevisions } from "../../ui/shared/revision-filters";
6
6
  import type { MarkupDisplay } from "../../ui/headless/comment-decoration-model";
7
+ import { getAuthorColor } from "../../ui/headless/revision-decoration-model";
7
8
 
8
9
  export interface TwRevisionSidebarProps {
9
10
  trackedChanges: TrackedChangesSnapshot;
10
11
  markupDisplay: MarkupDisplay;
11
12
  activeRevisionId?: string;
12
13
  onOpenRevision?: (revision: TrackedChangeEntrySnapshot) => void;
14
+ onReplyToRevision?: (revision: TrackedChangeEntrySnapshot) => void;
13
15
  onAcceptRevision?: (revisionId: string) => void;
14
16
  onRejectRevision?: (revisionId: string) => void;
15
17
  onAcceptAllChanges?: () => void;
@@ -36,6 +38,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
36
38
 
37
39
  const [typeFilter, setTypeFilter] = React.useState<TypeFilter>("all");
38
40
  const [authorFilter, setAuthorFilter] = React.useState<string | null>(null);
41
+ const activeCardRef = React.useRef<HTMLDivElement | null>(null);
39
42
 
40
43
  // Derive distinct authors from all visible revisions
41
44
  const authors = React.useMemo(() => {
@@ -78,6 +81,11 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
78
81
  }
79
82
  }, [filteredRevisions, typeFilter, authorFilter, props.onRejectAllChanges, props.onRejectRevision]);
80
83
 
84
+ React.useEffect(() => {
85
+ if (!activeRevisionId) return;
86
+ activeCardRef.current?.scrollIntoView({ block: "nearest" });
87
+ }, [activeRevisionId, filteredRevisions]);
88
+
81
89
  return (
82
90
  <div className="flex flex-col outline-none">
83
91
  {/* Stats header */}
@@ -118,48 +126,85 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
118
126
  <div className="space-y-2">
119
127
  {filteredRevisions.map((rev) => {
120
128
  const isActive = activeRevisionId === rev.revisionId;
129
+ const authorColor = getAuthorColor(rev.authorId);
121
130
 
122
131
  return (
123
- <button
132
+ <div
124
133
  key={rev.revisionId}
125
- type="button"
126
- className={`w-full text-left flex cursor-pointer rounded-md bg-surface/90 transition-colors ring-1 ring-border ${focusRingClass} ${isActive ? "bg-accent-soft/40 ring-accent/25" : "hover:bg-surface"}`}
127
- onClick={() => props.onOpenRevision?.(rev)}
134
+ ref={(node) => {
135
+ if (isActive) {
136
+ activeCardRef.current = node;
137
+ }
138
+ }}
139
+ className={`w-full text-left flex rounded-md bg-surface/90 transition-colors ring-1 ring-border ${isActive ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]" : "hover:bg-surface"}`}
140
+ style={
141
+ authorColor && isActive
142
+ ? { boxShadow: `0 0 0 1px ${authorColor}, var(--shadow-soft)` }
143
+ : undefined
144
+ }
128
145
  >
129
146
  <div className={`w-0.5 shrink-0 rounded-l-md ${
130
147
  rev.kind === "insertion" ? "bg-insert"
131
148
  : rev.kind === "deletion" ? "bg-danger"
132
149
  : "bg-tertiary"
133
- }`} />
134
- <div className="p-2 flex-1 min-w-0">
135
- <div className="mb-0.5 flex items-start justify-between gap-2">
136
- <span className="text-[11px] font-medium text-primary">{rev.anchorLabel}</span>
137
- <RevisionBadge status={rev.status} actionability={rev.actionability} />
138
- </div>
139
- <p className="mb-1 text-[10px] text-tertiary">{rev.authorId} · {rev.createdAt}</p>
140
- {rev.excerpt ? (
141
- <p className={`text-[11px] ${
142
- rev.kind === "insertion" ? "text-insert"
143
- : rev.kind === "deletion" ? "text-danger line-through"
144
- : "text-secondary"
145
- }`}>
146
- {rev.excerpt}
150
+ }`} style={authorColor ? { backgroundColor: authorColor } : undefined} />
151
+ <div className="flex-1 min-w-0">
152
+ <button
153
+ type="button"
154
+ className={`w-full p-2 text-left ${focusRingClass}`}
155
+ onClick={() => props.onOpenRevision?.(rev)}
156
+ >
157
+ <div className="mb-0.5 flex items-start justify-between gap-2">
158
+ <span className="text-[11px] font-medium text-primary">{rev.anchorLabel}</span>
159
+ <RevisionBadge status={rev.status} actionability={rev.actionability} />
160
+ </div>
161
+ <p className="mb-1 flex items-center gap-1.5 text-[10px] text-tertiary">
162
+ {authorColor ? (
163
+ <span
164
+ aria-hidden="true"
165
+ className="h-2 w-2 rounded-full"
166
+ style={{ backgroundColor: authorColor }}
167
+ />
168
+ ) : null}
169
+ <span className="truncate">{rev.authorId}</span>
170
+ <span aria-hidden="true">·</span>
171
+ <span>{rev.createdAt}</span>
147
172
  </p>
148
- ) : (
149
- <p className="text-[11px] text-secondary">{rev.label}</p>
150
- )}
151
- {rev.detail ? (
152
- <p className="mt-1 text-[10px] text-secondary">{rev.detail}</p>
153
- ) : null}
154
- <div className="mt-2 flex gap-1.5">
173
+ {rev.excerpt ? (
174
+ <p className={`text-[11px] ${
175
+ rev.kind === "insertion" ? "text-insert"
176
+ : rev.kind === "deletion" ? "text-danger line-through"
177
+ : "text-secondary"
178
+ }`}>
179
+ {rev.excerpt}
180
+ </p>
181
+ ) : (
182
+ <p className="text-[11px] text-secondary">{rev.label}</p>
183
+ )}
184
+ {rev.detail ? (
185
+ <p className="mt-1 text-[10px] text-secondary">{rev.detail}</p>
186
+ ) : null}
187
+ </button>
188
+ <div className="flex flex-wrap gap-1.5 px-2 pb-2">
189
+ {props.onReplyToRevision ? (
190
+ <button
191
+ type="button"
192
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-secondary hover:bg-canvas transition-colors"
193
+ onClick={() => props.onReplyToRevision?.(rev)}
194
+ >
195
+ <MessageSquare className="h-3 w-3" />
196
+ {rev.replyCount && rev.replyCount > 0
197
+ ? `Reply ${rev.replyCount}`
198
+ : "Reply"}
199
+ </button>
200
+ ) : null}
155
201
  {rev.actionability === "actionable" ? (
156
202
  <>
157
203
  <button
158
204
  type="button"
159
205
  disabled={!rev.canAccept || rev.status === "accepted"}
160
206
  className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-accent hover:bg-accent-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
161
- onClick={(e) => {
162
- e.stopPropagation();
207
+ onClick={() => {
163
208
  props.onAcceptRevision?.(rev.revisionId);
164
209
  }}
165
210
  >
@@ -169,8 +214,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
169
214
  type="button"
170
215
  disabled={!rev.canReject || rev.status === "rejected"}
171
216
  className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-danger hover:bg-danger-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
172
- onClick={(e) => {
173
- e.stopPropagation();
217
+ onClick={() => {
174
218
  props.onRejectRevision?.(rev.revisionId);
175
219
  }}
176
220
  >
@@ -182,7 +226,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
182
226
  )}
183
227
  </div>
184
228
  </div>
185
- </button>
229
+ </div>
186
230
  );
187
231
  })}
188
232
  </div>
@@ -36,6 +36,24 @@ const POSTURE_META: Record<
36
36
  "blocked-import": { chip: "BLOCKED REGION", kind: "danger" },
37
37
  };
38
38
 
39
+ const focusRingClass =
40
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
41
+
42
+ type ScopeFilterKey = "edit" | "suggest" | "comment" | "view" | "candidate" | "blocked";
43
+
44
+ const SCOPE_FILTERS: ReadonlyArray<{
45
+ key: ScopeFilterKey;
46
+ label: string;
47
+ postures: readonly ScopeRailPosture[];
48
+ }> = [
49
+ { key: "edit", label: "Edit", postures: ["edit"] },
50
+ { key: "suggest", label: "Suggest", postures: ["suggest"] },
51
+ { key: "comment", label: "Comment", postures: ["comment"] },
52
+ { key: "view", label: "Review", postures: ["view"] },
53
+ { key: "candidate", label: "Scheduled", postures: ["candidate"] },
54
+ { key: "blocked", label: "Blocked", postures: ["preserve-only", "blocked-import"] },
55
+ ];
56
+
39
57
  export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
40
58
  segments,
41
59
  activeScopeId,
@@ -43,13 +61,38 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
43
61
  onActiveScopeChange,
44
62
  }) => {
45
63
  // Dedupe by scopeId so a scope spanning multiple pages shows once.
46
- const byScopeId = new Map<string, ScopeRailSegment>();
47
- for (const segment of segments) {
48
- if (!byScopeId.has(segment.scopeId)) {
49
- byScopeId.set(segment.scopeId, segment);
64
+ const uniqueSegments = React.useMemo(() => {
65
+ const byScopeId = new Map<string, ScopeRailSegment>();
66
+ for (const segment of segments) {
67
+ if (!byScopeId.has(segment.scopeId)) {
68
+ byScopeId.set(segment.scopeId, segment);
69
+ }
50
70
  }
51
- }
52
- const uniqueSegments = Array.from(byScopeId.values());
71
+ return Array.from(byScopeId.values()).sort(compareWorkflowSegments(activeScopeId ?? null));
72
+ }, [activeScopeId, segments]);
73
+ const [query, setQuery] = React.useState("");
74
+ const [enabledFilters, setEnabledFilters] = React.useState<ReadonlySet<ScopeFilterKey>>(
75
+ () => new Set(SCOPE_FILTERS.map((filter) => filter.key)),
76
+ );
77
+ const availableFilters = React.useMemo(() => {
78
+ const presentPostures = new Set(uniqueSegments.map((segment) => segment.posture));
79
+ return SCOPE_FILTERS.filter((filter) =>
80
+ filter.postures.some((posture) => presentPostures.has(posture)),
81
+ );
82
+ }, [uniqueSegments]);
83
+ const visibleSegments = React.useMemo(() => {
84
+ const normalizedQuery = normalizeScopeQuery(query);
85
+ return uniqueSegments.filter((segment) => {
86
+ const filterKey = filterKeyForPosture(segment.posture);
87
+ if (!enabledFilters.has(filterKey)) {
88
+ return false;
89
+ }
90
+ if (!normalizedQuery) {
91
+ return true;
92
+ }
93
+ return scopeSearchText(segment).includes(normalizedQuery);
94
+ });
95
+ }, [enabledFilters, query, uniqueSegments]);
53
96
 
54
97
  if (uniqueSegments.length === 0) {
55
98
  return (
@@ -73,9 +116,79 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
73
116
  <div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-accent">
74
117
  Document Intelligence
75
118
  </div>
76
- <div className="text-[15px] font-semibold text-primary">Workflow Scopes</div>
119
+ <div className="flex items-baseline justify-between gap-3">
120
+ <div className="text-[15px] font-semibold text-primary">Workflow Scopes</div>
121
+ <div
122
+ className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary"
123
+ data-testid="workflow-scope-count"
124
+ >
125
+ {visibleSegments.length}/{uniqueSegments.length} shown
126
+ </div>
127
+ </div>
77
128
  </div>
78
- {uniqueSegments.map((segment) => {
129
+
130
+ <div
131
+ className="wre-workflow-tab-controls rounded-lg border border-border bg-surface/55 p-2"
132
+ data-testid="workflow-scope-controls"
133
+ >
134
+ <input
135
+ aria-label="Search workflow scopes"
136
+ className={`h-8 w-full rounded-md border border-border bg-canvas px-2 text-[12px] text-primary placeholder:text-tertiary ${focusRingClass}`}
137
+ placeholder="Search scope, page, section..."
138
+ type="search"
139
+ value={query}
140
+ onChange={(event) => setQuery(event.currentTarget.value)}
141
+ />
142
+ {availableFilters.length > 1 ? (
143
+ <div
144
+ aria-label="Workflow scope layers"
145
+ className="mt-2 flex flex-wrap gap-1"
146
+ role="group"
147
+ >
148
+ {availableFilters.map((filter) => {
149
+ const isEnabled = enabledFilters.has(filter.key);
150
+ return (
151
+ <button
152
+ key={filter.key}
153
+ type="button"
154
+ aria-pressed={isEnabled}
155
+ className={[
156
+ "rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] transition-colors",
157
+ isEnabled
158
+ ? "border-accent/50 bg-accent/10 text-accent"
159
+ : "border-border bg-canvas text-tertiary hover:text-secondary",
160
+ ].join(" ")}
161
+ data-testid={`workflow-scope-filter-${filter.key}`}
162
+ onClick={() => {
163
+ setEnabledFilters((current) => {
164
+ const next = new Set(current);
165
+ if (next.has(filter.key)) {
166
+ next.delete(filter.key);
167
+ } else {
168
+ next.add(filter.key);
169
+ }
170
+ return next;
171
+ });
172
+ }}
173
+ >
174
+ {filter.label}
175
+ </button>
176
+ );
177
+ })}
178
+ </div>
179
+ ) : null}
180
+ </div>
181
+
182
+ {visibleSegments.length === 0 ? (
183
+ <div
184
+ className="rounded-md border border-dashed border-border bg-canvas/50 p-3 text-[11px] text-tertiary"
185
+ data-testid="workflow-scope-filter-empty"
186
+ >
187
+ No workflow scopes match the current search or layer filters.
188
+ </div>
189
+ ) : null}
190
+
191
+ {visibleSegments.map((segment) => {
79
192
  const meta = POSTURE_META[segment.posture];
80
193
  const isActive = activeScopeId === segment.scopeId || segment.isActiveWorkItem;
81
194
  return (
@@ -115,4 +228,42 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
115
228
  );
116
229
  };
117
230
 
231
+ function compareWorkflowSegments(activeScopeId: string | null) {
232
+ return (left: ScopeRailSegment, right: ScopeRailSegment): number => {
233
+ const leftActive = left.scopeId === activeScopeId || left.isActiveWorkItem;
234
+ const rightActive = right.scopeId === activeScopeId || right.isActiveWorkItem;
235
+ if (leftActive !== rightActive) {
236
+ return leftActive ? -1 : 1;
237
+ }
238
+ if (left.pageIndex !== right.pageIndex) {
239
+ return left.pageIndex - right.pageIndex;
240
+ }
241
+ if (left.sectionIndex !== right.sectionIndex) {
242
+ return left.sectionIndex - right.sectionIndex;
243
+ }
244
+ return (left.label || left.scopeId).localeCompare(right.label || right.scopeId);
245
+ };
246
+ }
247
+
248
+ function filterKeyForPosture(posture: ScopeRailPosture): ScopeFilterKey {
249
+ if (posture === "preserve-only" || posture === "blocked-import") {
250
+ return "blocked";
251
+ }
252
+ return posture;
253
+ }
254
+
255
+ function normalizeScopeQuery(value: string): string {
256
+ return value.trim().toLocaleLowerCase();
257
+ }
258
+
259
+ function scopeSearchText(segment: ScopeRailSegment): string {
260
+ return normalizeScopeQuery([
261
+ segment.label,
262
+ segment.scopeId,
263
+ segment.posture,
264
+ `page ${segment.pageIndex + 1}`,
265
+ `section ${segment.sectionIndex + 1}`,
266
+ ].filter(Boolean).join(" "));
267
+ }
268
+
118
269
  export default TwWorkflowTab;
@@ -145,6 +145,9 @@ export interface TwReviewWorkspaceProps {
145
145
  activeRailTab: ReviewRailTab;
146
146
  activeCommentId?: string;
147
147
  activeRevisionId?: string;
148
+ /** Authoring mode toggle state: whether new edits are recorded as tracked changes. */
149
+ trackedChangesAuthoringEnabled?: boolean;
150
+ /** Visual markup state: whether tracked-change decorations are currently shown. */
148
151
  showTrackedChanges: boolean;
149
152
  workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
150
153
  interactionGuardSnapshot?: InteractionGuardSnapshot;
@@ -286,6 +289,7 @@ export interface TwReviewWorkspaceProps {
286
289
  onAddReply?: (commentId: string, body: string) => void;
287
290
  onEditBody?: (commentId: string, body: string) => void;
288
291
  onOpenRevision?: (revision: TrackedChangeEntrySnapshot) => void;
292
+ onReplyToRevision?: (revision: TrackedChangeEntrySnapshot) => void;
289
293
  onAcceptRevision?: (revisionId: string) => void;
290
294
  onRejectRevision?: (revisionId: string) => void;
291
295
  onAcceptAllChanges?: () => void;
@@ -21,7 +21,7 @@ export interface ReviewRailState {
21
21
  * Review-rail open/close state + the responsive transition effect.
22
22
  *
23
23
  * When the responsive signature flips (narrow↔wide, or
24
- * `reviewRailAvailable` changes), the rail resets to its default open
24
+ * `reviewRailAvailable` changes), the rail resets to its default closed
25
25
  * state per `getInitialReviewRailOpen`. A ref guards the effect so it
26
26
  * only fires on actual transitions, not every viewport resize.
27
27
  *
@@ -25,8 +25,6 @@ import {
25
25
  ChevronLeft,
26
26
  ChevronRight,
27
27
  CircleOff,
28
- Eye,
29
- EyeOff,
30
28
  FileDiff,
31
29
  Flag,
32
30
  Hand,
@@ -226,33 +224,33 @@ function RoleActionButton(arg: RoleActionButtonProps): React.JSX.Element | null
226
224
  </Tooltip.Portal>
227
225
  </Tooltip.Root>
228
226
  );
229
- case "tracked-changes-toggle":
227
+ case "tracked-changes-toggle": {
228
+ const trackChangesLabel = (props.showTrackedChanges ?? false)
229
+ ? "Stop tracking changes"
230
+ : "Start tracking changes";
230
231
  return (
231
232
  <Tooltip.Root>
232
233
  <Tooltip.Trigger asChild>
233
234
  <Toggle.Root
234
235
  pressed={props.showTrackedChanges ?? false}
235
236
  onPressedChange={(v) => props.onShowTrackedChangesChange?.(v)}
236
- aria-label={(props.showTrackedChanges ?? false) ? "Turn off tracked changes" : "Turn on tracked changes"}
237
+ aria-label={trackChangesLabel}
237
238
  disabled={props.capabilities ? !props.capabilities.trackChangesSupported : false}
238
239
  onMouseDown={preserveEditorSelectionMouseDown}
239
240
  className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-canvas data-[state=on]:text-accent data-[state=on]:ring-1 data-[state=on]:ring-accent/30 data-[state=on]:shadow-sm outline-none disabled:opacity-40 ${focusRingClass}`}
240
241
  data-testid="role-tracked-changes-toggle"
241
242
  >
242
- {(props.showTrackedChanges ?? false) ? (
243
- <Eye className="h-3.5 w-3.5" />
244
- ) : (
245
- <EyeOff className="h-3.5 w-3.5" />
246
- )}
243
+ <FileDiff className="h-3.5 w-3.5" />
247
244
  </Toggle.Root>
248
245
  </Tooltip.Trigger>
249
246
  <Tooltip.Portal>
250
247
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
251
- {(props.showTrackedChanges ?? false) ? "Turn off tracked changes" : "Turn on tracked changes"}
248
+ {trackChangesLabel}
252
249
  </Tooltip.Content>
253
250
  </Tooltip.Portal>
254
251
  </Tooltip.Root>
255
252
  );
253
+ }
256
254
  case "review-sidebar-tracked-changes":
257
255
  return (
258
256
  <Tooltip.Root>
@@ -15,8 +15,7 @@ import {
15
15
  Bold,
16
16
  ChevronDown,
17
17
  Download,
18
- Eye,
19
- EyeOff,
18
+ FileDiff,
20
19
  FileText,
21
20
  Highlighter,
22
21
  ImagePlus,
@@ -89,7 +88,7 @@ export interface TwToolbarProps {
89
88
  formattingState?: FormattingStateSnapshot;
90
89
  activeListContext?: ActiveListContext | null;
91
90
  styleCatalog?: StyleCatalogSnapshot;
92
- /** Display toggle for tracked change decorations (not a runtime mutation toggle). */
91
+ /** Authoring toggle for recording new edits as tracked changes. */
93
92
  showTrackedChanges: boolean;
94
93
  /** Active story target — shows a breadcrumb when editing a secondary story. */
95
94
  activeStory?: EditorStoryTarget;
@@ -378,7 +377,7 @@ export function TwToolbar(props: TwToolbarProps) {
378
377
  label="Bold"
379
378
  shortcut="⌘B"
380
379
  active={props.formattingState?.bold ?? false}
381
- disabled={!canEdit}
380
+ disabled={!canEdit || !props.onToggleBold}
382
381
  onClick={props.onToggleBold}
383
382
  />
384
383
  <TwToolbarIconButton
@@ -386,7 +385,7 @@ export function TwToolbar(props: TwToolbarProps) {
386
385
  label="Italic"
387
386
  shortcut="⌘I"
388
387
  active={props.formattingState?.italic ?? false}
389
- disabled={!canEdit}
388
+ disabled={!canEdit || !props.onToggleItalic}
390
389
  onClick={props.onToggleItalic}
391
390
  />
392
391
  <TwToolbarIconButton
@@ -394,12 +393,17 @@ export function TwToolbar(props: TwToolbarProps) {
394
393
  label="Underline"
395
394
  shortcut="⌘U"
396
395
  active={props.formattingState?.underline ?? false}
397
- disabled={!canEdit}
396
+ disabled={!canEdit || !props.onToggleUnderline}
398
397
  onClick={props.onToggleUnderline}
399
398
  />
400
399
  {showAdvancedFormatting ? (
401
400
  <ToolbarFormattingOverflow
402
- disabled={!canEdit}
401
+ disabled={
402
+ !canEdit ||
403
+ (!props.onToggleStrikethrough &&
404
+ !props.onToggleSuperscript &&
405
+ !props.onToggleSubscript)
406
+ }
403
407
  formattingState={props.formattingState}
404
408
  onToggleStrikethrough={props.onToggleStrikethrough}
405
409
  onToggleSuperscript={props.onToggleSuperscript}
@@ -452,14 +456,14 @@ export function TwToolbar(props: TwToolbarProps) {
452
456
  icon={List}
453
457
  label="Bulleted list"
454
458
  active={Boolean(props.activeListContext && !props.activeListContext.isOrdered)}
455
- disabled={!canEdit}
459
+ disabled={!canEdit || !props.onToggleBulletedList}
456
460
  onClick={props.onToggleBulletedList}
457
461
  />
458
462
  <TwToolbarIconButton
459
463
  icon={Rows3}
460
464
  label="Numbered list"
461
465
  active={Boolean(props.activeListContext?.isOrdered)}
462
- disabled={!canEdit}
466
+ disabled={!canEdit || !props.onToggleNumberedList}
463
467
  onClick={props.onToggleNumberedList}
464
468
  />
465
469
  </>
@@ -469,13 +473,13 @@ export function TwToolbar(props: TwToolbarProps) {
469
473
  <TwToolbarIconButton
470
474
  icon={Outdent}
471
475
  label="Outdent"
472
- disabled={!canEdit}
476
+ disabled={!canEdit || !props.onOutdent}
473
477
  onClick={props.onOutdent}
474
478
  />
475
479
  <TwToolbarIconButton
476
480
  icon={Indent}
477
481
  label="Indent"
478
- disabled={!canEdit}
482
+ disabled={!canEdit || !props.onIndent}
479
483
  onClick={props.onIndent}
480
484
  />
481
485
  </>
@@ -669,12 +673,12 @@ export function TwToolbar(props: TwToolbarProps) {
669
673
  <Toggle.Root
670
674
  pressed={props.showTrackedChanges}
671
675
  onPressedChange={props.onShowTrackedChangesChange}
672
- aria-label={props.showTrackedChanges ? "Turn off tracked changes" : "Turn on tracked changes"}
676
+ aria-label={props.showTrackedChanges ? "Stop tracking changes" : "Start tracking changes"}
673
677
  disabled={caps ? !caps.trackChangesSupported : false}
674
678
  onMouseDown={preserveEditorSelectionMouseDown}
675
679
  className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-canvas data-[state=on]:text-accent data-[state=on]:ring-1 data-[state=on]:ring-accent/30 data-[state=on]:shadow-sm outline-none disabled:opacity-40 ${focusRingClass}`}
676
680
  >
677
- {props.showTrackedChanges ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
681
+ <FileDiff className="h-3.5 w-3.5" />
678
682
  </Toggle.Root>
679
683
  </Tooltip.Trigger>
680
684
  <Tooltip.Portal>
@@ -682,7 +686,7 @@ export function TwToolbar(props: TwToolbarProps) {
682
686
  className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
683
687
  sideOffset={6}
684
688
  >
685
- {props.showTrackedChanges ? "Turn off tracked changes" : "Turn on tracked changes"}
689
+ {props.showTrackedChanges ? "Stop tracking changes" : "Start tracking changes"}
686
690
  </Tooltip.Content>
687
691
  </Tooltip.Portal>
688
692
  </Tooltip.Root>
@@ -1420,7 +1424,7 @@ function ToolbarFormattingOverflow(props: {
1420
1424
  <ToolbarPopoverActionButton
1421
1425
  active={props.formattingState?.strikethrough ?? false}
1422
1426
  ariaLabel="Strikethrough"
1423
- disabled={props.disabled}
1427
+ disabled={props.disabled || !props.onToggleStrikethrough}
1424
1428
  icon={<Strikethrough className="h-3.5 w-3.5" />}
1425
1429
  onClick={() => {
1426
1430
  props.onToggleStrikethrough?.();
@@ -1430,7 +1434,7 @@ function ToolbarFormattingOverflow(props: {
1430
1434
  <ToolbarPopoverActionButton
1431
1435
  active={props.formattingState?.superscript ?? false}
1432
1436
  ariaLabel="Superscript"
1433
- disabled={props.disabled}
1437
+ disabled={props.disabled || !props.onToggleSuperscript}
1434
1438
  icon={<Superscript className="h-3.5 w-3.5" />}
1435
1439
  onClick={() => {
1436
1440
  props.onToggleSuperscript?.();
@@ -1440,7 +1444,7 @@ function ToolbarFormattingOverflow(props: {
1440
1444
  <ToolbarPopoverActionButton
1441
1445
  active={props.formattingState?.subscript ?? false}
1442
1446
  ariaLabel="Subscript"
1443
- disabled={props.disabled}
1447
+ disabled={props.disabled || !props.onToggleSubscript}
1444
1448
  icon={<Subscript className="h-3.5 w-3.5" />}
1445
1449
  onClick={() => {
1446
1450
  props.onToggleSubscript?.();
@@ -181,9 +181,12 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
181
181
  // in the render tree below.
182
182
  const { bodySlotRef, pmSurfaceElement } = usePmSurfaceCapture();
183
183
  const { scrollRootRef, pageStackScrollRoot } = useScrollRootCapture();
184
+ const lastHoveredRevisionIdRef = useRef<string | null>(null);
184
185
  const caps = props.capabilities;
185
186
  const isPageWorkspace = props.workspaceMode === "page";
186
187
  const markupDisplay = props.markupDisplay;
188
+ const trackedChangesAuthoringEnabled =
189
+ props.trackedChangesAuthoringEnabled ?? props.showTrackedChanges;
187
190
  const [navOpen, setNavOpen] = useState(false);
188
191
  const handleOpenPageModeStory = useCallback(
189
192
  (target: EditorStoryTarget) => {
@@ -246,6 +249,22 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
246
249
  reviewRailAvailable,
247
250
  viewportWidth,
248
251
  });
252
+ const handleDocumentMouseOver = useCallback(
253
+ (event: React.MouseEvent<HTMLDivElement>) => {
254
+ const element = event.target as HTMLElement | null;
255
+ const revisionId =
256
+ element?.closest?.("[data-revision-id]")?.getAttribute("data-revision-id") ?? null;
257
+ if (!revisionId || (revisionId === lastHoveredRevisionIdRef.current && reviewRailOpen)) {
258
+ return;
259
+ }
260
+ lastHoveredRevisionIdRef.current = revisionId;
261
+ if (reviewRailAvailable) {
262
+ setReviewRailOpen(true);
263
+ props.onActiveRailTabChange?.("changes");
264
+ }
265
+ },
266
+ [props.onActiveRailTabChange, reviewRailAvailable, reviewRailOpen, setReviewRailOpen],
267
+ );
249
268
  // Incremented on zoom_changed / render_frame_ready so the placement
250
269
  // useMemo below re-executes when the render kernel emits new rects.
251
270
  const renderFrameRevision = useLayoutFacetRenderSignal(props.layoutFacet);
@@ -609,7 +628,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
609
628
  toolbarInteractionPolicy?.canAddComment ??
610
629
  (caps ? caps.canAddComment : false)
611
630
  }
612
- showTrackedChanges={props.showTrackedChanges}
631
+ showTrackedChanges={trackedChangesAuthoringEnabled}
613
632
  capabilities={caps}
614
633
  onAddComment={
615
634
  props.onAddComment
@@ -744,7 +763,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
744
763
  formattingState={props.formattingState}
745
764
  activeListContext={props.activeListContext}
746
765
  styleCatalog={props.styleCatalog}
747
- showTrackedChanges={props.showTrackedChanges}
766
+ showTrackedChanges={trackedChangesAuthoringEnabled}
748
767
  showSidebarToggle={responsiveChrome.showSidebarToggle}
749
768
  isSidebarOpen={reviewRailOpen}
750
769
  onUndo={runWithSelectionToolbarDismiss(props.onUndo)}
@@ -926,6 +945,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
926
945
  <div className="flex flex-1 flex-col min-w-0">
927
946
  <div
928
947
  ref={scrollRootRef}
948
+ onMouseOver={handleDocumentMouseOver}
929
949
  className="flex-1 overflow-y-auto bg-surface"
930
950
  data-wre-scroll-root="true"
931
951
  >
@@ -1243,6 +1263,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1243
1263
  onAddReply: props.onAddReply,
1244
1264
  onEditBody: props.onEditBody,
1245
1265
  onOpenRevision: props.onOpenRevision,
1266
+ onReplyToRevision: props.onReplyToRevision,
1246
1267
  onAcceptRevision: props.onAcceptRevision,
1247
1268
  onRejectRevision: props.onRejectRevision,
1248
1269
  onAcceptAllChanges: props.onAcceptAllChanges,
@@ -1251,6 +1272,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1251
1272
  // Layer-06 workflow facet. Layout facet no longer exposes
1252
1273
  // `getAllScopeRailSegments` (methods removed in v40 / Slice 4C).
1253
1274
  scopeRailSegments: props.workflowFacet?.getAllRailSegments() ?? [],
1275
+ activeScopeId,
1276
+ onOpenScope: (segment) => {
1277
+ handleScopeStripeClick({ scopeId: segment.scopeId });
1278
+ },
1254
1279
  workflowTab: props.reviewRailWorkflowTab,
1255
1280
  workflowCount: props.reviewRailWorkflowCount,
1256
1281
  workflowScopesTitle: props.reviewRailWorkflowScopesTitle,