@beyondwork/docx-react-component 1.0.36 → 1.0.38

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/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +402 -1
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/commands/section-layout-commands.ts +58 -0
  7. package/src/core/commands/table-grid.ts +431 -0
  8. package/src/core/commands/table-structure-commands.ts +815 -55
  9. package/src/core/selection/mapping.ts +6 -0
  10. package/src/io/docx-session.ts +24 -9
  11. package/src/io/export/build-app-properties-xml.ts +88 -0
  12. package/src/io/export/serialize-comments.ts +6 -1
  13. package/src/io/export/serialize-footnotes.ts +10 -9
  14. package/src/io/export/serialize-headers-footers.ts +11 -10
  15. package/src/io/export/serialize-main-document.ts +328 -50
  16. package/src/io/export/serialize-numbering.ts +114 -24
  17. package/src/io/export/serialize-tables.ts +87 -11
  18. package/src/io/export/table-properties-xml.ts +174 -20
  19. package/src/io/export/twip.ts +66 -0
  20. package/src/io/normalize/normalize-text.ts +20 -0
  21. package/src/io/ooxml/parse-footnotes.ts +62 -1
  22. package/src/io/ooxml/parse-headers-footers.ts +62 -1
  23. package/src/io/ooxml/parse-main-document.ts +158 -1
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/legal/bookmarks.ts +78 -0
  26. package/src/model/canonical-document.ts +45 -0
  27. package/src/review/store/scope-tag-diff.ts +130 -0
  28. package/src/runtime/document-layout.ts +4 -2
  29. package/src/runtime/document-navigation.ts +2 -306
  30. package/src/runtime/document-runtime.ts +287 -11
  31. package/src/runtime/layout/default-page-format.ts +96 -0
  32. package/src/runtime/layout/docx-font-loader.ts +143 -0
  33. package/src/runtime/layout/index.ts +233 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +59 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +628 -0
  36. package/src/runtime/layout/layout-invalidation.ts +257 -0
  37. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  38. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  39. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  40. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  43. package/src/runtime/layout/page-graph.ts +452 -0
  44. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  45. package/src/runtime/layout/page-story-resolver.ts +195 -0
  46. package/src/runtime/layout/paginated-layout-engine.ts +921 -0
  47. package/src/runtime/layout/project-block-fragments.ts +91 -0
  48. package/src/runtime/layout/public-facet.ts +1398 -0
  49. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  50. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  51. package/src/runtime/layout/table-render-plan.ts +229 -0
  52. package/src/runtime/render/block-fragment-projection.ts +35 -0
  53. package/src/runtime/render/decoration-resolver.ts +189 -0
  54. package/src/runtime/render/index.ts +57 -0
  55. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  56. package/src/runtime/render/render-frame-types.ts +317 -0
  57. package/src/runtime/render/render-kernel.ts +755 -0
  58. package/src/runtime/scope-tag-registry.ts +95 -0
  59. package/src/runtime/surface-projection.ts +1 -0
  60. package/src/runtime/text-ack-range.ts +49 -0
  61. package/src/runtime/view-state.ts +67 -0
  62. package/src/runtime/workflow-markup.ts +1 -5
  63. package/src/runtime/workflow-rail-segments.ts +280 -0
  64. package/src/ui/WordReviewEditor.tsx +99 -15
  65. package/src/ui/editor-runtime-boundary.ts +10 -1
  66. package/src/ui/editor-shell-view.tsx +6 -0
  67. package/src/ui/editor-surface-controller.tsx +3 -0
  68. package/src/ui/headless/chrome-registry.ts +501 -0
  69. package/src/ui/headless/scoped-chrome-policy.ts +183 -0
  70. package/src/ui/headless/selection-tool-context.ts +2 -0
  71. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  75. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  76. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  77. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  78. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  79. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  80. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  81. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  82. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  86. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
  87. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
  88. package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
  90. package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
  91. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  92. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  93. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  94. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
  95. package/src/ui-tailwind/index.ts +33 -0
  96. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  97. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  98. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  99. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  100. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  101. package/src/ui-tailwind/theme/editor-theme.css +505 -144
  102. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  103. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  104. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  105. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  106. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
  107. package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
@@ -52,7 +52,12 @@ import {
52
52
  recordPerfSample,
53
53
  startPerfProbe,
54
54
  } from "./perf-probe";
55
- import type { PositionMap } from "./pm-position-map";
55
+ import { buildPositionMap, type PositionMap } from "./pm-position-map";
56
+ import { createLocalEditSessionState } from "./local-edit-session-state";
57
+ import { createFastTextEditLane } from "./fast-text-edit-lane";
58
+ import { createPredictedTxGate } from "./predicted-tx-gate";
59
+ import { createScopeTagRegistry } from "../../runtime/scope-tag-registry";
60
+ import { hasBailIfCrossedTagInRange } from "./predicted-tag-preflight";
56
61
  import {
57
62
  clearSearch as clearSearchPlugin,
58
63
  createSearchPlugin,
@@ -111,6 +116,15 @@ export interface TwProseMirrorSurfaceProps {
111
116
  activeWorkflowWorkItemId?: string | null;
112
117
  activeWorkflowScopeIds?: readonly string[];
113
118
  workflowMetadata?: readonly WorkflowMetadataMarkup[];
119
+ /**
120
+ * Synchronous dispatcher for predicted-lane runtime commands. When provided,
121
+ * the surface routes text input through `FastTextEditLane` for immediate
122
+ * local PM feel; when absent, it falls back to the legacy callback-based
123
+ * round-trip that calls `runtime.applyActiveStoryTextCommand` externally.
124
+ */
125
+ dispatchRuntimeCommand?: (
126
+ command: import("./fast-text-edit-lane").LaneRuntimeCommand,
127
+ ) => import("../../api/public-types").TextCommandAck;
114
128
  }
115
129
 
116
130
  export interface TwProseMirrorSurfaceRef {
@@ -157,6 +171,9 @@ export const TwProseMirrorSurface = forwardRef<
157
171
  const documentBuildKeyRef = useRef<string | null>(null);
158
172
  const decorationBuildKeyRef = useRef<string | null>(null);
159
173
  const suppressSelectionEchoRef = useRef(false);
174
+ const sessionRef = useRef<import("./local-edit-session-state").LocalEditSessionState | null>(null);
175
+ const laneRef = useRef<import("./fast-text-edit-lane").FastTextEditLane | null>(null);
176
+ const equivalentAckKeyRef = useRef<string | null>(null);
160
177
  const selectionToolbarFrameRef = useRef<number | null>(null);
161
178
  const lastSelectionToolbarMeasurementRef = useRef<{
162
179
  key: string | null;
@@ -165,6 +182,13 @@ export const TwProseMirrorSurface = forwardRef<
165
182
  key: null,
166
183
  anchor: null,
167
184
  });
185
+ const snapshotRef = useRef(snapshot);
186
+ snapshotRef.current = snapshot;
187
+
188
+ const scopeTagRegistry = useMemo(
189
+ () => createScopeTagRegistry(),
190
+ [],
191
+ );
168
192
 
169
193
  // Keep callbacks ref up to date (avoids stale closures in PM plugins)
170
194
  callbacksRef.current = {
@@ -263,6 +287,10 @@ export const TwProseMirrorSurface = forwardRef<
263
287
  callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
264
288
  };
265
289
 
290
+ const gate = createPredictedTxGate({
291
+ isPredicted: (opId) => sessionRef.current?.isPredicted(opId) ?? false,
292
+ });
293
+
266
294
  const corePlugins = props.ydoc
267
295
  ? createCollabPlugins({
268
296
  ydoc: props.ydoc,
@@ -271,16 +299,50 @@ export const TwProseMirrorSurface = forwardRef<
271
299
  })
272
300
  : createCommandBridgePlugins({
273
301
  ...selectionCallbacks,
274
- onInsertText: (text) => callbacksRef.current?.onInsertText(text),
275
- onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
276
- onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
277
- onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
278
- onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
302
+ gate,
303
+ onInsertText: (text) => {
304
+ if (laneRef.current) {
305
+ laneRef.current.onInsertText(text);
306
+ } else {
307
+ callbacksRef.current?.onInsertText(text);
308
+ }
309
+ },
310
+ onDeleteBackward: () => {
311
+ if (laneRef.current) {
312
+ laneRef.current.onDeleteBackward();
313
+ } else {
314
+ callbacksRef.current?.onDeleteBackward();
315
+ }
316
+ },
317
+ onDeleteForward: () => {
318
+ if (laneRef.current) {
319
+ laneRef.current.onDeleteForward();
320
+ } else {
321
+ callbacksRef.current?.onDeleteForward();
322
+ }
323
+ },
324
+ onSplitParagraph: () => {
325
+ if (laneRef.current) {
326
+ laneRef.current.onSplitParagraph();
327
+ } else {
328
+ callbacksRef.current?.onSplitParagraph();
329
+ }
330
+ },
331
+ onInsertHardBreak: () => {
332
+ if (laneRef.current) {
333
+ laneRef.current.onInsertHardBreak();
334
+ } else {
335
+ callbacksRef.current?.onInsertHardBreak();
336
+ }
337
+ },
279
338
  onInsertTab: () => callbacksRef.current?.onInsertTab(),
280
339
  onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
281
340
  onUndo: () => callbacksRef.current?.onUndo(),
282
341
  onRedo: () => callbacksRef.current?.onRedo(),
283
342
  onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
343
+ onCompositionChange: (composing) => {
344
+ sessionRef.current?.setComposing(composing);
345
+ },
284
346
  });
285
347
 
286
348
  return [
@@ -338,6 +400,72 @@ export const TwProseMirrorSurface = forwardRef<
338
400
  ],
339
401
  );
340
402
 
403
+ // Create the local edit session once per mount. The session is scoped to the
404
+ // view lifetime; the rebuild effect seeds the base revision token below.
405
+ useEffect(() => {
406
+ sessionRef.current = createLocalEditSessionState({
407
+ baseRevisionToken: snapshot.revisionToken,
408
+ });
409
+ return () => {
410
+ sessionRef.current = null;
411
+ laneRef.current = null;
412
+ };
413
+ // Intentionally empty deps: session is scoped to the view lifetime.
414
+ // eslint-disable-next-line react-hooks/exhaustive-deps
415
+ }, []);
416
+
417
+ // Build the FastTextEditLane whenever `dispatchRuntimeCommand` changes.
418
+ // The lane is consulted via `laneRef.current` inside PM plugin callbacks,
419
+ // so the plugins memo does not need to depend on this effect.
420
+ useEffect(() => {
421
+ if (!props.dispatchRuntimeCommand || !sessionRef.current) {
422
+ laneRef.current = null;
423
+ return;
424
+ }
425
+ laneRef.current = createFastTextEditLane({
426
+ session: sessionRef.current,
427
+ getView: () => viewRef.current,
428
+ getPositionMap: () => positionMapRef.current,
429
+ dispatchRuntimeCommand: props.dispatchRuntimeCommand,
430
+ suppressSelectionSync: (suppressed) => {
431
+ suppressSelectionEchoRef.current = suppressed;
432
+ },
433
+ shouldBailBeforePredict: (_intent, fromRuntime, toRuntime) => {
434
+ const surface = snapshotRef.current.surface;
435
+ if (!surface) return false;
436
+ return hasBailIfCrossedTagInRange(
437
+ surface,
438
+ scopeTagRegistry,
439
+ fromRuntime,
440
+ toRuntime,
441
+ );
442
+ },
443
+ onEquivalentAck: () => {
444
+ // INVARIANT: this marker is set only by onEquivalentAck, which the
445
+ // runtime invokes synchronously from dispatchRuntimeCommand. The
446
+ // rebuild effect's short-circuit (search for "Predicted-lane
447
+ // short-circuit" below) reads it during the same React render cycle
448
+ // that the predicted dispatch triggered. If the runtime ack ever
449
+ // becomes async (microtask, animation frame, network round-trip),
450
+ // this marker will be stale by the time the rebuild effect runs and
451
+ // the short-circuit must be replaced with a
452
+ // `pendingEquivalentAckOpIds: Set<string>` ledger keyed by opId.
453
+ equivalentAckKeyRef.current = documentBuildKeyRef.current;
454
+ },
455
+ onAdjustedAck: () => {
456
+ // Adjusted path: allow the rebuild effect to run (it will call
457
+ // view.updateState with the canonical snapshot).
458
+ equivalentAckKeyRef.current = null;
459
+ },
460
+ onRejectedAck: () => {
461
+ equivalentAckKeyRef.current = null;
462
+ },
463
+ onStructuralDivergence: () => {
464
+ equivalentAckKeyRef.current = null;
465
+ },
466
+ });
467
+ }, [props.dispatchRuntimeCommand, scopeTagRegistry]);
468
+
341
469
  useEffect(() => {
342
470
  if (!mountRef.current || !surface) return;
343
471
 
@@ -350,6 +478,34 @@ export const TwProseMirrorSurface = forwardRef<
350
478
  return;
351
479
  }
352
480
 
481
+ // Predicted-lane short-circuit: if the lane just confirmed an equivalent
482
+ // ack, the PM doc already matches the canonical snapshot. Update tracking
483
+ // refs and decorations without rebuilding the PM state.
484
+ //
485
+ // INVARIANT: reads `equivalentAckKeyRef.current` set by `onEquivalentAck`
486
+ // above. Depends on the runtime ack being synchronous so the marker is
487
+ // already in place when this effect runs after the predicted dispatch.
488
+ // See the comment at `onEquivalentAck` for the async-ack migration path.
489
+ if (
490
+ viewRef.current &&
491
+ equivalentAckKeyRef.current !== null &&
492
+ sessionRef.current &&
493
+ !sessionRef.current.hasPending() &&
494
+ sessionRef.current.getBaseRevisionToken() === snapshot.revisionToken
495
+ ) {
496
+ // Rebuild the position map so runtime↔PM translations track the new
497
+ // canonical surface shape. The PM document itself is already correct.
498
+ positionMapRef.current = buildPositionMap(surface);
499
+ documentBuildKeyRef.current = documentBuildKey;
500
+ applyDecorationProps(viewRef.current, positionMapRef.current);
501
+ equivalentAckKeyRef.current = null;
502
+ if (pendingTypingProbeRef.current) {
503
+ finishPerfProbe(pendingTypingProbeRef.current);
504
+ pendingTypingProbeRef.current = null;
505
+ }
506
+ return;
507
+ }
508
+
353
509
  const { state, positionMap } = createPMStateFromSnapshot(
354
510
  surface,
355
511
  snapshot.selection,
@@ -473,6 +629,20 @@ export const TwProseMirrorSurface = forwardRef<
473
629
  return;
474
630
  }
475
631
 
632
+ // Skip the dispatch when PM is blurred. `view.dispatch(setSelection)`
633
+ // synchronizes the DOM selection, and on a blurred contenteditable some
634
+ // browsers pull DOM focus back to the editor as a side effect — which
635
+ // would steal focus from any toolbar button or other chrome the user
636
+ // just clicked. PM's state.selection is allowed to diverge from the
637
+ // runtime's selection while PM is blurred; the natural focus flow
638
+ // (click → onFocus → activeRuntime.focus() → snapshot update → this
639
+ // effect fires again with view.hasFocus() === true) resyncs them when
640
+ // PM regains focus, and the next canonical rebuild also re-derives PM
641
+ // selection from the snapshot.
642
+ if (!view.hasFocus()) {
643
+ return;
644
+ }
645
+
476
646
  suppressSelectionEchoRef.current = true;
477
647
  view.dispatch(view.state.tr.setSelection(nextSelection));
478
648
  recordPerfSample("selection.sync");
@@ -14,10 +14,31 @@ export { TwReviewRail, type TwReviewRailProps, type ReviewRailTab } from "./revi
14
14
  export { TwCommentSidebar } from "./review/tw-comment-sidebar";
15
15
  export { TwRevisionSidebar } from "./review/tw-revision-sidebar";
16
16
  export { TwHealthPanel } from "./review/tw-health-panel";
17
+ export { TwWorkflowTab, type TwWorkflowTabProps } from "./review/tw-workflow-tab";
18
+ export {
19
+ TwRailCard,
20
+ type TwRailCardProps,
21
+ type RailCardTone,
22
+ type RailCardAvatar,
23
+ type RailCardCounter,
24
+ type RailCardProgress,
25
+ } from "./review/tw-rail-card";
26
+ export {
27
+ TwReviewRailFooter,
28
+ type TwReviewRailFooterProps,
29
+ } from "./review/tw-review-rail-footer";
17
30
 
18
31
  // Toolbar
19
32
  export { TwToolbar, type TwToolbarProps } from "./toolbar/tw-toolbar";
20
33
  export { TwToolbarIconButton } from "./toolbar/tw-toolbar-icon-button";
34
+ export {
35
+ TwShellHeader,
36
+ type TwShellHeaderProps,
37
+ type ShellHeaderMode,
38
+ type ShellHeaderModeOption,
39
+ type ShellHeaderPrimaryAction,
40
+ type ShellHeaderIconAction,
41
+ } from "./toolbar/tw-shell-header";
21
42
  export type { WorkspaceMode, ZoomLevel } from "../api/public-types";
22
43
 
23
44
  // Status
@@ -27,6 +48,18 @@ export { TwStatusBar } from "./status/tw-status-bar";
27
48
  export { TwAlertBanner } from "./chrome/tw-alert-banner";
28
49
  export { TwSelectionToolbar } from "./chrome/tw-selection-toolbar";
29
50
 
51
+ // Chrome overlay plane (R3a — scope rail, workspace dock)
52
+ export {
53
+ TwChromeOverlay,
54
+ type TwChromeOverlayProps,
55
+ TwScopeRailLayer,
56
+ type TwScopeRailLayerProps,
57
+ TwWorkspaceViewSwitcher,
58
+ type TwWorkspaceViewSwitcherProps,
59
+ type WorkspaceView,
60
+ type WorkspaceViewAction,
61
+ } from "./chrome-overlay";
62
+
30
63
  // Session capabilities
31
64
  export {
32
65
  deriveCapabilities,
@@ -93,10 +93,10 @@ function CommentThreadCard(props: {
93
93
  role="button"
94
94
  tabIndex={0}
95
95
  className={[
96
- "cursor-pointer rounded-md bg-surface/90 px-3 py-2.5 transition-colors ring-1 ring-border/40",
96
+ "cursor-pointer rounded-lg bg-surface/90 px-3 py-2.5 transition-colors ring-1 ring-border/40",
97
97
  focusRingClass,
98
98
  isActive
99
- ? "bg-accent-soft/40 ring-accent/25"
99
+ ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]"
100
100
  : "hover:bg-surface",
101
101
  thread.status === "detached" ? "opacity-70" : "",
102
102
  ].join(" ")}
@@ -0,0 +1,150 @@
1
+ import React, { type ReactNode } from "react";
2
+
3
+ /**
4
+ * TwRailCard — shared editorial card primitive consumed by the runtime review
5
+ * rail tabs (Workflow, Comments, Changes).
6
+ *
7
+ * The card is tone-aware: the tone drives a 3px left edge rule, the eyebrow
8
+ * color, and the optional progress-bar fill via CSS custom properties declared
9
+ * in `src/ui-tailwind/theme/editor-theme.css` under `.wre-rail-card`.
10
+ *
11
+ * This primitive deliberately stays host-agnostic — no workflow, comment, or
12
+ * revision coupling. Consumers pass display fields and an optional footer
13
+ * slot so the card can host accept/reject, reply composer, or resolve actions
14
+ * without the primitive owning any domain logic.
15
+ */
16
+
17
+ export type RailCardTone =
18
+ | "neutral"
19
+ | "inReview"
20
+ | "blocked"
21
+ | "scheduled"
22
+ | "resolved";
23
+
24
+ export interface RailCardAvatar {
25
+ initials: string;
26
+ color?: string;
27
+ alt?: string;
28
+ }
29
+
30
+ export interface RailCardProgress {
31
+ value: number;
32
+ total?: number;
33
+ }
34
+
35
+ export interface RailCardCounter {
36
+ label: string;
37
+ value: string;
38
+ }
39
+
40
+ export interface TwRailCardProps {
41
+ tone: RailCardTone;
42
+ eyebrow: string;
43
+ title: string;
44
+ detail?: string;
45
+ leadingIcon?: ReactNode;
46
+ avatars?: readonly RailCardAvatar[];
47
+ avatarOverflowCount?: number;
48
+ counter?: RailCardCounter;
49
+ progress?: RailCardProgress;
50
+ footer?: ReactNode;
51
+ onClick?: () => void;
52
+ onSelect?: () => void;
53
+ isActive?: boolean;
54
+ dataTestId?: string;
55
+ }
56
+
57
+ export function TwRailCard(props: TwRailCardProps) {
58
+ const {
59
+ tone,
60
+ eyebrow,
61
+ title,
62
+ detail,
63
+ leadingIcon,
64
+ avatars,
65
+ avatarOverflowCount,
66
+ counter,
67
+ progress,
68
+ footer,
69
+ onClick,
70
+ onSelect,
71
+ isActive,
72
+ dataTestId,
73
+ } = props;
74
+
75
+ const handleClick = onClick || onSelect;
76
+ const tag: "article" | "button" = handleClick ? "button" : "article";
77
+
78
+ const clamped = progress
79
+ ? Math.max(0, Math.min(1, progress.total && progress.total > 0 ? progress.value / progress.total : progress.value))
80
+ : 0;
81
+
82
+ const commonProps: Record<string, unknown> = {
83
+ className: "wre-rail-card block w-full text-left",
84
+ "data-tone": tone,
85
+ "data-active": isActive ? "true" : "false",
86
+ "data-testid": dataTestId,
87
+ };
88
+
89
+ if (handleClick) {
90
+ commonProps.onClick = handleClick;
91
+ commonProps.type = "button";
92
+ }
93
+
94
+ return React.createElement(
95
+ tag,
96
+ commonProps,
97
+ <>
98
+ {counter ? (
99
+ <span className="wre-rail-card__counter" aria-label={counter.label} title={counter.label}>
100
+ {counter.value}
101
+ </span>
102
+ ) : null}
103
+
104
+ <span className="wre-rail-card__eyebrow">
105
+ {leadingIcon ? <span aria-hidden="true">{leadingIcon}</span> : null}
106
+ {eyebrow}
107
+ </span>
108
+
109
+ <p className="wre-rail-card__title">{title}</p>
110
+
111
+ {detail ? <p className="wre-rail-card__detail">{detail}</p> : null}
112
+
113
+ {avatars && avatars.length > 0 ? (
114
+ <span className="wre-rail-card__avatars" aria-hidden={avatars.every((a) => !a.alt) ? "true" : undefined}>
115
+ {avatars.map((avatar, index) => (
116
+ <span
117
+ key={`${avatar.initials}-${index}`}
118
+ className="wre-rail-card__avatar"
119
+ style={avatar.color ? { background: avatar.color } : undefined}
120
+ title={avatar.alt}
121
+ aria-label={avatar.alt}
122
+ >
123
+ {avatar.initials}
124
+ </span>
125
+ ))}
126
+ {avatarOverflowCount && avatarOverflowCount > 0 ? (
127
+ <span className="wre-rail-card__avatar-counter">+{avatarOverflowCount}</span>
128
+ ) : null}
129
+ </span>
130
+ ) : null}
131
+
132
+ {footer ? <div className="wre-rail-card__footer">{footer}</div> : null}
133
+
134
+ {progress ? (
135
+ <span
136
+ className="wre-rail-card__progress"
137
+ role="progressbar"
138
+ aria-valuemin={0}
139
+ aria-valuemax={progress.total ?? 1}
140
+ aria-valuenow={progress.value}
141
+ >
142
+ <span
143
+ className="wre-rail-card__progress-fill"
144
+ style={{ width: `${Math.round(clamped * 100)}%` }}
145
+ />
146
+ </span>
147
+ ) : null}
148
+ </>,
149
+ );
150
+ }
@@ -0,0 +1,52 @@
1
+ import React from "react";
2
+ import { HelpCircle, Search } from "lucide-react";
3
+
4
+ /**
5
+ * Thin pinned footer rendered at the bottom of the review rail. The footer
6
+ * auto-hides when neither `onSearch` nor `helpHref` is supplied, so existing
7
+ * hosts that do not opt in see no change (progressive disclosure, DESIGN.md
8
+ * §5.4).
9
+ */
10
+ export interface TwReviewRailFooterProps {
11
+ onSearch?: () => void;
12
+ helpHref?: string;
13
+ helpLabel?: string;
14
+ searchLabel?: string;
15
+ }
16
+
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";
19
+
20
+ export function TwReviewRailFooter(props: TwReviewRailFooterProps) {
21
+ const searchLabel = props.searchLabel ?? "SEARCH";
22
+ const helpLabel = props.helpLabel ?? "HELP";
23
+
24
+ if (!props.onSearch && !props.helpHref) {
25
+ return null;
26
+ }
27
+
28
+ return (
29
+ <footer className="flex h-10 shrink-0 items-center gap-2 border-t border-border/60 px-3">
30
+ {props.onSearch ? (
31
+ <button
32
+ type="button"
33
+ onClick={props.onSearch}
34
+ className={`inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-tertiary transition-colors hover:text-secondary ${focusRingClass}`}
35
+ >
36
+ <Search aria-hidden="true" className="h-3 w-3" />
37
+ <span>{searchLabel}</span>
38
+ </button>
39
+ ) : null}
40
+
41
+ {props.helpHref ? (
42
+ <a
43
+ href={props.helpHref}
44
+ className={`inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-tertiary transition-colors hover:text-secondary ${focusRingClass}`}
45
+ >
46
+ <HelpCircle aria-hidden="true" className="h-3 w-3" />
47
+ <span>{helpLabel}</span>
48
+ </a>
49
+ ) : null}
50
+ </footer>
51
+ );
52
+ }