@beyondwork/docx-react-component 1.0.80 → 1.0.81

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.80",
4
+ "version": "1.0.81",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -5611,17 +5611,15 @@ export interface WordReviewEditorProps {
5611
5611
  import("../ui/headless/chrome-registry").SelectionToolRegistryEntry
5612
5612
  >;
5613
5613
  /**
5614
- * Phase C.γ host-callback bag. When supplied, the workspace mounts
5615
- * `TwWorkspaceChromeHost` which in turn mounts the right-click
5616
- * context menu (`TwContextMenuPortal`) and the Ctrl/Cmd+K command
5617
- * palette (`TwCommandPaletteMount`), dispatching through the shared
5618
- * `editorActionRegistry`. Actions without a wired callback are
5619
- * hidden from every surface progressive disclosure per
5620
- * `designsystem.md §2.1 principle 4`.
5621
- *
5622
- * Omit to preserve pre-Phase-C behavior (no right-click menu, no
5623
- * palette). Required for the default editor to expose the
5624
- * Phase C.γ surfaces shipped in `5fce913a`.
5614
+ * Optional host-callback extension bag for workspace command chrome.
5615
+ * The default `<WordReviewEditor />` path now mounts
5616
+ * `TwWorkspaceChromeHost` with product-backed commands for formatting,
5617
+ * paragraph/list actions, comments, and table insertion/structure.
5618
+ * Supplying this bag overrides or extends those defaults for host-owned
5619
+ * actions such as custom table properties, hyperlink handling, or
5620
+ * object metadata. Actions without a wired callback are hidden from
5621
+ * every surface — progressive disclosure per `designsystem.md §2.1
5622
+ * principle 4`.
5625
5623
  */
5626
5624
  editorActionHost?: import("../ui-tailwind/chrome/editor-action-registry").EditorActionHostCallbacks;
5627
5625
  /**
@@ -5637,8 +5635,8 @@ export interface WordReviewEditorProps {
5637
5635
  /**
5638
5636
  * Suppress the global Ctrl/Cmd+K palette listener — e.g. when a
5639
5637
  * higher-priority modal captures keyboard focus. Defaults to
5640
- * `false` (palette listener is active when `editorActionHost` is
5641
- * supplied).
5638
+ * `false` (the default product command palette listener is active
5639
+ * unless this prop suppresses it).
5642
5640
  */
5643
5641
  commandPaletteDisabled?: boolean;
5644
5642
  /**
@@ -131,6 +131,7 @@ export interface ChromeCompositionInput {
131
131
  | "simple"
132
132
  | "all";
133
133
  readonly activeRailTab?: EditorRailTab | null;
134
+ readonly railOpen?: boolean;
134
135
  readonly pinnedRailTabs?: ReadonlySet<EditorRailTab>;
135
136
  readonly density?: ChromeDensity;
136
137
  readonly containerWidth?: number;
@@ -255,13 +256,18 @@ function resolveVisibleRailTabs(
255
256
  railOpen: boolean,
256
257
  diagnosticsSignal: DiagnosticsSignal,
257
258
  pinned: ReadonlySet<EditorRailTab>,
259
+ mode: EditorChromeMode,
258
260
  ): ReadonlySet<EditorRailTab> {
259
261
  const visible = new Set<EditorRailTab>();
260
262
  if (railOpen) {
261
263
  visible.add("comments");
262
264
  visible.add("changes");
263
265
  visible.add("workflow");
264
- if (diagnosticsSignal.severity !== "none" || diagnosticsSignal.count > 0) {
266
+ if (
267
+ diagnosticsSignal.severity !== "none" ||
268
+ diagnosticsSignal.count > 0 ||
269
+ mode === "more"
270
+ ) {
265
271
  visible.add("health");
266
272
  }
267
273
  }
@@ -294,11 +300,13 @@ export function resolveChromeComposition(
294
300
  const density: ChromeDensity = input.density ?? "standard";
295
301
  const pinnedRailTabs: ReadonlySet<EditorRailTab> =
296
302
  input.pinnedRailTabs ?? new Set<EditorRailTab>();
297
- const railOpen = options.showReviewRail && visibility.reviewRail;
303
+ const railOpen =
304
+ input.railOpen ?? (options.showReviewRail && visibility.reviewRail);
298
305
  const visibleTabs = resolveVisibleRailTabs(
299
306
  railOpen,
300
307
  diagnosticsSignal,
301
308
  pinnedRailTabs,
309
+ mode,
302
310
  );
303
311
 
304
312
  // Default active tab: honor caller; otherwise land on the mode-appropriate tab.
@@ -186,6 +186,7 @@ import { EditorShellView } from "./editor-shell-view.tsx";
186
186
  import { TwDebugPresentation } from "../ui-tailwind/debug/index.ts";
187
187
  import { shellPasteFragmentParser as SHELL_PASTE_FRAGMENT_PARSER } from "../shell/paste-adapter.ts";
188
188
  import { EditorSurfaceController } from "./editor-surface-controller.tsx";
189
+ import type { EditorActionHostCallbacks } from "../ui-tailwind/chrome/editor-action-registry";
189
190
  import type { TwWorkspaceChromeHostController } from "../ui-tailwind/chrome/tw-workspace-chrome-host";
190
191
  import {
191
192
  resolveChromePreset,
@@ -3306,6 +3307,63 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3306
3307
  },
3307
3308
  });
3308
3309
 
3310
+ const productEditorActionHost = useMemo<EditorActionHostCallbacks>(() => {
3311
+ const runClipboardCommand = (command: "copy" | "cut") => {
3312
+ try {
3313
+ globalThis.document?.execCommand?.(command);
3314
+ } catch {
3315
+ // Browser-native clipboard commands are best-effort fallbacks.
3316
+ }
3317
+ };
3318
+
3319
+ const defaultHost: EditorActionHostCallbacks = {
3320
+ onUndo: commands.onUndo,
3321
+ onRedo: commands.onRedo,
3322
+ onCut: () => runClipboardCommand("cut"),
3323
+ onCopy: () => runClipboardCommand("copy"),
3324
+ onPaste: () => {
3325
+ const readText = globalThis.navigator?.clipboard?.readText;
3326
+ if (typeof readText !== "function") return;
3327
+ void readText.call(globalThis.navigator.clipboard)
3328
+ .then((text: string) => {
3329
+ if (!text) return;
3330
+ dispatchTextCommand(
3331
+ activeRuntime,
3332
+ { type: "insert-text", text },
3333
+ DISPATCH_CONTEXT,
3334
+ );
3335
+ })
3336
+ .catch(() => {
3337
+ // Clipboard permission failures should not break the menu.
3338
+ });
3339
+ },
3340
+ onToggleBold: commands.onToggleBold,
3341
+ onToggleItalic: commands.onToggleItalic,
3342
+ onToggleUnderline: commands.onToggleUnderline,
3343
+ onToggleStrikethrough: commands.onToggleStrikethrough,
3344
+ onToggleBulletedList: commands.onToggleBulletedList,
3345
+ onToggleNumberedList: commands.onToggleNumberedList,
3346
+ onOutdent: commands.onOutdent,
3347
+ onIndent: commands.onIndent,
3348
+ onSetAlignment: (alignment) => commands.onSetAlignment?.(alignment),
3349
+ onInsertTable: commands.onInsertTable,
3350
+ onAddComment: commands.onAddComment,
3351
+ onInsertRowAbove: commands.onAddRowBefore,
3352
+ onInsertRowBelow: commands.onAddRowAfter,
3353
+ onInsertColumnBefore: commands.onAddColumnBefore,
3354
+ onInsertColumnAfter: commands.onAddColumnAfter,
3355
+ onDeleteRow: commands.onDeleteRow,
3356
+ onDeleteColumn: commands.onDeleteColumn,
3357
+ onDeleteTable: commands.onDeleteTable,
3358
+ onMergeCells: commands.onMergeCells,
3359
+ onSplitCell: commands.onSplitCell,
3360
+ };
3361
+
3362
+ return editorActionHost
3363
+ ? { ...defaultHost, ...editorActionHost }
3364
+ : defaultHost;
3365
+ }, [activeRuntime, commands, editorActionHost]);
3366
+
3309
3367
  const harnessShowUnsupportedPreviews = readHarnessDebugPortsFlag(__harnessDebugPorts, "unsupportedObjectPreviews");
3310
3368
  const effectiveShowUnsupportedPreviews = computeEffectiveShowUnsupportedPreviews({
3311
3369
  harnessShowUnsupportedPreviews,
@@ -3342,7 +3400,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3342
3400
  activeWorkflowScopeIds={workflowScopeSnapshot?.activeWorkItem?.scopeIds ?? []}
3343
3401
  workflowMetadata={workflowMarkupSnapshot?.metadata}
3344
3402
  onSelectionToolbarAnchorChange={handleSelectionToolbarAnchorChange}
3345
- {...(editorActionHost ? { onContextMenuRequested: handleContextMenuRequested } : {})}
3403
+ onContextMenuRequested={handleContextMenuRequested}
3346
3404
  {...editorCallbacks}
3347
3405
  dispatchRuntimeCommand={(command) =>
3348
3406
  activeRuntime.applyActiveStoryTextCommand(command as never)
@@ -3420,8 +3478,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3420
3478
  interactionGuardSnapshot={interactionGuardSnapshot}
3421
3479
  chromePreset={effectiveChromePreset}
3422
3480
  chromeOptions={chromeOptions}
3423
- {...(editorActionHost ? { editorActionHost } : {})}
3424
- {...(editorActionHost ? { chromeControllerRef: teedChromeControllerRef } : {})}
3481
+ density={density}
3482
+ editorActionHost={productEditorActionHost}
3483
+ chromeControllerRef={teedChromeControllerRef}
3425
3484
  {...(commandPaletteDisabled !== undefined
3426
3485
  ? { commandPaletteDisabled }
3427
3486
  : {})}
@@ -83,6 +83,7 @@ export interface EditorShellViewProps {
83
83
  interactionGuardSnapshot?: InteractionGuardSnapshot;
84
84
  chromePreset?: WordReviewEditorChromePreset;
85
85
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
86
+ density?: "compact" | "standard" | "comfortable";
86
87
  editorActionHost?: import("../ui-tailwind/chrome/editor-action-registry.ts").EditorActionHostCallbacks;
87
88
  chromeControllerRef?: React.Ref<
88
89
  import("../ui-tailwind/chrome/tw-workspace-chrome-host.tsx").TwWorkspaceChromeHostController
@@ -66,11 +66,30 @@ export interface EditorActionDispatchContext {
66
66
  * `WordReviewEditorRef`; the surface is intentionally minimal now.
67
67
  */
68
68
  export interface EditorActionHostCallbacks {
69
+ // Command history
70
+ readonly onUndo?: () => void;
71
+ readonly onRedo?: () => void;
72
+
69
73
  // Clipboard (always available — browser-native fallback acceptable)
70
74
  readonly onCut?: () => void;
71
75
  readonly onCopy?: () => void;
72
76
  readonly onPaste?: () => void;
73
77
 
78
+ // Formatting / paragraph operations (plain text + table-cell targets)
79
+ readonly onToggleBold?: () => void;
80
+ readonly onToggleItalic?: () => void;
81
+ readonly onToggleUnderline?: () => void;
82
+ readonly onToggleStrikethrough?: () => void;
83
+ readonly onToggleBulletedList?: () => void;
84
+ readonly onToggleNumberedList?: () => void;
85
+ readonly onOutdent?: () => void;
86
+ readonly onIndent?: () => void;
87
+ readonly onSetAlignment?: (
88
+ alignment: "left" | "center" | "right" | "justify",
89
+ ) => void;
90
+ readonly onInsertTable?: () => void;
91
+ readonly onAddComment?: () => void;
92
+
74
93
  // Tracked-change operations (suggestion target)
75
94
  readonly onAcceptSuggestion?: () => void;
76
95
  readonly onRejectSuggestion?: () => void;
@@ -87,6 +106,7 @@ export interface EditorActionHostCallbacks {
87
106
  readonly onInsertColumnAfter?: () => void;
88
107
  readonly onDeleteRow?: () => void;
89
108
  readonly onDeleteColumn?: () => void;
109
+ readonly onDeleteTable?: () => void;
90
110
  readonly onMergeCells?: () => void;
91
111
  readonly onSplitCell?: () => void;
92
112
  readonly onOpenTableProperties?: () => void;
@@ -220,6 +240,24 @@ function mkArg<A, K extends keyof EditorActionHostCallbacks>(opts: {
220
240
  }
221
241
 
222
242
  export const EDITOR_ACTION_REGISTRY: readonly EditorAction[] = [
243
+ // -------- History --------
244
+ mk({
245
+ id: "undo",
246
+ label: "Undo",
247
+ shortcut: ["Mod", "Z"],
248
+ group: "misc",
249
+ targetKinds: ["plain-text", "table-cell"],
250
+ callback: "onUndo",
251
+ }),
252
+ mk({
253
+ id: "redo",
254
+ label: "Redo",
255
+ shortcut: ["Mod", "Shift", "Z"],
256
+ group: "misc",
257
+ targetKinds: ["plain-text", "table-cell"],
258
+ callback: "onRedo",
259
+ }),
260
+
223
261
  // -------- Clipboard (always available across every target kind) --------
224
262
  mk({
225
263
  id: "cut",
@@ -271,6 +309,113 @@ export const EDITOR_ACTION_REGISTRY: readonly EditorAction[] = [
271
309
  callback: "onPaste",
272
310
  }),
273
311
 
312
+ // -------- Formatting / paragraph --------
313
+ mk({
314
+ id: "format-bold",
315
+ label: "Bold",
316
+ shortcut: ["Mod", "B"],
317
+ group: "formatting",
318
+ targetKinds: ["plain-text", "table-cell"],
319
+ callback: "onToggleBold",
320
+ }),
321
+ mk({
322
+ id: "format-italic",
323
+ label: "Italic",
324
+ shortcut: ["Mod", "I"],
325
+ group: "formatting",
326
+ targetKinds: ["plain-text", "table-cell"],
327
+ callback: "onToggleItalic",
328
+ }),
329
+ mk({
330
+ id: "format-underline",
331
+ label: "Underline",
332
+ shortcut: ["Mod", "U"],
333
+ group: "formatting",
334
+ targetKinds: ["plain-text", "table-cell"],
335
+ callback: "onToggleUnderline",
336
+ }),
337
+ mk({
338
+ id: "format-strikethrough",
339
+ label: "Strikethrough",
340
+ group: "formatting",
341
+ targetKinds: ["plain-text", "table-cell"],
342
+ callback: "onToggleStrikethrough",
343
+ }),
344
+ mk({
345
+ id: "list-bulleted",
346
+ label: "Bulleted list",
347
+ group: "formatting",
348
+ targetKinds: ["plain-text", "table-cell"],
349
+ callback: "onToggleBulletedList",
350
+ }),
351
+ mk({
352
+ id: "list-numbered",
353
+ label: "Numbered list",
354
+ group: "formatting",
355
+ targetKinds: ["plain-text", "table-cell"],
356
+ callback: "onToggleNumberedList",
357
+ }),
358
+ mk({
359
+ id: "paragraph-outdent",
360
+ label: "Decrease indent",
361
+ group: "formatting",
362
+ targetKinds: ["plain-text", "table-cell"],
363
+ callback: "onOutdent",
364
+ }),
365
+ mk({
366
+ id: "paragraph-indent",
367
+ label: "Increase indent",
368
+ group: "formatting",
369
+ targetKinds: ["plain-text", "table-cell"],
370
+ callback: "onIndent",
371
+ }),
372
+ mkArg<"left" | "center" | "right" | "justify", "onSetAlignment">({
373
+ id: "align-left",
374
+ label: "Align left",
375
+ group: "formatting",
376
+ targetKinds: ["plain-text", "table-cell"],
377
+ callback: "onSetAlignment",
378
+ payload: "left",
379
+ }),
380
+ mkArg<"left" | "center" | "right" | "justify", "onSetAlignment">({
381
+ id: "align-center",
382
+ label: "Align center",
383
+ group: "formatting",
384
+ targetKinds: ["plain-text", "table-cell"],
385
+ callback: "onSetAlignment",
386
+ payload: "center",
387
+ }),
388
+ mkArg<"left" | "center" | "right" | "justify", "onSetAlignment">({
389
+ id: "align-right",
390
+ label: "Align right",
391
+ group: "formatting",
392
+ targetKinds: ["plain-text", "table-cell"],
393
+ callback: "onSetAlignment",
394
+ payload: "right",
395
+ }),
396
+ mkArg<"left" | "center" | "right" | "justify", "onSetAlignment">({
397
+ id: "align-justify",
398
+ label: "Justify",
399
+ group: "formatting",
400
+ targetKinds: ["plain-text", "table-cell"],
401
+ callback: "onSetAlignment",
402
+ payload: "justify",
403
+ }),
404
+ mk({
405
+ id: "insert-table",
406
+ label: "Insert table",
407
+ group: "table",
408
+ targetKinds: ["plain-text"],
409
+ callback: "onInsertTable",
410
+ }),
411
+ mk({
412
+ id: "comment-add",
413
+ label: "Add comment",
414
+ group: "comment",
415
+ targetKinds: ["plain-text", "table-cell"],
416
+ callback: "onAddComment",
417
+ }),
418
+
274
419
  // -------- Suggestion / tracked change --------
275
420
  mk({
276
421
  id: "accept-suggestion",
@@ -379,6 +524,14 @@ export const EDITOR_ACTION_REGISTRY: readonly EditorAction[] = [
379
524
  targetKinds: ["table-column", "table-whole"],
380
525
  callback: "onDeleteColumn",
381
526
  }),
527
+ // ── Whole-table destructive op ──
528
+ mk({
529
+ id: "table-delete-table",
530
+ label: "Delete table",
531
+ group: "table",
532
+ targetKinds: ["table-whole"],
533
+ callback: "onDeleteTable",
534
+ }),
382
535
  // ── Whole-table — properties / borders surface at any tier so
383
536
  // they are reachable from a single right-click in a cell ──
384
537
  mk({
@@ -75,6 +75,7 @@ export interface TwSelectionToolHostProps {
75
75
  onDistributeColumnsEvenly?: () => void;
76
76
  onSetTableAlignment?: (alignment: "left" | "center" | "right") => void;
77
77
  onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
78
+ onOpenTableMore?: (coords: { clientX: number; clientY: number }) => void;
78
79
  }
79
80
 
80
81
  /**
@@ -299,6 +300,7 @@ function renderTool(
299
300
  onDistributeColumnsEvenly={props.onDistributeColumnsEvenly}
300
301
  onSetTableAlignment={props.onSetTableAlignment}
301
302
  onSetCellVerticalAlign={props.onSetCellVerticalAlign}
303
+ onOpenTableMore={props.onOpenTableMore}
302
304
  />
303
305
  );
304
306
  case "comment-thread":
@@ -35,6 +35,7 @@ export interface TwSelectionToolStructureProps {
35
35
  onDistributeColumnsEvenly?: () => void;
36
36
  onSetTableAlignment?: (alignment: "left" | "center" | "right") => void;
37
37
  onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
38
+ onOpenTableMore?: (coords: { clientX: number; clientY: number }) => void;
38
39
  }
39
40
 
40
41
  export function TwSelectionToolStructure(props: TwSelectionToolStructureProps) {
@@ -58,7 +59,10 @@ export function TwSelectionToolStructure(props: TwSelectionToolStructureProps) {
58
59
  return (
59
60
  <TwTableContextToolbar
60
61
  disabled={!props.model.canMutate}
61
- tableContext={props.model.activeTable ?? null} tableStyles={props.model.tableStyles ?? []}
62
+ tableContext={props.model.activeTable ?? null}
63
+ tableStyles={props.model.tableStyles ?? []}
64
+ compact={true}
65
+ onOpenMore={props.onOpenTableMore}
62
66
  onSetTableStyle={props.onSetTableStyle}
63
67
  onAddRowBefore={props.onAddRowBefore}
64
68
  onAddRowAfter={props.onAddRowAfter}
@@ -58,6 +58,18 @@ function tableContextToKinds(
58
58
  }
59
59
  }
60
60
 
61
+ function mergeTableTierKinds(
62
+ kinds: readonly TargetKind[],
63
+ tableContext: TableStructureContextSnapshot | null | undefined,
64
+ ): readonly TargetKind[] {
65
+ if (!kinds.includes("table-cell") || !tableContext) return kinds;
66
+ const merged = [...kinds];
67
+ for (const k of tableContextToKinds(tableContext)) {
68
+ if (!merged.includes(k)) merged.push(k);
69
+ }
70
+ return merged;
71
+ }
72
+
61
73
  export interface ContextMenuRequest {
62
74
  readonly clientX: number;
63
75
  readonly clientY: number;
@@ -149,14 +161,7 @@ export function useContextMenuController(
149
161
  // is in a table cell. Cell-level ops carry every table-* kind
150
162
  // (so they are still visible) and tier-only ops (delete row /
151
163
  // delete column) gate on the matching kind.
152
- const tierKinds =
153
- baseKinds.includes("table-cell") && tableContext
154
- ? tableContextToKinds(tableContext)
155
- : [];
156
- const merged = [...baseKinds];
157
- for (const k of tierKinds) {
158
- if (!merged.includes(k)) merged.push(k);
159
- }
164
+ const merged = mergeTableTierKinds(baseKinds, tableContext);
160
165
  const entries = buildEntries(merged);
161
166
  // Progressive disclosure: if no actions resolve (every host callback
162
167
  // absent + no scope-anchor + etc), don't open an empty menu. Let
@@ -180,7 +185,7 @@ export function useContextMenuController(
180
185
  clientY: number;
181
186
  kinds: readonly TargetKind[];
182
187
  }) => {
183
- const entries = buildEntries(args.kinds);
188
+ const entries = buildEntries(mergeTableTierKinds(args.kinds, tableContext));
184
189
  if (entries.length === 0) return;
185
190
  setState({
186
191
  open: true,
@@ -189,7 +194,7 @@ export function useContextMenuController(
189
194
  entries,
190
195
  });
191
196
  },
192
- [buildEntries],
197
+ [buildEntries, tableContext],
193
198
  );
194
199
 
195
200
  // D.5.Nit1 — the return value is consumed directly by the caller
@@ -150,6 +150,7 @@ export interface TwReviewWorkspaceProps {
150
150
  interactionGuardSnapshot?: InteractionGuardSnapshot;
151
151
  chromePreset?: WordReviewEditorChromePreset;
152
152
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
153
+ density?: "compact" | "standard" | "comfortable";
153
154
  /** P9g — live collab session for the `"collab"` chrome preset's top nav. */
154
155
  collabSession?: import("../../runtime/collab-session.ts").CollabSession;
155
156
  collabTransportStatus?: import("../../api/awareness-identity-types.ts").TransportStatus;
@@ -10,10 +10,18 @@ export type UseWorkspaceCompositionOptions = Pick<
10
10
  ChromeCompositionInput,
11
11
  | "chromePreset"
12
12
  | "chromeOptions"
13
+ | "chromeVisibility"
13
14
  | "reviewMode"
14
15
  | "role"
16
+ | "readOnly"
15
17
  | "markupDisplay"
18
+ | "activeRailTab"
19
+ | "railOpen"
20
+ | "pinnedRailTabs"
21
+ | "density"
22
+ | "containerWidth"
16
23
  | "diagnosticsSignal"
24
+ | "modeOverride"
17
25
  >;
18
26
 
19
27
  /**
@@ -49,11 +57,11 @@ export type UseWorkspaceCompositionOptions = Pick<
49
57
  * downstream. Review M6 (2026-04-22) flagged this as a silent perf
50
58
  * regression risk.
51
59
  *
52
- * `readOnly` is intentionally omitted from the option set: the
53
- * composition seam is independent of read-only posture today; Phase E
54
- * threads it through the interactionGuardSnapshot wiring once the rail
55
- * owns the full posture composition.
56
- */
60
+ * Product-lane note: this hook intentionally accepts the full set of
61
+ * mounted composition signals the resolver already models. Leaving these
62
+ * out makes `resolveChromeComposition()` look canonical in tests while the
63
+ * shipped editor silently falls back to stale defaults.
64
+ */
57
65
  export function useWorkspaceComposition(
58
66
  options: UseWorkspaceCompositionOptions,
59
67
  ): ReturnType<typeof resolveChromeComposition> {
@@ -61,10 +69,18 @@ export function useWorkspaceComposition(
61
69
  const {
62
70
  chromePreset,
63
71
  chromeOptions,
72
+ chromeVisibility,
64
73
  reviewMode,
65
74
  role,
75
+ readOnly,
66
76
  markupDisplay,
77
+ activeRailTab,
78
+ railOpen,
79
+ pinnedRailTabs,
80
+ density,
81
+ containerWidth,
67
82
  diagnosticsSignal,
83
+ modeOverride,
68
84
  } = options;
69
85
 
70
86
  return useMemo(
@@ -72,15 +88,39 @@ export function useWorkspaceComposition(
72
88
  const input: ChromeCompositionInput = {
73
89
  chromePreset,
74
90
  chromeOptions,
91
+ chromeVisibility,
75
92
  reviewMode,
76
93
  role,
94
+ readOnly,
77
95
  markupDisplay,
96
+ activeRailTab,
97
+ railOpen,
98
+ pinnedRailTabs,
99
+ density,
100
+ containerWidth,
78
101
  diagnosticsSignal,
102
+ modeOverride,
79
103
  };
80
104
  return ui
81
105
  ? ui.chrome.getComposition(input)
82
106
  : resolveChromeComposition(input);
83
107
  },
84
- [ui, chromePreset, chromeOptions, reviewMode, role, markupDisplay, diagnosticsSignal],
108
+ [
109
+ ui,
110
+ chromePreset,
111
+ chromeOptions,
112
+ chromeVisibility,
113
+ reviewMode,
114
+ role,
115
+ readOnly,
116
+ markupDisplay,
117
+ activeRailTab,
118
+ railOpen,
119
+ pinnedRailTabs,
120
+ density,
121
+ containerWidth,
122
+ diagnosticsSignal,
123
+ modeOverride,
124
+ ],
85
125
  );
86
126
  }
@@ -43,7 +43,10 @@ import {
43
43
  import { TwContextBand } from "./chrome/tw-context-band";
44
44
  import { TwRoleActionRegion } from "./toolbar/tw-role-action-region";
45
45
  import { LocalSurfaceArbiterContext } from "./chrome/local-surface-arbiter";
46
- import { TwWorkspaceChromeHost } from "./chrome/tw-workspace-chrome-host";
46
+ import {
47
+ TwWorkspaceChromeHost,
48
+ type TwWorkspaceChromeHostController,
49
+ } from "./chrome/tw-workspace-chrome-host";
47
50
  import { TwChromeOverlay, TwPageStackOverlayLayer } from "./chrome-overlay";
48
51
  import { TwFloatingImageLayer } from "./page-stack/tw-floating-image-layer.tsx";
49
52
  import { shouldHidePageBorderForSelection } from "./review-workspace/paragraph-layout.ts";
@@ -85,16 +88,14 @@ export type {
85
88
  import type { EditorRole } from "../api/public-types.ts";
86
89
 
87
90
  // Default shell-header modes for the workspace's default composition.
88
- // Designsystem §6.1 prescribes a 4-mode switcher (edit / review / workflow
89
- // / more). "more" is kept in the layout for parity but marked disabled
90
- // until its handler is defined by refactor/11 Slice 7 + refactor/10
91
- // Slice 5 (Phase Q debug UX). Hosts that want a fully-wired 4-mode set
92
- // supply their own `shellHeader` prop.
91
+ // Designsystem §6.1 prescribes a 4-mode switcher; all four are reachable in
92
+ // the product path. "More" is a shell-owned diagnostics/search posture, so it
93
+ // uses `ChromeCompositionInput.modeOverride` instead of changing runtime role.
93
94
  const DEFAULT_WORKSPACE_SHELL_MODES: readonly ShellHeaderModeOption[] = [
94
95
  { id: "edit", label: "Edit" },
95
96
  { id: "review", label: "Review" },
96
97
  { id: "workflow", label: "Workflow" },
97
- { id: "more", label: "More", disabled: true },
98
+ { id: "more", label: "More" },
98
99
  ];
99
100
 
100
101
  function editorRoleToShellMode(role: EditorRole): ShellHeaderMode {
@@ -121,6 +122,47 @@ function shellModeToEditorRole(mode: ShellHeaderMode): EditorRole | null {
121
122
  }
122
123
  }
123
124
 
125
+ function TwMoreContextBandContent(props: {
126
+ onOpenDiagnostics: () => void;
127
+ onOpenCompatibility: () => void;
128
+ onOpenOutline: () => void;
129
+ onOpenSearch?: () => void;
130
+ }): React.JSX.Element {
131
+ return (
132
+ <div
133
+ className="flex min-w-0 items-center gap-1"
134
+ data-testid="more-context-band-content"
135
+ >
136
+ <ContextBandButton label="Diagnostics" onClick={props.onOpenDiagnostics} />
137
+ <ContextBandButton label="Compatibility" onClick={props.onOpenCompatibility} />
138
+ <ContextBandButton label="Outline" onClick={props.onOpenOutline} />
139
+ <ContextBandButton
140
+ label="Search"
141
+ onClick={props.onOpenSearch}
142
+ disabled={!props.onOpenSearch}
143
+ />
144
+ </div>
145
+ );
146
+ }
147
+
148
+ function ContextBandButton(props: {
149
+ label: string;
150
+ onClick?: () => void;
151
+ disabled?: boolean;
152
+ }): React.JSX.Element {
153
+ return (
154
+ <button
155
+ type="button"
156
+ disabled={props.disabled}
157
+ onMouseDown={preserveEditorSelectionMouseDown}
158
+ onClick={props.onClick}
159
+ className="inline-flex h-7 items-center rounded-[var(--radius-md)] px-2.5 text-xs font-medium text-secondary transition-colors hover:bg-hover focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)] disabled:cursor-not-allowed disabled:opacity-40"
160
+ >
161
+ {props.label}
162
+ </button>
163
+ );
164
+ }
165
+
124
166
  export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
125
167
  const props = {
126
168
  ...inputProps,
@@ -128,6 +170,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
128
170
  } as TwReviewWorkspaceProps & EditorCommandBag;
129
171
  const { snapshot, viewState } = props;
130
172
  const selectionToolbarRootRef = useRef<HTMLDivElement>(null);
173
+ const workspaceChromeControllerRef =
174
+ useRef<TwWorkspaceChromeHostController | null>(null);
175
+ const [shellModeOverride, setShellModeOverride] =
176
+ useState<ShellHeaderMode | null>(null);
131
177
  // P8.11 — body slot wrapping `{props.document}` (the PM surface) + scroll
132
178
  // root ref. The chrome layer's `TwPageStackChromeLayer` needs both to
133
179
  // measure per-page rects and to reparent PM's DOM node across band
@@ -382,18 +428,46 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
382
428
  props.onSetParagraphTabStops,
383
429
  ]);
384
430
 
431
+ const setWorkspaceChromeController = useCallback(
432
+ (instance: TwWorkspaceChromeHostController | null) => {
433
+ workspaceChromeControllerRef.current = instance;
434
+ const ref = props.chromeControllerRef;
435
+ if (!ref) return;
436
+ if (typeof ref === "function") {
437
+ ref(instance);
438
+ return;
439
+ }
440
+ (ref as React.MutableRefObject<TwWorkspaceChromeHostController | null>).current =
441
+ instance;
442
+ },
443
+ [props.chromeControllerRef],
444
+ );
445
+
446
+ const openTableMoreMenu = useCallback(
447
+ (coords: { clientX: number; clientY: number }) => {
448
+ workspaceChromeControllerRef.current?.openWithKinds({
449
+ ...coords,
450
+ kinds: ["table-cell"],
451
+ });
452
+ },
453
+ [],
454
+ );
455
+
385
456
  // Audit §2.4 — the shell header is ALWAYS present in default composition.
386
457
  // When the host does not supply a pre-assembled shell node, fall back to
387
458
  // a default TwShellHeader wired to the workspace's editor-role state so
388
459
  // the mode tabs actually change the active role instead of being
389
- // decorative. The "more" tab is included for layout parity with §6.1
390
- // but disabled until its handler is defined (Phase Q debug UX / Slice 7).
460
+ // decorative. The "more" tab is shell-owned diagnostics/search posture:
461
+ // it drives composition mode via `modeOverride` and does not mutate the
462
+ // runtime editor role.
391
463
  // Host-supplied shells continue to win (back-compat).
392
464
  const defaultShellModes: readonly ShellHeaderModeOption[] =
393
465
  DEFAULT_WORKSPACE_SHELL_MODES;
394
466
  const defaultShellActiveMode: ShellHeaderMode = editorRoleToShellMode(
395
467
  viewState.editorRole,
396
468
  );
469
+ const activeShellMode: ShellHeaderMode =
470
+ shellModeOverride === "more" ? "more" : defaultShellActiveMode;
397
471
  // coord-11 §21 — host-supplied shellHeader wins; otherwise the default
398
472
  // TwShellHeader mounts only when `chromeVisibility.shellHeader === true`
399
473
  // (default for every preset except `selection`). The `selection` preset
@@ -406,8 +480,15 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
406
480
  ? (
407
481
  <TwShellHeader
408
482
  modes={defaultShellModes}
409
- activeMode={defaultShellActiveMode}
483
+ activeMode={activeShellMode}
410
484
  onModeChange={(mode) => {
485
+ if (mode === "more") {
486
+ setShellModeOverride("more");
487
+ setReviewRailOpen(true);
488
+ props.onActiveRailTabChange?.("health");
489
+ return;
490
+ }
491
+ setShellModeOverride(null);
411
492
  const nextRole = shellModeToEditorRole(mode);
412
493
  if (nextRole !== null && nextRole !== viewState.editorRole) {
413
494
  props.onEditorRoleChange?.(nextRole);
@@ -444,10 +525,17 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
444
525
  const composition = useWorkspaceComposition({
445
526
  chromePreset,
446
527
  chromeOptions: props.chromeOptions,
528
+ chromeVisibility: props.chromeVisibility,
447
529
  reviewMode: props.reviewMode,
448
530
  role: viewState.editorRole,
531
+ readOnly: snapshot.readOnly,
449
532
  markupDisplay: props.markupDisplay,
533
+ activeRailTab: props.activeRailTab,
534
+ railOpen: reviewRailOpen,
535
+ density: props.density,
536
+ containerWidth: viewportWidth,
450
537
  diagnosticsSignal,
538
+ modeOverride: shellModeOverride === "more" ? "more" : undefined,
451
539
  });
452
540
  const showHealthRailTab = composition.rail.visibleTabs.has("health");
453
541
 
@@ -481,7 +569,20 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
481
569
  */}
482
570
  {chromeVisibility.toolbar ? (
483
571
  <TwContextBand mode={composition.mode}>
484
- {viewState.editorRole ? (
572
+ {composition.mode === "more" ? (
573
+ <TwMoreContextBandContent
574
+ onOpenDiagnostics={() => {
575
+ setReviewRailOpen(true);
576
+ props.onActiveRailTabChange?.("health");
577
+ }}
578
+ onOpenCompatibility={() => {
579
+ setReviewRailOpen(true);
580
+ props.onActiveRailTabChange?.("health");
581
+ }}
582
+ onOpenOutline={() => setNavOpen(true)}
583
+ onOpenSearch={props.reviewRailFooter?.onSearch}
584
+ />
585
+ ) : viewState.editorRole ? (
485
586
  <TwRoleActionRegion
486
587
  role={viewState.editorRole}
487
588
  policy={scopedChromePolicy}
@@ -574,9 +675,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
574
675
  <TwWorkspaceChromeHost
575
676
  mode={composition.mode}
576
677
  editorActionHost={props.editorActionHost}
577
- {...(props.chromeControllerRef
578
- ? { controllerRef: props.chromeControllerRef }
579
- : {})}
678
+ controllerRef={setWorkspaceChromeController}
580
679
  {...(props.commandPaletteDisabled !== undefined
581
680
  ? { paletteDisabled: props.commandPaletteDisabled }
582
681
  : {})}
@@ -875,6 +974,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
875
974
  onDistributeColumnsEvenly={props.onDistributeColumnsEvenly}
876
975
  onSetTableAlignment={props.onSetTableAlignment}
877
976
  onSetCellVerticalAlign={props.onSetCellVerticalAlign}
977
+ onOpenTableMore={openTableMoreMenu}
878
978
  chromePins={viewState.chromePins}
879
979
  onChromePinChange={props.onChromePinChange}
880
980
  />