@beyondwork/docx-react-component 1.0.53 → 1.0.54

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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +35 -7
  3. package/src/io/docx-session.ts +30 -6
  4. package/src/runtime/collab/checkpoint-store.ts +1 -1
  5. package/src/runtime/collab/event-types.ts +4 -0
  6. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  7. package/src/runtime/document-runtime.ts +23 -9
  8. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  9. package/src/runtime/layout/layout-engine-version.ts +58 -1
  10. package/src/runtime/layout/layout-invalidation.ts +150 -30
  11. package/src/runtime/layout/page-graph.ts +19 -0
  12. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  13. package/src/runtime/layout/project-block-fragments.ts +27 -0
  14. package/src/runtime/layout/public-facet.ts +27 -0
  15. package/src/runtime/render/render-frame-diff.ts +38 -2
  16. package/src/ui/WordReviewEditor.tsx +6 -3
  17. package/src/ui/headless/comment-decoration-model.ts +60 -5
  18. package/src/ui/headless/revision-decoration-model.ts +94 -6
  19. package/src/ui/shared/revision-filters.ts +16 -6
  20. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  21. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  22. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  23. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  24. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  25. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  26. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  27. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  28. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  29. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  30. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  31. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  32. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  33. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  34. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  35. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  36. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  37. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  38. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  39. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  40. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  41. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  42. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  43. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  44. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  45. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  46. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  47. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  48. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  49. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  50. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  51. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  52. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  53. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  54. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  55. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  57. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  58. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  59. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  60. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  61. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  62. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  63. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  65. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  66. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  67. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  68. package/src/ui-tailwind/index.ts +11 -0
  69. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  70. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  71. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  72. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  73. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  74. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  75. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  76. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  77. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  78. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  79. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  80. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  81. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  82. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  83. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  84. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  85. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  86. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -174,6 +174,40 @@ export interface TwToolbarProps {
174
174
  chromePins?: ChromePinsState;
175
175
  /** Called when the user detaches or re-attaches the topnav. */
176
176
  onChromePinChange?: (surface: ChromePinSurface, pin: PinState | null) => void;
177
+
178
+ /**
179
+ * Lane 6b §6b.U1 — unread count badges on the sidebar toggle when the
180
+ * review rail is closed. Hosts pass summed open-comment / open-change
181
+ * counts from the runtime snapshot; the badge only shows when
182
+ * `isSidebarOpen === false` and `(openCommentCount + openChangeCount) > 0`.
183
+ */
184
+ openCommentCount?: number;
185
+ openChangeCount?: number;
186
+
187
+ /**
188
+ * Lane 6b §6b.U2 — mixed-value grammar for the three style / font
189
+ * dropdowns. When `true`, the trigger renders italic "Mixed" instead
190
+ * of the current value (selection spans different values). Hosts
191
+ * compute this from whatever mixed-selection signal their runtime
192
+ * exposes; undefined keeps today's single-value behaviour.
193
+ */
194
+ hasMixedParagraphStyle?: boolean;
195
+ hasMixedFontFamily?: boolean;
196
+ hasMixedFontSize?: boolean;
197
+
198
+ /**
199
+ * Lane 6b §6b.U3 — per-item disabled-explanation reasons for the
200
+ * Insert menu. When the item is disabled (either because its handler
201
+ * is undefined or because `canInsertStructural` is false), hovering
202
+ * it surfaces `title="Not available: {reason}"` so the user understands
203
+ * WHY the capability is missing in the current context.
204
+ */
205
+ insertDisabledReasons?: {
206
+ pageBreak?: string;
207
+ table?: string;
208
+ image?: string;
209
+ sectionBreak?: string;
210
+ };
177
211
  }
178
212
 
179
213
  export interface ToolbarInteractionPolicy {
@@ -275,11 +309,26 @@ export function TwToolbar(props: TwToolbarProps) {
275
309
 
276
310
  return (
277
311
  <header
312
+ data-testid="tw-toolbar"
313
+ style={
314
+ isCompact
315
+ ? undefined
316
+ : {
317
+ // Lane 6b §6b.U5 — density opt-in. Scales the 40 px base
318
+ // by `--space-density-multiplier` so `data-density="compact"`
319
+ // shrinks the toolbar to ~34 px and `comfortable` expands
320
+ // it to ~46 px. Wrap mode keeps flex-wrap semantics in
321
+ // compact viewports.
322
+ height:
323
+ "calc(40px * var(--space-density-multiplier, 1))",
324
+ }
325
+ }
278
326
  className={[
279
- "shrink-0 rounded-xl border border-border/70 bg-canvas/92 px-2.5 shadow-[0_8px_20px_-18px_var(--color-shadow-strong)] backdrop-blur-sm",
327
+ "shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border-subtle)]",
328
+ "bg-[var(--color-bg-chrome)]/92 px-2.5 shadow-[var(--shadow-soft)] backdrop-blur-sm",
280
329
  isCompact
281
330
  ? "flex min-h-10 flex-wrap items-center gap-1.5 py-1.5"
282
- : "flex h-10 items-center gap-1",
331
+ : "flex items-center gap-1",
283
332
  ].join(" ")}
284
333
  >
285
334
  {/* Left cluster: undo/redo + formatting */}
@@ -287,12 +336,14 @@ export function TwToolbar(props: TwToolbarProps) {
287
336
  <TwToolbarIconButton
288
337
  icon={Undo2}
289
338
  label="Undo"
339
+ shortcut="⌘Z"
290
340
  disabled={caps ? !caps.canUndo : true}
291
341
  onClick={props.onUndo}
292
342
  />
293
343
  <TwToolbarIconButton
294
344
  icon={Redo2}
295
345
  label="Redo"
346
+ shortcut="⌘⇧Z"
296
347
  disabled={caps ? !caps.canRedo : true}
297
348
  onClick={props.onRedo}
298
349
  />
@@ -304,17 +355,20 @@ export function TwToolbar(props: TwToolbarProps) {
304
355
  disabled={!canEdit || paragraphStyles.length === 0 || !props.onSetParagraphStyle}
305
356
  styles={paragraphStyles}
306
357
  value={props.formattingState?.paragraphStyleId}
358
+ hasMixedValue={props.hasMixedParagraphStyle ?? false}
307
359
  onValueChange={props.onSetParagraphStyle}
308
360
  />
309
361
 
310
362
  <ToolbarFontFamilySelect
311
363
  disabled={!canEdit || !props.onSetFontFamily}
312
364
  value={props.formattingState?.fontFamily}
365
+ hasMixedValue={props.hasMixedFontFamily ?? false}
313
366
  onValueChange={props.onSetFontFamily}
314
367
  />
315
368
  <ToolbarFontSizeSelect
316
369
  disabled={!canEdit || !props.onSetFontSize}
317
370
  value={props.formattingState?.fontSize}
371
+ hasMixedValue={props.hasMixedFontSize ?? false}
318
372
  onValueChange={props.onSetFontSize}
319
373
  />
320
374
 
@@ -327,6 +381,7 @@ export function TwToolbar(props: TwToolbarProps) {
327
381
  <TwToolbarIconButton
328
382
  icon={Bold}
329
383
  label="Bold"
384
+ shortcut="⌘B"
330
385
  active={props.formattingState?.bold ?? false}
331
386
  disabled={!canEdit}
332
387
  onClick={props.onToggleBold}
@@ -334,6 +389,7 @@ export function TwToolbar(props: TwToolbarProps) {
334
389
  <TwToolbarIconButton
335
390
  icon={Italic}
336
391
  label="Italic"
392
+ shortcut="⌘I"
337
393
  active={props.formattingState?.italic ?? false}
338
394
  disabled={!canEdit}
339
395
  onClick={props.onToggleItalic}
@@ -341,6 +397,7 @@ export function TwToolbar(props: TwToolbarProps) {
341
397
  <TwToolbarIconButton
342
398
  icon={Underline}
343
399
  label="Underline"
400
+ shortcut="⌘U"
344
401
  active={props.formattingState?.underline ?? false}
345
402
  disabled={!canEdit}
346
403
  onClick={props.onToggleUnderline}
@@ -455,6 +512,7 @@ export function TwToolbar(props: TwToolbarProps) {
455
512
  {showInsertMenu && showInsertActionsInRow ? (
456
513
  <ToolbarInsertMenu
457
514
  disabled={!canInsertStructural}
515
+ disabledReasons={props.insertDisabledReasons}
458
516
  onInsertImage={props.onInsertImage}
459
517
  onInsertPageBreak={props.onInsertPageBreak}
460
518
  onInsertSectionBreak={props.onInsertSectionBreak}
@@ -581,12 +639,43 @@ export function TwToolbar(props: TwToolbarProps) {
581
639
  ) : null}
582
640
  {showSidebarToggle ? (
583
641
  <>
584
- <TwToolbarIconButton
585
- icon={PanelRight}
586
- label="Toggle sidebar"
587
- active={props.isSidebarOpen ?? false}
588
- onClick={props.onToggleSidebar}
589
- />
642
+ {/*
643
+ Lane 6b §6b.U1 — unread badge on the sidebar toggle when the
644
+ rail is closed. Host passes summed open-comment / open-
645
+ change counts; we only paint the badge when the rail is
646
+ closed AND there is something unread.
647
+ */}
648
+ <span className="relative inline-flex">
649
+ <TwToolbarIconButton
650
+ icon={PanelRight}
651
+ label="Toggle sidebar"
652
+ active={props.isSidebarOpen ?? false}
653
+ onClick={props.onToggleSidebar}
654
+ />
655
+ {(() => {
656
+ const isOpen = props.isSidebarOpen ?? false;
657
+ const count =
658
+ (props.openCommentCount ?? 0) +
659
+ (props.openChangeCount ?? 0);
660
+ if (isOpen || count <= 0) return null;
661
+ const display = count > 99 ? "99+" : String(count);
662
+ return (
663
+ <span
664
+ className={[
665
+ "pointer-events-none absolute -top-1 -right-1",
666
+ "inline-flex h-4 min-w-4 items-center justify-center",
667
+ "rounded-[var(--radius-pill)] px-1",
668
+ "bg-[var(--color-accent-primary)] text-[var(--color-text-on-accent)]",
669
+ "text-[9px] font-semibold leading-none",
670
+ ].join(" ")}
671
+ data-testid="toolbar-sidebar-toggle-badge"
672
+ aria-label={`${count} unread review items`}
673
+ >
674
+ {display}
675
+ </span>
676
+ );
677
+ })()}
678
+ </span>
590
679
  <div className="mx-1 h-4 w-px bg-border" />
591
680
  </>
592
681
  ) : null}
@@ -851,10 +940,14 @@ function ToolbarParagraphStyleSelect(props: {
851
940
  styles: StyleCatalogSnapshot["paragraphs"];
852
941
  value?: string;
853
942
  disabled: boolean;
943
+ hasMixedValue?: boolean;
854
944
  onValueChange?: (styleId: string) => void;
855
945
  }) {
946
+ const isMixed = props.hasMixedValue === true;
856
947
  const resolvedValue =
857
- props.value && props.styles.some((style) => style.styleId === props.value)
948
+ !isMixed &&
949
+ props.value &&
950
+ props.styles.some((style) => style.styleId === props.value)
858
951
  ? props.value
859
952
  : "";
860
953
 
@@ -868,10 +961,20 @@ function ToolbarParagraphStyleSelect(props: {
868
961
  aria-label="Paragraph style"
869
962
  aria-disabled={props.disabled || undefined}
870
963
  data-disabled={props.disabled ? "" : undefined}
964
+ data-mixed={isMixed ? "true" : undefined}
871
965
  onMouseDown={preserveEditorSelectionMouseDown}
872
966
  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}`}
873
967
  >
874
- <Select.Value placeholder="Style" />
968
+ {isMixed ? (
969
+ <span
970
+ className="italic text-[var(--color-text-tertiary)]"
971
+ data-testid="toolbar-paragraph-style-mixed"
972
+ >
973
+ Mixed
974
+ </span>
975
+ ) : (
976
+ <Select.Value placeholder="Style" />
977
+ )}
875
978
  <Select.Icon>
876
979
  <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
877
980
  </Select.Icon>
@@ -903,9 +1006,14 @@ function ToolbarParagraphStyleSelect(props: {
903
1006
  function ToolbarFontFamilySelect(props: {
904
1007
  value?: string;
905
1008
  disabled: boolean;
1009
+ hasMixedValue?: boolean;
906
1010
  onValueChange?: (fontFamily: string) => void;
907
1011
  }) {
908
- const resolvedValue = props.value && FONT_FAMILIES.includes(props.value) ? props.value : "";
1012
+ const isMixed = props.hasMixedValue === true;
1013
+ const resolvedValue =
1014
+ !isMixed && props.value && FONT_FAMILIES.includes(props.value)
1015
+ ? props.value
1016
+ : "";
909
1017
 
910
1018
  return (
911
1019
  <Select.Root
@@ -917,10 +1025,20 @@ function ToolbarFontFamilySelect(props: {
917
1025
  aria-label="Font family"
918
1026
  aria-disabled={props.disabled || undefined}
919
1027
  data-disabled={props.disabled ? "" : undefined}
1028
+ data-mixed={isMixed ? "true" : undefined}
920
1029
  onMouseDown={preserveEditorSelectionMouseDown}
921
1030
  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}`}
922
1031
  >
923
- <Select.Value placeholder="Font" />
1032
+ {isMixed ? (
1033
+ <span
1034
+ className="italic text-[var(--color-text-tertiary)]"
1035
+ data-testid="toolbar-font-family-mixed"
1036
+ >
1037
+ Mixed
1038
+ </span>
1039
+ ) : (
1040
+ <Select.Value placeholder="Font" />
1041
+ )}
924
1042
  <Select.Icon>
925
1043
  <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
926
1044
  </Select.Icon>
@@ -952,10 +1070,16 @@ function ToolbarFontFamilySelect(props: {
952
1070
  function ToolbarFontSizeSelect(props: {
953
1071
  value?: number;
954
1072
  disabled: boolean;
1073
+ hasMixedValue?: boolean;
955
1074
  onValueChange?: (fontSize: number) => void;
956
1075
  }) {
1076
+ const isMixed = props.hasMixedValue === true;
957
1077
  const resolvedValue =
958
- typeof props.value === "number" && FONT_SIZES.includes(props.value) ? String(props.value) : "";
1078
+ !isMixed &&
1079
+ typeof props.value === "number" &&
1080
+ FONT_SIZES.includes(props.value)
1081
+ ? String(props.value)
1082
+ : "";
959
1083
 
960
1084
  return (
961
1085
  <Select.Root
@@ -967,10 +1091,20 @@ function ToolbarFontSizeSelect(props: {
967
1091
  aria-label="Font size"
968
1092
  aria-disabled={props.disabled || undefined}
969
1093
  data-disabled={props.disabled ? "" : undefined}
1094
+ data-mixed={isMixed ? "true" : undefined}
970
1095
  onMouseDown={preserveEditorSelectionMouseDown}
971
1096
  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}`}
972
1097
  >
973
- <Select.Value placeholder="Size" />
1098
+ {isMixed ? (
1099
+ <span
1100
+ className="italic text-[var(--color-text-tertiary)]"
1101
+ data-testid="toolbar-font-size-mixed"
1102
+ >
1103
+ Mixed
1104
+ </span>
1105
+ ) : (
1106
+ <Select.Value placeholder="Size" />
1107
+ )}
974
1108
  <Select.Icon>
975
1109
  <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
976
1110
  </Select.Icon>
@@ -1483,6 +1617,17 @@ function ToolbarAlignmentPopover(props: {
1483
1617
 
1484
1618
  function ToolbarInsertMenu(props: {
1485
1619
  disabled: boolean;
1620
+ /**
1621
+ * Lane 6b §6b.U3 — optional per-item disabled-explanation reasons.
1622
+ * When the item is disabled (no handler or policy-gated), hovering it
1623
+ * surfaces a `title=` tooltip "Not available: {reason}".
1624
+ */
1625
+ disabledReasons?: {
1626
+ pageBreak?: string;
1627
+ table?: string;
1628
+ image?: string;
1629
+ sectionBreak?: string;
1630
+ };
1486
1631
  onInsertPageBreak?: () => void;
1487
1632
  onInsertTable?: () => void;
1488
1633
  onInsertSectionBreak?: (type: SectionBreakType) => void;
@@ -1535,6 +1680,7 @@ function ToolbarInsertMenu(props: {
1535
1680
  <ToolbarMenuButton
1536
1681
  ariaLabel="Insert page break"
1537
1682
  disabled={props.disabled || !props.onInsertPageBreak}
1683
+ disabledReason={props.disabledReasons?.pageBreak}
1538
1684
  icon={<Minus className="h-3.5 w-3.5" />}
1539
1685
  label="Page break"
1540
1686
  onClick={() => {
@@ -1545,6 +1691,7 @@ function ToolbarInsertMenu(props: {
1545
1691
  <ToolbarMenuButton
1546
1692
  ariaLabel="Insert table"
1547
1693
  disabled={props.disabled || !props.onInsertTable}
1694
+ disabledReason={props.disabledReasons?.table}
1548
1695
  icon={<Rows3 className="h-3.5 w-3.5" />}
1549
1696
  label="Table"
1550
1697
  onClick={() => {
@@ -1573,6 +1720,7 @@ function ToolbarInsertMenu(props: {
1573
1720
  <ToolbarMenuButton
1574
1721
  ariaLabel="Insert next-page section break"
1575
1722
  disabled={props.disabled || !props.onInsertSectionBreak}
1723
+ disabledReason={props.disabledReasons?.sectionBreak}
1576
1724
  icon={<FileText className="h-3.5 w-3.5" />}
1577
1725
  label="Next-page section break"
1578
1726
  onClick={() => {
@@ -1616,13 +1764,29 @@ function ToolbarMenuButton(props: {
1616
1764
  disabled: boolean;
1617
1765
  icon: React.ReactNode;
1618
1766
  label: string;
1767
+ /**
1768
+ * Lane 6b §6b.U3 — optional explanation surfaced as `title=` when the
1769
+ * item is disabled. Hosts pass the capability-policy reason so users
1770
+ * hover the entry and understand why it is unavailable in the current
1771
+ * context, rather than just seeing a faded click target.
1772
+ */
1773
+ disabledReason?: string;
1619
1774
  onClick?: () => void;
1620
1775
  }) {
1776
+ const titleAttr =
1777
+ props.disabled && props.disabledReason
1778
+ ? `Not available: ${props.disabledReason}`
1779
+ : undefined;
1621
1780
  return (
1622
1781
  <button
1623
1782
  type="button"
1624
1783
  aria-label={props.ariaLabel}
1784
+ aria-disabled={props.disabled ? "true" : undefined}
1625
1785
  disabled={props.disabled}
1786
+ title={titleAttr}
1787
+ data-disabled-reason={
1788
+ props.disabled && props.disabledReason ? props.disabledReason : undefined
1789
+ }
1626
1790
  onMouseDown={preserveEditorSelectionMouseDown}
1627
1791
  onClick={props.onClick}
1628
1792
  className={`flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
@@ -57,6 +57,10 @@ import {
57
57
  useVisiblePageIndexRange,
58
58
  } from "./page-stack/use-visible-block-range.ts";
59
59
  import { sliceBlocksForPage } from "./editor-surface/page-slice-util.ts";
60
+ import {
61
+ findScrollAnchor,
62
+ restoreScrollAnchor,
63
+ } from "./editor-surface/scroll-anchor.ts";
60
64
  import { TwPageBlockView } from "./editor-surface/tw-page-block-view.tsx";
61
65
  import { computeLineMarkersIfEnabled } from "./page-chrome-model.ts";
62
66
  import type { SessionCapabilities } from "../runtime/session-capabilities";
@@ -140,14 +144,31 @@ export interface TwReviewWorkspaceProps {
140
144
  helpLabel?: string;
141
145
  };
142
146
  /**
143
- * Opt-in floating mode dock pinned to the bottom of the workspace.
144
- * Hosts pass the derived label / icon / actions; defaults to hidden.
147
+ * @deprecated Lane 6b §6b.U4 (designsystem §6.26).
148
+ *
149
+ * The floating bottom-center mode dock is retained for back-compat but is
150
+ * off by default. Its functionality is covered by TwShellHeader mode tabs
151
+ * + TwRoleActionRegion. To render it, hosts must supply BOTH `modeDock`
152
+ * data and `experimental.showModeDock = true`.
145
153
  */
146
154
  modeDock?: {
147
155
  label: string;
148
156
  icon?: ReactNode;
149
157
  actions?: readonly import("./chrome/tw-mode-dock").TwModeDockAction[];
150
158
  };
159
+ /**
160
+ * Experimental feature flags. Anything here is subject to change or
161
+ * removal without a major-version bump. Do not depend on these in
162
+ * production consumers.
163
+ */
164
+ experimental?: {
165
+ /**
166
+ * Re-enable the deprecated TwModeDock floating bottom dock. Defaults to
167
+ * `false` — the shell header + role action region supersede this surface
168
+ * per designsystem §6.26.
169
+ */
170
+ showModeDock?: boolean;
171
+ };
151
172
  document: ReactNode;
152
173
  workspaceMode: WorkspaceMode;
153
174
  zoomLevel?: ZoomLevel;
@@ -1152,7 +1173,22 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1152
1173
  : undefined}
1153
1174
  onWorkspaceModeChange={(value) => {
1154
1175
  dismissSelectionToolbar();
1176
+ // L6d.U3: capture the block the viewport sits over,
1177
+ // then restore the same block's scroll offset after
1178
+ // the new mode has painted. Two rAFs: the first waits
1179
+ // for React to commit the mode change, the second
1180
+ // waits for layout + the page-break widgets to paint
1181
+ // before we write scrollTop.
1182
+ const root = scrollRootRef.current;
1183
+ const anchor = findScrollAnchor(root);
1155
1184
  props.onWorkspaceModeChange(value);
1185
+ if (anchor && typeof requestAnimationFrame === "function") {
1186
+ requestAnimationFrame(() => {
1187
+ requestAnimationFrame(() => {
1188
+ restoreScrollAnchor(scrollRootRef.current, anchor);
1189
+ });
1190
+ });
1191
+ }
1156
1192
  }}
1157
1193
  onToggleSidebar={() => {
1158
1194
  dismissSelectionToolbar();
@@ -1220,9 +1256,6 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1220
1256
  </div>
1221
1257
  ) : null}
1222
1258
 
1223
- {/* Legacy TwReviewQueueBar is suppressed — review role's action region
1224
- now owns queue prev/next + counts inline in the top toolbar. */}
1225
-
1226
1259
  {chromeVisibility.alerts ? <TwAlertBanner
1227
1260
  snapshot={snapshot}
1228
1261
  preserveOnlyCount={preserveOnlyCount}
@@ -1784,7 +1817,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1784
1817
  </div>
1785
1818
  ) : null}
1786
1819
  </div>
1787
- {props.modeDock ? (
1820
+ {props.modeDock && props.experimental?.showModeDock === true ? (
1788
1821
  <TwModeDock
1789
1822
  label={props.modeDock.label}
1790
1823
  icon={props.modeDock.icon}
@@ -1,85 +0,0 @@
1
- import React from "react";
2
-
3
- import { ChevronLeft, ChevronRight, MessageSquareText, Rows3 } from "lucide-react";
4
-
5
- import type { ReviewQueueSnapshot } from "../../api/public-types";
6
- import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
7
-
8
- /** @deprecated Use the role action region (TwRoleActionRegion) for queue navigation. */
9
- export interface TwReviewQueueBarProps {
10
- queue: ReviewQueueSnapshot;
11
- onPrevious?: () => void;
12
- onNext?: () => void;
13
- }
14
-
15
- const buttonClass =
16
- "inline-flex h-8 items-center gap-1 rounded-lg border border-border/80 bg-canvas px-2.5 text-xs font-medium text-secondary transition-colors hover:bg-surface-raised focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas disabled:cursor-not-allowed disabled:opacity-40";
17
-
18
- export function TwReviewQueueBar(props: TwReviewQueueBarProps) {
19
- const activeItem = props.queue.items[props.queue.activeIndex] ?? null;
20
- const activeLabel =
21
- activeItem?.label ??
22
- (props.queue.totalCount > 0 ? "Review queue" : "No review items");
23
- const activeKindLabel = activeItem ? formatReviewQueueItemKind(activeItem.kind) : null;
24
-
25
- return (
26
- <div className="border-b border-border/80 bg-surface/80 px-3 py-2.5 backdrop-blur-sm">
27
- <div className="flex flex-wrap items-center gap-2">
28
- <button
29
- type="button"
30
- aria-label="Previous review item"
31
- className={buttonClass}
32
- disabled={props.queue.totalCount === 0}
33
- onMouseDown={preserveEditorSelectionMouseDown}
34
- onClick={props.onPrevious}
35
- >
36
- <ChevronLeft className="h-3.5 w-3.5" />
37
- Prev
38
- </button>
39
- <button
40
- type="button"
41
- aria-label="Next review item"
42
- className={buttonClass}
43
- disabled={props.queue.totalCount === 0}
44
- onMouseDown={preserveEditorSelectionMouseDown}
45
- onClick={props.onNext}
46
- >
47
- Next
48
- <ChevronRight className="h-3.5 w-3.5" />
49
- </button>
50
- <div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-secondary">
51
- <span className="inline-flex items-center gap-1 rounded-full bg-canvas px-2.5 py-1 font-medium text-primary">
52
- <MessageSquareText className="h-3.5 w-3.5 text-comment" />
53
- {props.queue.openCount} open
54
- </span>
55
- <span className="inline-flex items-center gap-1 rounded-full bg-canvas px-2.5 py-1 font-medium text-primary">
56
- <Rows3 className="h-3.5 w-3.5 text-accent" />
57
- {props.queue.totalCount} total
58
- </span>
59
- </div>
60
- </div>
61
- <div className="mt-2 flex items-center gap-2 min-w-0">
62
- {activeKindLabel ? (
63
- <span
64
- className="inline-flex shrink-0 rounded-full bg-canvas px-2 py-0.5 text-[11px] font-medium text-secondary"
65
- data-testid="review-queue-item-kind"
66
- >
67
- {activeKindLabel}
68
- </span>
69
- ) : null}
70
- <div className="min-w-0 truncate text-sm font-medium text-primary">{activeLabel}</div>
71
- </div>
72
- </div>
73
- );
74
- }
75
-
76
- function formatReviewQueueItemKind(kind: ReviewQueueSnapshot["items"][number]["kind"]): string {
77
- switch (kind) {
78
- case "comment":
79
- return "Comment";
80
- case "change":
81
- return "Redline";
82
- case "section_mark":
83
- return "Tag";
84
- }
85
- }