@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
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Lane 6d — Slice N2: display-mode selector (P11.10).
3
+ *
4
+ * Chrome-toolbar Popover exposing Word's 4 markup display modes:
5
+ * - All Markup — every tracked change + comment highlighted
6
+ * - Simple Markup — compact indicators in the gutter, subtle spans
7
+ * - No Markup — accepted-preview: insertions inline, deletions hidden
8
+ * - Original — rejected-preview: insertions hidden, deletions as plain text
9
+ *
10
+ * Values are emitted in Word's canonical grammar (`"all-markup"` etc.).
11
+ * Legacy `"clean" | "simple" | "all"` values are accepted as `value`
12
+ * via `normalizeMarkupDisplay`, so a host that already passes the old
13
+ * names sees the right option highlighted without code changes.
14
+ */
15
+
16
+ import React, { useState } from "react";
17
+ import * as Popover from "@radix-ui/react-popover";
18
+ import { ChevronDown, Eye, EyeOff, Highlighter, Scroll } from "lucide-react";
19
+
20
+ import {
21
+ normalizeMarkupDisplay,
22
+ type MarkupDisplay,
23
+ } from "../../ui/headless/comment-decoration-model";
24
+
25
+ export type DisplayMode = "all-markup" | "simple-markup" | "no-markup" | "original";
26
+
27
+ export interface TwDisplayModeSelectorProps {
28
+ value: MarkupDisplay;
29
+ onChange: (value: DisplayMode) => void;
30
+ disabled?: boolean;
31
+ "data-testid"?: string;
32
+ }
33
+
34
+ interface ModeEntry {
35
+ mode: DisplayMode;
36
+ label: string;
37
+ hint: string;
38
+ icon: React.ComponentType<{ className?: string }>;
39
+ }
40
+
41
+ const MODES: readonly ModeEntry[] = [
42
+ {
43
+ mode: "all-markup",
44
+ label: "All Markup",
45
+ hint: "Every tracked change and comment visible",
46
+ icon: Highlighter,
47
+ },
48
+ {
49
+ mode: "simple-markup",
50
+ label: "Simple Markup",
51
+ hint: "Compact indicators in the gutter",
52
+ icon: Scroll,
53
+ },
54
+ {
55
+ mode: "no-markup",
56
+ label: "No Markup",
57
+ hint: "Preview as if all changes accepted",
58
+ icon: Eye,
59
+ },
60
+ {
61
+ mode: "original",
62
+ label: "Original",
63
+ hint: "Preview as if all changes rejected",
64
+ icon: EyeOff,
65
+ },
66
+ ];
67
+
68
+ export function TwDisplayModeSelector(props: TwDisplayModeSelectorProps): React.ReactElement {
69
+ const [open, setOpen] = useState(false);
70
+ const canonical = normalizeMarkupDisplay(props.value);
71
+ const activeEntry = MODES.find((m) => m.mode === canonical) ?? MODES[0]!;
72
+
73
+ return (
74
+ <Popover.Root open={open} onOpenChange={setOpen}>
75
+ <Popover.Trigger asChild>
76
+ <button
77
+ type="button"
78
+ disabled={props.disabled}
79
+ data-testid={props["data-testid"] ?? "display-mode-selector-trigger"}
80
+ aria-label={`Display mode: ${activeEntry.label}`}
81
+ className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-surface focus-visible:outline-none focus-visible:bg-surface disabled:opacity-50"
82
+ >
83
+ <activeEntry.icon className="h-3.5 w-3.5 text-tertiary" />
84
+ <span>{activeEntry.label}</span>
85
+ <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
86
+ </button>
87
+ </Popover.Trigger>
88
+ <Popover.Portal>
89
+ <Popover.Content
90
+ className="z-50 w-[260px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
91
+ sideOffset={8}
92
+ align="end"
93
+ data-testid="display-mode-selector-content"
94
+ >
95
+ <div className="mb-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
96
+ Display mode
97
+ </div>
98
+ {MODES.map((entry) => {
99
+ const isActive = entry.mode === canonical;
100
+ return (
101
+ <Popover.Close key={entry.mode} asChild>
102
+ <button
103
+ type="button"
104
+ role="menuitemradio"
105
+ aria-checked={isActive}
106
+ onClick={() => {
107
+ props.onChange(entry.mode);
108
+ }}
109
+ className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-[11px] transition-colors hover:bg-surface focus-visible:outline-none focus-visible:bg-surface"
110
+ data-testid={`display-mode-option-${entry.mode}`}
111
+ data-mode={entry.mode}
112
+ data-active={isActive ? "true" : undefined}
113
+ >
114
+ <entry.icon
115
+ className={[
116
+ "mt-0.5 h-3.5 w-3.5 shrink-0",
117
+ isActive ? "text-accent" : "text-tertiary",
118
+ ].join(" ")}
119
+ />
120
+ <span className="flex flex-col">
121
+ <span className={`font-medium ${isActive ? "text-accent" : "text-primary"}`}>
122
+ {entry.label}
123
+ </span>
124
+ <span className="text-[10px] text-secondary">{entry.hint}</span>
125
+ </span>
126
+ </button>
127
+ </Popover.Close>
128
+ );
129
+ })}
130
+ </Popover.Content>
131
+ </Popover.Portal>
132
+ </Popover.Root>
133
+ );
134
+ }
135
+
136
+ export default TwDisplayModeSelector;
@@ -0,0 +1,76 @@
1
+ import React, { type ReactNode } from "react";
2
+
3
+ export interface TwEmptyStateProps {
4
+ icon?: ReactNode;
5
+ title?: string;
6
+ body?: string;
7
+ action?: { label: string; onClick: () => void };
8
+ className?: string;
9
+ }
10
+
11
+ const focusRingClass =
12
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
13
+
14
+ export function TwEmptyState(props: TwEmptyStateProps): React.JSX.Element {
15
+ const { icon, title, body, action, className } = props;
16
+
17
+ const containerClass = [
18
+ "rounded-[var(--radius-lg)]",
19
+ "bg-[var(--color-bg-muted)]",
20
+ "ring-1 ring-[var(--color-border-subtle)]/40",
21
+ "px-4 py-5",
22
+ "flex flex-col items-center text-center gap-1.5",
23
+ className,
24
+ ]
25
+ .filter(Boolean)
26
+ .join(" ");
27
+
28
+ return (
29
+ <div
30
+ className={containerClass}
31
+ data-testid="tw-empty-state"
32
+ >
33
+ {icon !== undefined && (
34
+ <span
35
+ className="h-5 w-5 text-[var(--color-text-tertiary)]"
36
+ data-testid="tw-empty-state__icon"
37
+ aria-hidden="true"
38
+ >
39
+ {icon}
40
+ </span>
41
+ )}
42
+ {title !== undefined && (
43
+ <p
44
+ className="text-[13px] font-semibold text-[var(--color-text-primary)]"
45
+ data-testid="tw-empty-state__title"
46
+ >
47
+ {title}
48
+ </p>
49
+ )}
50
+ {body !== undefined && (
51
+ <p
52
+ className="text-[12px] text-[var(--color-text-tertiary)] leading-snug"
53
+ data-testid="tw-empty-state__body"
54
+ >
55
+ {body}
56
+ </p>
57
+ )}
58
+ {action !== undefined && (
59
+ <button
60
+ type="button"
61
+ className={[
62
+ "mt-1 inline-flex items-center h-7 rounded-[var(--radius-sm)]",
63
+ "border border-[var(--color-border-default)] bg-[var(--color-bg-canvas)]",
64
+ "px-3 text-[12px] font-medium text-[var(--color-text-primary)]",
65
+ "hover:bg-[var(--color-bg-hover)]",
66
+ focusRingClass,
67
+ ].join(" ")}
68
+ data-testid="tw-empty-state__action"
69
+ onClick={action.onClick}
70
+ >
71
+ {action.label}
72
+ </button>
73
+ )}
74
+ </div>
75
+ );
76
+ }
@@ -30,7 +30,7 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
30
30
  return (
31
31
  <div
32
32
  data-testid="image-context-toolbar"
33
- className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-canvas px-2.5 py-1.5 shadow-sm"
33
+ className="flex flex-wrap items-center gap-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-canvas)] px-2.5 py-1.5 shadow-[var(--shadow-float)]"
34
34
  >
35
35
  <span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
36
36
  Image
@@ -38,20 +38,34 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
38
38
  <span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.1em] text-secondary">
39
39
  {activeImage.display}
40
40
  </span>
41
- {IMAGE_SIZE_PRESETS.map((preset) => (
42
- <ToolbarButton
43
- key={preset.label}
44
- ariaLabel={preset.label}
45
- disabled={props.disabled || !props.onSetImageLayout}
46
- onClick={() =>
47
- props.onSetImageLayout?.(activeImage.mediaId, {
48
- widthEmu: preset.widthEmu,
49
- heightEmu: preset.heightEmu,
50
- })}
51
- >
52
- {preset.label.replace(" image", "")}
53
- </ToolbarButton>
54
- ))}
41
+ <div role="group" aria-label="Image size" className="inline-flex items-center rounded-[var(--radius-sm)] bg-[var(--color-bg-muted)] p-0.5">
42
+ {IMAGE_SIZE_PRESETS.map((preset) => {
43
+ const isActive =
44
+ activeImage.widthEmu === preset.widthEmu &&
45
+ activeImage.heightEmu === preset.heightEmu;
46
+ const shortLabel = preset.label.replace(" image", "");
47
+ return (
48
+ <button
49
+ key={preset.label}
50
+ type="button"
51
+ aria-pressed={isActive}
52
+ aria-label={preset.label}
53
+ disabled={props.disabled || !props.onSetImageLayout}
54
+ onMouseDown={preserveEditorSelectionMouseDown}
55
+ onClick={() =>
56
+ props.onSetImageLayout?.(activeImage.mediaId, {
57
+ widthEmu: preset.widthEmu,
58
+ heightEmu: preset.heightEmu,
59
+ })}
60
+ className={`inline-flex h-6 items-center px-2 text-[11px] font-medium rounded-[var(--radius-sm)] transition-colors disabled:cursor-not-allowed disabled:opacity-40
61
+ aria-pressed:bg-[var(--color-bg-selected)] aria-pressed:text-[var(--color-accent-primary)]
62
+ text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]`}
63
+ >
64
+ {shortLabel}
65
+ </button>
66
+ );
67
+ })}
68
+ </div>
55
69
  {activeImage.display === "floating" ? (
56
70
  <>
57
71
  <ToolbarButton
@@ -113,7 +127,7 @@ function ToolbarButton(props: {
113
127
  disabled={props.disabled}
114
128
  onMouseDown={preserveEditorSelectionMouseDown}
115
129
  onClick={props.onClick}
116
- className="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
130
+ className="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-accent-soft)] disabled:cursor-not-allowed disabled:opacity-40"
117
131
  >
118
132
  {props.children}
119
133
  </button>
@@ -5,13 +5,31 @@ export interface TwObjectContextToolbarProps {
5
5
  activeObject: ActiveObjectContext;
6
6
  }
7
7
 
8
+ function InfoIcon(props: { className?: string; "aria-hidden"?: boolean }) {
9
+ return (
10
+ <svg
11
+ xmlns="http://www.w3.org/2000/svg"
12
+ viewBox="0 0 20 20"
13
+ fill="currentColor"
14
+ className={props.className}
15
+ aria-hidden={props["aria-hidden"]}
16
+ >
17
+ <path
18
+ fillRule="evenodd"
19
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
20
+ clipRule="evenodd"
21
+ />
22
+ </svg>
23
+ );
24
+ }
25
+
8
26
  export function TwObjectContextToolbar(props: TwObjectContextToolbarProps) {
9
27
  const label = props.activeObject.kind === "textbox" ? "Text box" : "Shape";
10
28
 
11
29
  return (
12
30
  <div
13
31
  data-testid="object-context-toolbar"
14
- className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-canvas px-2.5 py-1.5 shadow-sm"
32
+ className="flex flex-wrap items-center gap-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-canvas)] px-2.5 py-1.5 shadow-[var(--shadow-float)]"
15
33
  >
16
34
  <span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
17
35
  Object
@@ -22,9 +40,10 @@ export function TwObjectContextToolbar(props: TwObjectContextToolbarProps) {
22
40
  <span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.1em] text-secondary">
23
41
  {props.activeObject.display}
24
42
  </span>
25
- <span className="text-[11px] text-secondary">
26
- Object selection is active.
27
- </span>
43
+ <div className="inline-flex items-center gap-1.5">
44
+ <InfoIcon className="h-3.5 w-3.5 text-[var(--color-semantic-info)]" aria-hidden={true} />
45
+ <span className="text-[11px] text-[var(--color-text-secondary)]">Shape preserved for export — opens in Word.</span>
46
+ </div>
28
47
  </div>
29
48
  );
30
49
  }
@@ -0,0 +1,113 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { AlertTriangle } from "lucide-react";
3
+
4
+ export interface TwPasteDropToastProps {
5
+ /**
6
+ * Current blocked-input payload. When it changes to a non-null value
7
+ * the toast renders and self-dismisses after `durationMs`. Pass a
8
+ * fresh object (not the same reference) for each new event — the
9
+ * identity change triggers the timer.
10
+ */
11
+ event: {
12
+ command: "paste" | "drop";
13
+ message: string;
14
+ /**
15
+ * Optional unique id per event so repeated events with the same
16
+ * command / message still re-trigger the toast. Defaults to the
17
+ * current time when unset.
18
+ */
19
+ eventId?: string;
20
+ } | null;
21
+ /** How long the toast stays visible, in ms (default 3000). */
22
+ durationMs?: number;
23
+ /** Called when the toast fades out. */
24
+ onDismiss?: () => void;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * TwPasteDropToast — non-blocking bottom-right toast surfaced when the
30
+ * runtime refuses a paste / drop input (Lane 6b §6b.U7).
31
+ *
32
+ * Host wiring pattern (see `src/ui-tailwind/editor-surface/tw-prosemirror-
33
+ * surface.tsx`):
34
+ *
35
+ * const [blockedInput, setBlockedInput] = useState(null);
36
+ * // Wire the surface callback → state:
37
+ * onBlockedInput={(command, message) =>
38
+ * setBlockedInput({ command, message, eventId: String(Date.now()) })
39
+ * }
40
+ *
41
+ * // Mount the toast once in the shell zone:
42
+ * <TwPasteDropToast
43
+ * event={blockedInput}
44
+ * onDismiss={() => setBlockedInput(null)}
45
+ * />
46
+ *
47
+ * All colours / shadow / radius / motion bind Lane 6a tokens. Severity
48
+ * is treated as `warning` (semantic-warning-soft bg + semantic-warning
49
+ * glyph) — the input was refused, not dropped to the floor.
50
+ */
51
+ export function TwPasteDropToast(
52
+ props: TwPasteDropToastProps,
53
+ ): React.ReactElement | null {
54
+ // Start visible on any non-null event so SSR renders the toast. Auto-
55
+ // dismiss runs client-side via setTimeout after `durationMs`.
56
+ const [dismissed, setDismissed] = useState(false);
57
+ const duration = props.durationMs ?? 3000;
58
+ const eventKey = props.event
59
+ ? `${props.event.command}:${props.event.eventId ?? props.event.message}`
60
+ : null;
61
+
62
+ useEffect(() => {
63
+ if (!props.event) {
64
+ setDismissed(false);
65
+ return;
66
+ }
67
+ setDismissed(false);
68
+ const handle = setTimeout(() => {
69
+ setDismissed(true);
70
+ props.onDismiss?.();
71
+ }, duration);
72
+ return () => clearTimeout(handle);
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [eventKey, duration]);
75
+
76
+ if (!props.event || dismissed) return null;
77
+
78
+ const label =
79
+ props.event.command === "paste" ? "Paste blocked" : "Drop blocked";
80
+
81
+ return (
82
+ <div
83
+ role="status"
84
+ aria-live="polite"
85
+ data-testid="tw-paste-drop-toast"
86
+ data-command={props.event.command}
87
+ className={[
88
+ "fixed bottom-4 right-4 z-50",
89
+ "inline-flex items-start gap-3",
90
+ "max-w-sm px-3 py-2",
91
+ "rounded-[var(--radius-sm)]",
92
+ "bg-[var(--color-semantic-warning-soft)] text-[var(--color-semantic-warning)]",
93
+ "shadow-[var(--shadow-float)]",
94
+ "border border-[var(--color-border-subtle)]",
95
+ "transition-opacity duration-[var(--motion-default)]",
96
+ props.className,
97
+ ]
98
+ .filter(Boolean)
99
+ .join(" ")}
100
+ >
101
+ <AlertTriangle
102
+ className="mt-0.5 h-4 w-4 shrink-0"
103
+ aria-hidden="true"
104
+ />
105
+ <div className="min-w-0 flex-1">
106
+ <div className="text-xs font-semibold">{label}</div>
107
+ <div className="mt-0.5 text-[11px] text-[var(--color-text-secondary)]">
108
+ {props.event.message}
109
+ </div>
110
+ </div>
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Lane 6d — Slice N4: revision hover preview (P11.12).
3
+ *
4
+ * Wrapper that renders its children unchanged, but attaches a
5
+ * 250 ms-delayed Radix Popover showing a compact summary card for a
6
+ * tracked change:
7
+ * - Author display name (optionally colored by N3's palette index)
8
+ * - Relative timestamp (e.g. "3 hours ago")
9
+ * - Kind label ("Inserted", "Deleted", "Formatting")
10
+ * - Short excerpt of the affected text
11
+ *
12
+ * Re-entering the Popover.Content keeps it open so the reviewer can
13
+ * click through to the change card. When `hidden` is true (e.g. the
14
+ * host is in `no-markup` display mode) the wrapper passes its children
15
+ * through without attaching the popover at all.
16
+ */
17
+
18
+ import React, { useCallback, useRef, useState } from "react";
19
+ import * as Popover from "@radix-ui/react-popover";
20
+
21
+ import {
22
+ AUTHOR_PALETTE,
23
+ getAuthorColor,
24
+ } from "../../ui/headless/revision-decoration-model";
25
+
26
+ export const HOVER_DELAY_MS = 250;
27
+
28
+ export type RevisionKind = "insertion" | "deletion" | "formatting";
29
+
30
+ export interface TwRevisionHoverPreviewProps {
31
+ /** Stable id for the tracked change — used as the Popover's aria key. */
32
+ revisionId: string;
33
+ authorId?: string;
34
+ authorDisplayName?: string;
35
+ /** Relative-time string (e.g. "3 hours ago"); rendered verbatim. */
36
+ relativeTime?: string;
37
+ kind: RevisionKind;
38
+ excerpt?: string;
39
+ /**
40
+ * When true, pass children through without the popover wrapper.
41
+ * Hosts set this when `markupDisplay === "no-markup"` or when
42
+ * hovering previews would be noisy (selection active, print preview,
43
+ * etc).
44
+ */
45
+ hidden?: boolean;
46
+ children: React.ReactNode;
47
+ }
48
+
49
+ const KIND_LABEL: Record<RevisionKind, string> = {
50
+ insertion: "Inserted",
51
+ deletion: "Deleted",
52
+ formatting: "Formatting change",
53
+ };
54
+
55
+ export function TwRevisionHoverPreview(props: TwRevisionHoverPreviewProps): React.ReactElement {
56
+ const { hidden, children } = props;
57
+ const [open, setOpen] = useState(false);
58
+ const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
59
+
60
+ const cancelTimer = useCallback(() => {
61
+ if (openTimerRef.current !== null) {
62
+ clearTimeout(openTimerRef.current);
63
+ openTimerRef.current = null;
64
+ }
65
+ }, []);
66
+
67
+ const handleMouseEnter = useCallback(() => {
68
+ cancelTimer();
69
+ openTimerRef.current = setTimeout(() => {
70
+ setOpen(true);
71
+ openTimerRef.current = null;
72
+ }, HOVER_DELAY_MS);
73
+ }, [cancelTimer]);
74
+
75
+ const handleMouseLeave = useCallback(() => {
76
+ cancelTimer();
77
+ // Defer close so moving onto the Popover.Content keeps it open.
78
+ openTimerRef.current = setTimeout(() => {
79
+ setOpen(false);
80
+ openTimerRef.current = null;
81
+ }, 80);
82
+ }, [cancelTimer]);
83
+
84
+ if (hidden) {
85
+ return <>{children}</>;
86
+ }
87
+
88
+ const authorColor = getAuthorColor(props.authorId);
89
+ const author = props.authorDisplayName ?? props.authorId ?? "Unknown author";
90
+
91
+ return (
92
+ <Popover.Root open={open} onOpenChange={setOpen}>
93
+ <Popover.Trigger asChild>
94
+ <span
95
+ data-revision-id={props.revisionId}
96
+ data-revision-hover-trigger=""
97
+ onMouseEnter={handleMouseEnter}
98
+ onMouseLeave={handleMouseLeave}
99
+ >
100
+ {children}
101
+ </span>
102
+ </Popover.Trigger>
103
+ <Popover.Portal>
104
+ <Popover.Content
105
+ role="dialog"
106
+ aria-label={`Revision by ${author}`}
107
+ className="wre-revision-hover-card z-50 w-[280px] rounded-[var(--radius-card,8px)] bg-[var(--color-card,var(--color-surface))] p-3 text-[11px] shadow-[var(--shadow-soft)] ring-1 ring-[var(--color-border-default)]"
108
+ sideOffset={6}
109
+ onMouseEnter={cancelTimer}
110
+ onMouseLeave={handleMouseLeave}
111
+ data-testid="revision-hover-preview-content"
112
+ >
113
+ <div className="flex items-center gap-2">
114
+ {authorColor ? (
115
+ <span
116
+ aria-hidden="true"
117
+ data-kind="author-swatch"
118
+ style={{
119
+ display: "inline-block",
120
+ width: "10px",
121
+ height: "10px",
122
+ borderRadius: "var(--radius-pill)",
123
+ backgroundColor: authorColor,
124
+ }}
125
+ />
126
+ ) : null}
127
+ <span className="font-medium text-primary">{author}</span>
128
+ {props.relativeTime ? (
129
+ <span className="text-tertiary">· {props.relativeTime}</span>
130
+ ) : null}
131
+ </div>
132
+ <div className="mt-1 text-tertiary">{KIND_LABEL[props.kind]}</div>
133
+ {props.excerpt ? (
134
+ <div className="mt-2 rounded bg-[var(--color-surface)] p-2 text-secondary">
135
+ {props.excerpt}
136
+ </div>
137
+ ) : null}
138
+ </Popover.Content>
139
+ </Popover.Portal>
140
+ </Popover.Root>
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Exported for tests — guards against the palette drifting out of
146
+ * sync with the hover-preview swatch.
147
+ */
148
+ export { AUTHOR_PALETTE };
149
+
150
+ export default TwRevisionHoverPreview;
@@ -14,6 +14,7 @@ export interface TwSelectionToolFormattingProps {
14
14
  onSetTextColor?: (color: string) => void;
15
15
  onSetHighlightColor?: (color: string | null) => void;
16
16
  onAddComment?: () => void;
17
+ density?: "micro" | "full";
17
18
  }
18
19
 
19
20
  export const TwSelectionToolFormatting = forwardRef<HTMLDivElement, TwSelectionToolFormattingProps>(
@@ -31,6 +32,7 @@ export const TwSelectionToolFormatting = forwardRef<HTMLDivElement, TwSelectionT
31
32
  onSetTextColor={props.onSetTextColor}
32
33
  onSetHighlightColor={props.onSetHighlightColor}
33
34
  onAddComment={props.onAddComment}
35
+ density={props.density}
34
36
  />
35
37
  );
36
38
  },
@@ -1,4 +1,4 @@
1
- import React, { useCallback, type CSSProperties, type FocusEventHandler, type Ref } from "react";
1
+ import React, { useCallback, useEffect, useState, type CSSProperties, type FocusEventHandler, type Ref } from "react";
2
2
 
3
3
  import type {
4
4
  ChromePinsState,
@@ -73,7 +73,41 @@ export interface TwSelectionToolHostProps {
73
73
  onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
74
74
  }
75
75
 
76
+ /**
77
+ * Dwell promotion: starts at "micro" on selection change, promotes to
78
+ * "full" after 150ms of selection stability. Keyed on the tool kind and
79
+ * previewText so that a new selection (which produces a new previewText)
80
+ * resets the timer. Pure re-renders with the same selection do not reset.
81
+ */
82
+ function useDwellDensity(tool: ActiveSelectionToolModel | null): "micro" | "full" {
83
+ // Derive a stable selection key from the tool. For formatting-inline we
84
+ // use the previewText (reflects selected content). For other kinds we use
85
+ // a fixed sentinel so they never reset mid-render.
86
+ const selectionKey = tool
87
+ ? `${tool.kind}:${tool.previewText ?? "none"}`
88
+ : null;
89
+ const [density, setDensity] = useState<"micro" | "full">("micro");
90
+
91
+ useEffect(() => {
92
+ if (selectionKey === null) {
93
+ setDensity("micro");
94
+ return;
95
+ }
96
+ setDensity("micro");
97
+ const handle = window.setTimeout(() => {
98
+ setDensity("full");
99
+ }, 150);
100
+ return () => {
101
+ window.clearTimeout(handle);
102
+ };
103
+ }, [selectionKey]);
104
+
105
+ return density;
106
+ }
107
+
76
108
  export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
109
+ const density = useDwellDensity(props.tool);
110
+
77
111
  if (!props.tool) {
78
112
  return null;
79
113
  }
@@ -96,7 +130,7 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
96
130
  );
97
131
 
98
132
  const overlayTestId = getOverlayTestId(props.tool.kind, Boolean(props.placement));
99
- const toolContent = renderTool(props, props.tool);
133
+ const toolContent = renderTool(props, props.tool, density);
100
134
  const content = toolContent ? (
101
135
  <div
102
136
  ref={props.rootRef}
@@ -185,6 +219,7 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
185
219
  function renderTool(
186
220
  props: TwSelectionToolHostProps,
187
221
  tool: ActiveSelectionToolModel,
222
+ density: "micro" | "full",
188
223
  ): React.ReactNode {
189
224
  switch (tool.kind) {
190
225
  case "formatting-inline":
@@ -197,6 +232,7 @@ function renderTool(
197
232
  onSetTextColor={props.onSetTextColor}
198
233
  onSetHighlightColor={props.onSetHighlightColor}
199
234
  onAddComment={props.onAddComment}
235
+ density={density}
200
236
  />
201
237
  );
202
238
  case "suggestion-review":