@beyondwork/docx-react-component 1.0.58 → 1.0.60

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 (135) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +980 -10
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +4 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +72 -42
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/workflow-payload.ts +6 -1
  69. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  70. package/src/io/ooxml/xml-parser.ts +183 -0
  71. package/src/legal/bookmarks.ts +1 -1
  72. package/src/legal/cross-references.ts +1 -1
  73. package/src/legal/defined-terms.ts +1 -1
  74. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  75. package/src/legal/signature-blocks.ts +1 -1
  76. package/src/model/canonical-document.ts +159 -6
  77. package/src/model/chart-types.ts +439 -0
  78. package/src/model/snapshot.ts +5 -1
  79. package/src/review/store/comment-remapping.ts +24 -11
  80. package/src/review/store/revision-actions.ts +482 -2
  81. package/src/review/store/revision-store.ts +15 -0
  82. package/src/review/store/revision-types.ts +76 -0
  83. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  84. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  85. package/src/runtime/diagnostics/build-diagnostic.ts +153 -0
  86. package/src/runtime/diagnostics/code-metadata-table.ts +230 -0
  87. package/src/runtime/document-runtime.ts +821 -54
  88. package/src/runtime/document-search.ts +115 -0
  89. package/src/runtime/edit-ops/index.ts +18 -2
  90. package/src/runtime/footnote-resolver.ts +130 -0
  91. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  92. package/src/runtime/layout/layout-engine-version.ts +37 -1
  93. package/src/runtime/layout/page-graph.ts +14 -1
  94. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  95. package/src/runtime/numbering-prefix.ts +17 -0
  96. package/src/runtime/query-scopes.ts +108 -10
  97. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  98. package/src/runtime/revision-runtime.ts +27 -1
  99. package/src/runtime/selection/post-edit-validator.ts +60 -6
  100. package/src/runtime/structure-ops/index.ts +20 -4
  101. package/src/runtime/surface-projection.ts +290 -21
  102. package/src/runtime/table-schema.ts +6 -0
  103. package/src/runtime/theme-color-resolver.ts +2 -2
  104. package/src/runtime/units.ts +9 -0
  105. package/src/runtime/workflow-rail-segments.ts +4 -0
  106. package/src/ui/WordReviewEditor.tsx +187 -43
  107. package/src/ui/editor-runtime-boundary.ts +10 -0
  108. package/src/ui/editor-shell-view.tsx +4 -1
  109. package/src/ui/headless/chrome-registry.ts +53 -0
  110. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  111. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  112. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  113. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  114. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  115. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  116. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  117. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +0 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -0
  119. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  120. package/src/ui-tailwind/editor-surface/pm-schema.ts +87 -25
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -0
  122. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  124. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  125. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  126. package/src/ui-tailwind/index.ts +9 -0
  127. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  128. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  129. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  130. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  131. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  132. package/src/ui-tailwind/theme/tokens.ts +14 -0
  133. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  134. package/src/ui-tailwind/tw-review-workspace.tsx +29 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -71,6 +71,20 @@ export function resolveMarkerJustificationCss(raw: string | undefined): string {
71
71
  }
72
72
  }
73
73
 
74
+ export function resolveMarkerAlignCss(raw: string | undefined): React.CSSProperties["textAlign"] {
75
+ switch (raw) {
76
+ case "left":
77
+ return "left";
78
+ case "center":
79
+ return "center";
80
+ case "right":
81
+ case "both":
82
+ case "distribute":
83
+ default:
84
+ return "right";
85
+ }
86
+ }
87
+
74
88
  /** Build CSSProperties for a paragraph block from spacing/indent/alignment. */
75
89
  export function buildParagraphStyle(
76
90
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
@@ -144,11 +158,12 @@ export function buildMarkerStyle(
144
158
  suffix: "tab" | "space" | "nothing" | undefined,
145
159
  markerRunProperties: CanonicalRunFormatting | undefined,
146
160
  markerWidth: number | undefined,
161
+ markerStart: number | undefined,
147
162
  markerJustification: string | undefined,
148
163
  ): React.CSSProperties {
149
164
  const style: React.CSSProperties = {
150
165
  fontVariantNumeric: "tabular-nums",
151
- justifyContent: resolveMarkerJustificationCss(markerJustification),
166
+ textAlign: resolveMarkerAlignCss(markerJustification),
152
167
  };
153
168
 
154
169
  if (markerRunProperties) {
@@ -180,8 +195,10 @@ export function buildMarkerStyle(
180
195
  style.width = `${markerWidthPt}pt`;
181
196
  style.minWidth = `${markerWidthPt}pt`;
182
197
  style.flexBasis = `${markerWidthPt}pt`;
198
+ style.marginLeft = `-${markerWidthPt}pt`;
183
199
  style.marginRight = 0;
184
200
  style.overflow = "visible";
201
+ void markerStart; // consumed via paragraph padding-left geometry
185
202
  } else {
186
203
  const fallbackMinWidth = Math.min(Math.max(prefix.length + 1, 4), 14);
187
204
  const fallbackMarginRight =
@@ -142,6 +142,7 @@ function ParagraphBlock({
142
142
  const resolvedNumbering = block.resolvedNumbering;
143
143
  const markerRunProperties = resolvedNumbering?.markerRunProperties;
144
144
  const markerWidth = resolvedNumbering?.geometry?.markerLane?.width;
145
+ const markerStart = resolvedNumbering?.geometry?.markerLane?.start;
145
146
  const markerJustification = resolvedNumbering?.geometry?.markerJustification;
146
147
 
147
148
  const prefixSpan =
@@ -164,6 +165,7 @@ function ParagraphBlock({
164
165
  numberingSuffix,
165
166
  markerRunProperties,
166
167
  markerWidth,
168
+ markerStart,
167
169
  markerJustification,
168
170
  )}
169
171
  >
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { Node as PMNode } from "prosemirror-model";
12
12
  import type { NodeViewConstructor, ViewMutationRecord } from "prosemirror-view";
13
+ import { PERCENTAGE_PARTS } from "../../runtime/units.ts";
13
14
 
14
15
  // R2c: band class styles live in ./tw-table-bands.module.css. Consumers import
15
16
  // that stylesheet through their build pipeline (same pattern as editor-theme.css).
@@ -325,7 +326,7 @@ function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
325
326
  const tableWidthType = node.attrs.tableWidthType as string | null | undefined;
326
327
  let baseClasses = "border-collapse w-full my-2 text-sm";
327
328
  if (tableWidthType === "pct" && typeof tableWidth === "number") {
328
- // OOXML pct widths are fiftieths of a percent (5000 = 100%).
329
+ // OOXML pct widths are fiftieths of a percent (PERCENTAGE_PARTS = 100%).
329
330
  table.style.width = `${tableWidth / 50}%`;
330
331
  baseClasses = "border-collapse my-2 text-sm";
331
332
  } else if (tableWidthType === "dxa" && typeof tableWidth === "number") {
@@ -406,6 +407,14 @@ function syncColgroup(table: HTMLTableElement, node: PMNode): void {
406
407
  const gridColumns = Array.isArray(node.attrs.gridColumns)
407
408
  ? (node.attrs.gridColumns as number[])
408
409
  : [];
410
+ // SOW gap G1 — percent widths win when the table itself is sized in
411
+ // percent. The relative array sums to 100 and comes from
412
+ // `computeRelativeGridColumns` in surface-projection so the column
413
+ // proportions track the container instead of the absolute `pt` widths
414
+ // sliding against it. `null` (the default) keeps the legacy pt path.
415
+ const gridColumnsRelative = Array.isArray(node.attrs.gridColumnsRelative)
416
+ ? (node.attrs.gridColumnsRelative as number[])
417
+ : null;
409
418
  const existing = Array.from(table.children).find(
410
419
  (child): child is HTMLTableColElement =>
411
420
  child instanceof (table.ownerDocument?.defaultView?.HTMLTableColElement ??
@@ -429,12 +438,19 @@ function syncColgroup(table: HTMLTableElement, node: PMNode): void {
429
438
  while (colgroup.childElementCount > desired) {
430
439
  colgroup.lastElementChild?.remove();
431
440
  }
441
+ const usePct =
442
+ gridColumnsRelative !== null && gridColumnsRelative.length === desired;
432
443
  for (let i = 0; i < desired; i += 1) {
433
444
  const col = colgroup.children[i] as HTMLTableColElement;
434
445
  const twips = gridColumns[i] ?? 0;
435
446
  col.setAttribute("data-col-index", String(i));
436
447
  col.setAttribute("data-col-twips", String(twips));
437
- col.style.width = twips > 0 ? `${twips / 20}pt` : "";
448
+ if (usePct) {
449
+ const pct = gridColumnsRelative[i] ?? 0;
450
+ col.style.width = pct > 0 ? `${pct.toFixed(4)}%` : "";
451
+ } else {
452
+ col.style.width = twips > 0 ? `${twips / 20}pt` : "";
453
+ }
438
454
  }
439
455
 
440
456
  if (!existing) {
@@ -67,6 +67,15 @@ export {
67
67
  type CommandPaletteItem,
68
68
  type TwCommandPaletteProps,
69
69
  } from "./chrome/tw-command-palette";
70
+ export {
71
+ TwCommandPaletteMount,
72
+ type TwCommandPaletteMountProps,
73
+ } from "./chrome/tw-command-palette-mount";
74
+ export {
75
+ useContainerBreakpoint,
76
+ resolveBreakpoint,
77
+ type BreakpointMap,
78
+ } from "./chrome/use-container-breakpoint";
70
79
 
71
80
  // Collab chrome (P9) — mount when chromePreset === "collab"; each
72
81
  // component is pure presentational and takes snapshots + callbacks.
@@ -3,6 +3,16 @@ import type {
3
3
  PageLayoutSnapshot,
4
4
  SurfaceBlockSnapshot,
5
5
  } from "../api/public-types.ts";
6
+ import { findPageForOffset } from "../runtime/document-navigation.ts";
7
+ import {
8
+ DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
9
+ estimateBlockHeight,
10
+ estimateParagraphLineCount,
11
+ estimateParagraphLineHeight,
12
+ getUsableColumnWidth,
13
+ } from "../runtime/page-layout-estimation.ts";
14
+
15
+ const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
6
16
 
7
17
  export interface LineMarker {
8
18
  id: string;
@@ -14,14 +24,76 @@ export function computeLineMarkersIfEnabled(input: {
14
24
  pageLayout: PageLayoutSnapshot | undefined;
15
25
  surfaceBlocks: readonly SurfaceBlockSnapshot[];
16
26
  pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>;
17
- buildLineNumberMarkers: (
18
- blocks: readonly SurfaceBlockSnapshot[],
19
- pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
20
- ) => LineMarker[];
21
27
  }): LineMarker[] {
22
28
  if (!input.pageLayout?.lineNumbering) {
23
29
  return [];
24
30
  }
25
31
 
26
- return input.buildLineNumberMarkers(input.surfaceBlocks, input.pages);
32
+ return buildLineNumberMarkers(input.surfaceBlocks, input.pages);
33
+ }
34
+
35
+ function buildLineNumberMarkers(
36
+ blocks: readonly SurfaceBlockSnapshot[],
37
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
38
+ ): LineMarker[] {
39
+ const markers: LineMarker[] = [];
40
+ if (pages.length === 0) {
41
+ return markers;
42
+ }
43
+
44
+ let currentTopTwips = 0;
45
+ let lineNumber = 1;
46
+ let lastPageIndex = -1;
47
+ let lastSectionIndex = -1;
48
+
49
+ for (const block of blocks) {
50
+ const pageIndex = findPageForOffset(pages, block.from);
51
+ const page = pages[pageIndex];
52
+ if (!page) {
53
+ continue;
54
+ }
55
+
56
+ const lineNumbering = page.layout.lineNumbering;
57
+ const restartMode = lineNumbering?.restart ?? "newPage";
58
+ const restartStart = lineNumbering?.start ?? 1;
59
+ const countBy = Math.max(1, lineNumbering?.countBy ?? 1);
60
+ const columnWidth = getUsableColumnWidth(page.layout);
61
+
62
+ if (pageIndex !== lastPageIndex) {
63
+ if (restartMode === "newPage" || lastPageIndex === -1) {
64
+ lineNumber = restartStart;
65
+ }
66
+ lastPageIndex = pageIndex;
67
+ }
68
+ if (page.sectionIndex !== lastSectionIndex) {
69
+ if (restartMode === "newSection" || lastSectionIndex === -1) {
70
+ lineNumber = restartStart;
71
+ }
72
+ lastSectionIndex = page.sectionIndex;
73
+ }
74
+
75
+ if (block.kind === "paragraph" && lineNumbering) {
76
+ const lineCount = estimateParagraphLineCount(block, columnWidth);
77
+ const lineHeight = estimateParagraphLineHeight(block);
78
+ const suppress = block.suppressLineNumbers === true;
79
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
80
+ if (!suppress && (lineNumber - restartStart) % countBy === 0) {
81
+ markers.push({
82
+ id: `${block.blockId}-${lineIndex}`,
83
+ label: String(lineNumber),
84
+ topPx:
85
+ DOCUMENT_CONTENT_TOP_PADDING_PX +
86
+ (currentTopTwips + lineIndex * lineHeight) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
87
+ });
88
+ }
89
+ if (!suppress) {
90
+ lineNumber += 1;
91
+ }
92
+ }
93
+ }
94
+
95
+ currentTopTwips += estimateBlockHeight(block, columnWidth);
96
+ }
97
+
98
+ return markers;
27
99
  }
@@ -147,7 +147,7 @@ export interface TwPageStackChromeLayerProps {
147
147
  "data-testid"?: string;
148
148
  }
149
149
 
150
- export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
150
+ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
151
151
  facet,
152
152
  scrollRoot,
153
153
  renderFrameRevision,
@@ -409,4 +409,59 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
409
409
  );
410
410
  };
411
411
 
412
+ function storyTargetEqual(
413
+ a: TwPageStackChromeLayerProps["activeStory"],
414
+ b: TwPageStackChromeLayerProps["activeStory"],
415
+ ): boolean {
416
+ if (a.kind !== b.kind) return false;
417
+ if (a.kind === "main") return true;
418
+ if (a.kind === "footnote" || a.kind === "endnote") {
419
+ // TS narrows a to { noteId: string }; b shares the same kind (guard above)
420
+ return a.noteId === (b as Extract<typeof b, { noteId: string }>).noteId;
421
+ }
422
+ if (a.kind === "header" || a.kind === "footer") {
423
+ const bh = b as Extract<typeof b, { kind: "header" | "footer" }>;
424
+ return (
425
+ a.relationshipId === bh.relationshipId &&
426
+ a.variant === bh.variant &&
427
+ a.sectionIndex === bh.sectionIndex
428
+ );
429
+ }
430
+ return false;
431
+ }
432
+
433
+ function rangeEqual(
434
+ a: { start: number; end: number } | null | undefined,
435
+ b: { start: number; end: number } | null | undefined,
436
+ ): boolean {
437
+ if (a == null && b == null) return true;
438
+ if (a == null || b == null) return false;
439
+ return a.start === b.start && a.end === b.end;
440
+ }
441
+
442
+ function propsAreEqual(
443
+ prev: TwPageStackChromeLayerProps,
444
+ next: TwPageStackChromeLayerProps,
445
+ ): boolean {
446
+ return (
447
+ prev.facet === next.facet &&
448
+ prev.scrollRoot === next.scrollRoot &&
449
+ prev.renderFrameRevision === next.renderFrameRevision &&
450
+ storyTargetEqual(prev.activeStory, next.activeStory) &&
451
+ prev.onOpenStory === next.onOpenStory &&
452
+ prev.pmSurfaceElement === next.pmSurfaceElement &&
453
+ prev.pmView === next.pmView &&
454
+ rangeEqual(prev.visiblePageIndexRange, next.visiblePageIndexRange) &&
455
+ prev["data-testid"] === next["data-testid"]
456
+ );
457
+ }
458
+
459
+ export const TwPageStackChromeLayer = React.memo(
460
+ TwPageStackChromeLayerInner,
461
+ propsAreEqual,
462
+ );
463
+
464
+ /** Exported for unit testing only. */
465
+ export { propsAreEqual as _propsAreEqualForTest };
466
+
412
467
  export default TwPageStackChromeLayer;
@@ -140,6 +140,7 @@ function RegionParagraph({
140
140
  const resolvedNumbering = block.resolvedNumbering;
141
141
  const markerRunProperties = resolvedNumbering?.markerRunProperties;
142
142
  const markerWidth = resolvedNumbering?.geometry?.markerLane?.width;
143
+ const markerStart = resolvedNumbering?.geometry?.markerLane?.start;
143
144
  const markerJustification = resolvedNumbering?.geometry?.markerJustification;
144
145
 
145
146
  const prefixSpan =
@@ -164,6 +165,7 @@ function RegionParagraph({
164
165
  numberingSuffix,
165
166
  markerRunProperties,
166
167
  markerWidth,
168
+ markerStart,
167
169
  markerJustification,
168
170
  )}
169
171
  >
@@ -157,6 +157,16 @@ function CommentThreadCard(props: {
157
157
  const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
158
158
  const hasNoBody = isEmptyCommentBody(leadEntry?.body);
159
159
  const showExcerpt = Boolean(thread.excerpt) && !isDraftThread && thread.excerpt !== "Empty thread";
160
+ const threadCardClassName = [
161
+ "rounded-lg bg-surface/90 transition-colors ring-1 ring-border",
162
+ isActive
163
+ ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]"
164
+ : "hover:bg-surface",
165
+ thread.status === "detached"
166
+ ? "border-l-[3px] border-[var(--color-semantic-warning)] opacity-70"
167
+ : "",
168
+ ].join(" ");
169
+ const threadContentPaddingClass = thread.status === "detached" ? "pl-2.5 pr-3" : "px-3";
160
170
 
161
171
  const scrollRef = useCallback(
162
172
  (node: HTMLButtonElement | null) => {
@@ -168,130 +178,123 @@ function CommentThreadCard(props: {
168
178
  );
169
179
 
170
180
  return (
171
- <button
172
- type="button"
173
- ref={scrollRef}
174
- data-comment-thread-id={thread.commentId}
175
- data-comment-thread-status={thread.status}
176
- className={[
177
- "w-full text-left cursor-pointer rounded-lg bg-surface/90 px-3 py-2.5 transition-colors ring-1 ring-border",
178
- focusRingClass,
179
- isActive
180
- ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]"
181
- : "hover:bg-surface",
182
- thread.status === "detached"
183
- ? "border-l-[3px] border-[var(--color-semantic-warning)] opacity-70 pl-2.5"
184
- : "",
185
- ].join(" ")}
186
- onClick={() => props.onOpenComment?.(thread)}
187
- >
188
- {/* Header row: avatar + author + date + status */}
189
- <div className="mb-1.5 flex items-center gap-1.5">
190
- <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-subtle text-[8px] font-semibold text-secondary">
191
- {thread.createdBy.charAt(0).toUpperCase()}
192
- </span>
193
- <span className="truncate text-[10px] font-medium text-primary">{thread.createdBy}</span>
194
- {thread.status === "detached" && (
195
- <span
196
- data-comment-thread-detached-chip="true"
197
- className="inline-flex items-center rounded-full bg-[var(--color-semantic-warning-soft)] text-[var(--color-semantic-warning)] text-[9px] font-semibold uppercase tracking-[0.08em] px-1.5 py-0.5 ml-1.5"
198
- >
199
- Detached
181
+ <div data-comment-thread-card={thread.commentId} className={threadCardClassName}>
182
+ <button
183
+ type="button"
184
+ ref={scrollRef}
185
+ data-comment-thread-id={thread.commentId}
186
+ data-comment-thread-status={thread.status}
187
+ className={["w-full cursor-pointer pb-1 pt-2.5 text-left", threadContentPaddingClass, focusRingClass].join(" ")}
188
+ onClick={() => props.onOpenComment?.(thread)}
189
+ >
190
+ {/* Header row: avatar + author + date + status */}
191
+ <div className="mb-1.5 flex items-center gap-1.5">
192
+ <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-subtle text-[8px] font-semibold text-secondary">
193
+ {thread.createdBy.charAt(0).toUpperCase()}
200
194
  </span>
201
- )}
202
- <span data-comment-thread-created-at="true" className="text-[9px] text-tertiary">
203
- {formatCommentDate(thread.createdAt)}
204
- </span>
205
- <span className="flex-1" />
206
- {isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
207
- {thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
208
- </div>
195
+ <span className="truncate text-[10px] font-medium text-primary">{thread.createdBy}</span>
196
+ {thread.status === "detached" && (
197
+ <span
198
+ data-comment-thread-detached-chip="true"
199
+ className="ml-1.5 inline-flex items-center rounded-full bg-[var(--color-semantic-warning-soft)] px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[var(--color-semantic-warning)]"
200
+ >
201
+ Detached
202
+ </span>
203
+ )}
204
+ <span data-comment-thread-created-at="true" className="text-[9px] text-tertiary">
205
+ {formatCommentDate(thread.createdAt)}
206
+ </span>
207
+ <span className="flex-1" />
208
+ {isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
209
+ {thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
210
+ </div>
209
211
 
210
- {/* Excerpt — anchored text from document */}
211
- {showExcerpt ? (
212
- <p className="mb-1.5 rounded-md bg-comment-soft px-2 py-1 text-[9px] leading-4 text-secondary italic whitespace-pre-wrap break-words line-clamp-2">
213
- {thread.excerpt}
214
- </p>
215
- ) : null}
212
+ {/* Excerpt — anchored text from document */}
213
+ {showExcerpt ? (
214
+ <p className="mb-1.5 rounded-md bg-comment-soft px-2 py-1 text-[9px] italic leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-2">
215
+ {thread.excerpt}
216
+ </p>
217
+ ) : null}
216
218
 
217
- {/* Comment body */}
218
- {canEdit && (isActive || hasNoBody) ? (
219
- <InlineEditableBody
220
- body={leadEntry?.body ?? ""}
221
- autoFocus={isActive && hasNoBody}
222
- onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
223
- label={isDraftThread ? "New comment" : undefined}
224
- />
225
- ) : presentation ? (
226
- <CommentMarkdownRenderer
227
- body={presentation.body}
228
- mentions={presentation.mentions}
229
- attachments={presentation.attachments}
230
- resolveAttachmentHref={resolveAttachmentHref}
231
- className="text-[10px] leading-[1.1rem] text-secondary break-words"
232
- />
233
- ) : leadEntry?.body ? (
234
- <p
235
- className="text-[10px] leading-[1.1rem] text-secondary whitespace-pre-wrap break-words line-clamp-4"
236
- data-comment-thread-body="true"
237
- >
238
- {leadEntry.body}
239
- </p>
240
- ) : canEdit ? (
241
- <p
242
- className="cursor-text text-[10px] italic text-tertiary"
243
- onClick={(e) => {
244
- e.stopPropagation();
245
- props.onOpenComment?.(thread);
246
- }}
247
- >
248
- New comment
249
- </p>
250
- ) : null}
219
+ {/* Comment body */}
220
+ {canEdit && (isActive || hasNoBody) ? (
221
+ <InlineEditableBody
222
+ body={leadEntry?.body ?? ""}
223
+ autoFocus={isActive && hasNoBody}
224
+ onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
225
+ label={isDraftThread ? "New comment" : undefined}
226
+ />
227
+ ) : presentation ? (
228
+ <CommentMarkdownRenderer
229
+ body={presentation.body}
230
+ mentions={presentation.mentions}
231
+ attachments={presentation.attachments}
232
+ resolveAttachmentHref={resolveAttachmentHref}
233
+ className="text-[10px] leading-[1.1rem] text-secondary break-words"
234
+ />
235
+ ) : leadEntry?.body ? (
236
+ <p
237
+ className="text-[10px] leading-[1.1rem] text-secondary whitespace-pre-wrap break-words line-clamp-4"
238
+ data-comment-thread-body="true"
239
+ >
240
+ {leadEntry.body}
241
+ </p>
242
+ ) : canEdit ? (
243
+ <p
244
+ className="cursor-text text-[10px] italic text-tertiary"
245
+ onClick={(e) => {
246
+ e.stopPropagation();
247
+ props.onOpenComment?.(thread);
248
+ }}
249
+ >
250
+ New comment
251
+ </p>
252
+ ) : null}
251
253
 
252
- {/* Reply entries (compact) */}
253
- {thread.entries.slice(1).map((entry) => {
254
- const replyPresentation = replyPresentationByEntryId.get(entry.entryId);
255
- return (
256
- <div key={entry.entryId} className="mt-2 ml-4 border-l border-border pl-2.5">
257
- <div className="mb-0.5 flex items-center gap-1">
258
- <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
259
- <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
254
+ {/* Reply entries (compact) */}
255
+ {thread.entries.slice(1).map((entry) => {
256
+ const replyPresentation = replyPresentationByEntryId.get(entry.entryId);
257
+ return (
258
+ <div key={entry.entryId} className="mt-2 ml-4 border-l border-border pl-2.5">
259
+ <div className="mb-0.5 flex items-center gap-1">
260
+ <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
261
+ <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
262
+ </div>
263
+ {replyPresentation ? (
264
+ <CommentMarkdownRenderer
265
+ body={replyPresentation.body}
266
+ mentions={presentation?.mentions}
267
+ attachments={presentation?.attachments}
268
+ resolveAttachmentHref={resolveAttachmentHref}
269
+ className="text-[10px] leading-4 text-secondary break-words"
270
+ />
271
+ ) : (
272
+ <p
273
+ className="text-[10px] leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-3"
274
+ data-comment-reply-body="true"
275
+ >
276
+ {entry.body}
277
+ </p>
278
+ )}
260
279
  </div>
261
- {replyPresentation ? (
262
- <CommentMarkdownRenderer
263
- body={replyPresentation.body}
264
- mentions={presentation?.mentions}
265
- attachments={presentation?.attachments}
266
- resolveAttachmentHref={resolveAttachmentHref}
267
- className="text-[10px] leading-4 text-secondary break-words"
268
- />
269
- ) : (
270
- <p
271
- className="text-[10px] leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-3"
272
- data-comment-reply-body="true"
273
- >
274
- {entry.body}
275
- </p>
276
- )}
277
- </div>
278
- );
279
- })}
280
+ );
281
+ })}
280
282
 
281
- {thread.entryCount > thread.entries.length ? (
282
- <p className="mt-1 text-[9px] text-tertiary">
283
- +{thread.entryCount - thread.entries.length} more
284
- </p>
285
- ) : null}
283
+ {thread.entryCount > thread.entries.length ? (
284
+ <p className="mt-1 text-[9px] text-tertiary">
285
+ +{thread.entryCount - thread.entries.length} more
286
+ </p>
287
+ ) : null}
288
+ </button>
286
289
 
287
290
  {/* Inline actions — compact, horizontal */}
288
- <div className="mt-2 flex items-center gap-1">
291
+ <div className={["mt-2 flex items-center gap-1 pb-2.5", threadContentPaddingClass].join(" ")}>
289
292
  {thread.status === "open" && (
290
293
  <>
291
294
  <button
292
295
  type="button"
293
296
  className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-accent hover:bg-accent-soft transition-colors"
294
- onClick={(e) => { e.stopPropagation(); props.onResolveComment?.(thread.commentId); }}
297
+ onClick={() => props.onResolveComment?.(thread.commentId)}
295
298
  >
296
299
  <Check className="h-2 w-2" /> Resolve
297
300
  </button>
@@ -305,7 +308,7 @@ function CommentThreadCard(props: {
305
308
  type="button"
306
309
  className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-secondary hover:bg-surface-hover transition-colors"
307
310
  data-comment-thread-action="reopen"
308
- onClick={(e) => { e.stopPropagation(); props.onReopenComment?.(thread.commentId); }}
311
+ onClick={() => props.onReopenComment?.(thread.commentId)}
309
312
  >
310
313
  <RotateCcw className="h-2 w-2" /> Reopen
311
314
  </button>
@@ -314,7 +317,7 @@ function CommentThreadCard(props: {
314
317
  <span className="text-[9px] text-comment">Detached</span>
315
318
  )}
316
319
  </div>
317
- </button>
320
+ </div>
318
321
  );
319
322
  }
320
323
 
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { HelpCircle, Search } from "lucide-react";
3
+ import { FOCUS_RING_CLASSES } from "../theme/tokens";
3
4
 
4
5
  /**
5
6
  * Thin pinned footer rendered at the bottom of the review rail. The footer
@@ -14,8 +15,7 @@ export interface TwReviewRailFooterProps {
14
15
  searchLabel?: string;
15
16
  }
16
17
 
17
- const focusRingClass =
18
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
18
+ const focusRingClass = FOCUS_RING_CLASSES;
19
19
 
20
20
  /**
21
21
  * Accept only http(s) and mailto help links. Rejects javascript:, data:,
@@ -273,6 +273,20 @@ export const HOST_LOCKED_TOKENS = [
273
273
  export type HostOverridableToken = (typeof HOST_OVERRIDABLE_TOKENS)[number];
274
274
  export type HostLockedToken = (typeof HOST_LOCKED_TOKENS)[number];
275
275
 
276
+ /**
277
+ * Canonical focus-visible ring class string (designsystem §4.7 / §7.2).
278
+ *
279
+ * Every interactive chrome surface that renders a custom focus indicator
280
+ * MUST import this constant rather than inline the Tailwind utilities.
281
+ * The invariant is enforced by `test/ui-tailwind/focus-ring-canonical.test.ts`.
282
+ *
283
+ * Exceptions: input / textarea elements that use a 1-px border ring as the
284
+ * idle-state affordance (e.g. `tw-comment-sidebar` reply composer) are
285
+ * a distinct pattern and not covered by this utility.
286
+ */
287
+ export const FOCUS_RING_CLASSES =
288
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
289
+
276
290
  /** Returns true if `path` is in the locked set (must not be overridden by hosts). */
277
291
  export function isTokenPathLocked(path: string): boolean {
278
292
  return (HOST_LOCKED_TOKENS as readonly string[]).includes(path);
@@ -131,6 +131,11 @@ export function TwShellHeader(props: TwShellHeaderProps): React.ReactElement {
131
131
  key={mode.id}
132
132
  value={mode.id}
133
133
  disabled={mode.disabled}
134
+ onClick={() => {
135
+ if (!mode.disabled) {
136
+ props.onModeChange?.(mode.id);
137
+ }
138
+ }}
134
139
  className={`wre-rail-tab ${focusRingClass}`}
135
140
  data-testid={`tw-shell-header__mode-${mode.id}`}
136
141
  >