@beyondwork/docx-react-component 1.0.106 → 1.0.109

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 (190) hide show
  1. package/package.json +19 -5
  2. package/src/api/geometry-overlay-rects.ts +5 -0
  3. package/src/api/package-version.ts +1 -1
  4. package/src/api/page-anchor-id.ts +5 -0
  5. package/src/api/public-types.ts +16 -9
  6. package/src/api/table-node-specs.ts +6 -0
  7. package/src/api/v3/_create.ts +2 -1
  8. package/src/api/v3/_page-anchor-id.ts +52 -0
  9. package/src/api/v3/_runtime-handle.ts +92 -1
  10. package/src/api/v3/ai/_audit-time.ts +5 -0
  11. package/src/api/v3/ai/_pe2-evidence.ts +38 -0
  12. package/src/api/v3/ai/attach.ts +7 -2
  13. package/src/api/v3/ai/replacement.ts +101 -18
  14. package/src/api/v3/ai/resolve.ts +2 -2
  15. package/src/api/v3/ai/review.ts +177 -3
  16. package/src/api/v3/index.ts +1 -0
  17. package/src/api/v3/runtime/collab.ts +462 -0
  18. package/src/api/v3/runtime/document.ts +503 -20
  19. package/src/api/v3/runtime/geometry.ts +97 -0
  20. package/src/api/v3/runtime/layout.ts +744 -0
  21. package/src/api/v3/runtime/perf-probe.ts +14 -0
  22. package/src/api/v3/runtime/viewport.ts +9 -8
  23. package/src/api/v3/ui/_types.ts +149 -55
  24. package/src/api/v3/ui/chrome-preset-model.ts +5 -5
  25. package/src/api/v3/ui/debug.ts +115 -2
  26. package/src/api/v3/ui/index.ts +13 -0
  27. package/src/api/v3/ui/overlays.ts +0 -8
  28. package/src/api/v3/ui/surface.ts +56 -0
  29. package/src/api/v3/ui/viewport.ts +22 -9
  30. package/src/core/commands/image-commands.ts +1 -0
  31. package/src/core/commands/index.ts +6 -0
  32. package/src/core/schema/text-schema.ts +43 -5
  33. package/src/core/selection/mapping.ts +8 -1
  34. package/src/core/selection/review-anchors.ts +5 -1
  35. package/src/core/state/text-transaction.ts +8 -2
  36. package/src/io/export/serialize-revisions.ts +149 -1
  37. package/src/io/normalize/normalize-text.ts +6 -0
  38. package/src/io/ooxml/parse-bookmark-references.ts +55 -0
  39. package/src/io/ooxml/parse-fields.ts +24 -2
  40. package/src/io/ooxml/parse-headers-footers.ts +38 -5
  41. package/src/io/ooxml/parse-main-document.ts +153 -9
  42. package/src/io/ooxml/parse-numbering.ts +20 -0
  43. package/src/io/ooxml/parse-revisions.ts +19 -8
  44. package/src/io/opc/package-reader.ts +98 -8
  45. package/src/model/anchor.ts +4 -3
  46. package/src/model/canonical-document.ts +220 -2
  47. package/src/model/canonical-hash.ts +221 -0
  48. package/src/model/canonical-layout-inputs.ts +245 -6
  49. package/src/model/layout/index.ts +1 -0
  50. package/src/model/layout/page-graph-types.ts +118 -1
  51. package/src/model/review/revision-types.ts +14 -3
  52. package/src/preservation/store.ts +20 -4
  53. package/src/review/README.md +1 -1
  54. package/src/review/store/revision-actions.ts +14 -2
  55. package/src/runtime/collab/event-types.ts +67 -1
  56. package/src/runtime/collab/runtime-collab-sync.ts +177 -5
  57. package/src/runtime/diagnostics/layout-guard-warning.ts +18 -0
  58. package/src/runtime/document-heading-outline.ts +147 -0
  59. package/src/runtime/document-navigation.ts +8 -243
  60. package/src/runtime/document-runtime.ts +240 -97
  61. package/src/runtime/edit-dispatch/dispatch-text-command.ts +11 -0
  62. package/src/runtime/formatting/layout-inputs.ts +38 -5
  63. package/src/runtime/formatting/numbering/geometry.ts +28 -2
  64. package/src/runtime/geometry/adjacent-geometry-intake.ts +835 -0
  65. package/src/runtime/geometry/caret-geometry.ts +5 -6
  66. package/src/runtime/geometry/geometry-facet.ts +60 -10
  67. package/src/runtime/geometry/geometry-index.ts +591 -20
  68. package/src/runtime/geometry/geometry-types.ts +59 -0
  69. package/src/runtime/geometry/hit-test.ts +11 -1
  70. package/src/runtime/geometry/overlay-rects.ts +5 -3
  71. package/src/runtime/geometry/project-anchors.ts +1 -1
  72. package/src/runtime/geometry/word-layout-v2-line-intake.ts +323 -0
  73. package/src/runtime/layout/index.ts +6 -0
  74. package/src/runtime/layout/layout-engine-instance.ts +6 -1
  75. package/src/runtime/layout/layout-engine-version.ts +181 -16
  76. package/src/runtime/layout/layout-facet-types.ts +6 -0
  77. package/src/runtime/layout/page-graph.ts +21 -4
  78. package/src/runtime/layout/paginated-layout-engine.ts +139 -15
  79. package/src/runtime/layout/project-block-fragments.ts +265 -7
  80. package/src/runtime/layout/public-facet.ts +78 -24
  81. package/src/runtime/layout/table-row-continuation-contract.ts +107 -0
  82. package/src/runtime/layout/table-row-split.ts +92 -35
  83. package/src/runtime/prerender/cache-envelope.ts +2 -2
  84. package/src/runtime/prerender/cache-key.ts +5 -4
  85. package/src/runtime/prerender/customxml-cache.ts +0 -1
  86. package/src/runtime/render/render-kernel.ts +1 -1
  87. package/src/runtime/revision-runtime.ts +112 -10
  88. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  89. package/src/runtime/scopes/action-validation.ts +22 -2
  90. package/src/runtime/scopes/capabilities.ts +316 -0
  91. package/src/runtime/scopes/compile-scope-bundle.ts +14 -0
  92. package/src/runtime/scopes/compiler-service.ts +108 -4
  93. package/src/runtime/scopes/content-control-evidence.ts +79 -0
  94. package/src/runtime/scopes/create-issue.ts +5 -5
  95. package/src/runtime/scopes/evidence.ts +91 -0
  96. package/src/runtime/scopes/formatting/apply.ts +2 -0
  97. package/src/runtime/scopes/geometry-evidence.ts +130 -0
  98. package/src/runtime/scopes/index.ts +54 -0
  99. package/src/runtime/scopes/issue-lifecycle.ts +224 -0
  100. package/src/runtime/scopes/layout-evidence.ts +374 -0
  101. package/src/runtime/scopes/multi-paragraph-refusal.ts +37 -0
  102. package/src/runtime/scopes/preservation-boundary.ts +7 -1
  103. package/src/runtime/scopes/replacement/apply.ts +97 -34
  104. package/src/runtime/scopes/scope-kinds/paragraph.ts +108 -12
  105. package/src/runtime/scopes/semantic-scope-types.ts +242 -3
  106. package/src/runtime/scopes/visualization.ts +28 -0
  107. package/src/runtime/surface-projection.ts +44 -5
  108. package/src/runtime/telemetry/perf-probe.ts +216 -0
  109. package/src/runtime/virtualized-rendering.ts +36 -1
  110. package/src/runtime/workflow/ai-issue-lifecycle.ts +253 -0
  111. package/src/runtime/workflow/coordinator.ts +39 -11
  112. package/src/runtime/workflow/derived-scope-resolver.ts +63 -9
  113. package/src/runtime/workflow/index.ts +3 -0
  114. package/src/runtime/workflow/overlay-lane-types.ts +58 -0
  115. package/src/runtime/workflow/overlay-lanes.ts +168 -10
  116. package/src/runtime/workflow/overlay-store.ts +2 -2
  117. package/src/runtime/workflow/redline-posture-calibration.ts +257 -0
  118. package/src/runtime/workflow/word-field-matrix-calibration.ts +231 -0
  119. package/src/session/_sync-legacy.ts +17 -27
  120. package/src/session/import/loader.ts +6 -4
  121. package/src/session/import/source-package-evidence.ts +186 -2
  122. package/src/session/index.ts +5 -6
  123. package/src/session/session.ts +30 -56
  124. package/src/session/types.ts +8 -13
  125. package/src/shell/session-bootstrap.ts +155 -81
  126. package/src/ui/WordReviewEditor.tsx +520 -12
  127. package/src/ui/editor-shell-view.tsx +14 -4
  128. package/src/ui/editor-surface-controller.tsx +5 -3
  129. package/src/ui/headless/selection-tool-resolver.ts +1 -2
  130. package/src/ui/presence-overlay-lane.ts +0 -1
  131. package/src/ui/ui-controller-factory.ts +7 -0
  132. package/src/ui-tailwind/chrome/build-context-menu-entries.ts +5 -1
  133. package/src/ui-tailwind/chrome/editor-action-registry.ts +105 -5
  134. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +7 -0
  135. package/src/ui-tailwind/chrome/layer-debug-contracts.ts +208 -0
  136. package/src/ui-tailwind/chrome/resolve-target-kind.ts +13 -0
  137. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +11 -3
  138. package/src/ui-tailwind/chrome/tw-command-palette.tsx +36 -6
  139. package/src/ui-tailwind/chrome/tw-context-menu.tsx +6 -1
  140. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +42 -109
  141. package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +26 -6
  142. package/src/ui-tailwind/chrome/tw-navigation-command-bar.tsx +328 -0
  143. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +8 -4
  144. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +129 -1
  145. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +19 -5
  146. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +5 -1
  147. package/src/ui-tailwind/chrome/tw-workspace-chrome-host.tsx +28 -12
  148. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +30 -3
  149. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +116 -10
  150. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +223 -94
  151. package/src/ui-tailwind/chrome-overlay/tw-presence-overlay-lane.tsx +157 -0
  152. package/src/ui-tailwind/chrome-overlay/tw-review-overlay-lane-markers.tsx +259 -0
  153. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +5 -2
  154. package/src/ui-tailwind/chrome-overlay/tw-substrate-overlay-lanes.tsx +314 -0
  155. package/src/ui-tailwind/debug/README.md +4 -1
  156. package/src/ui-tailwind/debug/layer11-consumer-readiness.ts +272 -0
  157. package/src/ui-tailwind/debug/layer11-word-field-matrix-evidence.ts +160 -0
  158. package/src/ui-tailwind/editor-surface/perf-probe.ts +14 -215
  159. package/src/ui-tailwind/editor-surface/pm-decorations.ts +42 -0
  160. package/src/ui-tailwind/editor-surface/pm-position-map.ts +38 -2
  161. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -4
  162. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +34 -5
  163. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +9 -19
  164. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -2
  165. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +145 -0
  166. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +16 -11
  167. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +8 -10
  168. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +3 -0
  169. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +4 -2
  170. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +60 -20
  171. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +16 -11
  172. package/src/ui-tailwind/review/tw-health-panel.tsx +36 -17
  173. package/src/ui-tailwind/review/tw-review-rail.tsx +7 -4
  174. package/src/ui-tailwind/review-workspace/diagnostics-visibility.ts +44 -0
  175. package/src/ui-tailwind/review-workspace/page-shell-metrics.ts +11 -0
  176. package/src/ui-tailwind/review-workspace/tw-review-workspace-rail.tsx +16 -1
  177. package/src/ui-tailwind/review-workspace/types.ts +26 -12
  178. package/src/ui-tailwind/review-workspace/use-diagnostics-signal.ts +40 -11
  179. package/src/ui-tailwind/review-workspace/use-layout-facet-render-signal.ts +2 -1
  180. package/src/ui-tailwind/review-workspace/use-page-markers.ts +15 -26
  181. package/src/ui-tailwind/review-workspace/use-scope-card-state.ts +35 -18
  182. package/src/ui-tailwind/review-workspace/use-selection-toolbar-placement.ts +41 -32
  183. package/src/ui-tailwind/review-workspace/use-status-bar-page-facts.ts +2 -1
  184. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +2 -1
  185. package/src/ui-tailwind/status/tw-status-bar.tsx +6 -5
  186. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +52 -80
  187. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +12 -48
  188. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +9 -4
  189. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +328 -361
  190. package/src/ui-tailwind/tw-review-workspace.tsx +152 -286
@@ -1,5 +1,4 @@
1
1
  import React from "react";
2
- import { createPortal } from "react-dom";
3
2
 
4
3
  import * as Popover from "@radix-ui/react-popover";
5
4
  import * as Select from "@radix-ui/react-select";
@@ -71,7 +70,10 @@ import {
71
70
  } from "../../ui/headless/scoped-chrome-policy";
72
71
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
73
72
  import { TwDisplayModeSelector } from "../chrome/tw-display-mode-selector";
74
- import { type MarkupDisplayMode } from "./tw-role-action-region";
73
+ import {
74
+ TwRoleActionRegion,
75
+ type MarkupDisplayMode,
76
+ } from "./tw-role-action-region";
75
77
  import { TwToolbarIconButton } from "./tw-toolbar-icon-button";
76
78
 
77
79
  export interface TwToolbarProps {
@@ -86,9 +88,9 @@ export interface TwToolbarProps {
86
88
  compactMode?: boolean;
87
89
  /**
88
90
  * True when the runtime has an active editable text/paragraph target
89
- * (focused caret or range selection). Formatting, paragraph, list, and
90
- * structural insert controls use this in addition to document-level edit
91
- * capability so top chrome does not advertise commands against no target.
91
+ * (focused caret or range selection). Selection-scoped controls combine
92
+ * this with edit capability so top chrome does not advertise document
93
+ * commands when focus is on chrome or an object frame.
92
94
  */
93
95
  hasEditableSelectionTarget?: boolean;
94
96
  workspaceMode: WorkspaceMode;
@@ -132,7 +134,7 @@ export interface TwToolbarProps {
132
134
  onToggleSidebar?: () => void;
133
135
  onZoomChange?: (level: ZoomLevel) => void;
134
136
  onShowTrackedChangesChange: (show: boolean) => void;
135
- /** Top-toolbar fallback for changing redline/comment display when review context band is not active. */
137
+ /** Top-toolbar control for changing redline/comment display. */
136
138
  onMarkupDisplayChange?: (mode: MarkupDisplayMode) => void;
137
139
  onRestartNumbering?: () => void;
138
140
  onContinueNumbering?: () => void;
@@ -141,13 +143,13 @@ export interface TwToolbarProps {
141
143
 
142
144
  // ───── R1: role-scoped inline action region (spec §6.4) ──────────────
143
145
  /**
144
- * Active editor role. Since Chrome Closure Pass Task 1 (2026-04-22) the
145
- * role-action region renders inside `<TwContextBand>` the toolbar
146
- * itself no longer mounts `<TwRoleActionRegion>`. The `role` prop is
147
- * retained here only so the formatting cluster can defer items owned
148
- * by the role-region via `isChromeItemOwnedByRoleRegion`.
146
+ * Active editor role. Shipping product chrome keeps role switching in
147
+ * the main toolbar's Edit / Review / Workflow toggle; extra
148
+ * context-band rows are not mounted by default.
149
149
  */
150
150
  role?: EditorRole;
151
+ /** Called when the main toolbar role toggle changes product mode. */
152
+ onEditorRoleChange?: (role: EditorRole) => void;
151
153
  /** Review-queue snapshot — consumed only by the view-cluster change-counter chip. */
152
154
  reviewQueue?: ReviewQueueSnapshot;
153
155
  /** Markup display mode for the review role. */
@@ -159,8 +161,7 @@ export interface TwToolbarProps {
159
161
  // Workflow + review role: scope posture
160
162
  onMarkScopePosture?: (posture: ScopeRailPosture) => void;
161
163
  // Review role — inline prev/next + accept/reject stay on the toolbar for
162
- // the view-cluster mount (queue counter chip); batch ops moved with the
163
- // role-region to <TwContextBand> and are threaded there, not here.
164
+ // the view-cluster mount (queue counter chip).
164
165
  onReviewPrev?: () => void;
165
166
  onReviewNext?: () => void;
166
167
  onReviewAccept?: () => void;
@@ -228,6 +229,31 @@ export function getSupportedZoomPresets(): ReadonlyArray<number> {
228
229
  const focusRingClass =
229
230
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
230
231
 
232
+ function ToolbarTextButton(props: {
233
+ ariaLabel: string;
234
+ children: React.ReactNode;
235
+ disabled?: boolean;
236
+ disabledReason?: string;
237
+ onClick?: () => void;
238
+ }): React.JSX.Element {
239
+ const disabledReason =
240
+ props.disabled && props.disabledReason ? props.disabledReason : undefined;
241
+ return (
242
+ <button
243
+ type="button"
244
+ aria-label={props.ariaLabel}
245
+ data-disabled-reason={disabledReason}
246
+ disabled={props.disabled}
247
+ title={disabledReason ? `Not available: ${disabledReason}` : undefined}
248
+ onMouseDown={preserveEditorSelectionMouseDown}
249
+ onClick={props.onClick}
250
+ className={`inline-flex h-6 items-center rounded-md px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
251
+ >
252
+ {props.children}
253
+ </button>
254
+ );
255
+ }
256
+
231
257
  const FONT_FAMILIES = ["Arial", "Times New Roman", "Calibri", "Cambria", "Georgia", "Verdana"];
232
258
  const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36];
233
259
  const TEXT_COLORS = ["#1f1f1f", "#5c5852", "#1660a8", "#50684d", "#9b4f49", "#7b5f32"];
@@ -238,80 +264,19 @@ const HIGHLIGHT_COLORS = [
238
264
  { value: "#e6e0d4", label: "Stone" },
239
265
  { value: null, label: "None" },
240
266
  ] as const;
241
-
242
- function ToolbarPortalMenu(props: {
243
- anchorRef: React.RefObject<HTMLButtonElement | null>;
244
- align?: "start" | "end";
245
- children: React.ReactNode;
246
- className: string;
247
- menuWidthPx: number;
248
- open: boolean;
249
- }): React.ReactPortal | null {
250
- const style = useToolbarPortalPosition({
251
- align: props.align ?? "start",
252
- anchorRef: props.anchorRef,
253
- menuWidthPx: props.menuWidthPx,
254
- open: props.open,
255
- });
256
- const body = props.anchorRef.current?.ownerDocument?.body;
257
- if (!props.open || !body) {
258
- return null;
259
- }
260
- return createPortal(
261
- <div className={props.className} style={style}>
262
- {props.children}
263
- </div>,
264
- body,
265
- );
266
- }
267
-
268
- function useToolbarPortalPosition(input: {
269
- align: "start" | "end";
270
- anchorRef: React.RefObject<HTMLButtonElement | null>;
271
- menuWidthPx: number;
272
- open: boolean;
273
- }): React.CSSProperties {
274
- const [style, setStyle] = React.useState<React.CSSProperties>({
275
- left: 8,
276
- position: "fixed",
277
- top: 8,
278
- zIndex: 50,
279
- });
280
-
281
- React.useLayoutEffect(() => {
282
- if (!input.open) return;
283
- const anchor = input.anchorRef.current;
284
- const ownerWindow = anchor?.ownerDocument?.defaultView;
285
- if (!anchor || !ownerWindow) return;
286
-
287
- const update = () => {
288
- const rect = anchor.getBoundingClientRect();
289
- const viewportWidth = ownerWindow.innerWidth || input.menuWidthPx + 16;
290
- const rawLeft =
291
- input.align === "end" ? rect.right - input.menuWidthPx : rect.left;
292
- const left = Math.min(
293
- Math.max(8, rawLeft),
294
- Math.max(8, viewportWidth - input.menuWidthPx - 8),
295
- );
296
- setStyle({
297
- left,
298
- position: "fixed",
299
- top: Math.max(8, rect.bottom + 8),
300
- zIndex: 50,
301
- });
302
- };
303
-
304
- update();
305
- ownerWindow.addEventListener("resize", update);
306
- ownerWindow.addEventListener("scroll", update, true);
307
- return () => {
308
- ownerWindow.removeEventListener("resize", update);
309
- ownerWindow.removeEventListener("scroll", update, true);
310
- };
311
- }, [input.align, input.anchorRef, input.menuWidthPx, input.open]);
312
-
313
- return style;
314
- }
267
+ const ROLE_TOGGLE_ITEMS: ReadonlyArray<{
268
+ role: EditorRole;
269
+ label: string;
270
+ testId: string;
271
+ }> = [
272
+ { role: "editor", label: "Edit", testId: "toolbar-role-toggle-edit" },
273
+ { role: "review", label: "Review", testId: "toolbar-role-toggle-review" },
274
+ {
275
+ role: "workflow",
276
+ label: "Workflow",
277
+ testId: "toolbar-role-toggle-workflow",
278
+ },
279
+ ];
315
280
 
316
281
  export function TwToolbar(props: TwToolbarProps) {
317
282
  const caps = props.capabilities;
@@ -330,9 +295,11 @@ export function TwToolbar(props: TwToolbarProps) {
330
295
  props.interactionPolicy && props.interactionPolicy.mode !== "edit"
331
296
  ? `Not available in ${props.interactionPolicy.mode} mode.`
332
297
  : "Editing is not available in this document.";
333
- const editDisabledReason = selectionTargetDisabledReason ?? (!baseCanFormatText ? editModeDisabledReason : undefined);
298
+ const editDisabledReason =
299
+ selectionTargetDisabledReason ?? (!baseCanFormatText ? editModeDisabledReason : undefined);
334
300
  const insertDisabledReason =
335
- selectionTargetDisabledReason ?? (!baseCanInsertStructural ? editModeDisabledReason : undefined);
301
+ selectionTargetDisabledReason ??
302
+ (!baseCanInsertStructural ? editModeDisabledReason : undefined);
336
303
  const canEdit = baseCanFormatText && hasEditableSelectionTarget;
337
304
  const canInsertStructural = baseCanInsertStructural && hasEditableSelectionTarget;
338
305
  const canAddComment = props.interactionPolicy?.canAddComment ?? (caps ? caps.canAddComment : false);
@@ -367,10 +334,20 @@ export function TwToolbar(props: TwToolbarProps) {
367
334
  const showRightClusterComment =
368
335
  isToolbarChromeItemVisible(scopedChromePolicy, "comment") &&
369
336
  !isChromeItemOwnedByRoleRegion("comment", props.role);
337
+ const healthIssueCount = caps?.healthIssueCount ?? 0;
338
+ const hasHealthIssue =
339
+ healthIssueCount > 0 ||
340
+ props.compatibility?.blockExport === true ||
341
+ (props.compatibility?.featureEntries.some(
342
+ (entry) => entry.featureClass === "unsupported-fatal",
343
+ ) ??
344
+ false) ||
345
+ (props.warnings?.length ?? 0) > 0 ||
346
+ (props.blockedReasons?.length ?? 0) > 0;
370
347
  const showHealth =
371
348
  showDiagnosticsChrome &&
372
349
  isToolbarChromeItemVisible(scopedChromePolicy, "health") &&
373
- Boolean(props.compatibility && props.warnings);
350
+ hasHealthIssue;
374
351
  const showListActions = isToolbarChromeItemVisible(scopedChromePolicy, "list-actions");
375
352
  const showUpdateActions = isToolbarChromeItemVisible(scopedChromePolicy, "update-actions");
376
353
  const showSidebarToggle =
@@ -429,6 +406,16 @@ export function TwToolbar(props: TwToolbarProps) {
429
406
  >
430
407
  {/* Left cluster: undo/redo + formatting */}
431
408
  <div className={`flex min-w-0 flex-1 items-center gap-0.5 ${isCompact ? "flex-wrap" : ""}`}>
409
+ {props.role ? (
410
+ <>
411
+ <ToolbarRoleToggle
412
+ role={props.role}
413
+ disabled={!props.onEditorRoleChange}
414
+ onRoleChange={props.onEditorRoleChange}
415
+ />
416
+ <div className="mx-1 h-4 w-px bg-border" />
417
+ </>
418
+ ) : null}
432
419
  <TwToolbarIconButton
433
420
  icon={Undo2}
434
421
  label="Undo"
@@ -531,7 +518,6 @@ export function TwToolbar(props: TwToolbarProps) {
531
518
  disabled={!canEdit || !props.onSetTextColor}
532
519
  disabledReason={editDisabledReason}
533
520
  icon={<Baseline className="h-3.5 w-3.5" />}
534
- activeValue={props.formattingState?.textColor?.toLowerCase()}
535
521
  onSelect={(value) => {
536
522
  if (value) {
537
523
  props.onSetTextColor?.(value);
@@ -545,7 +531,6 @@ export function TwToolbar(props: TwToolbarProps) {
545
531
  disabled={!canEdit || !props.onSetHighlightColor}
546
532
  disabledReason={editDisabledReason}
547
533
  icon={<Highlighter className="h-3.5 w-3.5" />}
548
- activeValue={props.formattingState?.highlightColor?.toLowerCase() ?? null}
549
534
  onSelect={(value) => props.onSetHighlightColor?.(value)}
550
535
  title="Highlight color"
551
536
  />
@@ -604,30 +589,22 @@ export function TwToolbar(props: TwToolbarProps) {
604
589
  ) : null}
605
590
  {showListContinuationInRow ? (
606
591
  <>
607
- <button
608
- type="button"
609
- aria-label="Restart numbering"
592
+ <ToolbarTextButton
593
+ ariaLabel="Restart numbering"
610
594
  disabled={!canEdit || !props.onRestartNumbering}
611
- title={!canEdit && editDisabledReason ? `Not available: ${editDisabledReason}` : undefined}
612
- data-disabled-reason={!canEdit && editDisabledReason ? editDisabledReason : undefined}
613
- onMouseDown={preserveEditorSelectionMouseDown}
595
+ disabledReason={editDisabledReason}
614
596
  onClick={props.onRestartNumbering}
615
- className={`inline-flex h-6 items-center rounded-md px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
616
597
  >
617
598
  Restart
618
- </button>
619
- <button
620
- type="button"
621
- aria-label="Continue numbering"
599
+ </ToolbarTextButton>
600
+ <ToolbarTextButton
601
+ ariaLabel="Continue numbering"
622
602
  disabled={!canEdit || !props.onContinueNumbering}
623
- title={!canEdit && editDisabledReason ? `Not available: ${editDisabledReason}` : undefined}
624
- data-disabled-reason={!canEdit && editDisabledReason ? editDisabledReason : undefined}
625
- onMouseDown={preserveEditorSelectionMouseDown}
603
+ disabledReason={editDisabledReason}
626
604
  onClick={props.onContinueNumbering}
627
- className={`inline-flex h-6 items-center rounded-md px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
628
605
  >
629
606
  Continue
630
- </button>
607
+ </ToolbarTextButton>
631
608
  </>
632
609
  ) : null}
633
610
  {showInsertMenu && showInsertActionsInRow ? (
@@ -643,30 +620,22 @@ export function TwToolbar(props: TwToolbarProps) {
643
620
  ) : null}
644
621
  {showUpdateActions && showUpdateActionsInRow ? (
645
622
  <>
646
- <button
647
- type="button"
648
- aria-label="Refresh fields"
623
+ <ToolbarTextButton
624
+ ariaLabel="Refresh fields"
649
625
  disabled={!canEdit || !props.onUpdateFields}
650
- title={!canEdit && editDisabledReason ? `Not available: ${editDisabledReason}` : undefined}
651
- data-disabled-reason={!canEdit && editDisabledReason ? editDisabledReason : undefined}
652
- onMouseDown={preserveEditorSelectionMouseDown}
626
+ disabledReason={editDisabledReason}
653
627
  onClick={props.onUpdateFields}
654
- className={`inline-flex h-6 items-center rounded-md px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
655
628
  >
656
629
  Fields
657
- </button>
658
- <button
659
- type="button"
660
- aria-label="Refresh table of contents"
630
+ </ToolbarTextButton>
631
+ <ToolbarTextButton
632
+ ariaLabel="Refresh table of contents"
661
633
  disabled={!canEdit || !props.onUpdateTableOfContents}
662
- title={!canEdit && editDisabledReason ? `Not available: ${editDisabledReason}` : undefined}
663
- data-disabled-reason={!canEdit && editDisabledReason ? editDisabledReason : undefined}
664
- onMouseDown={preserveEditorSelectionMouseDown}
634
+ disabledReason={editDisabledReason}
665
635
  onClick={props.onUpdateTableOfContents}
666
- className={`inline-flex h-6 items-center rounded-md px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
667
636
  >
668
637
  TOC
669
- </button>
638
+ </ToolbarTextButton>
670
639
  </>
671
640
  ) : null}
672
641
  {showCompactOverflow ? (
@@ -721,16 +690,28 @@ export function TwToolbar(props: TwToolbarProps) {
721
690
  ) : null}
722
691
  </div>
723
692
 
724
- {/*
725
- * Chrome Closure Pass · Task 1 (designsystem.md §6.3) — the
726
- * role-action region used to render here as the toolbar's center
727
- * subregion. It now lives inside `TwContextBand` (rendered as a
728
- * sibling above the toolbar) so the workspace row is
729
- * [shell] · [TwContextBand carrying role actions] · [TwToolbar]
730
- * The toolbar still consumes `props.role` above for
731
- * `isChromeItemOwnedByRoleRegion` deferral so it does not
732
- * duplicate controls the band already exposes (§6.2 "Do not").
733
- */}
693
+ {props.role ? (
694
+ <TwRoleActionRegion
695
+ role={props.role}
696
+ policy={scopedChromePolicy}
697
+ compactMode={isCompact}
698
+ reviewQueue={props.reviewQueue}
699
+ markupDisplay={props.markupDisplay}
700
+ canAddComment={canAddComment}
701
+ showTrackedChanges={props.showTrackedChanges}
702
+ onAddComment={props.onAddComment}
703
+ onShowTrackedChangesChange={props.onShowTrackedChangesChange}
704
+ capabilities={caps}
705
+ onReviewSidebarTrackedChanges={props.onReviewSidebarTrackedChanges}
706
+ onReviewSidebarComments={props.onReviewSidebarComments}
707
+ onMarkScopePosture={props.onMarkScopePosture}
708
+ onReviewPrev={props.onReviewPrev}
709
+ onReviewNext={props.onReviewNext}
710
+ onReviewAccept={props.onReviewAccept}
711
+ onReviewReject={props.onReviewReject}
712
+ onReviewMarkupMode={props.onMarkupDisplayChange}
713
+ />
714
+ ) : null}
734
715
 
735
716
  {/* Right cluster: comment, track changes, markup, view, export */}
736
717
  <div className={`flex items-center gap-0.5 ${isCompact ? "ml-auto flex-wrap justify-end" : ""}`}>
@@ -1002,17 +983,17 @@ export function TwToolbar(props: TwToolbarProps) {
1002
983
  className={`relative inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface hover:text-primary outline-none ${focusRingClass}`}
1003
984
  >
1004
985
  <AlertCircle className="h-3.5 w-3.5" />
1005
- {(caps?.healthIssueCount ?? 0) > 0 ? (
986
+ {healthIssueCount > 0 ? (
1006
987
  <span className="absolute -top-0.5 -right-0.5 flex h-3 min-w-[12px] items-center justify-center rounded-full bg-tertiary text-[8px] font-medium text-white">
1007
- {caps?.healthIssueCount}
988
+ {healthIssueCount}
1008
989
  </span>
1009
990
  ) : null}
1010
991
  </button>
1011
992
  </Tooltip.Trigger>
1012
993
  <Tooltip.Portal>
1013
994
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
1014
- {(caps?.healthIssueCount ?? 0) > 0
1015
- ? `Document health — ${caps?.healthIssueCount} issue${(caps?.healthIssueCount ?? 0) !== 1 ? "s" : ""} (opens rail)`
995
+ {healthIssueCount > 0
996
+ ? `Document health — ${healthIssueCount} issue${healthIssueCount !== 1 ? "s" : ""} (opens rail)`
1016
997
  : "Document health — no issues"}
1017
998
  </Tooltip.Content>
1018
999
  </Tooltip.Portal>
@@ -1036,6 +1017,49 @@ export function TwToolbar(props: TwToolbarProps) {
1036
1017
  );
1037
1018
  }
1038
1019
 
1020
+ function ToolbarRoleToggle(props: {
1021
+ role: EditorRole;
1022
+ disabled?: boolean;
1023
+ onRoleChange?: (role: EditorRole) => void;
1024
+ }): React.JSX.Element {
1025
+ return (
1026
+ <ToggleGroup.Root
1027
+ type="single"
1028
+ value={props.role}
1029
+ aria-label="Editor mode"
1030
+ data-testid="toolbar-role-toggle"
1031
+ disabled={props.disabled}
1032
+ onValueChange={(value) => {
1033
+ if (!value || value === props.role) {
1034
+ return;
1035
+ }
1036
+ props.onRoleChange?.(value as EditorRole);
1037
+ }}
1038
+ className="mr-0.5 inline-flex h-7 shrink-0 items-center rounded-[var(--radius-md)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-muted)] p-0.5"
1039
+ >
1040
+ {ROLE_TOGGLE_ITEMS.map((item) => (
1041
+ <ToggleGroup.Item
1042
+ key={item.role}
1043
+ value={item.role}
1044
+ aria-label={item.label}
1045
+ data-testid={item.testId}
1046
+ onMouseDown={preserveEditorSelectionMouseDown}
1047
+ className={[
1048
+ "inline-flex h-6 items-center rounded-[var(--radius-sm)] px-2 text-[11px] font-semibold",
1049
+ "text-[var(--color-text-secondary)] transition-colors duration-[var(--motion-fast)]",
1050
+ "hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]",
1051
+ "data-[state=on]:bg-[var(--color-bg-canvas)] data-[state=on]:text-[var(--color-accent-primary)]",
1052
+ "disabled:cursor-not-allowed disabled:opacity-40",
1053
+ "focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]",
1054
+ ].join(" ")}
1055
+ >
1056
+ {item.label}
1057
+ </ToggleGroup.Item>
1058
+ ))}
1059
+ </ToggleGroup.Root>
1060
+ );
1061
+ }
1062
+
1039
1063
  function ToolbarParagraphStyleSelect(props: {
1040
1064
  styles: StyleCatalogSnapshot["paragraphs"];
1041
1065
  value?: string;
@@ -1062,9 +1086,15 @@ function ToolbarParagraphStyleSelect(props: {
1062
1086
  aria-label="Paragraph style"
1063
1087
  aria-disabled={props.disabled || undefined}
1064
1088
  data-disabled={props.disabled ? "" : undefined}
1065
- data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1089
+ data-disabled-reason={
1090
+ props.disabled && props.disabledReason ? props.disabledReason : undefined
1091
+ }
1066
1092
  data-mixed={isMixed ? "true" : undefined}
1067
- title={props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined}
1093
+ title={
1094
+ props.disabled && props.disabledReason
1095
+ ? `Not available: ${props.disabledReason}`
1096
+ : undefined
1097
+ }
1068
1098
  onMouseDown={preserveEditorSelectionMouseDown}
1069
1099
  className={`inline-flex h-6 min-w-[7.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1070
1100
  >
@@ -1129,9 +1159,15 @@ function ToolbarFontFamilySelect(props: {
1129
1159
  aria-label="Font family"
1130
1160
  aria-disabled={props.disabled || undefined}
1131
1161
  data-disabled={props.disabled ? "" : undefined}
1132
- data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1162
+ data-disabled-reason={
1163
+ props.disabled && props.disabledReason ? props.disabledReason : undefined
1164
+ }
1133
1165
  data-mixed={isMixed ? "true" : undefined}
1134
- title={props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined}
1166
+ title={
1167
+ props.disabled && props.disabledReason
1168
+ ? `Not available: ${props.disabledReason}`
1169
+ : undefined
1170
+ }
1135
1171
  onMouseDown={preserveEditorSelectionMouseDown}
1136
1172
  className={`inline-flex h-6 min-w-[6.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1137
1173
  >
@@ -1198,9 +1234,15 @@ function ToolbarFontSizeSelect(props: {
1198
1234
  aria-label="Font size"
1199
1235
  aria-disabled={props.disabled || undefined}
1200
1236
  data-disabled={props.disabled ? "" : undefined}
1201
- data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1237
+ data-disabled-reason={
1238
+ props.disabled && props.disabledReason ? props.disabledReason : undefined
1239
+ }
1202
1240
  data-mixed={isMixed ? "true" : undefined}
1203
- title={props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined}
1241
+ title={
1242
+ props.disabled && props.disabledReason
1243
+ ? `Not available: ${props.disabledReason}`
1244
+ : undefined
1245
+ }
1204
1246
  onMouseDown={preserveEditorSelectionMouseDown}
1205
1247
  className={`inline-flex h-6 min-w-[3.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1206
1248
  >
@@ -1273,7 +1315,6 @@ function ToolbarCompactOverflow(props: {
1273
1315
  onUpdateTableOfContents?: () => void;
1274
1316
  }) {
1275
1317
  const [open, setOpen] = React.useState(false);
1276
- const triggerRef = React.useRef<HTMLButtonElement>(null);
1277
1318
 
1278
1319
  async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
1279
1320
  const file = event.target.files?.[0];
@@ -1293,28 +1334,19 @@ function ToolbarCompactOverflow(props: {
1293
1334
  }
1294
1335
 
1295
1336
  return (
1296
- <Popover.Root open={open} onOpenChange={setOpen}>
1337
+ <div className="relative">
1297
1338
  <Tooltip.Root>
1298
1339
  <Tooltip.Trigger asChild>
1299
- <Popover.Anchor asChild>
1300
- <button
1301
- ref={triggerRef}
1302
- type="button"
1303
- aria-label="More document tools"
1304
- aria-expanded={open}
1305
- aria-haspopup="menu"
1306
- onMouseDown={preserveEditorSelectionMouseDown}
1307
- onClick={(event) => {
1308
- event.preventDefault();
1309
- setOpen((value) => !value);
1310
- }}
1311
- className={`inline-flex h-6 w-6 items-center justify-center rounded-md border border-border bg-canvas text-primary transition-colors hover:bg-surface outline-none ${
1312
- open ? "text-accent ring-1 ring-accent/30 shadow-sm" : ""
1313
- } ${focusRingClass}`}
1314
- >
1315
- <MoreHorizontal className="h-3.5 w-3.5" />
1316
- </button>
1317
- </Popover.Anchor>
1340
+ <button
1341
+ type="button"
1342
+ aria-label="More document tools"
1343
+ aria-expanded={open}
1344
+ onMouseDown={preserveEditorSelectionMouseDown}
1345
+ onClick={() => setOpen((value) => !value)}
1346
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md border border-border bg-canvas text-primary transition-colors hover:bg-surface outline-none ${focusRingClass}`}
1347
+ >
1348
+ <MoreHorizontal className="h-3.5 w-3.5" />
1349
+ </button>
1318
1350
  </Tooltip.Trigger>
1319
1351
  <Tooltip.Portal>
1320
1352
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
@@ -1322,12 +1354,8 @@ function ToolbarCompactOverflow(props: {
1322
1354
  </Tooltip.Content>
1323
1355
  </Tooltip.Portal>
1324
1356
  </Tooltip.Root>
1325
- <ToolbarPortalMenu
1326
- anchorRef={triggerRef}
1327
- className="w-[min(20rem,calc(100vw-2rem))] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1328
- menuWidthPx={320}
1329
- open={open}
1330
- >
1357
+ {open ? (
1358
+ <div className="absolute left-0 top-9 z-50 w-[min(20rem,calc(100vw-2rem))] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
1331
1359
  <div className="space-y-3">
1332
1360
  {props.showStyleSelectors ? (
1333
1361
  <div className="space-y-2">
@@ -1487,14 +1515,15 @@ function ToolbarCompactOverflow(props: {
1487
1515
  }}
1488
1516
  />
1489
1517
  <label
1490
- title={
1518
+ aria-disabled={!props.canInsertStructural || !props.onInsertImage ? "true" : undefined}
1519
+ data-disabled-reason={
1491
1520
  !props.canInsertStructural && props.insertDisabledReason
1492
- ? `Not available: ${props.insertDisabledReason}`
1521
+ ? props.insertDisabledReason
1493
1522
  : undefined
1494
1523
  }
1495
- data-disabled-reason={
1524
+ title={
1496
1525
  !props.canInsertStructural && props.insertDisabledReason
1497
- ? props.insertDisabledReason
1526
+ ? `Not available: ${props.insertDisabledReason}`
1498
1527
  : undefined
1499
1528
  }
1500
1529
  className={`flex h-7 cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] font-medium text-primary transition-colors hover:bg-surface ${
@@ -1558,8 +1587,9 @@ function ToolbarCompactOverflow(props: {
1558
1587
  </div>
1559
1588
  ) : null}
1560
1589
  </div>
1561
- </ToolbarPortalMenu>
1562
- </Popover.Root>
1590
+ </div>
1591
+ ) : null}
1592
+ </div>
1563
1593
  );
1564
1594
  }
1565
1595
 
@@ -1572,36 +1602,28 @@ function ToolbarFormattingOverflow(props: {
1572
1602
  onToggleSubscript?: () => void;
1573
1603
  }) {
1574
1604
  const [open, setOpen] = React.useState(false);
1575
- const triggerRef = React.useRef<HTMLButtonElement>(null);
1576
1605
  const disabledTitle =
1577
1606
  props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined;
1578
1607
 
1579
1608
  return (
1580
- <Popover.Root open={open} onOpenChange={setOpen}>
1609
+ <div className="relative">
1581
1610
  <Tooltip.Root>
1582
1611
  <Tooltip.Trigger asChild>
1583
- <Popover.Anchor asChild>
1584
- <button
1585
- ref={triggerRef}
1586
- type="button"
1587
- aria-label="More text formatting"
1588
- aria-expanded={open}
1589
- aria-haspopup="menu"
1590
- data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1591
- disabled={props.disabled}
1592
- title={disabledTitle}
1593
- onMouseDown={preserveEditorSelectionMouseDown}
1594
- onClick={(event) => {
1595
- event.preventDefault();
1596
- setOpen((value) => !value);
1597
- }}
1598
- className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${
1599
- open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : ""
1600
- } ${focusRingClass}`}
1601
- >
1602
- <MoreHorizontal className="h-3.5 w-3.5" />
1603
- </button>
1604
- </Popover.Anchor>
1612
+ <button
1613
+ type="button"
1614
+ aria-label="More text formatting"
1615
+ aria-expanded={open}
1616
+ data-disabled-reason={
1617
+ props.disabled && props.disabledReason ? props.disabledReason : undefined
1618
+ }
1619
+ disabled={props.disabled}
1620
+ title={disabledTitle}
1621
+ onMouseDown={preserveEditorSelectionMouseDown}
1622
+ onClick={() => setOpen((value) => !value)}
1623
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1624
+ >
1625
+ <MoreHorizontal className="h-3.5 w-3.5" />
1626
+ </button>
1605
1627
  </Tooltip.Trigger>
1606
1628
  <Tooltip.Portal>
1607
1629
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
@@ -1609,12 +1631,8 @@ function ToolbarFormattingOverflow(props: {
1609
1631
  </Tooltip.Content>
1610
1632
  </Tooltip.Portal>
1611
1633
  </Tooltip.Root>
1612
- <ToolbarPortalMenu
1613
- anchorRef={triggerRef}
1614
- className="w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1615
- menuWidthPx={220}
1616
- open={open}
1617
- >
1634
+ {open ? (
1635
+ <div className="absolute left-0 top-9 z-50 w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
1618
1636
  <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
1619
1637
  Text styling
1620
1638
  </div>
@@ -1653,8 +1671,9 @@ function ToolbarFormattingOverflow(props: {
1653
1671
  }}
1654
1672
  />
1655
1673
  </div>
1656
- </ToolbarPortalMenu>
1657
- </Popover.Root>
1674
+ </div>
1675
+ ) : null}
1676
+ </div>
1658
1677
  );
1659
1678
  }
1660
1679
 
@@ -1664,42 +1683,32 @@ function ToolbarColorPopover(props: {
1664
1683
  disabled: boolean;
1665
1684
  disabledReason?: string;
1666
1685
  icon: React.ReactNode;
1667
- activeValue?: string | null;
1668
1686
  title: string;
1669
1687
  onSelect: (value: string | null) => void;
1670
1688
  }) {
1671
1689
  const [open, setOpen] = React.useState(false);
1672
- const triggerRef = React.useRef<HTMLButtonElement>(null);
1673
- const activeValue = props.activeValue?.toLowerCase() ?? null;
1674
1690
  const disabledTitle =
1675
1691
  props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined;
1676
1692
 
1677
1693
  return (
1678
- <Popover.Root open={open} onOpenChange={setOpen}>
1694
+ <div className="relative">
1679
1695
  <Tooltip.Root>
1680
1696
  <Tooltip.Trigger asChild>
1681
- <Popover.Anchor asChild>
1682
- <button
1683
- ref={triggerRef}
1684
- type="button"
1685
- aria-label={props.ariaLabel}
1686
- aria-expanded={open}
1687
- aria-haspopup="menu"
1688
- data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1689
- disabled={props.disabled}
1690
- title={disabledTitle}
1691
- onMouseDown={preserveEditorSelectionMouseDown}
1692
- onClick={(event) => {
1693
- event.preventDefault();
1694
- setOpen((value) => !value);
1695
- }}
1696
- className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${
1697
- open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : ""
1698
- } ${focusRingClass}`}
1699
- >
1700
- {props.icon}
1701
- </button>
1702
- </Popover.Anchor>
1697
+ <button
1698
+ type="button"
1699
+ aria-label={props.ariaLabel}
1700
+ aria-expanded={open}
1701
+ data-disabled-reason={
1702
+ props.disabled && props.disabledReason ? props.disabledReason : undefined
1703
+ }
1704
+ disabled={props.disabled}
1705
+ title={disabledTitle}
1706
+ onMouseDown={preserveEditorSelectionMouseDown}
1707
+ onClick={() => setOpen((value) => !value)}
1708
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1709
+ >
1710
+ {props.icon}
1711
+ </button>
1703
1712
  </Tooltip.Trigger>
1704
1713
  <Tooltip.Portal>
1705
1714
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
@@ -1707,46 +1716,35 @@ function ToolbarColorPopover(props: {
1707
1716
  </Tooltip.Content>
1708
1717
  </Tooltip.Portal>
1709
1718
  </Tooltip.Root>
1710
- <ToolbarPortalMenu
1711
- anchorRef={triggerRef}
1712
- className="w-[180px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1713
- menuWidthPx={180}
1714
- open={open}
1715
- >
1719
+ {open ? (
1720
+ <div className="absolute left-0 top-9 z-50 w-[180px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
1716
1721
  <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
1717
1722
  {props.title}
1718
1723
  </div>
1719
1724
  <div className="grid grid-cols-3 gap-1">
1720
- {props.colors.map((color) => {
1721
- const normalizedValue = color.value?.toLowerCase() ?? null;
1722
- const isActive = normalizedValue === activeValue;
1723
- return (
1724
- <button
1725
- key={`${props.ariaLabel}-${color.label}`}
1726
- type="button"
1727
- aria-label={`${props.title} ${color.label}`}
1728
- aria-pressed={isActive}
1729
- data-active={isActive ? "true" : undefined}
1730
- disabled={props.disabled}
1731
- onMouseDown={preserveEditorSelectionMouseDown}
1732
- onClick={() => {
1733
- props.onSelect(color.value);
1734
- setOpen(false);
1735
- }}
1736
- className={`inline-flex h-7 items-center justify-center rounded-md border text-[10px] font-medium text-primary transition-transform hover:scale-[1.04] disabled:cursor-not-allowed disabled:opacity-40 ${
1737
- isActive
1738
- ? "border-accent ring-2 ring-accent/35 shadow-sm"
1739
- : "border-border"
1740
- } ${color.value ? "" : "bg-surface"} ${focusRingClass}`}
1741
- style={color.value ? { backgroundColor: color.value } : undefined}
1742
- >
1743
- {color.value ? <span className="sr-only">{color.label}</span> : "None"}
1744
- </button>
1745
- );
1746
- })}
1725
+ {props.colors.map((color) => (
1726
+ <button
1727
+ key={`${props.ariaLabel}-${color.label}`}
1728
+ type="button"
1729
+ aria-label={`${props.title} ${color.label}`}
1730
+ disabled={props.disabled}
1731
+ onMouseDown={preserveEditorSelectionMouseDown}
1732
+ onClick={() => {
1733
+ props.onSelect(color.value);
1734
+ setOpen(false);
1735
+ }}
1736
+ className={`inline-flex h-7 items-center justify-center rounded-md border border-border text-[10px] font-medium text-primary transition-transform hover:scale-[1.04] disabled:cursor-not-allowed disabled:opacity-40 ${
1737
+ color.value ? "" : "bg-surface"
1738
+ } ${focusRingClass}`}
1739
+ style={color.value ? { backgroundColor: color.value } : undefined}
1740
+ >
1741
+ {color.value ? <span className="sr-only">{color.label}</span> : "None"}
1742
+ </button>
1743
+ ))}
1747
1744
  </div>
1748
- </ToolbarPortalMenu>
1749
- </Popover.Root>
1745
+ </div>
1746
+ ) : null}
1747
+ </div>
1750
1748
  );
1751
1749
  }
1752
1750
 
@@ -1757,7 +1755,6 @@ function ToolbarAlignmentPopover(props: {
1757
1755
  onSelect: (alignment: FormattingAlignment) => void;
1758
1756
  }) {
1759
1757
  const [open, setOpen] = React.useState(false);
1760
- const triggerRef = React.useRef<HTMLButtonElement>(null);
1761
1758
  const disabledTitle =
1762
1759
  props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined;
1763
1760
  const alignments = [
@@ -1768,31 +1765,24 @@ function ToolbarAlignmentPopover(props: {
1768
1765
  ];
1769
1766
 
1770
1767
  return (
1771
- <Popover.Root open={open} onOpenChange={setOpen}>
1768
+ <div className="relative">
1772
1769
  <Tooltip.Root>
1773
1770
  <Tooltip.Trigger asChild>
1774
- <Popover.Anchor asChild>
1775
- <button
1776
- ref={triggerRef}
1777
- type="button"
1778
- aria-label="Paragraph alignment"
1779
- aria-expanded={open}
1780
- aria-haspopup="menu"
1781
- data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1782
- disabled={props.disabled}
1783
- title={disabledTitle}
1784
- onMouseDown={preserveEditorSelectionMouseDown}
1785
- onClick={(event) => {
1786
- event.preventDefault();
1787
- setOpen((value) => !value);
1788
- }}
1789
- className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${
1790
- open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : ""
1791
- } ${focusRingClass}`}
1792
- >
1793
- {(alignments.find((entry) => entry.value === props.activeAlignment) ?? alignments[0])?.icon}
1794
- </button>
1795
- </Popover.Anchor>
1771
+ <button
1772
+ type="button"
1773
+ aria-label="Paragraph alignment"
1774
+ aria-expanded={open}
1775
+ data-disabled-reason={
1776
+ props.disabled && props.disabledReason ? props.disabledReason : undefined
1777
+ }
1778
+ disabled={props.disabled}
1779
+ title={disabledTitle}
1780
+ onMouseDown={preserveEditorSelectionMouseDown}
1781
+ onClick={() => setOpen((value) => !value)}
1782
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1783
+ >
1784
+ {(alignments.find((entry) => entry.value === props.activeAlignment) ?? alignments[0])?.icon}
1785
+ </button>
1796
1786
  </Tooltip.Trigger>
1797
1787
  <Tooltip.Portal>
1798
1788
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
@@ -1800,12 +1790,8 @@ function ToolbarAlignmentPopover(props: {
1800
1790
  </Tooltip.Content>
1801
1791
  </Tooltip.Portal>
1802
1792
  </Tooltip.Root>
1803
- <ToolbarPortalMenu
1804
- anchorRef={triggerRef}
1805
- className="w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1806
- menuWidthPx={220}
1807
- open={open}
1808
- >
1793
+ {open ? (
1794
+ <div className="absolute left-0 top-9 z-50 w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
1809
1795
  <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
1810
1796
  Paragraph alignment
1811
1797
  </div>
@@ -1825,8 +1811,9 @@ function ToolbarAlignmentPopover(props: {
1825
1811
  />
1826
1812
  ))}
1827
1813
  </div>
1828
- </ToolbarPortalMenu>
1829
- </Popover.Root>
1814
+ </div>
1815
+ ) : null}
1816
+ </div>
1830
1817
  );
1831
1818
  }
1832
1819
 
@@ -1850,9 +1837,11 @@ function ToolbarInsertMenu(props: {
1850
1837
  onInsertImage?: (options: InsertImageOptions) => void;
1851
1838
  }) {
1852
1839
  const [open, setOpen] = React.useState(false);
1853
- const triggerRef = React.useRef<HTMLButtonElement>(null);
1840
+ const triggerUnavailable = props.disabled && props.disabledReason
1841
+ ? props.disabledReason
1842
+ : undefined;
1854
1843
  const disabledTitle =
1855
- props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined;
1844
+ triggerUnavailable ? `Not available: ${triggerUnavailable}` : undefined;
1856
1845
 
1857
1846
  async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
1858
1847
  const file = event.target.files?.[0];
@@ -1871,50 +1860,31 @@ function ToolbarInsertMenu(props: {
1871
1860
  }
1872
1861
 
1873
1862
  return (
1874
- <Popover.Root open={open} onOpenChange={setOpen}>
1875
- <Tooltip.Root>
1876
- <Tooltip.Trigger asChild>
1877
- <Popover.Anchor asChild>
1878
- <button
1879
- ref={triggerRef}
1880
- type="button"
1881
- aria-label="Insert"
1882
- aria-expanded={open}
1883
- aria-haspopup="menu"
1884
- data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1885
- disabled={props.disabled}
1886
- title={disabledTitle}
1887
- onMouseDown={preserveEditorSelectionMouseDown}
1888
- onClick={(event) => {
1889
- event.preventDefault();
1890
- setOpen((value) => !value);
1891
- }}
1892
- className={`inline-flex h-6 items-center gap-1 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${
1893
- open ? "text-accent ring-1 ring-accent/30 shadow-sm" : ""
1894
- } ${focusRingClass}`}
1895
- >
1896
- Insert
1897
- <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
1898
- </button>
1899
- </Popover.Anchor>
1900
- </Tooltip.Trigger>
1901
- <Tooltip.Portal>
1902
- <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
1903
- Insert
1904
- </Tooltip.Content>
1905
- </Tooltip.Portal>
1906
- </Tooltip.Root>
1907
- <ToolbarPortalMenu
1908
- anchorRef={triggerRef}
1909
- className="w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1910
- menuWidthPx={220}
1911
- open={open}
1863
+ <div className="relative">
1864
+ <button
1865
+ type="button"
1866
+ aria-label="Insert"
1867
+ aria-disabled={triggerUnavailable ? "true" : undefined}
1868
+ aria-expanded={open}
1869
+ data-disabled-reason={triggerUnavailable}
1870
+ title={disabledTitle ?? "Insert"}
1871
+ onMouseDown={preserveEditorSelectionMouseDown}
1872
+ onClick={() => setOpen((value) => !value)}
1873
+ className={`inline-flex h-6 items-center gap-1 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none ${focusRingClass}`}
1912
1874
  >
1875
+ Insert
1876
+ <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
1877
+ </button>
1878
+ {open ? (
1879
+ <div
1880
+ className="absolute left-0 top-9 z-[60] w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1881
+ data-testid="toolbar-insert-menu"
1882
+ >
1913
1883
  <div className="space-y-1">
1914
1884
  <ToolbarMenuButton
1915
1885
  ariaLabel="Insert page break"
1916
1886
  disabled={props.disabled || !props.onInsertPageBreak}
1917
- disabledReason={props.disabled ? props.disabledReason : props.disabledReasons?.pageBreak}
1887
+ disabledReason={props.disabledReasons?.pageBreak ?? props.disabledReason}
1918
1888
  icon={<Minus className="h-3.5 w-3.5" />}
1919
1889
  label="Page break"
1920
1890
  onClick={() => {
@@ -1925,7 +1895,7 @@ function ToolbarInsertMenu(props: {
1925
1895
  <ToolbarMenuButton
1926
1896
  ariaLabel="Insert table"
1927
1897
  disabled={props.disabled || !props.onInsertTable}
1928
- disabledReason={props.disabled ? props.disabledReason : props.disabledReasons?.table}
1898
+ disabledReason={props.disabledReasons?.table ?? props.disabledReason}
1929
1899
  icon={<Rows3 className="h-3.5 w-3.5" />}
1930
1900
  label="Table"
1931
1901
  onClick={() => {
@@ -1935,19 +1905,15 @@ function ToolbarInsertMenu(props: {
1935
1905
  />
1936
1906
  <label
1937
1907
  aria-disabled={props.disabled || !props.onInsertImage ? "true" : undefined}
1938
- title={
1939
- props.disabled
1940
- ? disabledTitle
1941
- : !props.onInsertImage && props.disabledReasons?.image
1942
- ? `Not available: ${props.disabledReasons.image}`
1943
- : undefined
1944
- }
1945
1908
  data-disabled-reason={
1946
- props.disabled
1947
- ? props.disabledReason
1948
- : !props.onInsertImage
1949
- ? props.disabledReasons?.image
1950
- : undefined
1909
+ props.disabled || !props.onInsertImage
1910
+ ? props.disabledReasons?.image ?? props.disabledReason
1911
+ : undefined
1912
+ }
1913
+ title={
1914
+ props.disabled || !props.onInsertImage
1915
+ ? `Not available: ${props.disabledReasons?.image ?? props.disabledReason ?? "Insert image is not available."}`
1916
+ : undefined
1951
1917
  }
1952
1918
  className={`flex h-7 cursor-pointer items-center gap-2 rounded-md px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface ${
1953
1919
  props.disabled || !props.onInsertImage ? "pointer-events-none opacity-40" : ""
@@ -1969,7 +1935,7 @@ function ToolbarInsertMenu(props: {
1969
1935
  <ToolbarMenuButton
1970
1936
  ariaLabel="Insert next-page section break"
1971
1937
  disabled={props.disabled || !props.onInsertSectionBreak}
1972
- disabledReason={props.disabled ? props.disabledReason : props.disabledReasons?.sectionBreak}
1938
+ disabledReason={props.disabledReasons?.sectionBreak ?? props.disabledReason}
1973
1939
  icon={<FileText className="h-3.5 w-3.5" />}
1974
1940
  label="Next-page section break"
1975
1941
  onClick={() => {
@@ -1978,8 +1944,9 @@ function ToolbarInsertMenu(props: {
1978
1944
  }}
1979
1945
  />
1980
1946
  </div>
1981
- </ToolbarPortalMenu>
1982
- </Popover.Root>
1947
+ </div>
1948
+ ) : null}
1949
+ </div>
1983
1950
  );
1984
1951
  }
1985
1952
 
@@ -2000,11 +1967,11 @@ function ToolbarPopoverActionButton(props: {
2000
1967
  type="button"
2001
1968
  aria-label={props.ariaLabel}
2002
1969
  aria-pressed={props.active}
2003
- disabled={props.disabled}
2004
- title={titleAttr}
2005
1970
  data-disabled-reason={
2006
1971
  props.disabled && props.disabledReason ? props.disabledReason : undefined
2007
1972
  }
1973
+ disabled={props.disabled}
1974
+ title={titleAttr}
2008
1975
  onMouseDown={preserveEditorSelectionMouseDown}
2009
1976
  onClick={props.onClick}
2010
1977
  className={`inline-flex h-7 items-center justify-center rounded-md border border-border transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${