@beyondwork/docx-react-component 1.0.19 → 1.0.21

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 (71) hide show
  1. package/package.json +44 -25
  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 +915 -1314
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1448 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +55 -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/workflow-surface-blocked-rails.ts +94 -0
  46. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  47. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  48. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  49. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  52. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  53. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  54. package/src/ui-tailwind/editor-surface/pm-decorations.ts +237 -0
  55. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  56. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  57. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  58. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +55 -0
  59. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +190 -48
  61. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  62. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  63. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  64. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  65. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  66. package/src/ui-tailwind/theme/editor-theme.css +130 -0
  67. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  68. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  69. package/src/validation/compatibility-engine.ts +27 -4
  70. package/src/validation/compatibility-report.ts +1 -0
  71. package/src/validation/docx-comment-proof.ts +220 -0
@@ -16,6 +16,9 @@ import type {
16
16
  SearchOptions,
17
17
  SearchResultSnapshot,
18
18
  SelectionSnapshot,
19
+ WorkflowBlockedCommandReason,
20
+ WorkflowCandidateRange,
21
+ WorkflowScope,
19
22
  } from "../../api/public-types";
20
23
  import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
21
24
  import { searchDocument } from "../../runtime/document-search.ts";
@@ -38,7 +41,12 @@ import {
38
41
  } from "./pm-command-bridge";
39
42
  import { buildDecorations } from "./pm-decorations";
40
43
  import { createContextualInteractionPlugin } from "./pm-contextual-ui";
41
- import { finishPerfProbe, startPerfProbe } from "./perf-probe";
44
+ import {
45
+ finishPerfProbe,
46
+ incrementInvalidationCounter,
47
+ recordPerfSample,
48
+ startPerfProbe,
49
+ } from "./perf-probe";
42
50
  import type { PositionMap } from "./pm-position-map";
43
51
  import {
44
52
  clearSearch as clearSearchPlugin,
@@ -47,8 +55,13 @@ import {
47
55
  performSearch,
48
56
  searchPluginKey,
49
57
  } from "./search-plugin";
58
+ import {
59
+ createSurfaceDecorationKey,
60
+ createSurfaceDocumentBuildKey,
61
+ } from "./surface-build-keys";
50
62
  import { tableNodeViews } from "./tw-table-node-view";
51
63
  import type { SelectionToolbarAnchor } from "../../ui/headless/selection-toolbar-model";
64
+ import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
52
65
 
53
66
  /**
54
67
  * Same props interface as the legacy TwEditorSurface — drop-in replacement.
@@ -77,6 +90,10 @@ export interface TwProseMirrorSurfaceProps {
77
90
  onCommentActivated?: (commentId: string) => void;
78
91
  onRevisionActivated?: (revisionId: string) => void;
79
92
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
93
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
94
+ workflowScopes?: readonly WorkflowScope[];
95
+ workflowCandidates?: readonly WorkflowCandidateRange[];
96
+ workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[];
80
97
  }
81
98
 
82
99
  export interface TwProseMirrorSurfaceRef {
@@ -97,6 +114,17 @@ export const TwProseMirrorSurface = forwardRef<
97
114
  onBlur,
98
115
  } = props;
99
116
  const surface = snapshot.surface;
117
+ const mediaPreviewKey = useMemo(
118
+ () =>
119
+ Object.entries(props.mediaPreviews ?? {})
120
+ .sort(([leftId], [rightId]) => leftId.localeCompare(rightId))
121
+ .map(
122
+ ([mediaId, preview]) =>
123
+ `${mediaId}:${preview.widthEmu ?? ""}:${preview.heightEmu ?? ""}:${preview.src}`,
124
+ )
125
+ .join("|"),
126
+ [props.mediaPreviews],
127
+ );
100
128
 
101
129
  const canEdit = Boolean(
102
130
  surface && snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError,
@@ -109,7 +137,8 @@ export const TwProseMirrorSurface = forwardRef<
109
137
  const activeSearchRef = useRef<{ query: string; options: SearchOptions } | null>(null);
110
138
  const pendingTypingProbeRef = useRef<string | null>(null);
111
139
  const pendingSelectionProbeRef = useRef<string | null>(null);
112
- const surfaceBuildKeyRef = useRef<string | null>(null);
140
+ const documentBuildKeyRef = useRef<string | null>(null);
141
+ const decorationBuildKeyRef = useRef<string | null>(null);
113
142
  const suppressSelectionEchoRef = useRef(false);
114
143
  const selectionToolbarFrameRef = useRef<number | null>(null);
115
144
  const lastSelectionToolbarMeasurementRef = useRef<{
@@ -158,6 +187,38 @@ export const TwProseMirrorSurface = forwardRef<
158
187
  () => createRevisionDecorationModel(snapshot.trackedChanges, props.activeRevisionId),
159
188
  [snapshot.trackedChanges, props.activeRevisionId],
160
189
  );
190
+ const documentBuildKey = useMemo(
191
+ () =>
192
+ createSurfaceDocumentBuildKey({
193
+ surface,
194
+ activeStory: snapshot.activeStory,
195
+ mediaPreviewKey,
196
+ }),
197
+ [mediaPreviewKey, snapshot.activeStory, surface],
198
+ );
199
+ const decorationBuildKey = useMemo(
200
+ () =>
201
+ createSurfaceDecorationKey({
202
+ markupDisplay,
203
+ showTrackedChanges,
204
+ canEdit,
205
+ activeCommentId: snapshot.comments.activeCommentId,
206
+ activeRevisionId: props.activeRevisionId,
207
+ workflowScopeSignature: JSON.stringify(props.workflowScopes ?? []),
208
+ workflowCandidateSignature: JSON.stringify(props.workflowCandidates ?? []),
209
+ workflowBlockedSignature: JSON.stringify(props.workflowBlockedReasons ?? []),
210
+ }),
211
+ [
212
+ canEdit,
213
+ markupDisplay,
214
+ props.activeRevisionId,
215
+ props.workflowCandidates,
216
+ props.workflowBlockedReasons,
217
+ props.workflowScopes,
218
+ showTrackedChanges,
219
+ snapshot.comments.activeCommentId,
220
+ ],
221
+ );
161
222
 
162
223
  // Create PM plugins (stable across renders — callbacks accessed via ref)
163
224
  const plugins = useMemo(() => {
@@ -174,6 +235,8 @@ export const TwProseMirrorSurface = forwardRef<
174
235
  onRedo: () => callbacksRef.current?.onRedo(),
175
236
  onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
176
237
  getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
238
+ isSelectionSyncSuppressed: () =>
239
+ callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
177
240
  }),
178
241
  createContextualInteractionPlugin({
179
242
  onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
@@ -183,37 +246,48 @@ export const TwProseMirrorSurface = forwardRef<
183
246
  ];
184
247
  }, [props.onCommentActivated, props.onRevisionActivated]);
185
248
 
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
- }
249
+ const applyDecorationProps = useCallback(
250
+ (view: EditorView, positionMap: PositionMap): void => {
205
251
  const decorations = buildDecorations(
206
- viewRef.current.state.doc,
252
+ view.state.doc,
207
253
  positionMap,
208
254
  commentModel,
209
255
  revisionModel,
210
256
  markupDisplay,
211
257
  showTrackedChanges,
258
+ props.workflowScopes,
259
+ snapshot.activeStory,
260
+ props.workflowCandidates,
261
+ props.workflowBlockedReasons,
212
262
  );
213
- viewRef.current.setProps({
263
+ view.setProps({
214
264
  editable: () => canEdit,
215
265
  decorations: () => decorations,
216
266
  });
267
+ decorationBuildKeyRef.current = decorationBuildKey;
268
+ recordPerfSample("pm.decorations");
269
+ incrementInvalidationCounter("pm.laneB.decorationUpdates");
270
+ },
271
+ [
272
+ canEdit,
273
+ commentModel,
274
+ decorationBuildKey,
275
+ markupDisplay,
276
+ revisionModel,
277
+ showTrackedChanges,
278
+ props.workflowBlockedReasons,
279
+ props.workflowCandidates,
280
+ props.workflowScopes,
281
+ props.workflowCandidates,
282
+ props.workflowBlockedReasons,
283
+ ],
284
+ );
285
+
286
+ // Create or update the PM document only when the structural key changes.
287
+ useEffect(() => {
288
+ if (!mountRef.current || !surface) return;
289
+
290
+ if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
217
291
  return;
218
292
  }
219
293
 
@@ -221,9 +295,9 @@ export const TwProseMirrorSurface = forwardRef<
221
295
  surface,
222
296
  snapshot.selection,
223
297
  plugins,
298
+ props.mediaPreviews,
224
299
  );
225
300
  positionMapRef.current = positionMap;
226
-
227
301
  const decorations = buildDecorations(
228
302
  state.doc,
229
303
  positionMap,
@@ -231,7 +305,13 @@ export const TwProseMirrorSurface = forwardRef<
231
305
  revisionModel,
232
306
  markupDisplay,
233
307
  showTrackedChanges,
308
+ props.workflowScopes,
309
+ snapshot.activeStory,
310
+ props.workflowCandidates,
311
+ props.workflowBlockedReasons,
234
312
  );
313
+ recordPerfSample("pm.rebuild");
314
+ incrementInvalidationCounter("pm.laneA.rebuilds");
235
315
 
236
316
  if (!viewRef.current) {
237
317
  // First time surface is available — create the EditorView
@@ -246,19 +326,16 @@ export const TwProseMirrorSurface = forwardRef<
246
326
  },
247
327
  });
248
328
  viewRef.current = view;
329
+ recordPerfSample("pm.mount");
249
330
  } else {
250
- // View exists — update state and decorations
251
- viewRef.current.setProps({
252
- editable: () => canEdit,
253
- decorations: () => decorations,
254
- });
255
331
  suppressSelectionEchoRef.current = true;
256
332
  viewRef.current.updateState(state);
257
333
  queueMicrotask(() => {
258
334
  suppressSelectionEchoRef.current = false;
259
335
  });
260
336
  }
261
- surfaceBuildKeyRef.current = surfaceBuildKey;
337
+ documentBuildKeyRef.current = documentBuildKey;
338
+ applyDecorationProps(viewRef.current, positionMap);
262
339
 
263
340
  if (activeSearchRef.current) {
264
341
  applySearch(
@@ -271,16 +348,29 @@ export const TwProseMirrorSurface = forwardRef<
271
348
  pendingTypingProbeRef.current = null;
272
349
  }
273
350
  }, [
274
- snapshot.activeStory,
275
- snapshot.revisionToken,
351
+ applyDecorationProps,
352
+ documentBuildKey,
276
353
  surface,
277
- commentModel,
278
- revisionModel,
279
- markupDisplay,
280
- canEdit,
281
- showTrackedChanges,
354
+ snapshot.selection,
355
+ plugins,
356
+ props.mediaPreviews,
282
357
  ]);
283
358
 
359
+ // Update decorations and editability without rebuilding the PM document.
360
+ useEffect(() => {
361
+ const view = viewRef.current;
362
+ const positionMap = positionMapRef.current;
363
+ if (!view || !surface || !positionMap) {
364
+ return;
365
+ }
366
+
367
+ if (decorationBuildKeyRef.current === decorationBuildKey) {
368
+ return;
369
+ }
370
+
371
+ applyDecorationProps(view, positionMap);
372
+ }, [applyDecorationProps, decorationBuildKey, surface]);
373
+
284
374
  useEffect(() => {
285
375
  const view = viewRef.current;
286
376
  const positionMap = positionMapRef.current;
@@ -299,6 +389,7 @@ export const TwProseMirrorSurface = forwardRef<
299
389
 
300
390
  suppressSelectionEchoRef.current = true;
301
391
  view.dispatch(view.state.tr.setSelection(nextSelection));
392
+ recordPerfSample("selection.sync");
302
393
  queueMicrotask(() => {
303
394
  suppressSelectionEchoRef.current = false;
304
395
  });
@@ -356,11 +447,41 @@ export const TwProseMirrorSurface = forwardRef<
356
447
 
357
448
  function applySearch(query: string, options: SearchOptions): SearchResultSnapshot[] {
358
449
  const view = viewRef.current;
450
+ const hiddenDeletionRanges =
451
+ markupDisplay === "clean"
452
+ ? snapshot.trackedChanges.revisions
453
+ .filter(
454
+ (
455
+ revision,
456
+ ): revision is typeof revision & {
457
+ anchor: Extract<typeof revision.anchor, { kind: "range" }>;
458
+ } =>
459
+ revision.kind === "deletion" &&
460
+ revision.status === "active" &&
461
+ revision.anchor.kind === "range",
462
+ )
463
+ .map((revision) => ({
464
+ from: revision.anchor.from,
465
+ to: revision.anchor.to,
466
+ }))
467
+ : [];
359
468
  if (view) {
360
- const rawResults = performSearch(view.state, query, options).slice(
361
- 0,
362
- options.limit ?? Number.POSITIVE_INFINITY,
363
- );
469
+ const rawResults = performSearch(view.state, query, options)
470
+ .filter((result) => {
471
+ if (hiddenDeletionRanges.length === 0) {
472
+ return true;
473
+ }
474
+ const positionMap = positionMapRef.current;
475
+ if (!positionMap) {
476
+ return true;
477
+ }
478
+ const runtimeFrom = positionMap.pmToRuntime(result.from);
479
+ const runtimeTo = positionMap.pmToRuntime(result.to);
480
+ return !hiddenDeletionRanges.some(
481
+ (range) => runtimeFrom < range.to && runtimeTo > range.from,
482
+ );
483
+ })
484
+ .slice(0, options.limit ?? Number.POSITIVE_INFINITY);
364
485
  view.dispatch(
365
486
  view.state.tr.setMeta(searchPluginKey, {
366
487
  results: rawResults,
@@ -369,16 +490,37 @@ export const TwProseMirrorSurface = forwardRef<
369
490
  );
370
491
  }
371
492
 
372
- return searchDocument(
373
- props.canonicalDocument,
374
- snapshot.selection,
375
- snapshot.activeStory,
376
- props.documentNavigation,
377
- query,
378
- options,
493
+ return filterHiddenDeletionSearchResults(
494
+ searchDocument(
495
+ props.canonicalDocument,
496
+ snapshot.selection,
497
+ snapshot.activeStory,
498
+ props.documentNavigation,
499
+ query,
500
+ options,
501
+ ),
502
+ hiddenDeletionRanges,
379
503
  );
380
504
  }
381
505
 
506
+ function filterHiddenDeletionSearchResults(
507
+ results: SearchResultSnapshot[],
508
+ hiddenRanges: Array<{ from: number; to: number }>,
509
+ ): SearchResultSnapshot[] {
510
+ if (hiddenRanges.length === 0) {
511
+ return results;
512
+ }
513
+ return results.filter((result) => {
514
+ const anchor = result.anchor;
515
+ if (anchor.kind !== "range") {
516
+ return true;
517
+ }
518
+ return !hiddenRanges.some(
519
+ (range) => anchor.from < range.to && anchor.to > range.from,
520
+ );
521
+ });
522
+ }
523
+
382
524
  function clearLiveSearch(): void {
383
525
  const view = viewRef.current;
384
526
  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>