@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.
- package/package.json +13 -1
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +347 -4
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +568 -1
- package/src/index.ts +118 -1
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +271 -0
- package/src/io/ooxml/workflow-payload.ts +146 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +280 -93
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +122 -12
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +230 -34
- package/src/runtime/layout/public-facet.ts +185 -13
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +9 -0
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +587 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +11 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +32 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- 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
|
|
3
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
142
|
+
data-active-scope-id={effectiveScopeId}
|
|
143
|
+
data-pinned-scope-id={isPinned ? model.scopeId : undefined}
|
|
87
144
|
>
|
|
88
|
-
<div
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
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
|
-
<
|
|
177
|
-
|
|
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-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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={
|
|
206
|
-
data-testid=
|
|
243
|
+
onClick={onTogglePin}
|
|
244
|
+
data-testid="scope-card-pin"
|
|
245
|
+
title={pinned ? "Unpin" : "Pin"}
|
|
207
246
|
>
|
|
208
|
-
{
|
|
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
|
-
{
|
|
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
|
// ---------------------------------------------------------------------------
|