@beyondwork/docx-react-component 1.0.41 → 1.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -1,18 +1,21 @@
1
1
  /**
2
- * TwScopeCardLayer — renders at most one scope card at a time, chosen
3
- * by `activeScopeId`. Consumes `facet.getAllScopeCardModels()` to
4
- * look up the model, then positions the card 8px above the model's
5
- * `primaryAnchorRect`.
2
+ * TwScopeCardLayer — renders at most one scope card at a time. The
3
+ * layer resolves which card is visible from two inputs:
6
4
  *
7
- * Per docs/plans/scope-card-overlay.md P1, the layer keeps
8
- * positioning pure: it never calls `getBoundingClientRect` or reads
9
- * DOM rects. Coordinates come from the render kernel's anchor index
10
- * through `ScopeCardModel.primaryAnchorRect`, projected into the
11
- * overlay's own coordinate space via the shared projector.
5
+ * 1. `activeScopeId` — the currently opened scope, set by the
6
+ * workspace when the user clicks a rail stripe. Resets on
7
+ * click-outside / Escape / close button.
8
+ * 2. internal `pinnedScopeId` when the user clicks the pin button
9
+ * on a card, that scope persists across `activeScopeId` changes
10
+ * until explicitly unpinned or the scope disappears from the
11
+ * card model list.
12
12
  *
13
- * Auto-flip, pin, and overlapping-scope stacking land in P3. P1
14
- * renders a single card above the scope's first line and falls back
15
- * to a top-left overlay placement when no rect is resolvable.
13
+ * Per docs/plans/scope-card-overlay.md P1 the layer keeps positioning
14
+ * pure: coordinates come from the render kernel's anchor index via
15
+ * `ScopeCardModel.primaryAnchorRect`, projected into the overlay's
16
+ * coordinate space. P3b adds auto-flip: when the anchor is within
17
+ * `CARD_ESTIMATED_HEIGHT_PX + CARD_GAP_PX` of the viewport top, the
18
+ * card flips below the scope's first line instead of above.
16
19
  */
17
20
 
18
21
  import * as React from "react";
@@ -22,6 +25,8 @@ import {
22
25
  } from "./chrome-overlay-projector";
23
26
  import { TwScopeCard } from "./tw-scope-card";
24
27
  import type {
28
+ EditorRole,
29
+ ScopeCardModel,
25
30
  ScopeIssueAction,
26
31
  WorkflowScopeMode,
27
32
  } from "../../api/public-types";
@@ -42,7 +47,22 @@ export interface TwScopeCardLayerProps {
42
47
  issueId: string,
43
48
  action: ScopeIssueAction,
44
49
  ) => void;
50
+ /** R3 — routed from chrome overlay; undefined hides the row. */
51
+ onAcceptSuggestionGroup?: (scopeId: string, groupId: string) => void;
52
+ onRejectSuggestionGroup?: (scopeId: string, groupId: string) => void;
53
+ /** K2 — routed from chrome overlay; undefined hides the button. */
54
+ onAskAgent?: (scopeId: string) => void;
55
+ /** P3 — active editor role threaded down to the card. */
56
+ editorRole?: EditorRole;
57
+ /** P3 — scope-tag editor slot (rendered only in workflow role). */
58
+ scopeTagEditor?: React.ReactNode;
45
59
  space?: OverlayCoordinateSpace;
60
+ /**
61
+ * P3b — top of the viewport in overlay-space pixels. The layer
62
+ * uses this to decide whether the card should flip below the scope.
63
+ * Default: 0 (overlay origin is viewport top).
64
+ */
65
+ viewportTopPx?: number;
46
66
  "data-testid"?: string;
47
67
  }
48
68
 
@@ -61,15 +81,48 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
61
81
  onClose,
62
82
  onModeChange,
63
83
  onIssueAction,
84
+ onAcceptSuggestionGroup,
85
+ onRejectSuggestionGroup,
86
+ onAskAgent,
87
+ editorRole,
88
+ scopeTagEditor,
64
89
  space,
90
+ viewportTopPx = 0,
65
91
  "data-testid": testId,
66
92
  }) => {
67
- if (!activeScopeId) return null;
93
+ const [pinnedScopeId, setPinnedScopeId] = React.useState<string | null>(null);
94
+
95
+ // If the layer's close handler fires, also clear any pin so the
96
+ // next stripe click starts from a clean state.
97
+ const handleClose = React.useCallback(() => {
98
+ setPinnedScopeId(null);
99
+ onClose();
100
+ }, [onClose]);
101
+
102
+ const handleTogglePin = React.useCallback((scopeId: string) => {
103
+ setPinnedScopeId((current) => (current === scopeId ? null : scopeId));
104
+ }, []);
105
+
106
+ // The effective scope is the pinned one if it still resolves to a
107
+ // model, else the active one. When a pinned scope disappears
108
+ // (e.g. the host cleared the overlay), drop the pin.
68
109
  const models =
69
110
  typeof facet.getAllScopeCardModels === "function"
70
111
  ? facet.getAllScopeCardModels()
71
112
  : [];
72
- const model = models.find((m) => m.scopeId === activeScopeId);
113
+
114
+ const pinnedModel = pinnedScopeId
115
+ ? models.find((m) => m.scopeId === pinnedScopeId) ?? null
116
+ : null;
117
+ if (pinnedScopeId && !pinnedModel) {
118
+ // Async cleanup via effect to avoid setState during render.
119
+ queueMicrotask(() => setPinnedScopeId(null));
120
+ }
121
+
122
+ const effectiveScopeId = pinnedModel ? pinnedScopeId : activeScopeId;
123
+ if (!effectiveScopeId) return null;
124
+ const model: ScopeCardModel | undefined =
125
+ pinnedModel ?? models.find((m) => m.scopeId === effectiveScopeId);
73
126
  if (!model) return null;
74
127
 
75
128
  const projectorSpace: OverlayCoordinateSpace =
@@ -77,23 +130,48 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
77
130
  const positioned = resolveCardPosition(
78
131
  model.primaryAnchorRect,
79
132
  projectorSpace,
133
+ viewportTopPx,
80
134
  );
81
135
 
136
+ const isPinned = pinnedScopeId === model.scopeId;
137
+
82
138
  return (
83
139
  <div
84
140
  className="wre-scope-card-layer pointer-events-none absolute inset-0 z-30"
85
141
  data-testid={testId ?? "scope-card-layer"}
86
- data-active-scope-id={activeScopeId}
142
+ data-active-scope-id={effectiveScopeId}
143
+ data-pinned-scope-id={isPinned ? model.scopeId : undefined}
87
144
  >
88
- <div style={positioned} className="absolute">
145
+ <div
146
+ style={positioned}
147
+ className="absolute"
148
+ data-card-placement={positioned["--wre-card-placement"] as string | undefined}
149
+ >
89
150
  <TwScopeCard
90
151
  model={model}
91
- onClose={onClose}
152
+ onClose={handleClose}
92
153
  onModeChange={(mode) => onModeChange(model.scopeId, mode)}
93
154
  onIssueAction={(action) => {
94
155
  if (!model.issue) return;
95
156
  onIssueAction(model.scopeId, model.issue.issueId, action);
96
157
  }}
158
+ onAcceptSuggestionGroup={
159
+ onAcceptSuggestionGroup
160
+ ? (groupId) => onAcceptSuggestionGroup(model.scopeId, groupId)
161
+ : undefined
162
+ }
163
+ onRejectSuggestionGroup={
164
+ onRejectSuggestionGroup
165
+ ? (groupId) => onRejectSuggestionGroup(model.scopeId, groupId)
166
+ : undefined
167
+ }
168
+ onAskAgent={
169
+ onAskAgent ? () => onAskAgent(model.scopeId) : undefined
170
+ }
171
+ editorRole={editorRole}
172
+ scopeTagEditor={scopeTagEditor}
173
+ pinned={isPinned}
174
+ onTogglePin={() => handleTogglePin(model.scopeId)}
97
175
  />
98
176
  </div>
99
177
  </div>
@@ -104,22 +182,41 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
104
182
  // Positioning
105
183
  // ---------------------------------------------------------------------------
106
184
 
185
+ /**
186
+ * Decide where the card renders relative to the scope's anchor rect.
187
+ *
188
+ * Preferred placement: 8px above the anchor's first line. When the
189
+ * resulting top would be within `CARD_ESTIMATED_HEIGHT_PX + CARD_GAP_PX`
190
+ * of the overlay-space viewport top, flip to 8px below the anchor's
191
+ * last line instead — that keeps the card visible even when the user
192
+ * is scrolled to the very first scope on the page.
193
+ *
194
+ * Returns a React.CSSProperties object with an extra
195
+ * `--wre-card-placement` custom property that tests (and the card's
196
+ * own shadow token tweaks) can key off to verify which placement the
197
+ * layer chose.
198
+ */
107
199
  function resolveCardPosition(
108
200
  anchor: RenderFrameRect | null,
109
201
  space: OverlayCoordinateSpace,
110
- ): React.CSSProperties {
202
+ viewportTopPx: number,
203
+ ): React.CSSProperties & { "--wre-card-placement"?: string } {
111
204
  if (!anchor) {
112
205
  return {
113
206
  left: `${CARD_FALLBACK_LEFT_PX}px`,
114
207
  top: `${CARD_FALLBACK_TOP_PX}px`,
208
+ "--wre-card-placement": "fallback",
115
209
  };
116
210
  }
117
- // Position the card above the anchor's first line, leaving
118
- // `CARD_GAP_PX` between anchor top and card bottom. P3 adds auto-
119
- // flip based on viewport clipping.
211
+ const preferredTop = anchor.topPx - CARD_ESTIMATED_HEIGHT_PX - CARD_GAP_PX;
212
+ const willClipAtTop = preferredTop < viewportTopPx;
213
+ const placement: "above" | "below" = willClipAtTop ? "below" : "above";
214
+ const targetTop = placement === "below"
215
+ ? anchor.topPx + anchor.heightPx + CARD_GAP_PX
216
+ : preferredTop;
120
217
  const targetRect: RenderFrameRect = {
121
218
  leftPx: anchor.leftPx,
122
- topPx: Math.max(0, anchor.topPx - CARD_ESTIMATED_HEIGHT_PX - CARD_GAP_PX),
219
+ topPx: targetTop,
123
220
  widthPx: 1,
124
221
  heightPx: 1,
125
222
  };
@@ -127,6 +224,7 @@ function resolveCardPosition(
127
224
  return {
128
225
  left: projected.left,
129
226
  top: projected.top,
227
+ "--wre-card-placement": placement,
130
228
  };
131
229
  }
132
230
 
@@ -21,13 +21,18 @@
21
21
 
22
22
  import * as React from "react";
23
23
  import type {
24
+ EditorRole,
24
25
  IssueMetadataValue,
25
26
  IssueOwner,
26
27
  IssueSeverity,
28
+ ReviewActionKind,
29
+ ReviewActionMetadataValue,
27
30
  ScopeCardModel,
28
31
  ScopeIssueAction,
32
+ SuggestionGroup,
29
33
  WorkflowScopeMode,
30
34
  } from "../../api/public-types";
35
+ import { resolveScopeCardVisibility } from "./scope-card-role-model";
31
36
 
32
37
  // ---------------------------------------------------------------------------
33
38
  // Types
@@ -38,6 +43,48 @@ export interface TwScopeCardProps {
38
43
  onClose: () => void;
39
44
  onModeChange: (mode: WorkflowScopeMode) => void;
40
45
  onIssueAction: (action: ScopeIssueAction) => void;
46
+ /**
47
+ * R3 — accept every member of a suggestion group. Renders the
48
+ * suggestion row only when this handler is supplied and the model
49
+ * has at least one group attached.
50
+ */
51
+ onAcceptSuggestionGroup?: (groupId: string) => void;
52
+ /** R3 — reject every member of a suggestion group. */
53
+ onRejectSuggestionGroup?: (groupId: string) => void;
54
+ /**
55
+ * K2 — "Ask review agent" button is only rendered when this
56
+ * handler is supplied (matches the hidden-by-default pattern of
57
+ * other review-role chrome). Fires once per click; the host
58
+ * resolves the selection text + surrounding context from the
59
+ * scope's anchor.
60
+ */
61
+ onAskAgent?: () => void;
62
+ /**
63
+ * P3 — active editor role. Drives which card sections render:
64
+ * `editor` keeps mode + issue only, `review` renders the full
65
+ * surface, `workflow` additionally reserves a scope-tag editor
66
+ * slot. Defaults to `review` if the host doesn't supply one —
67
+ * preserves the full card the P1/P2 tests exercised.
68
+ */
69
+ editorRole?: EditorRole;
70
+ /**
71
+ * P3 — slot the host may render in workflow role to expose a
72
+ * scope-tag editor (a chip picker, free-text input, etc.). When
73
+ * present and role is workflow, the card renders it between the
74
+ * mode row and the issue row.
75
+ */
76
+ scopeTagEditor?: React.ReactNode;
77
+ /**
78
+ * P3b — whether the card is pinned. A pinned card persists on
79
+ * the overlay across `activeScopeId` changes. The card adds an
80
+ * `aria-pressed` toggle to the pin button based on this flag.
81
+ */
82
+ pinned?: boolean;
83
+ /**
84
+ * P3b — toggle pin state. Omit to hide the pin button entirely
85
+ * (keeps the P1/P2 simple cards unchanged).
86
+ */
87
+ onTogglePin?: () => void;
41
88
  /** Test id applied to the root node. */
42
89
  "data-testid"?: string;
43
90
  }
@@ -80,8 +127,16 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
80
127
  onClose,
81
128
  onModeChange,
82
129
  onIssueAction,
130
+ onAcceptSuggestionGroup,
131
+ onRejectSuggestionGroup,
132
+ onAskAgent,
133
+ editorRole = "review",
134
+ scopeTagEditor,
135
+ pinned = false,
136
+ onTogglePin,
83
137
  "data-testid": testId,
84
138
  }) => {
139
+ const visibility = resolveScopeCardVisibility(editorRole);
85
140
  const rootRef = React.useRef<HTMLDivElement | null>(null);
86
141
  const headerId = React.useId();
87
142
  const liveRegionId = React.useId();
@@ -153,6 +208,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
153
208
  data-testid={testId ?? "scope-card"}
154
209
  data-scope-id={model.scopeId}
155
210
  data-posture={model.posture}
211
+ data-chrome-overlay="scope-card"
156
212
  onKeyDown={onKeyDown}
157
213
  >
158
214
  {/* Header --------------------------------------------------------- */}
@@ -173,46 +229,123 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
173
229
  <span className="truncate text-tertiary">· {model.label}</span>
174
230
  ) : null}
175
231
  </div>
176
- <button
177
- type="button"
178
- aria-label="Close scope card"
179
- className="flex h-6 w-6 items-center justify-center rounded-sm text-tertiary transition-colors hover:bg-surface hover:text-primary"
180
- onClick={onClose}
181
- data-testid="scope-card-close"
182
- >
183
- ×
184
- </button>
185
- </div>
186
-
187
- {/* Mode row ------------------------------------------------------- */}
188
- <div
189
- role="group"
190
- aria-label="Scope mode"
191
- className="flex gap-1 rounded-md border border-border bg-surface p-0.5"
192
- >
193
- {MODE_OPTIONS.map(({ mode, label }) => {
194
- const active = model.posture === mode;
195
- return (
232
+ <div className="flex items-center gap-1">
233
+ {onTogglePin ? (
196
234
  <button
197
- key={mode}
198
235
  type="button"
199
- aria-pressed={active ? "true" : "false"}
200
- className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
201
- active
202
- ? "bg-canvas text-primary shadow-sm"
203
- : "text-secondary hover:bg-canvas hover:text-primary"
236
+ aria-label={pinned ? "Unpin scope card" : "Pin scope card"}
237
+ aria-pressed={pinned ? "true" : "false"}
238
+ className={`flex h-6 w-6 items-center justify-center rounded-sm text-[10px] font-semibold uppercase tracking-[0.06em] transition-colors ${
239
+ pinned
240
+ ? "bg-surface text-accent"
241
+ : "text-tertiary hover:bg-surface hover:text-primary"
204
242
  }`}
205
- onClick={() => onModeChange(mode)}
206
- data-testid={`scope-card-mode-${mode}`}
243
+ onClick={onTogglePin}
244
+ data-testid="scope-card-pin"
245
+ title={pinned ? "Unpin" : "Pin"}
207
246
  >
208
- {label}
247
+ {pinned ? "ON" : "PIN"}
209
248
  </button>
210
- );
211
- })}
249
+ ) : null}
250
+ <button
251
+ type="button"
252
+ aria-label="Close scope card"
253
+ className="flex h-6 w-6 items-center justify-center rounded-sm text-tertiary transition-colors hover:bg-surface hover:text-primary"
254
+ onClick={onClose}
255
+ data-testid="scope-card-close"
256
+ >
257
+ ×
258
+ </button>
259
+ </div>
212
260
  </div>
213
261
 
262
+ {/* Mode row ------------------------------------------------------- */}
263
+ {visibility.modeRow ? (
264
+ <div
265
+ role="group"
266
+ aria-label="Scope mode"
267
+ className="flex gap-1 rounded-md border border-border bg-surface p-0.5"
268
+ >
269
+ {MODE_OPTIONS.map(({ mode, label }) => {
270
+ const active = model.posture === mode;
271
+ return (
272
+ <button
273
+ key={mode}
274
+ type="button"
275
+ aria-pressed={active ? "true" : "false"}
276
+ className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
277
+ active
278
+ ? "bg-canvas text-primary shadow-sm"
279
+ : "text-secondary hover:bg-canvas hover:text-primary"
280
+ }`}
281
+ onClick={() => onModeChange(mode)}
282
+ data-testid={`scope-card-mode-${mode}`}
283
+ >
284
+ {label}
285
+ </button>
286
+ );
287
+ })}
288
+ </div>
289
+ ) : null}
290
+
291
+ {/* Scope tag editor slot (P3 workflow role) ---------------------- */}
292
+ {visibility.scopeTagEditor && scopeTagEditor ? (
293
+ <div
294
+ className="flex flex-col gap-1 rounded-md bg-surface p-2"
295
+ data-testid="scope-card-scope-tag-editor"
296
+ >
297
+ {scopeTagEditor}
298
+ </div>
299
+ ) : null}
300
+
214
301
  {/* Issue row (R2) ------------------------------------------------- */}
215
- {issue ? <IssueRow issue={issue} onAction={onIssueAction} /> : null}
302
+ {visibility.issueRow && issue ? (
303
+ <IssueRow issue={issue} onAction={onIssueAction} />
304
+ ) : null}
305
+
306
+ {/* Suggestion group rows (R3) ------------------------------------ */}
307
+ {visibility.suggestionRows &&
308
+ model.suggestionGroups.length > 0 &&
309
+ (onAcceptSuggestionGroup || onRejectSuggestionGroup) ? (
310
+ <div className="flex flex-col gap-1.5">
311
+ {model.suggestionGroups.map((group) => (
312
+ <SuggestionGroupRow
313
+ key={group.groupId}
314
+ group={group}
315
+ onAccept={
316
+ onAcceptSuggestionGroup
317
+ ? () => onAcceptSuggestionGroup(group.groupId)
318
+ : undefined
319
+ }
320
+ onReject={
321
+ onRejectSuggestionGroup
322
+ ? () => onRejectSuggestionGroup(group.groupId)
323
+ : undefined
324
+ }
325
+ />
326
+ ))}
327
+ </div>
328
+ ) : null}
329
+
330
+ {/* Review-action timeline (K1) ----------------------------------- */}
331
+ {visibility.timeline && model.reviewActions.length > 0 ? (
332
+ <ReviewActionTimeline
333
+ actions={model.reviewActions}
334
+ totalCount={model.reviewActionCount}
335
+ />
336
+ ) : null}
337
+
338
+ {/* Ask review agent button (K2) ---------------------------------- */}
339
+ {visibility.agentButton && onAskAgent ? (
340
+ <button
341
+ type="button"
342
+ className="rounded-md border border-border bg-canvas px-2 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-surface"
343
+ onClick={onAskAgent}
344
+ data-testid="scope-card-ask-agent"
345
+ >
346
+ Ask review agent
347
+ </button>
348
+ ) : null}
216
349
 
217
350
  {/* A11y live region ---------------------------------------------- */}
218
351
  <span
@@ -314,6 +447,151 @@ const IssueActionButton: React.FC<{
314
447
  </button>
315
448
  );
316
449
 
450
+ // ---------------------------------------------------------------------------
451
+ // Suggestion group row (R3)
452
+ // ---------------------------------------------------------------------------
453
+
454
+ const TIER_LABEL: Record<NonNullable<SuggestionGroup["tier"]>, string> = {
455
+ preferred: "Preferred",
456
+ fallback_1: "Fallback 1",
457
+ fallback_2: "Fallback 2",
458
+ escalate_only: "Escalate only",
459
+ };
460
+
461
+ const SuggestionGroupRow: React.FC<{
462
+ group: SuggestionGroup;
463
+ onAccept?: () => void;
464
+ onReject?: () => void;
465
+ }> = ({ group, onAccept, onReject }) => (
466
+ <div
467
+ className="flex flex-col gap-1 rounded-md border border-border bg-surface p-2"
468
+ data-testid="scope-card-suggestion-group"
469
+ data-group-id={group.groupId}
470
+ >
471
+ <div className="flex items-center justify-between gap-2">
472
+ <div className="flex items-center gap-1.5 text-[11px] text-primary">
473
+ <span className="font-semibold">Suggestion group</span>
474
+ {group.tier ? (
475
+ <span className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] text-secondary">
476
+ {TIER_LABEL[group.tier]}
477
+ </span>
478
+ ) : null}
479
+ </div>
480
+ <span className="text-[10px] text-tertiary">
481
+ {group.suggestionIds.length} redline
482
+ {group.suggestionIds.length === 1 ? "" : "s"}
483
+ </span>
484
+ </div>
485
+ {group.rationale ? (
486
+ <div className="text-[11px] leading-snug text-secondary">{group.rationale}</div>
487
+ ) : null}
488
+ <div className="flex gap-1">
489
+ <button
490
+ type="button"
491
+ disabled={!onAccept}
492
+ className="flex-1 rounded-sm border border-border bg-canvas px-1.5 py-1 text-[11px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
493
+ onClick={onAccept}
494
+ data-testid="scope-card-suggestion-accept"
495
+ >
496
+ Accept
497
+ </button>
498
+ <button
499
+ type="button"
500
+ disabled={!onReject}
501
+ className="flex-1 rounded-sm border border-border bg-canvas px-1.5 py-1 text-[11px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
502
+ onClick={onReject}
503
+ data-testid="scope-card-suggestion-reject"
504
+ >
505
+ Reject
506
+ </button>
507
+ </div>
508
+ </div>
509
+ );
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // Review-action timeline (K1)
513
+ // ---------------------------------------------------------------------------
514
+
515
+ const ACTION_LABEL: Record<ReviewActionKind, string> = {
516
+ include: "Included",
517
+ edit: "Edited",
518
+ discard: "Discarded",
519
+ escalate: "Escalated",
520
+ acknowledge: "Acknowledged",
521
+ resolve: "Resolved",
522
+ waive: "Waived",
523
+ };
524
+
525
+ const TIMELINE_DEFAULT_VISIBLE = 8;
526
+
527
+ const ReviewActionTimeline: React.FC<{
528
+ actions: readonly ReviewActionMetadataValue[];
529
+ totalCount: number;
530
+ }> = ({ actions, totalCount }) => {
531
+ const [expanded, setExpanded] = React.useState(false);
532
+ const visible = expanded ? actions : actions.slice(0, TIMELINE_DEFAULT_VISIBLE);
533
+ const canExpand = actions.length > TIMELINE_DEFAULT_VISIBLE;
534
+
535
+ return (
536
+ <div
537
+ className="flex flex-col gap-1 rounded-md bg-surface p-2"
538
+ data-testid="scope-card-timeline"
539
+ >
540
+ <div className="flex items-center justify-between gap-2">
541
+ <span className="text-[10px] font-semibold uppercase tracking-[0.06em] text-tertiary">
542
+ Activity ({totalCount})
543
+ </span>
544
+ {canExpand ? (
545
+ <button
546
+ type="button"
547
+ className="rounded-sm border border-border bg-canvas px-1.5 py-0.5 text-[10px] font-medium text-secondary transition-colors hover:text-primary"
548
+ onClick={() => setExpanded((prev) => !prev)}
549
+ data-testid="scope-card-timeline-toggle"
550
+ >
551
+ {expanded ? "Show less" : "Show all"}
552
+ </button>
553
+ ) : null}
554
+ </div>
555
+ <div
556
+ className={`flex flex-col gap-1 ${
557
+ expanded && canExpand ? "max-h-60 overflow-y-auto" : ""
558
+ }`}
559
+ >
560
+ {visible.map((entry) => (
561
+ <div
562
+ key={entry.reviewActionId}
563
+ className="flex items-center gap-1.5 text-[11px] leading-tight text-primary"
564
+ data-testid="scope-card-timeline-entry"
565
+ >
566
+ <span
567
+ aria-hidden="true"
568
+ className="inline-block h-1.5 w-1.5 rounded-full bg-accent"
569
+ />
570
+ <span className="font-medium">{entry.actor}</span>
571
+ <span className="text-tertiary">· {entry.actorRole}</span>
572
+ <span className="text-secondary">· {ACTION_LABEL[entry.action]}</span>
573
+ <span className="text-tertiary">· {formatRelative(entry.createdAt)}</span>
574
+ </div>
575
+ ))}
576
+ </div>
577
+ </div>
578
+ );
579
+ };
580
+
581
+ function formatRelative(isoString: string): string {
582
+ const now = Date.now();
583
+ const then = new Date(isoString).getTime();
584
+ if (Number.isNaN(then)) return isoString;
585
+ const diffSeconds = Math.round((then - now) / 1000);
586
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
587
+ const abs = Math.abs(diffSeconds);
588
+ if (abs < 60) return rtf.format(diffSeconds, "second");
589
+ if (abs < 3600) return rtf.format(Math.round(diffSeconds / 60), "minute");
590
+ if (abs < 86400) return rtf.format(Math.round(diffSeconds / 3600), "hour");
591
+ if (abs < 604800) return rtf.format(Math.round(diffSeconds / 86400), "day");
592
+ return rtf.format(Math.round(diffSeconds / 604800), "week");
593
+ }
594
+
317
595
  // ---------------------------------------------------------------------------
318
596
  // Helpers
319
597
  // ---------------------------------------------------------------------------