@beyondwork/docx-react-component 1.0.53 → 1.0.55

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 (99) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +125 -7
  3. package/src/index.ts +5 -0
  4. package/src/io/docx-session.ts +27 -3
  5. package/src/io/normalize/normalize-text.ts +1 -0
  6. package/src/io/ooxml/parse-field-switches.ts +134 -0
  7. package/src/io/ooxml/parse-fields.ts +28 -2
  8. package/src/model/canonical-document.ts +13 -2
  9. package/src/runtime/chart/chart-model-store.ts +88 -0
  10. package/src/runtime/chart/chart-snapshot.ts +239 -0
  11. package/src/runtime/collab/checkpoint-store.ts +1 -1
  12. package/src/runtime/collab/event-types.ts +4 -0
  13. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  14. package/src/runtime/document-runtime.ts +115 -13
  15. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  16. package/src/runtime/layout/layout-engine-version.ts +58 -1
  17. package/src/runtime/layout/layout-invalidation.ts +150 -30
  18. package/src/runtime/layout/page-graph.ts +19 -0
  19. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  20. package/src/runtime/layout/project-block-fragments.ts +27 -0
  21. package/src/runtime/layout/public-facet.ts +27 -0
  22. package/src/runtime/page-number-format.ts +207 -0
  23. package/src/runtime/render/render-frame-diff.ts +38 -2
  24. package/src/runtime/surface-projection.ts +32 -3
  25. package/src/ui/WordReviewEditor.tsx +57 -3
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +90 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
  78. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
  79. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  80. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
  81. package/src/ui-tailwind/index.ts +11 -0
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  94. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  95. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  96. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  97. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  98. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  99. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -4,15 +4,20 @@ import * as Tabs from "@radix-ui/react-tabs";
4
4
  /**
5
5
  * TwShellHeader — the top "app chrome" bar above the document canvas.
6
6
  *
7
- * Anatomy matches the editorial reference mock:
7
+ * Designsystem §6.1 three subregions on a single 48-px row:
8
8
  * ┌─────────────────────────────────────────────────────────────────────┐
9
- * │ [brand] Edit | Review | Workflow | More [⋯] [CTA]
9
+ * │ LEFT CENTER │ RIGHT │
10
+ * │ brand + slot │ mode tabs (always-on) │ icon actions + CTA │
10
11
  * └─────────────────────────────────────────────────────────────────────┘
11
12
  *
12
- * All three regions are optional slots hosts opt in by supplying the
13
- * corresponding prop. When nothing is supplied the header renders empty but
14
- * preserves layout height so the document canvas does not jump when a CTA
15
- * appears.
13
+ * Layout is CSS-grid `grid-cols-[1fr_auto_1fr]` so the center zone is
14
+ * always visually centred regardless of left / right slot width. The
15
+ * 4-mode switcher (edit / review / workflow / more) renders
16
+ * unconditionally — Lane 6b §6b.S1 flips it from opt-in to always-on.
17
+ *
18
+ * All colors, shadows, radius, and motion bind to the Lane 6a token
19
+ * substrate (`var(--color-*)` / `var(--shadow-*)` / `var(--radius-*)` /
20
+ * `var(--motion-*)`) — no hex literals or legacy Tailwind palette names.
16
21
  */
17
22
 
18
23
  export type ShellHeaderMode = "edit" | "review" | "workflow" | "more";
@@ -40,6 +45,10 @@ export interface ShellHeaderIconAction {
40
45
 
41
46
  export interface TwShellHeaderProps {
42
47
  brand?: ReactNode;
48
+ /**
49
+ * Mode tab options. When omitted, a default 4-mode set is rendered so
50
+ * the center subregion always has tabs per designsystem §6.1.
51
+ */
43
52
  modes?: readonly ShellHeaderModeOption[];
44
53
  activeMode?: ShellHeaderMode;
45
54
  onModeChange?: (mode: ShellHeaderMode) => void;
@@ -50,23 +59,33 @@ export interface TwShellHeaderProps {
50
59
  className?: string;
51
60
  }
52
61
 
53
- const focusRingClass =
54
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
62
+ /**
63
+ * Default 4-mode set — designsystem §6.1. Exported so hosts can extend
64
+ * / relabel without reconstructing the whole list.
65
+ */
66
+ export const DEFAULT_SHELL_HEADER_MODES: readonly ShellHeaderModeOption[] = [
67
+ { id: "edit", label: "Edit" },
68
+ { id: "review", label: "Review" },
69
+ { id: "workflow", label: "Workflow" },
70
+ { id: "more", label: "More" },
71
+ ];
55
72
 
56
- export function TwShellHeader(props: TwShellHeaderProps) {
57
- const hasContent =
58
- props.brand ||
59
- (props.modes && props.modes.length > 0) ||
60
- (props.iconActions && props.iconActions.length > 0) ||
61
- props.primaryAction;
73
+ const focusRingClass =
74
+ "focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]";
62
75
 
63
- if (!hasContent) {
64
- return null;
65
- }
76
+ export function TwShellHeader(props: TwShellHeaderProps): React.ReactElement {
77
+ const modes =
78
+ props.modes && props.modes.length > 0
79
+ ? props.modes
80
+ : DEFAULT_SHELL_HEADER_MODES;
81
+ const activeMode: ShellHeaderMode = props.activeMode ?? modes[0]!.id;
66
82
 
67
83
  const className = [
68
- "flex h-12 shrink-0 items-center justify-between gap-4 px-4 bg-canvas/92 backdrop-blur-sm",
69
- props.isScrolled ? "border-b border-border" : "border-b border-transparent",
84
+ "grid h-12 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-2 px-4",
85
+ "bg-[var(--color-bg-chrome)]/92 backdrop-blur-sm",
86
+ props.isScrolled
87
+ ? "border-b border-[var(--color-border-subtle)]"
88
+ : "border-b border-transparent",
70
89
  "transition-colors duration-[var(--motion-fast)]",
71
90
  props.className,
72
91
  ]
@@ -75,10 +94,15 @@ export function TwShellHeader(props: TwShellHeaderProps) {
75
94
 
76
95
  return (
77
96
  <header className={className} data-testid="tw-shell-header">
78
- <div className="flex min-w-0 items-center gap-3">
97
+ {/* LEFT: brand + host-supplied slot */}
98
+ <div
99
+ className="flex min-w-0 items-center gap-3"
100
+ data-region="left"
101
+ data-testid="tw-shell-header__region-left"
102
+ >
79
103
  {props.brand ? (
80
104
  <div
81
- className="font-[family-name:var(--font-legal-serif)] text-[15px] font-semibold text-primary truncate"
105
+ className="truncate font-[family-name:var(--font-legal-serif)] text-[15px] font-semibold text-[var(--color-text-primary)]"
82
106
  data-testid="tw-shell-header__brand"
83
107
  >
84
108
  {props.brand}
@@ -86,9 +110,14 @@ export function TwShellHeader(props: TwShellHeaderProps) {
86
110
  ) : null}
87
111
  </div>
88
112
 
89
- {props.modes && props.modes.length > 0 && props.activeMode ? (
113
+ {/* CENTER: mode tabs (always on) */}
114
+ <div
115
+ className="flex items-center justify-center"
116
+ data-region="center"
117
+ data-testid="tw-shell-header__region-center"
118
+ >
90
119
  <Tabs.Root
91
- value={props.activeMode}
120
+ value={activeMode}
92
121
  onValueChange={(v: string) =>
93
122
  props.onModeChange?.(v as ShellHeaderMode)
94
123
  }
@@ -97,7 +126,7 @@ export function TwShellHeader(props: TwShellHeaderProps) {
97
126
  aria-label="Workspace modes"
98
127
  className="flex items-center gap-1"
99
128
  >
100
- {props.modes.map((mode) => (
129
+ {modes.map((mode) => (
101
130
  <Tabs.Trigger
102
131
  key={mode.id}
103
132
  value={mode.id}
@@ -110,17 +139,26 @@ export function TwShellHeader(props: TwShellHeaderProps) {
110
139
  ))}
111
140
  </Tabs.List>
112
141
  </Tabs.Root>
113
- ) : (
114
- <div aria-hidden="true" />
115
- )}
142
+ </div>
116
143
 
117
- <div className="flex items-center gap-1">
144
+ {/* RIGHT: icon actions + primary CTA */}
145
+ <div
146
+ className="flex items-center justify-end gap-1"
147
+ data-region="right"
148
+ data-testid="tw-shell-header__region-right"
149
+ >
118
150
  {props.iconActions?.map((action) => {
119
151
  const commonProps = {
120
152
  key: action.id,
121
153
  "aria-label": action.label,
122
154
  title: action.label,
123
- className: `inline-flex h-8 w-8 items-center justify-center rounded-sm text-secondary transition-colors hover:bg-surface-hover hover:text-primary ${focusRingClass}`,
155
+ className: [
156
+ "inline-flex h-8 w-8 items-center justify-center rounded-[var(--radius-sm)]",
157
+ "text-[var(--color-text-secondary)]",
158
+ "transition-colors duration-[var(--motion-fast)]",
159
+ "hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]",
160
+ focusRingClass,
161
+ ].join(" "),
124
162
  } as const;
125
163
 
126
164
  if (action.href) {
@@ -145,10 +183,13 @@ export function TwShellHeader(props: TwShellHeaderProps) {
145
183
  onClick={props.primaryAction.onClick}
146
184
  data-tone={props.primaryAction.tone ?? "accent"}
147
185
  className={[
148
- "ml-2 inline-flex h-8 items-center rounded-sm px-3 text-xs font-semibold transition-colors disabled:opacity-40",
186
+ "ml-2 inline-flex h-8 items-center rounded-[var(--radius-sm)] px-3",
187
+ "text-xs font-semibold",
188
+ "transition-colors duration-[var(--motion-fast)]",
189
+ "disabled:opacity-40",
149
190
  props.primaryAction.tone === "neutral"
150
- ? "bg-surface text-primary hover:bg-surface-hover"
151
- : "bg-accent text-white hover:bg-[color-mix(in_srgb,var(--color-accent)_85%,#000)]",
191
+ ? "bg-[var(--color-bg-muted)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]"
192
+ : "bg-[var(--color-accent-primary)] text-[var(--color-text-on-accent)] hover:bg-[var(--color-accent-primary-hover)]",
152
193
  focusRingClass,
153
194
  ].join(" ")}
154
195
  data-testid="tw-shell-header__primary-action"
@@ -7,13 +7,31 @@ export interface TwToolbarIconButtonProps {
7
7
  icon: React.ComponentType<{ className?: string }>;
8
8
  label: string;
9
9
  disabled?: boolean;
10
+ /**
11
+ * Active / pressed state. Designsystem §6.2 + Lane 6b §6b.S2: active
12
+ * buttons paint an accent-soft tint with an accent border ring — NOT a
13
+ * filled accent CTA. The filled-CTA grammar is reserved for the shell
14
+ * header primary action only.
15
+ */
10
16
  active?: boolean;
17
+ /**
18
+ * Emphasis variant — used for "stand-out" toolbar items (e.g. a pinned
19
+ * AI action). Paints the accent glyph color at rest; still tints on
20
+ * hover and on active.
21
+ */
11
22
  emphasis?: boolean;
23
+ /**
24
+ * Lane 6b §6b.U6 — optional keyboard-shortcut hint rendered as a small
25
+ * `<kbd>` chip to the right of the label inside the tooltip. Use
26
+ * platform-agnostic symbols (⌘, ⇧, ⌥, ⌃) — callers format for macOS
27
+ * vs. Windows however they like.
28
+ */
29
+ shortcut?: string;
12
30
  onClick?: () => void;
13
31
  }
14
32
 
15
33
  const focusRingClass =
16
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
34
+ "focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]";
17
35
 
18
36
  export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
19
37
  return (
@@ -22,17 +40,22 @@ export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
22
40
  <button
23
41
  type="button"
24
42
  aria-label={props.label}
43
+ aria-pressed={props.active ?? undefined}
44
+ data-active={props.active ? "true" : undefined}
25
45
  disabled={props.disabled}
26
46
  onMouseDown={preserveEditorSelectionMouseDown}
27
47
  onClick={props.onClick}
28
48
  className={[
29
- "inline-flex h-6 w-6 items-center justify-center rounded-md border border-transparent transition-colors outline-none",
49
+ "inline-flex h-6 w-6 items-center justify-center rounded-[var(--radius-sm)]",
50
+ "border border-transparent outline-none",
51
+ "transition-colors duration-[var(--motion-fast)]",
30
52
  "disabled:opacity-30 disabled:cursor-not-allowed",
31
- props.emphasis
32
- ? "text-accent hover:border-border/60 hover:bg-surface"
33
- : props.active
34
- ? "border-border/70 bg-surface text-accent shadow-[0_4px_12px_-10px_var(--color-shadow-strong)]"
35
- : "text-secondary hover:border-border/60 hover:bg-surface hover:text-primary",
53
+ props.active
54
+ ? // Active = underline-tint grammar: accent-soft fill, accent-primary glyph, accent border ring.
55
+ "bg-[var(--color-accent-soft)] text-[var(--color-accent-primary)] border-[var(--color-border-accent)]"
56
+ : props.emphasis
57
+ ? "text-[var(--color-accent-primary)] hover:bg-[var(--color-bg-hover)] hover:border-[var(--color-border-subtle)]"
58
+ : "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-border-subtle)]",
36
59
  focusRingClass,
37
60
  ].join(" ")}
38
61
  >
@@ -41,10 +64,27 @@ export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
41
64
  </Tooltip.Trigger>
42
65
  <Tooltip.Portal>
43
66
  <Tooltip.Content
44
- className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
67
+ className={[
68
+ "inline-flex items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1 text-xs z-50",
69
+ "bg-[var(--color-text-primary)] text-[var(--color-text-inverse)]",
70
+ "shadow-[var(--shadow-soft)]",
71
+ ].join(" ")}
45
72
  sideOffset={6}
46
73
  >
47
- {props.label}
74
+ <span>{props.label}</span>
75
+ {props.shortcut ? (
76
+ <kbd
77
+ className={[
78
+ "inline-flex items-center rounded-[var(--radius-sm)]",
79
+ "px-1 py-0.5 font-sans text-[10px] font-medium",
80
+ "border border-[var(--color-border-subtle)]/40",
81
+ "bg-[var(--color-bg-overlay)] text-[var(--color-text-inverse)]/80",
82
+ ].join(" ")}
83
+ data-testid="tw-toolbar-icon-button__shortcut"
84
+ >
85
+ {props.shortcut}
86
+ </kbd>
87
+ ) : null}
48
88
  </Tooltip.Content>
49
89
  </Tooltip.Portal>
50
90
  </Tooltip.Root>
@@ -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}`}