@lemonriver/core 0.1.0 → 0.2.0

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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
  3. package/dist/cli/bin.js +4 -4
  4. package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
  5. package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
  6. package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +4 -3
  9. package/dist/locale/index.d.ts +5 -2
  10. package/dist/locale/index.js +2 -2
  11. package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
  12. package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
  13. package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
  14. package/dist/vite/index.d.ts +1 -1
  15. package/dist/vite/index.js +1 -1
  16. package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
  17. package/package.json +21 -15
  18. package/skills/apply-comments/SKILL.md +10 -6
  19. package/skills/create-slide/SKILL.md +2 -0
  20. package/skills/current-slide/SKILL.md +1 -0
  21. package/skills/slide-authoring/SKILL.md +31 -1
  22. package/src/agent/prompt.ts +141 -0
  23. package/src/agent/provider.ts +123 -0
  24. package/src/agent/types.ts +151 -0
  25. package/src/app/app.tsx +2 -0
  26. package/src/app/assets/open-slide.svg +39 -0
  27. package/src/app/components/agent/agent-dock.tsx +35 -0
  28. package/src/app/components/agent/agent-markdown.tsx +45 -0
  29. package/src/app/components/agent/agent-panel.tsx +692 -0
  30. package/src/app/components/agent/agent-presence.tsx +74 -0
  31. package/src/app/components/agent/composer.tsx +120 -0
  32. package/src/app/components/agent/connect-view.tsx +184 -0
  33. package/src/app/components/agent/history-view.tsx +135 -0
  34. package/src/app/components/agent/provider-logo.tsx +34 -0
  35. package/src/app/components/agent/transcript.tsx +609 -0
  36. package/src/app/components/board/board-canvas.tsx +143 -12
  37. package/src/app/components/board/board.css +51 -0
  38. package/src/app/components/board/layout.test.ts +53 -1
  39. package/src/app/components/board/layout.ts +50 -2
  40. package/src/app/components/board/page-node.tsx +287 -13
  41. package/src/app/components/board/use-board-cards.ts +210 -0
  42. package/src/app/components/board/use-page-order.ts +17 -9
  43. package/src/app/components/command/slide-command-menu.tsx +10 -0
  44. package/src/app/components/icon-switcher-indicator.tsx +1 -1
  45. package/src/app/components/inspector/comment-layer.tsx +1011 -0
  46. package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
  47. package/src/app/components/inspector/inspect-overlay.tsx +2 -39
  48. package/src/app/components/inspector/inspector-panel.tsx +10 -93
  49. package/src/app/components/inspector/inspector-provider.tsx +121 -47
  50. package/src/app/components/panel/editor-sidebar.tsx +85 -16
  51. package/src/app/components/panel/panel-shell.tsx +2 -2
  52. package/src/app/components/panel/resize-handle.tsx +129 -0
  53. package/src/app/components/player.tsx +0 -1
  54. package/src/app/components/preview-step-host.tsx +16 -0
  55. package/src/app/components/sidebar/sidebar.tsx +2 -2
  56. package/src/app/components/style-panel/style-panel.tsx +3 -2
  57. package/src/app/components/thumbnail-rail.tsx +76 -1
  58. package/src/app/favicon.ico +0 -0
  59. package/src/app/favicon.svg +39 -0
  60. package/src/app/index.html +2 -1
  61. package/src/app/lib/agent/use-agent.ts +543 -0
  62. package/src/app/lib/inspector/freeze-motion.ts +61 -0
  63. package/src/app/lib/inspector/use-comments.ts +32 -3
  64. package/src/app/lib/locale-store.ts +6 -1
  65. package/src/app/lib/print-ready.ts +9 -6
  66. package/src/app/lib/use-auto-grow.ts +15 -0
  67. package/src/app/lib/utils.ts +4 -0
  68. package/src/app/routes/board.tsx +48 -11
  69. package/src/app/routes/home.tsx +43 -7
  70. package/src/app/routes/presenter.tsx +1 -16
  71. package/src/app/routes/preview.tsx +169 -0
  72. package/src/app/routes/slide.tsx +221 -173
  73. package/src/app/styles.css +234 -80
  74. package/src/editing/placeholder-id.ts +1 -0
  75. package/src/locale/en.ts +127 -5
  76. package/src/locale/es.ts +692 -0
  77. package/src/locale/index.ts +1 -0
  78. package/src/locale/ja.ts +120 -6
  79. package/src/locale/types.ts +132 -5
  80. package/src/locale/zh-cn.ts +117 -5
  81. package/src/locale/zh-tw.ts +117 -5
  82. package/src/app/assets/open-slide.png +0 -0
  83. package/src/app/components/inspector/comment-widget.tsx +0 -119
@@ -0,0 +1,1011 @@
1
+ import { ArrowUp, Check, MessageSquare, Trash2 } from 'lucide-react';
2
+ import {
3
+ type ComponentProps,
4
+ type CSSProperties,
5
+ type ReactNode,
6
+ type RefObject,
7
+ useCallback,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+ import { toast } from 'sonner';
14
+ import { requestAgent, useAgentStatus } from '@/lib/agent/use-agent';
15
+ import { findSlideSource } from '@/lib/inspector/fiber';
16
+ import { useFreezeSlideMotion } from '@/lib/inspector/freeze-motion';
17
+ import { type Point, type Rect, rectContains } from '@/lib/inspector/geometry';
18
+ import {
19
+ isInspectableEventTarget,
20
+ pickElement,
21
+ pickInspectorTarget,
22
+ } from '@/lib/inspector/pick-target';
23
+ import type { CommentRect, SlideComment } from '@/lib/inspector/use-comments';
24
+ import { type Canvas, readCanvas, readFrame } from '@/lib/inspector/visual-dom';
25
+ import { IS_APPLE, isTypingTarget } from '@/lib/keys';
26
+ import { format, plural, useLocale } from '@/lib/use-locale';
27
+ import { cn } from '@/lib/utils';
28
+ import { applyCommentsPrompt } from '../../../agent/prompt';
29
+ import { type SelectedTarget, useInspector } from './inspector-provider';
30
+
31
+ type ScreenRect = { left: number; top: number; width: number; height: number };
32
+ type Size = { width: number; height: number };
33
+
34
+ type Draft =
35
+ | { kind: 'element'; target: SelectedTarget }
36
+ | { kind: 'region'; rect: Rect; target: SelectedTarget };
37
+
38
+ type Gesture = {
39
+ pointerId: number;
40
+ start: Point;
41
+ hit: SelectedTarget | null;
42
+ canvas: Canvas;
43
+ moved: boolean;
44
+ };
45
+
46
+ type PinLayout = { frame: ScreenRect; region: boolean };
47
+
48
+ type Layout = {
49
+ width: number;
50
+ height: number;
51
+ canvas: ScreenRect | null;
52
+ scale: number;
53
+ pins: Record<string, PinLayout>;
54
+ hoverFrame: ScreenRect | null;
55
+ draftFrame: ScreenRect | null;
56
+ };
57
+
58
+ const EMPTY_LAYOUT: Layout = {
59
+ width: 0,
60
+ height: 0,
61
+ canvas: null,
62
+ scale: 1,
63
+ pins: {},
64
+ hoverFrame: null,
65
+ draftFrame: null,
66
+ };
67
+
68
+ // A freshly written marker shifts source lines, so a comment's `target`
69
+ // can point at a loc the canvas only picks up once HMR re-renders — and
70
+ // that reload can remount this layer. Remember each pin's last frame
71
+ // across mounts so it doesn't jump to the fallback corner in between.
72
+ const rememberedFrames = new Map<string, PinLayout>();
73
+
74
+ const PIN = 24;
75
+ const GAP = 8;
76
+ const MIN_REGION = 6;
77
+
78
+ function pinRect(frame: ScreenRect): ScreenRect {
79
+ return { left: frame.left + frame.width - 3, top: frame.top - PIN + 3, width: PIN, height: PIN };
80
+ }
81
+
82
+ function placeBeside(anchor: ScreenRect, size: Size, bounds: Size): { left: number; top: number } {
83
+ let left = anchor.left + anchor.width + GAP;
84
+ if (left + size.width > bounds.width - GAP) left = anchor.left - size.width - GAP;
85
+ if (left < GAP) left = Math.max(GAP, Math.min(anchor.left, bounds.width - size.width - GAP));
86
+ const top = Math.max(GAP, Math.min(anchor.top, bounds.height - size.height - GAP));
87
+ return { left, top };
88
+ }
89
+
90
+ function fallbackFrame(canvas: Canvas): Rect {
91
+ return { x: canvas.width - 72, y: 24, width: 48, height: 48 };
92
+ }
93
+
94
+ // Deepest host element whose box fully contains the drawn region; falls
95
+ // back to the largest host element (usually the page wrapper) so the
96
+ // marker always has a JSX container to live in.
97
+ function containerFor(rect: Rect, canvas: Canvas, slideId: string): SelectedTarget | null {
98
+ const tolerance = {
99
+ x: rect.x - 1,
100
+ y: rect.y - 1,
101
+ width: rect.width + 2,
102
+ height: rect.height + 2,
103
+ };
104
+ let best: { target: SelectedTarget; area: number } | null = null;
105
+ let largest: { target: SelectedTarget; area: number } | null = null;
106
+ const seen = new Set<HTMLElement>();
107
+ for (const element of canvas.root.querySelectorAll<HTMLElement>('[data-slide-loc]')) {
108
+ const hit = findSlideSource(element, slideId, { hostOnly: true });
109
+ if (!hit || seen.has(hit.anchor)) continue;
110
+ seen.add(hit.anchor);
111
+ const frame = readFrame(hit.anchor, canvas);
112
+ const area = frame.width * frame.height;
113
+ if (!area) continue;
114
+ if (!largest || area > largest.area) largest = { target: hit, area };
115
+ if (rectContains(frame, tolerance) && (!best || area < best.area)) best = { target: hit, area };
116
+ }
117
+ return best?.target ?? largest?.target ?? null;
118
+ }
119
+
120
+ export function CommentLayer() {
121
+ const { commentMode, exitCommentMode, slideId, pageIndex, comments, add, update, remove, error } =
122
+ useInspector();
123
+ const t = useLocale();
124
+ const agent = useAgentStatus();
125
+ useFreezeSlideMotion(commentMode ? 'comment' : null);
126
+ const overlayRef = useRef<HTMLDivElement>(null);
127
+ const gestureRef = useRef<Gesture | null>(null);
128
+ const [hover, setHover] = useState<HTMLElement | null>(null);
129
+ const [marquee, setMarquee] = useState<Rect | null>(null);
130
+ const [draft, setDraft] = useState<Draft | null>(null);
131
+ const [openId, setOpenId] = useState<string | null>(null);
132
+ const [hoverId, setHoverId] = useState<string | null>(null);
133
+ const [layout, setLayout] = useState<Layout>(EMPTY_LAYOUT);
134
+
135
+ useEffect(() => {
136
+ if (commentMode) return;
137
+ gestureRef.current = null;
138
+ setHover(null);
139
+ setMarquee(null);
140
+ setDraft(null);
141
+ setOpenId(null);
142
+ setHoverId(null);
143
+ }, [commentMode]);
144
+
145
+ useEffect(() => {
146
+ void pageIndex;
147
+ setDraft(null);
148
+ setOpenId(null);
149
+ }, [pageIndex]);
150
+
151
+ useLayoutEffect(() => {
152
+ if (!commentMode) return;
153
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
154
+ let raf = 0;
155
+ const measure = () => {
156
+ const overlay = overlayRef.current;
157
+ const canvas = readCanvas();
158
+ if (!overlay || !canvas) return;
159
+ const origin = overlay.getBoundingClientRect();
160
+ const screen = (rect: Rect): ScreenRect => ({
161
+ left: canvas.rect.left - origin.left + rect.x * canvas.scale,
162
+ top: canvas.rect.top - origin.top + rect.y * canvas.scale,
163
+ width: rect.width * canvas.scale,
164
+ height: rect.height * canvas.scale,
165
+ });
166
+ const element = (anchor: HTMLElement): ScreenRect => {
167
+ const box = anchor.getBoundingClientRect();
168
+ return {
169
+ left: box.left - origin.left,
170
+ top: box.top - origin.top,
171
+ width: box.width,
172
+ height: box.height,
173
+ };
174
+ };
175
+ const region = (rect: CommentRect): ScreenRect =>
176
+ screen({ x: rect.x, y: rect.y, width: rect.w, height: rect.h });
177
+ const pins: Record<string, PinLayout> = {};
178
+ for (const comment of comments) {
179
+ const memoryKey = `${slideId}:${comment.id}`;
180
+ // Pages often share a wrapper component, so the target loc alone
181
+ // would match on every page; the stored page index decides.
182
+ if (comment.page !== undefined && comment.page !== pageIndex) continue;
183
+ const anchor = comment.target
184
+ ? canvas.root.querySelector<HTMLElement>(
185
+ `[data-slide-loc="${comment.target.line}:${comment.target.column}"]`,
186
+ )
187
+ : null;
188
+ if (anchor) {
189
+ pins[comment.id] = {
190
+ frame: comment.rect ? region(comment.rect) : element(anchor),
191
+ region: !!comment.rect,
192
+ };
193
+ rememberedFrames.set(memoryKey, pins[comment.id]);
194
+ } else if (comment.page === pageIndex) {
195
+ pins[comment.id] = comment.rect
196
+ ? { frame: region(comment.rect), region: true }
197
+ : (rememberedFrames.get(memoryKey) ?? {
198
+ frame: screen(fallbackFrame(canvas)),
199
+ region: false,
200
+ });
201
+ }
202
+ }
203
+ const draftFrame = !draft
204
+ ? null
205
+ : draft.kind === 'region'
206
+ ? screen(draft.rect)
207
+ : draft.target.anchor.isConnected
208
+ ? element(draft.target.anchor)
209
+ : null;
210
+ const next: Layout = {
211
+ width: origin.width,
212
+ height: origin.height,
213
+ canvas: screen({ x: 0, y: 0, width: canvas.width, height: canvas.height }),
214
+ scale: canvas.scale,
215
+ pins,
216
+ hoverFrame: hover?.isConnected ? element(hover) : null,
217
+ draftFrame,
218
+ };
219
+ setLayout((previous) =>
220
+ JSON.stringify(previous) === JSON.stringify(next) ? previous : next,
221
+ );
222
+ };
223
+ const schedule = () => {
224
+ if (raf) return;
225
+ raf = requestAnimationFrame(() => {
226
+ raf = 0;
227
+ measure();
228
+ });
229
+ };
230
+ const stopAt = performance.now() + 420;
231
+ const track = () => {
232
+ measure();
233
+ if (performance.now() < stopAt) raf = requestAnimationFrame(track);
234
+ else raf = 0;
235
+ };
236
+ track();
237
+ const resize = new ResizeObserver(schedule);
238
+ if (overlayRef.current) resize.observe(overlayRef.current);
239
+ const mutations = root ? new MutationObserver(schedule) : null;
240
+ mutations?.observe(root as HTMLElement, {
241
+ childList: true,
242
+ subtree: true,
243
+ attributes: true,
244
+ attributeFilter: ['data-slide-loc', 'style', 'class'],
245
+ });
246
+ window.addEventListener('resize', schedule);
247
+ window.addEventListener('scroll', schedule, true);
248
+ return () => {
249
+ cancelAnimationFrame(raf);
250
+ resize.disconnect();
251
+ mutations?.disconnect();
252
+ window.removeEventListener('resize', schedule);
253
+ window.removeEventListener('scroll', schedule, true);
254
+ };
255
+ }, [commentMode, comments, pageIndex, hover, draft, slideId]);
256
+
257
+ useEffect(() => {
258
+ if (!commentMode) return;
259
+ const targetAt = (event: PointerEvent): SelectedTarget | null => {
260
+ const element = pickInspectorTarget(pickElement(event.clientX, event.clientY));
261
+ return element ? findSlideSource(element, slideId, { hostOnly: true }) : null;
262
+ };
263
+ const point = (event: PointerEvent, canvas: Canvas): Point => ({
264
+ x: (event.clientX - canvas.rect.left) / canvas.scale,
265
+ y: (event.clientY - canvas.rect.top) / canvas.scale,
266
+ });
267
+ const onDown = (event: PointerEvent) => {
268
+ if (event.button !== 0 || !event.isPrimary || gestureRef.current) return;
269
+ if (!isInspectableEventTarget(event.target)) return;
270
+ const canvas = readCanvas();
271
+ if (!canvas) return;
272
+ if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
273
+ setDraft(null);
274
+ setOpenId(null);
275
+ gestureRef.current = {
276
+ pointerId: event.pointerId,
277
+ start: point(event, canvas),
278
+ hit: targetAt(event),
279
+ canvas,
280
+ moved: false,
281
+ };
282
+ event.preventDefault();
283
+ event.stopPropagation();
284
+ };
285
+ const onMove = (event: PointerEvent) => {
286
+ const gesture = gestureRef.current;
287
+ if (gesture) {
288
+ if (gesture.pointerId !== event.pointerId) return;
289
+ const current = point(event, gesture.canvas);
290
+ const delta = { x: current.x - gesture.start.x, y: current.y - gesture.start.y };
291
+ if (!gesture.moved && Math.hypot(delta.x, delta.y) * gesture.canvas.scale < 3) return;
292
+ gesture.moved = true;
293
+ setHover(null);
294
+ setMarquee({
295
+ x: Math.min(gesture.start.x, current.x),
296
+ y: Math.min(gesture.start.y, current.y),
297
+ width: Math.abs(delta.x),
298
+ height: Math.abs(delta.y),
299
+ });
300
+ return;
301
+ }
302
+ if (!isInspectableEventTarget(event.target)) {
303
+ setHover(null);
304
+ return;
305
+ }
306
+ setHover(targetAt(event)?.anchor ?? null);
307
+ };
308
+ const finish = (event: PointerEvent, cancelled: boolean) => {
309
+ const gesture = gestureRef.current;
310
+ if (!gesture || gesture.pointerId !== event.pointerId) return;
311
+ gestureRef.current = null;
312
+ setMarquee(null);
313
+ if (cancelled) return;
314
+ if (gesture.moved) {
315
+ const current = point(event, gesture.canvas);
316
+ const rect = {
317
+ x: Math.min(gesture.start.x, current.x),
318
+ y: Math.min(gesture.start.y, current.y),
319
+ width: Math.abs(current.x - gesture.start.x),
320
+ height: Math.abs(current.y - gesture.start.y),
321
+ };
322
+ if (
323
+ rect.width * gesture.canvas.scale < MIN_REGION ||
324
+ rect.height * gesture.canvas.scale < MIN_REGION
325
+ )
326
+ return;
327
+ const target = containerFor(rect, gesture.canvas, slideId);
328
+ if (!target) {
329
+ toast.error(t.inspector.commentRegionNoTarget);
330
+ return;
331
+ }
332
+ setDraft({ kind: 'region', rect, target });
333
+ return;
334
+ }
335
+ if (gesture.hit) setDraft({ kind: 'element', target: gesture.hit });
336
+ };
337
+ const onUp = (event: PointerEvent) => finish(event, false);
338
+ const onCancel = (event: Event) => {
339
+ if (event instanceof PointerEvent) finish(event, true);
340
+ else {
341
+ gestureRef.current = null;
342
+ setMarquee(null);
343
+ }
344
+ };
345
+ const onClick = (event: MouseEvent) => {
346
+ if (!isInspectableEventTarget(event.target)) return;
347
+ event.preventDefault();
348
+ event.stopImmediatePropagation();
349
+ };
350
+ const onKey = (event: KeyboardEvent) => {
351
+ if (event.key !== 'Escape' || event.isComposing || event.keyCode === 229) return;
352
+ if (isTypingTarget(event.target) && !overlayRef.current?.contains(event.target as Node))
353
+ return;
354
+ if (
355
+ event.target instanceof Element &&
356
+ event.target.closest('[role="dialog"], [role="menu"], [role="listbox"]')
357
+ )
358
+ return;
359
+ event.preventDefault();
360
+ event.stopImmediatePropagation();
361
+ if (gestureRef.current) {
362
+ gestureRef.current = null;
363
+ setMarquee(null);
364
+ } else if (draft) setDraft(null);
365
+ else if (openId) setOpenId(null);
366
+ else exitCommentMode();
367
+ };
368
+ window.addEventListener('pointerdown', onDown, true);
369
+ window.addEventListener('pointermove', onMove, true);
370
+ window.addEventListener('pointerup', onUp, true);
371
+ window.addEventListener('pointercancel', onCancel, true);
372
+ window.addEventListener('blur', onCancel);
373
+ window.addEventListener('click', onClick, true);
374
+ window.addEventListener('keydown', onKey, true);
375
+ return () => {
376
+ window.removeEventListener('pointerdown', onDown, true);
377
+ window.removeEventListener('pointermove', onMove, true);
378
+ window.removeEventListener('pointerup', onUp, true);
379
+ window.removeEventListener('pointercancel', onCancel, true);
380
+ window.removeEventListener('blur', onCancel);
381
+ window.removeEventListener('click', onClick, true);
382
+ window.removeEventListener('keydown', onKey, true);
383
+ };
384
+ }, [commentMode, slideId, draft, openId, exitCommentMode, t]);
385
+
386
+ const submit = useCallback(
387
+ async (text: string) => {
388
+ if (!draft) return;
389
+ const { target } = draft;
390
+ const rect =
391
+ draft.kind === 'region'
392
+ ? {
393
+ x: Math.round(draft.rect.x),
394
+ y: Math.round(draft.rect.y),
395
+ w: Math.round(draft.rect.width),
396
+ h: Math.round(draft.rect.height),
397
+ }
398
+ : undefined;
399
+ try {
400
+ await add({ line: target.line, column: target.column, text, rect, page: pageIndex });
401
+ setDraft(null);
402
+ } catch (err) {
403
+ toast.error(`${t.inspector.commentAddFailed} ${String((err as Error).message ?? err)}`);
404
+ }
405
+ },
406
+ [draft, add, pageIndex, t],
407
+ );
408
+
409
+ const handleUpdate = useCallback(
410
+ async (id: string, text: string) => {
411
+ try {
412
+ await update(id, text);
413
+ setOpenId(null);
414
+ } catch (err) {
415
+ toast.error(`${t.inspector.commentUpdateFailed} ${String((err as Error).message ?? err)}`);
416
+ }
417
+ },
418
+ [update, t],
419
+ );
420
+
421
+ const handleRemove = useCallback(
422
+ async (id: string) => {
423
+ try {
424
+ await remove(id);
425
+ setOpenId((current) => (current === id ? null : current));
426
+ } catch (err) {
427
+ toast.error(String((err as Error).message ?? err));
428
+ }
429
+ },
430
+ [remove],
431
+ );
432
+
433
+ if (!commentMode) return null;
434
+
435
+ const bounds = { width: layout.width, height: layout.height };
436
+ const toScreen = (rect: Rect): ScreenRect => ({
437
+ left: (layout.canvas?.left ?? 0) + rect.x * layout.scale,
438
+ top: (layout.canvas?.top ?? 0) + rect.y * layout.scale,
439
+ width: rect.width * layout.scale,
440
+ height: rect.height * layout.scale,
441
+ });
442
+ const count = comments.length;
443
+
444
+ return (
445
+ <div
446
+ ref={overlayRef}
447
+ data-inspector-ui
448
+ data-comment-layer
449
+ className="pointer-events-none absolute inset-0 z-30 select-none"
450
+ >
451
+ {layout.hoverFrame && !gestureRef.current && !draft && (
452
+ <div className="absolute border border-dashed border-brand/70" style={layout.hoverFrame} />
453
+ )}
454
+ {marquee && (
455
+ <div className="absolute border border-brand bg-brand/10" style={toScreen(marquee)} />
456
+ )}
457
+ {comments.map((comment) => {
458
+ const pin = layout.pins[comment.id];
459
+ if (!pin) return null;
460
+ const open = openId === comment.id;
461
+ return (
462
+ <CommentPin
463
+ key={comment.id}
464
+ comment={comment}
465
+ frame={pin.frame}
466
+ region={pin.region}
467
+ anchor={pinRect(pin.frame)}
468
+ bounds={bounds}
469
+ open={open}
470
+ hovered={hoverId === comment.id}
471
+ onToggle={() => {
472
+ setDraft(null);
473
+ setOpenId(open ? null : comment.id);
474
+ }}
475
+ onHover={(hovering) => setHoverId(hovering ? comment.id : null)}
476
+ onSave={(text) => handleUpdate(comment.id, text)}
477
+ onRemove={() => handleRemove(comment.id)}
478
+ />
479
+ );
480
+ })}
481
+ {draft && layout.draftFrame && (
482
+ <Composer
483
+ frame={layout.draftFrame}
484
+ region={draft.kind === 'region'}
485
+ anchor={pinRect(layout.draftFrame)}
486
+ bounds={bounds}
487
+ onSubmit={submit}
488
+ />
489
+ )}
490
+ <div className="absolute inset-x-0 bottom-3 flex justify-center">
491
+ <IslandHint
492
+ contentKey={
493
+ error ? 'error' : count === 0 ? 'empty' : agent.connected ? 'count-agent' : 'count'
494
+ }
495
+ tone={error ? 'error' : 'default'}
496
+ >
497
+ {error ? (
498
+ error
499
+ ) : count === 0 ? (
500
+ t.inspector.commentEmptyHint
501
+ ) : agent.connected ? (
502
+ <>
503
+ <CommentCountLabel count={count} />
504
+ <span aria-hidden className="mx-1.5">
505
+ ·
506
+ </span>
507
+ <button
508
+ type="button"
509
+ className="pointer-events-auto rounded-[4px] px-1 py-0.5 font-medium text-brand outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/30"
510
+ onClick={() => requestAgent({ text: applyCommentsPrompt(slideId) })}
511
+ >
512
+ {format(t.agent.applyCommentsWith, { provider: agent.providerName ?? '' })}
513
+ </button>
514
+ </>
515
+ ) : (
516
+ <>
517
+ <CommentCountLabel count={count} />
518
+ <span aria-hidden className="mx-1.5">
519
+ ·
520
+ </span>
521
+ {t.inspector.commentsApplyHintPrefix}
522
+ <code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">
523
+ /apply-comments
524
+ </code>
525
+ {t.inspector.commentsApplyHintSuffix}
526
+ </>
527
+ )}
528
+ </IslandHint>
529
+ </div>
530
+ </div>
531
+ );
532
+ }
533
+
534
+ const ISLAND_SWAP_MS = 320;
535
+ const SUBMIT_SHORTCUT = IS_APPLE ? '⌘↵' : 'Ctrl ↵';
536
+
537
+ // Only the digits roll when the count changes; the surrounding words
538
+ // stay put so the pill reads as a counter ticking, not a text swap.
539
+ function CommentCountLabel({ count }: { count: number }) {
540
+ const t = useLocale();
541
+ const [prefix, suffix] = plural(count, t.inspector.commentsCount).split('{count}');
542
+ return (
543
+ <span className="font-medium text-foreground nums">
544
+ {prefix}
545
+ <RollingNumber value={count} />
546
+ {suffix}
547
+ </span>
548
+ );
549
+ }
550
+
551
+ function RollingNumber({ value }: { value: number }) {
552
+ const [outgoing, setOutgoing] = useState<number | null>(null);
553
+ const lastRef = useRef(value);
554
+ useLayoutEffect(() => {
555
+ const previous = lastRef.current;
556
+ if (previous === value) return;
557
+ lastRef.current = value;
558
+ setOutgoing(previous);
559
+ const timer = setTimeout(() => setOutgoing(null), ISLAND_SWAP_MS);
560
+ return () => clearTimeout(timer);
561
+ }, [value]);
562
+ const direction = outgoing !== null && value < outgoing ? -1 : 1;
563
+ return (
564
+ <span
565
+ className="inline-grid overflow-hidden align-bottom"
566
+ style={{ '--roll': direction } as CSSProperties}
567
+ >
568
+ {outgoing !== null && (
569
+ <span key={`out-${outgoing}`} aria-hidden className="roll-leave col-start-1 row-start-1">
570
+ {outgoing}
571
+ </span>
572
+ )}
573
+ <span
574
+ key={`in-${value}`}
575
+ className={cn('col-start-1 row-start-1', outgoing !== null && 'roll-arrive')}
576
+ >
577
+ {value}
578
+ </span>
579
+ </span>
580
+ );
581
+ }
582
+
583
+ // The pill resizes to whatever it says, Dynamic Island style: the shell
584
+ // animates its width while the outgoing text blurs out and the new text
585
+ // blurs in. The shell's width is driven by the incoming content alone so
586
+ // it never waits for the old text to unmount.
587
+ function IslandHint({
588
+ contentKey,
589
+ tone,
590
+ children,
591
+ }: {
592
+ contentKey: string;
593
+ tone: 'default' | 'error';
594
+ children: ReactNode;
595
+ }) {
596
+ const contentRef = useRef<HTMLSpanElement>(null);
597
+ const [width, setWidth] = useState<number | null>(null);
598
+ const [outgoing, setOutgoing] = useState<{ key: string; node: ReactNode } | null>(null);
599
+ const lastRef = useRef<{ key: string; node: ReactNode }>({ key: contentKey, node: children });
600
+
601
+ useLayoutEffect(() => {
602
+ const previous = lastRef.current;
603
+ lastRef.current = { key: contentKey, node: children };
604
+ if (previous.key === contentKey) return;
605
+ setOutgoing(previous);
606
+ const timer = setTimeout(() => setOutgoing(null), ISLAND_SWAP_MS);
607
+ return () => clearTimeout(timer);
608
+ }, [contentKey, children]);
609
+
610
+ // The content span remounts per key, and its entrance scales it, so
611
+ // re-attach per key and read the unscaled layout width.
612
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-observe the remounted span
613
+ useLayoutEffect(() => {
614
+ const node = contentRef.current;
615
+ if (!node) return;
616
+ const update = () => setWidth(node.offsetWidth);
617
+ update();
618
+ const observer = new ResizeObserver(update);
619
+ observer.observe(node);
620
+ return () => observer.disconnect();
621
+ }, [contentKey]);
622
+
623
+ return (
624
+ <div
625
+ data-comment-hint
626
+ className={cn(
627
+ 'island-enter grid max-w-[calc(100%-2rem)] justify-items-start overflow-hidden rounded-full border bg-card/95 px-3 py-1.5 text-[11px] text-muted-foreground shadow-floating backdrop-blur',
628
+ 'motion-safe:transition-[width] motion-safe:duration-500 motion-safe:ease-[cubic-bezier(0.32,0.72,0,1)]',
629
+ tone === 'error' && 'text-destructive',
630
+ )}
631
+ style={width === null ? undefined : { width: width + 26 }}
632
+ >
633
+ {outgoing && (
634
+ <span
635
+ key={outgoing.key}
636
+ aria-hidden
637
+ className="island-leave col-start-1 row-start-1 truncate whitespace-nowrap"
638
+ >
639
+ {outgoing.node}
640
+ </span>
641
+ )}
642
+ <span
643
+ key={contentKey}
644
+ ref={contentRef}
645
+ className={cn(
646
+ 'col-start-1 row-start-1 w-max whitespace-nowrap',
647
+ outgoing && 'island-arrive',
648
+ )}
649
+ >
650
+ {children}
651
+ </span>
652
+ </div>
653
+ );
654
+ }
655
+
656
+ // `mountKey` re-attaches the observer when the measured card mounts later
657
+ // than the hook's owner (the pin's cards only exist while open or hovered).
658
+ function useMeasuredSize(
659
+ fallback: Size,
660
+ mountKey: unknown = null,
661
+ ): [RefObject<HTMLDivElement | null>, Size] {
662
+ const ref = useRef<HTMLDivElement>(null);
663
+ const [size, setSize] = useState<Size>(fallback);
664
+ // biome-ignore lint/correctness/useExhaustiveDependencies: mountKey only triggers re-observation
665
+ useLayoutEffect(() => {
666
+ const node = ref.current;
667
+ if (!node) return;
668
+ const update = () => {
669
+ const next = { width: node.offsetWidth, height: node.offsetHeight };
670
+ setSize((previous) =>
671
+ previous.width === next.width && previous.height === next.height ? previous : next,
672
+ );
673
+ };
674
+ update();
675
+ const observer = new ResizeObserver(update);
676
+ observer.observe(node);
677
+ return () => observer.disconnect();
678
+ }, [mountKey]);
679
+ return [ref, size];
680
+ }
681
+
682
+ function Pin({
683
+ anchor,
684
+ lit,
685
+ children,
686
+ ...props
687
+ }: ComponentProps<'button'> & { anchor: ScreenRect; lit: boolean }) {
688
+ return (
689
+ <button
690
+ type="button"
691
+ {...props}
692
+ className={cn(
693
+ 'pointer-events-auto absolute flex size-6 items-center justify-center rounded-full rounded-bl-[3px] bg-brand font-mono text-[10px] font-semibold text-brand-foreground ring-2 ring-background shadow-floating nums',
694
+ 'transition-[scale,box-shadow] duration-150 ease-swift hover:scale-110 focus-visible:outline-none focus-visible:ring-ring/60',
695
+ lit && 'scale-110',
696
+ props.className,
697
+ )}
698
+ style={{ left: anchor.left, top: anchor.top }}
699
+ >
700
+ {children}
701
+ </button>
702
+ );
703
+ }
704
+
705
+ function CommentPin({
706
+ comment,
707
+ frame,
708
+ region,
709
+ anchor,
710
+ bounds,
711
+ open,
712
+ hovered,
713
+ onToggle,
714
+ onHover,
715
+ onSave,
716
+ onRemove,
717
+ }: {
718
+ comment: SlideComment;
719
+ frame: ScreenRect;
720
+ region: boolean;
721
+ anchor: ScreenRect;
722
+ bounds: Size;
723
+ open: boolean;
724
+ hovered: boolean;
725
+ onToggle: () => void;
726
+ onHover: (hovering: boolean) => void;
727
+ onSave: (text: string) => Promise<void>;
728
+ onRemove: () => void;
729
+ }) {
730
+ const t = useLocale();
731
+ const lit = open || hovered;
732
+ const [cardRef, cardSize] = useMeasuredSize(
733
+ { width: 288, height: 120 },
734
+ open ? 'editor' : hovered ? 'preview' : null,
735
+ );
736
+ const card = placeBeside(anchor, cardSize, bounds);
737
+ const meta = `${format(t.inspector.commentLineLabel, { n: comment.line })}${
738
+ region ? ` · ${t.inspector.commentRegionLabel}` : ''
739
+ }`;
740
+ return (
741
+ <>
742
+ {(lit || region) && (
743
+ <div
744
+ className={cn(
745
+ 'absolute border border-brand',
746
+ region && !lit && 'border-dashed border-brand/50',
747
+ )}
748
+ style={frame}
749
+ />
750
+ )}
751
+ <Pin
752
+ anchor={anchor}
753
+ lit={lit}
754
+ aria-label={comment.note}
755
+ aria-expanded={open}
756
+ onClick={onToggle}
757
+ onPointerEnter={() => onHover(true)}
758
+ onPointerLeave={() => onHover(false)}
759
+ onFocus={() => onHover(true)}
760
+ onBlur={() => onHover(false)}
761
+ >
762
+ <MessageSquare className="size-3" />
763
+ </Pin>
764
+ {open ? (
765
+ <CommentEditor
766
+ key={comment.id}
767
+ cardRef={cardRef}
768
+ style={card}
769
+ meta={meta}
770
+ note={comment.note}
771
+ onSave={onSave}
772
+ onRemove={onRemove}
773
+ />
774
+ ) : (
775
+ hovered && (
776
+ <div
777
+ ref={cardRef}
778
+ data-comment-preview
779
+ className="island-arrive pointer-events-none absolute w-72 origin-top-left rounded-[10px] border border-border/80 bg-card text-[12.5px] shadow-overlay"
780
+ style={card}
781
+ >
782
+ <div className="truncate px-3 pt-2 font-mono text-[10px] text-muted-foreground">
783
+ {meta}
784
+ </div>
785
+ <p className="px-3 pt-1 pb-3 leading-relaxed break-words whitespace-pre-wrap">
786
+ {comment.note}
787
+ </p>
788
+ </div>
789
+ )
790
+ )}
791
+ </>
792
+ );
793
+ }
794
+
795
+ function CommentEditor({
796
+ cardRef,
797
+ style,
798
+ meta,
799
+ note,
800
+ onSave,
801
+ onRemove,
802
+ }: {
803
+ cardRef: RefObject<HTMLDivElement | null>;
804
+ style: { left: number; top: number };
805
+ meta: string;
806
+ note: string;
807
+ onSave: (text: string) => Promise<void>;
808
+ onRemove: () => void;
809
+ }) {
810
+ const t = useLocale();
811
+ const [text, setText] = useState(note);
812
+ const [saving, setSaving] = useState(false);
813
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
814
+ const dirty = text.trim() !== note && text.trim() !== '';
815
+
816
+ useEffect(() => {
817
+ const node = textareaRef.current;
818
+ if (!node) return;
819
+ node.focus({ preventScroll: true });
820
+ node.setSelectionRange(node.value.length, node.value.length);
821
+ }, []);
822
+
823
+ const save = async () => {
824
+ if (!dirty || saving) return;
825
+ setSaving(true);
826
+ try {
827
+ await onSave(text.trim());
828
+ } finally {
829
+ setSaving(false);
830
+ }
831
+ };
832
+
833
+ return (
834
+ <CommentCard ref={cardRef} style={style} data-comment-editor>
835
+ <div className="flex h-8 items-center justify-between gap-2 pr-1.5 pl-3">
836
+ <span className="truncate font-mono text-[10px] text-muted-foreground">{meta}</span>
837
+ <button
838
+ type="button"
839
+ onClick={onRemove}
840
+ disabled={saving}
841
+ className="flex size-6 shrink-0 items-center justify-center rounded-[5px] text-muted-foreground transition-[color,background-color,scale] duration-150 hover:bg-muted hover:text-destructive active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
842
+ aria-label={t.inspector.commentDeleteAria}
843
+ title={t.inspector.commentDeleteAria}
844
+ >
845
+ <Trash2 className="size-3.5" />
846
+ </button>
847
+ </div>
848
+ <CommentTextarea
849
+ ref={textareaRef}
850
+ value={text}
851
+ disabled={saving}
852
+ onChange={(event) => setText(event.target.value)}
853
+ onSubmit={save}
854
+ className="pt-0"
855
+ />
856
+ <CommentCardFooter>
857
+ <SendButton icon={Check} label={t.common.save} onClick={save} disabled={saving || !dirty} />
858
+ </CommentCardFooter>
859
+ </CommentCard>
860
+ );
861
+ }
862
+
863
+ function Composer({
864
+ frame,
865
+ region,
866
+ anchor,
867
+ bounds,
868
+ onSubmit,
869
+ }: {
870
+ frame: ScreenRect;
871
+ region: boolean;
872
+ anchor: ScreenRect;
873
+ bounds: Size;
874
+ onSubmit: (text: string) => Promise<void>;
875
+ }) {
876
+ const t = useLocale();
877
+ const [text, setText] = useState('');
878
+ const [submitting, setSubmitting] = useState(false);
879
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
880
+ const [cardRef, cardSize] = useMeasuredSize({ width: 288, height: 112 });
881
+ const card = placeBeside(anchor, cardSize, bounds);
882
+
883
+ useEffect(() => {
884
+ textareaRef.current?.focus({ preventScroll: true });
885
+ }, []);
886
+
887
+ const post = async () => {
888
+ const trimmed = text.trim();
889
+ if (!trimmed || submitting) return;
890
+ setSubmitting(true);
891
+ try {
892
+ await onSubmit(trimmed);
893
+ } finally {
894
+ setSubmitting(false);
895
+ }
896
+ };
897
+
898
+ return (
899
+ <>
900
+ <div className={cn('absolute border border-brand', region && 'bg-brand/5')} style={frame} />
901
+ <Pin anchor={anchor} lit aria-hidden tabIndex={-1}>
902
+ <MessageSquare className="size-3" />
903
+ </Pin>
904
+ <CommentCard ref={cardRef} style={card} data-comment-composer>
905
+ <CommentTextarea
906
+ ref={textareaRef}
907
+ value={text}
908
+ disabled={submitting}
909
+ onChange={(event) => setText(event.target.value)}
910
+ onSubmit={post}
911
+ placeholder={t.inspector.commentPlaceholder}
912
+ />
913
+ <CommentCardFooter>
914
+ <SendButton
915
+ icon={ArrowUp}
916
+ label={t.inspector.commentPost}
917
+ onClick={post}
918
+ disabled={submitting || !text.trim()}
919
+ />
920
+ </CommentCardFooter>
921
+ </CommentCard>
922
+ </>
923
+ );
924
+ }
925
+
926
+ // One quiet surface: the text sits directly on the card instead of inside
927
+ // a second framed box, and the only strong element is the send button.
928
+ function CommentCard({
929
+ className,
930
+ ...props
931
+ }: ComponentProps<'div'> & { ref: RefObject<HTMLDivElement | null> }) {
932
+ return (
933
+ <div
934
+ {...props}
935
+ className={cn(
936
+ 'island-arrive pointer-events-auto absolute w-72 origin-top-left rounded-[10px] border border-border/80 bg-card shadow-overlay',
937
+ className,
938
+ )}
939
+ />
940
+ );
941
+ }
942
+
943
+ function CommentTextarea({
944
+ onSubmit,
945
+ className,
946
+ ...props
947
+ }: Omit<ComponentProps<'textarea'>, 'onSubmit'> & {
948
+ ref: RefObject<HTMLTextAreaElement | null>;
949
+ onSubmit: () => void;
950
+ }) {
951
+ return (
952
+ <textarea
953
+ rows={2}
954
+ {...props}
955
+ onKeyDown={(event) => {
956
+ if (event.nativeEvent.isComposing) return;
957
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
958
+ event.preventDefault();
959
+ onSubmit();
960
+ }
961
+ }}
962
+ className={cn(
963
+ 'field-sizing-content block max-h-40 min-h-[3.25rem] w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-[12.5px] leading-[1.5] text-foreground outline-none',
964
+ 'placeholder:text-muted-foreground/60 disabled:opacity-60',
965
+ className,
966
+ )}
967
+ />
968
+ );
969
+ }
970
+
971
+ function CommentCardFooter({ children }: { children: ReactNode }) {
972
+ return (
973
+ <div className="flex items-center justify-between gap-2 px-2 pt-0.5 pb-2 pl-3">
974
+ <kbd className="rounded-[4px] border border-border/70 bg-muted/60 px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.04em] text-muted-foreground">
975
+ {SUBMIT_SHORTCUT}
976
+ </kbd>
977
+ {children}
978
+ </div>
979
+ );
980
+ }
981
+
982
+ function SendButton({
983
+ icon: Icon,
984
+ label,
985
+ onClick,
986
+ disabled,
987
+ }: {
988
+ icon: typeof ArrowUp;
989
+ label: string;
990
+ onClick: () => void;
991
+ disabled: boolean;
992
+ }) {
993
+ return (
994
+ <button
995
+ type="button"
996
+ onClick={onClick}
997
+ disabled={disabled}
998
+ aria-label={label}
999
+ title={label}
1000
+ className={cn(
1001
+ 'flex size-7 shrink-0 items-center justify-center rounded-full transition-[background-color,color,scale,opacity] duration-150 ease-swift',
1002
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
1003
+ disabled
1004
+ ? 'bg-muted text-muted-foreground/50'
1005
+ : 'bg-brand text-brand-foreground shadow-[inset_0_1px_0_oklch(1_0_0/0.18),0_1px_0_oklch(0_0_0/0.16)] hover:brightness-105 active:scale-90',
1006
+ )}
1007
+ >
1008
+ <Icon className="size-3.5" strokeWidth={2.5} />
1009
+ </button>
1010
+ );
1011
+ }