@beyondwork/docx-react-component 1.0.18 → 1.0.19

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 (74) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. package/src/validation/docx-comment-proof.ts +487 -0
@@ -1,5 +1,5 @@
1
- import React, { useRef, useState } from "react";
2
- import { Check, MessageSquarePlus } from "lucide-react";
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Check, CornerDownRight, RotateCcw } from "lucide-react";
3
3
 
4
4
  import type { CommentSidebarSnapshot, CommentSidebarThreadSnapshot } from "../../api/public-types";
5
5
 
@@ -22,130 +22,218 @@ export function TwCommentSidebar(props: TwCommentSidebarProps) {
22
22
 
23
23
  return (
24
24
  <div className="outline-none">
25
- <p className="text-xs text-tertiary mb-3">
26
- {comments.openCommentIds.length} open · {comments.resolvedCommentIds.length} resolved · {comments.detachedCommentIds.length} detached
27
- </p>
25
+ <div className="mb-2 flex items-center gap-2 text-[10px] text-tertiary">
26
+ <span>{comments.openCommentIds.length} open</span>
27
+ <span className="text-border">·</span>
28
+ <span>{comments.resolvedCommentIds.length} resolved</span>
29
+ {comments.detachedCommentIds.length > 0 && (
30
+ <>
31
+ <span className="text-border">·</span>
32
+ <span>{comments.detachedCommentIds.length} detached</span>
33
+ </>
34
+ )}
35
+ </div>
28
36
  {comments.threads.length > 0 ? (
29
- <div className="space-y-1">
30
- {comments.threads.map((thread) => {
31
- const isActive = activeCommentId === thread.commentId;
32
- const leadEntry = thread.entries[0];
33
- const isOwnComment = currentUserId != null && leadEntry?.authorId === currentUserId;
34
- const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
35
-
36
- return (
37
- <div
38
- key={thread.commentId}
39
- role="button"
40
- tabIndex={0}
41
- className={`rounded-lg p-2.5 transition-colors cursor-pointer ${focusRingClass} ${isActive ? "bg-accent-soft" : "hover:bg-surface"}`}
42
- onClick={() => props.onOpenComment?.(thread)}
43
- onKeyDown={(event) => {
44
- if (event.key === "Enter" || event.key === " ") {
45
- event.preventDefault();
46
- props.onOpenComment?.(thread);
47
- }
48
- }}
49
- >
50
- <div className="flex items-start justify-between gap-2 mb-1">
51
- <span className="text-sm font-medium text-primary">{thread.createdBy}</span>
52
- <StatusBadge status={thread.status} />
53
- </div>
54
- <p className="text-xs text-tertiary mb-1">{thread.createdAt}</p>
55
- <p className="text-xs font-medium text-comment bg-comment-soft rounded px-1 py-0.5 inline-block mb-1.5">
56
- {thread.excerpt}
57
- </p>
37
+ <div className="space-y-1.5">
38
+ {comments.threads.map((thread) => (
39
+ <CommentThreadCard
40
+ key={thread.commentId}
41
+ thread={thread}
42
+ isActive={activeCommentId === thread.commentId}
43
+ currentUserId={currentUserId}
44
+ onOpenComment={props.onOpenComment}
45
+ onResolveComment={props.onResolveComment}
46
+ onReopenComment={props.onReopenComment}
47
+ onAddReply={props.onAddReply}
48
+ onEditBody={props.onEditBody}
49
+ />
50
+ ))}
51
+ </div>
52
+ ) : (
53
+ <div className="rounded-xl border border-dashed border-border bg-surface/60 px-3 py-4 text-[11px] leading-5 text-tertiary">
54
+ No comment threads yet. Select text and add one from the toolbar.
55
+ </div>
56
+ )}
57
+ </div>
58
+ );
59
+ }
58
60
 
59
- {/* Comment body — inline editable for own comments on open threads */}
60
- {leadEntry?.body ? (
61
- canEdit ? (
62
- <InlineEditableBody
63
- commentId={thread.commentId}
64
- body={leadEntry.body}
65
- onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
66
- />
67
- ) : (
68
- <p className="text-sm text-secondary leading-relaxed">{leadEntry.body}</p>
69
- )
70
- ) : null}
61
+ function CommentThreadCard(props: {
62
+ thread: CommentSidebarThreadSnapshot;
63
+ isActive: boolean;
64
+ currentUserId?: string;
65
+ onOpenComment?: (thread: CommentSidebarThreadSnapshot) => void;
66
+ onResolveComment?: (commentId: string) => void;
67
+ onReopenComment?: (commentId: string) => void;
68
+ onAddReply?: (commentId: string, body: string) => void;
69
+ onEditBody?: (commentId: string, body: string) => void;
70
+ }) {
71
+ const { thread, isActive } = props;
72
+ const leadEntry = thread.entries[0];
73
+ const isDraftThread = thread.status === "open" && thread.entryCount === 1 && isEmptyCommentBody(leadEntry?.body);
74
+ const isOwnComment = props.currentUserId != null && leadEntry?.authorId === props.currentUserId;
75
+ const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
76
+ const hasNoBody = isEmptyCommentBody(leadEntry?.body);
77
+ const showExcerpt = Boolean(thread.excerpt) && !isDraftThread && thread.excerpt !== "Empty thread";
71
78
 
72
- {/* Show reply entries */}
73
- {thread.entries.slice(1).map((entry) => (
74
- <div key={entry.entryId} className="mt-2 pl-2 border-l border-border">
75
- <p className="text-xs text-tertiary">{entry.authorId} · {entry.createdAt}</p>
76
- <p className="text-sm text-secondary leading-relaxed">{entry.body}</p>
77
- </div>
78
- ))}
79
+ const scrollRef = useCallback(
80
+ (node: HTMLDivElement | null) => {
81
+ if (node && isActive && typeof node.scrollIntoView === "function") {
82
+ node.scrollIntoView({ behavior: "smooth", block: "nearest" });
83
+ }
84
+ },
85
+ [isActive],
86
+ );
79
87
 
80
- {thread.entryCount > thread.entries.length ? (
81
- <p className="text-xs text-tertiary mt-1.5">
82
- +{thread.entryCount - thread.entries.length} more repl{thread.entryCount - thread.entries.length === 1 ? "y" : "ies"}
83
- </p>
84
- ) : null}
88
+ return (
89
+ <div
90
+ ref={scrollRef}
91
+ data-comment-thread-id={thread.commentId}
92
+ data-comment-thread-status={thread.status}
93
+ role="button"
94
+ tabIndex={0}
95
+ className={[
96
+ "cursor-pointer rounded-xl border px-2.5 py-2 transition-colors",
97
+ focusRingClass,
98
+ isActive
99
+ ? "border-accent/25 bg-accent-soft/35"
100
+ : "border-border bg-canvas hover:border-border-strong hover:bg-surface/70",
101
+ thread.status === "detached" ? "opacity-70" : "",
102
+ ].join(" ")}
103
+ onClick={() => props.onOpenComment?.(thread)}
104
+ onKeyDown={(event) => {
105
+ if (event.key === "Enter" || event.key === " ") {
106
+ event.preventDefault();
107
+ props.onOpenComment?.(thread);
108
+ }
109
+ }}
110
+ >
111
+ {/* Header row: avatar + author + date + status */}
112
+ <div className="mb-1 flex items-center gap-1.5">
113
+ <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-accent/10 text-[8px] font-semibold text-accent">
114
+ {thread.createdBy.charAt(0).toUpperCase()}
115
+ </span>
116
+ <span className="truncate text-[10px] font-medium text-primary">{thread.createdBy}</span>
117
+ <span data-comment-thread-created-at="true" className="text-[9px] text-tertiary">
118
+ {formatCommentDate(thread.createdAt)}
119
+ </span>
120
+ <span className="flex-1" />
121
+ {isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
122
+ {thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
123
+ {thread.status === "detached" ? <StatusBadge label="detached" tone="detached" /> : null}
124
+ </div>
85
125
 
86
- {thread.resolvedAt && thread.resolvedBy ? (
87
- <p className="text-xs text-tertiary mt-1">
88
- Resolved by {thread.resolvedBy} at {thread.resolvedAt}
89
- </p>
90
- ) : null}
126
+ {/* Excerpt anchored text from document */}
127
+ {showExcerpt ? (
128
+ <p className="mb-1 rounded-md border-l-2 border-comment/25 bg-comment-soft/30 px-2 py-1 text-[9px] leading-4 text-comment/80 italic line-clamp-2">
129
+ {thread.excerpt}
130
+ </p>
131
+ ) : null}
91
132
 
92
- <div className="flex gap-1.5 mt-2">
93
- {thread.status === "open" ? (
94
- <button
95
- type="button"
96
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-insert hover:bg-insert-soft transition-colors"
97
- onClick={(e) => {
98
- e.stopPropagation();
99
- props.onResolveComment?.(thread.commentId);
100
- }}
101
- >
102
- <Check className="h-3 w-3" /> Resolve
103
- </button>
104
- ) : thread.status === "resolved" ? (
105
- <button
106
- type="button"
107
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-secondary hover:bg-surface transition-colors"
108
- onClick={(e) => {
109
- e.stopPropagation();
110
- props.onReopenComment?.(thread.commentId);
111
- }}
112
- >
113
- Reopen
114
- </button>
115
- ) : (
116
- <span className="text-xs text-tertiary px-2 py-1">Detached</span>
117
- )}
118
- </div>
133
+ {/* Comment body */}
134
+ {canEdit && (isActive || hasNoBody) ? (
135
+ <InlineEditableBody
136
+ body={leadEntry?.body ?? ""}
137
+ autoFocus={isActive && hasNoBody}
138
+ onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
139
+ label={isDraftThread ? "New comment" : undefined}
140
+ />
141
+ ) : leadEntry?.body ? (
142
+ <p
143
+ className="text-[10px] leading-[1.15rem] text-secondary line-clamp-3"
144
+ data-comment-thread-body="true"
145
+ >
146
+ {leadEntry.body}
147
+ </p>
148
+ ) : canEdit ? (
149
+ <p
150
+ className="cursor-text text-[10px] italic text-tertiary"
151
+ onClick={(e) => {
152
+ e.stopPropagation();
153
+ props.onOpenComment?.(thread);
154
+ }}
155
+ >
156
+ New comment
157
+ </p>
158
+ ) : null}
119
159
 
120
- {/* Reply input only for open threads */}
121
- {thread.status === "open" && props.onAddReply ? (
122
- <ReplyInput commentId={thread.commentId} onAddReply={props.onAddReply} />
123
- ) : null}
124
- </div>
125
- );
126
- })}
160
+ {/* Reply entries (compact) */}
161
+ {thread.entries.slice(1).map((entry) => (
162
+ <div key={entry.entryId} className="mt-1 border-t border-border/40 pt-1">
163
+ <div className="mb-0.5 flex items-center gap-1">
164
+ <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
165
+ <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
166
+ </div>
167
+ <p
168
+ className="text-[10px] leading-4 text-secondary line-clamp-2"
169
+ data-comment-reply-body="true"
170
+ >
171
+ {entry.body}
172
+ </p>
127
173
  </div>
128
- ) : (
129
- <p className="text-xs text-tertiary py-4">
130
- Comment threads will appear here when the runtime loads them.
174
+ ))}
175
+
176
+ {thread.entryCount > thread.entries.length ? (
177
+ <p className="mt-1 text-[9px] text-tertiary">
178
+ +{thread.entryCount - thread.entries.length} more
131
179
  </p>
132
- )}
180
+ ) : null}
181
+
182
+ {/* Inline actions — compact, horizontal */}
183
+ <div className="mt-1.5 flex items-center gap-0.5">
184
+ {thread.status === "open" && (
185
+ <>
186
+ <button
187
+ type="button"
188
+ className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-insert hover:bg-insert-soft transition-colors"
189
+ onClick={(e) => { e.stopPropagation(); props.onResolveComment?.(thread.commentId); }}
190
+ >
191
+ <Check className="h-2 w-2" /> Resolve
192
+ </button>
193
+ {props.onAddReply && (
194
+ <ReplyInput commentId={thread.commentId} onAddReply={props.onAddReply} />
195
+ )}
196
+ </>
197
+ )}
198
+ {thread.status === "resolved" && (
199
+ <button
200
+ type="button"
201
+ className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-secondary hover:bg-surface transition-colors"
202
+ data-comment-thread-action="reopen"
203
+ onClick={(e) => { e.stopPropagation(); props.onReopenComment?.(thread.commentId); }}
204
+ >
205
+ <RotateCcw className="h-2 w-2" /> Reopen
206
+ </button>
207
+ )}
208
+ {thread.status === "detached" && (
209
+ <span className="text-[9px] text-comment">Detached</span>
210
+ )}
211
+ </div>
133
212
  </div>
134
213
  );
135
214
  }
136
215
 
137
216
  function InlineEditableBody(props: {
138
- commentId: string;
139
217
  body: string;
218
+ autoFocus?: boolean;
219
+ label?: string;
140
220
  onSave: (newBody: string) => void;
141
221
  }) {
142
- const [isEditing, setIsEditing] = useState(false);
222
+ const [isEditing, setIsEditing] = useState(props.autoFocus || props.body === "");
143
223
  const [draft, setDraft] = useState(props.body);
224
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
225
+
226
+ useEffect(() => {
227
+ if (isEditing && textareaRef.current) {
228
+ textareaRef.current.focus();
229
+ textareaRef.current.setSelectionRange(draft.length, draft.length);
230
+ }
231
+ }, [isEditing]);
144
232
 
145
233
  if (!isEditing) {
146
234
  return (
147
235
  <p
148
- className="text-sm text-secondary leading-relaxed cursor-text rounded px-1 -mx-1 hover:bg-surface transition-colors"
236
+ className={`cursor-text rounded px-1 text-[10px] leading-[1.15rem] -mx-1 transition-colors hover:bg-surface ${props.body ? "text-secondary" : "text-tertiary italic"}`}
149
237
  onClick={(e) => {
150
238
  e.stopPropagation();
151
239
  setDraft(props.body);
@@ -153,68 +241,84 @@ function InlineEditableBody(props: {
153
241
  }}
154
242
  title="Click to edit"
155
243
  >
156
- {props.body}
244
+ {props.body || "Click to add comment\u2026"}
157
245
  </p>
158
246
  );
159
247
  }
160
248
 
161
249
  return (
162
- <textarea
163
- className="w-full text-sm text-primary leading-relaxed bg-surface rounded-md border border-border px-2 py-1.5 resize-none focus:outline-none focus:ring-1 focus:ring-accent"
164
- rows={Math.max(2, props.body.split("\n").length)}
165
- value={draft}
166
- autoFocus
167
- onClick={(e) => e.stopPropagation()}
168
- onChange={(e) => setDraft(e.target.value)}
169
- onBlur={() => {
170
- if (draft.trim() && draft.trim() !== props.body) {
171
- props.onSave(draft.trim());
172
- }
173
- setIsEditing(false);
174
- }}
175
- onKeyDown={(e) => {
176
- if (e.key === "Enter" && !e.shiftKey) {
177
- e.preventDefault();
250
+ <div className="space-y-1">
251
+ {props.label ? (
252
+ <span className="block text-[10px] font-medium uppercase tracking-[0.08em] text-tertiary">
253
+ {props.label}
254
+ </span>
255
+ ) : null}
256
+ <textarea
257
+ ref={textareaRef}
258
+ className="w-full resize-none rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] leading-4 text-primary placeholder:text-tertiary focus:outline-none focus:ring-1 focus:ring-accent"
259
+ rows={2}
260
+ value={draft}
261
+ placeholder="Type your comment..."
262
+ onClick={(e) => e.stopPropagation()}
263
+ onChange={(e) => setDraft(e.target.value)}
264
+ onBlur={() => {
178
265
  if (draft.trim() && draft.trim() !== props.body) {
179
266
  props.onSave(draft.trim());
180
267
  }
181
268
  setIsEditing(false);
182
- }
183
- if (e.key === "Escape") {
184
- setDraft(props.body);
185
- setIsEditing(false);
186
- }
187
- e.stopPropagation();
188
- }}
189
- />
269
+ }}
270
+ onKeyDown={(e) => {
271
+ if (e.key === "Enter" && !e.shiftKey) {
272
+ e.preventDefault();
273
+ if (draft.trim() && draft.trim() !== props.body) {
274
+ props.onSave(draft.trim());
275
+ }
276
+ setIsEditing(false);
277
+ }
278
+ if (e.key === "Escape") {
279
+ setDraft(props.body);
280
+ setIsEditing(false);
281
+ }
282
+ e.stopPropagation();
283
+ }}
284
+ />
285
+ </div>
190
286
  );
191
287
  }
192
288
 
193
289
  function ReplyInput(props: { commentId: string; onAddReply: (commentId: string, body: string) => void }) {
194
290
  const [body, setBody] = useState("");
195
291
  const [isOpen, setIsOpen] = useState(false);
292
+ const inputRef = useRef<HTMLTextAreaElement>(null);
293
+
294
+ useEffect(() => {
295
+ if (isOpen && inputRef.current) {
296
+ inputRef.current.focus();
297
+ }
298
+ }, [isOpen]);
196
299
 
197
300
  if (!isOpen) {
198
301
  return (
199
302
  <button
200
303
  type="button"
201
- className="inline-flex items-center gap-1 text-xs text-tertiary hover:text-secondary transition-colors mt-1"
304
+ className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-tertiary hover:text-secondary hover:bg-surface transition-colors"
202
305
  onClick={(e) => {
203
306
  e.stopPropagation();
204
307
  setIsOpen(true);
205
308
  }}
206
309
  >
207
- <MessageSquarePlus className="h-3 w-3" /> Reply
310
+ <CornerDownRight className="h-2.5 w-2.5" /> Reply
208
311
  </button>
209
312
  );
210
313
  }
211
314
 
212
315
  return (
213
- <div className="mt-2" onClick={(e) => e.stopPropagation()}>
316
+ <div className="w-full mt-1.5" onClick={(e) => e.stopPropagation()}>
214
317
  <textarea
215
- className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-primary placeholder:text-tertiary resize-none focus:outline-none focus:ring-1 focus:ring-accent"
318
+ ref={inputRef}
319
+ className="w-full rounded border border-border bg-surface px-2 py-1 text-[11px] text-primary placeholder:text-tertiary resize-none focus:outline-none focus:ring-1 focus:ring-accent"
216
320
  rows={2}
217
- placeholder="Write a reply..."
321
+ placeholder="Reply..."
218
322
  value={body}
219
323
  onChange={(e) => setBody(e.target.value)}
220
324
  onKeyDown={(e) => {
@@ -230,13 +334,12 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
230
334
  }
231
335
  e.stopPropagation();
232
336
  }}
233
- autoFocus
234
337
  />
235
- <div className="flex gap-1.5 mt-1">
338
+ <div className="flex gap-1 mt-0.5">
236
339
  <button
237
340
  type="button"
238
341
  disabled={!body.trim()}
239
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-accent hover:bg-accent-soft transition-colors disabled:opacity-40"
342
+ className="rounded px-1.5 py-0.5 text-[10px] font-medium text-accent hover:bg-accent-soft transition-colors disabled:opacity-40"
240
343
  onClick={() => {
241
344
  if (body.trim()) {
242
345
  props.onAddReply(props.commentId, body.trim());
@@ -245,15 +348,12 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
245
348
  }
246
349
  }}
247
350
  >
248
- Reply
351
+ Send
249
352
  </button>
250
353
  <button
251
354
  type="button"
252
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-tertiary hover:bg-surface transition-colors"
253
- onClick={() => {
254
- setBody("");
255
- setIsOpen(false);
256
- }}
355
+ className="rounded px-1.5 py-0.5 text-[10px] text-tertiary hover:bg-surface transition-colors"
356
+ onClick={() => { setBody(""); setIsOpen(false); }}
257
357
  >
258
358
  Cancel
259
359
  </button>
@@ -262,15 +362,45 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
262
362
  );
263
363
  }
264
364
 
265
- function StatusBadge(props: { status: string }) {
365
+ function formatCommentDate(raw: string): string {
366
+ try {
367
+ const date = new Date(raw);
368
+ if (Number.isNaN(date.getTime())) return raw;
369
+ const now = new Date();
370
+ const diffMs = now.getTime() - date.getTime();
371
+ const diffMin = Math.floor(diffMs / 60000);
372
+ if (diffMin < 1) return "just now";
373
+ if (diffMin < 60) return `${diffMin}m ago`;
374
+ const diffHours = Math.floor(diffMin / 60);
375
+ if (diffHours < 24) return `${diffHours}h ago`;
376
+ const diffDays = Math.floor(diffHours / 24);
377
+ if (diffDays < 7) return `${diffDays}d ago`;
378
+ return new Intl.DateTimeFormat("en-US", {
379
+ month: "short",
380
+ day: "numeric",
381
+ year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
382
+ }).format(date);
383
+ } catch {
384
+ return raw;
385
+ }
386
+ }
387
+
388
+ function StatusBadge(props: { label: string; tone: "resolved" | "detached" | "draft" }) {
266
389
  const styles: Record<string, string> = {
267
- open: "text-accent bg-accent-soft",
268
390
  resolved: "text-insert bg-insert-soft",
269
391
  detached: "text-comment bg-warning-soft",
392
+ draft: "text-secondary bg-subtle",
270
393
  };
271
394
  return (
272
- <span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${styles[props.status] ?? "text-secondary bg-subtle"}`}>
273
- {props.status}
395
+ <span
396
+ className={`shrink-0 rounded px-1 py-px text-[8px] font-medium uppercase tracking-[0.08em] ${styles[props.tone] ?? "text-secondary bg-subtle"}`}
397
+ data-comment-thread-badge={props.tone}
398
+ >
399
+ {props.label}
274
400
  </span>
275
401
  );
276
402
  }
403
+
404
+ function isEmptyCommentBody(body: string | undefined): boolean {
405
+ return !body || body.trim() === "";
406
+ }
@@ -52,27 +52,27 @@ export function TwReviewRail(props: TwReviewRailProps) {
52
52
  return (
53
53
  <aside
54
54
  aria-label="Review rail"
55
- className="flex w-[340px] shrink-0 flex-col border-l border-border bg-canvas"
55
+ className="flex w-[336px] shrink-0 flex-col border-l border-border bg-canvas"
56
56
  >
57
57
  <Tabs.Root
58
58
  value={props.activeTab}
59
59
  onValueChange={(v: string) => props.onActiveTabChange(v as ReviewRailTab)}
60
60
  className="flex flex-1 flex-col min-h-0"
61
61
  >
62
- <Tabs.List className="flex shrink-0 border-b border-border">
62
+ <Tabs.List className="flex shrink-0 border-b border-border px-2">
63
63
  <Tabs.Trigger
64
64
  value="comments"
65
- className={`flex-1 py-2 text-xs text-tertiary transition-colors data-[state=active]:text-primary data-[state=active]:shadow-[inset_0_-2px_0_var(--color-accent)] outline-none ${focusRingClass}`}
65
+ className={`flex-1 py-2 text-xs text-tertiary font-medium transition-colors data-[state=active]:text-primary data-[state=active]:font-semibold data-[state=active]:shadow-[inset_0_-2px_0_var(--color-accent)] outline-none ${focusRingClass}`}
66
66
  >
67
67
  Comments{" "}
68
- <span className="text-tertiary">{props.comments.totalCount}</span>
68
+ <span className="ml-1 inline-flex min-w-[14px] items-center justify-center rounded-full bg-subtle px-1.5 py-px text-[10px] font-medium text-tertiary">{props.comments.totalCount}</span>
69
69
  </Tabs.Trigger>
70
70
  <Tabs.Trigger
71
71
  value="changes"
72
- className={`flex-1 py-2 text-xs text-tertiary transition-colors data-[state=active]:text-primary data-[state=active]:shadow-[inset_0_-2px_0_var(--color-accent)] outline-none ${focusRingClass}`}
72
+ className={`flex-1 py-2 text-xs text-tertiary font-medium transition-colors data-[state=active]:text-primary data-[state=active]:font-semibold data-[state=active]:shadow-[inset_0_-2px_0_var(--color-accent)] outline-none ${focusRingClass}`}
73
73
  >
74
74
  Changes{" "}
75
- <span className="text-tertiary">{props.trackedChanges.totalCount}</span>
75
+ <span className="ml-1 inline-flex min-w-[14px] items-center justify-center rounded-full bg-subtle px-1.5 py-px text-[10px] font-medium text-tertiary">{props.trackedChanges.totalCount}</span>
76
76
  </Tabs.Trigger>
77
77
  {/* Health moved to toolbar popover */}
78
78
  </Tabs.List>