@beyondwork/docx-react-component 1.0.71 → 1.0.73

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 (87) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +280 -1
  4. package/src/api/v3/_create.ts +16 -1
  5. package/src/api/v3/_runtime-handle.ts +2 -0
  6. package/src/api/v3/ai/evaluate.ts +113 -0
  7. package/src/api/v3/ai/outline.ts +140 -0
  8. package/src/api/v3/ai/policy.ts +31 -0
  9. package/src/api/v3/ai/replacement.ts +8 -0
  10. package/src/api/v3/ai/review.ts +342 -0
  11. package/src/api/v3/ai/stats.ts +62 -0
  12. package/src/api/v3/runtime/viewport.ts +181 -0
  13. package/src/api/v3/runtime/workflow.ts +114 -1
  14. package/src/api/v3/ui/_types.ts +35 -0
  15. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  16. package/src/api/v3/ui/index.ts +1 -0
  17. package/src/api/v3/ui/viewport.ts +112 -0
  18. package/src/compare/diff-engine.ts +2 -0
  19. package/src/core/commands/formatting-commands.ts +1 -0
  20. package/src/core/commands/table-structure-commands.ts +1 -0
  21. package/src/core/state/editor-state.ts +49 -6
  22. package/src/io/export/serialize-footnotes.ts +6 -0
  23. package/src/io/export/serialize-headers-footers.ts +7 -0
  24. package/src/io/export/serialize-main-document.ts +20 -0
  25. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  26. package/src/io/export/split-review-boundaries.ts +1 -0
  27. package/src/io/normalize/normalize-text.ts +49 -2
  28. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  29. package/src/io/ooxml/parse-main-document.ts +148 -7
  30. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  31. package/src/model/canonical-document.ts +401 -1
  32. package/src/runtime/formatting/formatting-context.ts +2 -1
  33. package/src/runtime/geometry/overlay-rects.ts +7 -10
  34. package/src/runtime/layout/layout-engine-version.ts +278 -1
  35. package/src/runtime/layout/paginated-layout-engine.ts +181 -8
  36. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  37. package/src/runtime/markdown-sanitizer.ts +21 -4
  38. package/src/runtime/render/render-kernel.ts +21 -1
  39. package/src/runtime/scopes/action-validation.ts +30 -4
  40. package/src/runtime/scopes/audit-bundle.ts +8 -0
  41. package/src/runtime/scopes/compiler-service.ts +1 -0
  42. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  43. package/src/runtime/scopes/replacement/apply.ts +50 -3
  44. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  45. package/src/runtime/scopes/semantic-scope-types.ts +27 -0
  46. package/src/runtime/surface-projection.ts +77 -0
  47. package/src/runtime/workflow/coordinator.ts +3 -0
  48. package/src/runtime/workflow/scope-writer.ts +34 -0
  49. package/src/session/export/embedded-reconstitute.ts +37 -3
  50. package/src/session/import/embedded-offload.ts +26 -1
  51. package/src/session/import/loader-types.ts +18 -0
  52. package/src/session/import/loader.ts +2 -0
  53. package/src/shell/media-previews.ts +8 -6
  54. package/src/ui/WordReviewEditor.tsx +1 -0
  55. package/src/ui/editor-surface-controller.tsx +11 -0
  56. package/src/ui/headless/selection-helpers.ts +2 -2
  57. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  58. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  62. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  63. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  64. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  65. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  66. package/src/ui-tailwind/editor-surface/pm-schema.ts +50 -4
  67. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  68. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  69. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  70. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +114 -0
  71. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  72. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  73. package/src/ui-tailwind/index.ts +4 -2
  74. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  75. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +54 -34
  76. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  77. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  78. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  79. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +8 -1
  80. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +11 -1
  81. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  82. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +139 -10
  83. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  84. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  85. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  86. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  87. package/src/ui-tailwind/tw-review-workspace.tsx +22 -14
@@ -4,15 +4,19 @@ import type {
4
4
  SurfaceBlockSnapshot,
5
5
  SurfaceInlineSegment,
6
6
  } from "../../api/public-types.ts";
7
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
7
8
  import {
8
9
  buildMarkerStyle,
9
10
  buildParagraphStyle,
10
11
  buildSegmentStyle,
12
+ computeTabWidthsInPoints,
11
13
  hasStyleEntries,
12
14
  headingClassList,
13
15
  resolveHeadingLevel,
14
16
  } from "../editor-surface/tw-page-block-view.helpers.ts";
15
17
 
18
+ const EMU_PER_PX = 9525;
19
+
16
20
  // ---------------------------------------------------------------------------
17
21
  // TwRegionBlockRenderer (P8.4)
18
22
  //
@@ -37,7 +41,27 @@ import {
37
41
  // Inline segment renderer — mirrors `tw-page-block-view`'s `renderSegment`.
38
42
  // ---------------------------------------------------------------------------
39
43
 
40
- function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
44
+ /**
45
+ * Fallback visual for image segments that cannot resolve real bytes
46
+ * (no preview in `mediaPreviews`, or `seg.state === "missing"`).
47
+ *
48
+ * - `"chip"` (default) — 48×32 gray placeholder. Valuable dev-mode signal;
49
+ * correct for the body renderer.
50
+ * - `"hidden"` — zero-size, `aria-hidden` span. Correct inside header/
51
+ * footer bands, where the chip geometry visibly disrupts layout.
52
+ *
53
+ * The DOM node (with `data-node-type="image"` + `data-state`) is still
54
+ * emitted under `"hidden"` so diagnostics + tests can still see that an
55
+ * image segment was present.
56
+ */
57
+ export type RegionImageFallbackDisplay = "chip" | "hidden";
58
+
59
+ function renderSegment(
60
+ seg: SurfaceInlineSegment,
61
+ mediaPreviews: Record<string, MediaPreviewDescriptor>,
62
+ fallbackDisplay: RegionImageFallbackDisplay,
63
+ tabWidthsPt: Map<string, number>,
64
+ ): React.ReactNode {
41
65
  switch (seg.kind) {
42
66
  case "text": {
43
67
  const style = buildSegmentStyle(seg.marks, seg.markAttrs);
@@ -51,23 +75,83 @@ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
51
75
  </span>
52
76
  );
53
77
  }
54
- case "tab":
78
+ case "tab": {
79
+ const widthPt = tabWidthsPt.get(seg.segmentId);
80
+ const tabStyle: React.CSSProperties =
81
+ typeof widthPt === "number"
82
+ ? { display: "inline-block", width: `${widthPt}pt`, minWidth: "8px" }
83
+ : { display: "inline-block", width: "32px", minWidth: "8px" };
55
84
  return (
56
85
  <span
57
86
  key={seg.segmentId}
58
87
  data-node-type="tab"
59
- style={{ display: "inline-block", width: "32px", minWidth: "8px" }}
88
+ style={tabStyle}
60
89
  >
61
90
  {"\u00A0"}
62
91
  </span>
63
92
  );
93
+ }
64
94
  case "hard_break":
65
95
  return <br key={seg.segmentId} />;
66
- case "image":
96
+ case "image": {
97
+ // §5.1 gap 3 — floating-anchor images are owned by the absolute
98
+ // floating-image overlay (`TwFloatingImageLayer`). Emitting them
99
+ // inline here would double-paint the CCEP header logo on every
100
+ // page. Skip entirely so only the overlay renders them.
101
+ if (seg.anchor?.display === "floating") {
102
+ return null;
103
+ }
104
+ // Mirror body-renderer behavior (`pm-state-from-snapshot.ts` :500+):
105
+ // look up the preview via `seg.mediaId` and render a real <img> when
106
+ // available. Without a preview (or `state === "missing"`), fall back
107
+ // to the placeholder chip — a deliberate signal that the media part
108
+ // exists canonical-side but no bytes have been resolved yet.
109
+ const preview = mediaPreviews[seg.mediaId];
110
+ const widthEmu = preview?.widthEmu ?? seg.anchor?.extent.widthEmu;
111
+ const heightEmu = preview?.heightEmu ?? seg.anchor?.extent.heightEmu;
112
+ if (preview?.src && seg.state !== "missing") {
113
+ const widthPx = widthEmu
114
+ ? Math.max(8, Math.round(widthEmu / EMU_PER_PX))
115
+ : undefined;
116
+ const heightPx = heightEmu
117
+ ? Math.max(8, Math.round(heightEmu / EMU_PER_PX))
118
+ : undefined;
119
+ return (
120
+ <img
121
+ key={seg.segmentId}
122
+ src={preview.src}
123
+ alt={seg.altText ?? ""}
124
+ data-node-type="image"
125
+ data-media-id={seg.mediaId}
126
+ style={{
127
+ display: "inline-block",
128
+ verticalAlign: "middle",
129
+ ...(widthPx ? { width: `${widthPx}px` } : {}),
130
+ ...(heightPx ? { height: `${heightPx}px` } : {}),
131
+ maxWidth: "100%",
132
+ objectFit: "contain",
133
+ }}
134
+ />
135
+ );
136
+ }
137
+ if (fallbackDisplay === "hidden") {
138
+ return (
139
+ <span
140
+ key={seg.segmentId}
141
+ data-node-type="image"
142
+ data-media-id={seg.mediaId}
143
+ data-state={seg.state}
144
+ aria-hidden="true"
145
+ style={{ display: "inline-block", width: 0, height: 0 }}
146
+ />
147
+ );
148
+ }
67
149
  return (
68
150
  <span
69
151
  key={seg.segmentId}
70
152
  data-node-type="image"
153
+ data-media-id={seg.mediaId}
154
+ data-state={seg.state}
71
155
  style={{
72
156
  display: "inline-block",
73
157
  width: "48px",
@@ -80,6 +164,7 @@ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
80
164
  title={seg.altText ?? "Image"}
81
165
  />
82
166
  );
167
+ }
83
168
  case "field_ref":
84
169
  return (
85
170
  <span
@@ -121,8 +206,12 @@ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
121
206
 
122
207
  function RegionParagraph({
123
208
  block,
209
+ mediaPreviews,
210
+ fallbackDisplay,
124
211
  }: {
125
212
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
213
+ mediaPreviews: Record<string, MediaPreviewDescriptor>;
214
+ fallbackDisplay: RegionImageFallbackDisplay;
126
215
  }): React.ReactElement {
127
216
  const headingLevel = resolveHeadingLevel(block.styleId, block.outlineLevel);
128
217
  const classes: string[] = ["leading-relaxed"];
@@ -131,6 +220,7 @@ function RegionParagraph({
131
220
  }
132
221
 
133
222
  const pStyle = buildParagraphStyle(block);
223
+ const tabWidthsPt = computeTabWidthsInPoints(block);
134
224
 
135
225
  // Numbering prefix span — matches tw-page-block-view so region content that
136
226
  // happens to carry numbering (e.g. footnote bodies authored as lists) shows
@@ -195,7 +285,7 @@ function RegionParagraph({
195
285
  <div {...attrs}>
196
286
  {prefixSpan}
197
287
  <span className="pm-paragraph-content">
198
- {block.segments.map((seg) => renderSegment(seg))}
288
+ {block.segments.map((seg) => renderSegment(seg, mediaPreviews, fallbackDisplay, tabWidthsPt))}
199
289
  </span>
200
290
  </div>
201
291
  );
@@ -203,8 +293,12 @@ function RegionParagraph({
203
293
 
204
294
  function RegionTable({
205
295
  block,
296
+ mediaPreviews,
297
+ fallbackDisplay,
206
298
  }: {
207
299
  block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
300
+ mediaPreviews: Record<string, MediaPreviewDescriptor>;
301
+ fallbackDisplay: RegionImageFallbackDisplay;
208
302
  }): React.ReactElement {
209
303
  const tableStyle: React.CSSProperties = {
210
304
  borderCollapse: "collapse",
@@ -273,7 +367,12 @@ function RegionTable({
273
367
  style={Object.keys(cellStyle).length > 0 ? cellStyle : undefined}
274
368
  >
275
369
  {cell.content.map((childBlock) => (
276
- <RegionBlockItem key={childBlock.blockId} block={childBlock} />
370
+ <RegionBlockItem
371
+ key={childBlock.blockId}
372
+ block={childBlock}
373
+ mediaPreviews={mediaPreviews}
374
+ fallbackDisplay={fallbackDisplay}
375
+ />
277
376
  ))}
278
377
  </td>
279
378
  );
@@ -311,14 +410,18 @@ function RegionOpaque({
311
410
 
312
411
  function RegionBlockItem({
313
412
  block,
413
+ mediaPreviews,
414
+ fallbackDisplay,
314
415
  }: {
315
416
  block: SurfaceBlockSnapshot;
417
+ mediaPreviews: Record<string, MediaPreviewDescriptor>;
418
+ fallbackDisplay: RegionImageFallbackDisplay;
316
419
  }): React.ReactElement | null {
317
420
  switch (block.kind) {
318
421
  case "paragraph":
319
- return <RegionParagraph block={block} />;
422
+ return <RegionParagraph block={block} mediaPreviews={mediaPreviews} fallbackDisplay={fallbackDisplay} />;
320
423
  case "table":
321
- return <RegionTable block={block} />;
424
+ return <RegionTable block={block} mediaPreviews={mediaPreviews} fallbackDisplay={fallbackDisplay} />;
322
425
  case "sdt_block":
323
426
  return (
324
427
  <section
@@ -328,7 +431,7 @@ function RegionBlockItem({
328
431
  style={{ margin: "8px 0" }}
329
432
  >
330
433
  {block.children.map((child) => (
331
- <RegionBlockItem key={child.blockId} block={child} />
434
+ <RegionBlockItem key={child.blockId} block={child} mediaPreviews={mediaPreviews} fallbackDisplay={fallbackDisplay} />
332
435
  ))}
333
436
  </section>
334
437
  );
@@ -348,8 +451,26 @@ export interface TwRegionBlockRendererProps {
348
451
  blocks: readonly SurfaceBlockSnapshot[];
349
452
  /** Optional class name applied to the root wrapper. */
350
453
  className?: string;
454
+ /**
455
+ * Media preview catalog. Without it, any `kind: "image"` segment falls
456
+ * back to the 48×32 gray placeholder chip — the pre-existing behavior
457
+ * for the 7-of-8 CCEP docs whose headers carry a logo. Pass the same
458
+ * catalog the body renderer uses (`props.mediaPreviews` on the
459
+ * workspace root) to light up headers + footers + footnote bodies with
460
+ * real image bytes.
461
+ */
462
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
463
+ /**
464
+ * Behavior for image segments that can't resolve real bytes.
465
+ * Defaults to `"chip"` for back-compat with body-renderer parity;
466
+ * pass `"hidden"` inside header/footer bands where the 48×32 chip
467
+ * disrupts band geometry (§5.5 tuning-phase handover).
468
+ */
469
+ fallbackDisplay?: RegionImageFallbackDisplay;
351
470
  }
352
471
 
472
+ const EMPTY_MEDIA_PREVIEWS: Record<string, MediaPreviewDescriptor> = {};
473
+
353
474
  /**
354
475
  * TwRegionBlockRenderer — read-only React renderer for a region's
355
476
  * `SurfaceBlockSnapshot[]`. Used by the header / footer / footnote /
@@ -363,9 +484,12 @@ export interface TwRegionBlockRendererProps {
363
484
  export function TwRegionBlockRenderer({
364
485
  blocks,
365
486
  className,
487
+ mediaPreviews,
488
+ fallbackDisplay = "chip",
366
489
  }: TwRegionBlockRendererProps): React.ReactElement {
367
490
  const rootClasses = ["ProseMirror"];
368
491
  if (className) rootClasses.push(className);
492
+ const previews = mediaPreviews ?? EMPTY_MEDIA_PREVIEWS;
369
493
  return (
370
494
  <div
371
495
  className={rootClasses.join(" ")}
@@ -373,7 +497,12 @@ export function TwRegionBlockRenderer({
373
497
  data-region-block-renderer=""
374
498
  >
375
499
  {blocks.map((block) => (
376
- <RegionBlockItem key={block.blockId} block={block} />
500
+ <RegionBlockItem
501
+ key={block.blockId}
502
+ block={block}
503
+ mediaPreviews={previews}
504
+ fallbackDisplay={fallbackDisplay}
505
+ />
377
506
  ))}
378
507
  </div>
379
508
  );
@@ -5,7 +5,7 @@ import type {
5
5
  CommentBody,
6
6
  CommentMention,
7
7
  } from "../../api/comment-presentation-types";
8
- import { sanitizeMarkdown } from "../../runtime/markdown-sanitizer";
8
+ import { sanitizeMarkdown } from "../../api/public-types";
9
9
 
10
10
  export interface CommentMarkdownRendererProps {
11
11
  body: CommentBody;
@@ -1,10 +1,10 @@
1
1
  import type { CSSProperties } from "react";
2
2
 
3
- import type {
4
- DocumentNavigationSnapshot,
5
- RuntimeRenderSnapshot,
3
+ import {
4
+ type DocumentNavigationSnapshot,
5
+ type RuntimeRenderSnapshot,
6
+ DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
6
7
  } from "../../api/public-types";
7
- import { DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP } from "../../runtime/page-layout-estimation.ts";
8
8
  import { computeLineMarkersIfEnabled } from "../page-chrome-model.ts";
9
9
 
10
10
  export interface PageChromeModel {
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect } from "react";
2
2
  import type { Dispatch, SetStateAction } from "react";
3
3
 
4
- import { createCanvasBackend } from "../../runtime/layout/index.ts";
4
+ import { createCanvasBackend } from "../../api/public-types.ts";
5
5
  import type { RuntimeRenderSnapshot } from "../../api/public-types.ts";
6
6
  import {
7
7
  incrementInvalidationCounter,
@@ -1058,30 +1058,29 @@
1058
1058
  * `data-story-active="header|footer"` and the body PM surface dims to
1059
1059
  * 0.65 so the active band reads as the focal surface.
1060
1060
  */
1061
+ /*
1062
+ * Designsystem §6.20 — inactive = low-contrast tint (color.bg.muted),
1063
+ * active = tint color.accent.soft + border color.border.accent.
1064
+ * The 3px top accent stripe was the prior signal; §6.20 rejects it in
1065
+ * favor of a border/tint-led active state that reads as "a focal frame"
1066
+ * rather than "a colored line draped across the top".
1067
+ */
1061
1068
  .wre-page-band {
1062
- opacity: 0.6;
1063
- transition: opacity var(--motion-fast, 120ms) ease-out;
1069
+ background-color: var(--color-bg-muted);
1070
+ border: 1px solid transparent;
1071
+ transition:
1072
+ background-color var(--motion-fast, 120ms) ease-out,
1073
+ border-color var(--motion-fast, 120ms) ease-out;
1064
1074
  pointer-events: auto;
1065
1075
  }
1066
1076
 
1067
1077
  .wre-page-band:hover {
1068
- opacity: 0.85;
1078
+ background-color: color-mix(in srgb, var(--color-bg-muted) 80%, var(--color-surface));
1069
1079
  }
1070
1080
 
1071
1081
  .wre-page-band[data-active="true"] {
1072
- opacity: 1;
1073
- }
1074
-
1075
- .wre-page-band[data-active="true"]::before {
1076
- content: "";
1077
- position: absolute;
1078
- top: 0;
1079
- left: 0;
1080
- right: 0;
1081
- height: 3px;
1082
- background: var(--color-accent);
1083
- border-radius: 0 0 var(--radius-pill) var(--radius-pill);
1084
- pointer-events: none;
1082
+ background-color: var(--color-accent-soft);
1083
+ border: 1px solid var(--color-border-accent);
1085
1084
  }
1086
1085
 
1087
1086
  .wre-page-band__label {
@@ -351,21 +351,28 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
351
351
  const defaultShellActiveMode: ShellHeaderMode = editorRoleToShellMode(
352
352
  viewState.editorRole,
353
353
  );
354
+ // coord-11 §21 — host-supplied shellHeader wins; otherwise the default
355
+ // TwShellHeader mounts only when `chromeVisibility.shellHeader === true`
356
+ // (default for every preset except `selection`). The `selection` preset
357
+ // is intended for minimal embeds (incl. visual-fidelity `chrome=none`)
358
+ // and must not paint a workspace mode-tab header.
354
359
  const renderedShell =
355
- props.shellHeader !== undefined ? (
356
- props.shellHeader
357
- ) : (
358
- <TwShellHeader
359
- modes={defaultShellModes}
360
- activeMode={defaultShellActiveMode}
361
- onModeChange={(mode) => {
362
- const nextRole = shellModeToEditorRole(mode);
363
- if (nextRole !== null && nextRole !== viewState.editorRole) {
364
- props.onEditorRoleChange?.(nextRole);
365
- }
366
- }}
367
- />
368
- );
360
+ props.shellHeader !== undefined
361
+ ? props.shellHeader
362
+ : chromeVisibility.shellHeader
363
+ ? (
364
+ <TwShellHeader
365
+ modes={defaultShellModes}
366
+ activeMode={defaultShellActiveMode}
367
+ onModeChange={(mode) => {
368
+ const nextRole = shellModeToEditorRole(mode);
369
+ if (nextRole !== null && nextRole !== viewState.editorRole) {
370
+ props.onEditorRoleChange?.(nextRole);
371
+ }
372
+ }}
373
+ />
374
+ )
375
+ : null;
369
376
 
370
377
  // Audit §2.5 — context band mounts as a composition-level sibling of
371
378
  // the toolbar so the workspace row becomes
@@ -1030,6 +1037,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1030
1037
  onOpenStory={props.onOpenStory}
1031
1038
  pmSurfaceElement={pmSurfaceElement}
1032
1039
  visiblePageIndexRange={visiblePageIndexRange}
1040
+ mediaPreviews={props.mediaPreviews}
1033
1041
  />
1034
1042
  ) : null}
1035
1043
  </div>