@beyondwork/docx-react-component 1.0.53 → 1.0.55

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 (99) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +125 -7
  3. package/src/index.ts +5 -0
  4. package/src/io/docx-session.ts +27 -3
  5. package/src/io/normalize/normalize-text.ts +1 -0
  6. package/src/io/ooxml/parse-field-switches.ts +134 -0
  7. package/src/io/ooxml/parse-fields.ts +28 -2
  8. package/src/model/canonical-document.ts +13 -2
  9. package/src/runtime/chart/chart-model-store.ts +88 -0
  10. package/src/runtime/chart/chart-snapshot.ts +239 -0
  11. package/src/runtime/collab/checkpoint-store.ts +1 -1
  12. package/src/runtime/collab/event-types.ts +4 -0
  13. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  14. package/src/runtime/document-runtime.ts +115 -13
  15. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  16. package/src/runtime/layout/layout-engine-version.ts +58 -1
  17. package/src/runtime/layout/layout-invalidation.ts +150 -30
  18. package/src/runtime/layout/page-graph.ts +19 -0
  19. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  20. package/src/runtime/layout/project-block-fragments.ts +27 -0
  21. package/src/runtime/layout/public-facet.ts +27 -0
  22. package/src/runtime/page-number-format.ts +207 -0
  23. package/src/runtime/render/render-frame-diff.ts +38 -2
  24. package/src/runtime/surface-projection.ts +32 -3
  25. package/src/ui/WordReviewEditor.tsx +57 -3
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +90 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
  78. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
  79. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  80. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
  81. package/src/ui-tailwind/index.ts +11 -0
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  94. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  95. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  96. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  97. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  98. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  99. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Smooth-curve helper — Catmull-Rom → cubic Bézier (Stage 4 Slice 4B, B4).
3
+ *
4
+ * Emits an SVG path string for a smoothed polyline through the given
5
+ * points. Word's `c:smooth` switch produces a Bezier/Catmull-Rom family
6
+ * curve (per blog.splitwise.com / walice/beziersplines reverse-
7
+ * engineering); LibreOffice uses a cubic natural spline. We use
8
+ * Catmull-Rom with tension=0.5 (the canonical choice that matches
9
+ * Word's output within ~1-2 pixels at 100% zoom for typical 4-20 knot
10
+ * datasets).
11
+ *
12
+ * The conversion Catmull-Rom → cubic Bézier emits a sequence of `C`
13
+ * commands between every pair of adjacent knots, computing control
14
+ * points from the 4-point sliding window (p[i-1], p[i], p[i+1], p[i+2])
15
+ * with endpoint reflection for the first/last segments.
16
+ *
17
+ * Falls back to a simple polyline (`M … L …`) for n < 3.
18
+ */
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Public API
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type Point = readonly [number, number];
25
+
26
+ export interface SmoothCurveOptions {
27
+ /**
28
+ * Catmull-Rom tension. 0.5 = centripetal-ish; 0 = uniform; 1 = chordal.
29
+ * Word's observed behavior matches tension ≈ 0.5.
30
+ */
31
+ tension?: number;
32
+ }
33
+
34
+ /**
35
+ * Return an SVG path `d` string for a smoothed curve through `points`.
36
+ *
37
+ * n=0 → "" (no path)
38
+ * n=1 → "M x y" (single-point marker)
39
+ * n=2 → "M x1 y1 L x2 y2" (straight line)
40
+ * n≥3 → "M x0 y0 C cp1x cp1y cp2x cp2y x1 y1 C …"
41
+ *
42
+ * The first and last segments reflect their neighbors (no virtual
43
+ * overshoot), matching Word's endpoint behavior.
44
+ */
45
+ export function smoothPath(points: ReadonlyArray<Point>, options: SmoothCurveOptions = {}): string {
46
+ if (points.length === 0) return "";
47
+ if (points.length === 1) return `M ${fmt(points[0]![0])} ${fmt(points[0]![1])}`;
48
+ if (points.length === 2) {
49
+ return `M ${fmt(points[0]![0])} ${fmt(points[0]![1])} L ${fmt(points[1]![0])} ${fmt(points[1]![1])}`;
50
+ }
51
+ const tension = options.tension ?? 0.5;
52
+ const parts: string[] = [`M ${fmt(points[0]![0])} ${fmt(points[0]![1])}`];
53
+ for (let i = 0; i < points.length - 1; i++) {
54
+ const p0 = i === 0 ? reflect(points[1]!, points[0]!) : points[i - 1]!;
55
+ const p1 = points[i]!;
56
+ const p2 = points[i + 1]!;
57
+ const p3 = i === points.length - 2 ? reflect(points[i]!, points[i + 1]!) : points[i + 2]!;
58
+ const cp1 = controlPoint(p0, p1, p2, tension);
59
+ const cp2 = controlPoint(p3, p2, p1, tension);
60
+ parts.push(
61
+ `C ${fmt(cp1[0])} ${fmt(cp1[1])} ${fmt(cp2[0])} ${fmt(cp2[1])} ${fmt(p2[0])} ${fmt(p2[1])}`,
62
+ );
63
+ }
64
+ return parts.join(" ");
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Implementation
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Compute a Catmull-Rom control point for the segment from p1 to p2.
73
+ * The control point lives at `p1 + (p2 - p0) * tension / 3`, which is
74
+ * the classic conversion of a Catmull-Rom tangent into a cubic-Bézier
75
+ * handle.
76
+ */
77
+ function controlPoint(p0: Point, p1: Point, p2: Point, tension: number): Point {
78
+ return [
79
+ p1[0] + ((p2[0] - p0[0]) * tension) / 3,
80
+ p1[1] + ((p2[1] - p0[1]) * tension) / 3,
81
+ ];
82
+ }
83
+
84
+ /**
85
+ * Reflect `ref` across `anchor` to produce the "virtual" point used at
86
+ * polyline endpoints (so the curve's tangent at the endpoint matches
87
+ * the chord, preventing overshoot).
88
+ */
89
+ function reflect(ref: Point, anchor: Point): Point {
90
+ return [2 * anchor[0] - ref[0], 2 * anchor[1] - ref[1]];
91
+ }
92
+
93
+ /**
94
+ * Format a number to at most 3 decimal places without trailing zeros.
95
+ * Keeps SVG path strings compact and deterministic.
96
+ */
97
+ function fmt(n: number): string {
98
+ if (!Number.isFinite(n)) return "0";
99
+ const rounded = Math.round(n * 1000) / 1000;
100
+ return rounded === 0 ? "0" : String(rounded);
101
+ }
@@ -0,0 +1,378 @@
1
+ /**
2
+ * SVG element helpers for chart rendering (Stage 3B).
3
+ *
4
+ * Each helper returns a plain object (`React.createElement` props) rather
5
+ * than JSX so this file stays free of the `.tsx` extension and its build
6
+ * dependency. Chart renderer components assemble the returned props into
7
+ * JSX:
8
+ *
9
+ * const r = svgRect({ x, y, w, h, fill, defs });
10
+ * // → <rect {...r} />
11
+ *
12
+ * Gradient fills auto-register into the `DefsRegistry` passed via `defs`
13
+ * and emit `fill="url(#<id>)"`. Callers must render `defsRegistry.toElement()`
14
+ * inside a `<defs>` block at the root of the SVG.
15
+ *
16
+ * Color resolution uses `resolveColor(ref, theme)` from Stage 2 so renderers
17
+ * never need to import resolve-color directly.
18
+ *
19
+ * Stroke widths are stored in EMU (English Metric Units) on the model but
20
+ * converted to pt for SVG: 1 pt = 12700 EMU.
21
+ */
22
+
23
+ import { resolveColor } from "../../../io/ooxml/chart/resolve-color.ts";
24
+ import type {
25
+ FillSpec,
26
+ StrokeSpec,
27
+ ColorRef,
28
+ } from "../../../io/ooxml/chart/types.ts";
29
+ import type { ResolvedTheme } from "../../../model/canonical-document.ts";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // DefsRegistry — collects gradient / pattern defs for the SVG <defs> block
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const EMU_PER_PT = 12700;
36
+
37
+ /**
38
+ * Deterministic ID generator using FNV-1a (32-bit). Ensures gradient IDs
39
+ * are stable across renders for Stage 7 pixel-diff (no Math.random / counter
40
+ * state that drifts between runs).
41
+ */
42
+ function fnv1a32(str: string): string {
43
+ let hash = 0x811c9dc5;
44
+ for (let i = 0; i < str.length; i++) {
45
+ hash ^= str.charCodeAt(i);
46
+ hash = (Math.imul(hash, 0x01000193) >>> 0);
47
+ }
48
+ return hash.toString(16).padStart(8, "0");
49
+ }
50
+
51
+ export interface GradientStop {
52
+ offset: number; // 0–1
53
+ color: string; // resolved "#RRGGBB"
54
+ }
55
+
56
+ interface DefsEntry {
57
+ kind: "linearGradient";
58
+ id: string;
59
+ angle: number;
60
+ stops: GradientStop[];
61
+ }
62
+
63
+ export class DefsRegistry {
64
+ private readonly entries = new Map<string, DefsEntry>();
65
+
66
+ /**
67
+ * Register a linear gradient and return its `id`. If an identical
68
+ * gradient was already registered, the existing `id` is returned
69
+ * without creating a duplicate.
70
+ */
71
+ registerGradient(stops: GradientStop[], angleDeg: number): string {
72
+ const key = stops.map((s) => `${s.offset}:${s.color}`).join("|") + `@${angleDeg}`;
73
+ const id = `grad-${fnv1a32(key)}`;
74
+ if (!this.entries.has(id)) {
75
+ this.entries.set(id, { kind: "linearGradient", id, angle: angleDeg, stops });
76
+ }
77
+ return id;
78
+ }
79
+
80
+ /** Serialise all registered defs to an SVG attribute-object map array. */
81
+ toDefsEntries(): ReadonlyArray<DefsEntry> {
82
+ return Array.from(this.entries.values());
83
+ }
84
+
85
+ /** True when no defs have been registered. */
86
+ isEmpty(): boolean {
87
+ return this.entries.size === 0;
88
+ }
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Fill / stroke resolution helpers
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Resolve a `FillSpec` → SVG fill attribute string + optional gradient
97
+ * registration.
98
+ */
99
+ export function resolveFill(
100
+ fill: FillSpec | undefined,
101
+ theme: ResolvedTheme | undefined,
102
+ defs: DefsRegistry,
103
+ ): string {
104
+ if (!fill || fill.kind === "auto") return "#808080";
105
+ if (fill.kind === "none") return "none";
106
+ if (fill.kind === "solid") {
107
+ return resolveColorSafe(fill.color, theme);
108
+ }
109
+ if (fill.kind === "gradient") {
110
+ const stops: GradientStop[] = fill.stops.map((s) => ({
111
+ offset: Math.max(0, Math.min(1, s.pos / 100)),
112
+ color: resolveColorSafe(s.color, theme),
113
+ }));
114
+ const angle = fill.angle ?? 0;
115
+ return `url(#${defs.registerGradient(stops, angle)})`;
116
+ }
117
+ return "#808080";
118
+ }
119
+
120
+ /** Resolve a `StrokeSpec` → SVG stroke attribute string. */
121
+ export function resolveStroke(
122
+ stroke: StrokeSpec | undefined,
123
+ theme: ResolvedTheme | undefined,
124
+ ): string {
125
+ if (!stroke || stroke.noFill) return "none";
126
+ if (!stroke.color) return "none";
127
+ return resolveColorSafe(stroke.color, theme);
128
+ }
129
+
130
+ /** Resolve stroke width EMU → pt string for SVG `stroke-width`. */
131
+ export function resolveStrokeWidthPt(
132
+ stroke: StrokeSpec | undefined,
133
+ ): number {
134
+ if (!stroke || stroke.noFill) return 0;
135
+ if (!stroke.widthEmu) return 0.75; // Word default thin stroke
136
+ return stroke.widthEmu / EMU_PER_PT;
137
+ }
138
+
139
+ function resolveColorSafe(ref: ColorRef, theme: ResolvedTheme | undefined): string {
140
+ return resolveColor(ref, theme ?? { colors: {} });
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // SVG element attribute helpers
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export interface PrimitiveProps {
148
+ theme: ResolvedTheme | undefined;
149
+ defs: DefsRegistry;
150
+ }
151
+
152
+ export interface RectAttrs {
153
+ x: number;
154
+ y: number;
155
+ width: number;
156
+ height: number;
157
+ fill: string;
158
+ stroke: string;
159
+ strokeWidth: number;
160
+ "data-role"?: string;
161
+ }
162
+
163
+ export function svgRect(opts: {
164
+ x: number;
165
+ y: number;
166
+ w: number;
167
+ h: number;
168
+ fill?: FillSpec;
169
+ stroke?: StrokeSpec;
170
+ role?: string;
171
+ } & PrimitiveProps): RectAttrs {
172
+ const fillStr = resolveFill(opts.fill, opts.theme, opts.defs);
173
+ const strokeStr = resolveStroke(opts.stroke, opts.theme);
174
+ const sw = resolveStrokeWidthPt(opts.stroke);
175
+ const attrs: RectAttrs = {
176
+ x: opts.x,
177
+ y: opts.y,
178
+ width: opts.w,
179
+ height: opts.h,
180
+ fill: fillStr,
181
+ stroke: strokeStr,
182
+ strokeWidth: sw,
183
+ };
184
+ if (opts.role) attrs["data-role"] = opts.role;
185
+ return attrs;
186
+ }
187
+
188
+ export interface LineAttrs {
189
+ x1: number;
190
+ y1: number;
191
+ x2: number;
192
+ y2: number;
193
+ stroke: string;
194
+ strokeWidth: number;
195
+ strokeDasharray?: string;
196
+ "data-role"?: string;
197
+ }
198
+
199
+ export function svgLine(opts: {
200
+ x1: number;
201
+ y1: number;
202
+ x2: number;
203
+ y2: number;
204
+ stroke?: StrokeSpec;
205
+ role?: string;
206
+ } & PrimitiveProps): LineAttrs {
207
+ const strokeStr = resolveStroke(opts.stroke, opts.theme);
208
+ const sw = resolveStrokeWidthPt(opts.stroke);
209
+ const dash = opts.stroke?.dash ? dashArray(opts.stroke.dash, sw) : undefined;
210
+ const attrs: LineAttrs = {
211
+ x1: opts.x1,
212
+ y1: opts.y1,
213
+ x2: opts.x2,
214
+ y2: opts.y2,
215
+ stroke: strokeStr,
216
+ strokeWidth: sw,
217
+ };
218
+ if (dash) attrs.strokeDasharray = dash;
219
+ if (opts.role) attrs["data-role"] = opts.role;
220
+ return attrs;
221
+ }
222
+
223
+ export interface CircleAttrs {
224
+ cx: number;
225
+ cy: number;
226
+ r: number;
227
+ fill: string;
228
+ stroke: string;
229
+ strokeWidth: number;
230
+ "data-role"?: string;
231
+ }
232
+
233
+ export function svgCircle(opts: {
234
+ cx: number;
235
+ cy: number;
236
+ r: number;
237
+ fill?: FillSpec;
238
+ stroke?: StrokeSpec;
239
+ role?: string;
240
+ } & PrimitiveProps): CircleAttrs {
241
+ const attrs: CircleAttrs = {
242
+ cx: opts.cx,
243
+ cy: opts.cy,
244
+ r: opts.r,
245
+ fill: resolveFill(opts.fill, opts.theme, opts.defs),
246
+ stroke: resolveStroke(opts.stroke, opts.theme),
247
+ strokeWidth: resolveStrokeWidthPt(opts.stroke),
248
+ };
249
+ if (opts.role) attrs["data-role"] = opts.role;
250
+ return attrs;
251
+ }
252
+
253
+ export interface PathAttrs {
254
+ d: string;
255
+ fill: string;
256
+ stroke: string;
257
+ strokeWidth: number;
258
+ strokeDasharray?: string;
259
+ fillRule?: "nonzero" | "evenodd";
260
+ "data-role"?: string;
261
+ }
262
+
263
+ export function svgPath(opts: {
264
+ d: string;
265
+ fill?: FillSpec;
266
+ stroke?: StrokeSpec;
267
+ fillRule?: "nonzero" | "evenodd";
268
+ role?: string;
269
+ } & PrimitiveProps): PathAttrs {
270
+ const sw = resolveStrokeWidthPt(opts.stroke);
271
+ const dash = opts.stroke?.dash ? dashArray(opts.stroke.dash, sw) : undefined;
272
+ const attrs: PathAttrs = {
273
+ d: opts.d,
274
+ fill: resolveFill(opts.fill, opts.theme, opts.defs),
275
+ stroke: resolveStroke(opts.stroke, opts.theme),
276
+ strokeWidth: sw,
277
+ };
278
+ if (dash) attrs.strokeDasharray = dash;
279
+ if (opts.fillRule) attrs.fillRule = opts.fillRule;
280
+ if (opts.role) attrs["data-role"] = opts.role;
281
+ return attrs;
282
+ }
283
+
284
+ export interface TextAttrs {
285
+ x: number;
286
+ y: number;
287
+ fill: string;
288
+ fontFamily: string;
289
+ fontSize: number;
290
+ fontWeight: string;
291
+ fontStyle: string;
292
+ textAnchor: "start" | "middle" | "end";
293
+ dominantBaseline: "auto" | "middle" | "hanging";
294
+ transform?: string;
295
+ "data-role"?: string;
296
+ }
297
+
298
+ export function svgText(opts: {
299
+ x: number;
300
+ y: number;
301
+ fontFamily?: string;
302
+ fontSizePt?: number;
303
+ bold?: boolean;
304
+ italic?: boolean;
305
+ color?: ColorRef;
306
+ anchor?: "start" | "middle" | "end";
307
+ baseline?: "auto" | "middle" | "hanging";
308
+ rotate?: number;
309
+ role?: string;
310
+ } & PrimitiveProps): TextAttrs {
311
+ const PT_TO_PX = 96 / 72;
312
+ const attrs: TextAttrs = {
313
+ x: opts.x,
314
+ y: opts.y,
315
+ fill: opts.color ? resolveColorSafe(opts.color, opts.theme) : "currentColor",
316
+ fontFamily: opts.fontFamily ?? "Calibri, Carlito, 'Segoe UI', Arial, sans-serif",
317
+ fontSize: (opts.fontSizePt ?? 10) * PT_TO_PX,
318
+ fontWeight: opts.bold ? "bold" : "normal",
319
+ fontStyle: opts.italic ? "italic" : "normal",
320
+ textAnchor: opts.anchor ?? "start",
321
+ dominantBaseline: opts.baseline ?? "auto",
322
+ };
323
+ if (opts.rotate !== undefined && opts.rotate !== 0) {
324
+ attrs.transform = `rotate(${opts.rotate},${opts.x},${opts.y})`;
325
+ }
326
+ if (opts.role) attrs["data-role"] = opts.role;
327
+ return attrs;
328
+ }
329
+
330
+ export interface PolygonAttrs {
331
+ points: string;
332
+ fill: string;
333
+ stroke: string;
334
+ strokeWidth: number;
335
+ "data-role"?: string;
336
+ }
337
+
338
+ export function svgPolygon(opts: {
339
+ points: Array<[number, number]>;
340
+ fill?: FillSpec;
341
+ stroke?: StrokeSpec;
342
+ role?: string;
343
+ } & PrimitiveProps): PolygonAttrs {
344
+ const pointsStr = opts.points.map(([x, y]) => `${x},${y}`).join(" ");
345
+ const attrs: PolygonAttrs = {
346
+ points: pointsStr,
347
+ fill: resolveFill(opts.fill, opts.theme, opts.defs),
348
+ stroke: resolveStroke(opts.stroke, opts.theme),
349
+ strokeWidth: resolveStrokeWidthPt(opts.stroke),
350
+ };
351
+ if (opts.role) attrs["data-role"] = opts.role;
352
+ return attrs;
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Dash-array helper
357
+ // ---------------------------------------------------------------------------
358
+
359
+ type DashPattern = NonNullable<StrokeSpec["dash"]>;
360
+
361
+ /**
362
+ * Convert an OOXML dash name to an SVG `stroke-dasharray` value scaled
363
+ * to the given stroke width. Patterns are approximate matches to Word's
364
+ * rendering.
365
+ */
366
+ function dashArray(dash: DashPattern, strokeWidth: number): string {
367
+ const sw = Math.max(0.5, strokeWidth);
368
+ const patterns: Record<DashPattern, string> = {
369
+ solid: "",
370
+ dash: `${4 * sw} ${2 * sw}`,
371
+ dashDot: `${4 * sw} ${1.5 * sw} ${sw} ${1.5 * sw}`,
372
+ lgDash: `${8 * sw} ${3 * sw}`,
373
+ lgDashDot: `${8 * sw} ${3 * sw} ${sw} ${3 * sw}`,
374
+ sysDash: `${3 * sw} ${sw}`,
375
+ sysDashDot: `${3 * sw} ${sw} ${sw} ${sw}`,
376
+ };
377
+ return patterns[dash] ?? "";
378
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Unsupported-chart fallback renderer (Stage 4 Slice 4H).
3
+ *
4
+ * Two paths:
5
+ * 1. `previewMediaId` present — render the fallback bitmap Word cached
6
+ * in `mc:Fallback`. The host passes a `mediaUrl` resolver via the
7
+ * surrounding runtime; when none is provided, we fall through to
8
+ * the typed badge.
9
+ * 2. No preview available — render a grey rounded-rect typed badge
10
+ * sized to the reserved chart rectangle. The badge shows the chart
11
+ * type (`"Unsupported chart"` plus the parser's `reason` detail).
12
+ *
13
+ * This is the Stage 4 floor: every `ChartModel` with `kind:
14
+ * "unsupported"` gets a predictable, sized placeholder so chart slots
15
+ * in the document never collapse to zero-height.
16
+ */
17
+
18
+ import React from "react";
19
+ import type { UnsupportedChartModel } from "../../../io/ooxml/chart/types.ts";
20
+ import type { ResolvedTheme } from "../../../model/canonical-document.ts";
21
+ import type { PlotAreaLayout } from "../layout/plot-area.ts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Public API
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface UnsupportedChartProps {
28
+ model: UnsupportedChartModel;
29
+ layout: PlotAreaLayout;
30
+ theme: ResolvedTheme | undefined;
31
+ /**
32
+ * Optional resolver — caller provides URL for `previewMediaId`.
33
+ * When absent, the typed-badge fallback renders.
34
+ */
35
+ resolveMediaUrl?: (mediaId: string) => string | undefined;
36
+ /** Optional preview-media ID threaded by the surface layer. */
37
+ previewMediaId?: string;
38
+ }
39
+
40
+ function UnsupportedChartImpl({
41
+ model,
42
+ layout,
43
+ resolveMediaUrl,
44
+ previewMediaId,
45
+ theme,
46
+ }: UnsupportedChartProps): React.ReactElement {
47
+ void theme;
48
+ const rect = layout.plotRect;
49
+ const url = previewMediaId && resolveMediaUrl ? resolveMediaUrl(previewMediaId) : undefined;
50
+ if (url) {
51
+ return (
52
+ <image
53
+ x={rect.x}
54
+ y={rect.y}
55
+ width={rect.w}
56
+ height={rect.h}
57
+ href={url}
58
+ preserveAspectRatio="xMidYMid meet"
59
+ data-role="chart-fallback-image"
60
+ />
61
+ );
62
+ }
63
+ return (
64
+ <g data-role="chart-unsupported" data-reason={model.reason}>
65
+ <rect
66
+ x={rect.x}
67
+ y={rect.y}
68
+ width={rect.w}
69
+ height={rect.h}
70
+ rx={4}
71
+ ry={4}
72
+ fill="#F2F2F2"
73
+ stroke="#BFBFBF"
74
+ strokeWidth={1}
75
+ strokeDasharray="4 2"
76
+ />
77
+ <text
78
+ x={rect.x + rect.w / 2}
79
+ y={rect.y + rect.h / 2 - 4}
80
+ textAnchor="middle"
81
+ fontSize={13}
82
+ fill="#595959"
83
+ data-role="chart-unsupported-title"
84
+ >
85
+ Unsupported chart
86
+ </text>
87
+ <text
88
+ x={rect.x + rect.w / 2}
89
+ y={rect.y + rect.h / 2 + 14}
90
+ textAnchor="middle"
91
+ fontSize={10}
92
+ fill="#808080"
93
+ data-role="chart-unsupported-detail"
94
+ >
95
+ {describeReason(model)}
96
+ </text>
97
+ </g>
98
+ );
99
+ }
100
+
101
+ export const UnsupportedChart = React.memo(
102
+ UnsupportedChartImpl,
103
+ (prev, next) =>
104
+ prev.model.rawXml === next.model.rawXml &&
105
+ prev.layout.plotRect.w === next.layout.plotRect.w &&
106
+ prev.layout.plotRect.h === next.layout.plotRect.h &&
107
+ prev.previewMediaId === next.previewMediaId,
108
+ );
109
+
110
+ function describeReason(model: UnsupportedChartModel): string {
111
+ const map: Record<UnsupportedChartModel["reason"], string> = {
112
+ "not-yet-implemented": "Not yet implemented",
113
+ pivot: "Pivot chart",
114
+ stock: "Stock chart",
115
+ surface: "Surface chart",
116
+ treemap: "Treemap",
117
+ sunburst: "Sunburst",
118
+ histogram: "Histogram",
119
+ waterfall: "Waterfall",
120
+ funnel: "Funnel",
121
+ map: "Map",
122
+ "no-plot-area": "No plot area",
123
+ "parse-error": "Chart could not be parsed",
124
+ };
125
+ return map[model.reason] ?? "Unsupported chart type";
126
+ }
@@ -41,6 +41,17 @@ export function CollabAudienceChip({
41
41
  "tw-collab-audience-chip",
42
42
  audience ? `tw-collab-audience-chip--${audience}` : "tw-collab-audience-chip--empty",
43
43
  disabled ? "tw-collab-audience-chip--disabled" : null,
44
+ // Lane 6b §6b.S6 — calm chip; disabled = dim tertiary text. BEM
45
+ // classes above remain as hooks for host CSS to tint by audience.
46
+ "inline-flex items-center px-2 py-0.5",
47
+ "rounded-[var(--radius-pill)]",
48
+ "bg-[var(--color-bg-muted)] text-[var(--color-text-secondary)]",
49
+ "text-[11px] font-medium capitalize",
50
+ "transition-colors duration-[var(--motion-fast)]",
51
+ "focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]",
52
+ disabled
53
+ ? "opacity-60 text-[var(--color-text-tertiary)] cursor-not-allowed"
54
+ : "hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] cursor-pointer",
44
55
  className ?? null,
45
56
  ]
46
57
  .filter((v): v is string => v !== null)
@@ -58,6 +58,11 @@ export function CollabNegotiationActionBar({
58
58
  "tw-collab-negotiation-action-bar",
59
59
  `tw-collab-negotiation-action-bar--${state}`,
60
60
  blocked ? "tw-collab-negotiation-action-bar--blocked" : null,
61
+ // Lane 6b §6b.S6 — calm toolbar row; blocked state dims.
62
+ "inline-flex items-center gap-1 px-2 py-1",
63
+ "text-[11px] text-[var(--color-text-secondary)]",
64
+ "transition-colors duration-[var(--motion-fast)]",
65
+ blocked ? "opacity-60" : null,
61
66
  className ?? null,
62
67
  ]
63
68
  .filter((v): v is string => v !== null)
@@ -85,24 +90,45 @@ export function CollabNegotiationActionBar({
85
90
  No active comment
86
91
  </span>
87
92
  ) : (
88
- buttons.map((btn) => (
89
- <button
90
- key={btn.id}
91
- type="button"
92
- className={`tw-collab-negotiation-action-bar__button tw-collab-negotiation-action-bar__button--${btn.id}`}
93
- data-testid={`collab-negotiation-action-${btn.id}`}
94
- data-action-id={btn.id}
95
- disabled={blocked || btn.disabled}
96
- aria-disabled={blocked || btn.disabled ? "true" : undefined}
97
- title={btn.title}
98
- onClick={() => {
99
- if (blocked || btn.disabled) return;
100
- onDispatch(btn.build());
101
- }}
102
- >
103
- {btn.label}
104
- </button>
105
- ))
93
+ buttons.map((btn) => {
94
+ // Lane 6b §6b.S6 — semantic tone per action class:
95
+ // accept / vote-approve → accent primary (the "go" action)
96
+ // reject / vote-reject → semantic error soft tint
97
+ // lock / reopen / propose / counter → calm secondary
98
+ const toneClass =
99
+ btn.id === "accept" || btn.id === "vote-approve"
100
+ ? "bg-[var(--color-accent-primary)] text-[var(--color-text-on-accent)] hover:bg-[var(--color-accent-primary-hover)]"
101
+ : btn.id === "reject" || btn.id === "vote-reject"
102
+ ? "bg-[var(--color-semantic-error-soft)] text-[var(--color-semantic-error)] hover:bg-[var(--color-semantic-error-soft)]/80"
103
+ : "bg-[var(--color-bg-muted)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]";
104
+ return (
105
+ <button
106
+ key={btn.id}
107
+ type="button"
108
+ className={[
109
+ "tw-collab-negotiation-action-bar__button",
110
+ `tw-collab-negotiation-action-bar__button--${btn.id}`,
111
+ "inline-flex items-center rounded-[var(--radius-sm)]",
112
+ "px-2 py-1 text-[11px] font-medium",
113
+ "transition-colors duration-[var(--motion-fast)]",
114
+ "focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]",
115
+ "disabled:opacity-40 disabled:cursor-not-allowed",
116
+ toneClass,
117
+ ].join(" ")}
118
+ data-testid={`collab-negotiation-action-${btn.id}`}
119
+ data-action-id={btn.id}
120
+ disabled={blocked || btn.disabled}
121
+ aria-disabled={blocked || btn.disabled ? "true" : undefined}
122
+ title={btn.title}
123
+ onClick={() => {
124
+ if (blocked || btn.disabled) return;
125
+ onDispatch(btn.build());
126
+ }}
127
+ >
128
+ {btn.label}
129
+ </button>
130
+ );
131
+ })
106
132
  )}
107
133
  </div>
108
134
  );