@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  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/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -0,0 +1,244 @@
1
+ import * as React from "react";
2
+
3
+ import type {
4
+ CommentNegotiationAction,
5
+ CommentNegotiationEntry,
6
+ CommentNegotiationState,
7
+ NegotiationRole,
8
+ } from "../../api/comment-negotiation-types.ts";
9
+
10
+ export interface CollabNegotiationActionBarProps {
11
+ /** Currently-selected comment's negotiation entry. `undefined` when no comment is active. */
12
+ entry: CommentNegotiationEntry | undefined;
13
+ /** Local user identity — used to stamp actor / author ids on actions. */
14
+ actorId: string;
15
+ /** Local user role — drives button visibility. */
16
+ role: NegotiationRole;
17
+ /**
18
+ * Dispatcher. Caller routes to `session.dispatchCommentNegotiation(action, ctx)`.
19
+ * We hand back the fully-populated action; context (role/now) is the caller's job.
20
+ */
21
+ onDispatch: (action: CommentNegotiationAction) => void;
22
+ /** Blocks the whole bar when true — used by the chrome when the tamper gate is tampered. */
23
+ disabled?: boolean;
24
+ /** Fresh timestamp source (injectable for tests). */
25
+ now?: () => string;
26
+ className?: string;
27
+ }
28
+
29
+ /**
30
+ * Per-state, per-role action bar for the active comment negotiation
31
+ * (P9e). Mirrors the role-gating matrix in `collab-master-plan §7`:
32
+ *
33
+ * state author reviewer observer
34
+ * proposed propose / counter / reject propose / counter (none)
35
+ * negotiating approve / reject (vote) / counter / approve / reject (none)
36
+ * reject-thread / accept / lock (vote) / counter
37
+ * accepted lock — (none)
38
+ * rejected lock — (none)
39
+ * resolved reopen reopen (none)
40
+ *
41
+ * Accept is author-only (needs quorum validated server-side; the
42
+ * reducer will block reviewers with `collab_role_restricted`).
43
+ */
44
+ export function CollabNegotiationActionBar({
45
+ entry,
46
+ actorId,
47
+ role,
48
+ onDispatch,
49
+ disabled = false,
50
+ now = () => new Date().toISOString(),
51
+ className,
52
+ }: CollabNegotiationActionBarProps): React.ReactElement {
53
+ const empty = entry === undefined;
54
+ const blocked = disabled || role === "observer" || empty;
55
+ const state: CommentNegotiationState | "empty" = entry?.state ?? "empty";
56
+
57
+ const rootClass = [
58
+ "tw-collab-negotiation-action-bar",
59
+ `tw-collab-negotiation-action-bar--${state}`,
60
+ blocked ? "tw-collab-negotiation-action-bar--blocked" : null,
61
+ className ?? null,
62
+ ]
63
+ .filter((v): v is string => v !== null)
64
+ .join(" ");
65
+
66
+ const buttons = entry
67
+ ? buildButtons(entry, role, actorId, now)
68
+ : [];
69
+
70
+ return (
71
+ <div
72
+ className={rootClass}
73
+ data-testid="collab-negotiation-action-bar"
74
+ data-state={state}
75
+ data-role={role}
76
+ data-blocked={blocked ? "true" : "false"}
77
+ role="toolbar"
78
+ aria-label="Comment negotiation actions"
79
+ >
80
+ {empty ? (
81
+ <span
82
+ className="tw-collab-negotiation-action-bar__empty"
83
+ data-testid="collab-negotiation-action-bar-empty"
84
+ >
85
+ No active comment
86
+ </span>
87
+ ) : (
88
+ buttons.map((btn) => (
89
+ <button
90
+ key={btn.id}
91
+ type="button"
92
+ className={`tw-collab-negotiation-action-bar__button tw-collab-negotiation-action-bar__button--${btn.id}`}
93
+ data-testid={`collab-negotiation-action-${btn.id}`}
94
+ data-action-id={btn.id}
95
+ disabled={blocked || btn.disabled}
96
+ aria-disabled={blocked || btn.disabled ? "true" : undefined}
97
+ title={btn.title}
98
+ onClick={() => {
99
+ if (blocked || btn.disabled) return;
100
+ onDispatch(btn.build());
101
+ }}
102
+ >
103
+ {btn.label}
104
+ </button>
105
+ ))
106
+ )}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+
113
+ type ButtonId =
114
+ | "propose-change"
115
+ | "counter-propose"
116
+ | "vote-approve"
117
+ | "vote-reject"
118
+ | "accept"
119
+ | "reject"
120
+ | "lock"
121
+ | "reopen";
122
+
123
+ interface ButtonDef {
124
+ id: ButtonId;
125
+ label: string;
126
+ title?: string;
127
+ disabled?: boolean;
128
+ build(): CommentNegotiationAction;
129
+ }
130
+
131
+ function buildButtons(
132
+ entry: CommentNegotiationEntry,
133
+ role: NegotiationRole,
134
+ actorId: string,
135
+ now: () => string,
136
+ ): ButtonDef[] {
137
+ const state = entry.state;
138
+ const isAuthor = role === "author";
139
+ const isReviewer = role === "reviewer";
140
+ const id = entry.commentId;
141
+ const buttons: ButtonDef[] = [];
142
+
143
+ if (state === "proposed" && (isAuthor || isReviewer)) {
144
+ buttons.push({
145
+ id: "propose-change",
146
+ label: "Propose change",
147
+ build: () => ({ type: "propose-change", commentId: id, actorId }),
148
+ });
149
+ buttons.push({
150
+ id: "counter-propose",
151
+ label: "Counter-propose",
152
+ build: () => ({
153
+ type: "counter-propose",
154
+ commentId: id,
155
+ authorId: actorId,
156
+ proposalId: generateProposalId(),
157
+ body: "",
158
+ createdAt: now(),
159
+ }),
160
+ });
161
+ buttons.push({
162
+ id: "reject",
163
+ label: "Reject",
164
+ build: () => ({ type: "reject", commentId: id, actorId }),
165
+ });
166
+ }
167
+
168
+ if (state === "negotiating" && (isAuthor || isReviewer)) {
169
+ buttons.push({
170
+ id: "vote-approve",
171
+ label: "Approve",
172
+ build: () => ({
173
+ type: "vote",
174
+ commentId: id,
175
+ authorId: actorId,
176
+ verdict: "approve",
177
+ }),
178
+ });
179
+ buttons.push({
180
+ id: "vote-reject",
181
+ label: "Reject (vote)",
182
+ build: () => ({
183
+ type: "vote",
184
+ commentId: id,
185
+ authorId: actorId,
186
+ verdict: "reject",
187
+ }),
188
+ });
189
+ buttons.push({
190
+ id: "counter-propose",
191
+ label: "Counter-propose",
192
+ build: () => ({
193
+ type: "counter-propose",
194
+ commentId: id,
195
+ authorId: actorId,
196
+ proposalId: generateProposalId(),
197
+ body: "",
198
+ createdAt: now(),
199
+ }),
200
+ });
201
+ buttons.push({
202
+ id: "reject",
203
+ label: "Reject thread",
204
+ build: () => ({ type: "reject", commentId: id, actorId }),
205
+ });
206
+ if (isAuthor) {
207
+ buttons.push({
208
+ id: "accept",
209
+ label: "Accept",
210
+ title: "Accept requires quorum",
211
+ build: () => ({
212
+ type: "accept",
213
+ commentId: id,
214
+ actorId,
215
+ }),
216
+ });
217
+ }
218
+ }
219
+
220
+ if ((state === "accepted" || state === "rejected") && isAuthor) {
221
+ buttons.push({
222
+ id: "lock",
223
+ label: "Lock",
224
+ build: () => ({ type: "lock", commentId: id, actorId }),
225
+ });
226
+ }
227
+
228
+ if (state === "resolved" && (isAuthor || isReviewer)) {
229
+ buttons.push({
230
+ id: "reopen",
231
+ label: "Reopen",
232
+ build: () => ({ type: "reopen", commentId: id, actorId }),
233
+ });
234
+ }
235
+
236
+ return buttons;
237
+ }
238
+
239
+ function generateProposalId(): string {
240
+ if (typeof globalThis.crypto?.randomUUID === "function") {
241
+ return `prop-${globalThis.crypto.randomUUID()}`;
242
+ }
243
+ return `prop-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
244
+ }
@@ -0,0 +1,150 @@
1
+ import * as React from "react";
2
+
3
+ import type {
4
+ AwarenessPeer,
5
+ PresenceSnapshot,
6
+ TransportStatus,
7
+ } from "../../api/awareness-identity-types.ts";
8
+
9
+ export interface CollabPresenceStripProps {
10
+ presence: PresenceSnapshot;
11
+ /**
12
+ * Optional current-user posture for display styling. When `role ===
13
+ * "observer"` the strip is rendered dimmed to reflect the read-only
14
+ * session posture.
15
+ */
16
+ localRole?: "author" | "reviewer" | "observer";
17
+ /** Max number of peer tiles to render inline before collapsing the remainder. */
18
+ maxInline?: number;
19
+ /** Classname hook for host styling. */
20
+ className?: string;
21
+ }
22
+
23
+ /**
24
+ * Presence strip for the `"collab"` chrome preset (P9b).
25
+ *
26
+ * Reads the snapshot passed in by the host (from
27
+ * `session.getPresenceSnapshot()`). Kept as a pure component so the
28
+ * preset can mount / remount it freely without the chrome owning a
29
+ * session subscription.
30
+ *
31
+ * Visual contract:
32
+ * - Per-peer avatar tile with display-name initials.
33
+ * - `authorKind` shown as a small badge (`agent` / `system`; `human` is
34
+ * the default and unlabelled).
35
+ * - Transport chip at the end of the strip: `connected` / `syncing` /
36
+ * `offline (N queued)`.
37
+ * - When `localRole === "observer"` the whole strip gets the
38
+ * `tw-collab-presence-observer` modifier so the host CSS can dim it.
39
+ * - When there are zero peers the strip still renders the transport
40
+ * chip so hosts see the disconnected state unambiguously.
41
+ */
42
+ export function CollabPresenceStrip({
43
+ presence,
44
+ localRole,
45
+ maxInline = 6,
46
+ className,
47
+ }: CollabPresenceStripProps): React.ReactElement {
48
+ const observer = localRole === "observer";
49
+ const tiles = presence.peers.slice(0, maxInline);
50
+ const overflow = Math.max(0, presence.peers.length - tiles.length);
51
+ const rootClass = [
52
+ "tw-collab-presence-strip",
53
+ observer ? "tw-collab-presence-observer" : null,
54
+ className ?? null,
55
+ ]
56
+ .filter((value): value is string => value !== null)
57
+ .join(" ");
58
+
59
+ return (
60
+ <div
61
+ className={rootClass}
62
+ data-testid="collab-presence-strip"
63
+ role="group"
64
+ aria-label="Collaborators"
65
+ data-observer={observer ? "true" : "false"}
66
+ >
67
+ <ul className="tw-collab-presence-strip__peers" aria-live="polite">
68
+ {tiles.map((peer) => (
69
+ <CollabPresencePeerTile key={peer.userId} peer={peer} />
70
+ ))}
71
+ </ul>
72
+ {overflow > 0 ? (
73
+ <span
74
+ className="tw-collab-presence-strip__overflow"
75
+ aria-label={`${overflow} additional peers`}
76
+ data-testid="collab-presence-overflow"
77
+ >
78
+ +{overflow}
79
+ </span>
80
+ ) : null}
81
+ <CollabTransportChip
82
+ status={presence.transportStatus}
83
+ queuedLocalEvents={presence.queuedLocalEvents}
84
+ />
85
+ </div>
86
+ );
87
+ }
88
+
89
+ function CollabPresencePeerTile({ peer }: { peer: AwarenessPeer }) {
90
+ const initials = computeInitials(peer.displayName);
91
+ return (
92
+ <li
93
+ className="tw-collab-presence-strip__tile"
94
+ data-testid={`collab-presence-peer-${peer.userId}`}
95
+ data-author-kind={peer.authorKind}
96
+ data-active-story={peer.activeStoryId ?? ""}
97
+ title={peer.displayName}
98
+ >
99
+ <span className="tw-collab-presence-strip__avatar" aria-hidden="true">
100
+ {initials}
101
+ </span>
102
+ <span className="tw-collab-presence-strip__name">{peer.displayName}</span>
103
+ {peer.authorKind !== "human" ? (
104
+ <span
105
+ className="tw-collab-presence-strip__badge"
106
+ data-testid={`collab-presence-peer-${peer.userId}-badge`}
107
+ >
108
+ {peer.authorKind}
109
+ </span>
110
+ ) : null}
111
+ </li>
112
+ );
113
+ }
114
+
115
+ function CollabTransportChip({
116
+ status,
117
+ queuedLocalEvents,
118
+ }: {
119
+ status: TransportStatus;
120
+ queuedLocalEvents: number;
121
+ }) {
122
+ const label =
123
+ status === "offline" && queuedLocalEvents > 0
124
+ ? `offline (${queuedLocalEvents} queued)`
125
+ : status;
126
+ return (
127
+ <span
128
+ className={`tw-collab-presence-strip__transport tw-collab-presence-strip__transport--${status}`}
129
+ data-testid="collab-presence-transport"
130
+ data-status={status}
131
+ data-queued={queuedLocalEvents.toString()}
132
+ aria-label={`Transport ${label}`}
133
+ >
134
+ {label}
135
+ </span>
136
+ );
137
+ }
138
+
139
+ function computeInitials(displayName: string): string {
140
+ const trimmed = displayName.trim();
141
+ if (trimmed.length === 0) return "?";
142
+ const words = trimmed.split(/\s+/);
143
+ if (words.length === 1) {
144
+ return words[0]!.slice(0, 2).toUpperCase();
145
+ }
146
+ return (
147
+ (words[0]![0] ?? "").toUpperCase() +
148
+ (words[words.length - 1]![0] ?? "").toUpperCase()
149
+ );
150
+ }
@@ -0,0 +1,62 @@
1
+ import * as React from "react";
2
+
3
+ import type {
4
+ CollabPosture,
5
+ TransportStatus,
6
+ } from "../../api/awareness-identity-types.ts";
7
+
8
+ export interface CollabRoleChipProps {
9
+ posture: CollabPosture;
10
+ /** Optional transport override (chip dims on offline). */
11
+ transportStatus?: TransportStatus;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Role + peer-count chip for the collab top nav (P9c).
17
+ *
18
+ * Reads `CollabPosture` (role, transport, peers). Pure presentational —
19
+ * the host passes in the snapshot from `session.getCollabPosture()`.
20
+ * Observer role gets a dimmed modifier class so the chrome can visually
21
+ * flag the read-only session posture alongside the role chip.
22
+ */
23
+ export function CollabRoleChip({
24
+ posture,
25
+ transportStatus,
26
+ className,
27
+ }: CollabRoleChipProps): React.ReactElement {
28
+ const offline =
29
+ transportStatus === "offline" || posture.transport === "none";
30
+ const rootClass = [
31
+ "tw-collab-role-chip",
32
+ `tw-collab-role-chip--${posture.role}`,
33
+ offline ? "tw-collab-role-chip--offline" : null,
34
+ className ?? null,
35
+ ]
36
+ .filter((v): v is string => v !== null)
37
+ .join(" ");
38
+
39
+ const peerLabel =
40
+ posture.peers === 0
41
+ ? "alone"
42
+ : posture.peers === 1
43
+ ? "1 peer"
44
+ : `${posture.peers} peers`;
45
+
46
+ return (
47
+ <span
48
+ className={rootClass}
49
+ data-testid="collab-role-chip"
50
+ data-role={posture.role}
51
+ data-transport={posture.transport}
52
+ data-peers={posture.peers.toString()}
53
+ aria-label={`Role ${posture.role}, ${peerLabel}`}
54
+ title={`${posture.role} · ${peerLabel}`}
55
+ >
56
+ <span className="tw-collab-role-chip__role">{posture.role}</span>
57
+ <span className="tw-collab-role-chip__peers" aria-hidden="true">
58
+ {posture.peers}
59
+ </span>
60
+ </span>
61
+ );
62
+ }
@@ -0,0 +1,68 @@
1
+ import * as React from "react";
2
+
3
+ import type { MetadataIntegrity } from "../../runtime/tamper-gate.ts";
4
+
5
+ export interface CollabSendToSupplierButtonProps {
6
+ role: "author" | "reviewer" | "observer";
7
+ integrity: MetadataIntegrity;
8
+ /** Counts from the presentation snapshot, per audience. */
9
+ shareableCount: number; // external + shared
10
+ internalCount: number;
11
+ onOpenModal: () => void;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Send-to-supplier button (P9f). Author-only; disabled when the
17
+ * tamper gate reports "tampered" or there are zero shareable comments
18
+ * (external + shared). Clicking opens the confirmation modal; the
19
+ * caller owns modal state.
20
+ */
21
+ export function CollabSendToSupplierButton({
22
+ role,
23
+ integrity,
24
+ shareableCount,
25
+ internalCount,
26
+ onOpenModal,
27
+ className,
28
+ }: CollabSendToSupplierButtonProps): React.ReactElement | null {
29
+ if (role !== "author") return null;
30
+
31
+ const tampered = integrity === "tampered";
32
+ const emptyShareable = shareableCount === 0;
33
+ const disabled = tampered || emptyShareable;
34
+ const rootClass = [
35
+ "tw-collab-send-to-supplier-button",
36
+ tampered ? "tw-collab-send-to-supplier-button--tampered" : null,
37
+ emptyShareable ? "tw-collab-send-to-supplier-button--empty" : null,
38
+ className ?? null,
39
+ ]
40
+ .filter((v): v is string => v !== null)
41
+ .join(" ");
42
+
43
+ const title = tampered
44
+ ? "Blocked: metadata integrity check failed. Acknowledge first."
45
+ : emptyShareable
46
+ ? "No external or shared comments to send."
47
+ : `Send ${shareableCount} comment${shareableCount === 1 ? "" : "s"} to supplier · ${internalCount} internal kept local`;
48
+
49
+ return (
50
+ <button
51
+ type="button"
52
+ className={rootClass}
53
+ data-testid="collab-send-to-supplier-button"
54
+ data-shareable={shareableCount.toString()}
55
+ data-internal={internalCount.toString()}
56
+ data-tampered={tampered ? "true" : "false"}
57
+ disabled={disabled}
58
+ aria-disabled={disabled ? "true" : undefined}
59
+ title={title}
60
+ onClick={() => {
61
+ if (disabled) return;
62
+ onOpenModal();
63
+ }}
64
+ >
65
+ Send to supplier
66
+ </button>
67
+ );
68
+ }
@@ -0,0 +1,149 @@
1
+ import * as React from "react";
2
+
3
+ export interface CollabSendToSupplierSubmitArgs {
4
+ recipient: string;
5
+ archiveRef: string;
6
+ }
7
+
8
+ export interface CollabSendToSupplierModalProps {
9
+ open: boolean;
10
+ shareableCount: number;
11
+ internalCount: number;
12
+ onClose: () => void;
13
+ /**
14
+ * Invoked when the user confirms. Caller routes to
15
+ * `session.sendToExternal({ payloadXml, recipient, archiveRef, ... })`
16
+ * and owns the resulting docx-zip rewrite.
17
+ */
18
+ onSubmit: (args: CollabSendToSupplierSubmitArgs) => void | Promise<void>;
19
+ /** Seed value; primarily useful for hosts that remember the last supplier. */
20
+ initialRecipient?: string;
21
+ /** Seed value for the archive reference field. */
22
+ initialArchiveRef?: string;
23
+ className?: string;
24
+ }
25
+
26
+ /**
27
+ * Confirmation modal for send-to-supplier (P9f). Collects the
28
+ * `recipient` + `archiveRef` from the user and hands them to the
29
+ * caller. Kept controlled (open/close managed externally) so the
30
+ * button in the top nav owns modal state.
31
+ *
32
+ * Deliberately minimal DOM for unit testing — the full visual design
33
+ * (icons, animations, role-scoped styling) ships later as host CSS;
34
+ * this component owns semantics + accessibility only.
35
+ */
36
+ export function CollabSendToSupplierModal({
37
+ open,
38
+ shareableCount,
39
+ internalCount,
40
+ onClose,
41
+ onSubmit,
42
+ initialRecipient = "",
43
+ initialArchiveRef = "",
44
+ className,
45
+ }: CollabSendToSupplierModalProps): React.ReactElement | null {
46
+ const [recipient, setRecipient] = React.useState(initialRecipient);
47
+ const [archiveRef, setArchiveRef] = React.useState(initialArchiveRef);
48
+ const [submitting, setSubmitting] = React.useState(false);
49
+
50
+ // Reset form fields (to the seeds) when the modal is reopened.
51
+ React.useEffect(() => {
52
+ if (open) {
53
+ setRecipient(initialRecipient);
54
+ setArchiveRef(initialArchiveRef);
55
+ setSubmitting(false);
56
+ }
57
+ }, [open, initialRecipient, initialArchiveRef]);
58
+
59
+ if (!open) return null;
60
+
61
+ const canSubmit =
62
+ recipient.trim().length > 0 && archiveRef.trim().length > 0 && !submitting;
63
+
64
+ const rootClass = [
65
+ "tw-collab-send-to-supplier-modal",
66
+ className ?? null,
67
+ ]
68
+ .filter((v): v is string => v !== null)
69
+ .join(" ");
70
+
71
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
72
+ event.preventDefault();
73
+ if (!canSubmit) return;
74
+ setSubmitting(true);
75
+ try {
76
+ await onSubmit({
77
+ recipient: recipient.trim(),
78
+ archiveRef: archiveRef.trim(),
79
+ });
80
+ } finally {
81
+ setSubmitting(false);
82
+ }
83
+ };
84
+
85
+ return (
86
+ <div
87
+ className={rootClass}
88
+ data-testid="collab-send-to-supplier-modal"
89
+ role="dialog"
90
+ aria-modal="true"
91
+ aria-labelledby="collab-send-to-supplier-title"
92
+ >
93
+ <div className="tw-collab-send-to-supplier-modal__backdrop" onClick={onClose} />
94
+ <form
95
+ className="tw-collab-send-to-supplier-modal__panel"
96
+ onSubmit={handleSubmit}
97
+ >
98
+ <h2 id="collab-send-to-supplier-title">Send to supplier</h2>
99
+ <p className="tw-collab-send-to-supplier-modal__summary">
100
+ {shareableCount} comment{shareableCount === 1 ? "" : "s"} will ship
101
+ to the supplier; {internalCount} internal comment
102
+ {internalCount === 1 ? "" : "s"} will stay in your archive.
103
+ </p>
104
+ <label>
105
+ <span>Recipient</span>
106
+ <input
107
+ data-testid="collab-send-to-supplier-modal-recipient"
108
+ type="text"
109
+ value={recipient}
110
+ onChange={(e) => setRecipient(e.target.value)}
111
+ placeholder="supplier-org-42"
112
+ required
113
+ />
114
+ </label>
115
+ <label>
116
+ <span>Archive reference</span>
117
+ <input
118
+ data-testid="collab-send-to-supplier-modal-archive-ref"
119
+ type="text"
120
+ value={archiveRef}
121
+ onChange={(e) => setArchiveRef(e.target.value)}
122
+ placeholder="clm://archive/abc"
123
+ required
124
+ />
125
+ </label>
126
+ <div className="tw-collab-send-to-supplier-modal__actions">
127
+ <button
128
+ type="button"
129
+ className="tw-collab-send-to-supplier-modal__cancel"
130
+ data-testid="collab-send-to-supplier-modal-cancel"
131
+ onClick={onClose}
132
+ disabled={submitting}
133
+ >
134
+ Cancel
135
+ </button>
136
+ <button
137
+ type="submit"
138
+ className="tw-collab-send-to-supplier-modal__submit"
139
+ data-testid="collab-send-to-supplier-modal-submit"
140
+ disabled={!canSubmit}
141
+ aria-disabled={!canSubmit ? "true" : undefined}
142
+ >
143
+ {submitting ? "Sending…" : "Send"}
144
+ </button>
145
+ </div>
146
+ </form>
147
+ </div>
148
+ );
149
+ }