@beyondwork/docx-react-component 1.0.19 → 1.0.20

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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +336 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/formatting-commands.ts +1 -1
  5. package/src/core/commands/index.ts +14 -2
  6. package/src/core/search/search-text.ts +28 -0
  7. package/src/core/state/editor-state.ts +3 -0
  8. package/src/index.ts +21 -0
  9. package/src/io/docx-session.ts +363 -17
  10. package/src/io/export/serialize-comments.ts +104 -34
  11. package/src/io/export/serialize-footnotes.ts +198 -1
  12. package/src/io/export/serialize-headers-footers.ts +203 -10
  13. package/src/io/export/serialize-main-document.ts +83 -3
  14. package/src/io/export/split-review-boundaries.ts +181 -19
  15. package/src/io/normalize/normalize-text.ts +82 -8
  16. package/src/io/ooxml/highlight-colors.ts +39 -0
  17. package/src/io/ooxml/parse-comments.ts +85 -19
  18. package/src/io/ooxml/parse-fields.ts +396 -0
  19. package/src/io/ooxml/parse-footnotes.ts +240 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +431 -7
  21. package/src/io/ooxml/parse-inline-media.ts +15 -1
  22. package/src/io/ooxml/parse-main-document.ts +396 -14
  23. package/src/io/ooxml/parse-revisions.ts +317 -38
  24. package/src/legal/bookmarks.ts +44 -0
  25. package/src/legal/cross-references.ts +59 -1
  26. package/src/model/canonical-document.ts +117 -1
  27. package/src/model/snapshot.ts +85 -1
  28. package/src/review/store/revision-store.ts +6 -0
  29. package/src/review/store/revision-types.ts +1 -0
  30. package/src/runtime/document-navigation.ts +52 -13
  31. package/src/runtime/document-runtime.ts +1521 -75
  32. package/src/runtime/read-only-diagnostics-runtime.ts +8 -0
  33. package/src/runtime/session-capabilities.ts +33 -3
  34. package/src/runtime/surface-projection.ts +86 -25
  35. package/src/runtime/table-schema.ts +2 -2
  36. package/src/runtime/view-state.ts +24 -6
  37. package/src/runtime/workflow-markup.ts +349 -0
  38. package/src/ui/WordReviewEditor.tsx +850 -1315
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1422 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +51 -0
  43. package/src/ui/headless/revision-decoration-model.ts +4 -4
  44. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  45. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  46. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  47. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  48. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  49. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  50. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  51. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  52. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  53. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  54. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  55. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  56. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  57. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  58. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  59. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +174 -48
  60. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  61. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  62. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  63. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  64. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  65. package/src/ui-tailwind/theme/editor-theme.css +4 -0
  66. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  67. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  68. package/src/validation/compatibility-engine.ts +27 -4
  69. package/src/validation/compatibility-report.ts +1 -0
  70. package/src/validation/docx-comment-proof.ts +220 -0
@@ -16,6 +16,7 @@ import type {
16
16
  SearchOptions,
17
17
  SearchResultSnapshot,
18
18
  SelectionSnapshot,
19
+ WorkflowScope,
19
20
  } from "../../api/public-types";
20
21
  import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
21
22
  import { searchDocument } from "../../runtime/document-search.ts";
@@ -38,7 +39,12 @@ import {
38
39
  } from "./pm-command-bridge";
39
40
  import { buildDecorations } from "./pm-decorations";
40
41
  import { createContextualInteractionPlugin } from "./pm-contextual-ui";
41
- import { finishPerfProbe, startPerfProbe } from "./perf-probe";
42
+ import {
43
+ finishPerfProbe,
44
+ incrementInvalidationCounter,
45
+ recordPerfSample,
46
+ startPerfProbe,
47
+ } from "./perf-probe";
42
48
  import type { PositionMap } from "./pm-position-map";
43
49
  import {
44
50
  clearSearch as clearSearchPlugin,
@@ -47,8 +53,13 @@ import {
47
53
  performSearch,
48
54
  searchPluginKey,
49
55
  } from "./search-plugin";
56
+ import {
57
+ createSurfaceDecorationKey,
58
+ createSurfaceDocumentBuildKey,
59
+ } from "./surface-build-keys";
50
60
  import { tableNodeViews } from "./tw-table-node-view";
51
61
  import type { SelectionToolbarAnchor } from "../../ui/headless/selection-toolbar-model";
62
+ import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
52
63
 
53
64
  /**
54
65
  * Same props interface as the legacy TwEditorSurface — drop-in replacement.
@@ -77,6 +88,8 @@ export interface TwProseMirrorSurfaceProps {
77
88
  onCommentActivated?: (commentId: string) => void;
78
89
  onRevisionActivated?: (revisionId: string) => void;
79
90
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
91
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
92
+ workflowScopes?: readonly WorkflowScope[];
80
93
  }
81
94
 
82
95
  export interface TwProseMirrorSurfaceRef {
@@ -97,6 +110,17 @@ export const TwProseMirrorSurface = forwardRef<
97
110
  onBlur,
98
111
  } = props;
99
112
  const surface = snapshot.surface;
113
+ const mediaPreviewKey = useMemo(
114
+ () =>
115
+ Object.entries(props.mediaPreviews ?? {})
116
+ .sort(([leftId], [rightId]) => leftId.localeCompare(rightId))
117
+ .map(
118
+ ([mediaId, preview]) =>
119
+ `${mediaId}:${preview.widthEmu ?? ""}:${preview.heightEmu ?? ""}:${preview.src}`,
120
+ )
121
+ .join("|"),
122
+ [props.mediaPreviews],
123
+ );
100
124
 
101
125
  const canEdit = Boolean(
102
126
  surface && snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError,
@@ -109,7 +133,8 @@ export const TwProseMirrorSurface = forwardRef<
109
133
  const activeSearchRef = useRef<{ query: string; options: SearchOptions } | null>(null);
110
134
  const pendingTypingProbeRef = useRef<string | null>(null);
111
135
  const pendingSelectionProbeRef = useRef<string | null>(null);
112
- const surfaceBuildKeyRef = useRef<string | null>(null);
136
+ const documentBuildKeyRef = useRef<string | null>(null);
137
+ const decorationBuildKeyRef = useRef<string | null>(null);
113
138
  const suppressSelectionEchoRef = useRef(false);
114
139
  const selectionToolbarFrameRef = useRef<number | null>(null);
115
140
  const lastSelectionToolbarMeasurementRef = useRef<{
@@ -158,6 +183,34 @@ export const TwProseMirrorSurface = forwardRef<
158
183
  () => createRevisionDecorationModel(snapshot.trackedChanges, props.activeRevisionId),
159
184
  [snapshot.trackedChanges, props.activeRevisionId],
160
185
  );
186
+ const documentBuildKey = useMemo(
187
+ () =>
188
+ createSurfaceDocumentBuildKey({
189
+ surface,
190
+ activeStory: snapshot.activeStory,
191
+ mediaPreviewKey,
192
+ }),
193
+ [mediaPreviewKey, snapshot.activeStory, surface],
194
+ );
195
+ const decorationBuildKey = useMemo(
196
+ () =>
197
+ createSurfaceDecorationKey({
198
+ markupDisplay,
199
+ showTrackedChanges,
200
+ canEdit,
201
+ activeCommentId: snapshot.comments.activeCommentId,
202
+ activeRevisionId: props.activeRevisionId,
203
+ workflowScopeSignature: JSON.stringify(props.workflowScopes ?? []),
204
+ }),
205
+ [
206
+ canEdit,
207
+ markupDisplay,
208
+ props.activeRevisionId,
209
+ props.workflowScopes,
210
+ showTrackedChanges,
211
+ snapshot.comments.activeCommentId,
212
+ ],
213
+ );
161
214
 
162
215
  // Create PM plugins (stable across renders — callbacks accessed via ref)
163
216
  const plugins = useMemo(() => {
@@ -174,6 +227,8 @@ export const TwProseMirrorSurface = forwardRef<
174
227
  onRedo: () => callbacksRef.current?.onRedo(),
175
228
  onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
176
229
  getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
230
+ isSelectionSyncSuppressed: () =>
231
+ callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
177
232
  }),
178
233
  createContextualInteractionPlugin({
179
234
  onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
@@ -183,37 +238,42 @@ export const TwProseMirrorSurface = forwardRef<
183
238
  ];
184
239
  }, [props.onCommentActivated, props.onRevisionActivated]);
185
240
 
186
- // Create or update PM view whenever surface becomes available or changes.
187
- // The view is created lazily if surface is null on first render (loading),
188
- // it will be created when the runtime provides a real snapshot.
189
- useEffect(() => {
190
- if (!mountRef.current || !surface) return;
191
-
192
- const surfaceBuildKey = JSON.stringify({
193
- revisionToken: snapshot.revisionToken,
194
- activeStory: snapshot.activeStory,
195
- markupDisplay,
196
- canEdit,
197
- showTrackedChanges,
198
- });
199
-
200
- if (viewRef.current && surfaceBuildKeyRef.current === surfaceBuildKey) {
201
- const positionMap = positionMapRef.current;
202
- if (!positionMap) {
203
- return;
204
- }
241
+ const applyDecorationProps = useCallback(
242
+ (view: EditorView, positionMap: PositionMap): void => {
205
243
  const decorations = buildDecorations(
206
- viewRef.current.state.doc,
244
+ view.state.doc,
207
245
  positionMap,
208
246
  commentModel,
209
247
  revisionModel,
210
248
  markupDisplay,
211
249
  showTrackedChanges,
250
+ props.workflowScopes,
251
+ snapshot.activeStory,
212
252
  );
213
- viewRef.current.setProps({
253
+ view.setProps({
214
254
  editable: () => canEdit,
215
255
  decorations: () => decorations,
216
256
  });
257
+ decorationBuildKeyRef.current = decorationBuildKey;
258
+ recordPerfSample("pm.decorations");
259
+ incrementInvalidationCounter("pm.laneB.decorationUpdates");
260
+ },
261
+ [
262
+ canEdit,
263
+ commentModel,
264
+ decorationBuildKey,
265
+ markupDisplay,
266
+ revisionModel,
267
+ showTrackedChanges,
268
+ props.workflowScopes,
269
+ ],
270
+ );
271
+
272
+ // Create or update the PM document only when the structural key changes.
273
+ useEffect(() => {
274
+ if (!mountRef.current || !surface) return;
275
+
276
+ if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
217
277
  return;
218
278
  }
219
279
 
@@ -221,9 +281,9 @@ export const TwProseMirrorSurface = forwardRef<
221
281
  surface,
222
282
  snapshot.selection,
223
283
  plugins,
284
+ props.mediaPreviews,
224
285
  );
225
286
  positionMapRef.current = positionMap;
226
-
227
287
  const decorations = buildDecorations(
228
288
  state.doc,
229
289
  positionMap,
@@ -231,7 +291,11 @@ export const TwProseMirrorSurface = forwardRef<
231
291
  revisionModel,
232
292
  markupDisplay,
233
293
  showTrackedChanges,
294
+ props.workflowScopes,
295
+ snapshot.activeStory,
234
296
  );
297
+ recordPerfSample("pm.rebuild");
298
+ incrementInvalidationCounter("pm.laneA.rebuilds");
235
299
 
236
300
  if (!viewRef.current) {
237
301
  // First time surface is available — create the EditorView
@@ -246,19 +310,16 @@ export const TwProseMirrorSurface = forwardRef<
246
310
  },
247
311
  });
248
312
  viewRef.current = view;
313
+ recordPerfSample("pm.mount");
249
314
  } else {
250
- // View exists — update state and decorations
251
- viewRef.current.setProps({
252
- editable: () => canEdit,
253
- decorations: () => decorations,
254
- });
255
315
  suppressSelectionEchoRef.current = true;
256
316
  viewRef.current.updateState(state);
257
317
  queueMicrotask(() => {
258
318
  suppressSelectionEchoRef.current = false;
259
319
  });
260
320
  }
261
- surfaceBuildKeyRef.current = surfaceBuildKey;
321
+ documentBuildKeyRef.current = documentBuildKey;
322
+ applyDecorationProps(viewRef.current, positionMap);
262
323
 
263
324
  if (activeSearchRef.current) {
264
325
  applySearch(
@@ -271,16 +332,29 @@ export const TwProseMirrorSurface = forwardRef<
271
332
  pendingTypingProbeRef.current = null;
272
333
  }
273
334
  }, [
274
- snapshot.activeStory,
275
- snapshot.revisionToken,
335
+ applyDecorationProps,
336
+ documentBuildKey,
276
337
  surface,
277
- commentModel,
278
- revisionModel,
279
- markupDisplay,
280
- canEdit,
281
- showTrackedChanges,
338
+ snapshot.selection,
339
+ plugins,
340
+ props.mediaPreviews,
282
341
  ]);
283
342
 
343
+ // Update decorations and editability without rebuilding the PM document.
344
+ useEffect(() => {
345
+ const view = viewRef.current;
346
+ const positionMap = positionMapRef.current;
347
+ if (!view || !surface || !positionMap) {
348
+ return;
349
+ }
350
+
351
+ if (decorationBuildKeyRef.current === decorationBuildKey) {
352
+ return;
353
+ }
354
+
355
+ applyDecorationProps(view, positionMap);
356
+ }, [applyDecorationProps, decorationBuildKey, surface]);
357
+
284
358
  useEffect(() => {
285
359
  const view = viewRef.current;
286
360
  const positionMap = positionMapRef.current;
@@ -299,6 +373,7 @@ export const TwProseMirrorSurface = forwardRef<
299
373
 
300
374
  suppressSelectionEchoRef.current = true;
301
375
  view.dispatch(view.state.tr.setSelection(nextSelection));
376
+ recordPerfSample("selection.sync");
302
377
  queueMicrotask(() => {
303
378
  suppressSelectionEchoRef.current = false;
304
379
  });
@@ -356,11 +431,41 @@ export const TwProseMirrorSurface = forwardRef<
356
431
 
357
432
  function applySearch(query: string, options: SearchOptions): SearchResultSnapshot[] {
358
433
  const view = viewRef.current;
434
+ const hiddenDeletionRanges =
435
+ markupDisplay === "clean"
436
+ ? snapshot.trackedChanges.revisions
437
+ .filter(
438
+ (
439
+ revision,
440
+ ): revision is typeof revision & {
441
+ anchor: Extract<typeof revision.anchor, { kind: "range" }>;
442
+ } =>
443
+ revision.kind === "deletion" &&
444
+ revision.status === "active" &&
445
+ revision.anchor.kind === "range",
446
+ )
447
+ .map((revision) => ({
448
+ from: revision.anchor.from,
449
+ to: revision.anchor.to,
450
+ }))
451
+ : [];
359
452
  if (view) {
360
- const rawResults = performSearch(view.state, query, options).slice(
361
- 0,
362
- options.limit ?? Number.POSITIVE_INFINITY,
363
- );
453
+ const rawResults = performSearch(view.state, query, options)
454
+ .filter((result) => {
455
+ if (hiddenDeletionRanges.length === 0) {
456
+ return true;
457
+ }
458
+ const positionMap = positionMapRef.current;
459
+ if (!positionMap) {
460
+ return true;
461
+ }
462
+ const runtimeFrom = positionMap.pmToRuntime(result.from);
463
+ const runtimeTo = positionMap.pmToRuntime(result.to);
464
+ return !hiddenDeletionRanges.some(
465
+ (range) => runtimeFrom < range.to && runtimeTo > range.from,
466
+ );
467
+ })
468
+ .slice(0, options.limit ?? Number.POSITIVE_INFINITY);
364
469
  view.dispatch(
365
470
  view.state.tr.setMeta(searchPluginKey, {
366
471
  results: rawResults,
@@ -369,16 +474,37 @@ export const TwProseMirrorSurface = forwardRef<
369
474
  );
370
475
  }
371
476
 
372
- return searchDocument(
373
- props.canonicalDocument,
374
- snapshot.selection,
375
- snapshot.activeStory,
376
- props.documentNavigation,
377
- query,
378
- options,
477
+ return filterHiddenDeletionSearchResults(
478
+ searchDocument(
479
+ props.canonicalDocument,
480
+ snapshot.selection,
481
+ snapshot.activeStory,
482
+ props.documentNavigation,
483
+ query,
484
+ options,
485
+ ),
486
+ hiddenDeletionRanges,
379
487
  );
380
488
  }
381
489
 
490
+ function filterHiddenDeletionSearchResults(
491
+ results: SearchResultSnapshot[],
492
+ hiddenRanges: Array<{ from: number; to: number }>,
493
+ ): SearchResultSnapshot[] {
494
+ if (hiddenRanges.length === 0) {
495
+ return results;
496
+ }
497
+ return results.filter((result) => {
498
+ const anchor = result.anchor;
499
+ if (anchor.kind !== "range") {
500
+ return true;
501
+ }
502
+ return !hiddenRanges.some(
503
+ (range) => anchor.from < range.to && anchor.to > range.from,
504
+ );
505
+ });
506
+ }
507
+
382
508
  function clearLiveSearch(): void {
383
509
  const view = viewRef.current;
384
510
  if (!view) {
@@ -0,0 +1,27 @@
1
+ import type {
2
+ DocumentNavigationSnapshot,
3
+ PageLayoutSnapshot,
4
+ SurfaceBlockSnapshot,
5
+ } from "../api/public-types.ts";
6
+
7
+ export interface LineMarker {
8
+ id: string;
9
+ label: string;
10
+ topPx: number;
11
+ }
12
+
13
+ export function computeLineMarkersIfEnabled(input: {
14
+ pageLayout: PageLayoutSnapshot | undefined;
15
+ surfaceBlocks: readonly SurfaceBlockSnapshot[];
16
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>;
17
+ buildLineNumberMarkers: (
18
+ blocks: readonly SurfaceBlockSnapshot[],
19
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
20
+ ) => LineMarker[];
21
+ }): LineMarker[] {
22
+ if (!input.pageLayout?.lineNumbering) {
23
+ return [];
24
+ }
25
+
26
+ return input.buildLineNumberMarkers(input.surfaceBlocks, input.pages);
27
+ }
@@ -50,7 +50,7 @@ export function TwCommentSidebar(props: TwCommentSidebarProps) {
50
50
  ))}
51
51
  </div>
52
52
  ) : (
53
- <div className="rounded-xl border border-dashed border-border bg-surface/60 px-3 py-4 text-[11px] leading-5 text-tertiary">
53
+ <div className="rounded-lg border border-dashed border-border bg-surface/60 px-2.5 py-3 text-[10px] leading-4 text-tertiary">
54
54
  No comment threads yet. Select text and add one from the toolbar.
55
55
  </div>
56
56
  )}
@@ -93,7 +93,7 @@ function CommentThreadCard(props: {
93
93
  role="button"
94
94
  tabIndex={0}
95
95
  className={[
96
- "cursor-pointer rounded-xl border px-2.5 py-2 transition-colors",
96
+ "cursor-pointer rounded-lg border px-2 py-1.5 transition-colors",
97
97
  focusRingClass,
98
98
  isActive
99
99
  ? "border-accent/25 bg-accent-soft/35"
@@ -125,7 +125,7 @@ function CommentThreadCard(props: {
125
125
 
126
126
  {/* Excerpt — anchored text from document */}
127
127
  {showExcerpt ? (
128
- <p className="mb-1 rounded-md border-l-2 border-comment/25 bg-comment-soft/30 px-2 py-1 text-[9px] leading-4 text-comment/80 italic line-clamp-2">
128
+ <p className="mb-1 rounded-md border-l-2 border-comment/25 bg-comment-soft/30 px-2 py-1 text-[9px] leading-4 text-comment/80 italic whitespace-pre-wrap break-words line-clamp-2">
129
129
  {thread.excerpt}
130
130
  </p>
131
131
  ) : null}
@@ -140,7 +140,7 @@ function CommentThreadCard(props: {
140
140
  />
141
141
  ) : leadEntry?.body ? (
142
142
  <p
143
- className="text-[10px] leading-[1.15rem] text-secondary line-clamp-3"
143
+ className="text-[10px] leading-[1.1rem] text-secondary whitespace-pre-wrap break-words line-clamp-4"
144
144
  data-comment-thread-body="true"
145
145
  >
146
146
  {leadEntry.body}
@@ -159,13 +159,13 @@ function CommentThreadCard(props: {
159
159
 
160
160
  {/* Reply entries (compact) */}
161
161
  {thread.entries.slice(1).map((entry) => (
162
- <div key={entry.entryId} className="mt-1 border-t border-border/40 pt-1">
162
+ <div key={entry.entryId} className="mt-1.5 ml-4 border-l border-border/50 pl-2.5">
163
163
  <div className="mb-0.5 flex items-center gap-1">
164
164
  <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
165
165
  <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
166
166
  </div>
167
167
  <p
168
- className="text-[10px] leading-4 text-secondary line-clamp-2"
168
+ className="text-[10px] leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-3"
169
169
  data-comment-reply-body="true"
170
170
  >
171
171
  {entry.body}
@@ -180,7 +180,7 @@ function CommentThreadCard(props: {
180
180
  ) : null}
181
181
 
182
182
  {/* Inline actions — compact, horizontal */}
183
- <div className="mt-1.5 flex items-center gap-0.5">
183
+ <div className="mt-1 flex items-center gap-0.5">
184
184
  {thread.status === "open" && (
185
185
  <>
186
186
  <button
@@ -5,15 +5,17 @@ import type {
5
5
  CompatibilityFeatureEntry,
6
6
  CompatibilityPanelSnapshot,
7
7
  EditorWarning,
8
+ WorkflowBlockedCommandReason,
8
9
  } from "../../api/public-types";
9
10
 
10
11
  export interface TwHealthPanelProps {
11
12
  compatibility: CompatibilityPanelSnapshot;
12
13
  warnings: EditorWarning[];
14
+ blockedReasons?: WorkflowBlockedCommandReason[];
13
15
  }
14
16
 
15
17
  export function TwHealthPanel(props: TwHealthPanelProps) {
16
- const { compatibility, warnings } = props;
18
+ const { compatibility, warnings, blockedReasons = [] } = props;
17
19
  const supportedCount = compatibility.featureEntries.filter(
18
20
  (e) => e.featureClass === "supported-roundtrip",
19
21
  ).length;
@@ -80,7 +82,34 @@ export function TwHealthPanel(props: TwHealthPanelProps) {
80
82
  </div>
81
83
  ))}
82
84
 
83
- {compatibility.featureEntries.length === 0 && warnings.length === 0 ? (
85
+ {blockedReasons.length > 0 ? (
86
+ <>
87
+ <div className="border-t border-border mt-2 pt-2">
88
+ <p className="text-xs font-medium text-tertiary mb-1">Workflow blocked reasons</p>
89
+ </div>
90
+ {blockedReasons.map((reason, index) => (
91
+ <div key={`blocked-${index}`} className="flex rounded-lg transition-colors hover:bg-surface">
92
+ <div className="w-0.5 shrink-0 rounded-l-lg bg-amber-400" />
93
+ <div className="flex items-start gap-2 p-2.5 flex-1">
94
+ <ShieldAlert className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
95
+ <div className="flex-1 min-w-0">
96
+ <div className="flex items-start justify-between gap-2">
97
+ <span className="text-sm font-medium text-primary">{reason.message}</span>
98
+ <span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium text-amber-700 bg-amber-100">
99
+ {reason.code.replace(/_/g, " ")}
100
+ </span>
101
+ </div>
102
+ {reason.scopeId ? (
103
+ <p className="text-xs text-tertiary mt-0.5">scope: {reason.scopeId}</p>
104
+ ) : null}
105
+ </div>
106
+ </div>
107
+ </div>
108
+ ))}
109
+ </>
110
+ ) : null}
111
+
112
+ {compatibility.featureEntries.length === 0 && warnings.length === 0 && blockedReasons.length === 0 ? (
84
113
  <p className="text-xs text-tertiary py-4">
85
114
  No compatibility entries or warnings to display.
86
115
  </p>
@@ -52,7 +52,7 @@ export function TwReviewRail(props: TwReviewRailProps) {
52
52
  return (
53
53
  <aside
54
54
  aria-label="Review rail"
55
- className="flex w-[336px] shrink-0 flex-col border-l border-border bg-canvas"
55
+ className="flex w-[304px] shrink-0 flex-col border-l border-border bg-canvas"
56
56
  >
57
57
  <Tabs.Root
58
58
  value={props.activeTab}
@@ -79,7 +79,7 @@ export function TwReviewRail(props: TwReviewRailProps) {
79
79
 
80
80
  <ScrollArea.Root className="flex-1 min-h-0">
81
81
  <ScrollArea.Viewport className="h-full w-full">
82
- <Tabs.Content value="comments" className="p-3 outline-none">
82
+ <Tabs.Content value="comments" className="p-2.5 outline-none">
83
83
  <TwCommentSidebar
84
84
  currentUserId={props.currentUserId}
85
85
  comments={props.comments}
@@ -92,7 +92,7 @@ export function TwReviewRail(props: TwReviewRailProps) {
92
92
  />
93
93
  </Tabs.Content>
94
94
 
95
- <Tabs.Content value="changes" className="p-3 outline-none">
95
+ <Tabs.Content value="changes" className="p-2.5 outline-none">
96
96
  <TwRevisionSidebar
97
97
  trackedChanges={props.trackedChanges}
98
98
  markupDisplay={props.markupDisplay}
@@ -28,16 +28,16 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
28
28
 
29
29
  return (
30
30
  <div className="outline-none">
31
- <p className="text-xs text-tertiary mb-3">
31
+ <p className="mb-2 text-[10px] text-tertiary">
32
32
  {trackedChanges.pendingChangeIds.length} active · {trackedChanges.acceptedChangeIds.length} accepted · {trackedChanges.preserveOnlyChangeIds.length} preserve-only
33
33
  </p>
34
34
 
35
35
  {/* Bulk actions */}
36
- <div className="flex gap-1.5 mb-3">
36
+ <div className="mb-2 flex gap-1">
37
37
  <button
38
38
  type="button"
39
39
  disabled={actionablePendingCount === 0}
40
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-semibold text-accent hover:bg-accent-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
40
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-semibold text-accent hover:bg-accent-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
41
41
  onClick={props.onAcceptAllChanges}
42
42
  >
43
43
  Accept all ({actionablePendingCount})
@@ -45,7 +45,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
45
45
  <button
46
46
  type="button"
47
47
  disabled={actionablePendingCount === 0}
48
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs text-secondary hover:bg-surface transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
48
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] text-secondary hover:bg-surface transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
49
49
  onClick={props.onRejectAllChanges}
50
50
  >
51
51
  Reject all
@@ -76,14 +76,14 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
76
76
  : rev.kind === "deletion" ? "bg-danger"
77
77
  : "bg-tertiary"
78
78
  }`} />
79
- <div className="p-2.5 flex-1 min-w-0">
80
- <div className="flex items-start justify-between gap-2 mb-1">
81
- <span className="text-sm font-medium text-primary">{rev.anchorLabel}</span>
79
+ <div className="p-2 flex-1 min-w-0">
80
+ <div className="mb-0.5 flex items-start justify-between gap-2">
81
+ <span className="text-[11px] font-medium text-primary">{rev.anchorLabel}</span>
82
82
  <RevisionBadge status={rev.status} actionability={rev.actionability} />
83
83
  </div>
84
- <p className="text-xs text-tertiary mb-1">{rev.authorId} · {rev.createdAt}</p>
84
+ <p className="mb-1 text-[10px] text-tertiary">{rev.authorId} · {rev.createdAt}</p>
85
85
  {rev.excerpt ? (
86
- <p className={`text-sm ${
86
+ <p className={`text-[11px] ${
87
87
  rev.kind === "insertion" ? "text-insert"
88
88
  : rev.kind === "deletion" ? "text-danger line-through"
89
89
  : "text-secondary"
@@ -91,18 +91,18 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
91
91
  {rev.excerpt}
92
92
  </p>
93
93
  ) : (
94
- <p className="text-sm text-secondary">{rev.label}</p>
94
+ <p className="text-[11px] text-secondary">{rev.label}</p>
95
95
  )}
96
96
  {rev.detail ? (
97
- <p className="text-xs text-secondary mt-1">{rev.detail}</p>
97
+ <p className="mt-1 text-[10px] text-secondary">{rev.detail}</p>
98
98
  ) : null}
99
- <div className="flex gap-1.5 mt-2">
99
+ <div className="mt-1.5 flex gap-1">
100
100
  {rev.actionability === "actionable" ? (
101
101
  <>
102
102
  <button
103
103
  type="button"
104
104
  disabled={!rev.canAccept || rev.status === "accepted"}
105
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-insert hover:bg-insert-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
105
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-insert hover:bg-insert-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
106
106
  onClick={(e) => {
107
107
  e.stopPropagation();
108
108
  props.onAcceptRevision?.(rev.revisionId);
@@ -113,7 +113,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
113
113
  <button
114
114
  type="button"
115
115
  disabled={!rev.canReject || rev.status === "rejected"}
116
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-danger hover:bg-delete-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
116
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-danger hover:bg-delete-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
117
117
  onClick={(e) => {
118
118
  e.stopPropagation();
119
119
  props.onRejectRevision?.(rev.revisionId);
@@ -123,7 +123,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
123
123
  </button>
124
124
  </>
125
125
  ) : (
126
- <span className="text-xs text-tertiary px-2 py-1">Preserve-only</span>
126
+ <span className="px-1.5 py-0.5 text-[10px] text-tertiary">Preserve-only</span>
127
127
  )}
128
128
  </div>
129
129
  </div>
@@ -266,6 +266,10 @@
266
266
  margin: 0 0 0.5em 0;
267
267
  }
268
268
 
269
+ .prosemirror-surface .ProseMirror p[data-numbered="true"][data-list-continuation="true"] {
270
+ margin-bottom: 0.25em;
271
+ }
272
+
269
273
  .prosemirror-surface .ProseMirror [data-node-type="opaque_block"] {
270
274
  user-select: none;
271
275
  cursor: default;