@beyondwork/docx-react-component 1.0.56 → 1.0.58

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 (113) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +330 -0
  4. package/src/compare/diff-engine.ts +3 -0
  5. package/src/core/commands/formatting-commands.ts +1 -0
  6. package/src/core/commands/index.ts +17 -11
  7. package/src/core/selection/mapping.ts +18 -1
  8. package/src/core/selection/review-anchors.ts +29 -18
  9. package/src/io/chart-preview-resolver.ts +175 -41
  10. package/src/io/docx-session.ts +57 -2
  11. package/src/io/export/serialize-main-document.ts +82 -0
  12. package/src/io/export/serialize-styles.ts +61 -3
  13. package/src/io/export/table-properties-xml.ts +19 -4
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-anchor.ts +182 -0
  16. package/src/io/ooxml/parse-drawing.ts +319 -0
  17. package/src/io/ooxml/parse-fields.ts +115 -2
  18. package/src/io/ooxml/parse-fill.ts +215 -0
  19. package/src/io/ooxml/parse-font-table.ts +190 -0
  20. package/src/io/ooxml/parse-footnotes.ts +52 -1
  21. package/src/io/ooxml/parse-main-document.ts +241 -1
  22. package/src/io/ooxml/parse-numbering.ts +96 -0
  23. package/src/io/ooxml/parse-picture.ts +158 -0
  24. package/src/io/ooxml/parse-settings.ts +34 -0
  25. package/src/io/ooxml/parse-shapes.ts +87 -0
  26. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  27. package/src/io/ooxml/parse-styles.ts +74 -1
  28. package/src/io/ooxml/parse-theme.ts +60 -0
  29. package/src/io/paste/html-clipboard.ts +449 -0
  30. package/src/io/paste/word-clipboard.ts +5 -1
  31. package/src/legal/_document-root.ts +26 -0
  32. package/src/legal/bookmarks.ts +4 -3
  33. package/src/legal/cross-references.ts +3 -2
  34. package/src/legal/defined-terms.ts +2 -1
  35. package/src/legal/signature-blocks.ts +2 -1
  36. package/src/model/canonical-document.ts +421 -3
  37. package/src/runtime/chart/chart-model-store.ts +73 -10
  38. package/src/runtime/document-runtime.ts +760 -41
  39. package/src/runtime/document-search.ts +61 -0
  40. package/src/runtime/edit-ops/index.ts +129 -0
  41. package/src/runtime/event-refresh-hints.ts +7 -0
  42. package/src/runtime/field-resolver.ts +341 -0
  43. package/src/runtime/footnote-resolver.ts +55 -0
  44. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  45. package/src/runtime/object-grab/index.ts +51 -0
  46. package/src/runtime/paragraph-style-resolver.ts +105 -0
  47. package/src/runtime/query-scopes.ts +186 -0
  48. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  49. package/src/runtime/scope-resolver.ts +60 -0
  50. package/src/runtime/selection/cursor-ops.ts +186 -15
  51. package/src/runtime/selection/index.ts +17 -1
  52. package/src/runtime/structure-ops/index.ts +77 -0
  53. package/src/runtime/styles-cascade.ts +33 -0
  54. package/src/runtime/surface-projection.ts +192 -12
  55. package/src/runtime/theme-color-resolver.ts +189 -44
  56. package/src/runtime/units.ts +46 -0
  57. package/src/runtime/view-state.ts +13 -2
  58. package/src/ui/WordReviewEditor.tsx +239 -11
  59. package/src/ui/editor-runtime-boundary.ts +97 -1
  60. package/src/ui/editor-shell-view.tsx +1 -1
  61. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  62. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  63. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  64. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  65. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  66. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  67. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  68. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  69. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  70. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  71. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  72. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  73. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  74. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  75. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  76. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  77. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  78. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  79. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  80. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  81. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +24 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +157 -0
  86. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  88. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  89. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  90. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  91. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  92. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  93. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  94. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  95. package/src/ui-tailwind/editor-surface/pm-schema.ts +214 -11
  96. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +32 -2
  97. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  98. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  99. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  100. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  101. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  102. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  103. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  104. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  105. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  106. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  107. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  108. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  109. package/src/ui-tailwind/theme/tokens.css +6 -0
  110. package/src/ui-tailwind/theme/tokens.ts +10 -0
  111. package/src/ui-tailwind/tw-review-workspace.tsx +23 -0
  112. package/src/validation/compatibility-engine.ts +2 -0
  113. package/src/validation/docx-comment-proof.ts +12 -3
@@ -73,6 +73,7 @@ import type {
73
73
  WordReviewEditorLayoutFacet,
74
74
  } from "../../api/public-types.ts";
75
75
  import {
76
+ measureWidgetsViaOffsetChain,
76
77
  measureWidgetsViaBoundingRect,
77
78
  resolvePageOverlayRects,
78
79
  type PageOverlayRect,
@@ -176,7 +177,10 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
176
177
  const origin = overlayRootRef.current;
177
178
  const pageCount = facet.getPageCount();
178
179
  if (origin) {
179
- const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin);
180
+ const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
181
+ pageCount,
182
+ visiblePageIndexRange,
183
+ });
180
184
  const originRect = origin.getBoundingClientRect();
181
185
  // jsdom + SSR never populate `origin.clientHeight` (no layout
182
186
  // pass). Fall back to `originRect.height`, and if that's also
@@ -194,12 +198,24 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
194
198
  widgets,
195
199
  pageCount,
196
200
  scrollHeight,
201
+ visiblePageIndexRange,
197
202
  }),
198
203
  );
199
204
  } else {
200
- setRects(resolvePageOverlayRects([scrollRoot, pageCount]));
205
+ const widgets = measureWidgetsViaOffsetChain(scrollRoot, {
206
+ pageCount,
207
+ visiblePageIndexRange,
208
+ });
209
+ setRects(
210
+ resolvePageOverlayRects({
211
+ widgets,
212
+ pageCount,
213
+ scrollHeight: scrollRoot.clientHeight,
214
+ visiblePageIndexRange,
215
+ }),
216
+ );
201
217
  }
202
- }, [facet, scrollRoot]);
218
+ }, [facet, scrollRoot, visiblePageIndexRange]);
203
219
 
204
220
  const refreshRects = React.useCallback(() => {
205
221
  if (!scrollRoot) {
@@ -371,14 +387,14 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
371
387
  }
372
388
  style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
373
389
  >
374
- {rects.map((rect, pageIndex) => {
375
- const page = facet.getPage(pageIndex);
390
+ {rects.map((rect) => {
391
+ const page = facet.getPage(rect.pageIndex);
376
392
  if (!page) return null;
377
393
  return (
378
394
  <TwPageChromeEntry
379
395
  key={`page-chrome-${rect.pageId}`}
380
396
  rect={rect}
381
- pageIndex={pageIndex}
397
+ pageIndex={rect.pageIndex}
382
398
  page={page}
383
399
  facet={facet}
384
400
  activeStory={activeStory}
@@ -159,7 +159,7 @@ function CommentThreadCard(props: {
159
159
  const showExcerpt = Boolean(thread.excerpt) && !isDraftThread && thread.excerpt !== "Empty thread";
160
160
 
161
161
  const scrollRef = useCallback(
162
- (node: HTMLDivElement | null) => {
162
+ (node: HTMLButtonElement | null) => {
163
163
  if (node && isActive && typeof node.scrollIntoView === "function") {
164
164
  node.scrollIntoView({ behavior: "smooth", block: "nearest" });
165
165
  }
@@ -168,14 +168,13 @@ function CommentThreadCard(props: {
168
168
  );
169
169
 
170
170
  return (
171
- <div
171
+ <button
172
+ type="button"
172
173
  ref={scrollRef}
173
174
  data-comment-thread-id={thread.commentId}
174
175
  data-comment-thread-status={thread.status}
175
- role="button"
176
- tabIndex={0}
177
176
  className={[
178
- "cursor-pointer rounded-lg bg-surface/90 px-3 py-2.5 transition-colors ring-1 ring-border/40",
177
+ "w-full text-left cursor-pointer rounded-lg bg-surface/90 px-3 py-2.5 transition-colors ring-1 ring-border",
179
178
  focusRingClass,
180
179
  isActive
181
180
  ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]"
@@ -185,12 +184,6 @@ function CommentThreadCard(props: {
185
184
  : "",
186
185
  ].join(" ")}
187
186
  onClick={() => props.onOpenComment?.(thread)}
188
- onKeyDown={(event) => {
189
- if (event.key === "Enter" || event.key === " ") {
190
- event.preventDefault();
191
- props.onOpenComment?.(thread);
192
- }
193
- }}
194
187
  >
195
188
  {/* Header row: avatar + author + date + status */}
196
189
  <div className="mb-1.5 flex items-center gap-1.5">
@@ -260,7 +253,7 @@ function CommentThreadCard(props: {
260
253
  {thread.entries.slice(1).map((entry) => {
261
254
  const replyPresentation = replyPresentationByEntryId.get(entry.entryId);
262
255
  return (
263
- <div key={entry.entryId} className="mt-2 ml-4 border-l border-border/50 pl-2.5">
256
+ <div key={entry.entryId} className="mt-2 ml-4 border-l border-border pl-2.5">
264
257
  <div className="mb-0.5 flex items-center gap-1">
265
258
  <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
266
259
  <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
@@ -321,7 +314,7 @@ function CommentThreadCard(props: {
321
314
  <span className="text-[9px] text-comment">Detached</span>
322
315
  )}
323
316
  </div>
324
- </div>
317
+ </button>
325
318
  );
326
319
  }
327
320
 
@@ -338,7 +331,8 @@ function InlineEditableBody(props: {
338
331
  useEffect(() => {
339
332
  if (isEditing && textareaRef.current) {
340
333
  textareaRef.current.focus();
341
- textareaRef.current.setSelectionRange(draft.length, draft.length);
334
+ const len = textareaRef.current.value.length;
335
+ textareaRef.current.setSelectionRange(len, len);
342
336
  }
343
337
  }, [isEditing]);
344
338
 
@@ -367,7 +361,7 @@ function InlineEditableBody(props: {
367
361
  ) : null}
368
362
  <textarea
369
363
  ref={textareaRef}
370
- className="w-full resize-none rounded-md bg-surface px-2 py-1.5 text-[10px] leading-4 text-primary placeholder:text-tertiary focus:outline-none focus:ring-1 focus:ring-accent ring-1 ring-border/50"
364
+ className="w-full resize-none rounded-md bg-surface px-2 py-1.5 text-[10px] leading-4 text-primary placeholder:text-tertiary focus:outline-none focus:ring-1 focus:ring-accent ring-1 ring-border"
371
365
  rows={2}
372
366
  value={draft}
373
367
  placeholder="Type your comment..."
@@ -428,7 +422,7 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
428
422
  <div className="w-full mt-1.5" onClick={(e) => e.stopPropagation()}>
429
423
  <textarea
430
424
  ref={inputRef}
431
- className="w-full resize-none rounded bg-surface px-2 py-1 text-[11px] text-primary placeholder:text-tertiary focus:outline-none focus:ring-1 focus:ring-accent ring-1 ring-border/50"
425
+ className="w-full resize-none rounded bg-surface px-2 py-1 text-[11px] text-primary placeholder:text-tertiary focus:outline-none focus:ring-1 focus:ring-accent ring-1 ring-border"
432
426
  rows={2}
433
427
  placeholder="Reply..."
434
428
  value={body}
@@ -187,28 +187,3 @@ export function TwHealthPanel(props: TwHealthPanelProps) {
187
187
  );
188
188
  }
189
189
 
190
- function FeatureClassBadge(props: { featureClass: CompatibilityFeatureEntry["featureClass"] }) {
191
- const styles: Record<string, string> = {
192
- "supported-roundtrip":
193
- "text-[var(--color-semantic-success)] bg-[var(--color-semantic-success-soft)]",
194
- "preserve-only":
195
- "text-[var(--color-semantic-warning)] bg-[var(--color-semantic-warning-soft)]",
196
- "unsupported-fatal":
197
- "text-[var(--color-semantic-error)] bg-[var(--color-semantic-error-soft)]",
198
- };
199
- const labels: Record<string, string> = {
200
- "supported-roundtrip": "supported",
201
- "preserve-only": "preserve-only",
202
- "unsupported-fatal": "blocked",
203
- };
204
- return (
205
- <span
206
- className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${styles[props.featureClass] ?? ""}`}
207
- >
208
- {labels[props.featureClass]}
209
- </span>
210
- );
211
- }
212
-
213
- // Keep FeatureClassBadge exported for potential external use
214
- export { FeatureClassBadge };
@@ -23,6 +23,13 @@ export type RailCardTone =
23
23
 
24
24
  export interface RailCardAvatar {
25
25
  initials: string;
26
+ /**
27
+ * Author-specific avatar background color. Caller-provided CSS color string
28
+ * (hex, rgb, hsl, or named). Intentionally bypasses the design token system:
29
+ * author identity colors must remain stable across light/dark themes and
30
+ * across workspace re-brands. Callers should provide a color with sufficient
31
+ * contrast against white `initials` text (WCAG AA 4.5:1).
32
+ */
26
33
  color?: string;
27
34
  alt?: string;
28
35
  }
@@ -80,28 +87,14 @@ export function TwRailCard(props: TwRailCardProps) {
80
87
  } = props;
81
88
 
82
89
  const handleClick = onClick || onSelect;
83
- const tag: "article" | "button" = handleClick ? "button" : "article";
84
90
 
85
91
  const clamped = progress
86
92
  ? Math.max(0, Math.min(1, progress.total && progress.total > 0 ? progress.value / progress.total : progress.value))
87
93
  : 0;
88
94
 
89
- const commonProps: Record<string, unknown> = {
90
- className: `wre-rail-card block w-full text-left${isFocused ? " ring-1 ring-[var(--color-accent-primary)]/30" : ""}`,
91
- "data-tone": tone,
92
- "data-active": isActive ? "true" : "false",
93
- "data-focused": isFocused ? "true" : undefined,
94
- "data-testid": dataTestId,
95
- };
96
-
97
- if (handleClick) {
98
- commonProps.onClick = handleClick;
99
- commonProps.type = "button";
100
- }
95
+ const sharedClassName = `wre-rail-card block w-full text-left${isFocused ? " ring-1 ring-[var(--color-accent-primary)]/30" : ""}`;
101
96
 
102
- return React.createElement(
103
- tag,
104
- commonProps,
97
+ const cardChildren = (
105
98
  <>
106
99
  {counter ? (
107
100
  <span className="wre-rail-card__counter" aria-label={counter.label} title={counter.label}>
@@ -153,6 +146,34 @@ export function TwRailCard(props: TwRailCardProps) {
153
146
  />
154
147
  </span>
155
148
  ) : null}
156
- </>,
149
+ </>
150
+ );
151
+
152
+ if (handleClick) {
153
+ return (
154
+ <button
155
+ type="button"
156
+ className={sharedClassName}
157
+ data-tone={tone}
158
+ data-active={isActive ? "true" : "false"}
159
+ data-focused={isFocused ? "true" : undefined}
160
+ data-testid={dataTestId}
161
+ onClick={handleClick}
162
+ >
163
+ {cardChildren}
164
+ </button>
165
+ );
166
+ }
167
+
168
+ return (
169
+ <article
170
+ className={sharedClassName}
171
+ data-tone={tone}
172
+ data-active={isActive ? "true" : "false"}
173
+ data-focused={isFocused ? "true" : undefined}
174
+ data-testid={dataTestId}
175
+ >
176
+ {cardChildren}
177
+ </article>
157
178
  );
158
179
  }
@@ -157,8 +157,8 @@ export function TwReviewRail(props: TwReviewRailProps) {
157
157
  <Tabs.List
158
158
  className={
159
159
  editorial
160
- ? "flex shrink-0 items-center gap-2 border-b border-border/60 px-2"
161
- : "flex shrink-0 border-b border-border/60 px-3 py-2"
160
+ ? "flex shrink-0 items-center gap-2 border-b border-border px-2"
161
+ : "flex shrink-0 border-b border-border px-3 py-2"
162
162
  }
163
163
  style={
164
164
  editorial
@@ -120,18 +120,11 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
120
120
  const isActive = activeRevisionId === rev.revisionId;
121
121
 
122
122
  return (
123
- <div
123
+ <button
124
124
  key={rev.revisionId}
125
- role="button"
126
- tabIndex={0}
127
- className={`flex cursor-pointer rounded-md bg-surface/90 transition-colors ring-1 ring-border/40 ${focusRingClass} ${isActive ? "bg-accent-soft/40 ring-accent/25" : "hover:bg-surface"}`}
125
+ type="button"
126
+ className={`w-full text-left flex cursor-pointer rounded-md bg-surface/90 transition-colors ring-1 ring-border ${focusRingClass} ${isActive ? "bg-accent-soft/40 ring-accent/25" : "hover:bg-surface"}`}
128
127
  onClick={() => props.onOpenRevision?.(rev)}
129
- onKeyDown={(event) => {
130
- if (event.key === "Enter" || event.key === " ") {
131
- event.preventDefault();
132
- props.onOpenRevision?.(rev);
133
- }
134
- }}
135
128
  >
136
129
  <div className={`w-0.5 shrink-0 rounded-l-md ${
137
130
  rev.kind === "insertion" ? "bg-insert"
@@ -189,12 +182,12 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
189
182
  )}
190
183
  </div>
191
184
  </div>
192
- </div>
185
+ </button>
193
186
  );
194
187
  })}
195
188
  </div>
196
189
  ) : (
197
- <p className="rounded-lg bg-surface px-3 py-4 text-xs text-tertiary ring-1 ring-border/40">
190
+ <p className="rounded-lg bg-surface px-3 py-4 text-xs text-tertiary ring-1 ring-border">
198
191
  {trackedChanges.totalCount > 0
199
192
  ? (visibleRevisions.length > 0
200
193
  ? "No revisions match the current filter."
@@ -54,7 +54,7 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
54
54
  if (uniqueSegments.length === 0) {
55
55
  return (
56
56
  <div
57
- className="wre-workflow-tab-empty rounded-md border border-dashed border-border/60 bg-canvas/50 p-4 text-[11px] text-tertiary"
57
+ className="wre-workflow-tab-empty rounded-md border border-dashed border-border bg-canvas/50 p-4 text-[11px] text-tertiary"
58
58
  data-testid="workflow-tab-empty"
59
59
  >
60
60
  <div className="font-semibold uppercase tracking-[0.1em] text-secondary">
@@ -82,7 +82,7 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
82
82
  <button
83
83
  key={segment.scopeId}
84
84
  type="button"
85
- className={`wre-workflow-card flex flex-col gap-1 rounded-md border border-border/50 bg-canvas p-3 text-left transition-shadow hover:shadow-md ${
85
+ className={`wre-workflow-card flex flex-col gap-1 rounded-md border border-border bg-canvas p-3 text-left transition-shadow hover:shadow-md ${
86
86
  isActive ? "ring-1 ring-accent/60" : ""
87
87
  }`}
88
88
  onClick={() => {
@@ -154,6 +154,7 @@
154
154
  * instead of near-black. Accents drop from neon to the documented forest
155
155
  * green so they do not glow against the deep slate canvas.
156
156
  */
157
+ [data-theme="dark"],
157
158
  .dark {
158
159
  --color-surface: #182420;
159
160
  --color-surface-hover: #20302a;
@@ -91,6 +91,9 @@
91
91
  --color-change-comment: #E8F4EC;
92
92
  --color-change-selection: #DDF1E4;
93
93
 
94
+ /* Highlight (user-driven content, stable across themes — §3 cell-fill + text-highlight family) */
95
+ --color-highlight-default: #FFF59D;
96
+
94
97
  /* Chart — categorical */
95
98
  --color-chart-categorical-1: #1F6B4F;
96
99
  --color-chart-categorical-2: #72D6AE;
@@ -236,6 +239,9 @@
236
239
  --color-change-comment: #21342A;
237
240
  --color-change-selection: #294235;
238
241
 
242
+ /* Highlight (dimmed to avoid glare; same chroma family as light-mode yellow) */
243
+ --color-highlight-default: #B8A829;
244
+
239
245
  /* Chart — categorical */
240
246
  --color-chart-categorical-1: #53B487;
241
247
  --color-chart-categorical-2: #9AE7C7;
@@ -87,6 +87,15 @@ export const BRAND_TOKENS = {
87
87
  comment: "#E8F4EC",
88
88
  selection: "#DDF1E4",
89
89
  },
90
+ highlight: {
91
+ // Text-highlighter yellow. User-driven content color (like cell fills
92
+ // and author avatars) — intentionally stable across themes so highlights
93
+ // persist when the document is re-opened under a different theme.
94
+ // Light mode picks Material Amber-A100 for contrast on white canvas;
95
+ // dark mode dims the same chroma (~#B8A829) to avoid glare on dark
96
+ // canvas while keeping the mark recognizably "yellow".
97
+ default: "#FFF59D",
98
+ },
90
99
  scopeTint: {
91
100
  blocked: "#FBE3E6",
92
101
  inScope: "#E2F2E8",
@@ -170,6 +179,7 @@ export type BrandTokenPath =
170
179
  | `color.status.${keyof BrandTokens["color"]["status"]}`
171
180
  | `color.comment.${keyof BrandTokens["color"]["comment"]}`
172
181
  | `color.change.${keyof BrandTokens["color"]["change"]}`
182
+ | `color.highlight.${keyof BrandTokens["color"]["highlight"]}`
173
183
  | `color.scopeTint.${keyof BrandTokens["color"]["scopeTint"]}`
174
184
  | `color.chart.categorical.${keyof BrandTokens["color"]["chart"]["categorical"]}`
175
185
  | `color.chart.sequential.${keyof BrandTokens["color"]["chart"]["sequential"]}`
@@ -198,6 +198,8 @@ export interface TwReviewWorkspaceProps {
198
198
  selectionContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
199
199
  currentScopeContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
200
200
  commands: EditorCommandBag;
201
+ /** N6 — release the grabbed image/shape. Wired to `runtime.deselectObject()` by the host. */
202
+ onDeselectObject?: () => void;
201
203
  activeSelectionTool?: ActiveSelectionToolModel | null;
202
204
  selectionToolAnchor?: SelectionToolAnchor | null;
203
205
  documentNavigation?: DocumentNavigationSnapshot;
@@ -680,6 +682,23 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
680
682
  // above bumps `renderFrameRevision` on the same kinds; including it
681
683
  // in the dependency list re-runs this memo without a separate
682
684
  // subscription.
685
+ // N6 — resolve grabbed-object segment offsets from the surface so the
686
+ // selection overlay can query the anchor index without a full surface walk.
687
+ const grabbedSegmentOffsets = useMemo(() => {
688
+ const objectId = snapshot.grabbedObjectId ?? null;
689
+ if (!objectId || !snapshot.surface) return null;
690
+ for (const block of snapshot.surface.blocks) {
691
+ if (!("segments" in block)) continue;
692
+ for (const seg of (block as { segments?: unknown[] }).segments ?? []) {
693
+ const s = seg as { kind?: string; mediaId?: string; from?: number; to?: number };
694
+ if ((s.kind === "image" || s.kind === "shape") && s.mediaId === objectId && s.from != null) {
695
+ return { from: s.from, to: s.to ?? s.from + 1 };
696
+ }
697
+ }
698
+ }
699
+ return null;
700
+ }, [snapshot.grabbedObjectId, snapshot.surface]);
701
+
683
702
  const statusBarPageFacts = useMemo(() => {
684
703
  const facet = props.layoutFacet;
685
704
  if (!facet) {
@@ -1662,6 +1681,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1662
1681
  {props.layoutFacet ? (
1663
1682
  <TwChromeOverlay
1664
1683
  facet={props.layoutFacet}
1684
+ grabbedObjectId={snapshot.grabbedObjectId ?? null}
1685
+ grabbedObjectFromOffset={grabbedSegmentOffsets?.from ?? null}
1686
+ grabbedObjectToOffset={grabbedSegmentOffsets?.to ?? null}
1687
+ onDeselectObject={props.onDeselectObject}
1665
1688
  tableContext={props.tableContext}
1666
1689
  onSetColumnWidth={props.onSetColumnWidth}
1667
1690
  onSetRowHeight={props.onSetRowHeight}
@@ -444,6 +444,7 @@ function measureInlineNode(
444
444
  flags.hyperlinks = true;
445
445
  return node.children.reduce((size, child) => size + measureInlineNode(child, flags), 0);
446
446
  case "image":
447
+ case "drawing_frame":
447
448
  flags.images = true;
448
449
  flags.runs = true;
449
450
  return 1;
@@ -778,6 +779,7 @@ function collectLossyInlineContent(
778
779
  case "field":
779
780
  case "bookmark_start":
780
781
  case "bookmark_end":
782
+ case "drawing_frame":
781
783
  case "shape":
782
784
  case "wordart":
783
785
  case "vml_shape":
@@ -70,6 +70,7 @@ export type ClosureValidationCheck =
70
70
  | { type: "minCommentThreads"; count: number }
71
71
  | { type: "lockedFragmentCountAtLeast"; count: number }
72
72
  | { type: "surfaceBlockKind"; kind: string; count?: number }
73
+ | { type: "segmentLabelPrefix"; value: string; kind?: string }
73
74
  | { type: "opaqueBlockLabelPrefix"; value: string }
74
75
  | { type: "opaqueInlineLabelPrefix"; value: string }
75
76
  | {
@@ -238,6 +239,12 @@ export function evaluateClosureCheck(
238
239
  (check.count ?? 1),
239
240
  reason: `expected at least ${check.count ?? 1} surface blocks of kind ${check.kind}`,
240
241
  };
242
+ case "segmentLabelPrefix":
243
+ return {
244
+ type: check.type,
245
+ passed: hasSegmentLabelPrefix(context.surface, check.value, check.kind),
246
+ reason: `expected a ${check.kind ?? "surface"} segment label starting with ${check.value}`,
247
+ };
241
248
  case "opaqueBlockLabelPrefix":
242
249
  return {
243
250
  type: check.type,
@@ -253,7 +260,7 @@ export function evaluateClosureCheck(
253
260
  case "opaqueInlineLabelPrefix":
254
261
  return {
255
262
  type: check.type,
256
- passed: hasOpaqueInlineLabelPrefix(context.surface, check.value),
263
+ passed: hasSegmentLabelPrefix(context.surface, check.value, "opaque_inline"),
257
264
  reason: `expected an opaque inline label starting with ${check.value}`,
258
265
  };
259
266
  case "trackedChangeMatch": {
@@ -458,9 +465,10 @@ export function extractDocxCommentProof(bytes: Uint8Array): DocxCommentProof {
458
465
  };
459
466
  }
460
467
 
461
- function hasOpaqueInlineLabelPrefix(
468
+ function hasSegmentLabelPrefix(
462
469
  surface: RuntimeRenderSnapshot["surface"],
463
470
  prefix: string,
471
+ segmentKind?: string,
464
472
  ): boolean {
465
473
  if (!surface) {
466
474
  return false;
@@ -474,7 +482,8 @@ function hasOpaqueInlineLabelPrefix(
474
482
  if (
475
483
  block.segments.some(
476
484
  (segment) =>
477
- segment.kind === "opaque_inline" &&
485
+ (segmentKind === undefined || segment.kind === segmentKind) &&
486
+ "label" in segment &&
478
487
  typeof segment.label === "string" &&
479
488
  segment.label.startsWith(prefix),
480
489
  )