@beyondwork/docx-react-component 1.0.56 → 1.0.57

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 (107) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. package/src/validation/docx-comment-proof.ts +12 -3
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Lane 6d — Slice N10 (P12.1 + P12.2): pure SVG shape renderer.
3
+ *
4
+ * Maps the V2c.5 `kind: "shape"` surface segment onto inline SVG markup
5
+ * for the most common geometries (rect / ellipse / roundRect). Pure —
6
+ * no DOM access, no React; returns a plain markup string the toDOM can
7
+ * embed via attrs. Returns `null` for unsupported geometries so the
8
+ * caller falls back to the existing chip render.
9
+ *
10
+ * v1 fill coverage: solid (srgbClr) → `#${hex}`; solid (schemeClr) →
11
+ * `currentColor` placeholder until Lane 6a theme resolver wires through;
12
+ * none → `none`. Gradient + pattern fills are dropped at the surface
13
+ * boundary (V2c.5 surface only carries solid/none) — they remain on the
14
+ * canonical model for a future renderer extension.
15
+ *
16
+ * v1 line coverage: width = `widthEmu / 9525` px; color same handling as
17
+ * fill; `noLine: true` → `stroke: "none"`.
18
+ */
19
+
20
+ import { EMU_PER_PX } from "../../runtime/units";
21
+
22
+ const SUPPORTED_GEOMETRIES = new Set(["rect", "ellipse", "roundRect"]);
23
+
24
+ export type ShapeFill =
25
+ | { kind: "solid"; color: string; colorType: "srgbClr" | "schemeClr" }
26
+ | { kind: "none" };
27
+
28
+ export type ShapeLine = {
29
+ color?: string;
30
+ widthEmu?: number;
31
+ noLine?: boolean;
32
+ };
33
+
34
+ export interface ResolvedFillCss {
35
+ fill: string;
36
+ /** True when caller should warn about unresolved scheme color. */
37
+ isSchemePlaceholder: boolean;
38
+ }
39
+
40
+ export interface ResolvedLineCss {
41
+ stroke: string;
42
+ strokeWidth: number;
43
+ }
44
+
45
+ /**
46
+ * Resolve a shape fill into a CSS color string. schemeClr falls back
47
+ * to `currentColor` until Lane 6a's theme resolver is wired here —
48
+ * `isSchemePlaceholder` flags the placeholder so callers (or follow-up
49
+ * slices) can later substitute the resolved theme color.
50
+ */
51
+ export function resolveFillCss(fill: ShapeFill | undefined): ResolvedFillCss {
52
+ if (!fill || fill.kind === "none") {
53
+ return { fill: "none", isSchemePlaceholder: false };
54
+ }
55
+ if (fill.colorType === "schemeClr") {
56
+ return { fill: "currentColor", isSchemePlaceholder: true };
57
+ }
58
+ // srgbClr — color is uppercase hex without leading `#`.
59
+ return { fill: `#${fill.color}`, isSchemePlaceholder: false };
60
+ }
61
+
62
+ /**
63
+ * Resolve a shape line into stroke + stroke-width values. `noLine: true`
64
+ * collapses to `stroke: "none"` per OOXML semantics. Default stroke
65
+ * width is 1 px when widthEmu is absent or zero.
66
+ */
67
+ export function resolveLineCss(line: ShapeLine | undefined): ResolvedLineCss {
68
+ if (!line || line.noLine) {
69
+ return { stroke: "none", strokeWidth: 0 };
70
+ }
71
+ // Default stroke (no widthEmu) matches the floor used when widthEmu
72
+ // is set — otherwise a `widthEmu: 0` shape would get a fatter stroke
73
+ // (1 px) than a `widthEmu: 1` shape (0.5 px from the Math.max floor).
74
+ const strokeWidth = line.widthEmu && line.widthEmu > 0
75
+ ? Math.max(0.5, line.widthEmu / EMU_PER_PX)
76
+ : 0.5;
77
+ // line.color uses OOXML hex without `#`; "auto" → currentColor.
78
+ const stroke = !line.color || line.color === "auto"
79
+ ? "currentColor"
80
+ : `#${line.color}`;
81
+ return { stroke, strokeWidth };
82
+ }
83
+
84
+ export interface ShapeSegmentLike {
85
+ geometry?: string;
86
+ fill?: ShapeFill;
87
+ line?: ShapeLine;
88
+ }
89
+
90
+ /**
91
+ * PM `DOMOutputSpec`-style array tree for an SVG element. Each inner
92
+ * tuple is `[tag, attrs, ...children]`. Used by ProseMirror's
93
+ * `toDOM` to construct DOM nodes without string parsing.
94
+ */
95
+ export type SvgSpec = readonly [string, Record<string, string>, ...SvgSpec[]];
96
+
97
+ /**
98
+ * Render a supported geometry into a PM-compatible DOMOutputSpec tree
99
+ * for an inline `<svg>`. Returns `null` when the geometry is unsupported
100
+ * or the caller did not supply pixel dimensions (a chip-fallback signal).
101
+ *
102
+ * The SVG is sized 1:1 to its container; the wrapper span owns the
103
+ * outer `width:Xpx; height:Ypx`.
104
+ */
105
+ export function renderShapeSvg(
106
+ segment: ShapeSegmentLike,
107
+ widthPx: number,
108
+ heightPx: number,
109
+ ): SvgSpec | null {
110
+ if (!widthPx || !heightPx) return null;
111
+ if (!segment.geometry || !SUPPORTED_GEOMETRIES.has(segment.geometry)) {
112
+ return null;
113
+ }
114
+ const fillCss = resolveFillCss(segment.fill);
115
+ const lineCss = resolveLineCss(segment.line);
116
+ const sw = lineCss.strokeWidth;
117
+ // Inset the geometry by half the stroke so the stroke paints inside
118
+ // the bounding box (browsers default to centered stroke).
119
+ const insetX = sw / 2;
120
+ const insetY = sw / 2;
121
+ const innerW = Math.max(0, widthPx - sw);
122
+ const innerH = Math.max(0, heightPx - sw);
123
+
124
+ let geometryEl: SvgSpec;
125
+ switch (segment.geometry) {
126
+ case "rect":
127
+ geometryEl = [
128
+ "rect",
129
+ {
130
+ x: String(insetX),
131
+ y: String(insetY),
132
+ width: String(innerW),
133
+ height: String(innerH),
134
+ fill: fillCss.fill,
135
+ stroke: lineCss.stroke,
136
+ "stroke-width": String(sw),
137
+ },
138
+ ];
139
+ break;
140
+ case "roundRect": {
141
+ // Default OOXML preset uses ~10% of the shorter side for corner radius.
142
+ const r = Math.min(innerW, innerH) * 0.1;
143
+ geometryEl = [
144
+ "rect",
145
+ {
146
+ x: String(insetX),
147
+ y: String(insetY),
148
+ width: String(innerW),
149
+ height: String(innerH),
150
+ rx: String(r),
151
+ ry: String(r),
152
+ fill: fillCss.fill,
153
+ stroke: lineCss.stroke,
154
+ "stroke-width": String(sw),
155
+ },
156
+ ];
157
+ break;
158
+ }
159
+ case "ellipse": {
160
+ const cx = widthPx / 2;
161
+ const cy = heightPx / 2;
162
+ const rx = Math.max(0, (widthPx - sw) / 2);
163
+ const ry = Math.max(0, (heightPx - sw) / 2);
164
+ geometryEl = [
165
+ "ellipse",
166
+ {
167
+ cx: String(cx),
168
+ cy: String(cy),
169
+ rx: String(rx),
170
+ ry: String(ry),
171
+ fill: fillCss.fill,
172
+ stroke: lineCss.stroke,
173
+ "stroke-width": String(sw),
174
+ },
175
+ ];
176
+ break;
177
+ }
178
+ default:
179
+ return null;
180
+ }
181
+
182
+ // PM's DOMSerializer parses the tag string for an SVG namespace prefix:
183
+ // "http://www.w3.org/2000/svg svg" → createElementNS(). Setting an
184
+ // `xmlns` *attribute* after createElement() is meaningless — the
185
+ // resulting node is HTMLUnknownElement and won't paint as SVG.
186
+ // Children inherit the namespace, so the geometry tag stays bare.
187
+ return [
188
+ "http://www.w3.org/2000/svg svg",
189
+ {
190
+ viewBox: `0 0 ${widthPx} ${heightPx}`,
191
+ width: String(widthPx),
192
+ height: String(heightPx),
193
+ preserveAspectRatio: "none",
194
+ "aria-hidden": "true",
195
+ },
196
+ geometryEl,
197
+ ];
198
+ }
199
+
200
+ /**
201
+ * Whether N10 v1 supports rendering this geometry as SVG. Useful for
202
+ * caller-side branching when the chip fallback should fire.
203
+ */
204
+ export function isSupportedShapeGeometry(geometry: string | undefined): boolean {
205
+ return geometry !== undefined && SUPPORTED_GEOMETRIES.has(geometry);
206
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * R.4 SurfaceLayer — named shell for the outermost input surface layer.
3
+ * See `docs/plans/lane-1-editing-foundation.md` §R.4.
4
+ *
5
+ * Analogous to LibreOffice `SwWrtShell`. Owns: keyboard resolution, paste/drop
6
+ * routing decisions, composition state. Does NOT own: PM view construction,
7
+ * DOM manipulation, visible selection chrome — those stay in
8
+ * `pm-command-bridge.ts` as a thin plugin factory that forwards events here.
9
+ *
10
+ * The R.4 scope shipped in this first extraction is the TYPED RESULT SHAPE +
11
+ * a testable dispatch seam for the keyboard path. Tests can now exercise
12
+ * "Ctrl+F without React render" by calling `surfaceLayer.resolveKeyDown`
13
+ * directly — no PM boot, no DOM.
14
+ *
15
+ * Follow-up work (not in this slice): migrate `pm-command-bridge.ts`
16
+ * `handleKeyDown` / `handlePaste` / `handleDrop` to call into
17
+ * `surfaceLayer.dispatchPaste` / `surfaceLayer.dispatchDrop` so the bridge
18
+ * becomes a plugin factory with zero dispatch logic.
19
+ */
20
+
21
+ import {
22
+ resolveSurfaceShortcut,
23
+ resolveShellShortcut,
24
+ type ShellShortcutContext,
25
+ type ShellShortcutResolution,
26
+ type ShortcutKeyInput,
27
+ type SurfaceShortcutContext,
28
+ type SurfaceShortcutResolution,
29
+ } from "../../ui/runtime-shortcut-dispatch";
30
+
31
+ export type SurfaceResult =
32
+ | { kind: "dispatched"; surface: SurfaceShortcutResolution }
33
+ | { kind: "shell"; shell: ShellShortcutResolution }
34
+ | { kind: "pass-through" };
35
+
36
+ export interface SurfaceLayer {
37
+ /**
38
+ * Resolve a keydown event. Consults the shell-level dispatcher first
39
+ * (Ctrl+F / Ctrl+Shift+E / F5 etc.) and falls back to the surface-level
40
+ * dispatcher (typing, arrows, Tab). Returns a typed `SurfaceResult` so
41
+ * callers can switch on the kind without reaching into either
42
+ * dispatcher's specific resolution types.
43
+ */
44
+ resolveKeyDown(
45
+ input: ShortcutKeyInput,
46
+ shell: ShellShortcutContext,
47
+ surface: SurfaceShortcutContext,
48
+ ): SurfaceResult;
49
+ }
50
+
51
+ /**
52
+ * Default stateless SurfaceLayer instance. Safe to share across runtimes.
53
+ */
54
+ export const surfaceLayer: SurfaceLayer = {
55
+ resolveKeyDown(input, shell, surface) {
56
+ const shellResolution = resolveShellShortcut(input, shell);
57
+ if (shellResolution.kind !== "none") {
58
+ return { kind: "shell", shell: shellResolution };
59
+ }
60
+ const surfaceResolution = resolveSurfaceShortcut(input, surface);
61
+ if (surfaceResolution.kind !== "none") {
62
+ return { kind: "dispatched", surface: surfaceResolution };
63
+ }
64
+ return { kind: "pass-through" };
65
+ },
66
+ };
@@ -93,6 +93,35 @@ export function TwInlineToken(props: TwInlineTokenProps) {
93
93
  );
94
94
  }
95
95
 
96
+ // V2c.5 — shape segment (rect/ellipse/text-box/etc). Lane 6d N10 will
97
+ // upgrade this to proper SVG/HTML shape rendering; for now we render a
98
+ // selectable chip so reviewers can see the shape exists in the doc.
99
+ if (segment.kind === "shape") {
100
+ const isTextBox = Boolean(segment.isTextBox);
101
+ const label = isTextBox && segment.txbxText ? segment.txbxText : segment.label;
102
+ return (
103
+ <button
104
+ type="button"
105
+ tabIndex={-1}
106
+ onMouseDown={(e) => {
107
+ e.preventDefault();
108
+ props.onSelectionChange?.(createSelectionSnapshot(segment.from, segment.to));
109
+ }}
110
+ className={`inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs border-none cursor-pointer ${commentClass} text-secondary bg-surface ${
111
+ selected ? "ring-1 ring-accent/30" : ""
112
+ } ${focusRingClass}`}
113
+ title={segment.detail || segment.label}
114
+ data-segment-kind="shape"
115
+ data-shape-geometry={segment.geometry ?? ""}
116
+ >
117
+ {renderTwCaret(selection, segment.from)}
118
+ <span>{isTextBox ? "□" : "◇"}</span>
119
+ {label}
120
+ {renderTwCaret(selection, segment.to)}
121
+ </button>
122
+ );
123
+ }
124
+
96
125
  // opaque_inline
97
126
  if (segment.kind === "opaque_inline") {
98
127
  if (segment.presentation === "quiet-marker") {
@@ -23,7 +23,13 @@ export function TwSegmentView(props: TwSegmentViewProps) {
23
23
  const { segment, selection, markupDisplay } = props;
24
24
 
25
25
  // Non-text segments delegate to TwInlineToken
26
- if (segment.kind === "tab" || segment.kind === "hard_break" || segment.kind === "image" || segment.kind === "opaque_inline") {
26
+ if (
27
+ segment.kind === "tab" ||
28
+ segment.kind === "hard_break" ||
29
+ segment.kind === "image" ||
30
+ segment.kind === "opaque_inline" ||
31
+ segment.kind === "shape"
32
+ ) {
27
33
  return (
28
34
  <TwInlineToken
29
35
  segment={segment}
@@ -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;