@beyondwork/docx-react-component 1.0.38 → 1.0.40

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 (85) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +305 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/index.ts +9 -0
  6. package/src/io/docx-session.ts +1 -0
  7. package/src/io/export/serialize-numbering.ts +42 -8
  8. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  9. package/src/io/export/serialize-run-formatting.ts +90 -0
  10. package/src/io/export/serialize-styles.ts +212 -0
  11. package/src/io/ooxml/parse-fields.ts +10 -3
  12. package/src/io/ooxml/parse-numbering.ts +41 -1
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  14. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  15. package/src/io/ooxml/parse-styles.ts +31 -0
  16. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  17. package/src/io/ooxml/xml-element.ts +19 -0
  18. package/src/model/canonical-document.ts +83 -3
  19. package/src/runtime/collab/event-types.ts +165 -0
  20. package/src/runtime/collab/index.ts +22 -0
  21. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  22. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  23. package/src/runtime/document-runtime.ts +141 -18
  24. package/src/runtime/layout/docx-font-loader.ts +30 -11
  25. package/src/runtime/layout/index.ts +2 -0
  26. package/src/runtime/layout/inert-layout-facet.ts +3 -0
  27. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  28. package/src/runtime/layout/layout-invalidation.ts +14 -5
  29. package/src/runtime/layout/page-graph.ts +36 -0
  30. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  31. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  32. package/src/runtime/layout/project-block-fragments.ts +154 -20
  33. package/src/runtime/layout/public-facet.ts +81 -1
  34. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  35. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  36. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  37. package/src/runtime/layout/table-render-plan.ts +21 -1
  38. package/src/runtime/numbering-prefix.ts +5 -0
  39. package/src/runtime/paragraph-style-resolver.ts +194 -0
  40. package/src/runtime/render/render-kernel.ts +5 -1
  41. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  42. package/src/runtime/surface-projection.ts +129 -9
  43. package/src/runtime/table-schema.ts +11 -0
  44. package/src/runtime/workflow-rail-segments.ts +149 -1
  45. package/src/ui/WordReviewEditor.tsx +302 -5
  46. package/src/ui/editor-command-bag.ts +4 -0
  47. package/src/ui/editor-runtime-boundary.ts +16 -0
  48. package/src/ui/editor-shell-view.tsx +22 -0
  49. package/src/ui/editor-surface-controller.tsx +9 -1
  50. package/src/ui/headless/chrome-registry.ts +34 -5
  51. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  52. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  53. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  54. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
  55. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  57. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  58. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
  61. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  62. package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
  63. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
  65. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
  66. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
  67. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  68. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  69. package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
  70. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  71. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  72. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
  73. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  74. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  75. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  76. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  77. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  78. package/src/ui-tailwind/index.ts +6 -5
  79. package/src/ui-tailwind/theme/editor-theme.css +108 -15
  80. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  81. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  82. package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
  83. package/src/runtime/collab-review-sync.ts +0 -254
  84. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  85. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -15,9 +15,15 @@
15
15
  import * as React from "react";
16
16
  import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
17
17
  import type { ScopeRailSegment } from "../../runtime/layout";
18
- import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
18
+ import type {
19
+ ScopeIssueAction,
20
+ TableStructureContextSnapshot,
21
+ WordReviewEditorLayoutFacet,
22
+ WorkflowScopeMode,
23
+ } from "../../api/public-types";
19
24
  import { TwScopeRailLayer } from "./tw-scope-rail-layer";
20
- import { TwWorkspaceViewSwitcher, type WorkspaceView } from "./tw-workspace-view-switcher";
25
+ import { TwScopeCardLayer } from "./tw-scope-card-layer";
26
+ import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
21
27
 
22
28
  export interface TwChromeOverlayProps {
23
29
  /** Layout facet the overlay layers read from. */
@@ -26,18 +32,54 @@ export interface TwChromeOverlayProps {
26
32
  space?: OverlayCoordinateSpace;
27
33
  /** Active scope id (for emphasis + rail tab sync). */
28
34
  activeScopeId?: string | null;
29
- /** Click handler the rail layer forwards to consumers. */
35
+ /**
36
+ * Click handler fired when the user clicks a scope rail stripe.
37
+ * P0 wires this to open the scope card (P1 ships the card layer).
38
+ */
39
+ onScopeStripeClick?: (segment: ScopeRailSegment) => void;
40
+ /**
41
+ * Legacy click handler kept for compatibility with consumers that
42
+ * subscribed before the stripe affordance existed. Called alongside
43
+ * `onScopeStripeClick` on a stripe click.
44
+ */
30
45
  onScopeSegmentClick?: (segment: ScopeRailSegment) => void;
31
- /** Currently active workspace view preset (draft / layout / review / workflow). */
32
- activeWorkspaceView?: WorkspaceView;
33
- /** Handler that fires when the user picks a workspace view. */
34
- onWorkspaceViewChange?: (view: WorkspaceView) => void;
35
- /** Show the bottom workspace view-switcher dock. Default: true. */
36
- showWorkspaceDock?: boolean;
46
+ /**
47
+ * Fires when the scope card is dismissed (close button, Escape, or
48
+ * click-outside). The owner uses this to clear `activeScopeId`.
49
+ */
50
+ onScopeCardClose?: () => void;
51
+ /**
52
+ * Fires when a mode button inside the scope card is clicked. The
53
+ * owner relays this into a `scope-mode-change-requested` event for
54
+ * the host (which then drives the overlay-apply path).
55
+ */
56
+ onScopeCardModeChange?: (scopeId: string, mode: WorkflowScopeMode) => void;
57
+ /**
58
+ * Fires when an issue action button (Resolve/Waive/Escalate) inside
59
+ * the scope card is clicked. The owner relays this into a
60
+ * `scope-issue-action-requested` event for the host.
61
+ */
62
+ onScopeCardIssueAction?: (
63
+ scopeId: string,
64
+ issueId: string,
65
+ action: ScopeIssueAction,
66
+ ) => void;
37
67
  /** Test id applied to the overlay root. */
38
68
  "data-testid"?: string;
39
69
  /** Optional extra children (e.g., future comment balloon layer). */
40
70
  children?: React.ReactNode;
71
+
72
+ // Table grip props (P6) -----------------------------------------------
73
+ /** Active table context — when present, column/row resize grips are shown. */
74
+ tableContext?: TableStructureContextSnapshot | null;
75
+ /** Fires when a column grip drag completes. */
76
+ onSetColumnWidth?: (columnIndex: number, twips: number) => void;
77
+ /** Fires when a row grip drag completes. */
78
+ onSetRowHeight?: (
79
+ rowIndex: number,
80
+ twips: number,
81
+ rule: "auto" | "atLeast" | "exact",
82
+ ) => void;
41
83
  }
42
84
 
43
85
  /**
@@ -53,12 +95,16 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
53
95
  facet,
54
96
  space,
55
97
  activeScopeId,
98
+ onScopeStripeClick,
56
99
  onScopeSegmentClick,
57
- activeWorkspaceView,
58
- onWorkspaceViewChange,
59
- showWorkspaceDock = true,
100
+ onScopeCardClose,
101
+ onScopeCardModeChange,
102
+ onScopeCardIssueAction,
60
103
  "data-testid": testId,
61
104
  children,
105
+ tableContext,
106
+ onSetColumnWidth,
107
+ onSetRowHeight,
62
108
  }) => {
63
109
  return (
64
110
  <div
@@ -70,17 +116,35 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
70
116
  facet={facet}
71
117
  space={space}
72
118
  activeScopeId={activeScopeId}
119
+ onStripeClick={onScopeStripeClick}
73
120
  onSegmentClick={onScopeSegmentClick}
74
121
  />
122
+ <TwScopeCardLayer
123
+ facet={facet}
124
+ activeScopeId={activeScopeId ?? null}
125
+ onClose={onScopeCardClose ?? noop}
126
+ onModeChange={onScopeCardModeChange ?? noopModeChange}
127
+ onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
128
+ space={space}
129
+ />
130
+ <TwTableGripLayer
131
+ facet={facet}
132
+ tableContext={tableContext ?? null}
133
+ space={space}
134
+ onSetColumnWidth={onSetColumnWidth}
135
+ onSetRowHeight={onSetRowHeight}
136
+ />
75
137
  {children}
76
- {showWorkspaceDock ? (
77
- <TwWorkspaceViewSwitcher
78
- activeView={activeWorkspaceView ?? "review"}
79
- onViewChange={onWorkspaceViewChange}
80
- />
81
- ) : null}
82
138
  </div>
83
139
  );
84
140
  };
85
141
 
142
+ const noop = () => undefined;
143
+ const noopModeChange = (_scopeId: string, _mode: WorkflowScopeMode) => undefined;
144
+ const noopIssueAction = (
145
+ _scopeId: string,
146
+ _issueId: string,
147
+ _action: ScopeIssueAction,
148
+ ) => undefined;
149
+
86
150
  export default TwChromeOverlay;
@@ -0,0 +1,133 @@
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`.
6
+ *
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.
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.
16
+ */
17
+
18
+ import * as React from "react";
19
+ import {
20
+ projectRectToOverlay,
21
+ type OverlayCoordinateSpace,
22
+ } from "./chrome-overlay-projector";
23
+ import { TwScopeCard } from "./tw-scope-card";
24
+ import type {
25
+ ScopeIssueAction,
26
+ WorkflowScopeMode,
27
+ } from "../../api/public-types";
28
+ import type { RenderFrameRect } from "../../runtime/render";
29
+ import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface TwScopeCardLayerProps {
36
+ facet: WordReviewEditorLayoutFacet;
37
+ activeScopeId: string | null;
38
+ onClose: () => void;
39
+ onModeChange: (scopeId: string, mode: WorkflowScopeMode) => void;
40
+ onIssueAction: (
41
+ scopeId: string,
42
+ issueId: string,
43
+ action: ScopeIssueAction,
44
+ ) => void;
45
+ space?: OverlayCoordinateSpace;
46
+ "data-testid"?: string;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Component
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const CARD_GAP_PX = 8;
54
+ const CARD_ESTIMATED_HEIGHT_PX = 160;
55
+ const CARD_FALLBACK_LEFT_PX = 16;
56
+ const CARD_FALLBACK_TOP_PX = 16;
57
+
58
+ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
59
+ facet,
60
+ activeScopeId,
61
+ onClose,
62
+ onModeChange,
63
+ onIssueAction,
64
+ space,
65
+ "data-testid": testId,
66
+ }) => {
67
+ if (!activeScopeId) return null;
68
+ const models =
69
+ typeof facet.getAllScopeCardModels === "function"
70
+ ? facet.getAllScopeCardModels()
71
+ : [];
72
+ const model = models.find((m) => m.scopeId === activeScopeId);
73
+ if (!model) return null;
74
+
75
+ const projectorSpace: OverlayCoordinateSpace =
76
+ space ?? { originLeftPx: 0, originTopPx: 0 };
77
+ const positioned = resolveCardPosition(
78
+ model.primaryAnchorRect,
79
+ projectorSpace,
80
+ );
81
+
82
+ return (
83
+ <div
84
+ className="wre-scope-card-layer pointer-events-none absolute inset-0 z-30"
85
+ data-testid={testId ?? "scope-card-layer"}
86
+ data-active-scope-id={activeScopeId}
87
+ >
88
+ <div style={positioned} className="absolute">
89
+ <TwScopeCard
90
+ model={model}
91
+ onClose={onClose}
92
+ onModeChange={(mode) => onModeChange(model.scopeId, mode)}
93
+ onIssueAction={(action) => {
94
+ if (!model.issue) return;
95
+ onIssueAction(model.scopeId, model.issue.issueId, action);
96
+ }}
97
+ />
98
+ </div>
99
+ </div>
100
+ );
101
+ };
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Positioning
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function resolveCardPosition(
108
+ anchor: RenderFrameRect | null,
109
+ space: OverlayCoordinateSpace,
110
+ ): React.CSSProperties {
111
+ if (!anchor) {
112
+ return {
113
+ left: `${CARD_FALLBACK_LEFT_PX}px`,
114
+ top: `${CARD_FALLBACK_TOP_PX}px`,
115
+ };
116
+ }
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.
120
+ const targetRect: RenderFrameRect = {
121
+ leftPx: anchor.leftPx,
122
+ topPx: Math.max(0, anchor.topPx - CARD_ESTIMATED_HEIGHT_PX - CARD_GAP_PX),
123
+ widthPx: 1,
124
+ heightPx: 1,
125
+ };
126
+ const projected = projectRectToOverlay(targetRect, space);
127
+ return {
128
+ left: projected.left,
129
+ top: projected.top,
130
+ };
131
+ }
132
+
133
+ export default TwScopeCardLayer;
@@ -0,0 +1,386 @@
1
+ /**
2
+ * TwScopeCard — inline floating card shown above a scoped region when
3
+ * the user activates the rail stripe. Displays scope label + mode
4
+ * selector + (when an `IssueMetadataValue` is attached via
5
+ * `ScopeCardModel.issue`) the R2 issue severity, owner, title, and
6
+ * resolve/waive/escalate actions.
7
+ *
8
+ * Per docs/plans/scope-card-overlay.md P1, the card never mutates
9
+ * runtime state directly — it fires `onModeChange` / `onIssueAction`
10
+ * callbacks that bubble up to `scope-mode-change-requested` /
11
+ * `scope-issue-action-requested` events on WordReviewEditorEvent.
12
+ *
13
+ * A11y contract:
14
+ * - role="dialog", aria-modal="false" (not a hard-focus capture; the
15
+ * editor surface remains interactive while the card is open)
16
+ * - aria-labelledby points at the header id
17
+ * - Escape closes; focus-trap wraps Tab / Shift-Tab
18
+ * - An aria-live="polite" region announces the attached issue's
19
+ * severity when the card opens with an issue
20
+ */
21
+
22
+ import * as React from "react";
23
+ import type {
24
+ IssueMetadataValue,
25
+ IssueOwner,
26
+ IssueSeverity,
27
+ ScopeCardModel,
28
+ ScopeIssueAction,
29
+ WorkflowScopeMode,
30
+ } from "../../api/public-types";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface TwScopeCardProps {
37
+ model: ScopeCardModel;
38
+ onClose: () => void;
39
+ onModeChange: (mode: WorkflowScopeMode) => void;
40
+ onIssueAction: (action: ScopeIssueAction) => void;
41
+ /** Test id applied to the root node. */
42
+ "data-testid"?: string;
43
+ }
44
+
45
+ const MODE_OPTIONS: ReadonlyArray<{ mode: WorkflowScopeMode; label: string }> = [
46
+ { mode: "edit", label: "Edit" },
47
+ { mode: "suggest", label: "Suggest" },
48
+ { mode: "comment", label: "Comment" },
49
+ { mode: "view", label: "View" },
50
+ ];
51
+
52
+ const SEVERITY_COLOR: Record<IssueSeverity, string> = {
53
+ low: "var(--color-secondary)",
54
+ medium: "var(--color-warning)",
55
+ high: "var(--color-warning)",
56
+ blocker: "var(--color-danger)",
57
+ };
58
+
59
+ const SEVERITY_LABEL: Record<IssueSeverity, string> = {
60
+ low: "Low",
61
+ medium: "Medium",
62
+ high: "High",
63
+ blocker: "Blocker",
64
+ };
65
+
66
+ const OWNER_LABEL: Record<IssueOwner, string> = {
67
+ procurement: "Procurement",
68
+ legal: "Legal",
69
+ risk: "Risk",
70
+ finance: "Finance",
71
+ sustainability: "Sustainability",
72
+ };
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Component
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
79
+ model,
80
+ onClose,
81
+ onModeChange,
82
+ onIssueAction,
83
+ "data-testid": testId,
84
+ }) => {
85
+ const rootRef = React.useRef<HTMLDivElement | null>(null);
86
+ const headerId = React.useId();
87
+ const liveRegionId = React.useId();
88
+
89
+ // --- Focus management ----------------------------------------------------
90
+ React.useEffect(() => {
91
+ const root = rootRef.current;
92
+ if (!root) return undefined;
93
+ const first = getFocusable(root)[0];
94
+ first?.focus();
95
+ return undefined;
96
+ }, []);
97
+
98
+ // --- Escape + click-outside ---------------------------------------------
99
+ React.useEffect(() => {
100
+ const onKey = (event: KeyboardEvent) => {
101
+ if (event.key === "Escape") {
102
+ event.stopPropagation();
103
+ onClose();
104
+ }
105
+ };
106
+ const onPointerDown = (event: PointerEvent) => {
107
+ const root = rootRef.current;
108
+ if (!root) return;
109
+ if (event.target instanceof Node && root.contains(event.target)) return;
110
+ onClose();
111
+ };
112
+ window.addEventListener("keydown", onKey, true);
113
+ window.addEventListener("pointerdown", onPointerDown, true);
114
+ return () => {
115
+ window.removeEventListener("keydown", onKey, true);
116
+ window.removeEventListener("pointerdown", onPointerDown, true);
117
+ };
118
+ }, [onClose]);
119
+
120
+ // --- Focus trap ----------------------------------------------------------
121
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
122
+ if (event.key !== "Tab") return;
123
+ const root = rootRef.current;
124
+ if (!root) return;
125
+ const focusables = getFocusable(root);
126
+ if (focusables.length === 0) return;
127
+ const first = focusables[0];
128
+ const last = focusables[focusables.length - 1];
129
+ const active = document.activeElement;
130
+ if (event.shiftKey) {
131
+ if (active === first || !root.contains(active)) {
132
+ event.preventDefault();
133
+ last.focus();
134
+ }
135
+ } else {
136
+ if (active === last) {
137
+ event.preventDefault();
138
+ first.focus();
139
+ }
140
+ }
141
+ };
142
+
143
+ const issue = model.issue;
144
+ const postureLabel = posturePresentationLabel(model.posture);
145
+
146
+ return (
147
+ <div
148
+ ref={rootRef}
149
+ className="wre-scope-card pointer-events-auto absolute flex w-80 max-w-[22rem] flex-col gap-2 rounded-lg border border-border bg-canvas p-3 text-sm shadow-[var(--shadow-float)]"
150
+ role="dialog"
151
+ aria-modal="false"
152
+ aria-labelledby={headerId}
153
+ data-testid={testId ?? "scope-card"}
154
+ data-scope-id={model.scopeId}
155
+ data-posture={model.posture}
156
+ onKeyDown={onKeyDown}
157
+ >
158
+ {/* Header --------------------------------------------------------- */}
159
+ <div className="flex items-center justify-between gap-2">
160
+ <div
161
+ id={headerId}
162
+ className="flex min-w-0 flex-1 items-center gap-2 text-xs font-medium text-primary"
163
+ >
164
+ <span
165
+ className={`wre-scope-rail-icon wre-scope-rail-icon-${posturePresentationIcon(model.posture)}`}
166
+ aria-hidden="true"
167
+ style={{ color: postureTokenColor(model.posture) }}
168
+ />
169
+ <span className="truncate uppercase tracking-[0.06em]">
170
+ {postureLabel}
171
+ </span>
172
+ {model.label ? (
173
+ <span className="truncate text-tertiary">· {model.label}</span>
174
+ ) : null}
175
+ </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 (
196
+ <button
197
+ key={mode}
198
+ 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"
204
+ }`}
205
+ onClick={() => onModeChange(mode)}
206
+ data-testid={`scope-card-mode-${mode}`}
207
+ >
208
+ {label}
209
+ </button>
210
+ );
211
+ })}
212
+ </div>
213
+
214
+ {/* Issue row (R2) ------------------------------------------------- */}
215
+ {issue ? <IssueRow issue={issue} onAction={onIssueAction} /> : null}
216
+
217
+ {/* A11y live region ---------------------------------------------- */}
218
+ <span
219
+ id={liveRegionId}
220
+ role="status"
221
+ aria-live="polite"
222
+ className="sr-only"
223
+ >
224
+ {issue
225
+ ? `${postureLabel} scope, ${SEVERITY_LABEL[issue.severity]} severity issue attached: ${issue.title}`
226
+ : `${postureLabel} scope opened`}
227
+ </span>
228
+ </div>
229
+ );
230
+ };
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Issue row
234
+ // ---------------------------------------------------------------------------
235
+
236
+ const IssueRow: React.FC<{
237
+ issue: IssueMetadataValue;
238
+ onAction: (action: ScopeIssueAction) => void;
239
+ }> = ({ issue, onAction }) => {
240
+ const canResolve = issue.checklistState === "open" || issue.checklistState === "acknowledged";
241
+ const canWaive = issue.checklistState !== "waived";
242
+ const canEscalate = issue.escalationState !== "requested";
243
+
244
+ return (
245
+ <div
246
+ className="flex flex-col gap-1.5 rounded-md bg-surface p-2"
247
+ data-testid="scope-card-issue"
248
+ >
249
+ <div className="flex items-center gap-1.5 text-[11px]">
250
+ <span
251
+ aria-hidden="true"
252
+ className="inline-block h-2 w-2 rounded-full"
253
+ style={{ background: SEVERITY_COLOR[issue.severity] }}
254
+ />
255
+ <span className="font-semibold text-primary">
256
+ {SEVERITY_LABEL[issue.severity]}
257
+ </span>
258
+ {issue.owner ? (
259
+ <span
260
+ className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] text-secondary"
261
+ data-testid="scope-card-issue-owner"
262
+ >
263
+ {OWNER_LABEL[issue.owner]}
264
+ </span>
265
+ ) : null}
266
+ </div>
267
+ <div
268
+ className="text-xs font-medium leading-snug text-primary"
269
+ data-testid="scope-card-issue-title"
270
+ >
271
+ {issue.title}
272
+ </div>
273
+ {issue.summary ? (
274
+ <div className="text-[11px] leading-snug text-secondary">{issue.summary}</div>
275
+ ) : null}
276
+ <div className="flex gap-1">
277
+ <IssueActionButton
278
+ label="Resolve"
279
+ testId="scope-card-issue-resolve"
280
+ disabled={!canResolve}
281
+ onClick={() => onAction("resolve")}
282
+ />
283
+ <IssueActionButton
284
+ label="Waive"
285
+ testId="scope-card-issue-waive"
286
+ disabled={!canWaive}
287
+ onClick={() => onAction("waive")}
288
+ />
289
+ <IssueActionButton
290
+ label="Escalate"
291
+ testId="scope-card-issue-escalate"
292
+ disabled={!canEscalate}
293
+ onClick={() => onAction("escalate")}
294
+ />
295
+ </div>
296
+ </div>
297
+ );
298
+ };
299
+
300
+ const IssueActionButton: React.FC<{
301
+ label: string;
302
+ testId: string;
303
+ disabled: boolean;
304
+ onClick: () => void;
305
+ }> = ({ label, testId, disabled, onClick }) => (
306
+ <button
307
+ type="button"
308
+ disabled={disabled}
309
+ 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"
310
+ onClick={onClick}
311
+ data-testid={testId}
312
+ >
313
+ {label}
314
+ </button>
315
+ );
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Helpers
319
+ // ---------------------------------------------------------------------------
320
+
321
+ function getFocusable(root: HTMLElement): HTMLElement[] {
322
+ const selector =
323
+ 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])';
324
+ return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
325
+ (el) => !el.hasAttribute("inert") && el.offsetParent !== null,
326
+ );
327
+ }
328
+
329
+ function posturePresentationLabel(posture: ScopeCardModel["posture"]): string {
330
+ switch (posture) {
331
+ case "edit":
332
+ return "Edit";
333
+ case "suggest":
334
+ return "Suggest";
335
+ case "comment":
336
+ return "Comment";
337
+ case "view":
338
+ return "View";
339
+ case "candidate":
340
+ return "Proposed";
341
+ case "preserve-only":
342
+ case "blocked-import":
343
+ return "Blocked";
344
+ default:
345
+ return "Scope";
346
+ }
347
+ }
348
+
349
+ function posturePresentationIcon(posture: ScopeCardModel["posture"]): string {
350
+ switch (posture) {
351
+ case "edit":
352
+ return "pencil";
353
+ case "suggest":
354
+ return "sparkles";
355
+ case "comment":
356
+ return "message";
357
+ case "view":
358
+ return "eye";
359
+ case "candidate":
360
+ return "flag";
361
+ case "preserve-only":
362
+ case "blocked-import":
363
+ return "lock";
364
+ default:
365
+ return "eye";
366
+ }
367
+ }
368
+
369
+ function postureTokenColor(posture: ScopeCardModel["posture"]): string {
370
+ switch (posture) {
371
+ case "edit":
372
+ return "var(--color-accent)";
373
+ case "suggest":
374
+ case "candidate":
375
+ return "var(--color-warning)";
376
+ case "comment":
377
+ return "var(--color-insert)";
378
+ case "preserve-only":
379
+ case "blocked-import":
380
+ return "var(--color-danger)";
381
+ default:
382
+ return "var(--color-secondary)";
383
+ }
384
+ }
385
+
386
+ export default TwScopeCard;