@beyondwork/docx-react-component 1.0.41 → 1.0.42

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 (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -77,6 +77,7 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
77
77
  const DEFAULT_RAIL_LANE_PX = 44;
78
78
  const STRIPE_WIDTH_PX = 4;
79
79
  const LABEL_WIDTH_PX = 40;
80
+ const STACK_OFFSET_PX = 6;
80
81
 
81
82
  export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
82
83
  facet,
@@ -96,6 +97,27 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
96
97
  return null;
97
98
  }
98
99
 
100
+ // P2: which scopes currently have a `source: "ai"` candidate
101
+ // overlapping — drives the agent-pending shimmer class on their
102
+ // tints. Reads from the facet's card-model projection so the
103
+ // shimmer logic lives in the runtime, not the overlay.
104
+ const cardModels =
105
+ typeof facet.getAllScopeCardModels === "function"
106
+ ? facet.getAllScopeCardModels()
107
+ : [];
108
+ const agentPendingByScope = new Map<string, boolean>();
109
+ for (const model of cardModels) {
110
+ if (model.agentPending) {
111
+ agentPendingByScope.set(model.scopeId, true);
112
+ }
113
+ }
114
+
115
+ // P3c: stack offsets for overlapping scopes. Two scopes whose
116
+ // offset ranges intersect on the same page render as stacked
117
+ // stripes in the gutter; the inner stripe shifts STACK_OFFSET_PX
118
+ // further right per overlap count so all are clickable.
119
+ const stackIndexByScope = computeStackIndices(segments);
120
+
99
121
  const projectorSpace: OverlayCoordinateSpace = space ?? { originLeftPx: 0, originTopPx: 0 };
100
122
 
101
123
  return (
@@ -121,14 +143,20 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
121
143
  const stripeTopPx = firstLine.topPx;
122
144
  const stripeHeightPx =
123
145
  lastLine.topPx + lastLine.heightPx - firstLine.topPx;
146
+ const stackIndex = stackIndexByScope.get(segment.scopeId) ?? 0;
147
+ const stackOffset = stackIndex * STACK_OFFSET_PX;
124
148
  const stripeRect: RenderFrameRect = {
125
- leftPx: firstLine.leftPx - railLaneWidthPx + (railLaneWidthPx - STRIPE_WIDTH_PX) / 2,
149
+ leftPx:
150
+ firstLine.leftPx
151
+ - railLaneWidthPx
152
+ + (railLaneWidthPx - STRIPE_WIDTH_PX) / 2
153
+ + stackOffset,
126
154
  topPx: stripeTopPx,
127
155
  widthPx: STRIPE_WIDTH_PX,
128
156
  heightPx: Math.max(stripeHeightPx, 14),
129
157
  };
130
158
  const labelRect: RenderFrameRect = {
131
- leftPx: firstLine.leftPx - railLaneWidthPx,
159
+ leftPx: firstLine.leftPx - railLaneWidthPx + stackOffset,
132
160
  topPx: stripeTopPx,
133
161
  widthPx: LABEL_WIDTH_PX,
134
162
  heightPx: 20,
@@ -148,18 +176,28 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
148
176
  return (
149
177
  <React.Fragment key={`${segment.scopeId}:${segment.pageIndex}:${segment.fromOffset}`}>
150
178
  {/* Per-line tint behind the scoped text runs. */}
151
- {lineRects.map((lineRect, index) => (
152
- <div
153
- key={`tint:${index}`}
154
- className={`wre-scope-rail-tint wre-scope-rail-tint-${style.tintToken} ${
155
- isActive ? "wre-scope-rail-tint-active" : ""
156
- }`}
157
- data-scope-id={segment.scopeId}
158
- data-posture={segment.posture}
159
- data-line-index={index}
160
- style={projectRectToOverlay(lineRect, projectorSpace)}
161
- />
162
- ))}
179
+ {lineRects.map((lineRect, index) => {
180
+ const agentPending = agentPendingByScope.get(segment.scopeId) === true;
181
+ const tintClassList = [
182
+ "wre-scope-rail-tint",
183
+ `wre-scope-rail-tint-${style.tintToken}`,
184
+ ];
185
+ if (isActive) tintClassList.push("wre-scope-rail-tint-active");
186
+ if (agentPending) {
187
+ tintClassList.push("wre-scope-rail-tint-agent-pending");
188
+ }
189
+ return (
190
+ <div
191
+ key={`tint:${index}`}
192
+ className={tintClassList.join(" ")}
193
+ data-scope-id={segment.scopeId}
194
+ data-posture={segment.posture}
195
+ data-line-index={index}
196
+ data-agent-pending={agentPending ? "true" : undefined}
197
+ style={projectRectToOverlay(lineRect, projectorSpace)}
198
+ />
199
+ );
200
+ })}
163
201
  {/* Rail stripe in the gutter. */}
164
202
  <button
165
203
  type="button"
@@ -168,6 +206,7 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
168
206
  }`}
169
207
  data-scope-id={segment.scopeId}
170
208
  data-posture={segment.posture}
209
+ data-stack-index={stackIndex}
171
210
  data-testid="scope-rail-stripe"
172
211
  aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
173
212
  aria-expanded={isActive ? "true" : "false"}
@@ -246,4 +285,44 @@ function pushRectsFromPage(
246
285
  }
247
286
  }
248
287
 
288
+ /**
289
+ * P3c: Compute a stack index per scope so overlapping scopes render
290
+ * as offset stripes in the gutter instead of stacking on top of each
291
+ * other. For each scope's first segment, count earlier-document-
292
+ * order scopes whose offset range intersects on the same page — that
293
+ * count is the stack index (0 = outer / earliest, 1 = next, etc).
294
+ *
295
+ * Exported for unit testing.
296
+ */
297
+ export function computeStackIndices(
298
+ segments: readonly ScopeRailSegment[],
299
+ ): Map<string, number> {
300
+ const firstSegmentByScope = new Map<string, ScopeRailSegment>();
301
+ const scopeOrder: string[] = [];
302
+ for (const segment of segments) {
303
+ if (!firstSegmentByScope.has(segment.scopeId)) {
304
+ firstSegmentByScope.set(segment.scopeId, segment);
305
+ scopeOrder.push(segment.scopeId);
306
+ }
307
+ }
308
+
309
+ const result = new Map<string, number>();
310
+ for (let i = 0; i < scopeOrder.length; i += 1) {
311
+ const scopeId = scopeOrder[i];
312
+ const seg = firstSegmentByScope.get(scopeId);
313
+ if (!seg) continue;
314
+ let overlapCount = 0;
315
+ for (let j = 0; j < i; j += 1) {
316
+ const other = firstSegmentByScope.get(scopeOrder[j]);
317
+ if (!other) continue;
318
+ if (other.pageIndex !== seg.pageIndex) continue;
319
+ if (other.toOffset > seg.fromOffset && other.fromOffset < seg.toOffset) {
320
+ overlapCount += 1;
321
+ }
322
+ }
323
+ result.set(scopeId, overlapCount);
324
+ }
325
+ return result;
326
+ }
327
+
249
328
  export default TwScopeRailLayer;
@@ -23,6 +23,39 @@ type RailDecorationSpec = {
23
23
  attrs: Record<string, string>;
24
24
  };
25
25
 
26
+ /**
27
+ * Validate and normalize a host-supplied CSS color before interpolating it
28
+ * into an inline-style string. Accepts only the narrow subset a
29
+ * host-issued metadata color would legitimately use:
30
+ * - `#rgb`, `#rrggbb`, `#rrggbbaa` hex literals
31
+ * - `rgb(...)` / `rgba(...)` with numeric/percent/alpha args, no nested
32
+ * comments or closing-paren escape tricks
33
+ * - named colors drawn from a short whitelist
34
+ * Any semicolon, brace, or parenthesis outside the matched shape is
35
+ * rejected. Returns `null` when the input is unsafe; callers must drop
36
+ * the style attribute entirely in that case.
37
+ */
38
+ export function sanitizeHostCssColor(raw: unknown): string | null {
39
+ if (typeof raw !== "string") return null;
40
+ const value = raw.trim();
41
+ if (value.length === 0 || value.length > 64) return null;
42
+ // Hex: #rgb / #rrggbb / #rrggbbaa
43
+ if (/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$|^#[0-9a-fA-F]{8}$/.test(value)) {
44
+ return value;
45
+ }
46
+ // rgb()/rgba() with digits, dots, percent, commas and a single space.
47
+ if (/^rgba?\(\s*\d+(\.\d+)?%?(\s*,\s*\d+(\.\d+)?%?){2,3}\s*\)$/.test(value)) {
48
+ return value;
49
+ }
50
+ // Named colors — short whitelist that covers every host-chip use.
51
+ const named = new Set([
52
+ "black", "white", "red", "green", "blue", "yellow", "orange",
53
+ "purple", "pink", "brown", "gray", "grey", "transparent",
54
+ ]);
55
+ if (named.has(value.toLowerCase())) return value.toLowerCase();
56
+ return null;
57
+ }
58
+
26
59
  function isSelectionZoneScope(scope: WorkflowScope): boolean {
27
60
  return (
28
61
  scope.scopeId.startsWith("scope-lab-") ||
@@ -511,10 +544,11 @@ export function buildDecorations(
511
544
  const pmRange = buildAnchorPmRange(metadata.anchor, positionMap);
512
545
  if (!pmRange || !pmRange.allowInline || pmRange.from >= pmRange.to) continue;
513
546
 
547
+ const safeColor = sanitizeHostCssColor(metadata.color);
514
548
  decorations.push(
515
549
  Decoration.inline(pmRange.from, pmRange.to, {
516
550
  class: getWorkflowMetadataInlineClass(),
517
- ...(metadata.color ? { style: `--wre-workflow-metadata-color: ${metadata.color};` } : {}),
551
+ ...(safeColor ? { style: `--wre-workflow-metadata-color: ${safeColor};` } : {}),
518
552
  "data-workflow-entry-id": metadata.entryId,
519
553
  "data-workflow-metadata-id": metadata.metadataId,
520
554
  }),
@@ -178,7 +178,60 @@ interface ChromeWidgetInput {
178
178
  nextHeaderPreview: string;
179
179
  }
180
180
 
181
+ // P14.c — cache the widget DOM by input identity. PM rebuilds the
182
+ // page-break decorations on every commit (37 widgets per 38-page doc);
183
+ // each widget's DOM is ~10 elements. When the page IDs, labels, and
184
+ // preview text haven't changed (the common case during typing inside
185
+ // one page), returning the cached DOM lets PM's decoration
186
+ // reconciliation skip the remount entirely. Bounded LRU at 256 entries
187
+ // so a long session with significant content churn doesn't grow without
188
+ // bound.
189
+ const widgetDomCache = new Map<string, HTMLElement>();
190
+ const WIDGET_DOM_CACHE_LIMIT = 256;
191
+
192
+ function widgetCacheKey(input: ChromeWidgetInput): string {
193
+ return [
194
+ input.posture,
195
+ input.prevPageId,
196
+ input.nextPageId,
197
+ input.prevPageIndex,
198
+ input.nextPageIndex,
199
+ input.headerBandPx,
200
+ input.footerBandPx,
201
+ input.interGapPx,
202
+ input.prevPageLabel,
203
+ input.nextPageLabel,
204
+ input.hasPrevFooterStory ? "1" : "0",
205
+ input.hasNextHeaderStory ? "1" : "0",
206
+ input.prevFooterPreview,
207
+ input.nextHeaderPreview,
208
+ ].join("\x1f");
209
+ }
210
+
181
211
  function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
212
+ const key = widgetCacheKey(input);
213
+ const cached = widgetDomCache.get(key);
214
+ if (cached) {
215
+ // Touch LRU recency by re-inserting.
216
+ widgetDomCache.delete(key);
217
+ widgetDomCache.set(key, cached);
218
+ return cached;
219
+ }
220
+ const built = buildChromeWidgetDomUncached(input);
221
+ widgetDomCache.set(key, built);
222
+ if (widgetDomCache.size > WIDGET_DOM_CACHE_LIMIT) {
223
+ const oldest = widgetDomCache.keys().next().value;
224
+ if (oldest !== undefined) widgetDomCache.delete(oldest);
225
+ }
226
+ return built;
227
+ }
228
+
229
+ /** Test-only export — clears the cache between assertions. */
230
+ export function __resetPageBreakWidgetCache(): void {
231
+ widgetDomCache.clear();
232
+ }
233
+
234
+ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
182
235
  const root = document.createElement("div");
183
236
  root.className = "wre-page-chrome-widget";
184
237
  root.setAttribute("data-kind", "page-chrome-widget");
@@ -187,6 +240,12 @@ function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
187
240
  root.setAttribute("data-next-page-id", input.nextPageId);
188
241
  root.setAttribute("data-prev-page-index", String(input.prevPageIndex));
189
242
  root.setAttribute("data-next-page-index", String(input.nextPageIndex));
243
+ // P3.a: expose page-frame boundary markers so the page stack and tests
244
+ // can enumerate pages without re-walking the render graph. Each widget
245
+ // ends page N (`prev`) and starts page N+1 (`next`); the outer workspace
246
+ // frame still accounts for the boundaries at both ends of the document.
247
+ root.setAttribute("data-page-frame-end", input.prevPageId);
248
+ root.setAttribute("data-page-frame-start", input.nextPageId);
190
249
  root.contentEditable = "false";
191
250
  root.setAttribute("aria-hidden", "false");
192
251
  root.style.display = "block";
@@ -245,16 +304,40 @@ function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
245
304
  });
246
305
  root.appendChild(footer);
247
306
 
307
+ // P3.a: the inter-page gap is now a visible canvas strip that reads as
308
+ // "the space between two papers", not a subtle gradient inside one white
309
+ // page. The strip paints in the workspace canvas color (the same color
310
+ // the page frames float on in page mode), with subtle drop/rise shadows
311
+ // on either edge so the preceding footer reads as "bottom of page N's
312
+ // paper" and the following header reads as "top of page N+1's paper".
313
+ //
314
+ // The visual goal: bringing the user closer to a Word-native perception
315
+ // of distinct pages without requiring the PM editable tree to be split
316
+ // into per-page subtrees (that lands in P3.b).
248
317
  const separator = document.createElement("div");
249
318
  separator.className = "wre-page-chrome-separator";
319
+ separator.setAttribute("data-kind", "page-chrome-separator");
250
320
  separator.style.position = "absolute";
251
321
  separator.style.left = "0";
252
322
  separator.style.right = "0";
253
323
  separator.style.top = `${input.footerBandPx}px`;
254
324
  separator.style.height = `${input.interGapPx}px`;
255
- // Background: two subtle page-edge shadows mimicking real paper gap.
256
- separator.style.background =
257
- "linear-gradient(to bottom, rgba(0,0,0,0.045), rgba(0,0,0,0) 40%, rgba(0,0,0,0) 60%, rgba(0,0,0,0.035))";
325
+ // Canvas color (same as the scroll root's bg-surface) so the strip reads
326
+ // as "gap between two papers". Page mode and canvas mode both use the
327
+ // same token so the UX remains consistent at any chrome preset.
328
+ separator.style.backgroundColor = "var(--color-surface, #f1f5f9)";
329
+ // Inner shadows on top/bottom: the previous footer's bottom edge gains
330
+ // a subtle paper-edge shadow, and the next header's top edge likewise.
331
+ // Inset shadows let us avoid touching the footer / header DOM while
332
+ // keeping the shadows flush with the band borders.
333
+ separator.style.boxShadow = [
334
+ // Top edge — simulates the bottom shadow of page N's paper.
335
+ "inset 0 1px 0 rgba(15, 23, 42, 0.06)",
336
+ "inset 0 2px 3px -2px rgba(15, 23, 42, 0.12)",
337
+ // Bottom edge — simulates the top shadow of page N+1's paper.
338
+ "inset 0 -1px 0 rgba(15, 23, 42, 0.06)",
339
+ "inset 0 -2px 3px -2px rgba(15, 23, 42, 0.12)",
340
+ ].join(", ");
258
341
  root.appendChild(separator);
259
342
 
260
343
  const header = buildBand({
@@ -178,24 +178,24 @@ export const editorSchema = new Schema({
178
178
  const spacingBefore = node.attrs.spacingBefore as number | null;
179
179
  const contextualSpacingBefore = node.attrs.contextualSpacingBefore as boolean | null;
180
180
  if (contextualSpacingBefore) styles.push("margin-top: 0");
181
- else if (spacingBefore) styles.push(`margin-top: ${spacingBefore / 20}px`);
181
+ else if (spacingBefore) styles.push(`margin-top: ${spacingBefore / 20}pt`);
182
182
  const contextualSpacingAfter = node.attrs.contextualSpacingAfter as boolean | null;
183
183
  const spacingAfter = node.attrs.spacingAfter as number | null;
184
184
  if (contextualSpacingAfter) styles.push("margin-bottom: 0");
185
- else if (spacingAfter) styles.push(`margin-bottom: ${spacingAfter / 20}px`);
185
+ else if (spacingAfter) styles.push(`margin-bottom: ${spacingAfter / 20}pt`);
186
186
  const lineSpacing = node.attrs.lineSpacing as number | null;
187
187
  const lineRule = node.attrs.lineRule as string | null;
188
188
  if (lineSpacing && lineRule === "auto") styles.push(`line-height: ${lineSpacing / 240}`);
189
- else if (lineSpacing && lineRule === "exact") styles.push(`line-height: ${lineSpacing / 20}px`);
190
- else if (lineSpacing && lineRule === "atLeast") styles.push(`min-height: ${lineSpacing / 20}px`);
189
+ else if (lineSpacing && lineRule === "exact") styles.push(`line-height: ${lineSpacing / 20}pt`);
190
+ else if (lineSpacing && lineRule === "atLeast") styles.push(`min-height: ${lineSpacing / 20}pt`);
191
191
  const indentLeft = node.attrs.indentLeft as number | null;
192
- if (indentLeft) styles.push(`padding-left: ${indentLeft / 20}px`);
192
+ if (indentLeft) styles.push(`padding-left: ${indentLeft / 20}pt`);
193
193
  const indentRight = node.attrs.indentRight as number | null;
194
- if (indentRight) styles.push(`padding-right: ${indentRight / 20}px`);
194
+ if (indentRight) styles.push(`padding-right: ${indentRight / 20}pt`);
195
195
  const indentFirstLine = node.attrs.indentFirstLine as number | null;
196
196
  const indentHanging = node.attrs.indentHanging as number | null;
197
- if (indentHanging) styles.push(`text-indent: -${indentHanging / 20}px`);
198
- else if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}px`);
197
+ if (indentHanging) styles.push(`text-indent: -${indentHanging / 20}pt`);
198
+ else if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}pt`);
199
199
  const shadingColor = safeHexColor(node.attrs.shadingFill as string | null);
200
200
  if (shadingColor) styles.push(`background-color: ${shadingColor}`);
201
201
  for (const [side, attrName] of [["top", "borderTop"], ["bottom", "borderBottom"], ["left", "borderLeft"], ["right", "borderRight"]] as const) {
@@ -309,11 +309,13 @@ export const editorSchema = new Schema({
309
309
  }
310
310
 
311
311
  if (hasResolvedMarkerWidth) {
312
- const markerWidthPx = Math.max(1, Math.round(numberingMarkerWidth / 20));
312
+ // P13.a: emit marker geometry in pt (twips/20 == pt) so it
313
+ // self-scales under CSS `zoom` and matches Word's intent.
314
+ const markerWidthPt = Math.max(1, numberingMarkerWidth / 20);
313
315
  prefixStyles.push(
314
- `width: ${markerWidthPx}px`,
315
- `min-width: ${markerWidthPx}px`,
316
- `flex-basis: ${markerWidthPx}px`,
316
+ `width: ${markerWidthPt}pt`,
317
+ `min-width: ${markerWidthPt}pt`,
318
+ `flex-basis: ${markerWidthPt}pt`,
317
319
  `margin-right: 0`,
318
320
  `overflow: visible`,
319
321
  );
@@ -889,7 +891,7 @@ export const editorSchema = new Schema({
889
891
  attrs: { value: { default: 0 } },
890
892
  toDOM(mark) {
891
893
  const twips = mark.attrs.value as number;
892
- return ["span", { style: `letter-spacing: ${twips / 20}px` }, 0];
894
+ return ["span", { style: `letter-spacing: ${twips / 20}pt` }, 0];
893
895
  },
894
896
  },
895
897
  font_kerning: {
@@ -12,6 +12,23 @@ import {
12
12
  type RemoteCursorState,
13
13
  } from "../../runtime/collab/remote-cursor-awareness";
14
14
 
15
+ /**
16
+ * Remote `cursor.color` comes straight off Yjs Awareness — another peer
17
+ * can set it to anything. Validate to a narrow CSS-color allowlist
18
+ * before interpolating into an inline style, otherwise a malicious peer
19
+ * could inject arbitrary CSS declarations via `; background: url(...)`.
20
+ * Returns a fallback neutral color when the input is rejected.
21
+ */
22
+ const SAFE_REMOTE_CURSOR_FALLBACK = "#6b7280";
23
+ function safeRemoteCursorColor(raw: unknown): string {
24
+ if (typeof raw !== "string") return SAFE_REMOTE_CURSOR_FALLBACK;
25
+ const value = raw.trim();
26
+ if (value.length === 0 || value.length > 32) return SAFE_REMOTE_CURSOR_FALLBACK;
27
+ if (/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$|^#[0-9a-fA-F]{8}$/.test(value)) return value;
28
+ if (/^rgba?\(\s*\d+(\.\d+)?%?(\s*,\s*\d+(\.\d+)?%?){2,3}\s*\)$/.test(value)) return value;
29
+ return SAFE_REMOTE_CURSOR_FALLBACK;
30
+ }
31
+
15
32
  interface RemoteCursorPluginState {
16
33
  cursors: RemoteCursorState[];
17
34
  }
@@ -68,7 +85,7 @@ export function createRemoteCursorPlugin(
68
85
  if (from !== to) {
69
86
  decorations.push(
70
87
  Decoration.inline(from, to, {
71
- style: `background-color: ${cursor.color}33;`,
88
+ style: `background-color: ${safeRemoteCursorColor(cursor.color)}33;`,
72
89
  class: "remote-cursor-selection",
73
90
  }),
74
91
  );
@@ -149,7 +166,7 @@ function createCursorWidget(cursor: RemoteCursorState): HTMLElement {
149
166
  top: -0.2em;
150
167
  width: 2px;
151
168
  height: 1.2em;
152
- background-color: ${cursor.color};
169
+ background-color: ${safeRemoteCursorColor(cursor.color)};
153
170
  border-radius: 1px;
154
171
  `;
155
172
  container.appendChild(caret);
@@ -163,7 +180,7 @@ function createCursorWidget(cursor: RemoteCursorState): HTMLElement {
163
180
  font-family: system-ui, -apple-system, sans-serif;
164
181
  font-weight: 500;
165
182
  color: white;
166
- background-color: ${cursor.color};
183
+ background-color: ${safeRemoteCursorColor(cursor.color)};
167
184
  padding: 1px 4px;
168
185
  border-radius: 3px;
169
186
  white-space: nowrap;
@@ -91,17 +91,17 @@ function buildParagraphStyle(
91
91
  block.spacing?.lineRule ?? block.resolvedParagraphFormatting?.spacing?.lineRule;
92
92
 
93
93
  if (spacingBefore != null) {
94
- style.marginTop = `${spacingBefore / 20}px`;
94
+ style.marginTop = `${spacingBefore / 20}pt`;
95
95
  }
96
96
  if (spacingAfter != null) {
97
- style.marginBottom = `${spacingAfter / 20}px`;
97
+ style.marginBottom = `${spacingAfter / 20}pt`;
98
98
  }
99
99
  if (lineSpacing && lineRule === "auto") {
100
100
  style.lineHeight = String(lineSpacing / 240);
101
101
  } else if (lineSpacing && lineRule === "exact") {
102
- style.lineHeight = `${lineSpacing / 20}px`;
102
+ style.lineHeight = `${lineSpacing / 20}pt`;
103
103
  } else if (lineSpacing && lineRule === "atLeast") {
104
- style.minHeight = `${lineSpacing / 20}px`;
104
+ style.minHeight = `${lineSpacing / 20}pt`;
105
105
  }
106
106
 
107
107
  // Indentation
@@ -114,10 +114,10 @@ function buildParagraphStyle(
114
114
  const indentHanging =
115
115
  block.indentation?.hanging ?? block.resolvedParagraphFormatting?.indentation?.hanging;
116
116
 
117
- if (indentLeft) style.paddingLeft = `${indentLeft / 20}px`;
118
- if (indentRight) style.paddingRight = `${indentRight / 20}px`;
119
- if (indentHanging) style.textIndent = `-${indentHanging / 20}px`;
120
- else if (indentFirstLine) style.textIndent = `${indentFirstLine / 20}px`;
117
+ if (indentLeft) style.paddingLeft = `${indentLeft / 20}pt`;
118
+ if (indentRight) style.paddingRight = `${indentRight / 20}pt`;
119
+ if (indentHanging) style.textIndent = `-${indentHanging / 20}pt`;
120
+ else if (indentFirstLine) style.textIndent = `${indentFirstLine / 20}pt`;
121
121
 
122
122
  // Shading
123
123
  const shadingFill = block.shading?.fill;
@@ -171,10 +171,11 @@ function buildMarkerStyle(
171
171
 
172
172
  const hasResolvedMarkerWidth = typeof markerWidth === "number" && markerWidth > 0;
173
173
  if (hasResolvedMarkerWidth) {
174
- const markerWidthPx = Math.max(1, Math.round(markerWidth! / 20));
175
- style.width = `${markerWidthPx}px`;
176
- style.minWidth = `${markerWidthPx}px`;
177
- style.flexBasis = `${markerWidthPx}px`;
174
+ // P13.a: emit marker geometry in pt so it self-scales under CSS `zoom`.
175
+ const markerWidthPt = Math.max(1, markerWidth! / 20);
176
+ style.width = `${markerWidthPt}pt`;
177
+ style.minWidth = `${markerWidthPt}pt`;
178
+ style.flexBasis = `${markerWidthPt}pt`;
178
179
  style.marginRight = 0;
179
180
  style.overflow = "visible";
180
181
  } else {
@@ -450,9 +451,9 @@ function TableBlock({
450
451
  key={rowIdx}
451
452
  style={
452
453
  row.height != null && row.heightRule === "exact"
453
- ? { height: `${row.height / 20}px` }
454
+ ? { height: `${row.height / 20}pt` }
454
455
  : row.height != null && row.heightRule === "atLeast"
455
- ? { minHeight: `${row.height / 20}px` }
456
+ ? { minHeight: `${row.height / 20}pt` }
456
457
  : undefined
457
458
  }
458
459
  >
@@ -294,6 +294,72 @@ function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
294
294
  } else if (alignment === "right") {
295
295
  table.style.marginLeft = "auto";
296
296
  }
297
+ syncColgroup(table, node);
298
+ }
299
+
300
+ /**
301
+ * P6.a: real `<colgroup>` driven by the table's canonical `gridColumns`.
302
+ *
303
+ * Pre-P6 tables relied on per-row padding cells (`data-row-padding`) to
304
+ * approximate column widths. That produced correct-looking tables on
305
+ * simple CCEP fixtures but broke as soon as a row used `w:gridBefore` /
306
+ * `w:gridAfter` or a cell spanned multiple logical columns with
307
+ * non-uniform widths. HTML's `<colgroup>` is the canonical place to
308
+ * declare per-column widths; every browser honors it exactly and we
309
+ * avoid the padding-cell trick entirely for width purposes.
310
+ *
311
+ * Each `<col>` uses its twips width converted to `pt` via `twips / 20`
312
+ * (the same unit the row-padding helper already uses for its
313
+ * `style.width`). Keeping `pt` units avoids tying the NodeView to a
314
+ * pxPerTwip value — the CSS `zoom` applied at the workspace root
315
+ * rescales `pt` alongside every other length, so the columns follow
316
+ * zoom automatically.
317
+ *
318
+ * When `gridColumns` is empty (malformed input, or a table built
319
+ * programmatically without a declared grid), the colgroup is removed
320
+ * so the browser falls back to auto-sizing.
321
+ */
322
+ function syncColgroup(table: HTMLTableElement, node: PMNode): void {
323
+ const gridColumns = Array.isArray(node.attrs.gridColumns)
324
+ ? (node.attrs.gridColumns as number[])
325
+ : [];
326
+ const existing = Array.from(table.children).find(
327
+ (child): child is HTMLTableColElement =>
328
+ child instanceof (table.ownerDocument?.defaultView?.HTMLTableColElement ??
329
+ HTMLTableColElement) && child.tagName === "COLGROUP",
330
+ ) as HTMLElement | undefined;
331
+
332
+ if (gridColumns.length === 0) {
333
+ existing?.remove();
334
+ return;
335
+ }
336
+
337
+ const owner = table.ownerDocument ?? document;
338
+ const colgroup = existing ?? owner.createElement("colgroup");
339
+ colgroup.setAttribute("data-pm-table-colgroup", "true");
340
+ // Reuse existing <col> children where possible; add/remove tail cols.
341
+ const desired = gridColumns.length;
342
+ const actual = colgroup.childElementCount;
343
+ for (let i = actual; i < desired; i += 1) {
344
+ colgroup.appendChild(owner.createElement("col"));
345
+ }
346
+ while (colgroup.childElementCount > desired) {
347
+ colgroup.lastElementChild?.remove();
348
+ }
349
+ for (let i = 0; i < desired; i += 1) {
350
+ const col = colgroup.children[i] as HTMLTableColElement;
351
+ const twips = gridColumns[i] ?? 0;
352
+ col.setAttribute("data-col-index", String(i));
353
+ col.setAttribute("data-col-twips", String(twips));
354
+ col.style.width = twips > 0 ? `${twips / 20}pt` : "";
355
+ }
356
+
357
+ if (!existing) {
358
+ // Colgroup must be the first child of <table> per HTML spec so the
359
+ // column widths propagate to every row. `insertBefore(first)`
360
+ // handles both empty and populated tables uniformly.
361
+ table.insertBefore(colgroup, table.firstChild);
362
+ }
297
363
  }
298
364
 
299
365
  function applyRowAttrs(row: HTMLTableRowElement, node: PMNode): void {
@@ -53,6 +53,38 @@ export {
53
53
  type TwModeDockProps,
54
54
  } from "./chrome/tw-mode-dock";
55
55
 
56
+ // Collab chrome (P9) — mount when chromePreset === "collab"; each
57
+ // component is pure presentational and takes snapshots + callbacks.
58
+ export {
59
+ CollabPresenceStrip,
60
+ type CollabPresenceStripProps,
61
+ } from "./chrome/collab-presence-strip";
62
+ export {
63
+ CollabRoleChip,
64
+ type CollabRoleChipProps,
65
+ } from "./chrome/collab-role-chip";
66
+ export {
67
+ CollabAudienceChip,
68
+ type CollabAudienceChipProps,
69
+ } from "./chrome/collab-audience-chip";
70
+ export {
71
+ CollabTamperBanner,
72
+ type CollabTamperBannerProps,
73
+ } from "./chrome/collab-tamper-banner";
74
+ export {
75
+ CollabNegotiationActionBar,
76
+ type CollabNegotiationActionBarProps,
77
+ } from "./chrome/collab-negotiation-action-bar";
78
+ export {
79
+ CollabSendToSupplierButton,
80
+ type CollabSendToSupplierButtonProps,
81
+ } from "./chrome/collab-send-to-supplier-button";
82
+ export {
83
+ CollabSendToSupplierModal,
84
+ type CollabSendToSupplierModalProps,
85
+ type CollabSendToSupplierSubmitArgs,
86
+ } from "./chrome/collab-send-to-supplier-modal";
87
+
56
88
  // Chrome overlay plane (R3a — scope rail layer)
57
89
  export {
58
90
  TwChromeOverlay,
@@ -17,11 +17,36 @@ export interface TwReviewRailFooterProps {
17
17
  const focusRingClass =
18
18
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
19
19
 
20
+ /**
21
+ * Accept only http(s) and mailto help links. Rejects javascript:, data:,
22
+ * and any other scheme that could execute code when a reviewer clicks the
23
+ * help button. Host-supplied; treated as untrusted.
24
+ */
25
+ function safeHelpHref(raw: string | undefined): string | undefined {
26
+ if (typeof raw !== "string") return undefined;
27
+ const value = raw.trim();
28
+ if (value.length === 0) return undefined;
29
+ // Allow relative paths (no scheme) — hosts often use `/docs/help`.
30
+ if (value.startsWith("/") || value.startsWith("#") || value.startsWith("?")) {
31
+ return value;
32
+ }
33
+ try {
34
+ const url = new URL(value, "https://placeholder.invalid/");
35
+ if (url.protocol === "https:" || url.protocol === "http:" || url.protocol === "mailto:") {
36
+ return value;
37
+ }
38
+ } catch {
39
+ // fall through
40
+ }
41
+ return undefined;
42
+ }
43
+
20
44
  export function TwReviewRailFooter(props: TwReviewRailFooterProps) {
21
45
  const searchLabel = props.searchLabel ?? "SEARCH";
22
46
  const helpLabel = props.helpLabel ?? "HELP";
47
+ const helpHref = safeHelpHref(props.helpHref);
23
48
 
24
- if (!props.onSearch && !props.helpHref) {
49
+ if (!props.onSearch && !helpHref) {
25
50
  return null;
26
51
  }
27
52
 
@@ -38,9 +63,10 @@ export function TwReviewRailFooter(props: TwReviewRailFooterProps) {
38
63
  </button>
39
64
  ) : null}
40
65
 
41
- {props.helpHref ? (
66
+ {helpHref ? (
42
67
  <a
43
- href={props.helpHref}
68
+ href={helpHref}
69
+ rel="noopener noreferrer"
44
70
  className={`inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-tertiary transition-colors hover:text-secondary ${focusRingClass}`}
45
71
  >
46
72
  <HelpCircle aria-hidden="true" className="h-3 w-3" />