@beyondwork/docx-react-component 1.0.40 → 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
@@ -51,6 +51,24 @@ export function resolveChromePresetOptions(
51
51
  showSectionTagAction: false,
52
52
  showReviewRail: true,
53
53
  },
54
+ collab: {
55
+ // Collab preset composes on top of "review" — same review rail,
56
+ // plus the collab top nav (presence, role + audience chips,
57
+ // tamper banner, negotiation action bar, send-to-supplier).
58
+ // Each sub-surface has its own flag so hosts can selectively
59
+ // disable parts (e.g. single-user demo, or a presenter-only view)
60
+ // without forking the preset.
61
+ showReviewQueueBar: false,
62
+ showSectionTagAction: false,
63
+ showReviewRail: true,
64
+ showCollabTopNav: true,
65
+ showCollabPresenceStrip: true,
66
+ showCollabRoleChip: true,
67
+ showCollabAudienceChip: true,
68
+ showCollabTamperBanner: true,
69
+ showCollabNegotiationActionBar: true,
70
+ showCollabSendToSupplier: true,
71
+ },
54
72
  };
55
73
 
56
74
  return {
@@ -116,6 +134,16 @@ export function resolveChromeVisibilityForPreset(input: {
116
134
  statusBar: true,
117
135
  reviewRail: options.showReviewRail,
118
136
  },
137
+ collab: {
138
+ toolbar: true,
139
+ alerts: true,
140
+ selectionOverlay: true,
141
+ contextToolbars: true,
142
+ contextAnalytics: true,
143
+ pageChrome: true,
144
+ statusBar: true,
145
+ reviewRail: options.showReviewRail,
146
+ },
119
147
  };
120
148
 
121
149
  return {
@@ -0,0 +1,73 @@
1
+ import * as React from "react";
2
+
3
+ import type { CommentAudience } from "../../api/comment-presentation-types.ts";
4
+
5
+ export interface CollabAudienceChipProps {
6
+ /** `undefined` when there is no active comment. */
7
+ audience: CommentAudience | undefined;
8
+ /** `true` when the local user is allowed to change audience. */
9
+ canEdit: boolean;
10
+ /** Invoked when the user cycles the chip. Caller dispatches set-audience. */
11
+ onCycle?: (next: CommentAudience) => void;
12
+ className?: string;
13
+ }
14
+
15
+ const CYCLE: readonly CommentAudience[] = ["internal", "external", "shared"];
16
+
17
+ /**
18
+ * Audience chip for the active comment in the collab top nav (P9c).
19
+ *
20
+ * Renders the current `audience` of the active comment. Clicking
21
+ * cycles `internal → external → shared → internal` and invokes
22
+ * `onCycle(next)` — the caller is responsible for dispatching
23
+ * `set-audience` through `session.dispatchCommentPresentation`.
24
+ *
25
+ * Fail-safe rendering:
26
+ * - Renders a disabled "no comment" placeholder when `audience` is
27
+ * undefined (no active comment selected).
28
+ * - Respects `canEdit`: when false, renders a disabled chip with the
29
+ * current audience but no click handler — useful for observer /
30
+ * reviewer who cannot change the audience.
31
+ */
32
+ export function CollabAudienceChip({
33
+ audience,
34
+ canEdit,
35
+ onCycle,
36
+ className,
37
+ }: CollabAudienceChipProps): React.ReactElement {
38
+ const empty = audience === undefined;
39
+ const disabled = empty || !canEdit;
40
+ const rootClass = [
41
+ "tw-collab-audience-chip",
42
+ audience ? `tw-collab-audience-chip--${audience}` : "tw-collab-audience-chip--empty",
43
+ disabled ? "tw-collab-audience-chip--disabled" : null,
44
+ className ?? null,
45
+ ]
46
+ .filter((v): v is string => v !== null)
47
+ .join(" ");
48
+
49
+ const label = empty ? "no comment" : audience;
50
+
51
+ const handleClick = (): void => {
52
+ if (disabled || !audience) return;
53
+ const idx = CYCLE.indexOf(audience);
54
+ const next = CYCLE[(idx + 1) % CYCLE.length]!;
55
+ onCycle?.(next);
56
+ };
57
+
58
+ return (
59
+ <button
60
+ type="button"
61
+ className={rootClass}
62
+ data-testid="collab-audience-chip"
63
+ data-audience={audience ?? "none"}
64
+ data-can-edit={canEdit ? "true" : "false"}
65
+ aria-label={empty ? "No active comment" : `Audience: ${audience}`}
66
+ aria-disabled={disabled ? "true" : undefined}
67
+ disabled={disabled}
68
+ onClick={handleClick}
69
+ >
70
+ {label}
71
+ </button>
72
+ );
73
+ }
@@ -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
+ }